diff --git a/scripts/addons_core/node_wrangler/README.md b/scripts/addons_core/node_wrangler/README.md new file mode 100644 index 00000000000..3e083215944 --- /dev/null +++ b/scripts/addons_core/node_wrangler/README.md @@ -0,0 +1,5 @@ +# Running Tests + +``` +./utils/paths_test.py +``` diff --git a/scripts/addons_core/node_wrangler/__init__.py b/scripts/addons_core/node_wrangler/__init__.py new file mode 100644 index 00000000000..2d7c190e778 --- /dev/null +++ b/scripts/addons_core/node_wrangler/__init__.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: 2013-2023 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +bl_info = { + "name": "Node Wrangler", + "author": "Bartek Skorupa, Greg Zaal, Sebastian Koenig, Christian Brinkmann, Florian Meyer", + "version": (3, 54), + "blender": (4, 2, 0), + "location": "Node Editor Toolbar or Shift-W", + "description": "Various tools to enhance and speed up node-based workflow", + "warning": "", + "doc_url": "{BLENDER_MANUAL_URL}/addons/node/node_wrangler.html", + "category": "Node", +} + +import bpy +from bpy.props import ( + BoolProperty, + IntProperty, + StringProperty, +) + +from . import operators +from . import preferences +from . import interface + + +def register(): + # props + bpy.types.Scene.NWBusyDrawing = StringProperty( + name="Busy Drawing!", + default="", + description="An internal property used to store only the first mouse position") + bpy.types.Scene.NWLazySource = StringProperty( + name="Lazy Source!", + default="x", + description="An internal property used to store the first node in a Lazy Connect operation") + bpy.types.Scene.NWLazyTarget = StringProperty( + name="Lazy Target!", + default="x", + description="An internal property used to store the last node in a Lazy Connect operation") + bpy.types.Scene.NWSourceSocket = IntProperty( + name="Source Socket!", + default=0, + description="An internal property used to store the source socket in a Lazy Connect operation") + + operators.register() + interface.register() + preferences.register() + + +def unregister(): + preferences.unregister() + interface.unregister() + operators.unregister() + + # props + del bpy.types.Scene.NWBusyDrawing + del bpy.types.Scene.NWLazySource + del bpy.types.Scene.NWLazyTarget + del bpy.types.Scene.NWSourceSocket diff --git a/scripts/addons_core/node_wrangler/interface.py b/scripts/addons_core/node_wrangler/interface.py new file mode 100644 index 00000000000..d0131d289cf --- /dev/null +++ b/scripts/addons_core/node_wrangler/interface.py @@ -0,0 +1,503 @@ +# SPDX-FileCopyrightText: 2023 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +import bpy +from bpy.types import Panel, Menu +from bpy.props import StringProperty +from nodeitems_utils import node_categories_iter, NodeItemCustom + +from . import operators + +from .utils.constants import blend_types, geo_combine_operations, operations +from .utils.nodes import get_nodes_links, NWBaseMenu + + +def drawlayout(context, layout, mode='non-panel'): + tree_type = context.space_data.tree_type + + col = layout.column(align=True) + col.menu(NWMergeNodesMenu.bl_idname) + col.separator() + + if tree_type == 'ShaderNodeTree': + col = layout.column(align=True) + col.operator(operators.NWAddTextureSetup.bl_idname, text="Add Texture Setup", icon='NODE_SEL') + col.operator(operators.NWAddPrincipledSetup.bl_idname, text="Add Principled Setup", icon='NODE_SEL') + col.separator() + + col = layout.column(align=True) + col.operator(operators.NWDetachOutputs.bl_idname, icon='UNLINKED') + col.operator(operators.NWSwapLinks.bl_idname) + col.menu(NWAddReroutesMenu.bl_idname, text="Add Reroutes", icon='LAYER_USED') + col.separator() + + col = layout.column(align=True) + col.menu(NWLinkActiveToSelectedMenu.bl_idname, text="Link Active To Selected", icon='LINKED') + if tree_type != 'GeometryNodeTree': + col.operator(operators.NWLinkToOutputNode.bl_idname, icon='DRIVER') + col.separator() + + col = layout.column(align=True) + if mode == 'panel': + row = col.row(align=True) + row.operator(operators.NWClearLabel.bl_idname).option = True + row.operator(operators.NWModifyLabels.bl_idname) + else: + col.operator(operators.NWClearLabel.bl_idname).option = True + col.operator(operators.NWModifyLabels.bl_idname) + col.menu(NWBatchChangeNodesMenu.bl_idname, text="Batch Change") + col.separator() + col.menu(NWCopyToSelectedMenu.bl_idname, text="Copy to Selected") + col.separator() + + col = layout.column(align=True) + if tree_type == 'CompositorNodeTree': + col.operator(operators.NWResetBG.bl_idname, icon='ZOOM_PREVIOUS') + if tree_type != 'GeometryNodeTree': + col.operator(operators.NWReloadImages.bl_idname, icon='FILE_REFRESH') + col.separator() + + col = layout.column(align=True) + col.operator(operators.NWFrameSelected.bl_idname, icon='STICKY_UVS_LOC') + col.separator() + + col = layout.column(align=True) + col.operator(operators.NWAlignNodes.bl_idname, icon='CENTER_ONLY') + col.separator() + + col = layout.column(align=True) + col.operator(operators.NWDeleteUnused.bl_idname, icon='CANCEL') + col.separator() + + +class NodeWranglerPanel(Panel, NWBaseMenu): + bl_idname = "NODE_PT_nw_node_wrangler" + bl_space_type = 'NODE_EDITOR' + bl_label = "Node Wrangler" + bl_region_type = "UI" + bl_category = "Node Wrangler" + + prepend: StringProperty( + name='prepend', + ) + append: StringProperty() + remove: StringProperty() + + def draw(self, context): + self.layout.label(text="(Quick access: Shift+W)") + drawlayout(context, self.layout, mode='panel') + + +# +# M E N U S +# +class NodeWranglerMenu(Menu, NWBaseMenu): + bl_idname = "NODE_MT_nw_node_wrangler_menu" + bl_label = "Node Wrangler" + + def draw(self, context): + self.layout.operator_context = 'INVOKE_DEFAULT' + drawlayout(context, self.layout) + + +class NWMergeNodesMenu(Menu, NWBaseMenu): + bl_idname = "NODE_MT_nw_merge_nodes_menu" + bl_label = "Merge Selected Nodes" + + def draw(self, context): + type = context.space_data.tree_type + layout = self.layout + if type == 'ShaderNodeTree': + layout.menu(NWMergeShadersMenu.bl_idname, text="Use Shaders") + if type == 'GeometryNodeTree': + layout.menu(NWMergeGeometryMenu.bl_idname, text="Use Geometry Nodes") + layout.menu(NWMergeMathMenu.bl_idname, text="Use Math Nodes") + else: + layout.menu(NWMergeMixMenu.bl_idname, text="Use Mix Nodes") + layout.menu(NWMergeMathMenu.bl_idname, text="Use Math Nodes") + props = layout.operator(operators.NWMergeNodes.bl_idname, text="Use Z-Combine Nodes") + props.mode = 'MIX' + props.merge_type = 'ZCOMBINE' + props = layout.operator(operators.NWMergeNodes.bl_idname, text="Use Alpha Over Nodes") + props.mode = 'MIX' + props.merge_type = 'ALPHAOVER' + + +class NWMergeGeometryMenu(Menu, NWBaseMenu): + bl_idname = "NODE_MT_nw_merge_geometry_menu" + bl_label = "Merge Selected Nodes using Geometry Nodes" + + def draw(self, context): + layout = self.layout + # The boolean node + Join Geometry node + for type, name, description in geo_combine_operations: + props = layout.operator(operators.NWMergeNodes.bl_idname, text=name) + props.mode = type + props.merge_type = 'GEOMETRY' + + +class NWMergeShadersMenu(Menu, NWBaseMenu): + bl_idname = "NODE_MT_nw_merge_shaders_menu" + bl_label = "Merge Selected Nodes using Shaders" + + def draw(self, context): + layout = self.layout + for type in ('MIX', 'ADD'): + name = f'{type.capitalize()} Shader' + props = layout.operator(operators.NWMergeNodes.bl_idname, text=name) + props.mode = type + props.merge_type = 'SHADER' + + +class NWMergeMixMenu(Menu, NWBaseMenu): + bl_idname = "NODE_MT_nw_merge_mix_menu" + bl_label = "Merge Selected Nodes using Mix" + + def draw(self, context): + layout = self.layout + for type, name, description in blend_types: + props = layout.operator(operators.NWMergeNodes.bl_idname, text=name) + props.mode = type + props.merge_type = 'MIX' + + +class NWConnectionListOutputs(Menu, NWBaseMenu): + bl_idname = "NODE_MT_nw_connection_list_out" + bl_label = "From:" + + def draw(self, context): + layout = self.layout + nodes, links = get_nodes_links(context) + + n1 = nodes[context.scene.NWLazySource] + for index, output in enumerate(n1.outputs): + # Only show sockets that are exposed. + if output.enabled: + layout.operator( + operators.NWCallInputsMenu.bl_idname, + text=output.name, + icon="RADIOBUT_OFF").from_socket = index + + +class NWConnectionListInputs(Menu, NWBaseMenu): + bl_idname = "NODE_MT_nw_connection_list_in" + bl_label = "To:" + + def draw(self, context): + layout = self.layout + nodes, links = get_nodes_links(context) + + n2 = nodes[context.scene.NWLazyTarget] + + for index, input in enumerate(n2.inputs): + # Only show sockets that are exposed. + # This prevents, for example, the scale value socket + # of the vector math node being added to the list when + # the mode is not 'SCALE'. + if input.enabled: + op = layout.operator(operators.NWMakeLink.bl_idname, text=input.name, icon="FORWARD") + op.from_socket = context.scene.NWSourceSocket + op.to_socket = index + + +class NWMergeMathMenu(Menu, NWBaseMenu): + bl_idname = "NODE_MT_nw_merge_math_menu" + bl_label = "Merge Selected Nodes using Math" + + def draw(self, context): + layout = self.layout + for type, name, description in operations: + props = layout.operator(operators.NWMergeNodes.bl_idname, text=name) + props.mode = type + props.merge_type = 'MATH' + + +class NWBatchChangeNodesMenu(Menu, NWBaseMenu): + bl_idname = "NODE_MT_nw_batch_change_nodes_menu" + bl_label = "Batch Change Selected Nodes" + + def draw(self, context): + layout = self.layout + layout.menu(NWBatchChangeBlendTypeMenu.bl_idname) + layout.menu(NWBatchChangeOperationMenu.bl_idname) + + +class NWBatchChangeBlendTypeMenu(Menu, NWBaseMenu): + bl_idname = "NODE_MT_nw_batch_change_blend_type_menu" + bl_label = "Batch Change Blend Type" + + def draw(self, context): + layout = self.layout + for type, name, description in blend_types: + props = layout.operator(operators.NWBatchChangeNodes.bl_idname, text=name) + props.blend_type = type + props.operation = 'CURRENT' + + +class NWBatchChangeOperationMenu(Menu, NWBaseMenu): + bl_idname = "NODE_MT_nw_batch_change_operation_menu" + bl_label = "Batch Change Math Operation" + + def draw(self, context): + layout = self.layout + for type, name, description in operations: + props = layout.operator(operators.NWBatchChangeNodes.bl_idname, text=name) + props.blend_type = 'CURRENT' + props.operation = type + + +class NWCopyToSelectedMenu(Menu, NWBaseMenu): + bl_idname = "NODE_MT_nw_copy_node_properties_menu" + bl_label = "Copy to Selected" + + def draw(self, context): + layout = self.layout + layout.operator(operators.NWCopySettings.bl_idname, text="Settings from Active") + layout.menu(NWCopyLabelMenu.bl_idname) + + +class NWCopyLabelMenu(Menu, NWBaseMenu): + bl_idname = "NODE_MT_nw_copy_label_menu" + bl_label = "Copy Label" + + def draw(self, context): + layout = self.layout + layout.operator(operators.NWCopyLabel.bl_idname, text="from Active Node's Label").option = 'FROM_ACTIVE' + layout.operator(operators.NWCopyLabel.bl_idname, text="from Linked Node's Label").option = 'FROM_NODE' + layout.operator(operators.NWCopyLabel.bl_idname, text="from Linked Output's Name").option = 'FROM_SOCKET' + + +class NWAddReroutesMenu(Menu, NWBaseMenu): + bl_idname = "NODE_MT_nw_add_reroutes_menu" + bl_label = "Add Reroutes" + bl_description = "Add Reroute Nodes to Selected Nodes' Outputs" + + def draw(self, context): + layout = self.layout + layout.operator(operators.NWAddReroutes.bl_idname, text="to All Outputs").option = 'ALL' + layout.operator(operators.NWAddReroutes.bl_idname, text="to Loose Outputs").option = 'LOOSE' + layout.operator(operators.NWAddReroutes.bl_idname, text="to Linked Outputs").option = 'LINKED' + + +class NWLinkActiveToSelectedMenu(Menu, NWBaseMenu): + bl_idname = "NODE_MT_nw_link_active_to_selected_menu" + bl_label = "Link Active to Selected" + + def draw(self, context): + layout = self.layout + layout.menu(NWLinkStandardMenu.bl_idname) + layout.menu(NWLinkUseNodeNameMenu.bl_idname) + layout.menu(NWLinkUseOutputsNamesMenu.bl_idname) + + +class NWLinkStandardMenu(Menu, NWBaseMenu): + bl_idname = "NODE_MT_nw_link_standard_menu" + bl_label = "To All Selected" + + def draw(self, context): + layout = self.layout + props = layout.operator(operators.NWLinkActiveToSelected.bl_idname, text="Don't Replace Links") + props.replace = False + props.use_node_name = False + props.use_outputs_names = False + props = layout.operator(operators.NWLinkActiveToSelected.bl_idname, text="Replace Links") + props.replace = True + props.use_node_name = False + props.use_outputs_names = False + + +class NWLinkUseNodeNameMenu(Menu, NWBaseMenu): + bl_idname = "NODE_MT_nw_link_use_node_name_menu" + bl_label = "Use Node Name/Label" + + def draw(self, context): + layout = self.layout + props = layout.operator(operators.NWLinkActiveToSelected.bl_idname, text="Don't Replace Links") + props.replace = False + props.use_node_name = True + props.use_outputs_names = False + props = layout.operator(operators.NWLinkActiveToSelected.bl_idname, text="Replace Links") + props.replace = True + props.use_node_name = True + props.use_outputs_names = False + + +class NWLinkUseOutputsNamesMenu(Menu, NWBaseMenu): + bl_idname = "NODE_MT_nw_link_use_outputs_names_menu" + bl_label = "Use Outputs Names" + + def draw(self, context): + layout = self.layout + props = layout.operator(operators.NWLinkActiveToSelected.bl_idname, text="Don't Replace Links") + props.replace = False + props.use_node_name = False + props.use_outputs_names = True + props = layout.operator(operators.NWLinkActiveToSelected.bl_idname, text="Replace Links") + props.replace = True + props.use_node_name = False + props.use_outputs_names = True + + +class NWAttributeMenu(bpy.types.Menu): + bl_idname = "NODE_MT_nw_node_attribute_menu" + bl_label = "Attributes" + + @classmethod + def poll(cls, context): + space = context.space_data + return (space.type == 'NODE_EDITOR' + and space.node_tree is not None + and space.node_tree.library is None + and space.tree_type == 'ShaderNodeTree') + + def draw(self, context): + l = self.layout + nodes, links = get_nodes_links(context) + mat = context.object.active_material + + objs = [] + for obj in bpy.data.objects: + for slot in obj.material_slots: + if slot.material == mat: + objs.append(obj) + attrs = [] + for obj in objs: + if obj.data.attributes: + for attr in obj.data.attributes: + if not attr.is_internal: + attrs.append(attr.name) + attrs = list(set(attrs)) # get a unique list + + if attrs: + for attr in attrs: + l.operator(operators.NWAddAttrNode.bl_idname, text=attr).attr_name = attr + else: + l.label(text="No attributes on objects with this material") + + +class NWSwitchNodeTypeMenu(Menu, NWBaseMenu): + bl_idname = "NODE_MT_nw_switch_node_type_menu" + bl_label = "Switch Type to..." + + def draw(self, context): + layout = self.layout + layout.label(text="This operator is removed due to the changes of node menus.", icon='ERROR') + layout.label(text="A native implementation of the function is expected in the future.") + +# +# APPENDAGES TO EXISTING UI +# + + +def select_parent_children_buttons(self, context): + layout = self.layout + layout.operator(operators.NWSelectParentChildren.bl_idname, + text="Select frame's members (children)").option = 'CHILD' + layout.operator(operators.NWSelectParentChildren.bl_idname, text="Select parent frame").option = 'PARENT' + + +def attr_nodes_menu_func(self, context): + col = self.layout.column(align=True) + col.menu("NODE_MT_nw_node_attribute_menu") + col.separator() + + +def multipleimages_menu_func(self, context): + col = self.layout.column(align=True) + col.operator(operators.NWAddMultipleImages.bl_idname, text="Multiple Images") + col.operator(operators.NWAddSequence.bl_idname, text="Image Sequence") + col.separator() + + +def bgreset_menu_func(self, context): + self.layout.operator(operators.NWResetBG.bl_idname) + + +def save_viewer_menu_func(self, context): + space = context.space_data + if (space.type == 'NODE_EDITOR' + and space.node_tree is not None + and space.node_tree.library is None + and space.tree_type == 'CompositorNodeTree' + and context.scene.node_tree.nodes.active + and context.scene.node_tree.nodes.active.type == "VIEWER"): + self.layout.operator(operators.NWSaveViewer.bl_idname, icon='FILE_IMAGE') + + +def reset_nodes_button(self, context): + node_active = context.active_node + node_selected = context.selected_nodes + + # Check if active node is in the selection, ignore some node types + if (len(node_selected) != 1 + or node_active is None + or not node_active.select + or node_active.type in {"REROUTE", "GROUP"}): + return + + row = self.layout.row() + + if node_active.type == "FRAME": + row.operator(operators.NWResetNodes.bl_idname, text="Reset Nodes in Frame", icon="FILE_REFRESH") + else: + row.operator(operators.NWResetNodes.bl_idname, text="Reset Node", icon="FILE_REFRESH") + + self.layout.separator() + + +classes = ( + NodeWranglerPanel, + NodeWranglerMenu, + NWMergeNodesMenu, + NWMergeGeometryMenu, + NWMergeShadersMenu, + NWMergeMixMenu, + NWConnectionListOutputs, + NWConnectionListInputs, + NWMergeMathMenu, + NWBatchChangeNodesMenu, + NWBatchChangeBlendTypeMenu, + NWBatchChangeOperationMenu, + NWCopyToSelectedMenu, + NWCopyLabelMenu, + NWAddReroutesMenu, + NWLinkActiveToSelectedMenu, + NWLinkStandardMenu, + NWLinkUseNodeNameMenu, + NWLinkUseOutputsNamesMenu, + NWAttributeMenu, + NWSwitchNodeTypeMenu, +) + + +def register(): + from bpy.utils import register_class + for cls in classes: + register_class(cls) + + # menu items + bpy.types.NODE_MT_select.append(select_parent_children_buttons) + bpy.types.NODE_MT_category_shader_input.prepend(attr_nodes_menu_func) + bpy.types.NODE_PT_backdrop.append(bgreset_menu_func) + bpy.types.NODE_PT_active_node_generic.append(save_viewer_menu_func) + bpy.types.NODE_MT_category_shader_texture.prepend(multipleimages_menu_func) + bpy.types.NODE_MT_category_compositor_input.prepend(multipleimages_menu_func) + bpy.types.NODE_PT_active_node_generic.prepend(reset_nodes_button) + bpy.types.NODE_MT_node.prepend(reset_nodes_button) + + +def unregister(): + # menu items + bpy.types.NODE_MT_select.remove(select_parent_children_buttons) + bpy.types.NODE_MT_category_shader_input.remove(attr_nodes_menu_func) + bpy.types.NODE_PT_backdrop.remove(bgreset_menu_func) + bpy.types.NODE_PT_active_node_generic.remove(save_viewer_menu_func) + bpy.types.NODE_MT_category_shader_texture.remove(multipleimages_menu_func) + bpy.types.NODE_MT_category_compositor_input.remove(multipleimages_menu_func) + bpy.types.NODE_PT_active_node_generic.remove(reset_nodes_button) + bpy.types.NODE_MT_node.remove(reset_nodes_button) + + from bpy.utils import unregister_class + for cls in classes: + unregister_class(cls) diff --git a/scripts/addons_core/node_wrangler/operators.py b/scripts/addons_core/node_wrangler/operators.py new file mode 100644 index 00000000000..f8784e3dad5 --- /dev/null +++ b/scripts/addons_core/node_wrangler/operators.py @@ -0,0 +1,2481 @@ +# SPDX-FileCopyrightText: 2013-2023 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +import bpy + +from bpy.types import Operator +from bpy.props import ( + FloatProperty, + EnumProperty, + BoolProperty, + IntProperty, + StringProperty, + FloatVectorProperty, + CollectionProperty, +) +from bpy_extras.io_utils import ImportHelper, ExportHelper +from bpy_extras.node_utils import connect_sockets +from mathutils import Vector +from os import path +from glob import glob +from copy import copy +from itertools import chain + +from .interface import NWConnectionListInputs, NWConnectionListOutputs + +from .utils.constants import blend_types, geo_combine_operations, operations, navs, get_texture_node_types, rl_outputs +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, + get_group_output_node, get_output_location, force_update, get_internal_socket, nw_check, + nw_check_not_empty, nw_check_selected, nw_check_active, nw_check_space_type, + nw_check_node_type, nw_check_visible_outputs, nw_check_viewer_node, NWBase, + get_first_enabled_output, is_visible_socket) + +class NWLazyMix(Operator, NWBase): + """Add a Mix RGB/Shader node by interactively drawing lines between nodes""" + bl_idname = "node.nw_lazy_mix" + bl_label = "Mix Nodes" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return nw_check(cls, context) and nw_check_not_empty(cls, context) + + def modal(self, context, event): + context.area.tag_redraw() + nodes, links = get_nodes_links(context) + cont = True + + start_pos = [event.mouse_region_x, event.mouse_region_y] + + node1 = None + if not context.scene.NWBusyDrawing: + node1 = node_at_pos(nodes, context, event) + if node1: + context.scene.NWBusyDrawing = node1.name + else: + if context.scene.NWBusyDrawing != 'STOP': + node1 = nodes[context.scene.NWBusyDrawing] + + context.scene.NWLazySource = node1.name + context.scene.NWLazyTarget = node_at_pos(nodes, context, event).name + + if event.type == 'MOUSEMOVE': + self.mouse_path.append((event.mouse_region_x, event.mouse_region_y)) + + elif event.type == 'RIGHTMOUSE' and event.value == 'RELEASE': + end_pos = [event.mouse_region_x, event.mouse_region_y] + bpy.types.SpaceNodeEditor.draw_handler_remove(self._handle, 'WINDOW') + + node2 = None + node2 = node_at_pos(nodes, context, event) + if node2: + context.scene.NWBusyDrawing = node2.name + + if node1 == node2: + cont = False + + if cont: + if node1 and node2: + for node in nodes: + node.select = False + node1.select = True + node2.select = True + + bpy.ops.node.nw_merge_nodes(mode="MIX", merge_type="AUTO") + + context.scene.NWBusyDrawing = "" + return {'FINISHED'} + + elif event.type == 'ESC': + print('cancelled') + bpy.types.SpaceNodeEditor.draw_handler_remove(self._handle, 'WINDOW') + return {'CANCELLED'} + + return {'RUNNING_MODAL'} + + def invoke(self, context, event): + if context.area.type == 'NODE_EDITOR': + # the arguments we pass the the callback + args = (self, context, 'MIX') + # Add the region OpenGL drawing callback + # draw in view space with 'POST_VIEW' and 'PRE_VIEW' + self._handle = bpy.types.SpaceNodeEditor.draw_handler_add( + draw_callback_nodeoutline, args, 'WINDOW', 'POST_PIXEL') + + self.mouse_path = [] + + context.window_manager.modal_handler_add(self) + return {'RUNNING_MODAL'} + else: + self.report({'WARNING'}, "View3D not found, cannot run operator") + return {'CANCELLED'} + + +class NWLazyConnect(Operator, NWBase): + """Connect two nodes without clicking a specific socket (automatically determined""" + bl_idname = "node.nw_lazy_connect" + bl_label = "Lazy Connect" + bl_options = {'REGISTER', 'UNDO'} + with_menu: BoolProperty() + + @classmethod + def poll(cls, context): + return nw_check(cls, context) and nw_check_not_empty(cls, context) + + def modal(self, context, event): + context.area.tag_redraw() + nodes, links = get_nodes_links(context) + cont = True + + start_pos = [event.mouse_region_x, event.mouse_region_y] + + node1 = None + if not context.scene.NWBusyDrawing: + node1 = node_at_pos(nodes, context, event) + if node1: + context.scene.NWBusyDrawing = node1.name + else: + if context.scene.NWBusyDrawing != 'STOP': + node1 = nodes[context.scene.NWBusyDrawing] + + context.scene.NWLazySource = node1.name + context.scene.NWLazyTarget = node_at_pos(nodes, context, event).name + + if event.type == 'MOUSEMOVE': + self.mouse_path.append((event.mouse_region_x, event.mouse_region_y)) + + elif event.type == 'RIGHTMOUSE' and event.value == 'RELEASE': + end_pos = [event.mouse_region_x, event.mouse_region_y] + bpy.types.SpaceNodeEditor.draw_handler_remove(self._handle, 'WINDOW') + + node2 = None + node2 = node_at_pos(nodes, context, event) + if node2: + context.scene.NWBusyDrawing = node2.name + + if node1 == node2: + cont = False + + link_success = False + if cont: + if node1 and node2: + original_sel = [] + original_unsel = [] + for node in nodes: + if node.select: + node.select = False + original_sel.append(node) + else: + original_unsel.append(node) + node1.select = True + node2.select = True + + # link_success = autolink(node1, node2, links) + if self.with_menu: + if len(node1.outputs) > 1 and node2.inputs: + bpy.ops.wm.call_menu("INVOKE_DEFAULT", name=NWConnectionListOutputs.bl_idname) + elif len(node1.outputs) == 1: + bpy.ops.node.nw_call_inputs_menu(from_socket=0) + else: + link_success = autolink(node1, node2, links) + + for node in original_sel: + node.select = True + for node in original_unsel: + node.select = False + + if link_success: + force_update(context) + context.scene.NWBusyDrawing = "" + return {'FINISHED'} + + elif event.type == 'ESC': + bpy.types.SpaceNodeEditor.draw_handler_remove(self._handle, 'WINDOW') + return {'CANCELLED'} + + return {'RUNNING_MODAL'} + + def invoke(self, context, event): + if context.area.type == 'NODE_EDITOR': + nodes, links = get_nodes_links(context) + node = node_at_pos(nodes, context, event) + if node: + context.scene.NWBusyDrawing = node.name + + # the arguments we pass the the callback + mode = "LINK" + if self.with_menu: + mode = "LINKMENU" + args = (self, context, mode) + # Add the region OpenGL drawing callback + # draw in view space with 'POST_VIEW' and 'PRE_VIEW' + self._handle = bpy.types.SpaceNodeEditor.draw_handler_add( + draw_callback_nodeoutline, args, 'WINDOW', 'POST_PIXEL') + + self.mouse_path = [] + + context.window_manager.modal_handler_add(self) + return {'RUNNING_MODAL'} + else: + self.report({'WARNING'}, "View3D not found, cannot run operator") + return {'CANCELLED'} + + +class NWDeleteUnused(Operator, NWBase): + """Delete all nodes whose output is not used""" + bl_idname = 'node.nw_del_unused' + bl_label = 'Delete Unused Nodes' + bl_options = {'REGISTER', 'UNDO'} + + delete_muted: BoolProperty( + name="Delete Muted", + description="Delete (but reconnect, like Ctrl-X) all muted nodes", + default=True) + delete_frames: BoolProperty( + name="Delete Empty Frames", + description="Delete all frames that have no nodes inside them", + default=True) + + def is_unused_node(self, node): + end_types = ['OUTPUT_MATERIAL', 'OUTPUT', 'VIEWER', 'COMPOSITE', + 'SPLITVIEWER', 'OUTPUT_FILE', 'LEVELS', 'OUTPUT_LIGHT', + 'OUTPUT_WORLD', 'GROUP_INPUT', 'GROUP_OUTPUT', 'FRAME'] + if node.type in end_types: + return False + + for output in node.outputs: + if output.links: + return False + return True + + @classmethod + def poll(cls, context): + """Disabled for custom nodes as we do not know which nodes are supported.""" + return (nw_check(cls, context) + and nw_check_not_empty(cls, context) + and nw_check_space_type(cls, context, {'ShaderNodeTree', 'CompositorNodeTree', + 'TextureNodeTree', 'GeometryNodeTree'})) + + def execute(self, context): + nodes, links = get_nodes_links(context) + + # Store selection + selection = [] + for node in nodes: + if node.select: + selection.append(node.name) + + for node in nodes: + node.select = False + + deleted_nodes = [] + temp_deleted_nodes = [] + del_unused_iterations = len(nodes) + for it in range(0, del_unused_iterations): + temp_deleted_nodes = list(deleted_nodes) # keep record of last iteration + for node in nodes: + if self.is_unused_node(node): + node.select = True + deleted_nodes.append(node.name) + bpy.ops.node.delete() + + if temp_deleted_nodes == deleted_nodes: # stop iterations when there are no more nodes to be deleted + break + + if self.delete_frames: + repeat = True + while repeat: + frames_in_use = [] + frames = [] + repeat = False + for node in nodes: + if node.parent: + frames_in_use.append(node.parent) + for node in nodes: + if node.type == 'FRAME' and node not in frames_in_use: + frames.append(node) + if node.parent: + repeat = True # repeat for nested frames + for node in frames: + if node not in frames_in_use: + node.select = True + deleted_nodes.append(node.name) + bpy.ops.node.delete() + + if self.delete_muted: + for node in nodes: + if node.mute: + node.select = True + deleted_nodes.append(node.name) + bpy.ops.node.delete_reconnect() + + # get unique list of deleted nodes (iterations would count the same node more than once) + deleted_nodes = list(set(deleted_nodes)) + for n in deleted_nodes: + self.report({'INFO'}, "Node " + n + " deleted") + num_deleted = len(deleted_nodes) + n = ' node' + if num_deleted > 1: + n += 's' + if num_deleted: + self.report({'INFO'}, "Deleted " + str(num_deleted) + n) + else: + self.report({'INFO'}, "Nothing deleted") + + # Restore selection + nodes, links = get_nodes_links(context) + for node in nodes: + if node.name in selection: + node.select = True + return {'FINISHED'} + + def invoke(self, context, event): + return context.window_manager.invoke_confirm(self, event) + + +class NWSwapLinks(Operator, NWBase): + """Swap the output connections of the two selected nodes, or two similar inputs of a single node""" + bl_idname = 'node.nw_swap_links' + bl_label = 'Swap Links' + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return nw_check(cls, context) and nw_check_selected(cls, context, max=2) + + def execute(self, context): + nodes, links = get_nodes_links(context) + selected_nodes = context.selected_nodes + n1 = selected_nodes[0] + + # Swap outputs + if len(selected_nodes) == 2: + n2 = selected_nodes[1] + if n1.outputs and n2.outputs: + n1_outputs = [] + n2_outputs = [] + + out_index = 0 + for output in n1.outputs: + if output.links: + for link in output.links: + n1_outputs.append([out_index, link.to_socket]) + links.remove(link) + out_index += 1 + + out_index = 0 + for output in n2.outputs: + if output.links: + for link in output.links: + n2_outputs.append([out_index, link.to_socket]) + links.remove(link) + out_index += 1 + + for connection in n1_outputs: + try: + connect_sockets(n2.outputs[connection[0]], connection[1]) + except: + self.report({'WARNING'}, + "Some connections have been lost due to differing numbers of output sockets") + for connection in n2_outputs: + try: + connect_sockets(n1.outputs[connection[0]], connection[1]) + except: + self.report({'WARNING'}, + "Some connections have been lost due to differing numbers of output sockets") + else: + if n1.outputs or n2.outputs: + self.report({'WARNING'}, "One of the nodes has no outputs!") + else: + self.report({'WARNING'}, "Neither of the nodes have outputs!") + + # Swap Inputs + elif len(selected_nodes) == 1: + if n1.inputs and n1.inputs[0].is_multi_input: + self.report({'WARNING'}, "Can't swap inputs of a multi input socket!") + return {'FINISHED'} + if n1.inputs: + types = [] + i = 0 + for i1 in n1.inputs: + if i1.is_linked and not i1.is_multi_input: + similar_types = 0 + for i2 in n1.inputs: + if i1.type == i2.type and i2.is_linked and not i2.is_multi_input: + similar_types += 1 + types.append([i1, similar_types, i]) + i += 1 + types.sort(key=lambda k: k[1], reverse=True) + + if types: + t = types[0] + if t[1] == 2: + for i2 in n1.inputs: + if t[0].type == i2.type == t[0].type and t[0] != i2 and i2.is_linked: + pair = [t[0], i2] + i1f = pair[0].links[0].from_socket + i1t = pair[0].links[0].to_socket + i2f = pair[1].links[0].from_socket + i2t = pair[1].links[0].to_socket + connect_sockets(i1f, i2t) + connect_sockets(i2f, i1t) + if t[1] == 1: + if len(types) == 1: + fs = t[0].links[0].from_socket + i = t[2] + links.remove(t[0].links[0]) + if i + 1 == len(n1.inputs): + i = -1 + i += 1 + while n1.inputs[i].is_linked: + i += 1 + connect_sockets(fs, n1.inputs[i]) + elif len(types) == 2: + i1f = types[0][0].links[0].from_socket + i1t = types[0][0].links[0].to_socket + i2f = types[1][0].links[0].from_socket + i2t = types[1][0].links[0].to_socket + connect_sockets(i1f, i2t) + connect_sockets(i2f, i1t) + + else: + self.report({'WARNING'}, "This node has no input connections to swap!") + else: + self.report({'WARNING'}, "This node has no inputs to swap!") + + force_update(context) + return {'FINISHED'} + + +class NWResetBG(Operator, NWBase): + """Reset the zoom and position of the background image""" + bl_idname = 'node.nw_bg_reset' + bl_label = 'Reset Backdrop' + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return nw_check(cls, context) and nw_check_space_type(cls, context, {'CompositorNodeTree'}) + + def execute(self, context): + context.space_data.backdrop_zoom = 1 + context.space_data.backdrop_offset[0] = 0 + context.space_data.backdrop_offset[1] = 0 + return {'FINISHED'} + + +class NWAddAttrNode(Operator, NWBase): + """Add an Attribute node with this name""" + bl_idname = 'node.nw_add_attr_node' + bl_label = 'Add UV map' + bl_options = {'REGISTER', 'UNDO'} + + attr_name: StringProperty() + + @classmethod + def poll(cls, context): + return nw_check(cls, context) and nw_check_space_type(cls, context, {'ShaderNodeTree'}) + + def execute(self, context): + bpy.ops.node.add_node('INVOKE_DEFAULT', use_transform=True, type="ShaderNodeAttribute") + nodes, links = get_nodes_links(context) + nodes.active.attribute_name = self.attr_name + return {'FINISHED'} + + +class NWFrameSelected(Operator, NWBase): + bl_idname = "node.nw_frame_selected" + bl_label = "Frame Selected" + bl_description = "Add a frame node and parent the selected nodes to it" + bl_options = {'REGISTER', 'UNDO'} + + label_prop: StringProperty( + name='Label', + description='The visual name of the frame node', + default=' ' + ) + use_custom_color_prop: BoolProperty( + name="Custom Color", + description="Use custom color for the frame node", + default=False + ) + color_prop: FloatVectorProperty( + name="Color", + description="The color of the frame node", + default=(0.604, 0.604, 0.604), + min=0, max=1, step=1, precision=3, + subtype='COLOR_GAMMA', size=3 + ) + + def draw(self, context): + layout = self.layout + layout.prop(self, 'label_prop') + layout.prop(self, 'use_custom_color_prop') + col = layout.column() + col.active = self.use_custom_color_prop + col.prop(self, 'color_prop', text="") + + def execute(self, context): + nodes, links = get_nodes_links(context) + selected = [] + for node in nodes: + if node.select: + selected.append(node) + + bpy.ops.node.add_node(type='NodeFrame') + frm = nodes.active + frm.label = self.label_prop + frm.use_custom_color = self.use_custom_color_prop + frm.color = self.color_prop + + for node in selected: + node.parent = frm + + return {'FINISHED'} + + +class NWReloadImages(Operator): + bl_idname = "node.nw_reload_images" + bl_label = "Reload Images" + bl_description = "Update all the image nodes to match their files on disk" + + @classmethod + def poll(cls, context): + """Disabled for custom nodes.""" + return (nw_check(cls, context) + and nw_check_space_type(cls, context, {'ShaderNodeTree', 'CompositorNodeTree', + 'TextureNodeTree', 'GeometryNodeTree'})) + + def execute(self, context): + nodes, links = get_nodes_links(context) + image_types = ["IMAGE", "TEX_IMAGE", "TEX_ENVIRONMENT", "TEXTURE"] + num_reloaded = 0 + for node in nodes: + if node.type in image_types: + if node.type == "TEXTURE": + if node.texture: # node has texture assigned + if node.texture.type in ['IMAGE', 'ENVIRONMENT_MAP']: + if node.texture.image: # texture has image assigned + node.texture.image.reload() + num_reloaded += 1 + else: + if node.image: + node.image.reload() + num_reloaded += 1 + + if num_reloaded: + self.report({'INFO'}, "Reloaded images") + print("Reloaded " + str(num_reloaded) + " images") + force_update(context) + return {'FINISHED'} + else: + self.report({'WARNING'}, "No images found to reload in this node tree") + return {'CANCELLED'} + + +class NWMergeNodes(Operator, NWBase): + bl_idname = "node.nw_merge_nodes" + bl_label = "Merge Nodes" + bl_description = "Merge Selected Nodes" + bl_options = {'REGISTER', 'UNDO'} + + mode: EnumProperty( + name="mode", + description="All possible blend types, boolean operations and math operations", + items=blend_types + [op for op in geo_combine_operations if op not in blend_types] + [op for op in operations if op not in blend_types], + ) + merge_type: EnumProperty( + name="merge type", + description="Type of Merge to be used", + items=( + ('AUTO', 'Auto', 'Automatic Output Type Detection'), + ('SHADER', 'Shader', 'Merge using ADD or MIX Shader'), + ('GEOMETRY', 'Geometry', 'Merge using Mesh Boolean or Join Geometry Node'), + ('MIX', 'Mix Node', 'Merge using Mix Nodes'), + ('MATH', 'Math Node', 'Merge using Math Nodes'), + ('ZCOMBINE', 'Z-Combine Node', 'Merge using Z-Combine Nodes'), + ('ALPHAOVER', 'Alpha Over Node', 'Merge using Alpha Over Nodes'), + ), + ) + + # Check if the link connects to a node that is in selected_nodes + # If not, then check recursively for each link in the nodes outputs. + # If yes, return True. If the recursion stops without finding a node + # in selected_nodes, it returns False. The depth is used to prevent + # getting stuck in a loop because of an already present cycle. + @staticmethod + def link_creates_cycle(link, selected_nodes, depth=0) -> bool: + if depth > 255: + # We're stuck in a cycle, but that cycle was already present, + # so we return False. + # NOTE: The number 255 is arbitrary, but seems to work well. + return False + node = link.to_node + if node in selected_nodes: + return True + if not node.outputs: + return False + for output in node.outputs: + if output.is_linked: + for olink in output.links: + if NWMergeNodes.link_creates_cycle(olink, selected_nodes, depth + 1): + return True + # None of the outputs found a node in selected_nodes, so there is no cycle. + return False + + # Merge the nodes in `nodes_list` with a node of type `node_name` that has a multi_input socket. + # The parameters `socket_indices` gives the indices of the node sockets in the order that they should + # be connected. The last one is assumed to be a multi input socket. + # For convenience the node is returned. + @staticmethod + def merge_with_multi_input(nodes_list, merge_position, do_hide, loc_x, links, nodes, node_name, socket_indices): + # The y-location of the last node + loc_y = nodes_list[-1][2] + if merge_position == 'CENTER': + # Average the y-location + for i in range(len(nodes_list) - 1): + loc_y += nodes_list[i][2] + loc_y = loc_y / len(nodes_list) + new_node = nodes.new(node_name) + new_node.hide = do_hide + new_node.location.x = loc_x + new_node.location.y = loc_y + selected_nodes = [nodes[node_info[0]] for node_info in nodes_list] + prev_links = [] + outputs_for_multi_input = [] + for i, node in enumerate(selected_nodes): + node.select = False + # Search for the first node which had output links that do not create + # a cycle, which we can then reconnect afterwards. + if prev_links == [] and node.outputs[0].is_linked: + prev_links = [ + link for link in node.outputs[0].links if not NWMergeNodes.link_creates_cycle( + link, selected_nodes)] + # Get the index of the socket, the last one is a multi input, and is thus used repeatedly + # To get the placement to look right we need to reverse the order in which we connect the + # outputs to the multi input socket. + if i < len(socket_indices) - 1: + ind = socket_indices[i] + connect_sockets(node.outputs[0], new_node.inputs[ind]) + else: + outputs_for_multi_input.insert(0, node.outputs[0]) + if outputs_for_multi_input != []: + ind = socket_indices[-1] + for output in outputs_for_multi_input: + connect_sockets(output, new_node.inputs[ind]) + if prev_links != []: + for link in prev_links: + connect_sockets(new_node.outputs[0], link.to_node.inputs[0]) + return new_node + + @classmethod + def poll(cls, context): + return (nw_check(cls, context) + and nw_check_space_type(cls, context, {'ShaderNodeTree', 'CompositorNodeTree', + 'TextureNodeTree', 'GeometryNodeTree'}) + and nw_check_selected(cls, context)) + + def execute(self, context): + settings = context.preferences.addons[__package__].preferences + merge_hide = settings.merge_hide + merge_position = settings.merge_position # 'center' or 'bottom' + + do_hide = False + do_hide_shader = False + if merge_hide == 'ALWAYS': + do_hide = True + do_hide_shader = True + elif merge_hide == 'NON_SHADER': + do_hide = True + + tree_type = context.space_data.node_tree.type + if tree_type == 'GEOMETRY': + node_type = 'GeometryNode' + if tree_type == 'COMPOSITING': + node_type = 'CompositorNode' + elif tree_type == 'SHADER': + node_type = 'ShaderNode' + elif tree_type == 'TEXTURE': + node_type = 'TextureNode' + nodes, links = get_nodes_links(context) + mode = self.mode + merge_type = self.merge_type + # Prevent trying to add Z-Combine in not 'COMPOSITING' node tree. + # 'ZCOMBINE' works only if mode == 'MIX' + # Setting mode to None prevents trying to add 'ZCOMBINE' node. + if (merge_type == 'ZCOMBINE' or merge_type == 'ALPHAOVER') and tree_type != 'COMPOSITING': + merge_type = 'MIX' + mode = 'MIX' + if (merge_type != 'MATH' and merge_type != 'GEOMETRY') and tree_type == 'GEOMETRY': + merge_type = 'AUTO' + # The Mix node and math nodes used for geometry nodes are of type 'ShaderNode' + if (merge_type == 'MATH' or merge_type == 'MIX') and tree_type == 'GEOMETRY': + node_type = 'ShaderNode' + selected_mix = [] # entry = [index, loc] + selected_shader = [] # entry = [index, loc] + selected_geometry = [] # entry = [index, loc] + selected_math = [] # entry = [index, loc] + selected_vector = [] # entry = [index, loc] + selected_z = [] # entry = [index, loc] + selected_alphaover = [] # entry = [index, loc] + + for i, node in enumerate(nodes): + if node.select and node.outputs: + if merge_type == 'AUTO': + for (type, types_list, dst) in ( + ('SHADER', ('MIX', 'ADD'), selected_shader), + ('GEOMETRY', [t[0] for t in geo_combine_operations], selected_geometry), + ('RGBA', [t[0] for t in blend_types], selected_mix), + ('VALUE', [t[0] for t in operations], selected_math), + ('VECTOR', [], selected_vector), + ): + output = get_first_enabled_output(node) + output_type = output.type + valid_mode = mode in types_list + # When mode is 'MIX' we have to cheat since the mix node is not used in + # geometry nodes. + if tree_type == 'GEOMETRY': + if mode == 'MIX': + if output_type == 'VALUE' and type == 'VALUE': + valid_mode = True + elif output_type == 'VECTOR' and type == 'VECTOR': + valid_mode = True + elif type == 'GEOMETRY': + valid_mode = True + # When mode is 'MIX' use mix node for both 'RGBA' and 'VALUE' output types. + # Cheat that output type is 'RGBA', + # and that 'MIX' exists in math operations list. + # This way when selected_mix list is analyzed: + # Node data will be appended even though it doesn't meet requirements. + elif output_type != 'SHADER' and mode == 'MIX': + output_type = 'RGBA' + valid_mode = True + if output_type == type and valid_mode: + dst.append([i, node.location.x, node.location.y, node.dimensions.x, node.hide]) + else: + for (type, types_list, dst) in ( + ('SHADER', ('MIX', 'ADD'), selected_shader), + ('GEOMETRY', [t[0] for t in geo_combine_operations], selected_geometry), + ('MIX', [t[0] for t in blend_types], selected_mix), + ('MATH', [t[0] for t in operations], selected_math), + ('ZCOMBINE', ('MIX', ), selected_z), + ('ALPHAOVER', ('MIX', ), selected_alphaover), + ): + if merge_type == type and mode in types_list: + dst.append([i, node.location.x, node.location.y, node.dimensions.x, node.hide]) + # When nodes with output kinds 'RGBA' and 'VALUE' are selected at the same time + # use only 'Mix' nodes for merging. + # For that we add selected_math list to selected_mix list and clear selected_math. + if selected_mix and selected_math and merge_type == 'AUTO': + selected_mix += selected_math + selected_math = [] + + # If no nodes are selected, do nothing and pass through. + if not (selected_mix + selected_shader + selected_geometry + selected_math + + selected_vector + selected_z + selected_alphaover): + return {'PASS_THROUGH'} + + for nodes_list in [ + selected_mix, + selected_shader, + selected_geometry, + selected_math, + selected_vector, + selected_z, + selected_alphaover]: + if not nodes_list: + continue + count_before = len(nodes) + # sort list by loc_x - reversed + nodes_list.sort(key=lambda k: k[1], reverse=True) + # get maximum loc_x + loc_x = nodes_list[0][1] + nodes_list[0][3] + 70 + nodes_list.sort(key=lambda k: k[2], reverse=True) + + # Change the node type for math nodes in a geometry node tree. + if tree_type == 'GEOMETRY': + if nodes_list is selected_math or nodes_list is selected_vector or nodes_list is selected_mix: + node_type = 'ShaderNode' + if mode == 'MIX': + mode = 'ADD' + else: + node_type = 'GeometryNode' + if merge_position == 'CENTER': + # average yloc of last two nodes (lowest two) + loc_y = ((nodes_list[len(nodes_list) - 1][2]) + (nodes_list[len(nodes_list) - 2][2])) / 2 + if nodes_list[len(nodes_list) - 1][-1]: # if last node is hidden, mix should be shifted up a bit + if do_hide: + loc_y += 40 + else: + loc_y += 80 + else: + loc_y = nodes_list[len(nodes_list) - 1][2] + offset_y = 100 + if not do_hide: + offset_y = 200 + if nodes_list == selected_shader and not do_hide_shader: + offset_y = 150.0 + the_range = len(nodes_list) - 1 + if len(nodes_list) == 1: + the_range = 1 + was_multi = False + for i in range(the_range): + if nodes_list == selected_mix: + mix_name = 'Mix' + if tree_type == 'COMPOSITING': + mix_name = 'MixRGB' + add_type = node_type + mix_name + add = nodes.new(add_type) + if tree_type != 'COMPOSITING': + add.data_type = 'RGBA' + add.blend_type = mode + if mode != 'MIX': + add.inputs[0].default_value = 1.0 + add.show_preview = False + add.hide = do_hide + if do_hide: + loc_y = loc_y - 50 + first = 6 + second = 7 + if tree_type == 'COMPOSITING': + first = 1 + second = 2 + elif nodes_list == selected_math: + add_type = node_type + 'Math' + add = nodes.new(add_type) + add.operation = mode + add.hide = do_hide + if do_hide: + loc_y = loc_y - 50 + first = 0 + second = 1 + elif nodes_list == selected_shader: + if mode == 'MIX': + add_type = node_type + 'MixShader' + add = nodes.new(add_type) + add.hide = do_hide_shader + if do_hide_shader: + loc_y = loc_y - 50 + first = 1 + second = 2 + elif mode == 'ADD': + add_type = node_type + 'AddShader' + add = nodes.new(add_type) + add.hide = do_hide_shader + if do_hide_shader: + loc_y = loc_y - 50 + first = 0 + second = 1 + elif nodes_list == selected_geometry: + if mode in ('JOIN', 'MIX'): + add_type = node_type + 'JoinGeometry' + add = self.merge_with_multi_input( + nodes_list, merge_position, do_hide, loc_x, links, nodes, add_type, [0]) + else: + add_type = node_type + 'MeshBoolean' + indices = [0, 1] if mode == 'DIFFERENCE' else [1] + add = self.merge_with_multi_input( + nodes_list, merge_position, do_hide, loc_x, links, nodes, add_type, indices) + add.operation = mode + was_multi = True + break + elif nodes_list == selected_vector: + add_type = node_type + 'VectorMath' + add = nodes.new(add_type) + add.operation = mode + add.hide = do_hide + if do_hide: + loc_y = loc_y - 50 + first = 0 + second = 1 + elif nodes_list == selected_z: + add = nodes.new('CompositorNodeZcombine') + add.show_preview = False + add.hide = do_hide + if do_hide: + loc_y = loc_y - 50 + first = 0 + second = 2 + elif nodes_list == selected_alphaover: + add = nodes.new('CompositorNodeAlphaOver') + add.show_preview = False + add.hide = do_hide + if do_hide: + loc_y = loc_y - 50 + first = 1 + second = 2 + add.location = loc_x, loc_y + loc_y += offset_y + add.select = True + + # This has already been handled separately + if was_multi: + continue + count_adds = i + 1 + count_after = len(nodes) + index = count_after - 1 + first_selected = nodes[nodes_list[0][0]] + # "last" node has been added as first, so its index is count_before. + last_add = nodes[count_before] + # Create list of invalid indexes. + invalid_nodes = [nodes[n[0]] + for n in (selected_mix + selected_math + selected_shader + selected_z + selected_geometry)] + + # Special case: + # Two nodes were selected and first selected has no output links, second selected has output links. + # Then add links from last add to all links 'to_socket' of out links of second selected. + first_selected_output = get_first_enabled_output(first_selected) + if len(nodes_list) == 2: + if not first_selected_output.links: + second_selected = nodes[nodes_list[1][0]] + for ss_link in get_first_enabled_output(second_selected).links: + # Prevent cyclic dependencies when nodes to be merged are linked to one another. + # Link only if "to_node" index not in invalid indexes list. + if not self.link_creates_cycle(ss_link, invalid_nodes): + connect_sockets(get_first_enabled_output(last_add), ss_link.to_socket) + # add links from last_add to all links 'to_socket' of out links of first selected. + for fs_link in first_selected_output.links: + # Link only if "to_node" index not in invalid indexes list. + if not self.link_creates_cycle(fs_link, invalid_nodes): + connect_sockets(get_first_enabled_output(last_add), fs_link.to_socket) + # add link from "first" selected and "first" add node + node_to = nodes[count_after - 1] + connect_sockets(first_selected_output, node_to.inputs[first]) + if node_to.type == 'ZCOMBINE': + for fs_out in first_selected.outputs: + if fs_out != first_selected_output and fs_out.name in ('Z', 'Depth'): + connect_sockets(fs_out, node_to.inputs[1]) + break + # add links between added ADD nodes and between selected and ADD nodes + for i in range(count_adds): + if i < count_adds - 1: + node_from = nodes[index] + node_to = nodes[index - 1] + node_to_input_i = first + node_to_z_i = 1 # if z combine - link z to first z input + connect_sockets(get_first_enabled_output(node_from), node_to.inputs[node_to_input_i]) + if node_to.type == 'ZCOMBINE': + for from_out in node_from.outputs: + if from_out != get_first_enabled_output(node_from) and from_out.name in ('Z', 'Depth'): + connect_sockets(from_out, node_to.inputs[node_to_z_i]) + if len(nodes_list) > 1: + node_from = nodes[nodes_list[i + 1][0]] + node_to = nodes[index] + node_to_input_i = second + node_to_z_i = 3 # if z combine - link z to second z input + connect_sockets(get_first_enabled_output(node_from), node_to.inputs[node_to_input_i]) + if node_to.type == 'ZCOMBINE': + for from_out in node_from.outputs: + if from_out != get_first_enabled_output(node_from) and from_out.name in ('Z', 'Depth'): + connect_sockets(from_out, node_to.inputs[node_to_z_i]) + index -= 1 + # set "last" of added nodes as active + nodes.active = last_add + for i, x, y, dx, h in nodes_list: + nodes[i].select = False + + return {'FINISHED'} + + +class NWBatchChangeNodes(Operator, NWBase): + bl_idname = "node.nw_batch_change" + bl_label = "Batch Change" + bl_description = "Batch Change Blend Type and Math Operation" + bl_options = {'REGISTER', 'UNDO'} + + blend_type: EnumProperty( + name="Blend Type", + items=blend_types + navs, + ) + operation: EnumProperty( + name="Operation", + items=operations + navs, + ) + + @classmethod + def poll(cls, context): + return (nw_check(cls, context) + and nw_check_space_type(cls, context, {'ShaderNodeTree', 'CompositorNodeTree', + 'TextureNodeTree', 'GeometryNodeTree'}) + and nw_check_selected(cls, context)) + + def execute(self, context): + blend_type = self.blend_type + operation = self.operation + for node in context.selected_nodes: + if node.type == 'MIX_RGB' or (node.bl_idname == 'ShaderNodeMix' and node.data_type == 'RGBA'): + if blend_type not in [nav[0] for nav in navs]: + node.blend_type = blend_type + else: + if blend_type == 'NEXT': + index = [i for i, entry in enumerate(blend_types) if node.blend_type in entry][0] + # index = blend_types.index(node.blend_type) + if index == len(blend_types) - 1: + node.blend_type = blend_types[0][0] + else: + node.blend_type = blend_types[index + 1][0] + + if blend_type == 'PREV': + index = [i for i, entry in enumerate(blend_types) if node.blend_type in entry][0] + if index == 0: + node.blend_type = blend_types[len(blend_types) - 1][0] + else: + node.blend_type = blend_types[index - 1][0] + + if node.type == 'MATH' or node.bl_idname == 'ShaderNodeMath': + if operation not in [nav[0] for nav in navs]: + node.operation = operation + else: + if operation == 'NEXT': + index = [i for i, entry in enumerate(operations) if node.operation in entry][0] + # index = operations.index(node.operation) + if index == len(operations) - 1: + node.operation = operations[0][0] + else: + node.operation = operations[index + 1][0] + + if operation == 'PREV': + index = [i for i, entry in enumerate(operations) if node.operation in entry][0] + # index = operations.index(node.operation) + if index == 0: + node.operation = operations[len(operations) - 1][0] + else: + node.operation = operations[index - 1][0] + + return {'FINISHED'} + + +class NWChangeMixFactor(Operator, NWBase): + bl_idname = "node.nw_factor" + bl_label = "Change Factor" + bl_description = "Change Factors of Mix Nodes and Mix Shader Nodes" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return nw_check(cls, context) and nw_check_selected(cls, context) + + # option: Change factor. + # If option is 1.0 or 0.0 - set to 1.0 or 0.0 + # Else - change factor by option value. + option: FloatProperty() + + def execute(self, context): + nodes, links = get_nodes_links(context) + option = self.option + selected = [] # entry = index + for si, node in enumerate(nodes): + if node.select: + if node.type in {'MIX_RGB', 'MIX_SHADER'} or node.bl_idname == 'ShaderNodeMix': + selected.append(si) + + for si in selected: + fac = nodes[si].inputs[0] + nodes[si].hide = False + if option in {0.0, 1.0}: + fac.default_value = option + else: + fac.default_value += option + + return {'FINISHED'} + + +class NWCopySettings(Operator, NWBase): + bl_idname = "node.nw_copy_settings" + bl_label = "Copy Settings" + bl_description = "Copy Settings of Active Node to Selected Nodes" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return (nw_check(cls, context) + and nw_check_active(cls, context) + and nw_check_selected(cls, context, min=2) + and nw_check_node_type(cls, context, 'FRAME', invert=True)) + + def execute(self, context): + node_active = context.active_node + node_selected = context.selected_nodes + selected_node_names = [n.name for n in node_selected] + + # Get nodes in selection by type + valid_nodes = [n for n in node_selected if n.type == node_active.type] + + if not (len(valid_nodes) > 1) and node_active: + self.report({'ERROR'}, "Selected nodes are not of the same type as {}".format(node_active.name)) + return {'CANCELLED'} + + if len(valid_nodes) != len(node_selected): + # Report nodes that are not valid + valid_node_names = [n.name for n in valid_nodes] + not_valid_names = list(set(selected_node_names) - set(valid_node_names)) + self.report( + {'INFO'}, + "Ignored {} (not of the same type as {})".format( + ", ".join(not_valid_names), + node_active.name)) + + # Reference original + orig = node_active + # node_selected_names = [n.name for n in node_selected] + + # Output list + success_names = [] + + # Deselect all nodes + for i in node_selected: + i.select = False + + # Code by zeffii from http://blender.stackexchange.com/a/42338/3710 + # Run through all other nodes + for node in valid_nodes[1:]: + + # Check for frame node + parent = node.parent if node.parent else None + node_loc = [node.location.x, node.location.y] + + # Select original to duplicate + orig.select = True + + # Duplicate selected node + bpy.ops.node.duplicate() + new_node = context.selected_nodes[0] + + # Deselect copy + new_node.select = False + + # Properties to copy + node_tree = node.id_data + props_to_copy = 'bl_idname name location height width'.split(' ') + + # Input and outputs + reconnections = [] + mappings = chain.from_iterable([node.inputs, node.outputs]) + for i in (i for i in mappings if i.is_linked): + for L in i.links: + reconnections.append([L.from_socket.path_from_id(), L.to_socket.path_from_id()]) + + # Properties + props = {j: getattr(node, j) for j in props_to_copy} + props_to_copy.pop(0) + + for prop in props_to_copy: + setattr(new_node, prop, props[prop]) + + # Get the node tree to remove the old node + nodes = node_tree.nodes + nodes.remove(node) + new_node.name = props['name'] + + if parent: + new_node.parent = parent + new_node.location = node_loc + + for str_from, str_to in reconnections: + connect_sockets(eval(str_from), eval(str_to)) + + success_names.append(new_node.name) + + orig.select = True + node_tree.nodes.active = orig + self.report( + {'INFO'}, + "Successfully copied attributes from {} to: {}".format( + orig.name, + ", ".join(success_names))) + return {'FINISHED'} + + +class NWCopyLabel(Operator, NWBase): + bl_idname = "node.nw_copy_label" + bl_label = "Copy Label" + bl_options = {'REGISTER', 'UNDO'} + bl_description = "Copy label from active to selected nodes" + + option: EnumProperty( + name="option", + description="Source of name of label", + items=( + ('FROM_ACTIVE', 'from active', 'from active node',), + ('FROM_NODE', 'from node', 'from node linked to selected node'), + ('FROM_SOCKET', 'from socket', 'from socket linked to selected node'), + ) + ) + + @classmethod + def poll(cls, context): + return nw_check(cls, context) and nw_check_selected(cls, context, min=2) + + def execute(self, context): + nodes, links = get_nodes_links(context) + option = self.option + active = nodes.active + if option == 'FROM_ACTIVE': + if active: + src_label = active.label + for node in [n for n in nodes if n.select and nodes.active != n]: + node.label = src_label + elif option == 'FROM_NODE': + selected = [n for n in nodes if n.select] + for node in selected: + for input in node.inputs: + if input.links: + src = input.links[0].from_node + node.label = src.label + break + elif option == 'FROM_SOCKET': + selected = [n for n in nodes if n.select] + for node in selected: + for input in node.inputs: + if input.links: + src = input.links[0].from_socket + node.label = src.name + break + + return {'FINISHED'} + + +class NWClearLabel(Operator, NWBase): + bl_idname = "node.nw_clear_label" + bl_label = "Clear Label" + bl_options = {'REGISTER', 'UNDO'} + bl_description = "Clear labels on selected nodes" + + option: BoolProperty() + + @classmethod + def poll(cls, context): + return nw_check(cls, context) and nw_check_selected(cls, context) + + def execute(self, context): + nodes, links = get_nodes_links(context) + for node in [n for n in nodes if n.select]: + node.label = '' + + return {'FINISHED'} + + def invoke(self, context, event): + if self.option: + return self.execute(context) + else: + return context.window_manager.invoke_confirm(self, event) + + +class NWModifyLabels(Operator, NWBase): + """Modify labels of all selected nodes""" + bl_idname = "node.nw_modify_labels" + bl_label = "Modify Labels" + bl_options = {'REGISTER', 'UNDO'} + + prepend: StringProperty( + name="Add to Beginning" + ) + append: StringProperty( + name="Add to End" + ) + replace_from: StringProperty( + name="Text to Replace" + ) + replace_to: StringProperty( + name="Replace with" + ) + + @classmethod + def poll(cls, context): + return nw_check(cls, context) and nw_check_selected(cls, context) + + def execute(self, context): + nodes, links = get_nodes_links(context) + for node in [n for n in nodes if n.select]: + node.label = self.prepend + node.label.replace(self.replace_from, self.replace_to) + self.append + + return {'FINISHED'} + + def invoke(self, context, event): + self.prepend = "" + self.append = "" + self.remove = "" + return context.window_manager.invoke_props_dialog(self) + + +class NWAddTextureSetup(Operator, NWBase): + bl_idname = "node.nw_add_texture" + bl_label = "Texture Setup" + bl_description = "Add Texture Node Setup to Selected Shaders" + bl_options = {'REGISTER', 'UNDO'} + + add_mapping: BoolProperty( + name="Add Mapping Nodes", + description="Create coordinate and mapping nodes for the texture (ignored for selected texture nodes)", + default=True) + + @classmethod + def poll(cls, context): + return (nw_check(cls, context) + and nw_check_space_type(cls, context, {'ShaderNodeTree'}) + and nw_check_selected(cls, context)) + + def execute(self, context): + nodes, links = get_nodes_links(context) + + texture_types = get_texture_node_types() + selected_nodes = [n for n in nodes if n.select] + + for node in selected_nodes: + if not node.inputs: + continue + + input_index = 0 + target_input = node.inputs[0] + for input in node.inputs: + if input.enabled: + input_index += 1 + if not input.is_linked: + target_input = input + break + else: + self.report({'WARNING'}, "No free inputs for node: " + node.name) + continue + + x_offset = 0 + padding = 40.0 + locx = node.location.x + locy = node.location.y - (input_index * padding) + + is_texture_node = node.rna_type.identifier in texture_types + use_environment_texture = node.type == 'BACKGROUND' + + # Add an image texture before normal shader nodes. + if not is_texture_node: + image_texture_type = 'ShaderNodeTexEnvironment' if use_environment_texture else 'ShaderNodeTexImage' + image_texture_node = nodes.new(image_texture_type) + x_offset = x_offset + image_texture_node.width + padding + image_texture_node.location = [locx - x_offset, locy] + nodes.active = image_texture_node + connect_sockets(image_texture_node.outputs[0], target_input) + + # The mapping setup following this will connect to the first input of this image texture. + target_input = image_texture_node.inputs[0] + + node.select = False + + if is_texture_node or self.add_mapping: + # Add Mapping node. + mapping_node = nodes.new('ShaderNodeMapping') + x_offset = x_offset + mapping_node.width + padding + mapping_node.location = [locx - x_offset, locy] + connect_sockets(mapping_node.outputs[0], target_input) + + # Add Texture Coordinates node. + tex_coord_node = nodes.new('ShaderNodeTexCoord') + x_offset = x_offset + tex_coord_node.width + padding + tex_coord_node.location = [locx - x_offset, locy] + + is_procedural_texture = is_texture_node and node.type != 'TEX_IMAGE' + use_generated_coordinates = is_procedural_texture or use_environment_texture + tex_coord_output = tex_coord_node.outputs[0 if use_generated_coordinates else 2] + connect_sockets(tex_coord_output, mapping_node.inputs[0]) + + return {'FINISHED'} + + +class NWAddPrincipledSetup(Operator, NWBase, ImportHelper): + bl_idname = "node.nw_add_textures_for_principled" + bl_label = "Principled Texture Setup" + bl_description = "Add Texture Node Setup for Principled BSDF" + bl_options = {'REGISTER', 'UNDO'} + + directory: StringProperty( + name='Directory', + subtype='DIR_PATH', + default='', + description='Folder to search in for image files' + ) + files: CollectionProperty( + type=bpy.types.OperatorFileListElement, + options={'HIDDEN', 'SKIP_SAVE'} + ) + + relative_path: BoolProperty( + name='Relative Path', + description='Set the file path relative to the blend file, when possible', + default=True + ) + + order = [ + "filepath", + "files", + ] + + def draw(self, context): + layout = self.layout + layout.alignment = 'LEFT' + + layout.prop(self, 'relative_path') + + @classmethod + def poll(cls, context): + return (nw_check(cls, context) + and nw_check_active(cls, context) + and nw_check_space_type(cls, context, {'ShaderNodeTree'}) + and nw_check_node_type(cls, context, 'BSDF_PRINCIPLED')) + + def execute(self, context): + # Check if everything is ok + if not self.directory: + self.report({'INFO'}, 'No folder selected') + return {'CANCELLED'} + if not self.files[:]: + self.report({'INFO'}, 'No files selected') + return {'CANCELLED'} + + nodes, links = get_nodes_links(context) + active_node = nodes.active + + # Filter textures names for texturetypes in filenames + # [Socket Name, [abbreviations and keyword list], Filename placeholder] + tags = context.preferences.addons[__package__].preferences.principled_tags + normal_abbr = tags.normal.split(' ') + bump_abbr = tags.bump.split(' ') + gloss_abbr = tags.gloss.split(' ') + rough_abbr = tags.rough.split(' ') + socketnames = [ + ['Displacement', tags.displacement.split(' '), None], + ['Base Color', tags.base_color.split(' '), None], + ['Metallic', tags.metallic.split(' '), None], + ['Specular IOR Level', tags.specular.split(' '), None], + ['Roughness', rough_abbr + gloss_abbr, None], + ['Bump', bump_abbr, None], + ['Normal', normal_abbr, None], + ['Transmission Weight', tags.transmission.split(' '), None], + ['Emission Color', tags.emission.split(' '), None], + ['Alpha', tags.alpha.split(' '), None], + ['Ambient Occlusion', tags.ambient_occlusion.split(' '), None], + ] + + match_files_to_socket_names(self.files, socketnames) + # Remove socketnames without found files + socketnames = [s for s in socketnames if s[2] + and path.exists(self.directory + s[2])] + if not socketnames: + self.report({'INFO'}, 'No matching images found') + print('No matching images found') + return {'CANCELLED'} + + # Don't override path earlier as os.path is used to check the absolute path + import_path = self.directory + if self.relative_path: + if bpy.data.filepath: + try: + import_path = bpy.path.relpath(self.directory) + except ValueError: + pass + + # Add found images + print('\nMatched Textures:') + texture_nodes = [] + disp_texture = None + ao_texture = None + normal_node = None + normal_node_texture = None + bump_node = None + bump_node_texture = None + roughness_node = None + for i, sname in enumerate(socketnames): + print(i, sname[0], sname[2]) + + # DISPLACEMENT NODES + if sname[0] == 'Displacement': + disp_texture = nodes.new(type='ShaderNodeTexImage') + img = bpy.data.images.load(path.join(import_path, sname[2])) + disp_texture.image = img + disp_texture.label = 'Displacement' + if disp_texture.image: + disp_texture.image.colorspace_settings.is_data = True + + # Add displacement offset nodes + disp_node = nodes.new(type='ShaderNodeDisplacement') + # Align the Displacement node under the active Principled BSDF node + disp_node.location = active_node.location + Vector((100, -700)) + link = connect_sockets(disp_node.inputs[0], disp_texture.outputs[0]) + + # TODO Turn on true displacement in the material + # Too complicated for now + + # Find output node + output_node = [n for n in nodes if n.bl_idname == 'ShaderNodeOutputMaterial'] + if output_node: + if not output_node[0].inputs[2].is_linked: + link = connect_sockets(output_node[0].inputs[2], disp_node.outputs[0]) + + continue + + # BUMP NODES + elif sname[0] == 'Bump': + # Test if new texture node is bump map + fname_components = split_into_components(sname[2]) + match_bump = set(bump_abbr).intersection(set(fname_components)) + if match_bump: + # If Bump add bump node in between + bump_node_texture = nodes.new(type='ShaderNodeTexImage') + img = bpy.data.images.load(path.join(import_path, sname[2])) + img.colorspace_settings.is_data = True + bump_node_texture.image = img + bump_node_texture.label = 'Bump' + + # Add bump node + bump_node = nodes.new(type='ShaderNodeBump') + link = connect_sockets(bump_node.inputs[2], bump_node_texture.outputs[0]) + link = connect_sockets(active_node.inputs['Normal'], bump_node.outputs[0]) + continue + + # NORMAL NODES + elif sname[0] == 'Normal': + # Test if new texture node is normal map + fname_components = split_into_components(sname[2]) + match_normal = set(normal_abbr).intersection(set(fname_components)) + if match_normal: + # If Normal add normal node in between + normal_node_texture = nodes.new(type='ShaderNodeTexImage') + img = bpy.data.images.load(path.join(import_path, sname[2])) + img.colorspace_settings.is_data = True + normal_node_texture.image = img + normal_node_texture.label = 'Normal' + + # Add normal node + normal_node = nodes.new(type='ShaderNodeNormalMap') + link = connect_sockets(normal_node.inputs[1], normal_node_texture.outputs[0]) + # Connect to bump node if it was created before, otherwise to the BSDF + if bump_node is None: + link = connect_sockets(active_node.inputs[sname[0]], normal_node.outputs[0]) + else: + link = connect_sockets(bump_node.inputs[sname[0]], normal_node.outputs[sname[0]]) + continue + + # AMBIENT OCCLUSION TEXTURE + elif sname[0] == 'Ambient Occlusion': + ao_texture = nodes.new(type='ShaderNodeTexImage') + img = bpy.data.images.load(path.join(import_path, sname[2])) + ao_texture.image = img + ao_texture.label = sname[0] + if ao_texture.image: + ao_texture.image.colorspace_settings.is_data = True + + continue + + if not active_node.inputs[sname[0]].is_linked: + # No texture node connected -> add texture node with new image + texture_node = nodes.new(type='ShaderNodeTexImage') + img = bpy.data.images.load(path.join(import_path, sname[2])) + texture_node.image = img + + if sname[0] == 'Roughness': + # Test if glossy or roughness map + fname_components = split_into_components(sname[2]) + match_rough = set(rough_abbr).intersection(set(fname_components)) + match_gloss = set(gloss_abbr).intersection(set(fname_components)) + + if match_rough: + # If Roughness nothing to to + link = connect_sockets(active_node.inputs[sname[0]], texture_node.outputs[0]) + + elif match_gloss: + # If Gloss Map add invert node + invert_node = nodes.new(type='ShaderNodeInvert') + link = connect_sockets(invert_node.inputs[1], texture_node.outputs[0]) + + link = connect_sockets(active_node.inputs[sname[0]], invert_node.outputs[0]) + roughness_node = texture_node + + else: + # This is a simple connection Texture --> Input slot + link = connect_sockets(active_node.inputs[sname[0]], texture_node.outputs[0]) + + # Use non-color except for color inputs + if sname[0] not in ['Base Color', 'Emission Color'] and texture_node.image: + texture_node.image.colorspace_settings.is_data = True + + else: + # If already texture connected. add to node list for alignment + texture_node = active_node.inputs[sname[0]].links[0].from_node + + # This are all connected texture nodes + texture_nodes.append(texture_node) + texture_node.label = sname[0] + + if disp_texture: + texture_nodes.append(disp_texture) + if bump_node_texture: + texture_nodes.append(bump_node_texture) + if normal_node_texture: + texture_nodes.append(normal_node_texture) + + if ao_texture: + # We want the ambient occlusion texture to be the top most texture node + texture_nodes.insert(0, ao_texture) + + # Alignment + for i, texture_node in enumerate(texture_nodes): + offset = Vector((-550, (i * -280) + 200)) + texture_node.location = active_node.location + offset + + if normal_node: + # Extra alignment if normal node was added + normal_node.location = normal_node_texture.location + Vector((300, 0)) + + if bump_node: + # Extra alignment if bump node was added + bump_node.location = bump_node_texture.location + Vector((300, 0)) + + if roughness_node: + # Alignment of invert node if glossy map + invert_node.location = roughness_node.location + Vector((300, 0)) + + # Add texture input + mapping + mapping = nodes.new(type='ShaderNodeMapping') + mapping.location = active_node.location + Vector((-1050, 0)) + if len(texture_nodes) > 1: + # If more than one texture add reroute node in between + reroute = nodes.new(type='NodeReroute') + texture_nodes.append(reroute) + tex_coords = Vector((texture_nodes[0].location.x, + sum(n.location.y for n in texture_nodes) / len(texture_nodes))) + reroute.location = tex_coords + Vector((-50, -120)) + for texture_node in texture_nodes: + link = connect_sockets(texture_node.inputs[0], reroute.outputs[0]) + link = connect_sockets(reroute.inputs[0], mapping.outputs[0]) + else: + link = connect_sockets(texture_nodes[0].inputs[0], mapping.outputs[0]) + + # Connect texture_coordinates to mapping node + texture_input = nodes.new(type='ShaderNodeTexCoord') + texture_input.location = mapping.location + Vector((-200, 0)) + link = connect_sockets(mapping.inputs[0], texture_input.outputs[2]) + + # Create frame around tex coords and mapping + frame = nodes.new(type='NodeFrame') + frame.label = 'Mapping' + mapping.parent = frame + texture_input.parent = frame + frame.update() + + # Create frame around texture nodes + frame = nodes.new(type='NodeFrame') + frame.label = 'Textures' + for tnode in texture_nodes: + tnode.parent = frame + frame.update() + + # Just to be sure + active_node.select = False + nodes.update() + links.update() + force_update(context) + return {'FINISHED'} + + +class NWAddReroutes(Operator, NWBase): + """Add Reroute Nodes and link them to outputs of selected nodes""" + bl_idname = "node.nw_add_reroutes" + bl_label = "Add Reroutes" + bl_description = "Add Reroutes to Outputs" + bl_options = {'REGISTER', 'UNDO'} + + option: EnumProperty( + name="option", + items=[ + ('ALL', 'to all', 'Add to all outputs'), + ('LOOSE', 'to loose', 'Add only to loose outputs'), + ('LINKED', 'to linked', 'Add only to linked outputs'), + ] + ) + + @classmethod + def poll(cls, context): + return nw_check(cls, context) and nw_check_selected(cls, context) + + def execute(self, context): + nodes, _links = get_nodes_links(context) + post_select = [] # Nodes to be selected after execution. + y_offset = -22.0 + + # Create reroutes and recreate links. + for node in [n for n in nodes if n.select]: + if not node.outputs: + continue + x = node.location.x + node.width + 20.0 + y = node.location.y + new_node_reroutes = [] + + # Unhide 'REROUTE' nodes to avoid issues with location.y + if node.type == 'REROUTE': + node.hide = False + else: + y -= 35.0 + + reroutes_count = 0 # Will be used when aligning reroutes added to hidden nodes. + for out_i, output in enumerate(node.outputs): + if output.is_unavailable: + continue + if node.type == 'R_LAYERS' and output.name != 'Alpha': + # If 'R_LAYERS' check if output is used in render pass. + # If output is "Alpha", assume it's used. Not available in passes. + node_scene = node.scene + node_layer = node.layer + for rlo in rl_outputs: + # Check entries in global 'rl_outputs' variable. + if output.name in {rlo.output_name, rlo.exr_output_name}: + if not getattr(node_scene.view_layers[node_layer], rlo.render_pass): + continue + # Output is valid when option is 'all' or when 'loose' output has no links. + valid = ((self.option == 'ALL') or + (self.option == 'LOOSE' and not output.links) or + (self.option == 'LINKED' and output.links)) + if valid: + # Add reroutes only if valid. + n = nodes.new('NodeReroute') + nodes.active = n + for link in output.links: + connect_sockets(n.outputs[0], link.to_socket) + connect_sockets(output, n.inputs[0]) + n.location = x, y + new_node_reroutes.append(n) + post_select.append(n) + if valid or not output.hide: + # Offset reroutes for all outputs, except hidden ones. + reroutes_count += 1 + y += y_offset + + # Nicer reroutes distribution along y when node.hide. + if node.hide: + y_translate = reroutes_count * y_offset / 2.0 - y_offset - 35.0 + for reroute in new_node_reroutes: + reroute.location.y -= y_translate + + if post_select: + for node in nodes: + # Select only newly created nodes. + node.select = node in post_select + else: + # No new nodes were created. + return {'CANCELLED'} + + return {'FINISHED'} + + +class NWLinkActiveToSelected(Operator, NWBase): + """Link active node to selected nodes basing on various criteria""" + bl_idname = "node.nw_link_active_to_selected" + bl_label = "Link Active Node to Selected" + bl_options = {'REGISTER', 'UNDO'} + + replace: BoolProperty() + use_node_name: BoolProperty() + use_outputs_names: BoolProperty() + + @classmethod + def poll(cls, context): + return (nw_check(cls, context) + and nw_check_active(cls, context) + and nw_check_selected(cls, context, min=2)) + + def execute(self, context): + nodes, links = get_nodes_links(context) + replace = self.replace + use_node_name = self.use_node_name + use_outputs_names = self.use_outputs_names + active = nodes.active + selected = [node for node in nodes if node.select and node != active] + outputs = [] # Only usable outputs of active nodes will be stored here. + for out in active.outputs: + if active.type != 'R_LAYERS': + outputs.append(out) + else: + # 'R_LAYERS' node type needs special handling. + # outputs of 'R_LAYERS' are callable even if not seen in UI. + # Only outputs that represent used passes should be taken into account + # Check if pass represented by output is used. + # global 'rl_outputs' list will be used for that + for rlo in rl_outputs: + pass_used = False # initial value. Will be set to True if pass is used + if out.name == 'Alpha': + # Alpha output is always present. Doesn't have representation in render pass. Assume it's used. + pass_used = True + elif out.name in {rlo.output_name, rlo.exr_output_name}: + # example 'render_pass' entry: 'use_pass_uv' Check if True in scene render layers + pass_used = getattr(active.scene.view_layers[active.layer], rlo.render_pass) + break + if pass_used: + outputs.append(out) + doit = True # Will be changed to False when links successfully added to previous output. + for out in outputs: + if doit: + for node in selected: + dst_name = node.name # Will be compared with src_name if needed. + # When node has label - use it as dst_name + if node.label: + dst_name = node.label + valid = True # Initial value. Will be changed to False if names don't match. + src_name = dst_name # If names not used - this assignment will keep valid = True. + if use_node_name: + # Set src_name to source node name or label + src_name = active.name + if active.label: + src_name = active.label + elif use_outputs_names: + src_name = (out.name, ) + for rlo in rl_outputs: + if out.name in {rlo.output_name, rlo.exr_output_name}: + src_name = (rlo.output_name, rlo.exr_output_name) + if dst_name not in src_name: + valid = False + if valid: + for input in node.inputs: + if input.type == out.type or node.type == 'REROUTE': + if replace or not input.is_linked: + connect_sockets(out, input) + if not use_node_name and not use_outputs_names: + doit = False + break + + return {'FINISHED'} + + +class NWAlignNodes(Operator, NWBase): + '''Align the selected nodes neatly in a row/column''' + 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') + + @classmethod + def poll(cls, context): + return nw_check(cls, context) and nw_check_not_empty(cls, context) + + def execute(self, context): + nodes, links = get_nodes_links(context) + margin = self.margin + + selection = [] + for node in nodes: + if node.select and node.type != 'FRAME': + selection.append(node) + + # 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 + + # 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 + + # 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) + + # 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 + + 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) + + # 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) + + return {'FINISHED'} + + +class NWSelectParentChildren(Operator, NWBase): + bl_idname = "node.nw_select_parent_child" + bl_label = "Select Parent or Children" + bl_options = {'REGISTER', 'UNDO'} + + option: EnumProperty( + name="option", + items=( + ('PARENT', 'Select Parent', 'Select Parent Frame'), + ('CHILD', 'Select Children', 'Select members of selected frame'), + ) + ) + + @classmethod + def poll(cls, context): + return nw_check(cls, context) and nw_check_selected(cls, context) + + def execute(self, context): + nodes, links = get_nodes_links(context) + option = self.option + selected = [node for node in nodes if node.select] + if option == 'PARENT': + for sel in selected: + parent = sel.parent + if parent: + parent.select = True + else: # option == 'CHILD' + for sel in selected: + children = [node for node in nodes if node.parent == sel] + for kid in children: + kid.select = True + + return {'FINISHED'} + + +class NWDetachOutputs(Operator, NWBase): + """Detach outputs of selected node leaving inputs linked""" + bl_idname = "node.nw_detach_outputs" + bl_label = "Detach Outputs" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return nw_check(cls, context) and nw_check_selected(cls, context) + + def execute(self, context): + nodes, links = get_nodes_links(context) + selected = context.selected_nodes + bpy.ops.node.duplicate_move_keep_inputs() + new_nodes = context.selected_nodes + bpy.ops.node.select_all(action="DESELECT") + for node in selected: + node.select = True + bpy.ops.node.delete_reconnect() + for new_node in new_nodes: + new_node.select = True + bpy.ops.transform.translate('INVOKE_DEFAULT') + + return {'FINISHED'} + + +class NWLinkToOutputNode(Operator): + """Link to Composite node or Material Output node""" + bl_idname = "node.nw_link_out" + bl_label = "Connect to Output" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + """Disabled for custom nodes as we do not know which nodes are outputs.""" + return (nw_check(cls, context) + and nw_check_space_type(cls, context, {'ShaderNodeTree', 'CompositorNodeTree', + 'TextureNodeTree', 'GeometryNodeTree'}) + and nw_check_active(cls, context) + and nw_check_visible_outputs(cls, context)) + + def execute(self, context): + nodes, links = get_nodes_links(context) + active = nodes.active + output_index = None + tree_type = context.space_data.tree_type + shader_outputs = {'OBJECT': 'ShaderNodeOutputMaterial', + 'WORLD': 'ShaderNodeOutputWorld', + 'LINESTYLE': 'ShaderNodeOutputLineStyle'} + output_type = { + 'ShaderNodeTree': shader_outputs[context.space_data.shader_type], + 'CompositorNodeTree': 'CompositorNodeComposite', + 'TextureNodeTree': 'TextureNodeOutput', + 'GeometryNodeTree': 'NodeGroupOutput', + }[tree_type] + for node in nodes: + # check whether the node is an output node and, + # if supported, whether it's the active one + if node.rna_type.identifier == output_type \ + and (node.is_active_output if hasattr(node, 'is_active_output') + else True): + output_node = node + break + else: # No output node exists + bpy.ops.node.select_all(action="DESELECT") + output_node = nodes.new(output_type) + output_node.location.x = active.location.x + active.dimensions.x + 80 + output_node.location.y = active.location.y + + if active.outputs: + for i, output in enumerate(active.outputs): + if is_visible_socket(output): + output_index = i + break + for i, output in enumerate(active.outputs): + if output.type == output_node.inputs[0].type and is_visible_socket(output): + output_index = i + break + + out_input_index = 0 + if tree_type == 'ShaderNodeTree': + if active.outputs[output_index].name == 'Volume': + out_input_index = 1 + elif active.outputs[output_index].name == 'Displacement': + out_input_index = 2 + elif tree_type == 'GeometryNodeTree': + if active.outputs[output_index].type != 'GEOMETRY': + return {'CANCELLED'} + connect_sockets(active.outputs[output_index], output_node.inputs[out_input_index]) + + force_update(context) # viewport render does not update + + return {'FINISHED'} + + +class NWMakeLink(Operator, NWBase): + """Make a link from one socket to another""" + bl_idname = 'node.nw_make_link' + bl_label = 'Make Link' + bl_options = {'REGISTER', 'UNDO'} + from_socket: IntProperty() + to_socket: IntProperty() + + def execute(self, context): + nodes, links = get_nodes_links(context) + + n1 = nodes[context.scene.NWLazySource] + n2 = nodes[context.scene.NWLazyTarget] + + connect_sockets(n1.outputs[self.from_socket], n2.inputs[self.to_socket]) + + force_update(context) + + return {'FINISHED'} + + +class NWCallInputsMenu(Operator, NWBase): + """Link from this output""" + bl_idname = 'node.nw_call_inputs_menu' + bl_label = 'Make Link' + bl_options = {'REGISTER', 'UNDO'} + from_socket: IntProperty() + + def execute(self, context): + nodes, links = get_nodes_links(context) + + context.scene.NWSourceSocket = self.from_socket + + n1 = nodes[context.scene.NWLazySource] + n2 = nodes[context.scene.NWLazyTarget] + if len(n2.inputs) > 1: + bpy.ops.wm.call_menu("INVOKE_DEFAULT", name=NWConnectionListInputs.bl_idname) + elif len(n2.inputs) == 1: + connect_sockets(n1.outputs[self.from_socket], n2.inputs[0]) + return {'FINISHED'} + + +class NWAddSequence(Operator, NWBase, ImportHelper): + """Add an Image Sequence""" + bl_idname = 'node.nw_add_sequence' + bl_label = 'Import Image Sequence' + bl_options = {'REGISTER', 'UNDO'} + + directory: StringProperty( + subtype="DIR_PATH" + ) + filename: StringProperty( + subtype="FILE_NAME" + ) + files: CollectionProperty( + type=bpy.types.OperatorFileListElement, + options={'HIDDEN', 'SKIP_SAVE'} + ) + relative_path: BoolProperty( + name='Relative Path', + description='Set the file path relative to the blend file, when possible', + default=True + ) + + @classmethod + def poll(cls, context): + return (nw_check(cls, context) + and nw_check_space_type(cls, context, {'ShaderNodeTree', 'CompositorNodeTree'})) + + def draw(self, context): + layout = self.layout + layout.alignment = 'LEFT' + + layout.prop(self, 'relative_path') + + def execute(self, context): + nodes, links = get_nodes_links(context) + directory = self.directory + filename = self.filename + files = self.files + tree = context.space_data.node_tree + + # DEBUG + # print ("\nDIR:", directory) + # print ("FN:", filename) + # print ("Fs:", list(f.name for f in files), '\n') + + if tree.type == 'SHADER': + node_type = "ShaderNodeTexImage" + elif tree.type == 'COMPOSITING': + node_type = "CompositorNodeImage" + else: + self.report({'ERROR'}, "Unsupported Node Tree type!") + return {'CANCELLED'} + + if not files[0].name and not filename: + self.report({'ERROR'}, "No file chosen") + return {'CANCELLED'} + elif files[0].name and (not filename or not path.exists(directory + filename)): + # User has selected multiple files without an active one, or the active one is non-existent + filename = files[0].name + + if not path.exists(directory + filename): + self.report({'ERROR'}, filename + " does not exist!") + return {'CANCELLED'} + + without_ext = '.'.join(filename.split('.')[:-1]) + + # if last digit isn't a number, it's not a sequence + if not without_ext[-1].isdigit(): + self.report({'ERROR'}, filename + " does not seem to be part of a sequence") + return {'CANCELLED'} + + extension = filename.split('.')[-1] + reverse = without_ext[::-1] # reverse string + + count_numbers = 0 + for char in reverse: + if char.isdigit(): + count_numbers += 1 + else: + break + + without_num = without_ext[:count_numbers * -1] + + files = sorted(glob(directory + without_num + "[0-9]" * count_numbers + "." + extension)) + + num_frames = len(files) + + nodes_list = [node for node in nodes] + if nodes_list: + nodes_list.sort(key=lambda k: k.location.x) + xloc = nodes_list[0].location.x - 220 # place new nodes at far left + yloc = 0 + for node in nodes: + node.select = False + yloc += node_mid_pt(node, 'y') + yloc = yloc / len(nodes) + else: + xloc = 0 + yloc = 0 + + name_with_hashes = without_num + "#" * count_numbers + '.' + extension + + bpy.ops.node.add_node('INVOKE_DEFAULT', use_transform=True, type=node_type) + node = nodes.active + node.label = name_with_hashes + + filepath = directory + (without_ext + '.' + extension) + if self.relative_path: + if bpy.data.filepath: + try: + filepath = bpy.path.relpath(filepath) + except ValueError: + pass + + img = bpy.data.images.load(filepath) + img.source = 'SEQUENCE' + img.name = name_with_hashes + node.image = img + image_user = node.image_user if tree.type == 'SHADER' else node + # separate the number from the file name of the first file + image_user.frame_offset = int(files[0][len(without_num) + len(directory):-1 * (len(extension) + 1)]) - 1 + image_user.frame_duration = num_frames + + return {'FINISHED'} + + +class NWAddMultipleImages(Operator, NWBase, ImportHelper): + """Add multiple images at once""" + bl_idname = 'node.nw_add_multiple_images' + bl_label = 'Open Selected Images' + bl_options = {'REGISTER', 'UNDO'} + directory: StringProperty( + subtype="DIR_PATH" + ) + files: CollectionProperty( + type=bpy.types.OperatorFileListElement, + options={'HIDDEN', 'SKIP_SAVE'} + ) + + @classmethod + def poll(cls, context): + return (nw_check(cls, context) + and nw_check_space_type(cls, context, {'ShaderNodeTree', 'CompositorNodeTree'})) + + def execute(self, context): + nodes, links = get_nodes_links(context) + + xloc, yloc = context.region.view2d.region_to_view(context.area.width / 2, context.area.height / 2) + + if context.space_data.node_tree.type == 'SHADER': + node_type = "ShaderNodeTexImage" + elif context.space_data.node_tree.type == 'COMPOSITING': + node_type = "CompositorNodeImage" + else: + self.report({'ERROR'}, "Unsupported Node Tree type!") + return {'CANCELLED'} + + new_nodes = [] + for f in self.files: + fname = f.name + + node = nodes.new(node_type) + new_nodes.append(node) + node.label = fname + node.hide = True + node.location.x = xloc + node.location.y = yloc + yloc -= 40 + + img = bpy.data.images.load(self.directory + fname) + node.image = img + + # shift new nodes up to center of tree + list_size = new_nodes[0].location.y - new_nodes[-1].location.y + for node in nodes: + if node in new_nodes: + node.select = True + node.location.y += (list_size / 2) + else: + node.select = False + return {'FINISHED'} + + +class NWSaveViewer(bpy.types.Operator, ExportHelper): + """Save the current viewer node to an image file""" + bl_idname = "node.nw_save_viewer" + bl_label = "Save This Image" + filepath: StringProperty(subtype="FILE_PATH") + filename_ext: EnumProperty( + name="Format", + description="Choose the file format to save to", + items=(('.bmp', "BMP", ""), + ('.rgb', 'IRIS', ""), + ('.png', 'PNG', ""), + ('.jpg', 'JPEG', ""), + ('.jp2', 'JPEG2000', ""), + ('.tga', 'TARGA', ""), + ('.cin', 'CINEON', ""), + ('.dpx', 'DPX', ""), + ('.exr', 'OPEN_EXR', ""), + ('.hdr', 'HDR', ""), + ('.tif', 'TIFF', "")), + default='.png', + ) + + @classmethod + def poll(cls, context): + return (nw_check(cls, context) + and nw_check_space_type(cls, context, {'CompositorNodeTree'}) + and nw_check_viewer_node(cls)) + + def execute(self, context): + fp = self.filepath + if fp: + formats = { + '.bmp': 'BMP', + '.rgb': 'IRIS', + '.png': 'PNG', + '.jpg': 'JPEG', + '.jpeg': 'JPEG', + '.jp2': 'JPEG2000', + '.tga': 'TARGA', + '.cin': 'CINEON', + '.dpx': 'DPX', + '.exr': 'OPEN_EXR', + '.hdr': 'HDR', + '.tiff': 'TIFF', + '.tif': 'TIFF'} + basename, ext = path.splitext(fp) + old_render_format = context.scene.render.image_settings.file_format + context.scene.render.image_settings.file_format = formats[self.filename_ext] + context.area.type = "IMAGE_EDITOR" + context.area.spaces[0].image = bpy.data.images['Viewer Node'] + context.area.spaces[0].image.save_render(fp) + context.area.type = "NODE_EDITOR" + context.scene.render.image_settings.file_format = old_render_format + return {'FINISHED'} + + +class NWResetNodes(bpy.types.Operator): + """Reset Nodes in Selection""" + bl_idname = "node.nw_reset_nodes" + bl_label = "Reset Nodes" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return (nw_check(cls, context) + and nw_check_selected(cls, context) + and nw_check_active(cls, context)) + + def execute(self, context): + node_active = context.active_node + node_selected = context.selected_nodes + node_ignore = ["FRAME", "REROUTE", "GROUP", "SIMULATION_INPUT", "SIMULATION_OUTPUT"] + + active_node_name = node_active.name if node_active.select else None + valid_nodes = [n for n in node_selected if n.type not in node_ignore] + + # Create output lists + selected_node_names = [n.name for n in node_selected] + success_names = [] + + # Reset all valid children in a frame + node_active_is_frame = False + if len(node_selected) == 1 and node_active.type == "FRAME": + node_tree = node_active.id_data + children = [n for n in node_tree.nodes if n.parent == node_active] + if children: + valid_nodes = [n for n in children if n.type not in node_ignore] + selected_node_names = [n.name for n in children if n.type not in node_ignore] + node_active_is_frame = True + + # Check if valid nodes in selection + if not (len(valid_nodes) > 0): + # Check for frames only + frames_selected = [n for n in node_selected if n.type == "FRAME"] + if (len(frames_selected) > 1 and len(frames_selected) == len(node_selected)): + self.report({'ERROR'}, "Please select only 1 frame to reset") + else: + self.report({'ERROR'}, "No valid node(s) in selection") + return {'CANCELLED'} + + # Report nodes that are not valid + if len(valid_nodes) != len(node_selected) and node_active_is_frame is False: + valid_node_names = [n.name for n in valid_nodes] + not_valid_names = list(set(selected_node_names) - set(valid_node_names)) + self.report({'INFO'}, "Ignored {}".format(", ".join(not_valid_names))) + + # Deselect all nodes + for i in node_selected: + i.select = False + + # Run through all valid nodes + for node in valid_nodes: + + parent = node.parent if node.parent else None + node_loc = [node.location.x, node.location.y] + + node_tree = node.id_data + props_to_copy = 'bl_idname name location height width'.split(' ') + + reconnections = [] + mappings = chain.from_iterable([node.inputs, node.outputs]) + for i in (i for i in mappings if i.is_linked): + for L in i.links: + reconnections.append([L.from_socket.path_from_id(), L.to_socket.path_from_id()]) + + props = {j: getattr(node, j) for j in props_to_copy} + + new_node = node_tree.nodes.new(props['bl_idname']) + props_to_copy.pop(0) + + for prop in props_to_copy: + setattr(new_node, prop, props[prop]) + + nodes = node_tree.nodes + nodes.remove(node) + new_node.name = props['name'] + + if parent: + new_node.parent = parent + new_node.location = node_loc + + for str_from, str_to in reconnections: + connect_sockets(eval(str_from), eval(str_to)) + + new_node.select = False + success_names.append(new_node.name) + + # Reselect all nodes + if selected_node_names and node_active_is_frame is False: + for i in selected_node_names: + node_tree.nodes[i].select = True + + if active_node_name is not None: + node_tree.nodes[active_node_name].select = True + node_tree.nodes.active = node_tree.nodes[active_node_name] + + self.report({'INFO'}, "Successfully reset {}".format(", ".join(success_names))) + return {'FINISHED'} + + +classes = ( + NWLazyMix, + NWLazyConnect, + NWDeleteUnused, + NWSwapLinks, + NWResetBG, + NWAddAttrNode, + NWFrameSelected, + NWReloadImages, + NWMergeNodes, + NWBatchChangeNodes, + NWChangeMixFactor, + NWCopySettings, + NWCopyLabel, + NWClearLabel, + NWModifyLabels, + NWAddTextureSetup, + NWAddPrincipledSetup, + NWAddReroutes, + NWLinkActiveToSelected, + NWAlignNodes, + NWSelectParentChildren, + NWDetachOutputs, + NWLinkToOutputNode, + NWMakeLink, + NWCallInputsMenu, + NWAddSequence, + NWAddMultipleImages, + NWSaveViewer, + NWResetNodes, +) + + +def register(): + from bpy.utils import register_class + for cls in classes: + register_class(cls) + + +def unregister(): + from bpy.utils import unregister_class + + for cls in classes: + unregister_class(cls) diff --git a/scripts/addons_core/node_wrangler/preferences.py b/scripts/addons_core/node_wrangler/preferences.py new file mode 100644 index 00000000000..10c25e906bb --- /dev/null +++ b/scripts/addons_core/node_wrangler/preferences.py @@ -0,0 +1,397 @@ +# SPDX-FileCopyrightText: 2023 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +import bpy +from bpy.props import EnumProperty, BoolProperty, StringProperty +from nodeitems_utils import node_categories_iter + +from . import operators +from . import interface + +from .utils.constants import nice_hotkey_name + + +# Principled prefs +class NWPrincipledPreferences(bpy.types.PropertyGroup): + base_color: StringProperty( + name='Base Color', + default='diffuse diff albedo base col color basecolor', + description='Naming Components for Base Color maps') + metallic: StringProperty( + name='Metallic', + default='metallic metalness metal mtl', + description='Naming Components for metallness maps') + specular: StringProperty( + name='Specular', + default='specularity specular spec spc', + description='Naming Components for Specular maps') + normal: StringProperty( + name='Normal', + default='normal nor nrm nrml norm', + description='Naming Components for Normal maps') + bump: StringProperty( + name='Bump', + default='bump bmp', + description='Naming Components for bump maps') + rough: StringProperty( + name='Roughness', + default='roughness rough rgh', + description='Naming Components for roughness maps') + gloss: StringProperty( + name='Gloss', + default='gloss glossy glossiness', + description='Naming Components for glossy maps') + displacement: StringProperty( + name='Displacement', + default='displacement displace disp dsp height heightmap', + description='Naming Components for displacement maps') + transmission: StringProperty( + name='Transmission', + default='transmission transparency', + description='Naming Components for transmission maps') + emission: StringProperty( + name='Emission', + default='emission emissive emit', + description='Naming Components for emission maps') + alpha: StringProperty( + name='Alpha', + default='alpha opacity', + description='Naming Components for alpha maps') + ambient_occlusion: StringProperty( + name='Ambient Occlusion', + default='ao ambient occlusion', + description='Naming Components for AO maps') + + +# Addon prefs +class NWNodeWrangler(bpy.types.AddonPreferences): + bl_idname = __package__ + + merge_hide: EnumProperty( + name="Hide Mix nodes", + items=( + ("ALWAYS", "Always", "Always collapse the new merge nodes"), + ("NON_SHADER", "Non-Shader", "Collapse in all cases except for shaders"), + ("NEVER", "Never", "Never collapse the new merge nodes") + ), + default='NON_SHADER', + description="When merging nodes with the Ctrl+Numpad0 hotkey (and similar) specify whether to collapse them or show the full node with options expanded") + merge_position: EnumProperty( + name="Mix Node Position", + items=( + ("CENTER", "Center", "Place the Mix node between the two nodes"), + ("BOTTOM", "Bottom", "Place the Mix node at the same height as the lowest node") + ), + default='CENTER', + description="When merging nodes with the Ctrl+Numpad0 hotkey (and similar) specify the position of the new nodes") + + show_hotkey_list: BoolProperty( + name="Show Hotkey List", + default=False, + description="Expand this box into a list of all the hotkeys for functions in this addon" + ) + hotkey_list_filter: StringProperty( + name=" Filter by Name", + default="", + description="Show only hotkeys that have this text in their name", + options={'TEXTEDIT_UPDATE'} + ) + show_principled_lists: BoolProperty( + name="Show Principled naming tags", + default=False, + description="Expand this box into a list of all naming tags for principled texture setup" + ) + principled_tags: bpy.props.PointerProperty(type=NWPrincipledPreferences) + + def draw(self, context): + layout = self.layout + col = layout.column() + col.prop(self, "merge_position") + col.prop(self, "merge_hide") + + box = layout.box() + col = box.column(align=True) + col.prop( + self, + "show_principled_lists", + text='Edit tags for auto texture detection in Principled BSDF setup', + toggle=True) + if self.show_principled_lists: + tags = self.principled_tags + + col.prop(tags, "base_color") + col.prop(tags, "metallic") + col.prop(tags, "specular") + col.prop(tags, "rough") + col.prop(tags, "gloss") + col.prop(tags, "normal") + col.prop(tags, "bump") + col.prop(tags, "displacement") + col.prop(tags, "transmission") + col.prop(tags, "emission") + col.prop(tags, "alpha") + col.prop(tags, "ambient_occlusion") + + box = layout.box() + col = box.column(align=True) + hotkey_button_name = "Show Hotkey List" + if self.show_hotkey_list: + hotkey_button_name = "Hide Hotkey List" + col.prop(self, "show_hotkey_list", text=hotkey_button_name, toggle=True) + if self.show_hotkey_list: + col.prop(self, "hotkey_list_filter", icon="VIEWZOOM") + col.separator() + for hotkey in kmi_defs: + if hotkey[7]: + hotkey_name = hotkey[7] + + if self.hotkey_list_filter.lower() in hotkey_name.lower(): + row = col.row(align=True) + row.label(text=hotkey_name) + keystr = nice_hotkey_name(hotkey[1]) + if hotkey[4]: + keystr = "Shift " + keystr + if hotkey[5]: + keystr = "Alt " + keystr + if hotkey[3]: + keystr = "Ctrl " + keystr + row.label(text=keystr) + + +# +# REGISTER/UNREGISTER CLASSES AND KEYMAP ITEMS +# +addon_keymaps = [] +# kmi_defs entry: (identifier, key, action, CTRL, SHIFT, ALT, props, nice name) +# props entry: (property name, property value) +kmi_defs = ( + # MERGE NODES + # NWMergeNodes with Ctrl (AUTO). + (operators.NWMergeNodes.bl_idname, 'NUMPAD_0', 'PRESS', True, False, False, + (('mode', 'MIX'), ('merge_type', 'AUTO'),), "Merge Nodes (Automatic)"), + (operators.NWMergeNodes.bl_idname, 'ZERO', 'PRESS', True, False, False, + (('mode', 'MIX'), ('merge_type', 'AUTO'),), "Merge Nodes (Automatic)"), + (operators.NWMergeNodes.bl_idname, 'NUMPAD_PLUS', 'PRESS', True, False, False, + (('mode', 'ADD'), ('merge_type', 'AUTO'),), "Merge Nodes (Add)"), + (operators.NWMergeNodes.bl_idname, 'EQUAL', 'PRESS', True, False, False, + (('mode', 'ADD'), ('merge_type', 'AUTO'),), "Merge Nodes (Add)"), + (operators.NWMergeNodes.bl_idname, 'NUMPAD_ASTERIX', 'PRESS', True, False, False, + (('mode', 'MULTIPLY'), ('merge_type', 'AUTO'),), "Merge Nodes (Multiply)"), + (operators.NWMergeNodes.bl_idname, 'EIGHT', 'PRESS', True, False, False, + (('mode', 'MULTIPLY'), ('merge_type', 'AUTO'),), "Merge Nodes (Multiply)"), + (operators.NWMergeNodes.bl_idname, 'NUMPAD_MINUS', 'PRESS', True, False, False, + (('mode', 'SUBTRACT'), ('merge_type', 'AUTO'),), "Merge Nodes (Subtract)"), + (operators.NWMergeNodes.bl_idname, 'MINUS', 'PRESS', True, False, False, + (('mode', 'SUBTRACT'), ('merge_type', 'AUTO'),), "Merge Nodes (Subtract)"), + (operators.NWMergeNodes.bl_idname, 'NUMPAD_SLASH', 'PRESS', True, False, False, + (('mode', 'DIVIDE'), ('merge_type', 'AUTO'),), "Merge Nodes (Divide)"), + (operators.NWMergeNodes.bl_idname, 'SLASH', 'PRESS', True, False, False, + (('mode', 'DIVIDE'), ('merge_type', 'AUTO'),), "Merge Nodes (Divide)"), + (operators.NWMergeNodes.bl_idname, 'COMMA', 'PRESS', True, False, False, + (('mode', 'LESS_THAN'), ('merge_type', 'MATH'),), "Merge Nodes (Less than)"), + (operators.NWMergeNodes.bl_idname, 'PERIOD', 'PRESS', True, False, False, + (('mode', 'GREATER_THAN'), ('merge_type', 'MATH'),), "Merge Nodes (Greater than)"), + (operators.NWMergeNodes.bl_idname, 'NUMPAD_PERIOD', 'PRESS', True, False, False, + (('mode', 'MIX'), ('merge_type', 'ZCOMBINE'),), "Merge Nodes (Z-Combine)"), + # NWMergeNodes with Ctrl Alt (MIX or ALPHAOVER) + (operators.NWMergeNodes.bl_idname, 'NUMPAD_0', 'PRESS', True, False, True, + (('mode', 'MIX'), ('merge_type', 'ALPHAOVER'),), "Merge Nodes (Alpha Over)"), + (operators.NWMergeNodes.bl_idname, 'ZERO', 'PRESS', True, False, True, + (('mode', 'MIX'), ('merge_type', 'ALPHAOVER'),), "Merge Nodes (Alpha Over)"), + (operators.NWMergeNodes.bl_idname, 'NUMPAD_PLUS', 'PRESS', True, False, True, + (('mode', 'ADD'), ('merge_type', 'MIX'),), "Merge Nodes (Color, Add)"), + (operators.NWMergeNodes.bl_idname, 'EQUAL', 'PRESS', True, False, True, + (('mode', 'ADD'), ('merge_type', 'MIX'),), "Merge Nodes (Color, Add)"), + (operators.NWMergeNodes.bl_idname, 'NUMPAD_ASTERIX', 'PRESS', True, False, True, + (('mode', 'MULTIPLY'), ('merge_type', 'MIX'),), "Merge Nodes (Color, Multiply)"), + (operators.NWMergeNodes.bl_idname, 'EIGHT', 'PRESS', True, False, True, + (('mode', 'MULTIPLY'), ('merge_type', 'MIX'),), "Merge Nodes (Color, Multiply)"), + (operators.NWMergeNodes.bl_idname, 'NUMPAD_MINUS', 'PRESS', True, False, True, + (('mode', 'SUBTRACT'), ('merge_type', 'MIX'),), "Merge Nodes (Color, Subtract)"), + (operators.NWMergeNodes.bl_idname, 'MINUS', 'PRESS', True, False, True, + (('mode', 'SUBTRACT'), ('merge_type', 'MIX'),), "Merge Nodes (Color, Subtract)"), + (operators.NWMergeNodes.bl_idname, 'NUMPAD_SLASH', 'PRESS', True, False, True, + (('mode', 'DIVIDE'), ('merge_type', 'MIX'),), "Merge Nodes (Color, Divide)"), + (operators.NWMergeNodes.bl_idname, 'SLASH', 'PRESS', True, False, True, + (('mode', 'DIVIDE'), ('merge_type', 'MIX'),), "Merge Nodes (Color, Divide)"), + # NWMergeNodes with Ctrl Shift (MATH) + (operators.NWMergeNodes.bl_idname, 'NUMPAD_PLUS', 'PRESS', True, True, False, + (('mode', 'ADD'), ('merge_type', 'MATH'),), "Merge Nodes (Math, Add)"), + (operators.NWMergeNodes.bl_idname, 'EQUAL', 'PRESS', True, True, False, + (('mode', 'ADD'), ('merge_type', 'MATH'),), "Merge Nodes (Math, Add)"), + (operators.NWMergeNodes.bl_idname, 'NUMPAD_ASTERIX', 'PRESS', True, True, False, + (('mode', 'MULTIPLY'), ('merge_type', 'MATH'),), "Merge Nodes (Math, Multiply)"), + (operators.NWMergeNodes.bl_idname, 'EIGHT', 'PRESS', True, True, False, + (('mode', 'MULTIPLY'), ('merge_type', 'MATH'),), "Merge Nodes (Math, Multiply)"), + (operators.NWMergeNodes.bl_idname, 'NUMPAD_MINUS', 'PRESS', True, True, False, + (('mode', 'SUBTRACT'), ('merge_type', 'MATH'),), "Merge Nodes (Math, Subtract)"), + (operators.NWMergeNodes.bl_idname, 'MINUS', 'PRESS', True, True, False, + (('mode', 'SUBTRACT'), ('merge_type', 'MATH'),), "Merge Nodes (Math, Subtract)"), + (operators.NWMergeNodes.bl_idname, 'NUMPAD_SLASH', 'PRESS', True, True, False, + (('mode', 'DIVIDE'), ('merge_type', 'MATH'),), "Merge Nodes (Math, Divide)"), + (operators.NWMergeNodes.bl_idname, 'SLASH', 'PRESS', True, True, False, + (('mode', 'DIVIDE'), ('merge_type', 'MATH'),), "Merge Nodes (Math, Divide)"), + (operators.NWMergeNodes.bl_idname, 'COMMA', 'PRESS', True, True, False, + (('mode', 'LESS_THAN'), ('merge_type', 'MATH'),), "Merge Nodes (Math, Less than)"), + (operators.NWMergeNodes.bl_idname, 'PERIOD', 'PRESS', True, True, False, + (('mode', 'GREATER_THAN'), ('merge_type', 'MATH'),), "Merge Nodes (Math, Greater than)"), + # BATCH CHANGE NODES + # NWBatchChangeNodes with Alt + (operators.NWBatchChangeNodes.bl_idname, 'NUMPAD_0', 'PRESS', False, False, True, + (('blend_type', 'MIX'), ('operation', 'CURRENT'),), "Batch change blend type (Mix)"), + (operators.NWBatchChangeNodes.bl_idname, 'ZERO', 'PRESS', False, False, True, + (('blend_type', 'MIX'), ('operation', 'CURRENT'),), "Batch change blend type (Mix)"), + (operators.NWBatchChangeNodes.bl_idname, 'NUMPAD_PLUS', 'PRESS', False, False, True, + (('blend_type', 'ADD'), ('operation', 'ADD'),), "Batch change blend type (Add)"), + (operators.NWBatchChangeNodes.bl_idname, 'EQUAL', 'PRESS', False, False, True, + (('blend_type', 'ADD'), ('operation', 'ADD'),), "Batch change blend type (Add)"), + (operators.NWBatchChangeNodes.bl_idname, 'NUMPAD_ASTERIX', 'PRESS', False, False, True, + (('blend_type', 'MULTIPLY'), ('operation', 'MULTIPLY'),), "Batch change blend type (Multiply)"), + (operators.NWBatchChangeNodes.bl_idname, 'EIGHT', 'PRESS', False, False, True, + (('blend_type', 'MULTIPLY'), ('operation', 'MULTIPLY'),), "Batch change blend type (Multiply)"), + (operators.NWBatchChangeNodes.bl_idname, 'NUMPAD_MINUS', 'PRESS', False, False, True, + (('blend_type', 'SUBTRACT'), ('operation', 'SUBTRACT'),), "Batch change blend type (Subtract)"), + (operators.NWBatchChangeNodes.bl_idname, 'MINUS', 'PRESS', False, False, True, + (('blend_type', 'SUBTRACT'), ('operation', 'SUBTRACT'),), "Batch change blend type (Subtract)"), + (operators.NWBatchChangeNodes.bl_idname, 'NUMPAD_SLASH', 'PRESS', False, False, True, + (('blend_type', 'DIVIDE'), ('operation', 'DIVIDE'),), "Batch change blend type (Divide)"), + (operators.NWBatchChangeNodes.bl_idname, 'SLASH', 'PRESS', False, False, True, + (('blend_type', 'DIVIDE'), ('operation', 'DIVIDE'),), "Batch change blend type (Divide)"), + (operators.NWBatchChangeNodes.bl_idname, 'COMMA', 'PRESS', False, False, True, + (('blend_type', 'CURRENT'), ('operation', 'LESS_THAN'),), "Batch change blend type (Current)"), + (operators.NWBatchChangeNodes.bl_idname, 'PERIOD', 'PRESS', False, False, True, + (('blend_type', 'CURRENT'), ('operation', 'GREATER_THAN'),), "Batch change blend type (Current)"), + (operators.NWBatchChangeNodes.bl_idname, 'DOWN_ARROW', 'PRESS', False, False, True, + (('blend_type', 'NEXT'), ('operation', 'NEXT'),), "Batch change blend type (Next)"), + (operators.NWBatchChangeNodes.bl_idname, 'UP_ARROW', 'PRESS', False, False, True, + (('blend_type', 'PREV'), ('operation', 'PREV'),), "Batch change blend type (Previous)"), + # LINK ACTIVE TO SELECTED + # Don't use names, don't replace links (K) + (operators.NWLinkActiveToSelected.bl_idname, 'K', 'PRESS', False, False, False, + (('replace', False), ('use_node_name', False), ('use_outputs_names', False),), "Link active to selected (Don't replace links)"), + # Don't use names, replace links (Shift K) + (operators.NWLinkActiveToSelected.bl_idname, 'K', 'PRESS', False, True, False, + (('replace', True), ('use_node_name', False), ('use_outputs_names', False),), "Link active to selected (Replace links)"), + # Use node name, don't replace links (') + (operators.NWLinkActiveToSelected.bl_idname, 'QUOTE', 'PRESS', False, False, False, + (('replace', False), ('use_node_name', True), ('use_outputs_names', False),), "Link active to selected (Don't replace links, node names)"), + # Use node name, replace links (Shift ') + (operators.NWLinkActiveToSelected.bl_idname, 'QUOTE', 'PRESS', False, True, False, + (('replace', True), ('use_node_name', True), ('use_outputs_names', False),), "Link active to selected (Replace links, node names)"), + # Don't use names, don't replace links (;) + (operators.NWLinkActiveToSelected.bl_idname, 'SEMI_COLON', 'PRESS', False, False, False, + (('replace', False), ('use_node_name', False), ('use_outputs_names', True),), "Link active to selected (Don't replace links, output names)"), + # Don't use names, replace links (') + (operators.NWLinkActiveToSelected.bl_idname, 'SEMI_COLON', 'PRESS', False, True, False, + (('replace', True), ('use_node_name', False), ('use_outputs_names', True),), "Link active to selected (Replace links, output names)"), + # CHANGE MIX FACTOR + (operators.NWChangeMixFactor.bl_idname, 'LEFT_ARROW', 'PRESS', False, + False, True, (('option', -0.1),), "Reduce Mix Factor by 0.1"), + (operators.NWChangeMixFactor.bl_idname, 'RIGHT_ARROW', 'PRESS', False, + False, True, (('option', 0.1),), "Increase Mix Factor by 0.1"), + (operators.NWChangeMixFactor.bl_idname, 'LEFT_ARROW', 'PRESS', False, + True, True, (('option', -0.01),), "Reduce Mix Factor by 0.01"), + (operators.NWChangeMixFactor.bl_idname, 'RIGHT_ARROW', 'PRESS', False, + True, True, (('option', 0.01),), "Increase Mix Factor by 0.01"), + (operators.NWChangeMixFactor.bl_idname, 'LEFT_ARROW', 'PRESS', + True, True, True, (('option', 0.0),), "Set Mix Factor to 0.0"), + (operators.NWChangeMixFactor.bl_idname, 'RIGHT_ARROW', 'PRESS', + True, True, True, (('option', 1.0),), "Set Mix Factor to 1.0"), + (operators.NWChangeMixFactor.bl_idname, 'NUMPAD_0', 'PRESS', + True, True, True, (('option', 0.0),), "Set Mix Factor to 0.0"), + (operators.NWChangeMixFactor.bl_idname, 'ZERO', 'PRESS', True, True, True, (('option', 0.0),), "Set Mix Factor to 0.0"), + (operators.NWChangeMixFactor.bl_idname, 'NUMPAD_1', 'PRESS', True, True, True, (('option', 1.0),), "Mix Factor to 1.0"), + (operators.NWChangeMixFactor.bl_idname, 'ONE', 'PRESS', True, True, True, (('option', 1.0),), "Set Mix Factor to 1.0"), + # CLEAR LABEL (Alt L) + (operators.NWClearLabel.bl_idname, 'L', 'PRESS', False, False, True, (('option', False),), "Clear node labels"), + # MODIFY LABEL (Alt Shift L) + (operators.NWModifyLabels.bl_idname, 'L', 'PRESS', False, True, True, None, "Modify node labels"), + # Copy Label from active to selected + (operators.NWCopyLabel.bl_idname, 'V', 'PRESS', False, True, False, + (('option', 'FROM_ACTIVE'),), "Copy label from active to selected"), + # DETACH OUTPUTS (Alt Shift D) + (operators.NWDetachOutputs.bl_idname, 'D', 'PRESS', False, True, True, None, "Detach outputs"), + # LINK TO OUTPUT NODE (O) + (operators.NWLinkToOutputNode.bl_idname, 'O', 'PRESS', False, False, False, None, "Link to output node"), + # SELECT PARENT/CHILDREN + # Select Children + (operators.NWSelectParentChildren.bl_idname, 'RIGHT_BRACKET', 'PRESS', + False, False, False, (('option', 'CHILD'),), "Select children"), + # Select Parent + (operators.NWSelectParentChildren.bl_idname, 'LEFT_BRACKET', 'PRESS', + False, False, False, (('option', 'PARENT'),), "Select Parent"), + # Add Texture Setup + (operators.NWAddTextureSetup.bl_idname, 'T', 'PRESS', True, False, False, None, "Add texture setup"), + # Add Principled BSDF Texture Setup + (operators.NWAddPrincipledSetup.bl_idname, 'T', 'PRESS', True, True, False, None, "Add Principled texture setup"), + # Reset backdrop + (operators.NWResetBG.bl_idname, 'Z', 'PRESS', False, False, False, None, "Reset backdrop image zoom"), + # Delete unused + (operators.NWDeleteUnused.bl_idname, 'X', 'PRESS', False, False, True, None, "Delete unused nodes"), + # Frame Selected + (operators.NWFrameSelected.bl_idname, 'P', 'PRESS', False, True, False, None, "Frame selected nodes"), + # Swap Links + (operators.NWSwapLinks.bl_idname, 'S', 'PRESS', False, False, True, None, "Swap Links"), + # Reload Images + (operators.NWReloadImages.bl_idname, 'R', 'PRESS', False, False, True, None, "Reload images"), + # Lazy Mix + (operators.NWLazyMix.bl_idname, 'RIGHTMOUSE', 'PRESS', True, True, False, None, "Lazy Mix"), + # Lazy Connect + (operators.NWLazyConnect.bl_idname, 'RIGHTMOUSE', 'PRESS', False, False, True, (('with_menu', False),), "Lazy Connect"), + # Lazy Connect with Menu + (operators.NWLazyConnect.bl_idname, 'RIGHTMOUSE', 'PRESS', False, + True, True, (('with_menu', True),), "Lazy Connect with Socket Menu"), + # Align Nodes + (operators.NWAlignNodes.bl_idname, 'EQUAL', 'PRESS', False, True, + False, None, "Align selected nodes neatly in a row/column"), + # Reset Nodes (Back Space) + (operators.NWResetNodes.bl_idname, 'BACK_SPACE', 'PRESS', False, False, + False, None, "Revert node back to default state, but keep connections"), + # MENUS + ('wm.call_menu', 'W', 'PRESS', False, True, False, (('name', interface.NodeWranglerMenu.bl_idname),), "Node Wrangler menu"), + ('wm.call_menu', 'SLASH', 'PRESS', False, False, False, + (('name', interface.NWAddReroutesMenu.bl_idname),), "Add Reroutes menu"), + ('wm.call_menu', 'NUMPAD_SLASH', 'PRESS', False, False, False, + (('name', interface.NWAddReroutesMenu.bl_idname),), "Add Reroutes menu"), + ('wm.call_menu', 'BACK_SLASH', 'PRESS', False, False, False, + (('name', interface.NWLinkActiveToSelectedMenu.bl_idname),), "Link active to selected (menu)"), + ('wm.call_menu', 'C', 'PRESS', False, True, False, + (('name', interface.NWCopyToSelectedMenu.bl_idname),), "Copy to selected (menu)"), + ('wm.call_menu', 'S', 'PRESS', False, True, False, + (('name', interface.NWSwitchNodeTypeMenu.bl_idname),), "Switch node type menu"), +) + +classes = ( + NWPrincipledPreferences, NWNodeWrangler +) + + +def register(): + from bpy.utils import register_class + for cls in classes: + register_class(cls) + + # keymaps + addon_keymaps.clear() + kc = bpy.context.window_manager.keyconfigs.addon + if kc: + km = kc.keymaps.new(name='Node Editor', space_type="NODE_EDITOR") + for (identifier, key, action, CTRL, SHIFT, ALT, props, nicename) in kmi_defs: + kmi = km.keymap_items.new(identifier, key, action, ctrl=CTRL, shift=SHIFT, alt=ALT) + if props: + for prop, value in props: + setattr(kmi.properties, prop, value) + addon_keymaps.append((km, kmi)) + + +def unregister(): + + # keymaps + for km, kmi in addon_keymaps: + km.keymap_items.remove(kmi) + addon_keymaps.clear() + + from bpy.utils import unregister_class + for cls in classes: + unregister_class(cls) diff --git a/scripts/addons_core/node_wrangler/utils/constants.py b/scripts/addons_core/node_wrangler/utils/constants.py new file mode 100644 index 00000000000..02c4398825c --- /dev/null +++ b/scripts/addons_core/node_wrangler/utils/constants.py @@ -0,0 +1,232 @@ +# SPDX-FileCopyrightText: 2023 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +from collections import namedtuple + + +################# +# rl_outputs: +# list of outputs of Input Render Layer +# with attributes determining if pass is used, +# and MultiLayer EXR outputs names and corresponding render engines +# +# rl_outputs entry = (render_pass, rl_output_name, exr_output_name, in_eevee, in_cycles) +RL_entry = namedtuple('RL_Entry', ['render_pass', 'output_name', 'exr_output_name', 'in_eevee', 'in_cycles']) +rl_outputs = ( + RL_entry('use_pass_ambient_occlusion', 'AO', 'AO', True, True), + RL_entry('use_pass_combined', 'Image', 'Combined', True, True), + RL_entry('use_pass_diffuse_color', 'Diffuse Color', 'DiffCol', False, True), + RL_entry('use_pass_diffuse_direct', 'Diffuse Direct', 'DiffDir', False, True), + RL_entry('use_pass_diffuse_indirect', 'Diffuse Indirect', 'DiffInd', False, True), + RL_entry('use_pass_emit', 'Emit', 'Emit', False, True), + RL_entry('use_pass_environment', 'Environment', 'Env', False, False), + RL_entry('use_pass_glossy_color', 'Glossy Color', 'GlossCol', False, True), + RL_entry('use_pass_glossy_direct', 'Glossy Direct', 'GlossDir', False, True), + RL_entry('use_pass_glossy_indirect', 'Glossy Indirect', 'GlossInd', False, True), + RL_entry('use_pass_indirect', 'Indirect', 'Indirect', False, False), + RL_entry('use_pass_material_index', 'IndexMA', 'IndexMA', False, True), + RL_entry('use_pass_mist', 'Mist', 'Mist', True, True), + RL_entry('use_pass_normal', 'Normal', 'Normal', True, True), + RL_entry('use_pass_object_index', 'IndexOB', 'IndexOB', False, True), + RL_entry('use_pass_shadow', 'Shadow', 'Shadow', False, True), + RL_entry('use_pass_subsurface_color', 'Subsurface Color', 'SubsurfaceCol', True, True), + RL_entry('use_pass_subsurface_direct', 'Subsurface Direct', 'SubsurfaceDir', True, True), + RL_entry('use_pass_subsurface_indirect', 'Subsurface Indirect', 'SubsurfaceInd', False, True), + RL_entry('use_pass_transmission_color', 'Transmission Color', 'TransCol', False, True), + RL_entry('use_pass_transmission_direct', 'Transmission Direct', 'TransDir', False, True), + RL_entry('use_pass_transmission_indirect', 'Transmission Indirect', 'TransInd', False, True), + RL_entry('use_pass_uv', 'UV', 'UV', True, True), + RL_entry('use_pass_vector', 'Speed', 'Vector', False, True), + RL_entry('use_pass_z', 'Z', 'Depth', True, True), +) + +# list of blend types of "Mix" nodes in a form that can be used as 'items' for EnumProperty. +# used list, not tuple for easy merging with other lists. +blend_types = [ + ('MIX', 'Mix', 'Mix Mode'), + ('ADD', 'Add', 'Add Mode'), + ('MULTIPLY', 'Multiply', 'Multiply Mode'), + ('SUBTRACT', 'Subtract', 'Subtract Mode'), + ('SCREEN', 'Screen', 'Screen Mode'), + ('DIVIDE', 'Divide', 'Divide Mode'), + ('DIFFERENCE', 'Difference', 'Difference Mode'), + ('DARKEN', 'Darken', 'Darken Mode'), + ('LIGHTEN', 'Lighten', 'Lighten Mode'), + ('OVERLAY', 'Overlay', 'Overlay Mode'), + ('DODGE', 'Dodge', 'Dodge Mode'), + ('BURN', 'Burn', 'Burn Mode'), + ('HUE', 'Hue', 'Hue Mode'), + ('SATURATION', 'Saturation', 'Saturation Mode'), + ('VALUE', 'Value', 'Value Mode'), + ('COLOR', 'Color', 'Color Mode'), + ('SOFT_LIGHT', 'Soft Light', 'Soft Light Mode'), + ('LINEAR_LIGHT', 'Linear Light', 'Linear Light Mode'), +] + +# list of operations of "Math" nodes in a form that can be used as 'items' for EnumProperty. +# used list, not tuple for easy merging with other lists. +operations = [ + ('ADD', 'Add', 'Add Mode'), + ('SUBTRACT', 'Subtract', 'Subtract Mode'), + ('MULTIPLY', 'Multiply', 'Multiply Mode'), + ('DIVIDE', 'Divide', 'Divide Mode'), + ('MULTIPLY_ADD', 'Multiply Add', 'Multiply Add Mode'), + ('SINE', 'Sine', 'Sine Mode'), + ('COSINE', 'Cosine', 'Cosine Mode'), + ('TANGENT', 'Tangent', 'Tangent Mode'), + ('ARCSINE', 'Arcsine', 'Arcsine Mode'), + ('ARCCOSINE', 'Arccosine', 'Arccosine Mode'), + ('ARCTANGENT', 'Arctangent', 'Arctangent Mode'), + ('ARCTAN2', 'Arctan2', 'Arctan2 Mode'), + ('SINH', 'Hyperbolic Sine', 'Hyperbolic Sine Mode'), + ('COSH', 'Hyperbolic Cosine', 'Hyperbolic Cosine Mode'), + ('TANH', 'Hyperbolic Tangent', 'Hyperbolic Tangent Mode'), + ('POWER', 'Power', 'Power Mode'), + ('LOGARITHM', 'Logarithm', 'Logarithm Mode'), + ('SQRT', 'Square Root', 'Square Root Mode'), + ('INVERSE_SQRT', 'Inverse Square Root', 'Inverse Square Root Mode'), + ('EXPONENT', 'Exponent', 'Exponent Mode'), + ('MINIMUM', 'Minimum', 'Minimum Mode'), + ('MAXIMUM', 'Maximum', 'Maximum Mode'), + ('LESS_THAN', 'Less Than', 'Less Than Mode'), + ('GREATER_THAN', 'Greater Than', 'Greater Than Mode'), + ('SIGN', 'Sign', 'Sign Mode'), + ('COMPARE', 'Compare', 'Compare Mode'), + ('SMOOTH_MIN', 'Smooth Minimum', 'Smooth Minimum Mode'), + ('SMOOTH_MAX', 'Smooth Maximum', 'Smooth Maximum Mode'), + ('FRACT', 'Fraction', 'Fraction Mode'), + ('MODULO', 'Modulo', 'Modulo Mode'), + ('SNAP', 'Snap', 'Snap Mode'), + ('WRAP', 'Wrap', 'Wrap Mode'), + ('PINGPONG', 'Pingpong', 'Pingpong Mode'), + ('ABSOLUTE', 'Absolute', 'Absolute Mode'), + ('ROUND', 'Round', 'Round Mode'), + ('FLOOR', 'Floor', 'Floor Mode'), + ('CEIL', 'Ceil', 'Ceil Mode'), + ('TRUNCATE', 'Truncate', 'Truncate Mode'), + ('RADIANS', 'To Radians', 'To Radians Mode'), + ('DEGREES', 'To Degrees', 'To Degrees Mode'), +] + +# Operations used by the geometry boolean node and join geometry node +geo_combine_operations = [ + ('JOIN', 'Join Geometry', 'Join Geometry Mode'), + ('INTERSECT', 'Intersect', 'Intersect Mode'), + ('UNION', 'Union', 'Union Mode'), + ('DIFFERENCE', 'Difference', 'Difference Mode'), +] + +# in NWBatchChangeNodes additional types/operations. Can be used as 'items' for EnumProperty. +# used list, not tuple for easy merging with other lists. +navs = [ + ('CURRENT', 'Current', 'Leave at current state'), + ('NEXT', 'Next', 'Next blend type/operation'), + ('PREV', 'Prev', 'Previous blend type/operation'), +] + +draw_color_sets = { + "red_white": ( + (1.0, 1.0, 1.0, 0.7), + (1.0, 0.0, 0.0, 0.7), + (0.8, 0.2, 0.2, 1.0) + ), + "green": ( + (0.0, 0.0, 0.0, 1.0), + (0.38, 0.77, 0.38, 1.0), + (0.38, 0.77, 0.38, 1.0) + ), + "yellow": ( + (0.0, 0.0, 0.0, 1.0), + (0.77, 0.77, 0.16, 1.0), + (0.77, 0.77, 0.16, 1.0) + ), + "purple": ( + (0.0, 0.0, 0.0, 1.0), + (0.38, 0.38, 0.77, 1.0), + (0.38, 0.38, 0.77, 1.0) + ), + "grey": ( + (0.0, 0.0, 0.0, 1.0), + (0.63, 0.63, 0.63, 1.0), + (0.63, 0.63, 0.63, 1.0) + ), + "black": ( + (1.0, 1.0, 1.0, 0.7), + (0.0, 0.0, 0.0, 0.7), + (0.2, 0.2, 0.2, 1.0) + ) +} + + +def get_texture_node_types(): + return [ + "ShaderNodeTexBrick", + "ShaderNodeTexChecker", + "ShaderNodeTexEnvironment", + "ShaderNodeTexGradient", + "ShaderNodeTexIES", + "ShaderNodeTexImage", + "ShaderNodeTexMagic", + "ShaderNodeTexMusgrave", + "ShaderNodeTexNoise", + "ShaderNodeTexPointDensity", + "ShaderNodeTexSky", + "ShaderNodeTexVoronoi", + "ShaderNodeTexWave", + "ShaderNodeTexWhiteNoise" + ] + + +def nice_hotkey_name(punc): + # convert the ugly string name into the actual character + nice_name = { + 'LEFTMOUSE': "LMB", + 'MIDDLEMOUSE': "MMB", + 'RIGHTMOUSE': "RMB", + 'WHEELUPMOUSE': "Wheel Up", + 'WHEELDOWNMOUSE': "Wheel Down", + 'WHEELINMOUSE': "Wheel In", + 'WHEELOUTMOUSE': "Wheel Out", + 'ZERO': "0", + 'ONE': "1", + 'TWO': "2", + 'THREE': "3", + 'FOUR': "4", + 'FIVE': "5", + 'SIX': "6", + 'SEVEN': "7", + 'EIGHT': "8", + 'NINE': "9", + 'OSKEY': "Super", + 'RET': "Enter", + 'LINE_FEED': "Enter", + 'SEMI_COLON': ";", + 'PERIOD': ".", + 'COMMA': ",", + 'QUOTE': '"', + 'MINUS': "-", + 'SLASH': "/", + 'BACK_SLASH': "\\", + 'EQUAL': "=", + 'NUMPAD_1': "Numpad 1", + 'NUMPAD_2': "Numpad 2", + 'NUMPAD_3': "Numpad 3", + 'NUMPAD_4': "Numpad 4", + 'NUMPAD_5': "Numpad 5", + 'NUMPAD_6': "Numpad 6", + 'NUMPAD_7': "Numpad 7", + 'NUMPAD_8': "Numpad 8", + 'NUMPAD_9': "Numpad 9", + 'NUMPAD_0': "Numpad 0", + 'NUMPAD_PERIOD': "Numpad .", + 'NUMPAD_SLASH': "Numpad /", + 'NUMPAD_ASTERIX': "Numpad *", + 'NUMPAD_MINUS': "Numpad -", + 'NUMPAD_ENTER': "Numpad Enter", + 'NUMPAD_PLUS': "Numpad +", + } + try: + return nice_name[punc] + except KeyError: + return punc.replace("_", " ").title() diff --git a/scripts/addons_core/node_wrangler/utils/draw.py b/scripts/addons_core/node_wrangler/utils/draw.py new file mode 100644 index 00000000000..1851852cfbf --- /dev/null +++ b/scripts/addons_core/node_wrangler/utils/draw.py @@ -0,0 +1,219 @@ +# SPDX-FileCopyrightText: 2023 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +import bpy +import gpu +from gpu_extras.batch import batch_for_shader +from math import cos, sin, pi + +from .nodes import get_nodes_links, prefs_line_width, abs_node_location, dpi_fac + + +def draw_line(x1, y1, x2, y2, size, colour=(1.0, 1.0, 1.0, 0.7)): + shader = gpu.shader.from_builtin('POLYLINE_SMOOTH_COLOR') + shader.uniform_float("viewportSize", gpu.state.viewport_get()[2:]) + shader.uniform_float("lineWidth", size * prefs_line_width()) + + vertices = ((x1, y1), (x2, y2)) + vertex_colors = ((colour[0] + (1.0 - colour[0]) / 4, + colour[1] + (1.0 - colour[1]) / 4, + colour[2] + (1.0 - colour[2]) / 4, + colour[3] + (1.0 - colour[3]) / 4), + colour) + + batch = batch_for_shader(shader, 'LINE_STRIP', {"pos": vertices, "color": vertex_colors}) + batch.draw(shader) + + +def draw_circle_2d_filled(mx, my, radius, colour=(1.0, 1.0, 1.0, 0.7)): + radius = radius * prefs_line_width() + sides = 12 + vertices = [(radius * cos(i * 2 * pi / sides) + mx, + radius * sin(i * 2 * pi / sides) + my) + for i in range(sides + 1)] + + shader = gpu.shader.from_builtin('UNIFORM_COLOR') + shader.uniform_float("color", colour) + batch = batch_for_shader(shader, 'TRI_FAN', {"pos": vertices}) + batch.draw(shader) + + +def draw_rounded_node_border(node, radius=8, colour=(1.0, 1.0, 1.0, 0.7)): + area_width = bpy.context.area.width + sides = 16 + radius *= prefs_line_width() + + nlocx, nlocy = abs_node_location(node) + + nlocx = (nlocx + 1) * dpi_fac() + nlocy = (nlocy + 1) * dpi_fac() + ndimx = node.dimensions.x + ndimy = node.dimensions.y + + if node.hide: + nlocx += -1 + nlocy += 5 + if node.type == 'REROUTE': + # nlocx += 1 + nlocy -= 1 + ndimx = 0 + ndimy = 0 + radius += 6 + + shader = gpu.shader.from_builtin('UNIFORM_COLOR') + shader.uniform_float("color", colour) + + # Top left corner + mx, my = bpy.context.region.view2d.view_to_region(nlocx, nlocy, clip=False) + vertices = [(mx, my)] + for i in range(sides + 1): + if (4 <= i <= 8): + if mx < area_width: + cosine = radius * cos(i * 2 * pi / sides) + mx + sine = radius * sin(i * 2 * pi / sides) + my + vertices.append((cosine, sine)) + + batch = batch_for_shader(shader, 'TRI_FAN', {"pos": vertices}) + batch.draw(shader) + + # Top right corner + mx, my = bpy.context.region.view2d.view_to_region(nlocx + ndimx, nlocy, clip=False) + vertices = [(mx, my)] + for i in range(sides + 1): + if (0 <= i <= 4): + if mx < area_width: + cosine = radius * cos(i * 2 * pi / sides) + mx + sine = radius * sin(i * 2 * pi / sides) + my + vertices.append((cosine, sine)) + + batch = batch_for_shader(shader, 'TRI_FAN', {"pos": vertices}) + batch.draw(shader) + + # Bottom left corner + mx, my = bpy.context.region.view2d.view_to_region(nlocx, nlocy - ndimy, clip=False) + vertices = [(mx, my)] + for i in range(sides + 1): + if (8 <= i <= 12): + if mx < area_width: + cosine = radius * cos(i * 2 * pi / sides) + mx + sine = radius * sin(i * 2 * pi / sides) + my + vertices.append((cosine, sine)) + + batch = batch_for_shader(shader, 'TRI_FAN', {"pos": vertices}) + batch.draw(shader) + + # Bottom right corner + mx, my = bpy.context.region.view2d.view_to_region(nlocx + ndimx, nlocy - ndimy, clip=False) + vertices = [(mx, my)] + for i in range(sides + 1): + if (12 <= i <= 16): + if mx < area_width: + cosine = radius * cos(i * 2 * pi / sides) + mx + sine = radius * sin(i * 2 * pi / sides) + my + vertices.append((cosine, sine)) + + batch = batch_for_shader(shader, 'TRI_FAN', {"pos": vertices}) + batch.draw(shader) + + # prepare drawing all edges in one batch + vertices = [] + indices = [] + id_last = 0 + + # Left edge + m1x, m1y = bpy.context.region.view2d.view_to_region(nlocx, nlocy, clip=False) + m2x, m2y = bpy.context.region.view2d.view_to_region(nlocx, nlocy - ndimy, clip=False) + if m1x < area_width and m2x < area_width: + vertices.extend([(m2x - radius, m2y), (m2x, m2y), + (m1x, m1y), (m1x - radius, m1y)]) + indices.extend([(id_last, id_last + 1, id_last + 3), + (id_last + 3, id_last + 1, id_last + 2)]) + id_last += 4 + + # Top edge + m1x, m1y = bpy.context.region.view2d.view_to_region(nlocx, nlocy, clip=False) + m2x, m2y = bpy.context.region.view2d.view_to_region(nlocx + ndimx, nlocy, clip=False) + m1x = min(m1x, area_width) + m2x = min(m2x, area_width) + vertices.extend([(m1x, m1y), (m2x, m1y), + (m2x, m1y + radius), (m1x, m1y + radius)]) + indices.extend([(id_last, id_last + 1, id_last + 3), + (id_last + 3, id_last + 1, id_last + 2)]) + id_last += 4 + + # Right edge + m1x, m1y = bpy.context.region.view2d.view_to_region(nlocx + ndimx, nlocy, clip=False) + m2x, m2y = bpy.context.region.view2d.view_to_region(nlocx + ndimx, nlocy - ndimy, clip=False) + if m1x < area_width and m2x < area_width: + vertices.extend([(m1x, m2y), (m1x + radius, m2y), + (m1x + radius, m1y), (m1x, m1y)]) + indices.extend([(id_last, id_last + 1, id_last + 3), + (id_last + 3, id_last + 1, id_last + 2)]) + id_last += 4 + + # Bottom edge + m1x, m1y = bpy.context.region.view2d.view_to_region(nlocx, nlocy - ndimy, clip=False) + m2x, m2y = bpy.context.region.view2d.view_to_region(nlocx + ndimx, nlocy - ndimy, clip=False) + m1x = min(m1x, area_width) + m2x = min(m2x, area_width) + vertices.extend([(m1x, m2y), (m2x, m2y), + (m2x, m1y - radius), (m1x, m1y - radius)]) + indices.extend([(id_last, id_last + 1, id_last + 3), + (id_last + 3, id_last + 1, id_last + 2)]) + + # now draw all edges in one batch + if len(vertices) != 0: + batch = batch_for_shader(shader, 'TRIS', {"pos": vertices}, indices=indices) + batch.draw(shader) + + +def draw_callback_nodeoutline(self, context, mode): + if self.mouse_path: + gpu.state.blend_set('ALPHA') + + nodes, _links = get_nodes_links(context) + + if mode == "LINK": + col_outer = (1.0, 0.2, 0.2, 0.4) + col_inner = (0.0, 0.0, 0.0, 0.5) + col_circle_inner = (0.3, 0.05, 0.05, 1.0) + elif mode == "LINKMENU": + col_outer = (0.4, 0.6, 1.0, 0.4) + col_inner = (0.0, 0.0, 0.0, 0.5) + col_circle_inner = (0.08, 0.15, .3, 1.0) + elif mode == "MIX": + col_outer = (0.2, 1.0, 0.2, 0.4) + col_inner = (0.0, 0.0, 0.0, 0.5) + col_circle_inner = (0.05, 0.3, 0.05, 1.0) + + m1x = self.mouse_path[0][0] + m1y = self.mouse_path[0][1] + m2x = self.mouse_path[-1][0] + m2y = self.mouse_path[-1][1] + + n1 = nodes[context.scene.NWLazySource] + n2 = nodes[context.scene.NWLazyTarget] + + if n1 == n2: + col_outer = (0.4, 0.4, 0.4, 0.4) + col_inner = (0.0, 0.0, 0.0, 0.5) + col_circle_inner = (0.2, 0.2, 0.2, 1.0) + + draw_rounded_node_border(n1, radius=6, colour=col_outer) # outline + draw_rounded_node_border(n1, radius=5, colour=col_inner) # inner + draw_rounded_node_border(n2, radius=6, colour=col_outer) # outline + draw_rounded_node_border(n2, radius=5, colour=col_inner) # inner + + draw_line(m1x, m1y, m2x, m2y, 5, col_outer) # line outline + draw_line(m1x, m1y, m2x, m2y, 2, col_inner) # line inner + + # circle outline + draw_circle_2d_filled(m1x, m1y, 7, col_outer) + draw_circle_2d_filled(m2x, m2y, 7, col_outer) + + # circle inner + draw_circle_2d_filled(m1x, m1y, 5, col_circle_inner) + draw_circle_2d_filled(m2x, m2y, 5, col_circle_inner) + + gpu.state.blend_set('NONE') diff --git a/scripts/addons_core/node_wrangler/utils/nodes.py b/scripts/addons_core/node_wrangler/utils/nodes.py new file mode 100644 index 00000000000..665c10010aa --- /dev/null +++ b/scripts/addons_core/node_wrangler/utils/nodes.py @@ -0,0 +1,288 @@ +# SPDX-FileCopyrightText: 2023 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +import bpy +from bpy_extras.node_utils import connect_sockets +from math import hypot, inf + + +def force_update(context): + context.space_data.node_tree.update_tag() + + +def dpi_fac(): + prefs = bpy.context.preferences.system + return prefs.dpi / 72 + + +def prefs_line_width(): + prefs = bpy.context.preferences.system + return prefs.pixel_size + + +def node_mid_pt(node, axis): + if axis == 'x': + d = node.location.x + (node.dimensions.x / 2) + elif axis == 'y': + d = node.location.y - (node.dimensions.y / 2) + else: + d = 0 + return d + + +def autolink(node1, node2, links): + available_inputs = [inp for inp in node2.inputs if inp.enabled] + available_outputs = [outp for outp in node1.outputs if outp.enabled] + for outp in available_outputs: + for inp in available_inputs: + if not inp.is_linked and inp.name == outp.name: + connect_sockets(outp, inp) + return True + + for outp in available_outputs: + for inp in available_inputs: + if not inp.is_linked and inp.type == outp.type: + connect_sockets(outp, inp) + return True + + # force some connection even if the type doesn't match + if available_outputs: + for inp in available_inputs: + if not inp.is_linked: + connect_sockets(available_outputs[0], inp) + return True + + # even if no sockets are open, force one of matching type + for outp in available_outputs: + for inp in available_inputs: + if inp.type == outp.type: + connect_sockets(outp, inp) + return True + + # do something! + for outp in available_outputs: + for inp in available_inputs: + connect_sockets(outp, inp) + return True + + print("Could not make a link from " + node1.name + " to " + node2.name) + return False + + +def abs_node_location(node): + abs_location = node.location + if node.parent is None: + return abs_location + return abs_location + abs_node_location(node.parent) + + +def node_at_pos(nodes, context, event): + nodes_under_mouse = [] + target_node = None + + store_mouse_cursor(context, event) + x, y = context.space_data.cursor_location + + # Make a list of each corner (and middle of border) for each node. + # Will be sorted to find nearest point and thus nearest node + node_points_with_dist = [] + for node in nodes: + skipnode = False + if node.type != 'FRAME': # no point trying to link to a frame node + dimx = node.dimensions.x / dpi_fac() + dimy = node.dimensions.y / dpi_fac() + locx, locy = abs_node_location(node) + + if not skipnode: + node_points_with_dist.append([node, hypot(x - locx, y - locy)]) # Top Left + node_points_with_dist.append([node, hypot(x - (locx + dimx), y - locy)]) # Top Right + node_points_with_dist.append([node, hypot(x - locx, y - (locy - dimy))]) # Bottom Left + node_points_with_dist.append([node, hypot(x - (locx + dimx), y - (locy - dimy))]) # Bottom Right + + node_points_with_dist.append([node, hypot(x - (locx + (dimx / 2)), y - locy)]) # Mid Top + node_points_with_dist.append([node, hypot(x - (locx + (dimx / 2)), y - (locy - dimy))]) # Mid Bottom + node_points_with_dist.append([node, hypot(x - locx, y - (locy - (dimy / 2)))]) # Mid Left + node_points_with_dist.append([node, hypot(x - (locx + dimx), y - (locy - (dimy / 2)))]) # Mid Right + + nearest_node = sorted(node_points_with_dist, key=lambda k: k[1])[0][0] + + for node in nodes: + if node.type != 'FRAME' and skipnode == False: + locx, locy = abs_node_location(node) + dimx = node.dimensions.x / dpi_fac() + dimy = node.dimensions.y / dpi_fac() + if (locx <= x <= locx + dimx) and \ + (locy - dimy <= y <= locy): + nodes_under_mouse.append(node) + + if len(nodes_under_mouse) == 1: + if nodes_under_mouse[0] != nearest_node: + target_node = nodes_under_mouse[0] # use the node under the mouse if there is one and only one + else: + target_node = nearest_node # else use the nearest node + else: + target_node = nearest_node + return target_node + + +def store_mouse_cursor(context, event): + space = context.space_data + v2d = context.region.view2d + tree = space.edit_tree + + # convert mouse position to the View2D for later node placement + if context.region.type == 'WINDOW': + space.cursor_location_from_region(event.mouse_region_x, event.mouse_region_y) + else: + space.cursor_location = tree.view_center + + +def get_nodes_links(context): + tree = context.space_data.edit_tree + return tree.nodes, tree.links + + +def get_internal_socket(socket): + # get the internal socket from a socket inside or outside the group + node = socket.node + if node.type == 'GROUP_OUTPUT': + iterator = node.id_data.interface.items_tree + elif node.type == 'GROUP_INPUT': + iterator = node.id_data.interface.items_tree + elif hasattr(node, "node_tree"): + iterator = node.node_tree.interface.items_tree + else: + return None + + for s in iterator: + if s.identifier == socket.identifier: + return s + return iterator[0] + + +def get_group_output_node(tree, output_node_type='GROUP_OUTPUT'): + for node in tree.nodes: + if node.type == output_node_type and node.is_active_output: + return node + + +def get_output_location(tree): + # get right-most location + sorted_by_xloc = (sorted(tree.nodes, key=lambda x: x.location.x)) + max_xloc_node = sorted_by_xloc[-1] + + # get average y location + sum_yloc = 0 + for node in tree.nodes: + sum_yloc += node.location.y + + loc_x = max_xloc_node.location.x + max_xloc_node.dimensions.x + 80 + loc_y = sum_yloc / len(tree.nodes) + return loc_x, loc_y + + +def nw_check(cls, context): + space = context.space_data + if space.type != 'NODE_EDITOR': + cls.poll_message_set("Current editor is not a node editor.") + return False + if space.node_tree is None: + cls.poll_message_set("No node tree was found in the current node editor.") + return False + if space.node_tree.library is not None: + cls.poll_message_set("Current node tree is linked from another .blend file.") + return False + return True + + +def nw_check_not_empty(cls, context): + if not context.space_data.edit_tree.nodes: + cls.poll_message_set("Current node tree does not contain any nodes.") + return False + return True + + +def nw_check_active(cls, context): + if context.active_node is None or not context.active_node.select: + cls.poll_message_set("No active node.") + return False + return True + + +def nw_check_selected(cls, context, min=1, max=inf): + num_selected = len(context.selected_nodes) + if num_selected < min: + if min > 1: + cls.poll_message_set(f"At least {min} nodes must be selected.") + else: + cls.poll_message_set(f"At least {min} node must be selected.") + return False + if num_selected > max: + cls.poll_message_set(f"{num_selected} nodes are selected, but this operator can only work on {max}.") + return False + return True + + +def nw_check_space_type(cls, context, types): + if context.space_data.tree_type not in types: + tree_types_str = ", ".join(t.split('NodeTree')[0].lower() for t in sorted(types)) + cls.poll_message_set("Current node tree type not supported.\n" + "Should be one of " + tree_types_str + ".") + return False + return True + + +def nw_check_node_type(cls, context, type, invert=False): + if invert and context.active_node.type == type: + cls.poll_message_set(f"Active node should be not of type {type}.") + return False + elif not invert and context.active_node.type != type: + cls.poll_message_set(f"Active node should be of type {type}.") + return False + return True + + +def nw_check_visible_outputs(cls, context): + if not any(is_visible_socket(out) for out in context.active_node.outputs): + cls.poll_message_set("Current node has no visible outputs.") + return False + return True + + +def nw_check_viewer_node(cls): + for img in bpy.data.images: + # False if not connected or connected but no image + if (img.source == 'VIEWER' + and len(img.render_slots) == 0 + and sum(img.size) > 0): + return True + cls.poll_message_set("Viewer image not found.") + return False + + +def get_first_enabled_output(node): + for output in node.outputs: + if output.enabled: + return output + else: + return node.outputs[0] + + +def is_visible_socket(socket): + return not socket.hide and socket.enabled and socket.type != 'CUSTOM' + + +class NWBase: + @classmethod + def poll(cls, context): + return nw_check(cls, context) + + +class NWBaseMenu: + @classmethod + def poll(cls, context): + space = context.space_data + return (space.type == 'NODE_EDITOR' + and space.node_tree is not None + and space.node_tree.library is None) diff --git a/scripts/addons_core/node_wrangler/utils/paths.py b/scripts/addons_core/node_wrangler/utils/paths.py new file mode 100644 index 00000000000..7e75ef0b2f0 --- /dev/null +++ b/scripts/addons_core/node_wrangler/utils/paths.py @@ -0,0 +1,169 @@ +# SPDX-FileCopyrightText: 2023 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +from os import path +import re + + +def split_into_components(fname): + """ + Split filename into components + 'WallTexture_diff_2k.002.jpg' -> ['Wall', 'Texture', 'diff', 'k'] + """ + # Remove extension + fname = path.splitext(fname)[0] + # Remove digits + fname = "".join(i for i in fname if not i.isdigit()) + # Separate CamelCase by space + fname = re.sub(r"([a-z])([A-Z])", r"\g<1> \g<2>", fname) + # Replace common separators with SPACE + separators = ["_", ".", "-", "__", "--", "#"] + for sep in separators: + fname = fname.replace(sep, " ") + + components = fname.split(" ") + components = [c.lower() for c in components] + return components + + +def remove_common_prefix(names_to_tag_lists): + """ + Accepts a mapping of file names to tag lists that should be used for socket + matching. + + This function modifies the provided mapping so that any common prefix + between all the tag lists is removed. + + Returns true if some prefix was removed, false otherwise. + """ + if not names_to_tag_lists: + return False + sample_tags = next(iter(names_to_tag_lists.values())) + if not sample_tags: + return False + + common_prefix = sample_tags[0] + for tag_list in names_to_tag_lists.values(): + if tag_list[0] != common_prefix: + return False + + for name, tag_list in names_to_tag_lists.items(): + names_to_tag_lists[name] = tag_list[1:] + return True + + +def remove_common_suffix(names_to_tag_lists): + """ + Accepts a mapping of file names to tag lists that should be used for socket + matching. + + This function modifies the provided mapping so that any common suffix + between all the tag lists is removed. + + Returns true if some suffix was removed, false otherwise. + """ + if not names_to_tag_lists: + return False + sample_tags = next(iter(names_to_tag_lists.values())) + if not sample_tags: + return False + + common_suffix = sample_tags[-1] + for tag_list in names_to_tag_lists.values(): + if tag_list[-1] != common_suffix: + return False + + for name, tag_list in names_to_tag_lists.items(): + names_to_tag_lists[name] = tag_list[:-1] + return True + + +def files_to_clean_file_names_for_sockets(files, sockets): + """ + Accepts a list of files and a list of sockets. + + Returns a mapping from file names to tag lists that should be used for + classification. + + A file is something that we can do x.name on to figure out the file name. + + A socket is a tuple containing: + * name + * list of tags + * a None field where the selected file name will go later. Ignored by us. + """ + + names_to_tag_lists = {} + for file in files: + names_to_tag_lists[file.name] = split_into_components(file.name) + + all_tags = set() + for socket in sockets: + socket_tags = socket[1] + all_tags.update(socket_tags) + + while len(names_to_tag_lists) > 1: + something_changed = False + + # Common prefixes / suffixes provide zero information about what file + # should go to which socket, but they can confuse the mapping. So we get + # rid of them here. + something_changed |= remove_common_prefix(names_to_tag_lists) + something_changed |= remove_common_suffix(names_to_tag_lists) + + # Names matching zero tags provide no value, remove those + names_to_remove = set() + for name, tag_list in names_to_tag_lists.items(): + match_found = False + for tag in tag_list: + if tag in all_tags: + match_found = True + + if not match_found: + names_to_remove.add(name) + + for name_to_remove in names_to_remove: + del names_to_tag_lists[name_to_remove] + something_changed = True + + if not something_changed: + break + + return names_to_tag_lists + + +def match_files_to_socket_names(files, sockets): + """ + Given a list of files and a list of sockets, match file names to sockets. + + A file is something that you can get a file name out of using x.name. + + After this function returns, all possible sockets have had their file names + filled in. Sockets without any matches will not get their file names + changed. + + Sockets list format. Note that all file names are initially expected to be + None. Tags are strings, as are the socket names: [ + [ + socket_name, [tags], Optional[file_name] + ] + ] + """ + + names_to_tag_lists = files_to_clean_file_names_for_sockets(files, sockets) + + for sname in sockets: + for name, tag_list in names_to_tag_lists.items(): + if sname[0] == "Normal": + # Blender wants GL normals, not DX (DirectX) ones: + # https://www.reddit.com/r/blender/comments/rbuaua/texture_contains_normaldx_and_normalgl_files/ + if 'dx' in tag_list: + continue + if 'directx' in tag_list: + continue + + matches = set(sname[1]).intersection(set(tag_list)) + if matches: + sname[2] = name + break diff --git a/scripts/addons_core/node_wrangler/utils/paths_test.py b/scripts/addons_core/node_wrangler/utils/paths_test.py new file mode 100755 index 00000000000..3fcff13af94 --- /dev/null +++ b/scripts/addons_core/node_wrangler/utils/paths_test.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2023 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +# pylint: disable=missing-function-docstring +# pylint: disable=missing-class-docstring + +import unittest +from dataclasses import dataclass + +# XXX Not really nice, but that hack is needed to allow execution of that test +# from both automated CTest and by directly running the file manually. +if __name__ == "__main__": + from paths import match_files_to_socket_names +else: + from .paths import match_files_to_socket_names + + +# From NWPrincipledPreferences 2023-01-06 +TAGS_DISPLACEMENT = "displacement displace disp dsp height heightmap".split(" ") +TAGS_BASE_COLOR = "diffuse diff albedo base col color basecolor".split(" ") +TAGS_METALLIC = "metallic metalness metal mtl".split(" ") +TAGS_SPECULAR = "specularity specular spec spc".split(" ") +TAGS_ROUGHNESS = "roughness rough rgh".split(" ") +TAGS_GLOSS = "gloss glossy glossiness".split(" ") +TAGS_NORMAL = "normal nor nrm nrml norm".split(" ") +TAGS_BUMP = "bump bmp".split(" ") +TAGS_TRANSMISSION = "transmission transparency".split(" ") +TAGS_EMISSION = "emission emissive emit".split(" ") +TAGS_ALPHA = "alpha opacity".split(" ") +TAGS_AMBIENT_OCCLUSION = "ao ambient occlusion".split(" ") + + +@dataclass +class MockFile: + name: str + + +def sockets_fixture(): + return [ + ["Displacement", TAGS_DISPLACEMENT, None], + ["Base Color", TAGS_BASE_COLOR, None], + ["Metallic", TAGS_METALLIC, None], + ["Specular", TAGS_SPECULAR, None], + ["Roughness", TAGS_ROUGHNESS + TAGS_GLOSS, None], + ["Normal", TAGS_NORMAL + TAGS_BUMP, None], + ["Transmission Weight", TAGS_TRANSMISSION, None], + ["Emission Color", TAGS_EMISSION, None], + ["Alpha", TAGS_ALPHA, None], + ["Ambient Occlusion", TAGS_AMBIENT_OCCLUSION, None], + ] + + +def assert_sockets(asserter, sockets, expected): + checked_sockets = set() + errors = [] + for socket_name, expected_path in expected.items(): + if isinstance(expected_path, str): + expected_path = [expected_path] + + socket_found = False + for socket in sockets: + if socket[0] != socket_name: + continue + socket_found = True + + actual_path = socket[2] + if actual_path not in expected_path: + errors.append( + f"{socket_name:12}: Got {actual_path} but expected {expected_path}" + ) + checked_sockets.add(socket_name) + break + asserter.assertTrue(socket_found) + asserter.assertCountEqual([], errors) + + for socket in sockets: + if socket[0] in checked_sockets: + continue + asserter.assertEqual(socket[2], None) + + +class TestPutFileNamesInSockets(unittest.TestCase): + def test_no_files_selected(self): + sockets = sockets_fixture() + match_files_to_socket_names([], sockets) + + assert_sockets(self, sockets, {}) + + def test_weird_filename(self): + sockets = sockets_fixture() + match_files_to_socket_names( + [MockFile(""), MockFile(".jpg"), MockFile(" .png"), MockFile("...")], + sockets, + ) + + assert_sockets(self, sockets, {}) + + def test_poliigon(self): + """Texture from: https://www.poliigon.com/texture/metal-spotty-discoloration-001/3225""" + + # NOTE: These files all have directory prefixes. That's on purpose. Files + # without directory prefixes are tested in test_ambientcg_metal(). + files = [ + MockFile("d/MetalSpottyDiscoloration001_COL_2K_METALNESS.jpg"), + MockFile("d/MetalSpottyDiscoloration001_Cube.jpg"), + MockFile("d/MetalSpottyDiscoloration001_DISP16_2K_METALNESS.tif"), + MockFile("d/MetalSpottyDiscoloration001_DISP_2K_METALNESS.jpg"), + MockFile("d/MetalSpottyDiscoloration001_Flat.jpg"), + MockFile("d/MetalSpottyDiscoloration001_METALNESS_2K_METALNESS.jpg"), + MockFile("d/MetalSpottyDiscoloration001_NRM16_2K_METALNESS.tif"), + MockFile("d/MetalSpottyDiscoloration001_NRM_2K_METALNESS.jpg"), + MockFile("d/MetalSpottyDiscoloration001_ROUGHNESS_2K_METALNESS.jpg"), + MockFile("d/MetalSpottyDiscoloration001_Sphere.jpg"), + ] + sockets = sockets_fixture() + match_files_to_socket_names(files, sockets) + + assert_sockets( + self, + sockets, + { + "Base Color": "d/MetalSpottyDiscoloration001_COL_2K_METALNESS.jpg", + "Displacement": [ + "d/MetalSpottyDiscoloration001_DISP16_2K_METALNESS.tif", + "d/MetalSpottyDiscoloration001_DISP_2K_METALNESS.jpg", + ], + "Metallic": "d/MetalSpottyDiscoloration001_METALNESS_2K_METALNESS.jpg", + "Normal": [ + "d/MetalSpottyDiscoloration001_NRM16_2K_METALNESS.tif", + "d/MetalSpottyDiscoloration001_NRM_2K_METALNESS.jpg", + ], + "Roughness": "d/MetalSpottyDiscoloration001_ROUGHNESS_2K_METALNESS.jpg", + }, + ) + + def test_ambientcg(self): + """Texture from: https://ambientcg.com/view?id=MetalPlates003""" + + # NOTE: These files have no directory prefix. That's on purpose. Files + # with directory prefixes are tested in test_poliigon_metal(). + files = [ + MockFile("MetalPlates001_1K-JPG.usda"), + MockFile("MetalPlates001_1K-JPG.usdc"), + MockFile("MetalPlates001_1K_Color.jpg"), + MockFile("MetalPlates001_1K_Displacement.jpg"), + MockFile("MetalPlates001_1K_Metalness.jpg"), + MockFile("MetalPlates001_1K_NormalDX.jpg"), + MockFile("MetalPlates001_1K_NormalGL.jpg"), + MockFile("MetalPlates001_1K_Roughness.jpg"), + MockFile("MetalPlates001_PREVIEW.jpg"), + ] + sockets = sockets_fixture() + match_files_to_socket_names(files, sockets) + + assert_sockets( + self, + sockets, + { + "Base Color": "MetalPlates001_1K_Color.jpg", + "Displacement": "MetalPlates001_1K_Displacement.jpg", + "Metallic": "MetalPlates001_1K_Metalness.jpg", + # Blender wants GL normals: + # https://www.reddit.com/r/blender/comments/rbuaua/texture_contains_normaldx_and_normalgl_files/ + "Normal": "MetalPlates001_1K_NormalGL.jpg", + "Roughness": "MetalPlates001_1K_Roughness.jpg", + }, + ) + + def test_3dtextures_me(self): + """Texture from: https://3dtextures.me/2022/05/13/metal-006/""" + + files = [ + MockFile("Material_2079.jpg"), + MockFile("Metal_006_ambientOcclusion.jpg"), + MockFile("Metal_006_basecolor.jpg"), + MockFile("Metal_006_height.png"), + MockFile("Metal_006_metallic.jpg"), + MockFile("Metal_006_normal.jpg"), + MockFile("Metal_006_roughness.jpg"), + ] + sockets = sockets_fixture() + match_files_to_socket_names(files, sockets) + + assert_sockets( + self, + sockets, + { + "Ambient Occlusion": "Metal_006_ambientOcclusion.jpg", + "Base Color": "Metal_006_basecolor.jpg", + "Displacement": "Metal_006_height.png", + "Metallic": "Metal_006_metallic.jpg", + "Normal": "Metal_006_normal.jpg", + "Roughness": "Metal_006_roughness.jpg", + }, + ) + + def test_polyhaven(self): + """Texture from: https://polyhaven.com/a/rusty_metal_02""" + + files = [ + MockFile("rusty_metal_02_ao_1k.jpg"), + MockFile("rusty_metal_02_arm_1k.jpg"), + MockFile("rusty_metal_02_diff_1k.jpg"), + MockFile("rusty_metal_02_disp_1k.png"), + MockFile("rusty_metal_02_nor_dx_1k.exr"), + MockFile("rusty_metal_02_nor_gl_1k.exr"), + MockFile("rusty_metal_02_rough_1k.exr"), + MockFile("rusty_metal_02_spec_1k.png"), + ] + sockets = sockets_fixture() + match_files_to_socket_names(files, sockets) + + assert_sockets( + self, + sockets, + { + "Ambient Occlusion": "rusty_metal_02_ao_1k.jpg", + "Base Color": "rusty_metal_02_diff_1k.jpg", + "Displacement": "rusty_metal_02_disp_1k.png", + "Normal": "rusty_metal_02_nor_gl_1k.exr", + "Roughness": "rusty_metal_02_rough_1k.exr", + "Specular": "rusty_metal_02_spec_1k.png", + }, + ) + + def test_texturecan(self): + """Texture from: https://www.texturecan.com/details/67/""" + + files = [ + MockFile("metal_0010_ao_1k.jpg"), + MockFile("metal_0010_color_1k.jpg"), + MockFile("metal_0010_height_1k.png"), + MockFile("metal_0010_metallic_1k.jpg"), + MockFile("metal_0010_normal_directx_1k.png"), + MockFile("metal_0010_normal_opengl_1k.png"), + MockFile("metal_0010_roughness_1k.jpg"), + ] + sockets = sockets_fixture() + match_files_to_socket_names(files, sockets) + + assert_sockets( + self, + sockets, + { + "Ambient Occlusion": "metal_0010_ao_1k.jpg", + "Base Color": "metal_0010_color_1k.jpg", + "Displacement": "metal_0010_height_1k.png", + "Metallic": "metal_0010_metallic_1k.jpg", + "Normal": "metal_0010_normal_opengl_1k.png", + "Roughness": "metal_0010_roughness_1k.jpg", + }, + ) + + def test_single_file_good(self): + """Regression test for https://projects.blender.org/blender/blender-addons/issues/104573""" + + files = [ + MockFile("banana-color.webp"), + ] + sockets = sockets_fixture() + match_files_to_socket_names(files, sockets) + + assert_sockets( + self, + sockets, + { + "Base Color": "banana-color.webp", + }, + ) + + def test_single_file_bad(self): + """Regression test for https://projects.blender.org/blender/blender-addons/issues/104573""" + + files = [ + MockFile("README-banana.txt"), + ] + sockets = sockets_fixture() + match_files_to_socket_names(files, sockets) + + assert_sockets( + self, + sockets, + {}, + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2)