Switch branches/tags
Nothing to show
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
524 lines (442 sloc) 20 KB
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
bl_info = {
"name": "NodeSet",
"author": "Michel Anders (varkenvarken)",
"version": (0, 0, 201708210945),
"blender": (2, 78, 4), # needs support for Principled shader to work
"location": "Node Editor -> Add",
"description": "Add a set of images and configure texture nodes based on names",
"warning": "",
"wiki_url": "",
"category": "Node",
import bpy, blf, bgl
from bpy.types import Operator, Panel, Menu
from bpy.props import FloatProperty, EnumProperty, BoolProperty, IntProperty, StringProperty, FloatVectorProperty, CollectionProperty
from bpy_extras.io_utils import ImportHelper
from mathutils import Vector
from os import path, listdir
from glob import glob
from copy import copy
from collections import OrderedDict as odict
# Addon prefs
class NodeSet(bpy.types.AddonPreferences):
bl_idname = __name__
suffix_color = StringProperty(
name="Color Suffix",
description="Suffix that identifies the Base Color Map")
suffix_roughness = StringProperty(
name="Roughness Suffix",
description="Suffix that identifies the Roughness Map")
suffix_metallic = StringProperty(
name="Metallic Suffix",
description="Suffix that identifies the Metallic Map")
suffix_normal = StringProperty(
name="Normal Suffix",
description="Suffix that identifies the Normal Map")
suffix_height = StringProperty(
name="Height Suffix",
description="Suffix that identifies the Height Map")
suffix_emission = StringProperty(
name="Emission Suffix",
description="Suffix that identifies the Emission Map")
suffix_diffuse = StringProperty(
name="Diffuse Suffix",
description="Suffix that identifies the Diffuse Map")
suffix_specular = StringProperty(
name="Specular Suffix",
description="Suffix that identifies the Specular Map")
suffix_glossiness = StringProperty(
name="Glossiness Suffix",
description="Suffix that identifies the Glossiness Map")
suffix_opacity = StringProperty(
name="Opacity Suffix",
description="Suffix that identifies the Opacity Map")
suffix_ao = StringProperty(
name="AO Suffix",
description="Suffix that identifies the Ambient Occlusion Map")
case_sensitive = BoolProperty(
name="Case Sensitive",
description="Case sensitivity of suffix matching")
link_if_exist = BoolProperty(
name="Link Existing Tex Files",
description="Link to texture files, if it's already exist in the Scene")
use_objectspace = BoolProperty(
name="Object Space in Normal Map",
description="Use Object Space instead of Tangent Space for Normal Map Node")
filter_to_color = BoolProperty(
name="Filter Visibility of Bitmaps in File Browser",
description="Limit visibility of texture files by wildcard")
filter_fragment = StringProperty(
name="Wildcard (Keyword)",
description="Keyword to use, for filtering long list of files")
extensions = StringProperty(
name="File Extensions Support",
default='png, jpeg, targa, tiff, exr, hdr',
description="Comma separated list of extensions for file search")
frame_color = FloatVectorProperty(
name = "Frame Node Color",
description = "Background color of the Frame Node",
default = (0.6, 0.6, 0.6),
min = 0, max = 1, step = 1, precision = 3,
subtype = 'COLOR_GAMMA',
size = 3)
def draw(self, context):
layout = self.layout
col = layout.column()
col.label("--- For PBR-Metal-Rough Workflow ---")
col.prop(self, "suffix_color")
col.prop(self, "suffix_roughness")
col.prop(self, "suffix_metallic")
col.prop(self, "suffix_normal")
col.prop(self, "suffix_height")
col.prop(self, "suffix_emission")
col.label("--- For PBR-Spec-Gloss Workflow ---")
col.prop(self, "suffix_diffuse")
col.prop(self, "suffix_specular")
col.prop(self, "suffix_glossiness")
col.label("--- For Other Additional Bitmaps --- ")
col.prop(self, "suffix_opacity")
col.prop(self, "suffix_ao")
col.label(" ")
row = col.row()
row.prop(self, "case_sensitive")
row.prop(self, "link_if_exist")
row.prop(self, "use_objectspace")
row = col.row()
row.prop(self, "filter_to_color")
if self.filter_to_color:
row.prop(self, "filter_fragment")
col.prop(self, "extensions")
col.label(" ")
row = col.row()
row.prop(self, "frame_color")
# from node wrangler
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)
d = 0
return d
# mainly from node wrangler
def get_nodes_links(context):
space = context.space_data
tree = space.node_tree
nodes, links = None, None
if tree:
nodes = tree.nodes
links = tree.links
active =
context_active = context.active_node
# check if we are working on regular node tree or node group is currently edited.
# if group is edited - active node of space_tree is the group
# if context.active_node != space active node - it means that the group is being edited.
# in such case we set "nodes" to be nodes of this group, "links" to be links of this group
# if context.active_node == space.active_node it means that we are not currently editing group
is_main_tree = True
if active:
is_main_tree = context_active == active
if not is_main_tree: # if group is currently edited
tree = active.node_tree
nodes = tree.nodes
links = tree.links
return nodes, links
def link_nodes(nodetree, fromnode, fromsocket, tonode, tosocket):
socket_in = tonode.inputs[tosocket]
socket_out = fromnode.outputs[fromsocket]
return, socket_out)
def sanitize(s):
t = str.maketrans("",""," \t/\\-_:;[]")
return s.translate(t)
# the node placement stuff at the start of execute() is from node wrangler
class NSAddMultipleImages(Operator):
"""Add a collection of bitmaps, exported from SP"""
bl_idname = 'node.ns_add_multiple_images'
bl_label = 'Import SP Bitmaps'
bl_options = {'REGISTER', 'UNDO'}
shader = BoolProperty(
name="Add Shaders",
description="Add Principled BSDF, Normal Map and Bump Node")
directory = StringProperty(subtype="DIR_PATH")
files = CollectionProperty(type=bpy.types.OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'})
filter_glob = StringProperty(
maxlen=255, # Max internal buffer length, longer would be clamped.
filepath = StringProperty(
name="File Path",
description="Filepath used for importing the file",
# needed for mix-ins
order = [
def poll(cls, context):
return context.space_data.node_tree is not None
def invoke(self, context, event):
settings = context.user_preferences.addons[__name__].preferences
if settings.filter_to_color:
self.filter_glob = "*" + settings.filter_fragment + "*"
self.filter_glob = "*.*"
return {'RUNNING_MODAL'}
def find_in_nodes(nodes,ttype):
for n in nodes:
if n.label.lower().find(ttype.lower())>=0:
return n
return None
def execute(self, context):
settings = context.user_preferences.addons[__name__].preferences
nodes, links = get_nodes_links(context)
if nodes is None:
return {'FINISHED'}
addshader = (context.space_data.node_tree.type == 'SHADER' and self.shader)
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 - (700 if addshader else 0) # place new nodes at far left with enough space for new nodes
yloc = 0
for node in nodes: = False
yloc += node_mid_pt(node, 'y')
yloc = yloc/len(nodes)
xloc = 0
yloc = 0
orgx, orgy = xloc, yloc
if context.space_data.node_tree.type == 'SHADER':
node_type = "ShaderNodeTexImage"
elif context.space_data.node_tree.type == 'COMPOSITING':
node_type = "CompositorNodeImage"
else:{'ERROR'}, "Unsupported Node Tree type!")
return {'CANCELLED'}
# an ordered dictionary will cause the loaded images to be in alphabetical order
# which is convenient because names are often quite long and hence unreadable in
# a collapsed node
suffixes = odict()
if settings.suffix_color != '' :
suffixes[settings.suffix_color] = False
if settings.suffix_diffuse != '' :
suffixes[settings.suffix_diffuse] = False
if settings.suffix_emission != '' :
suffixes[settings.suffix_emission] = False
if settings.suffix_glossiness != '' :
suffixes[settings.suffix_glossiness] = False
if settings.suffix_height != '' :
suffixes[settings.suffix_height] = False
if settings.suffix_metallic != '' :
suffixes[settings.suffix_metallic] = False
if settings.suffix_normal != '' :
suffixes[settings.suffix_normal] = False
if settings.suffix_opacity != '' :
suffixes[settings.suffix_opacity] = False
if settings.suffix_roughness != '' :
suffixes[settings.suffix_roughness] = False
if settings.suffix_specular != '' :
suffixes[settings.suffix_specular] = False
if settings.suffix_ao != '' :
suffixes[settings.suffix_ao] = False
def endswith(s, suffix):
if not settings.case_sensitive:
s = s.lower()
suffix = suffix.lower()
return s.endswith(suffix)
new_nodes = []
prefix = None
ext = None
# first we get all explicitely selected files and decide, which bitmaps needs to be a color data
for f in self.files:
fname =
basename = path.basename(path.splitext(fname)[0])
ext = path.splitext(fname)[1]
node =
node.label = fname
node.hide = True
node.width_hidden = 50
node.location.x = xloc
node.location.y = yloc
yloc -= 40
img =,settings.link_if_exist)
node.image = img
# we only mark a file with a color suffix as color data, all other as non color data
node.color_space = 'COLOR' if (endswith(basename, settings.suffix_color) or endswith(basename, settings.suffix_diffuse)) else 'NONE' # that is the string NONE
# we check if the loaded file is one in the specified list of suffixes and mark that one as seen
for k,v in suffixes.items():
if endswith(basename, k):
suffixes[k] = True
prefix = fname[:-len(k+ext)] # prefix is the filepath
node.label = sanitize(k)
# the next step is to load additional files if a suffix is specified and the user did not explicitely select it already
# however, if a texture was selected that has no recognized suffix, we skip this
if prefix is not None:
files = listdir(
fileslower = [f.lower() for f in files]
for k,v in suffixes.items():
if not v : # haven't loaded this filename yet explicitely
for ext in settings.extensions.split(','):
fname = prefix + k + '.' + ext.strip()
#print('checking ',fname)
if settings.case_sensitive:
if fname not in files:
#print('case sensitive, NOT FOUND')
if fname.lower() not in fileslower:
#print('case INsensitive, NOT FOUND')
for f in files:
if fname.lower() == f.lower():
fname = f
#print('loading for ',fname)
img =,settings.link_if_exist)
node =
node.label = sanitize(k)
node.hide = True
node.width_hidden = 50
node.location.x = xloc
node.location.y = yloc
yloc -= 40
node.image = img
node.color_space = 'COLOR' if (k == settings.suffix_color or k == settings.suffix_diffuse) else 'NONE' # that is the string NONE
#print('found ',fname)
# shift new nodes up to center of tree
list_size = new_nodes[0].location.y - new_nodes[-1].location.y
for node in new_nodes: = True
node.location.y += (list_size/2)
# sort the y location based on the label
sortedy = dict(zip(sorted(n.label for n in new_nodes), sorted([n.location.y for n in new_nodes], reverse=True)))
for n in new_nodes:
n.location.y = sortedy[n.label]
# add the new nodes to a frame
frm =
frm.label = path.basename(prefix if prefix else 'Material') + "Texture Set"
frm.label_size = 13 # the default of 20 will extend the shrink to fit area! bug?
frm.use_custom_color = True
frm.color = settings.frame_color
for node in new_nodes:
node.parent = frm
if addshader: #If we choose "SP Bitmaps -> PBR" option
# add a normal map node
normalmap ="ShaderNodeNormalMap")
normalmap.hide = False
normalmap.width_hidden = 50
normalmap.location.x = orgx + 250
normalmap.location.y = orgy - 100
# using tangent space or object space is somewhat a matter of taste but because
# tangent space normal maps together wit the experimental microdisplacement
# results in an all black material I prefer this option to be on by default.
# see
if settings.use_objectspace: = 'OBJECT'
# add a bump node
bump_node ="ShaderNodeBump")
bump_node.hide = False
bump_node.width_hidden = 100
bump_node.location.x = orgx + 450
bump_node.location.y = orgy - 100
pbr_bsdf = None
# add a principled shader (this only works for Blender 2.79 or some daily builds
pbr_bsdf ="ShaderNodeBsdfPrincipled")
#pbr_bsdf.hide = False
pbr_bsdf.width = 205
pbr_bsdf.location.x = orgx + 650
pbr_bsdf.location.y = orgy + 162
except: # yes I know it is bad form not to be specific
if pbr_bsdf:
colortex = NSAddMultipleImages.find_in_nodes(new_nodes, sanitize(settings.suffix_color))
if colortex:
link_nodes(context.space_data.node_tree,colortex,"Color",pbr_bsdf,"Base Color") # note the space in the name
metaltex = NSAddMultipleImages.find_in_nodes(new_nodes, sanitize(settings.suffix_metallic))
if metaltex:
roughnesstex = NSAddMultipleImages.find_in_nodes(new_nodes, sanitize(settings.suffix_roughness))
if roughnesstex:
normaltex = NSAddMultipleImages.find_in_nodes(new_nodes, sanitize(settings.suffix_normal))
if normaltex:
heighttex = NSAddMultipleImages.find_in_nodes(new_nodes, sanitize(settings.suffix_height))
if heighttex:
mat_output = nodes.get("Material Output")
if mat_output:
link_nodes(context.space_data.node_tree, pbr_bsdf, "BSDF", mat_output, "Surface")
context.area.tag_redraw() # this in itself is not enough to trigger a redraw...
return {'FINISHED'}
def multipleimages_menu_func(self, context):
col = self.layout.column(align=True)
op = col.operator(NSAddMultipleImages.bl_idname, text="Just Bitmaps TextureSet")
op.shader = False
op = col.operator(NSAddMultipleImages.bl_idname, text="Bitmaps TexSet ~> PBR BSDF")
op.shader = True
def register():
# menu items
def unregister():
if __name__ == "__main__":