Files
blender-portable-repo/extensions/blender_org/matbatchtools/__init__.py
T
2026-03-17 14:30:01 -06:00

2696 lines
138 KiB
Python

import mathutils
import bpy
import hashlib
bl_info = {
"name": "Material Batch Tools",
"description": "Batch tools for quickly modifying, copying, and pasting nodes on all materials in selected objects",
"author": "Theanine3D",
"version": (2, 2, 1),
"blender": (3, 0, 0),
"category": "Material",
"location": "Properties -> Material Properties",
"support": "COMMUNITY"
}
# PROPERTY DEFINITIONS
bake_node_preset = {
"image": "",
"interpolation": "Linear",
"projection": "FLAT",
"projection_blend": 0.0,
"extension": "REPEAT",
"name": "Bake Target Node"
}
node_unify_settings = {
"name": "",
"type": "",
"material": ""
}
class MatBatchProperties(bpy.types.PropertyGroup):
BakeTargetNodeColorEnable: bpy.props.BoolProperty(
name="Enable", description="Enable or disable the optional color decoration for the Bake Target Node", default=True)
BakeTargetNodeColor: bpy.props.FloatVectorProperty(
name="Color", subtype="COLOR", description="Color to use for the Bake Target Node. This is purely cosmetic - it just makes the node easier to find", default=(0.52, 0.145, 0.152), size=3, min=0, max=1)
UVMapNodeTarget: bpy.props.StringProperty(
name="UV Map", description="Name of the UV map to set in the UV Map node", default="UVMap", maxlen=64)
UVMapNodeExtensionFilter: bpy.props.StringProperty(
name="Filter", description="Only the Image Texture nodes that are set to this file format will be modified", default="PNG", maxlen=20)
UVSlotIndex: bpy.props.EnumProperty(
name="UV Slot", description="The UV Slot that will be modified by the buttons below", items=[("1", 'UV Slot 1', 'The first UV slot in the object mesh(es)', 1), ("2", 'UV Slot 2', 'The second UV slot in the object mesh(es)', 2)], default=1)
VCName: bpy.props.StringProperty(
name="Name", description="Name to set in Vertex Color slot 1", default="Col", maxlen=64)
AlphaBlendMode: bpy.props.EnumProperty(
name="Blend Mode", description="The Blend Mode and Shadow Mode to set in the material(s). If set to Alpha Blend, Shadow Mode will be set to Alpha Clip", items=[("OPAQUE", 'Opaque', 'No transparency', 0), ("CLIP", 'Alpha Clip', 'Pixels will be either 100 percent transparent or 100 percent opaque', 1), ("BLEND", 'Alpha Blend', 'Pixels will be anywhere between 0 to 100 percent transparent', 2)], default=0)
AlphaBlendFilter: bpy.props.EnumProperty(
name="Filter", description="Only materials that satisfy this filter will be modified", items=[("NOFILTER", 'None', 'No filter. All materials will be modified', 0), ("PRINCIPLEDNODE", 'Principled BSDF Alpha', 'There must be a Principled node in the material, and its "Alpha" input is either connected to another node or is less than 1.000', 1), ("TRANSPARENTNODE", 'Transparent BSDF', 'There must be at least one Transparent BSDF in the material', 2)], default=0)
AlphaThreshold: bpy.props.FloatProperty(
name="Clip Threshold", subtype="FACTOR", description="This setting is used only by Alpha Clip", default=0.5, min=0.0, max=1.0)
AlphaPrincipledRemove: bpy.props.BoolProperty(
name="Remove Principled BSDF Alpha", description="If this option is enabled, and the Blend Mode is set to Opaque, the Principled BSDF's 'Alpha' input will be disconnected, and its value will be set to 1.0", default=False)
SavedNodeName: bpy.props.StringProperty(
name="Copied Node", description="The name of the node from which settings were copied", default="", maxlen=200)
SavedNodeType: bpy.props.StringProperty(
name="Copied Node Type", description="The type of the node from which settings were copied", default="", maxlen=200)
UnifyFilterLabel: bpy.props.StringProperty(
name="Label Filter", description="If specified, the Unify button will only affect any nodes that have this custom label. Case sensitive! Leave blank if you want to alter ALL nodes of the same type as the template node", default="", maxlen=100)
SwitchShaderTarget: bpy.props.EnumProperty(
name="Shader", description="The shader to switch in all materials in all selected objects to. For example, if you select Principled, any Emission nodes will be switched to Principled", items=[("EMISSION", 'Emission', 'Fullbright / shadeless shader - not affected by scene lighting', 0), ("BSDF_PRINCIPLED", 'Principled BSDF', 'Standard shader in Blender, affected by scene lighting', 1)], default=0)
CopiedTexture: bpy.props.StringProperty(
name="Copied Texture Name", description="The name of the active image texture copied from a selected face", default="", maxlen=200)
Template: bpy.props.EnumProperty(
name="Template", description="The node graph template to apply to all materials in all selected objects", items=[("ECT", 'Emissive + Color + Texture', 'Blends vertex color onto texture, if one exists', 0),
("EC", 'Emissive + Color', 'Ignores image textures completely', 1),
("ACCT", 'Alpha Clip + Color + Texture', 'Transparency via alpha clip, and emission', 2),
("ACT", 'Additive + Color + Texture', 'Combines transparency and emission for an additive effect', 3),
("AC", 'Additive + Color', 'Combines transparency and emission for an additive effect', 4),
("PT", 'Principled + Texture', 'Principled shading, with texture', 5),
("PC", 'Principled + Color', 'Principled shading, with vertex color', 6),
("HDRT", 'HDR Lightmap', "Emissive but with an HDR lightmap applied for baked lighting. Your HDR's UV map must be named 'lightmap', and your HDR's filename must contain either 'light_', '.hdr' or '.exr' to be detected automatically", 7),
("PP", 'Mirror UV', "Mirroring / ping pong effect applied to any UV Maps, on both X and Y axis", 8),
("NO_PP", 'Unmirror UV', "Removes the mirror / ping pong effect from any UV Maps, on both X and Y axis", 9),
], default=0)
SkipTexture: bpy.props.StringProperty(
name="Skip Texture", description="Any texture containing this string in its filename will NOT be assigned in any image texture when applying a material template (optional - leave blank if unneeded)", default="", maxlen=200)
BackfaceCamera: bpy.props.BoolProperty(
name="Camera", description="Affects whether backface culling is enabled for the active camera. In Blender 4.1 or lower, this setting controls the legacy 'Backface Culling' setting", default=True)
BackfaceShadow: bpy.props.BoolProperty(
name="Shadow", description="Affects whether backface culling is enabled for shadows. Only supported in Blender 4.2 or higher", default=True)
BackfaceLightProbe: bpy.props.BoolProperty(
name="Light Probe Volume", description="Affects whether backface culling is enabled for light probe volume capture. Only supported in Blender 4.2 or higher", default=True)
IsolateCollection: bpy.props.BoolProperty(
name="Isolate to Collection", description="If enabled, any isolated meshes are moved to a separate collection for easier finding", default=True)
IsolateTrait: bpy.props.EnumProperty(
name="Trait", description="The trait to look for in materials, in order to isolate their assigned faces into a separate object", items=[("transparent", 'Transparent', "Material uses alpha transparency, via Transparent BSDF or Principled BSDF's alpha slot.", 0),
("emissive", 'Emissive', "Material uses Emission shader or Principled BSDF's Emission slot", 1),
("animated", 'Animated', "Material uses an Image Sequence node, or contains animation data from keyframes and/or drivers", 2),
], default=0)
# FUNCTION DEFINITIONS
def display_msg_box(message="", title="Info", icon='INFO'):
''' Open a pop-up message box to notify the user of something '''
''' Example: '''
''' display_msg_box("This is a message", "This is a custom title", "ERROR") '''
def draw(self, context):
lines = message.split("\n")
for line in lines:
self.layout.label(text=line)
print(line)
bpy.context.window_manager.popup_menu(draw, title=title, icon=icon)
def update_alpha_settings(mat, alpha_mode, shadow_mode, alpha_threshold):
if bpy.app.version >= (4, 2, 0):
if alpha_mode != "CLIP":
# Remove the Math "Greater Than" node if one exists
for node in bpy.data.materials[mat].node_tree.nodes:
if node.type == 'MATH' and node.operation == "GREATER_THAN":
bpy.data.materials[mat].node_tree.nodes.remove(node)
break
if alpha_mode == "BLEND":
alpha_mode = "BLENDED"
elif alpha_mode == "CLIP":
greaterthan_node = None
img_tex_node = None
principled_node = None
mix_shader_node = None
# Search for existing nodes first
for node in bpy.data.materials[mat].node_tree.nodes:
if node.type == 'MATH' and node.operation == "GREATER_THAN":
greaterthan_node = node
continue
elif node.type == 'TEX_IMAGE' and node.image:
img_tex_node = node
continue
elif node.type == 'MIX_SHADER':
mix_shader_node = node
continue
elif node.type == "BSDF_PRINCIPLED":
principled_node = node
continue
if img_tex_node is not None and (principled_node is not None or mix_shader_node is not None):
if greaterthan_node is None:
greaterthan_node = bpy.data.materials[mat].node_tree.nodes.new(type='ShaderNodeMath')
greaterthan_node.operation = "GREATER_THAN"
greaterthan_node.location = (img_tex_node.location.x + 100, img_tex_node.location.y - 286)
bpy.data.materials[mat].node_tree.links.new(img_tex_node.outputs[1], greaterthan_node.inputs[0])
if principled_node is not None:
bpy.data.materials[mat].node_tree.links.new(greaterthan_node.outputs[0], principled_node.inputs[4])
elif mix_shader_node is not None:
bpy.data.materials[mat].node_tree.links.new(greaterthan_node.outputs[0], mix_shader_node.inputs[0])
alpha_mode = "DITHERED"
else:
alpha_mode = "DITHERED"
bpy.data.materials[mat].surface_render_method = alpha_mode
else:
bpy.data.materials[mat].blend_method = alpha_mode
bpy.data.materials[mat].shadow_method = shadow_mode
bpy.data.materials[mat].alpha_threshold = alpha_threshold
def recursive_node_search(startnode, end_node_type):
'''Searches into a node's links for a specific node type. Search ends if a node of the specified type is found, or if .'''
endnode = None
connecting_nodes = list()
for x in range(0, 5, 1):
for i in startnode.inputs:
if len(startnode.inputs) >= (x + 1):
for l in startnode.inputs[x].links:
connecting_nodes.append(
startnode.inputs[x].links[x].from_node)
else:
break
while endnode == None:
for x in range(0, 5, 1):
for n in connecting_nodes:
for i in n.inputs:
if len(startnode.inputs) >= (x + 1):
if len(n.inputs[x].links) > 0:
for l in n.inputs[x].links:
if n.inputs[x].links[x].from_node.type == end_node_type:
endnode = n.inputs[x].links[x].from_node
return endnode
else:
connecting_nodes.append(
n.inputs[x].links[x].from_node)
else:
continue
connecting_nodes.remove(n)
if len(connecting_nodes) < 2:
if len(connecting_nodes) < 1:
return None
elif len(connecting_nodes[0].inputs.links) < 1:
return None
else:
continue
def check_for_selected(objectOnly=False):
list_of_mats = set()
# Check if any objects are selected.
if len(bpy.context.selected_objects) > 0:
# Check if we're only checking for selected objects, regardless if the objects have any materials
if objectOnly == False:
# For each selected object
for obj in bpy.context.selected_objects:
if obj.type == "MESH":
for mat in obj.material_slots.keys():
list_of_mats.add(mat)
if len(list_of_mats) > 0:
return list_of_mats
else:
display_msg_box(
"There are no valid materials in the selected objects", "Error", "ERROR")
return False
else:
return False
def is_node_connected(material, node_to_check):
''' Checks if a specified node is actually connected (indirectly or directly) to the final Material Output'''
output_node = None
# Find the Material Output node
for node in material.node_tree.nodes:
if node.type == 'OUTPUT_MATERIAL':
output_node = node
break
if not output_node:
return False
# Function to recursively check connections
def check_connections(current_node):
if current_node == node_to_check:
return True
for input_socket in current_node.inputs:
for link in input_socket.links:
source_node = link.from_node
if check_connections(source_node):
return True
return False
# Start from the output node and check connections
return check_connections(output_node)
def find_faces_with_material(mesh_obj, material_name):
if material_name not in mesh_obj.data.materials:
return []
mat_index = mesh_obj.data.materials.find(material_name)
faces_with_material = [poly.index for poly in mesh_obj.data.polygons if poly.material_index == mat_index]
return faces_with_material
def separate_faces(mesh_obj, face_indices):
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.reveal()
bpy.ops.mesh.select_all(action='DESELECT')
bpy.ops.mesh.select_mode(use_extend=False, use_expand=False, type='FACE')
bpy.ops.object.mode_set(mode='OBJECT')
for face_index in face_indices:
if face_index < len(mesh_obj.data.polygons):
mesh_obj.data.polygons[face_index].select = True
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.separate(type='SELECTED')
bpy.ops.object.mode_set(mode='OBJECT')
for obj in bpy.context.selected_objects:
bpy.context.view_layer.objects.active = obj
bpy.ops.object.material_slot_remove_unused()
mesh_obj.select_set(False)
bpy.context.view_layer.objects.active = bpy.context.selected_objects[0]
new_obj = bpy.context.active_object
return new_obj
# Bake Target copy operator
class CopyBakeTargetNode(bpy.types.Operator):
"""Copy the currently active Image Texture node and set it as a preset for baking."""
bl_idname = "material.copy_bake_target"
bl_label = "Copy"
bl_options = {'REGISTER'}
def execute(self, context):
# Check if there's actually an active object and active material
if bpy.context.active_object != None and bpy.context.active_object.active_material != None:
if len(bpy.context.active_object.material_slots) != 0:
if len(bpy.context.active_object.active_material.node_tree.nodes) != 0:
# Check if there's an active node
if bpy.context.active_object.active_material.node_tree.nodes.active != None:
# Check if the selected, active node is actually an Image Texture.
if bpy.context.active_object.active_material.node_tree.nodes.active.type == "TEX_IMAGE":
if bpy.context.active_object.active_material.node_tree.nodes.active.image != None:
bake_node_preset["image"] = bpy.context.active_object.active_material.node_tree.nodes.active.image.name
bake_node_preset["interpolation"] = bpy.context.active_object.active_material.node_tree.nodes.active.interpolation
bake_node_preset["projection"] = bpy.context.active_object.active_material.node_tree.nodes.active.projection
bake_node_preset["projection_blend"] = bpy.context.active_object.active_material.node_tree.nodes.active.projection_blend
bake_node_preset["extension"] = bpy.context.active_object.active_material.node_tree.nodes.active.extension
else:
display_msg_box(
"There is no active Image Texture node. Click on an Image Texture node to set it as active first", "Error", "ERROR")
else:
display_msg_box(
"The active material has no nodes. Select a valid material with at least one Image Texture node", "Error", "ERROR")
else:
display_msg_box(
"There are no valid materials in the active mesh object", "Error", "ERROR")
else:
display_msg_box(
"There is no active mesh object. Click on a mesh object to set it as active first", "Error", "ERROR")
return {'FINISHED'}
# Bake Target paste operator
class PasteBakeTargetNode(bpy.types.Operator):
"""Paste the Bake Target Node in all materials in selected objects, using the node settings previously copied with Copy button"""
bl_idname = "material.paste_bake_target"
bl_label = "Paste"
bl_options = {'REGISTER'}
def execute(self, context):
num_processed = 0
list_of_mats = check_for_selected()
# Check if any objects are selected.
if list_of_mats != False:
# Check if an image texture has actually been copied yet
if bake_node_preset["image"] != "":
# For each material in selected object
for mat in list_of_mats:
# Find Material Output node
reference_node = None
for node in bpy.data.materials[mat].node_tree.nodes:
if node.type == "OUTPUT_MATERIAL":
reference_node = node
break
# If no Material Output node exists, look for an alternative reference node instead
if reference_node is None:
for node in bpy.data.materials[mat].node_tree.nodes:
if node.type == "BSDF_PRINCIPLED" or node.type == "EMISSION":
reference_node = node
break
# If reference node was found:
if reference_node != None:
new_image_node = None
bake_target_exists = False
# Check if Bake Target Node already exists. If so, reset it.
for node in bpy.data.materials[mat].node_tree.nodes:
node.select = False
if "Bake Target Node" in node.name:
new_image_node = node
bake_target_exists = True
break
if not bake_target_exists:
# Create Image Texture node if Bake Target Node doesn't already exist
new_image_node = bpy.data.materials[mat].node_tree.nodes.new(
'ShaderNodeTexImage')
new_image_node.image = bpy.data.images[bake_node_preset["image"]]
new_image_node.location = mathutils.Vector(
((reference_node.location[0] + 180), (reference_node.location[1])))
new_image_node.interpolation = bake_node_preset["interpolation"]
new_image_node.projection = bake_node_preset["projection"]
new_image_node.projection_blend = bake_node_preset["projection_blend"]
new_image_node.extension = bake_node_preset["extension"]
new_image_node.color = bpy.context.scene.MatBatchProperties.BakeTargetNodeColor
new_image_node.use_custom_color = bpy.context.scene.MatBatchProperties.BakeTargetNodeColorEnable
new_image_node.name = "Bake Target Node"
new_image_node.label = "Bake Target"
new_image_node.select = True
bpy.data.materials[mat].node_tree.nodes.active = new_image_node
num_processed += 1
display_msg_box(
f'Created bake target in {num_processed} material(s).', 'Info', 'INFO')
else:
display_msg_box(
"There is no currently set Bake Target. Use the Copy button first to set one", "Error", "ERROR")
return {'FINISHED'}
# Bake Target delete operator
class DeleteBakeTargetNode(bpy.types.Operator):
"""Delete the Bake Target Node, if present, in all materials in selected objects"""
bl_idname = "material.delete_bake_target"
bl_label = "Delete"
bl_options = {'REGISTER'}
def execute(self, context):
num_processed = 0
list_of_mats = check_for_selected()
# Check if any objects are selected.
if list_of_mats != False:
if bake_node_preset["image"] != "":
# For each material in selected object
for mat in list_of_mats:
# Check if Bake Target Node already exists. If so, delete it.
for node in bpy.data.materials[mat].node_tree.nodes:
if "Bake Target Node" in node.name:
bpy.data.materials[mat].node_tree.nodes.remove(
node)
num_processed += 1
break
display_msg_box(
f'Deleted {num_processed} bake target node(s).', 'Info', 'INFO')
else:
display_msg_box(
"There is no currently set Bake Target. Use the Copy button first to set one", "Error", "ERROR")
return {'FINISHED'}
# Assign UV Map Node operator
class AssignUVMapNode(bpy.types.Operator):
"""Assign a UV Map node to any Image Texture that satisfies the entered Filter, in all materials in selected objects"""
bl_idname = "material.assign_uv_map_node"
bl_label = "Assign UV Map Node"
bl_options = {'REGISTER'}
def execute(self, context):
num_processed = 0
list_of_mats = check_for_selected()
# Check if any objects are selected.
if list_of_mats != False:
# For each material in selected object
for mat in list_of_mats:
nodetree = bpy.data.materials[mat].node_tree
links = bpy.data.materials[mat].node_tree.links
# Look for Image Texture nodes
for node in nodetree.nodes:
new_UV_node = None
reference_node = None
if node.type == "TEX_IMAGE" and node.image:
# Skip if it's a Bake Target node
if node.label == "Bake Target":
continue
# Check if Image Texture is in the user's entered format
if node.image.file_format == bpy.context.scene.MatBatchProperties.UVMapNodeExtensionFilter:
# Check if Image Texture already has a node connected to it
if node.inputs[0].links:
# If node connected to it is a UV Map node...
if node.inputs[0].links[0].from_node.type == "UVMAP":
# Delete the old UV Map node
nodetree.nodes.remove(
node.inputs[0].links[0].from_node)
reference_node = node
# If the Image Texture has some other kind of node connected... recursively search to find the closest UV Map node
else:
foundnode = recursive_node_search(
node, "UVMAP")
if foundnode:
reference_node = foundnode.outputs[0].links[0].to_node
nodetree.nodes.remove(
foundnode)
else:
reference_node = node
else:
reference_node = node
# Create new UV Map node
new_UV_node = nodetree.nodes.new(
"ShaderNodeUVMap")
new_UV_node.name = "Batch UV Map"
new_UV_node.uv_map = bpy.context.scene.MatBatchProperties.UVMapNodeTarget
new_UV_node.location = mathutils.Vector(
((reference_node.location[0] - 200), (reference_node.location[1] - 150)))
nodetree.links.new(
new_UV_node.outputs[0], reference_node.inputs[0])
num_processed += 1
continue
display_msg_box(
f'Created and assigned {num_processed} UV Map node(s).', 'Info', 'INFO')
return {'FINISHED'}
# Overwrite UV Slot Name operator
class OverwriteUVSlotName(bpy.types.Operator):
"""Using the specified UV Map name above, this button will overwrite the name of the UV Map in the specified UV slot, in all selected objects. If UV Map slot doesn't exist, a new UV Map will be created with that name"""
bl_idname = "object.overwrite_uv_slot_name"
bl_label = "Overwrite UV Slot Name"
bl_options = {'REGISTER'}
def execute(self, context):
num_processed = 0
# Check if any objects are selected
if check_for_selected(True) != False:
# For each selected object
for obj in bpy.context.selected_objects:
if obj.type == "MESH":
mesh = obj.data
uvslots = mesh.uv_layers
uvslot_index = int(
bpy.context.scene.MatBatchProperties.UVSlotIndex)
uvname = bpy.context.scene.MatBatchProperties.UVMapNodeTarget
counter = 0
for slot in uvslots:
if slot.name == uvname:
if counter != uvslot_index - 1:
slot.name = slot.name + ".001"
else:
counter += 1
if len(uvslots) == 0 and uvslot_index == 1:
uvslots.new(
name=uvname)
elif len(uvslots) == 1 and uvslot_index == 2:
uvslots.new(
name=uvname)
elif len(uvslots) == 0 and uvslot_index == 2:
uvslots.new(
name=uvname + ".001")
uvslots.new(
name=uvname)
elif uvslots[uvslot_index-1] != None:
uvslots[uvslot_index -
1].name = uvname
num_processed += 1
obj.data.update()
display_msg_box(
f'Renamed the UV map layer for {num_processed} object(s).', 'Info', 'INFO')
return {'FINISHED'}
# Set UV Slot as Active opterator
class SetUVSlotAsActive(bpy.types.Operator):
"""Sets the currently selected UV Slot above as the 'active' slot in all selected objects. Does not modify the UV map name"""
bl_idname = "object.set_uv_slot_as_active"
bl_label = "Set UV Slot as Active"
bl_options = {'REGISTER'}
def execute(self, context):
num_processed = 0
# Check if any objects are selected
if check_for_selected(True) != False:
# For each selected object
for obj in bpy.context.selected_objects:
if obj.type == "MESH":
mesh = obj.data
uvslots = mesh.uv_layers
uvslot_index = int(
bpy.context.scene.MatBatchProperties.UVSlotIndex)
if len(uvslots) > 0:
uvslots.active = uvslots[uvslot_index - 1]
num_processed += 1
obj.data.update()
display_msg_box(
f'Set the active UV slot for {num_processed} object(s).', 'Info', 'INFO')
return {'FINISHED'}
# Assign Vertex Color to Nodes operator
class AssignVCToNodes(bpy.types.Operator):
"""Assign the Vertex Color name above to all Color Attribute nodes, in all materials in selected objects"""
bl_idname = "material.assign_vc_to_nodes"
bl_label = "Assign Name to Color Nodes"
bl_options = {'REGISTER'}
def execute(self, context):
num_processed = 0
list_of_mats = check_for_selected()
# Check if any objects are selected.
if list_of_mats != False:
# For each selected object
for obj in bpy.context.selected_objects:
if obj.type == "MESH":
num_processed += 1
# For each material in selected object
for mat in list_of_mats:
for node in bpy.data.materials[mat].node_tree.nodes:
if node.type == "VERTEX_COLOR":
node.layer_name = bpy.context.scene.MatBatchProperties.VCName
elif node.type == "ATTRIBUTE":
node.attribute_name = bpy.context.scene.MatBatchProperties.VCName
obj.data.update()
display_msg_box(
f'Assigned vertex color layer in {num_processed} object(s).', 'Info', 'INFO')
return {'FINISHED'}
# Rename Vertex Color Slot operator
class RenameVertexColorSlot(bpy.types.Operator):
"""Rename the first Vertex Color slot in all selected objects, using the name specified above"""
bl_idname = "object.rename_vertex_color"
bl_label = "Rename Vertex Color Slot 1"
bl_options = {'REGISTER'}
def execute(self, context):
num_processed = 0
# Blender 3.2 renamed "vertex colors" to "color attributes," so let's check the version beforehand
useColorAttributes = (bpy.app.version >= (3, 2, 0))
# Check if any objects are selected
if check_for_selected(True) != False:
# For each selected object
for obj in bpy.context.selected_objects:
if obj.type == "MESH":
mesh = obj.data
if useColorAttributes:
vcslots = mesh.color_attributes
else:
vcslots = mesh.vertex_colors
vcname = bpy.context.scene.MatBatchProperties.VCName
if len(vcslots) > 0:
vcslots[0].name = vcname
else:
if useColorAttributes:
vcslots.new(name=vcname, type="FLOAT_COLOR",
domain="POINT")
# vcslots.new(name=vcname, type="BYTE_COLOR",
# domain="CORNER")
else:
vcslots.new(name=vcname)
num_processed += 1
obj.data.update()
display_msg_box(
f'Renamed {num_processed} vertex color slot(s).', 'Info', 'INFO')
return {'FINISHED'}
# Convert Vertex Color operator
class ConvertVertexColor(bpy.types.Operator):
"""Converts the data type of the first Color Attribute slot in all selected objects, between 'Face Corner Byte Color' and 'Vertex Color'"""
bl_idname = "object.convert_vertex_color"
bl_label = "Convert Vertex Color Slot 1"
bl_options = {'REGISTER'}
def execute(self, context):
# Blender 3.2 renamed "vertex colors" to "color attributes," so let's check the version beforehand
useColorAttributes = (bpy.app.version >= (3, 2, 0))
if not useColorAttributes:
display_msg_box(
f'This feature is only available in Blender 3.2 or higher.', 'Error', 'ERROR')
return {'FINISHED'}
num_processed = 0
# Check if any objects are selected
if check_for_selected(True) != False:
# For each selected object
for obj in bpy.context.selected_objects:
if obj.type == "MESH":
mesh = obj.data
vcslots = mesh.color_attributes
vcname = bpy.context.scene.MatBatchProperties.VCName
if len(vcslots) == 1:
if vcslots[0].data_type == "FLOAT_COLOR":
vcslots.remove(vcslots[0])
vcslots.new(name=vcname, type="BYTE_COLOR",
domain="CORNER")
elif vcslots[0].data_type == "BYTE_COLOR":
vcslots.remove(vcslots[0])
vcslots.new(name=vcname, type="FLOAT_COLOR",
domain="POINT")
num_processed += 1
obj.data.update()
display_msg_box(
f'Converted {num_processed} color attribute slot(s).', 'Info', 'INFO')
return {'FINISHED'}
# Set Blend Mode operator
class SetBlendMode(bpy.types.Operator):
"""Sets the currently selected Blend Mode above as the Blend & Shadow Mode in all materials, in all selected objects"""
bl_idname = "material.set_blend_mode"
bl_label = "Set Blend Mode"
bl_options = {'REGISTER'}
def execute(self, context):
alpha_mode = bpy.context.scene.MatBatchProperties.AlphaBlendMode
shadow_mode = bpy.context.scene.MatBatchProperties.AlphaBlendMode
filter_mode = bpy.context.scene.MatBatchProperties.AlphaBlendFilter
alpha_threshold = bpy.context.scene.MatBatchProperties.AlphaThreshold
principled_alpha_slot = 21 if bpy.app.version < (4, 0, 0) else 4
num_processed = 0
if alpha_mode == "BLEND":
shadow_mode = "CLIP"
list_of_mats = check_for_selected()
# Check if any objects are selected.
if list_of_mats != False:
# For each selected object
for obj in bpy.context.selected_objects:
if obj.type == "MESH":
# For each material in selected object
for mat in list_of_mats:
principled_nodes = []
for node in bpy.data.materials[mat].node_tree.nodes:
if node.type == "BSDF_PRINCIPLED":
principled_nodes.append(node)
# If user also wants to remove any alpha from the Principled node itself too
if bpy.context.scene.MatBatchProperties.AlphaPrincipledRemove == True and alpha_mode == "OPAQUE":
for node in principled_nodes:
if len(node.inputs[principled_alpha_slot].links) > 0:
bpy.data.materials[mat].node_tree.links.remove(
node.inputs[principled_alpha_slot].links[0])
node.inputs[principled_alpha_slot].default_value = 1.0
# Filter 1 - Principled BSDF with Alpha
if filter_mode == "PRINCIPLEDNODE":
for node in bpy.data.materials[mat].node_tree.nodes:
if node.type == "BSDF_PRINCIPLED":
if len(node.inputs[principled_alpha_slot].links) > 0:
update_alpha_settings(mat, alpha_mode, shadow_mode, alpha_threshold)
num_processed += 1
break
else:
if node.inputs[principled_alpha_slot].default_value < 1.0:
update_alpha_settings(mat, alpha_mode, shadow_mode, alpha_threshold)
num_processed += 1
break
# Filter 2 - Transparent BSDF
elif filter_mode == "TRANSPARENTNODE":
for node in bpy.data.materials[mat].node_tree.nodes:
update_alpha_settings(mat, alpha_mode, shadow_mode, alpha_threshold)
num_processed += 1
break
else:
update_alpha_settings(mat, alpha_mode, shadow_mode, alpha_threshold)
num_processed += 1
continue
obj.data.update()
display_msg_box(
f'Updated alpha settings for {str(num_processed)} material(s).', 'Info', 'INFO')
return {'FINISHED'}
# Copy Node Settings operator
class SetAsTemplateNode(bpy.types.Operator):
"""Stores the currently active, selected node as a template"""
bl_idname = "material.set_as_template_node"
bl_label = "Set as Template Node"
bl_options = {'REGISTER'}
def execute(self, context):
global node_unify_settings
node_unify_settings = {
"name": "",
"type": "",
"material": ""
}
# Check if there's an active object
if bpy.context.active_object != None:
# Check if there's an active node
if bpy.context.active_object.active_material.node_tree.nodes.active != None:
active_node = bpy.context.active_object.active_material.node_tree.nodes.active
node_unify_settings["name"] = active_node.name
node_unify_settings["type"] = active_node.type
node_unify_settings["material"] = bpy.context.active_object.active_material.name
bpy.context.scene.MatBatchProperties.SavedNodeName = active_node.name
bpy.context.scene.MatBatchProperties.SavedNodeType = active_node.bl_label
else:
display_msg_box(
"There is no active node. Click on a node to set one as active", "Error", "ERROR")
else:
display_msg_box(
"There is no active mesh object. Click on a mesh object to set one as active", "Error", "ERROR")
return {'FINISHED'}
# Unify Node Settings operator
class UnifyNodeSettings(bpy.types.Operator):
"""Searches for all nodes of the same type, in all materials on selected objects, and copies the template node's settings into those other nodes' settings. The original template node is not modified and must still exist"""
bl_idname = "material.unify_node_settings"
bl_label = "Unify Node Settings"
bl_options = {'REGISTER'}
def execute(self, context):
num_processed = 0
node_type = node_unify_settings["type"]
# Check if template node's material still exists:
if bpy.data.materials.get(node_unify_settings["material"]) != None:
# Check if template node still exists:
if bpy.data.materials[node_unify_settings["material"]].node_tree.nodes.get(node_unify_settings["name"]) != None:
template_node = bpy.data.materials[node_unify_settings["material"]
].node_tree.nodes[node_unify_settings["name"]]
list_of_mats = check_for_selected()
# Check if any objects are selected.
if list_of_mats != False:
# Check if there are any previously copied node settings
if node_unify_settings["name"] != "":
# For each selected object
for obj in bpy.context.selected_objects:
if obj.type == "MESH":
num_processed += 1
# For each material in selected object
for mat in list_of_mats:
valid_nodes = []
for node in bpy.data.materials[mat].node_tree.nodes:
# Check if node is of the saved type
if node.type == node_type:
# Check if a Label Filter was specified
if bpy.context.scene.MatBatchProperties.UnifyFilterLabel != "":
if node.label == bpy.context.scene.MatBatchProperties.UnifyFilterLabel:
valid_nodes.append(node)
else:
continue
else:
valid_nodes.append(node)
# Special operations for curve nodes - storing template curve data for later
template_curve_data = [[],[],[],[]]
template_curve_handle_types = [[],[],[],[]]
if 'CURVE' in node.type:
curve_index = 0
for curve in template_node.mapping.curves:
for point in curve.points:
template_curve_data[curve_index].append(tuple(point.location))
template_curve_handle_types[curve_index].append(point.handle_type)
curve_index += 1
else:
del template_curve_data
del template_curve_handle_types
# Special operations for color ramp nodes - storing template gradient data for later
template_ramp_data = []
if 'VALTORGB' in node.type:
for stop in template_node.color_ramp.elements:
template_ramp_data.append((stop.position, tuple(stop.color)))
else:
del template_ramp_data
for node in valid_nodes:
# Copy and paste inputs from template node
input_counter = 0
for i in node.inputs:
if hasattr(i, "default_value") and hasattr(template_node.inputs[input_counter], "default_value"):
i.default_value = template_node.inputs[
input_counter].default_value
input_counter += 1
# Copy and paste properties from template node, but exclude the properties contained in a "do not use" list
property_list = list(
template_node.bl_rna.properties.keys())
new_property_list = list()
do_not_use = ['rna_type', 'type', 'location', 'location_absolute', 'width', 'width_hidden', 'height', 'dimensions', 'name', 'label', 'inputs', 'outputs', 'internal_links', 'parent', 'use_custom_color', 'color', 'select', 'show_options',
'show_preview', 'hide', 'mute', 'show_texture', 'bl_idname', 'bl_label', 'bl_description', 'bl_icon', 'bl_static_type', 'bl_width_default', 'bl_width_min', 'bl_width_max', 'bl_height_default', 'bl_height_min', 'bl_height_max']
for prop in property_list:
if prop not in do_not_use:
if node.is_property_readonly(prop) == False:
new_property_list.append(
prop)
for prop in new_property_list:
setattr(
node, prop, eval(f"template_node.{prop}"))
# Special operations for curve nodes - copying template curve data over
if 'CURVE' in template_node.type and 'CURVE' in node.type:
curve_index = 0
for curve in node.mapping.curves:
# Clear the existing points first
point_count = len(curve.points)
for index in list(range(0,point_count)):
try:
curve.points.remove(curve.points[index])
except:
continue
# Check if the point counts are the same. If not, we need to add new points
point_count_difference = abs(len(curve.points) - len(template_curve_data[curve_index]))
if point_count_difference > 0:
for index in range(0, point_count_difference):
curve.points.new(1,1)
point_index = 0
for point in curve.points:
point.location = template_curve_data[curve_index][point_index]
point.handle_type = template_curve_handle_types[curve_index][point_index]
point_index += 1
curve_index += 1
node.mapping.use_clip = template_node.mapping.use_clip
node.mapping.clip_min_x = template_node.mapping.clip_min_x
node.mapping.clip_min_y = template_node.mapping.clip_min_y
node.mapping.clip_max_x = template_node.mapping.clip_max_x
node.mapping.clip_max_y = template_node.mapping.clip_max_y
node.mapping.update()
# Special operations for color ramp nodes - copying template gradient data over
if 'VALTORGB' in template_node.type and 'VALTORGB' in node.type:
# Clear the existing points first
stop_count = len(node.color_ramp.elements)
for index in list(range(0,stop_count)):
try:
node.color_ramp.elements.remove(node.color_ramp.elements[index])
except:
continue
# Check if the stop counts are the same. If not, we need to add new points.
stop_count_difference = abs(len(node.color_ramp.elements) - len(template_ramp_data))
if stop_count_difference > 0:
for index in range(0,stop_count_difference):
node.color_ramp.elements.new(1.0)
stop_index = 0
for stop in node.color_ramp.elements:
stop.position = template_ramp_data[stop_index][0]
stop.color = template_ramp_data[stop_index][1]
stop_index += 1
node.color_ramp.color_mode = template_node.color_ramp.color_mode
node.color_ramp.hue_interpolation = template_node.color_ramp.hue_interpolation
node.color_ramp.elements.update()
else:
display_msg_box(
"You haven't set a a template yet. Use the Set as Template button to set one.", "Error", "ERROR")
return {'FINISHED'}
else:
display_msg_box(
"The template node no longer exists. Use the Set as Template button set a new one", "Error", "ERROR")
return {'FINISHED'}
else:
display_msg_box(
"The template node's parent material no longer exists. Use the Set as Template button set a new one", "Error", "ERROR")
return {'FINISHED'}
display_msg_box(
f'Applied unified node settings in {num_processed} object(s).', 'Info', 'INFO')
return {'FINISHED'}
# Shader Switch operator
class SwitchShader(bpy.types.Operator):
"""Finds all Principled BSDF or Emission shader nodes, in all materials in all selected objects, and switches them to the shader selected above"""
bl_idname = "material.switch_shader"
bl_label = "Switch Shader"
bl_options = {'REGISTER'}
def execute(self, context):
num_processed = 0
list_of_mats = check_for_selected()
# Check if any objects are selected.
if list_of_mats != False:
old_shader_type = None
target_shader_type = bpy.context.scene.MatBatchProperties.SwitchShaderTarget
if target_shader_type == "EMISSION": # If user wants to switch to Emission
old_shader_type = "BSDF_PRINCIPLED"
else:
old_shader_type = "EMISSION"
# For each selected object
for obj in bpy.context.selected_objects:
if obj.type == "MESH":
# For each material in selected object
for mat in list_of_mats:
material = bpy.data.materials[mat]
# Find the other shader
old_shaders = []
for node in bpy.data.materials[mat].node_tree.nodes:
if node.type == old_shader_type:
old_shaders.append(node)
break
# If the old shader wasn't found, skip this material and continue to the next material
if len(old_shaders) == 0:
continue
# If opposite shader was found:
else:
for old_shader in old_shaders:
new_shader = None
input_node_socket = None
output_node_socket = None
if len(old_shader.inputs[0].links) > 0:
input_node_socket = old_shader.inputs[0].links[0].from_socket
if len(old_shader.outputs[0].links) > 0:
output_node_socket = old_shader.outputs[0].links[0].to_socket
# Create new shader
if target_shader_type == "BSDF_PRINCIPLED":
new_shader = bpy.data.materials[mat].node_tree.nodes.new(
"ShaderNodeBsdfPrincipled")
if target_shader_type == "EMISSION":
new_shader = bpy.data.materials[mat].node_tree.nodes.new(
"ShaderNodeEmission")
# Place the new shader in the old shader's location
new_shader.location = old_shader.location
if len(old_shader.inputs[0].links) > 0:
material.node_tree.links.new(
new_shader.inputs[0], input_node_socket)
if len(old_shader.outputs[0].links) > 0:
material.node_tree.links.new(
output_node_socket, new_shader.outputs[0])
material.node_tree.nodes.remove(old_shader)
num_processed += 1
display_msg_box(
f'Switched shader in {num_processed} material(s).', 'Info', 'INFO')
return {'FINISHED'}
# Apply Material Template operator
class ApplyMatTemplate(bpy.types.Operator):
"""Applies the selected material template, to all materials in all selected objects, while attempting to retain the albedo image texture if one exists"""
bl_idname = "material.apply_mat_template"
bl_label = "Apply Material Template"
bl_options = {'REGISTER'}
def execute(self, context):
num_processed = 0
list_of_mats = check_for_selected()
useColorAttributes = bpy.app.version >= (3, 2, 0)
mix_node_type = "ShaderNodeMixRGB" if bpy.app.version < (3, 4, 0) else "ShaderNodeMix"
principled_alpha_slot = 21 if bpy.app.version < (4, 0, 0) else 4
# Check if any objects are selected.
if list_of_mats != False:
target_template = bpy.context.scene.MatBatchProperties.Template
skip_texture = bpy.context.scene.MatBatchProperties.SkipTexture
# For each selected object
for obj in bpy.context.selected_objects:
if obj.type == "MESH":
# For each material in selected object
for mat in list_of_mats:
material = bpy.data.materials[mat]
material.use_nodes = True
if material and material.use_nodes:
match target_template:
case "ECT":
if bpy.app.version >= (4, 2, 0):
material.surface_render_method = 'DITHERED'
else:
material.blend_method = 'OPAQUE'
# Store the color of the existing Principled BSDF or Emissive node (if any)
stored_color = None
for node in material.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED' or node.type == 'EMISSION':
if is_node_connected(material, node):
stored_color = tuple(node.inputs[0].default_value)
break
# Store the image of the existing image texture node (if any)
stored_image = None
for node in material.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image:
stored_image = node.image
break
# Clear existing nodes
material.node_tree.nodes.clear()
if stored_image != None:
if skip_texture != "":
if skip_texture in stored_image.filepath:
stored_image = None
# Create necessary nodes
uv_map_node = None
img_tex_node = None
mix_color_node = None
if stored_image != None:
uv_map_node = material.node_tree.nodes.new(type='ShaderNodeUVMap')
img_texture_node = material.node_tree.nodes.new(type='ShaderNodeTexImage')
mix_color_node = material.node_tree.nodes.new(type=mix_node_type)
if "MixRGB" not in mix_node_type:
mix_color_node.data_type = 'RGBA'
mix_color_node.blend_type = 'MULTIPLY'
mix_color_node.inputs[0].default_value = 1.0 # Set the factor to 1.0
img_texture_node.image = stored_image
if len(obj.data.uv_layers) > 0:
uv_map_node.uv_map = obj.data.uv_layers[0].name
color_attr_node = material.node_tree.nodes.new(type='ShaderNodeVertexColor')
emission_node = material.node_tree.nodes.new(type='ShaderNodeEmission')
material_output_node = material.node_tree.nodes.new(type='ShaderNodeOutputMaterial')
# Copy the original base color over
if stored_color != None:
emission_node.inputs[0].default_value = stored_color
# Arrange nodes for clarity
if stored_image != None:
img_texture_node.location = (-500, 0)
mix_color_node.location = (-200, 100)
uv_map_node.location = (-700, 0)
color_attr_node.location = (-400, 150)
emission_node.location = (0, 100)
material_output_node.location = (200, 100)
# Add correct Vertex Color name
if useColorAttributes:
if len(obj.data.color_attributes) > 0:
color_attr_node.layer_name = obj.data.color_attributes[0].name
else:
if len(obj.data.vertex_colors) > 0:
color_attr_node.layer_name = obj.data.vertex_colors[0].name
# Link nodes
links = material.node_tree.links
if stored_image != None:
if "MixRGB" not in mix_node_type:
links.new(img_texture_node.outputs[0], mix_color_node.inputs[7])
links.new(color_attr_node.outputs[0], mix_color_node.inputs[6])
links.new(mix_color_node.outputs[2], emission_node.inputs[0])
else:
links.new(img_texture_node.outputs[0], mix_color_node.inputs[2])
links.new(color_attr_node.outputs[0], mix_color_node.inputs[1])
links.new(mix_color_node.outputs[0], emission_node.inputs[0])
links.new(emission_node.outputs[0], material_output_node.inputs[0])
links.new(uv_map_node.outputs[0], img_texture_node.inputs[0])
else:
links.new(color_attr_node.outputs[0], emission_node.inputs[0])
links.new(emission_node.outputs[0], material_output_node.inputs[0])
case "EC":
if bpy.app.version >= (4, 2, 0):
material.surface_render_method = 'DITHERED'
else:
material.blend_method = 'OPAQUE'
# Store the color of the existing Principled BSDF or Emissive node (if any)
stored_color = None
for node in material.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED' or node.type == 'EMISSION':
if is_node_connected(material, node):
stored_color = tuple(node.inputs[0].default_value)
break
# Clear existing nodes
material.node_tree.nodes.clear()
# Create necessary nodes
color_attr_node = material.node_tree.nodes.new(type='ShaderNodeVertexColor')
emission_node = material.node_tree.nodes.new(type='ShaderNodeEmission')
material_output_node = material.node_tree.nodes.new(type='ShaderNodeOutputMaterial')
# Copy the original base color over
if stored_color != None:
emission_node.inputs[0].default_value = stored_color
# Arrange nodes for clarity
color_attr_node.location = (-200, 100)
emission_node.location = (0, 100)
material_output_node.location = (200, 100)
# Add correct Vertex Color name
if useColorAttributes:
if len(obj.data.color_attributes) > 0:
color_attr_node.layer_name = obj.data.color_attributes[0].name
else:
if len(obj.data.vertex_colors) > 0:
color_attr_node.layer_name = obj.data.vertex_colors[0].name
# Link nodes
links = material.node_tree.links
links.new(color_attr_node.outputs[0], emission_node.inputs[0])
links.new(emission_node.outputs[0], material_output_node.inputs[0])
case "ACCT":
if bpy.app.version >= (4, 2, 0):
material.surface_render_method = 'DITHERED'
else:
material.blend_method = 'CLIP'
# Store the color of the existing Principled BSDF or Emissive node (if any)
stored_color = None
for node in material.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED' or node.type == 'EMISSION':
if is_node_connected(material, node):
stored_color = tuple(node.inputs[0].default_value)
break
# Store the image of the existing image texture node (if any)
stored_image = None
for node in material.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image:
stored_image = node.image
break
# Clear existing nodes
material.node_tree.nodes.clear()
if stored_image != None:
if skip_texture != "":
if skip_texture in stored_image.filepath:
stored_image = None
# Create necessary nodes
if stored_image is not None:
uv_map_node = material.node_tree.nodes.new(type='ShaderNodeUVMap')
img_texture_node = material.node_tree.nodes.new(type='ShaderNodeTexImage')
mix_color_node = material.node_tree.nodes.new(type=mix_node_type)
if "MixRGB" not in mix_node_type:
mix_color_node.data_type = 'RGBA'
mix_color_node.blend_type = 'MULTIPLY'
mix_color_node.inputs[0].default_value = 1.0 # Set the factor to 1.0
img_texture_node.image = stored_image
if len(obj.data.uv_layers) > 0:
uv_map_node.uv_map = obj.data.uv_layers[0].name
color_attr_node = material.node_tree.nodes.new(type='ShaderNodeVertexColor')
emission_node = material.node_tree.nodes.new(type='ShaderNodeEmission')
material_output_node = material.node_tree.nodes.new(type='ShaderNodeOutputMaterial')
mix_shader_node = material.node_tree.nodes.new(type='ShaderNodeMixShader')
transparent_node = material.node_tree.nodes.new(type='ShaderNodeBsdfTransparent')
material.alpha_threshold = 0.5
# Add correct Vertex Color name
if useColorAttributes:
if len(obj.data.color_attributes) > 0:
color_attr_node.layer_name = obj.data.color_attributes[0].name
else:
if len(obj.data.vertex_colors) > 0:
color_attr_node.layer_name = obj.data.vertex_colors[0].name
# Copy the original base color over
if stored_color != None:
emission_node.inputs[0].default_value = stored_color
# Arrange nodes for clarity
if stored_image is not None:
uv_map_node.location = (-700, 0)
img_texture_node.location = (-500, 0)
mix_color_node.location = (-200, 100)
color_attr_node.location = (-400, 150)
emission_node.location = (0, 100)
mix_shader_node.location = (200, 100)
transparent_node.location = (0, -100)
material_output_node.location = (400, 100)
# Link nodes
links = material.node_tree.links
if stored_image is not None:
links.new(uv_map_node.outputs[0], img_texture_node.inputs[0])
links.new(img_texture_node.outputs[0], mix_color_node.inputs[2])
if "MixRGB" not in mix_node_type:
links.new(img_texture_node.outputs[0], mix_color_node.inputs[7])
links.new(color_attr_node.outputs[0], mix_color_node.inputs[6])
links.new(mix_color_node.outputs[2], emission_node.inputs[0])
else:
links.new(img_texture_node.outputs[0], mix_color_node.inputs[2])
links.new(color_attr_node.outputs[0], mix_color_node.inputs[1])
links.new(mix_color_node.outputs[0], emission_node.inputs[0])
else:
links.new(color_attr_node.outputs[0], emission_node.inputs[0])
links.new(emission_node.outputs[0], material_output_node.inputs[0])
# Mix Shader links
links.new(emission_node.outputs[0], mix_shader_node.inputs[2])
links.new(transparent_node.outputs[0], mix_shader_node.inputs[1])
if stored_image is not None:
links.new(img_texture_node.outputs[1], mix_shader_node.inputs[0])
links.new(mix_shader_node.outputs[0], material_output_node.inputs[0])
# Blender 4.2 got rid of the alpha clip setting, so we use the Math node instead
if bpy.app.version >= (4, 2, 0):
material.surface_render_method = 'DITHERED'
greaterthan_node = material.node_tree.nodes.new(type='ShaderNodeMath')
greaterthan_node.operation = 'GREATER_THAN'
greaterthan_node.location = (-200,-140)
if stored_image is not None:
links.new(img_texture_node.outputs[1], greaterthan_node.inputs[0])
links.new(greaterthan_node.outputs[0], mix_shader_node.inputs[0])
else:
material.blend_method = "CLIP"
case "ACT":
material.blend_method = "BLEND"
# Store the color of the existing Principled BSDF or Emissive node (if any)
stored_color = None
for node in material.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED' or node.type == 'EMISSION':
if is_node_connected(material, node):
stored_color = tuple(node.inputs[0].default_value)
break
# Store the image of the existing image texture node (if any)
stored_image = None
for node in material.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image:
stored_image = node.image
break
# Clear existing nodes
material.node_tree.nodes.clear()
if stored_image != None:
if skip_texture != "":
if skip_texture in stored_image.filepath:
stored_image = None
# Create necessary nodes
if stored_image is not None:
uv_map_node = material.node_tree.nodes.new(type='ShaderNodeUVMap')
img_texture_node = material.node_tree.nodes.new(type='ShaderNodeTexImage')
mix_color_node = material.node_tree.nodes.new(type=mix_node_type)
if "MixRGB" not in mix_node_type:
mix_color_node.data_type = 'RGBA'
mix_color_node.blend_type = 'MULTIPLY'
mix_color_node.inputs[0].default_value = 1.0 # Set the factor to 1.0
img_texture_node.image = stored_image
if len(obj.data.uv_layers) > 0:
uv_map_node.uv_map = obj.data.uv_layers[0].name
color_attr_node = material.node_tree.nodes.new(type='ShaderNodeVertexColor')
emission_node = material.node_tree.nodes.new(type='ShaderNodeEmission')
material_output_node = material.node_tree.nodes.new(type='ShaderNodeOutputMaterial')
add_shader_node = material.node_tree.nodes.new(type='ShaderNodeAddShader')
transparent_node = material.node_tree.nodes.new(type='ShaderNodeBsdfTransparent')
# Copy the original base color over
if stored_color != None:
emission_node.inputs[0].default_value = stored_color
# Add correct Vertex Color name
if useColorAttributes:
if len(obj.data.color_attributes) > 0:
color_attr_node.layer_name = obj.data.color_attributes[0].name
else:
if len(obj.data.vertex_colors) > 0:
color_attr_node.layer_name = obj.data.vertex_colors[0].name
# Arrange nodes for clarity
if stored_image is not None:
uv_map_node.location = (-700, 0)
img_texture_node.location = (-500, 0)
mix_color_node.location = (-200, 100)
color_attr_node.location = (-400, 150)
emission_node.location = (0, 100)
add_shader_node.location = (200, 100)
transparent_node.location = (0, -100)
material_output_node.location = (400, 100)
# Link nodes
links = material.node_tree.links
if stored_image is not None:
links.new(uv_map_node.outputs[0], img_texture_node.inputs[0])
links.new(img_texture_node.outputs[0], mix_color_node.inputs[2])
if "MixRGB" not in mix_node_type:
links.new(img_texture_node.outputs[0], mix_color_node.inputs[7])
links.new(color_attr_node.outputs[0], mix_color_node.inputs[6])
links.new(mix_color_node.outputs[2], emission_node.inputs[0])
else:
links.new(img_texture_node.outputs[0], mix_color_node.inputs[2])
links.new(color_attr_node.outputs[0], mix_color_node.inputs[1])
links.new(mix_color_node.outputs[0], emission_node.inputs[0])
else:
links.new(color_attr_node.outputs[0], emission_node.inputs[0])
links.new(emission_node.outputs[0], material_output_node.inputs[0])
# Additive links
links.new(emission_node.outputs[0], add_shader_node.inputs[0])
links.new(transparent_node.outputs[0], add_shader_node.inputs[1])
links.new(add_shader_node.outputs[0], material_output_node.inputs[0])
case "AC":
material.blend_method = "BLEND"
# Store the color of the existing Principled BSDF or Emissive node (if any)
stored_color = None
for node in material.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED' or node.type == 'EMISSION':
if is_node_connected(material, node):
stored_color = tuple(node.inputs[0].default_value)
break
# Clear existing nodes
material.node_tree.nodes.clear()
# Create necessary nodes
color_attr_node = material.node_tree.nodes.new(type='ShaderNodeVertexColor')
emission_node = material.node_tree.nodes.new(type='ShaderNodeEmission')
material_output_node = material.node_tree.nodes.new(type='ShaderNodeOutputMaterial')
add_shader_node = material.node_tree.nodes.new(type='ShaderNodeAddShader')
transparent_node = material.node_tree.nodes.new(type='ShaderNodeBsdfTransparent')
# Add correct Vertex Color name
if useColorAttributes:
if len(obj.data.color_attributes) > 0:
color_attr_node.layer_name = obj.data.color_attributes[0].name
else:
if len(obj.data.vertex_colors) > 0:
color_attr_node.layer_name = obj.data.vertex_colors[0].name
# Copy the original base color over
if stored_color != None:
emission_node.inputs[0].default_value = stored_color
# Arrange nodes for clarity
color_attr_node.location = (-200, 100)
emission_node.location = (0, 100)
add_shader_node.location = (200, 100)
transparent_node.location = (0, -100)
material_output_node.location = (400, 100)
# Link nodes
links = material.node_tree.links
links.new(color_attr_node.outputs[0], emission_node.inputs[0])
links.new(emission_node.outputs[0], material_output_node.inputs[0])
# Additive links
links.new(emission_node.outputs[0], add_shader_node.inputs[0])
links.new(transparent_node.outputs[0], add_shader_node.inputs[1])
links.new(add_shader_node.outputs[0], material_output_node.inputs[0])
case "PT":
if bpy.app.version >= (4, 2, 0):
material.surface_render_method = 'DITHERED'
else:
material.blend_method = 'OPAQUE'
uses_transparency = False
has_alpha_channel = True
# Store the color of the existing Principled BSDF or Emission node (if any)
stored_color = None
for node in material.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED' or node.type == 'EMISSION':
if is_node_connected(material, node):
stored_color = tuple(node.inputs[0].default_value)
break
# Store the image of the existing image texture node (if any)
stored_image = None
for node in material.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image:
stored_image = node.image
# Check if the found texture (if any) was being used for transparency previously
for output in node.outputs:
if output.links:
for link in output.links:
if (link.to_node.type == "BSDF_PRINCIPLED" and link.to_socket.identifier == "Alpha") or (link.to_node.type == "MIX_SHADER" and link.to_socket.identifier == "Fac") or (link.to_node.type == "MATH" and link.to_node.operation == "GREATER_THAN"):
uses_transparency = True
has_alpha_channel = True if output.identifier == "Alpha" else False
break
break
nodes = material.node_tree.nodes
links = material.node_tree.links
nodes.clear()
# Create nodes: UV Map, Image Texture, Principled BSDF, Material Output
if stored_image != None:
uv_map_node = nodes.new(type='ShaderNodeUVMap')
img_tex_node = nodes.new(type='ShaderNodeTexImage')
if len(obj.data.uv_layers) > 0:
uv_map_node.uv_map = obj.data.uv_layers[0].name
img_tex_node.image = stored_image
principled_node = nodes.new(type='ShaderNodeBsdfPrincipled')
material_output_node = nodes.new(type='ShaderNodeOutputMaterial')
# Copy the original base color over
if stored_color != None:
principled_node.inputs[0].default_value = stored_color
# Set positions for the nodes
if stored_image != None:
uv_map_node.location = (-700, 0)
img_tex_node.location = (-500, 0)
principled_node.location = (-200, 0)
material_output_node.location = (100, 0)
# Create links between nodes
if stored_image != None:
links.new(uv_map_node.outputs[0], img_tex_node.inputs[0])
links.new(img_tex_node.outputs[0], principled_node.inputs[0])
links.new(principled_node.outputs[0], material_output_node.inputs[0])
if uses_transparency:
# Blender 4.0 moved the # of the Principled BSDF's Alpha input
links.new(img_tex_node.outputs[1 if has_alpha_channel else 0],principled_node.inputs[principled_alpha_slot])
if bpy.app.version >= (4, 2, 0):
material.surface_render_method = 'DITHERED'
greaterthan_node = material.node_tree.nodes.new(type='ShaderNodeMath')
greaterthan_node.operation = "GREATER_THAN"
greaterthan_node.location = (-377, -83)
img_tex_node.location = (-655, 0)
uv_map_node.location = (-854, 0)
links.new(img_tex_node.outputs[1], greaterthan_node.inputs[0])
links.new(greaterthan_node.outputs[0], principled_node.inputs[4])
else:
material.blend_method = "CLIP"
material.alpha_threshold = 0.5
case "PC":
if bpy.app.version >= (4, 2, 0):
material.surface_render_method = 'DITHERED'
else:
material.blend_method = 'OPAQUE'
# Store the color of the existing Principled BSDF or Emissive node (if any)
stored_color = None
for node in material.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED' or node.type == 'EMISSION':
if is_node_connected(material, node):
stored_color = tuple(node.inputs[0].default_value)
break
nodes = material.node_tree.nodes
links = material.node_tree.links
nodes.clear()
# Create nodes: Color Attribute, Principled BSDF, Material Output
color_attr_node = nodes.new(type='ShaderNodeVertexColor')
principled_node = nodes.new(type='ShaderNodeBsdfPrincipled')
material_output_node = nodes.new(type='ShaderNodeOutputMaterial')
# Copy the original base color over
if stored_color != None:
principled_node.inputs[0].default_value = stored_color
# Add correct Vertex Color name
if useColorAttributes:
if len(obj.data.color_attributes) > 0:
color_attr_node.layer_name = obj.data.color_attributes[0].name
else:
if len(obj.data.vertex_colors) > 0:
color_attr_node.layer_name = obj.data.vertex_colors[0].name
# Set positions for the nodes
color_attr_node.location = (-400, 0)
principled_node.location = (-200, 0)
material_output_node.location = (100, 0)
# Create links between nodes
links.new(color_attr_node.outputs[0], principled_node.inputs[0])
links.new(principled_node.outputs[0], material_output_node.inputs[0])
case "HDRT":
if bpy.app.version >= (4, 2, 0):
material.surface_render_method = 'BLENDED'
else:
material.blend_method = 'OPAQUE'
# Store the color of the existing Principled BSDF or Emissive node (if any)
stored_color = None
for node in material.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED' or node.type == 'EMISSION':
if is_node_connected(material, node):
stored_color = tuple(node.inputs[0].default_value)
break
# Store the image of the existing image texture node (if any)
stored_image = None
stored_hdr_image = None
for node in material.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image:
# An extra check to make sure we're not using the HDR texture as the albedo
if not ('OPEN_EXR' in node.image.file_format or 'HDR' in node.image.file_format or 'lightmap' in node.image.name_full):
if is_node_connected(material, node):
stored_image = node.image
break
for node in material.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image:
# Find the HDR texture in the material and store it, if one was already present
if 'OPEN_EXR' in node.image.file_format or 'HDR' in node.image.file_format or 'lightmap' in node.image.name_full:
stored_hdr_image = node.image
break
# Clear existing nodes and check if the designated "skipped texture" was stored
material.node_tree.nodes.clear()
if stored_image != None:
if skip_texture != "":
if skip_texture in stored_image.filepath:
stored_image = None
# Create necessary nodes
uv_map_node = None
uv_hdr_map_node = None
img_tex_node = None
mix_color_node = None
hdr_tex_node = material.node_tree.nodes.new(type='ShaderNodeTexImage')
uv_hdr_map_node = material.node_tree.nodes.new(type='ShaderNodeUVMap')
if stored_image != None:
uv_map_node = material.node_tree.nodes.new(type='ShaderNodeUVMap')
img_tex_node = material.node_tree.nodes.new(type='ShaderNodeTexImage')
mix_color_node = material.node_tree.nodes.new(type=mix_node_type)
if "MixRGB" not in mix_node_type:
mix_color_node.data_type = 'RGBA'
mix_color_node.blend_type = 'MULTIPLY'
if "MIX_RGB" in mix_color_node.type:
mix_color_node.use_clamp = False
else:
mix_color_node.clamp_factor = False
mix_color_node.clamp_result = False
mix_color_node.inputs[0].default_value = 1.0 # Set the factor to 1.0
img_tex_node.image = stored_image
if len(obj.data.uv_layers) > 0:
uv_map_node.uv_map = obj.data.uv_layers[0].name
emission_node = material.node_tree.nodes.new(type='ShaderNodeEmission')
material_output_node = material.node_tree.nodes.new(type='ShaderNodeOutputMaterial')
# Copy the original base color over
if stored_color != None:
emission_node.inputs[0].default_value = stored_color
# Arrange nodes for clarity
if stored_image != None:
img_tex_node.location = (-500, 0)
mix_color_node.location = (-200, 100)
uv_map_node.location = (-700, 0)
hdr_tex_node.location = (-500, 350)
uv_hdr_map_node.location = (hdr_tex_node.location.x - 200, hdr_tex_node.location.y)
emission_node.location = (0, 100)
material_output_node.location = (200, 100)
# Look for an HDR texture already stored in this Blender file, if one wasn't found earlier
if stored_hdr_image != None:
hdr_tex_node.image = stored_hdr_image
else:
for searched_img in bpy.data.images:
if 'OPEN_EXR' in searched_img.file_format or 'HDR' in searched_img.file_format or 'lightmap' in searched_img.name_full:
hdr_tex_node.image = searched_img
hdr_tex_node.label = "HDR Lightmap"
uv_hdr_map_node.uv_map = "lightmap"
# Link nodes
links = material.node_tree.links
print(f"\nStored image is: {stored_image}")
if stored_image != None:
if "MixRGB" not in mix_node_type:
links.new(img_tex_node.outputs[0], mix_color_node.inputs[7])
links.new(hdr_tex_node.outputs[0], mix_color_node.inputs[6])
links.new(mix_color_node.outputs[2], emission_node.inputs[0])
else:
links.new(img_tex_node.outputs[0], mix_color_node.inputs[2])
links.new(hdr_tex_node.outputs[0], mix_color_node.inputs[1])
links.new(mix_color_node.outputs[0], emission_node.inputs[0])
links.new(emission_node.outputs[0], material_output_node.inputs[0])
links.new(uv_map_node.outputs[0], img_tex_node.inputs[0])
else:
links.new(hdr_tex_node.outputs[0], emission_node.inputs[0])
links.new(emission_node.outputs[0], material_output_node.inputs[0])
links.new(uv_hdr_map_node.outputs[0], hdr_tex_node.inputs[0])
case "PP":
node_tree = material.node_tree
uv_map_nodes = [node for node in node_tree.nodes if node.type == 'UVMAP']
for uv_map_node in uv_map_nodes:
connections = [link.to_socket for link in uv_map_node.outputs[0].links]
# Create a Separate XYZ node
separate_xyz_node = node_tree.nodes.new(type='ShaderNodeSeparateXYZ')
separate_xyz_node.label = "Ping Pong Separate"
separate_xyz_node.location = uv_map_node.location.x, uv_map_node.location.y + 200
node_tree.links.new(uv_map_node.outputs[0], separate_xyz_node.inputs[0])
# Create the first Math node
math_node_1 = node_tree.nodes.new(type='ShaderNodeMath')
math_node_1.operation = 'PINGPONG'
math_node_1.label = "Ping Pong X"
math_node_1.inputs[1].default_value = 1.0
math_node_1.location = separate_xyz_node.location.x + 200, separate_xyz_node.location.y
node_tree.links.new(separate_xyz_node.outputs[0], math_node_1.inputs[0])
# Create the second Math node
math_node_2 = node_tree.nodes.new(type='ShaderNodeMath')
math_node_2.operation = 'PINGPONG'
math_node_2.label = "Ping Pong Y"
math_node_2.inputs[1].default_value = 1.0
math_node_2.location = separate_xyz_node.location.x + 200, separate_xyz_node.location.y - 100
node_tree.links.new(separate_xyz_node.outputs[1], math_node_2.inputs[0])
# Create the Combine XYZ node
combine_xyz_node = node_tree.nodes.new(type='ShaderNodeCombineXYZ')
combine_xyz_node.location = math_node_1.location.x + 200, (math_node_1.location.y + math_node_2.location.y) / 2
combine_xyz_node.label = "Ping Pong Combine"
combine_xyz_node.inputs[2].default_value = 0.0
node_tree.links.new(math_node_1.outputs['Value'], combine_xyz_node.inputs[0])
node_tree.links.new(math_node_2.outputs['Value'], combine_xyz_node.inputs[1])
# Reconnect the original connections to the Combine XYZ node
for socket in connections:
node_tree.links.new(combine_xyz_node.outputs['Vector'], socket)
case "NO_PP":
node_tree = material.node_tree
pp_separate_nodes = [node for node in node_tree.nodes if node.label == 'Ping Pong Separate']
for pp_separate_node in pp_separate_nodes:
in_node = pp_separate_node.inputs[0].links[0].from_node
pp_x_node = pp_separate_node.outputs[0].links[0].to_node
pp_y_node = pp_separate_node.outputs[1].links[0].to_node
pp_combine_node = pp_x_node.outputs[0].links[0].to_node
out_nodes = [link.to_node for link in pp_combine_node.outputs[0].links]
# Reconnect the original connections to the Combine XYZ node
for out_node in out_nodes:
node_tree.links.new(in_node.outputs[0], out_node.inputs[0])
# Remove Ping Pong nodes
node_tree.nodes.remove(pp_x_node)
node_tree.nodes.remove(pp_y_node)
node_tree.nodes.remove(pp_combine_node)
node_tree.nodes.remove(pp_separate_node)
num_processed += 1
obj.data.update()
display_msg_box(
f'Applied template to {num_processed} material(s).', 'Info', 'INFO')
return {'FINISHED'}
# Find Active Face Texture operator
class FindActiveFaceTexture(bpy.types.Operator):
"""Finds the diffuse texture assigned to the current active face, and selects it in the Image Editor"""
bl_idname = "image.find_active_diffuse"
bl_label = "Find Active Face Texture"
bl_options = {'REGISTER'}
def execute(self, context):
list_of_mats = check_for_selected()
# Check if any objects are selected.
if list_of_mats != False:
# Get the active object
obj = bpy.context.active_object
if obj.type == "MESH":
# Get active material
mat = obj.active_material
node_tree = mat.node_tree
# Find the diffuse image texture node
diffuse = None
for node in node_tree.nodes:
if node.type == "TEX_IMAGE":
if len(node.outputs[0].links) > 0:
for link in node.outputs[0].links:
# Check if Image Texture is connected to a "color" socket," or a Mix node's A and B sockets
if "Color" in link.to_socket.name or "A" in link.to_socket.name or "B" in link.to_socket.name:
diffuse = node
break
# Check if diffuse was found already, and if so, break out of the loop
if diffuse != None:
break
# If diffuse was found:
if diffuse != None:
# Find active image editor
for screen in bpy.context.screen.areas:
if screen.type == "IMAGE_EDITOR":
for space in screen.spaces:
if space.type == "IMAGE_EDITOR":
space.image = diffuse.image
else:
continue
else:
display_msg_box(
'No texture was found. Make sure the active object has at least 1 material, with at least 1 texture assigned to it. Then go into Edit mode and select a face, then try running the operation again', 'Error', 'ERROR')
return {'FINISHED'}
# Copy Active Face Texture operator
class CopyActiveFaceTexture(bpy.types.Operator):
"""Copies the diffuse texture assigned to the current active face, and stores it for pasting in another face's material"""
bl_idname = "image.copy_active_diffuse"
bl_label = "Copy Active Face Texture"
bl_options = {'REGISTER'}
def execute(self, context):
list_of_mats = check_for_selected()
# Check if any objects are selected.
if list_of_mats != False:
# Get the active object
obj = bpy.context.active_object
if obj.type == "MESH":
# Get active material
mat = obj.active_material
node_tree = mat.node_tree
# Find the diffuse image texture node
diffuse = None
for node in node_tree.nodes:
if node.type == "TEX_IMAGE":
if len(node.outputs[0].links) > 0:
for link in node.outputs[0].links:
# Check if Image Texture is connected to a "color" socket," or a Mix node's A and B sockets
if "Color" in link.to_socket.name or "A" in link.to_socket.name or "B" in link.to_socket.name:
# Check if there's an actual image texture loaded in the node
if node.image:
diffuse = node
break
# Check if diffuse was found already, and if so, break out of the loop
if diffuse != None:
break
# If diffuse was found:
if diffuse != None:
bpy.context.scene.MatBatchProperties.CopiedTexture = diffuse.image.name
else:
display_msg_box(
'No texture was found. Make sure the active object has at least 1 material, with at least 1 texture assigned to it. Then go into Edit mode and select a face, then try running the operation again', 'Error', 'ERROR')
return {'FINISHED'}
# Paste Active Face Texture operator
class PasteActiveFaceTexture(bpy.types.Operator):
"""Pastes the previously copied diffuse texture into the selected, active face's active material"""
bl_idname = "image.paste_active_diffuse"
bl_label = "Paste Active Face Texture"
bl_options = {'REGISTER'}
def execute(self, context):
list_of_mats = check_for_selected()
# Check if any objects are selected.
if list_of_mats != False:
# Get the active object
obj = bpy.context.active_object
if obj.type == "MESH":
# Get active material
mat = obj.active_material
node_tree = mat.node_tree
# Check if a texture was already copied
copied_tex = bpy.context.scene.MatBatchProperties.CopiedTexture
if copied_tex != "" and bpy.data.images[copied_tex]:
# Find the diffuse image texture node
diffuse = None
for node in node_tree.nodes:
if node.type == "TEX_IMAGE":
if len(node.outputs[0].links) > 0:
for link in node.outputs[0].links:
# Check if Image Texture is connected to a "color" socket," or a Mix node's A and B sockets
if "Color" in link.to_socket.name or "A" in link.to_socket.name or "B" in link.to_socket.name:
diffuse = node
break
# Check if diffuse was found already, and if so, break out of the loop
if diffuse != None:
break
# If diffuse was found:
if diffuse != None:
diffuse.image = bpy.data.images[copied_tex]
else:
display_msg_box(
'No image texture node was found. Make sure the active object has at least 1 material, with at least 1 image texture node in its node tree.', 'Error', 'ERROR')
return {'FINISHED'}
# Copy Texture to Material Name operator
class CopyTexToMatName(bpy.types.Operator):
"""Finds the diffuse texture in all materials, in all selected objects, and renames the material to the diffuse's texture name (minus the file extension). If the material contains multiple diffuse textures, all of them will be appended to the material name"""
bl_idname = "material.copy_tex_to_mat_name"
bl_label = "Copy Diffuse Texture to Material Name"
bl_options = {'REGISTER'}
def execute(self, context):
num_processed = 0
list_of_mats = check_for_selected()
mats_to_rename = dict()
# Check if any objects are selected.
if list_of_mats != False:
# For each selected object
for obj in bpy.context.selected_objects:
if obj.type == "MESH":
# For each material
for mat in list_of_mats:
diffuse_textures_found = set()
if mat in bpy.data.materials.keys():
node_tree = bpy.data.materials[mat].node_tree
# Find the diffuse image texture node
if node_tree != None:
for node in node_tree.nodes:
if node.type == "TEX_IMAGE" and is_node_connected(bpy.data.materials[mat], node):
# Check if a file is actually loaded in this image texture node
if node.image:
if len(node.outputs[0].links) > 0:
for link in node.outputs[0].links:
# Check if Image Texture is connected to a "color" socket," or a Mix node's A and B sockets
if "Color" in link.to_socket.name or "A" in link.to_socket.name or "B" in link.to_socket.name or link.to_socket.type == 'RGBA':
# Get the texture name, but without the file extension
diffuse_name = node.image.name.split(".", 1)[0]
diffuse_textures_found.add(diffuse_name)
continue
else:
continue
if len(diffuse_textures_found) != None:
mats_to_rename[mat] = diffuse_textures_found
if len(mats_to_rename.keys()) != 0:
# If diffuse was found:
for mat in mats_to_rename.keys():
finalized_name = ""
# Change the current material's name
for texture in mats_to_rename[mat]:
if finalized_name != "":
finalized_name = finalized_name + " "
finalized_name = finalized_name + texture
# Check if a material with that name already exists
if finalized_name in bpy.data.materials.keys():
if mat in obj.material_slots.keys():
# Compare the node count for the materials - make sure they have the same amount of nodes, to avoid mismatching any unique but very similar materials
if len(bpy.data.materials[mat].node_tree.nodes) != len(bpy.data.materials[finalized_name].node_tree.nodes):
finalized_name += " " + str(len(bpy.data.materials[mat].node_tree.nodes))
if finalized_name in bpy.data.materials.keys():
if mat in obj.material_slots.keys():
old_index = obj.material_slots[mat].slot_index
if obj.material_slots[old_index].material.name != finalized_name:
obj.material_slots[old_index].material = bpy.data.materials[finalized_name]
num_processed += 1
else:
if mat in bpy.data.materials.keys():
bpy.data.materials[mat].name = finalized_name
if mat in list_of_mats:
list_of_mats.remove(mat)
num_processed += 1
display_msg_box(
f'Renamed {num_processed} material(s).', 'Info', 'INFO')
else:
display_msg_box(
'No diffuse textures found.', 'Error', 'ERROR')
return {'FINISHED'}
# Isolate by Material Trait operator
class IsolateByMatTrait(bpy.types.Operator):
"""Searches any currently selected meshes for assigned materials with a specific trait, and isolates those polygons into a separate object (and optionally, a dedicated collection)"""
bl_idname = "material.isolate_by_trait"
bl_label = "Isolate by Material Trait"
bl_options = {'REGISTER'}
def execute(self, context):
list_of_mats = check_for_selected()
isolate_to_collection = bpy.context.scene.MatBatchProperties.IsolateCollection
principled_alpha_slot = 21 if bpy.app.version < (4, 0, 0) else 4
principled_emissive_color_slot = 19 if bpy.app.version < (4, 0, 0) else 27
principled_emissive_strength_slot = 20 if bpy.app.version < (4, 0, 0) else 28
trait = bpy.context.scene.MatBatchProperties.IsolateTrait
matching_materials = set()
separated_objs = set()
root_collection = None
# Check if any objects are selected.
if list_of_mats != False:
for obj in bpy.context.selected_objects:
if obj.type == "MESH":
for mat in list_of_mats:
material = bpy.data.materials[mat]
material.use_nodes = True
is_transparent = False
is_emissive = False
is_animated = False
if material and material.use_nodes:
for node in material.node_tree.nodes:
if is_node_connected(material, node):
if trait == "transparent":
# Transparency scenario 1 - Principled BSDF with alpha input
if node.type == "BSDF_PRINCIPLED":
if len(node.inputs[principled_alpha_slot].links) > 0 or node.inputs[principled_alpha_slot].default_value != 1:
is_transparent = True
break
# Transparency scenario 2 - Transparent BSDF
elif node.type == "BSDF_TRANSPARENT":
is_transparent = True
break
if trait == "emissive":
# Emissive scenario 1 - Principled BSDF with emissive input
if node.type == "BSDF_PRINCIPLED":
em_color_slot = node.inputs[principled_emissive_color_slot]
em_strength_slot = node.inputs[principled_emissive_strength_slot]
if em_strength_slot.default_value != 0.0 or len(em_strength_slot.links) > 0:
if len(em_color_slot.links) > 0:
is_emissive = True
break
elif list(em_color_slot.default_value) != [0.0,0.0,0.0,1.0] and list(em_color_slot.default_value) != [0.0,0.0,0.0,0.0]:
is_emissive = True
break
# Emissive scenario 2 - Emission shader
elif node.type == "EMISSION":
is_emissive = True
break
if trait == "animated":
# Animated scenario 1 - Animation data exists and isn't "none"
if material.node_tree.animation_data != None:
is_animated = True
break
# Animated scenario 2 - Image Sequence node
elif node.type == "TEX_IMAGE":
if node.image.source == 'SEQUENCE':
is_animated = True
break
if is_transparent and trait == "transparent":
matching_materials.add(material.name)
if is_emissive and trait == "emissive":
matching_materials.add(material.name)
if is_animated and trait == "animated":
matching_materials.add(material.name)
materials_matched_count = 0
if len(matching_materials) > 0:
# Search all selected mesh objects for faces that have this material assigned.
mesh_objects = [obj for obj in bpy.context.selected_objects if obj.type == "MESH" and not (obj.name.endswith("_" + trait))]
if len(mesh_objects) == 0:
display_msg_box(
f'Selected trait has already been fully isolated in the selected object(s).', 'Info', 'INFO')
return {'FINISHED'}
bpy.ops.object.select_all(action='DESELECT')
for obj in mesh_objects:
obj.hide_set(False)
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
matching_faces = set()
for material in matching_materials:
material_matched = False
for face_index in find_faces_with_material(obj, material):
matching_faces.add(face_index)
material_matched = True
if material_matched:
materials_matched_count += 1
if len(matching_faces) > 0:
if len(obj.data.polygons) == len(matching_faces):
continue
separated_obj = separate_faces(obj, matching_faces)
separated_obj.name = obj.name + "_" + trait
separated_objs.add(separated_obj)
if isolate_to_collection:
if trait.capitalize() in bpy.data.collections.keys():
root_collection = bpy.data.collections[trait.capitalize()]
else:
root_collection = bpy.data.collections.new(trait.capitalize())
bpy.context.scene.collection.children.link(root_collection)
for obj in separated_objs:
# Unlink the new collision model from other collections
obj_collections = [
c for c in bpy.data.collections if obj.name in c.objects.keys()]
for c in obj_collections:
if obj.name in c.objects.keys():
c.objects.unlink(obj)
if obj.name in bpy.context.scene.collection.objects.keys():
bpy.context.scene.collection.objects.unlink(obj)
root_collection.objects.link(obj)
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
display_msg_box(
f'Isolated {materials_matched_count} {trait} material(s) into {len(separated_objs)} separate object(s).', 'Info', 'INFO')
return {'FINISHED'}
# Update Backface Culling operator
class UpdateBackfaceCulling(bpy.types.Operator):
"""Updates the backface culling settings in all materials in all selected objects, based on the settings above"""
bl_idname = "material.update_backface_culling"
bl_label = "Update Backface Culling"
bl_options = {'REGISTER'}
def execute(self, context):
num_processed = 0
list_of_mats = check_for_selected()
# Check if any objects are selected.
if list_of_mats != False:
backface_culling_camera = bpy.context.scene.MatBatchProperties.BackfaceCamera
backface_culling_shadow = bpy.context.scene.MatBatchProperties.BackfaceShadow
backface_culling_lightprobe = bpy.context.scene.MatBatchProperties.BackfaceLightProbe
for mat in list_of_mats:
material = bpy.data.materials[mat]
material.use_backface_culling = backface_culling_camera
if bpy.app.version >= (4, 1, 0):
material.use_backface_culling_shadow = backface_culling_shadow
if bpy.app.version >= (4, 2, 0):
material.use_backface_culling_lightprobe_volume = backface_culling_lightprobe
num_processed += 1
display_msg_box(
f'Updated backface culling settings in {num_processed} material(s).', 'Info', 'INFO')
return {'FINISHED'}
# Rename All Textures by Hash
class RenameTexturesByHash(bpy.types.Operator):
"""Rename ALL textures in this Blender file by generating a unique MD5-generated hash for each texture"""
bl_idname = "material.rename_textures_by_hash"
bl_label = "Rename All Textures by Hash"
bl_options = {'REGISTER'}
def execute(self, context):
num_processed = 0
num_dupes_removed = 0
duplicates_to_remove = set()
original_images = set()
def generate_hash_from_image(image):
pixels = list(image.pixels) # Get image pixels as a flat list
pixel_data = ''.join([str(int(p * 255)) for p in pixels]) # Convert to string
hash_object = hashlib.md5(pixel_data.encode()) # Generate MD5 hash
return hash_object.hexdigest() # Return hexadecimal MD5 hash string
for image in bpy.data.images:
original_images.add(image)
for image in original_images:
hash_name = generate_hash_from_image(image)[:32]
if hash_name[:32] in bpy.data.images.keys():
duplicates_to_remove.add(hash_name)
image.name = hash_name
num_processed += 1
for material in bpy.data.materials:
if material.node_tree:
for node in material.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image.name.split(".")[0] in duplicates_to_remove:
node.image = bpy.data.images[node.image.name.split(".")[0]]
else:
continue
# Remove any duplicate images
for image in bpy.data.images:
if "." in image.name:
bpy.data.images.remove(image)
num_dupes_removed += 1
if len(bpy.data.images) > 0:
display_msg_box(
f'Renamed {num_processed} texture(s).\nRemoved {str(num_dupes_removed)} duplicate textures.', 'Info', 'INFO')
else:
display_msg_box(
'No textures found.', 'Error', 'ERROR')
return {'FINISHED'}
# End classes
def menu_func(self, context):
self.layout.operator(PasteBakeTargetNode.bl_idname)
self.layout.operator(CopyBakeTargetNode.bl_idname)
self.layout.operator(DeleteBakeTargetNode.bl_idname)
self.layout.operator(AssignUVMapNode.bl_idname)
self.layout.operator(OverwriteUVSlotName.bl_idname)
self.layout.operator(SetUVSlotAsActive.bl_idname)
self.layout.operator(AssignVCToNodes.bl_idname)
self.layout.operator(SetBlendMode.bl_idname)
self.layout.operator(SetAsTemplateNode.bl_idname)
self.layout.operator(UnifyNodeSettings.bl_idname)
self.layout.operator(SwitchShader.bl_idname)
self.layout.operator(ApplyMatTemplate.bl_idname)
self.layout.operator(FindActiveFaceTexture.bl_idname)
self.layout.operator(CopyTexToMatName.bl_idname)
self.layout.operator(IsolateByMatTrait.bl_idname)
self.layout.operator(RenameTexturesByHash.bl_idname)
def imageeditor_menu_func(self, context):
self.layout.operator(FindActiveFaceTexture.bl_idname)
self.layout.operator(CopyActiveFaceTexture.bl_idname)
self.layout.operator(PasteActiveFaceTexture.bl_idname)
self.layout.operator(RenameTexturesByHash.bl_idname)
self.layout.operator(CopyTexToMatName.bl_idname)
# MATERIALS PANEL
class MaterialBatchToolsPanel(bpy.types.Panel):
bl_label = 'Material Batch Tools'
bl_idname = "MATERIAL_PT_matbatchtools"
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_context = 'material'
@ classmethod
def poll(cls, context):
return (context.object != None)
def draw_header(self, context):
layout = self.layout
def draw(self, context):
layout = self.layout
layout.enabled = (len(bpy.context.selected_objects) > 0)
class MaterialBatchToolsSubPanel_Nodes(bpy.types.Panel):
bl_parent_id = "MATERIAL_PT_matbatchtools"
bl_label = 'Nodes'
bl_idname = "MATERIAL_PT_matbatchtools_nodes"
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_options = {"DEFAULT_CLOSED"}
bl_context = 'material'
@ classmethod
def poll(cls, context):
return (context.object != None)
def draw_header(self, context):
layout = self.layout
def draw(self, context):
layout = self.layout
# Node Unify UI
boxUnify = layout.box()
boxUnify.label(text="Node Unify")
rowUnify1 = boxUnify.row()
rowUnify1.label(text="Saved Node: " +
bpy.context.scene.MatBatchProperties.SavedNodeType)
rowUnify2 = boxUnify.row()
rowUnify3 = boxUnify.row()
rowUnify4 = boxUnify.row()
rowUnify2.prop(
bpy.context.scene.MatBatchProperties, "UnifyFilterLabel")
rowUnify3.operator("material.set_as_template_node")
rowUnify4.operator("material.unify_node_settings")
rowUnify4.enabled = (
bpy.context.scene.MatBatchProperties.SavedNodeName != "")
layout.separator()
# Bake Target Node UI
boxBakeTarget = layout.box()
boxBakeTarget.label(text="Bake Target Node")
rowBakeTarget0 = boxBakeTarget.row()
rowBakeTarget1 = boxBakeTarget.row()
rowBakeTarget2 = boxBakeTarget.row()
rowBakeTarget0.operator("material.copy_bake_target")
rowBakeTarget0.operator("material.paste_bake_target")
rowBakeTarget1.operator("material.delete_bake_target")
rowBakeTarget2.prop(
bpy.context.scene.MatBatchProperties, "BakeTargetNodeColorEnable")
rowBakeTarget2.prop(
bpy.context.scene.MatBatchProperties, "BakeTargetNodeColor")
layout.separator()
# Material Template UI
boxTemplate = layout.box()
boxTemplate.label(text="Material Templates")
rowSwitchShader1 = boxTemplate.row()
rowSwitchShader2 = boxTemplate.row()
rowTemplate1 = boxTemplate.row()
rowTemplate2 = boxTemplate.row()
rowTemplate3 = boxTemplate.row()
rowSwitchShader1.prop(
bpy.context.scene.MatBatchProperties, "SwitchShaderTarget")
rowSwitchShader2.operator("material.switch_shader")
rowTemplate1.prop(bpy.context.scene.MatBatchProperties, "Template")
rowTemplate2.prop(bpy.context.scene.MatBatchProperties, "SkipTexture")
rowTemplate3.operator("material.apply_mat_template")
class MaterialBatchToolsSubPanel_Transparency(bpy.types.Panel):
bl_parent_id = "MATERIAL_PT_matbatchtools"
bl_label = 'Transparency & Backface Culling'
bl_idname = "MATERIAL_PT_matbatchtools_transparency"
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_options = {"DEFAULT_CLOSED"}
bl_context = 'material'
@ classmethod
def poll(cls, context):
return (context.object != None)
def draw_header(self, context):
layout = self.layout
def draw(self, context):
layout = self.layout
# Transparency UI
boxTransparency = layout.box()
boxTransparency.label(text="Transparency")
rowTransparency1 = boxTransparency.row()
rowTransparency2 = boxTransparency.row()
rowTransparency3 = boxTransparency.row()
rowTransparency4 = boxTransparency.row()
rowTransparency5 = boxTransparency.row()
rowTransparency1.prop(
bpy.context.scene.MatBatchProperties, "AlphaBlendMode")
rowTransparency2.prop(bpy.context.scene.MatBatchProperties,
"AlphaBlendFilter")
rowTransparency3.prop(bpy.context.scene.MatBatchProperties,
"AlphaThreshold")
rowTransparency3.enabled = (
bpy.context.scene.MatBatchProperties.AlphaBlendMode == "CLIP")
rowTransparency4.prop(
bpy.context.scene.MatBatchProperties, "AlphaPrincipledRemove")
rowTransparency4.enabled = (
bpy.context.scene.MatBatchProperties.AlphaBlendMode == "OPAQUE")
rowTransparency5.operator("material.set_blend_mode")
# Backface Culling UI
boxBackfaceCulling = layout.box()
boxBackfaceCulling.label(text="Backface Culling")
rowBackfaceCulling1 = boxBackfaceCulling.row()
rowBackfaceCulling2 = boxBackfaceCulling.row()
rowBackfaceCulling3 = boxBackfaceCulling.row()
rowBackfaceCulling4 = boxBackfaceCulling.row()
rowBackfaceCulling1.prop(
bpy.context.scene.MatBatchProperties, "BackfaceCamera")
rowBackfaceCulling2.prop(bpy.context.scene.MatBatchProperties,
"BackfaceShadow")
rowBackfaceCulling3.prop(bpy.context.scene.MatBatchProperties,
"BackfaceLightProbe")
rowBackfaceCulling2.enabled = bpy.app.version >= (4, 2, 0)
rowBackfaceCulling3.enabled = bpy.app.version >= (4, 2, 0)
rowBackfaceCulling4.operator("material.update_backface_culling")
class MaterialBatchToolsSubPanel_UV_VC(bpy.types.Panel):
bl_parent_id = "MATERIAL_PT_matbatchtools"
bl_label = 'UV & Vertex Colors'
bl_idname = "MATERIAL_PT_matbatchtools_uv_vc"
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_options = {"DEFAULT_CLOSED"}
bl_context = 'material'
@ classmethod
def poll(cls, context):
return (context.object != None)
def draw_header(self, context):
layout = self.layout
def draw(self, context):
layout = self.layout
# UV Map Node UI
boxUVMap1 = layout.box()
rowUVMap1 = boxUVMap1.row()
rowUVMap2 = boxUVMap1.row()
rowUVMap3 = boxUVMap1.row()
rowUVMap1.prop(bpy.context.scene.MatBatchProperties,
"UVMapNodeTarget")
rowUVMap2.prop(bpy.context.scene.MatBatchProperties,
"UVMapNodeExtensionFilter")
rowUVMap3.operator("material.assign_uv_map_node")
boxUVMap2 = boxUVMap1.box()
rowUVMap4 = boxUVMap2.row()
rowUVMap5 = boxUVMap2.row()
rowUVMap6 = boxUVMap2.row()
# UV Slot UI
rowUVMap4.prop(bpy.context.scene.MatBatchProperties, "UVSlotIndex")
rowUVMap5.operator("object.overwrite_uv_slot_name")
rowUVMap6.operator("object.set_uv_slot_as_active")\
layout.separator()
# Vertex Colors UI
boxVertexColors = layout.box()
boxVertexColors.label(text="Vertex Colors")
rowVertexColors1 = boxVertexColors.row()
rowVertexColors2 = boxVertexColors.row()
rowVertexColors3 = boxVertexColors.row()
rowVertexColors4 = boxVertexColors.row()
rowVertexColors1.prop(bpy.context.scene.MatBatchProperties, "VCName")
rowVertexColors2.operator("material.assign_vc_to_nodes")
rowVertexColors3.operator("object.rename_vertex_color")
rowVertexColors4.operator("object.convert_vertex_color")
class MaterialBatchToolsSubPanel_Isolate(bpy.types.Panel):
bl_parent_id = "MATERIAL_PT_matbatchtools"
bl_label = 'Isolate'
bl_idname = "MATERIAL_PT_matbatchtools_isolate"
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_options = {"DEFAULT_CLOSED"}
bl_context = 'material'
@ classmethod
def poll(cls, context):
return (context.object != None)
def draw_header(self, context):
layout = self.layout
def draw(self, context):
layout = self.layout
# UV Map Node UI
boxIsolate = layout.box()
rowIsolate1 = boxIsolate.row()
rowIsolate2 = boxIsolate.row()
rowIsolate3 = boxIsolate.row()
rowIsolate1.prop(bpy.context.scene.MatBatchProperties, "IsolateCollection")
rowIsolate2.prop(bpy.context.scene.MatBatchProperties, "IsolateTrait")
rowIsolate3.operator("material.isolate_by_trait")
# End of classes
classes = (
MatBatchProperties,
CopyBakeTargetNode,
PasteBakeTargetNode,
DeleteBakeTargetNode,
AssignUVMapNode,
OverwriteUVSlotName,
SetUVSlotAsActive,
AssignVCToNodes,
RenameVertexColorSlot,
ConvertVertexColor,
SetBlendMode,
SetAsTemplateNode,
UnifyNodeSettings,
SwitchShader,
ApplyMatTemplate,
FindActiveFaceTexture,
CopyActiveFaceTexture,
PasteActiveFaceTexture,
CopyTexToMatName,
IsolateByMatTrait,
UpdateBackfaceCulling,
RenameTexturesByHash,
MaterialBatchToolsPanel,
MaterialBatchToolsSubPanel_Nodes,
MaterialBatchToolsSubPanel_UV_VC,
MaterialBatchToolsSubPanel_Transparency,
MaterialBatchToolsSubPanel_Isolate
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.MatBatchProperties = bpy.props.PointerProperty(
type=MatBatchProperties)
bpy.types.IMAGE_MT_image.append(imageeditor_menu_func)
def unregister():
for cls in classes:
bpy.utils.unregister_class(cls)
del bpy.types.Scene.MatBatchProperties
bpy.types.IMAGE_MT_image.remove(imageeditor_menu_func)
if __name__ == "__main__":
register()