OverviewFilesCommitsIssuesDocsReleasesPeers

hardops / operators / modals / map_scroll.py

1832 lines · python ref:
import bpy, os, random, time
from pathlib import Path
from mathutils import Vector
from bpy_extras.image_utils import load_image
from enum import Enum

from ... utility import addon

from ... ui_framework.master import Master
from ... ui_framework import form_ui as form

from ... utility import method_handler
from ... utility.base_modal_controls import Base_Modal_Controls
from ... utils.toggle_view3d_panels import collapse_3D_view_panels
from ... utils.modal_frame_drawing import draw_modal_frame
from ... utils.cursor_warp import mouse_warp

from . material_scroll import random_principled


class Tweaks(Enum):
    SCROLL = 0
    MOVE = 1
    SCALE = 2
    ROTATE = 3


OBJ_TYPES = {'MESH', 'CURVE', 'SURFACE', 'META', 'FONT'}
IMG_TYPES = ['PNG', 'JPG', 'BMP', 'TIFF', 'PNG-JPG', 'ALL']
BLEND_MODES = ['MIX', 'MULTIPLY', 'SCREEN', 'DIVIDE', 'LIGHTEN', 'ADD', 'SUBTRACT']
DESC = """Map Scroll V1
Scroll Texture Maps

Make sure a path is specified in prefs prior to use.

Press H for help
"""

class Data:
    def __init__(self):
        self.objs = []
        self.active_obj = None
        self.shared_material = None
        self.dirs = Dirs()
        self.imgs = Imgs()
        self.nodes = Nodes()


    def setup(self, op, context):

        # --- Objects --- #
        self.objs = []
        for obj in context.selected_objects:
            if obj and obj.type in OBJ_TYPES:
                # Ignore none principled
                mat = obj.active_material
                if mat != None:
                    tree = mat.node_tree
                    if get_node(tree, bl_idname='ShaderNodeBsdfPrincipled') == None:
                        continue
                self.objs.append(Object(obj))

        # Invalid setup
        if len(self.objs) < 1:
            bpy.ops.hops.display_notification(info="Invalid Objects / Materials : Use Principled")
            return False

        # Assign material if none
        for object in self.objs:
            if object.mat == None:
                if self.shared_material == None:
                    self.shared_material = random_principled()
                object.mat = self.shared_material
                object.use_mat_pop = True
                object.obj.active_material = self.shared_material
                # object.obj.data.materials.append(self.shared_material)
                object.obj.active_material_index = len(object.obj.data.materials) - 1

        # Set active object
        for object in self.objs:
            if context.active_object == object.obj:
                self.active_obj = object
                break
        if self.active_obj == None:
            self.active_obj = self.objs[0]

        # --- Image --- #
        mat = self.active_obj.mat
        img = self.nodes.find_img_texture(mat)
        if type(img) == bpy.types.Image:
            img.hops.maps_system = True
            img.colorspace_settings.name = 'Non-Color'
            self.imgs.cur_img = img
            self.imgs.cur_tex = os.path.split(img.filepath)[1]
        for image in bpy.data.images:
            image.hops.just_created = False

        # --- Nodes --- #
        self.nodes.setup(op)

        # --- Directory --- #
        if type(self.imgs.cur_img) == bpy.types.Image:
            img_path = self.imgs.cur_img.filepath
            if not os.path.exists(img_path):
                bpy.ops.hops.display_notification(info="Image file not found on device")
                return False
            self.dirs.current_dir = os.path.split(img_path)[0]

        return True


    def close(self, op, context, with_cancel=False):

        self.imgs.close(op, with_cancel)
        self.nodes.close(op, with_cancel)

        if not with_cancel: return

        # Remove created material
        for object in self.objs:
            if object.use_mat_pop:
                object.obj.data.materials.pop()

        if self.shared_material:
            if type(self.shared_material) == bpy.types.Material:
                bpy.data.materials.remove(self.shared_material)


class Object:
    def __init__(self, obj):
        self.obj = obj
        self.original = Original_OBJ(obj)
        self.mat = self.original.mat
        self.use_mat_pop = False


class Original_OBJ:
    def __init__(self, obj):
        self.index = obj.active_material_index
        self.mat = obj.active_material


class Dirs:
    def __init__(self):
        self.root_dir = self.prefs_folder()
        self.current_dir = self.root_dir
        self.filter = self.prefs_filter()

        # Scroll Data
        self.scroll_dirs = []
        for root, dirs, files in os.walk(self.root_dir):
            for f in files:
                self.scroll_dirs.append((f, root))
        self.filtered_dirs = []
        self.build_scroll_dirs()


    def build_scroll_dirs(self):
        self.filtered_dirs = [(f, r) for f, r in self.scroll_dirs if self.filter_file(Path(f))]


    def prefs_folder(self):
        folder = Path(addon.preference().property.maps_folder).resolve()
        if os.path.exists(folder): return folder

        try:
            folder.mkdir(parents=True, exist_ok=True)
            return folder
        except:
            print(f'Unable to create {folder}')
            return None


    def prefs_filter(self):
        prefs_map_type = addon.preference().property.map_scroll_ftype
        return prefs_map_type if prefs_map_type in IMG_TYPES else IMG_TYPES[0]


    def folders(self, adjacent=False):

        def folder_list(directory):
            return [f for f in os.listdir(directory) if os.path.isdir(os.path.join(directory, f))]

        if not adjacent:
            return folder_list(self.current_dir)

        # See if the current folder has any folders within it : if not : return adjacent folders
        if self.dir_contains_folders(self.current_dir):
            return folder_list(self.current_dir)
        else:
            directory = os.path.split(self.current_dir)[0]
            return folder_list(directory)


    def files(self, override_path=None):

        directory = override_path if override_path else self.current_dir
        files = []
        try:
            for f in os.listdir(directory):
                path = Path(os.path.join(directory, f))
                if not path.is_file: continue

                if self.filter_file(path):
                    files.append(f)
        except: pass

        return files


    def filter_file(self, path):

        suffix = path.suffix

        if self.filter == 'ALL': 
            if suffix in {'.png', '.jpg', '.jpeg', '.bmp', '.tiff'}:
                return True
        if self.filter == 'PNG-JPG':
            if suffix in {'.png', '.jpg', '.jpeg'}:
                return True
        elif self.filter == 'PNG':
            if suffix in {'.png'}:
                return True
        elif self.filter == 'JPG':
            if suffix in {'.jpg', '.jpeg'}:
                return True
        elif self.filter == 'BMP':
            if suffix in {'.bmp'}:
                return True
        elif self.filter == 'TIFF':
            if suffix in {'.tiff'}:
                return True

        return False


    def set_folder(self, op, folder_name=""):
        
        valid = False

        # Look in current directory
        folder = os.path.join(self.current_dir, folder_name)
        if os.path.isdir(folder):
            valid = True

        # Look in adjacent directory
        if not valid:
            folder = os.path.split(self.current_dir)[0]
            folder = os.path.join(folder, folder_name)
            if os.path.isdir(folder):
                valid = True

        # Try from root
        if not valid:
            self.current = self.root_dir
            folder = os.path.join(backup, folder_name)
            if os.path.isdir(folder):
                valid = True

        # Fall back to root
        if not valid:
            self.current_dir = self.root_dir

        self.current_dir = folder

        has_folders = False
        try:
            for directory in os.listdir(self.current_dir):
                if os.path.isdir(os.path.join(self.current_dir, directory)):
                    has_folders = True
                    break
        except: pass
        
        if has_folders:
            op.reload_folders()
            op.reload_files()
            op.form.build()
        else:
            op.reload_files()
            op.form.build()


    def go_back_dir(self, op):
        if self.dir_contains_folders(self.current_dir):
            path, split = os.path.split(self.current_dir)
            if os.path.isdir(path):

                root = Path(self.root_dir)
                child = Path(path)
                if root not in child.parents:
                    path = self.root_dir

                self.current_dir = path
                op.reload_folders()
                op.reload_files()
                op.form.build()
                return

        # Go back 2 directories if there was not one in current folder
        path, split = os.path.split(self.current_dir)
        path, split = os.path.split(path)
        self.current_dir = path
        op.reload_folders()
        op.reload_files()
        op.form.build()
        return


    def dir_contains_folders(self, directory):
        for path in os.listdir(directory):
            if os.path.isdir(os.path.join(directory, path)):
                return True
        return False


    def go_home(self, op):
        if os.path.isdir(self.root_dir):
            self.current_dir = self.root_dir
        
            op.reload_folders()
            op.reload_files()
            op.form.build()


    def highlight_hook(self, folder=""):
        if folder == os.path.split(self.current_dir)[1]:
            return True
        return False

    # --- Modal --- #

    def next_available(self, op, direction=0, use_random=False):

        def rebuild(op, img_file, root):
            self.current_dir = root
            op.reload_folders()
            folder_name = os.path.split(root)[1]
            self.set_folder(op, folder_name)
            op.data.imgs.load(op, img_file)

        # Get a new image file
        if op.data.imgs.cur_tex == "" or op.data.imgs.cur_img == None:
            index = 0
            if len(self.filtered_dirs) - 1 > 0:
                index = random.randint(0, len(self.filtered_dirs) - 1)
            elif len(self.filtered_dirs) - 1 < 0:
                bpy.ops.hops.display_notification(info="No Images : Change filter type (TAB)")
                return
            rebuild(op, self.filtered_dirs[index][0], self.filtered_dirs[index][1])
            return

        if use_random:
            index = 0
            if len(self.filtered_dirs) - 1 > 0:
                index = random.randint(0, len(self.filtered_dirs) - 1)
            elif len(self.filtered_dirs) - 1 < 0:
                bpy.ops.hops.display_notification(info="No Images : Change filter type (TAB)")
                return
            rebuild(op, self.filtered_dirs[index][0], self.filtered_dirs[index][1])
            return

        index = None
        img_file = op.data.imgs.cur_tex
        for i, items in enumerate(self.filtered_dirs):
            if items[0] == img_file and items[1] == self.current_dir:
                index = i
                break

        if index == None:
            rebuild(op, self.filtered_dirs[-1][0], self.filtered_dirs[-1][1])
            return

        if direction > 0:
            index += 1
            if index > len(self.filtered_dirs) - 1:
                index = 0

        elif direction < 0:
            index -= 1
            if index < 0:
                index = len(self.filtered_dirs) - 1

        rebuild(op, self.filtered_dirs[index][0], self.filtered_dirs[index][1])
  

class Imgs:
    def __init__(self):
        self.cur_img = None
        self.cur_tex = ""
        self.loaded = [] # [(File name , b3d image name)... ]
        self.cur_img_name = ""

    
    def load(self, op, texture):

        def set_image(op, img, texture):
            self.cur_img = img
            self.cur_tex = texture
            self.loaded.append((texture, img.name))
            self.cur_img_name = img.name
            op.data.nodes.set_new_texture(op)

        for file_name, b3d_img_name in self.loaded:
            if file_name == texture:
                if b3d_img_name in bpy.data.images:
                    set_image(op, bpy.data.images[b3d_img_name], texture)
                    return

        img = load_image(texture, dirname=op.data.dirs.current_dir)
        if type(img) == bpy.types.Image:

            img.hops.maps_system = True
            img.hops.just_created = True
            img.colorspace_settings.name = 'Non-Color'
            set_image(op, img, texture)


    def highlight_hook(self, texture=""):
        if texture == None: return False
        if self.cur_tex == "": return False
        if texture == self.cur_tex:
            return True
        return False


    def close(self, op, with_cancel=False):
        loaded = list(set(self.loaded))

        for file_name, b3d_img_name in loaded:
            if b3d_img_name in bpy.data.images:
                
                if b3d_img_name == self.cur_img_name:
                    if with_cancel == False:
                        continue
                    if bpy.data.images[b3d_img_name].hops.just_created == False:
                        continue

                img = bpy.data.images[b3d_img_name]
                bpy.data.images.remove(img, do_unlink=True)

        for image in bpy.data.images:
            image.hops.just_created = False


class Nodes:
    def __init__(self):
        self.original = Original_Nodes()
        self.node_groups = dict() # Key => Material, Val => [nodes]
        self.use_roughness = True
        self.use_metallic = False
        self.use_normal = False
        self.use_color = False

        self.viewer_set = False

        self.color_1 = (1,1,1,1)
        self.color_2 = (1,1,1,1)

        self.__scale = 1
        self.__location = 0
        self.__bright = 0
        self.__contrast = 0
        self.__bump_distance = 0
        self.__bump_strength = 0
        self.__roughness = .5
        self.__roughness_value = 0
        self.__metalness = .5
        self.__metalness_value = 0

        self.roughness_blend = BLEND_MODES[0]
        self.color_blend = BLEND_MODES[0]
        self.metalness_blend = BLEND_MODES[0]

    # --- Global --- #
    @property
    def scale(self):
        return round(self.__scale, 3)

    @scale.setter
    def scale(self, val):
        self.__scale = val
        self.set_params()

    @property
    def location(self):
        return round(self.__location, 3)

    @location.setter
    def location(self, val):
        self.__location = val
        self.set_params()

    @property
    def bright(self):
        return round(self.__bright, 3)

    @bright.setter
    def bright(self, val):
        self.__bright = val
        self.set_params()

    @property
    def contrast(self):
        return round(self.__contrast, 3)

    @contrast.setter
    def contrast(self, val):
        self.__contrast = val
        self.set_params()

    # --- Roughness --- #
    @property
    def roughness(self):
        return round(self.__roughness, 3)

    @roughness.setter
    def roughness(self, val):
        val = max(min(val, 1), 0)
        self.__roughness = val
        self.set_params()

    @property
    def roughness_value(self):
        return round(self.__roughness_value, 3)

    @roughness_value.setter
    def roughness_value(self, val):
        val = max(min(val, 1), 0)
        self.__roughness_value = val
        self.set_params()


    def set_roughness_channel(self, op, set_viewer=False):

        if op.form.db.increment and self.use_roughness:
            if self.viewer_set:
                self.viewer_set = False
                self.connect_principled_to_matout()
                bpy.ops.hops.display_notification(info="Viewer Off")
            else:
                self.viewer_set = True
                self.set_channel(
                    from_hops_param='roughness_mix', from_node_id='ShaderNodeMixRGB', from_node_socket='Color',
                    to_node_id='ShaderNodeEmission', to_node_socket='Color',
                    connect=True)
                self.connect_viewer_to_matout()
                bpy.ops.hops.display_notification(info="Viewing Roughness Channel")
            return

        if set_viewer:
            if not self.use_roughness:
                bpy.ops.hops.display_notification(info="Enable Channel to View")
                return

            if self.viewer_set:
                self.viewer_set = False
                self.connect_principled_to_matout()
                return

            self.viewer_set = True

            self.set_channel(
                from_hops_param='roughness_mix', from_node_id='ShaderNodeMixRGB', from_node_socket='Color',
                to_node_id='ShaderNodeEmission', to_node_socket='Color',
                connect=True)
            self.connect_viewer_to_matout()
            bpy.ops.hops.display_notification(info="Viewing Roughness Channel")
            return

        self.viewer_set = False
        self.use_roughness = not self.use_roughness
        self.set_channel(
            from_hops_param='roughness_mix', from_node_id='ShaderNodeMixRGB', from_node_socket='Color',
            to_node_id='ShaderNodeBsdfPrincipled', to_node_socket='Roughness',
            connect=self.use_roughness)
        self.connect_principled_to_matout()
        op.form.row_activation(label='ROUGHNESS', active=self.use_roughness)
        op.form.build()


    def roughness_channel_hook(self):
        return self.use_roughness

   
    def set_roughness_blend(self, opt=''):
        self.roughness_blend = opt
        self.set_params()


    def roughness_blend_hook(self):
        return self.roughness_blend

    # --- Color --- #
    def set_color_channel(self, op, set_viewer=False):

        if op.form.db.increment and self.use_color:
            if self.viewer_set:
                self.viewer_set = False
                self.connect_principled_to_matout()
                bpy.ops.hops.display_notification(info="Viewer Off")
            else:
                self.viewer_set = True
                self.set_channel(
                    from_hops_param='color_mix', from_node_id='ShaderNodeMixRGB', from_node_socket='Color',
                    to_node_id='ShaderNodeEmission', to_node_socket='Color',
                    connect=True)
                self.connect_viewer_to_matout()
                bpy.ops.hops.display_notification(info="Viewing Color Channel")
            return

        if set_viewer:
            if not self.use_color:
                bpy.ops.hops.display_notification(info="Enable Channel to View")
                return

            if self.viewer_set:
                self.viewer_set = False
                self.connect_principled_to_matout()
                return
                
            self.viewer_set = True

            self.set_channel(
                from_hops_param='color_mix', from_node_id='ShaderNodeMixRGB', from_node_socket='Color',
                to_node_id='ShaderNodeEmission', to_node_socket='Color',
                connect=True)
            self.connect_viewer_to_matout()
            bpy.ops.hops.display_notification(info="Viewing Color Channel")
            return

        self.viewer_set = False
        self.use_color = not self.use_color
        self.set_channel(
            from_hops_param='color_mix', from_node_id='ShaderNodeMixRGB', from_node_socket='Color',
            to_node_id='ShaderNodeBsdfPrincipled', to_node_socket='Base Color',
            connect=self.use_color)
        self.connect_principled_to_matout()
        op.form.row_activation(label='COLOR', active=self.use_color)
        op.form.build()


    def color_channel_hook(self):
        return self.use_color


    def set_color_blend(self, opt=''):
        self.color_blend = opt
        self.set_params()


    def color_blend_hook(self):
        return self.color_blend

    # --- Metal --- #
    @property
    def metalness(self):
        return round(self.__metalness, 3)

    @metalness.setter
    def metalness(self, val):
        val = max(min(val, 1), 0)
        self.__metalness = val
        self.set_params()

    @property
    def metalness_value(self):
        return round(self.__metalness_value, 3)

    @metalness_value.setter
    def metalness_value(self, val):
        val = max(min(val, 1), 0)
        self.__metalness_value = val
        self.set_params()


    def set_metallic_channel(self, op, set_viewer=False):

        if op.form.db.increment and self.use_metallic:
            if self.viewer_set:
                self.viewer_set = False
                self.connect_principled_to_matout()
                bpy.ops.hops.display_notification(info="Viewer Off")
            else:
                self.viewer_set = True
                self.set_channel(
                    from_hops_param='metal_mix', from_node_id='ShaderNodeMixRGB', from_node_socket='Color',
                    to_node_id='ShaderNodeEmission', to_node_socket='Color',
                    connect=True)
                self.connect_viewer_to_matout()
                bpy.ops.hops.display_notification(info="Viewing Metallic Channel")
            return

        if set_viewer:
            if not self.use_metallic:
                bpy.ops.hops.display_notification(info="Enable Channel to View")
                return

            if self.viewer_set:
                self.viewer_set = False
                self.connect_principled_to_matout()
                return

            self.viewer_set = True

            self.set_channel(
                from_hops_param='metal_mix', from_node_id='ShaderNodeMixRGB', from_node_socket='Color',
                to_node_id='ShaderNodeEmission', to_node_socket='Color',
                connect=True)
            self.connect_viewer_to_matout()
            bpy.ops.hops.display_notification(info="Viewing Metallic Channel")
            return

        self.viewer_set = False
        self.use_metallic = not self.use_metallic
        self.set_channel(
            from_hops_param='metal_mix', from_node_id='ShaderNodeMixRGB', from_node_socket='Color',
            to_node_id='ShaderNodeBsdfPrincipled', to_node_socket='Metallic',
            connect=self.use_metallic)
        self.connect_principled_to_matout()
        op.form.row_activation(label='METALNESS', active=self.use_metallic)
        op.form.build()


    def metallic_channel_hook(self):
        return self.use_metallic


    def set_metalness_blend(self, opt=''):
        self.metalness_blend = opt
        self.set_params()


    def metalness_blend_hook(self):
        return self.metalness_blend

    # --- Bump --- #
    @property
    def bump_distance(self):
        return round(self.__bump_distance, 3)

    @bump_distance.setter
    def bump_distance(self, val):
        self.__bump_distance = val
        self.set_params()

    @property
    def bump_strength(self):
        return abs(round(self.__bump_strength, 3))

    @bump_strength.setter
    def bump_strength(self, val):
        self.__bump_strength = abs(val)
        self.set_params()


    def set_normal_channel(self, op, set_viewer=False):

        if op.form.db.increment and self.use_normal:
            if self.viewer_set:
                self.viewer_set = False
                self.connect_principled_to_matout()
                bpy.ops.hops.display_notification(info="Viewer Off")
            else:
                self.viewer_set = True
                self.set_channel(
                    from_node_id='ShaderNodeBump', from_node_socket='Normal',
                    to_node_id='ShaderNodeEmission', to_node_socket='Color',
                    connect=True)
                self.connect_viewer_to_matout()
                bpy.ops.hops.display_notification(info="Viewing Normal Channel")
            return

        if set_viewer:
            if not self.use_normal:
                bpy.ops.hops.display_notification(info="Enable Channel to View")
                return

            if self.viewer_set:
                self.viewer_set = False
                self.connect_principled_to_matout()
                return

            self.viewer_set = True

            self.set_channel(
                from_node_id='ShaderNodeBump', from_node_socket='Normal',
                to_node_id='ShaderNodeEmission', to_node_socket='Color',
                connect=True)
            self.connect_viewer_to_matout()
            bpy.ops.hops.display_notification(info="Viewing Normal Channel")
            return

        self.viewer_set = False
        self.use_normal = not self.use_normal
        self.set_channel(
            from_node_id='ShaderNodeBump', from_node_socket='Normal',
            to_node_id='ShaderNodeBsdfPrincipled', to_node_socket='Normal',
            connect=self.use_normal)
        self.connect_principled_to_matout()
        op.form.row_activation(label='BUMP', active=self.use_normal)
        op.form.build()


    def normal_channel_hook(self):
        return self.use_normal

    # --- Utils --- #

    def setup(self, op):

        self.original.setup(op)
        self.assign_node_graph(op)

        mat = op.data.active_obj.mat
        tree = mat.node_tree

        shader_node = get_node(tree, bl_idname='ShaderNodeBsdfPrincipled', hops_param='maps_system')

        # --- Contrast --- #
        contrast_node = get_node(tree, bl_idname='ShaderNodeBrightContrast', hops_param='maps_system')
        self.__bright = contrast_node.inputs[1].default_value
        self.__contrast = contrast_node.inputs[2].default_value

        # --- Roughness --- #
        roughness_node = get_node(tree, bl_idname='ShaderNodeMixRGB', hops_param='roughness_mix')
        self.__roughness_value = roughness_node.inputs[2].default_value[0]
        self.__roughness = roughness_node.inputs[0].default_value
        
        if roughness_node.blend_type in BLEND_MODES:
            self.roughness_blend = roughness_node.blend_type

        # --- Metallic --- #
        metal_node = get_node(tree, bl_idname='ShaderNodeMixRGB', hops_param='metal_mix')
        for link in metal_node.outputs[0].links:
            if link.to_node == shader_node:
                if link.to_socket.name == 'Metallic':
                    self.use_metallic = True
                    break
        metal_val = shader_node.inputs[4].default_value
        metal_node.inputs[2].default_value = (metal_val, metal_val, metal_val, 1)
        self.__metalness_value = metal_val
        self.__metalness = metal_node.inputs[0].default_value

        if metal_node.blend_type in BLEND_MODES:
            self.metalness_blend = metal_node.blend_type

        # --- Bump --- #
        bump_node = get_node(tree, bl_idname='ShaderNodeBump', hops_param='maps_system')
        for link in bump_node.outputs[0].links:
            if link.to_node == shader_node:
                if link.to_socket.name == 'Normal':
                    self.use_normal = True
                    break
        if bump_node.hops.just_created:
            self.__bump_strength = .1
        else:
            self.__bump_strength = bump_node.inputs[0].default_value
        self.__bump_distance = min(bump_node.inputs[1].default_value, .1)
        
        if bump_node.invert: self.__bump_distance *= -1

        # --- Color --- #
        color_node = get_node(tree, bl_idname='ShaderNodeMixRGB', hops_param='color_mix')
        for link in color_node.outputs[0].links:
            if link.to_node == shader_node:
                if link.to_socket.name == 'Base Color':
                    self.use_color = True
                    break

        self.color_1 = color_node.inputs['Color1'].default_value[:]
        self.color_2 = color_node.inputs['Color2'].default_value[:]

        if color_node.blend_type in BLEND_MODES:
            self.color_blend = color_node.blend_type

        # --- Loc / Scale --- #
        mapping_node = get_node(tree, bl_idname='ShaderNodeMapping', hops_param='maps_system')
        self.__location = mapping_node.inputs[1].default_value[0]
        self.__scale = mapping_node.inputs[3].default_value[0]

        self.connect_principled_to_matout()
        self.set_params()


    def connect_principled_to_matout(self):
        self.set_channel(
            from_hops_param=None, from_node_id='ShaderNodeBsdfPrincipled', from_node_socket='BSDF',
            to_node_id='ShaderNodeOutputMaterial', to_node_socket='Surface', to_hops_param=None,
            connect=True)


    def connect_viewer_to_matout(self):
        self.set_channel(
            from_hops_param='viewer', from_node_id='ShaderNodeEmission', from_node_socket='Emission',
            to_node_id='ShaderNodeOutputMaterial', to_node_socket='Surface', to_hops_param=None,
            connect=True)


    def set_channel(self, from_hops_param='maps_system', from_node_id='', from_node_socket='', to_node_id='', to_node_socket='', to_hops_param='maps_system', connect=False):
        for mat_name, nodes in self.node_groups.items():
            if mat_name in bpy.data.materials:
                mat = bpy.data.materials[mat_name]
                tree = mat.node_tree

                from_node = None
                if from_hops_param == None:
                    from_node = get_node(tree, bl_idname=from_node_id)
                else:
                    from_node = get_node(tree, bl_idname=from_node_id, hops_param=from_hops_param)
                if not from_node: continue

                to_node = None
                if to_hops_param == None:
                    to_node = get_node(tree, bl_idname=to_node_id)
                else:
                    to_node = get_node(tree, bl_idname=to_node_id, hops_param=to_hops_param)
                if not to_node: continue

                # Remove and links first
                if from_node_socket in from_node.outputs:
                    if from_node.outputs[from_node_socket].is_linked:
                        for link in from_node.outputs[from_node_socket].links:
                            if link.to_node == to_node:
                                if link.to_socket.name == to_node_socket:
                                    tree.links.remove(link)
                                    break

                # Add new link
                if connect:
                    socket_connect(tree, from_node, from_node_socket, to_node, to_node_socket)
        self.set_params()


    def find_img_texture(self, mat):
        tree = mat.node_tree
        tex_img_node = get_node(tree, bl_idname='ShaderNodeTexImage', hops_param='maps_system')
        if not tex_img_node: return None
        return tex_img_node.image


    def materials(self, op):
        mats = []
        for object in op.data.objs:
            obj = object.obj
            mat = obj.active_material
            if mat not in mats:
                mats.append(mat)
        return mats


    def assign_node_graph(self, op):
        mats = self.materials(op)

        for mat in mats:
            if mat.use_nodes == False:
                mat.use_nodes = True
            tree = mat.node_tree
            shader_node = get_node(tree, 'ShaderNodeBsdfPrincipled')
            
            self.setup_nodes(op, mat, tree, shader_node)


    def setup_nodes(self, op, mat, tree, shader_node):
        x, y = shader_node.location
        x -= 300
        y -= 400
 
        # RUFF MIX -> SHADER
        rough_mix = get_node(tree, bl_idname='ShaderNodeMixRGB', hops_param='roughness_mix')
        rough_mix = rough_mix if rough_mix else create_node(tree, x, y, bl_idname='ShaderNodeMixRGB')
        rough_mix.hops.roughness_mix = True
        rough_mix.label = 'Roughness'
        rough_mix.location = (x - rough_mix.width, y)
        x, y = rough_mix.location
        x -= 20
        socket_connect(tree, rough_mix, 'Color', shader_node, 'Roughness')

        # CONTRAST -> RUFF MIX
        contrast_node = get_node(tree, bl_idname='ShaderNodeBrightContrast', hops_param='maps_system')
        contrast_node = contrast_node if contrast_node else create_node(tree, x, y, bl_idname='ShaderNodeBrightContrast')
        contrast_node.location = (x - contrast_node.width - 10, y)
        x, y = contrast_node.location
        socket_connect(tree, contrast_node, 'Color', rough_mix, 'Color1')

        # RAMP -> CONTRAST
        ramp_node = get_node(tree, bl_idname='ShaderNodeValToRGB', hops_param='maps_system')
        ramp_node = ramp_node if ramp_node else create_node(tree, x, y, bl_idname='ShaderNodeValToRGB')
        ramp_node.location = (x - ramp_node.width - 10, y)
        x, y = ramp_node.location
        socket_connect(tree, ramp_node, 'Color', contrast_node, 'Color')

        # TEX -> RAMP
        tex_img_node = get_node(tree, bl_idname='ShaderNodeTexImage', hops_param='maps_system')
        tex_img_node = tex_img_node if tex_img_node else create_node(tree, x, y, bl_idname='ShaderNodeTexImage')
        tex_img_node.location = (x - tex_img_node.width - 10, y)
        x, y = tex_img_node.location
        socket_connect(tree, tex_img_node, 'Color', ramp_node, 'Fac')

        # MAP -> TEX
        mapping_node = get_node(tree, bl_idname='ShaderNodeMapping', hops_param='maps_system')
        mapping_node = mapping_node if mapping_node else create_node(tree, x, y, bl_idname='ShaderNodeMapping')
        mapping_node.location = (x - mapping_node.width - 10, y)
        x, y = mapping_node.location
        socket_connect(tree, mapping_node, 'Vector', tex_img_node, 'Vector')

        # COORDS -> MAP
        tex_coord_node = get_node(tree, bl_idname='ShaderNodeTexCoord', hops_param='maps_system')
        tex_coord_node = tex_coord_node if tex_coord_node else create_node(tree, x, y, bl_idname='ShaderNodeTexCoord')
        tex_coord_node.location = (x - tex_coord_node.width - 10, y)
        socket_connect(tree, tex_coord_node, 'Object', mapping_node, 'Vector')

        # COLOR MIX
        color_mix_node = get_node(tree, bl_idname='ShaderNodeMixRGB', hops_param='color_mix')
        color_mix_node = color_mix_node if color_mix_node else create_node(tree, x, y, bl_idname='ShaderNodeMixRGB')
        color_mix_node.hops.color_mix = True
        color_mix_node.label = 'Color'
        color_mix_node.location = (rough_mix.location.x, y + 400)
        socket_connect(tree, contrast_node, 'Color', color_mix_node, 'Fac')

        # METAL MIX
        metal_mix_node = get_node(tree, bl_idname='ShaderNodeMixRGB', hops_param='metal_mix')
        metal_mix_node = metal_mix_node if metal_mix_node else create_node(tree, x, y, bl_idname='ShaderNodeMixRGB')
        metal_mix_node.hops.metal_mix = True
        metal_mix_node.label = 'Metal'
        metal_mix_node.location = (rough_mix.location.x, y + 200)
        socket_connect(tree, contrast_node, 'Color', metal_mix_node, 'Color1')

        # BUMP
        bump_node = get_node(tree, bl_idname='ShaderNodeBump', hops_param='maps_system')
        bump_node = bump_node if bump_node else create_node(tree, x, y, bl_idname='ShaderNodeBump')
        bump_node.location = (rough_mix.location.x, contrast_node.location.y - 200)
        socket_connect(tree, contrast_node, 'Color', bump_node, 'Height')

        # VIEWER
        view_node = get_node(tree, bl_idname='ShaderNodeEmission', hops_param='maps_system')
        view_node = view_node if view_node else create_node(tree, 0, 0, bl_idname='ShaderNodeEmission')
        view_node.hops.viewer = True
        view_node.location = (color_mix_node.location.x, color_mix_node.location.y + 200)

        # Text node props
        tex_img_node.projection = 'BOX'
        tex_img_node.projection_blend = .1
        tex_img_node.image = op.data.imgs.cur_img
        tex_img_node.interpolation = 'Cubic'

        # Hops Reference
        hops_nodes = [shader_node, rough_mix, contrast_node, ramp_node, tex_img_node, mapping_node, tex_coord_node, color_mix_node, metal_mix_node, bump_node, view_node]
        for node in hops_nodes:
            node.hops.maps_system = True
        self.node_groups[mat.name] = hops_nodes

        # Make sure there is a Material Output
        out_node = get_node(tree, bl_idname='ShaderNodeOutputMaterial')
        out_node = out_node if out_node else create_node(tree, 0, 0, bl_idname='ShaderNodeOutputMaterial')


    def set_new_texture(self, op):
        img = op.data.imgs.cur_img
        if type(img) != bpy.types.Image: return

        for node in self.stored_nodes():
            if node.bl_idname == 'ShaderNodeTexImage':
                node.image = img


    def set_params(self):
        for node in self.stored_nodes():
            if node.bl_idname == 'ShaderNodeBrightContrast':
                node.inputs[1].default_value = self.__bright
                node.inputs[2].default_value = self.__contrast
            
            elif node.bl_idname == 'ShaderNodeMapping':
                node.inputs[3].default_value = (self.__scale, self.__scale, self.__scale)
                node.inputs[1].default_value = (self.__location, self.__location, 0)
            
            elif node.bl_idname == 'ShaderNodeBump':
                if self.__bump_distance < 0: node.invert = True
                else: node.invert = False
                node.inputs[0].default_value = abs(self.__bump_strength)
                node.inputs[1].default_value = abs(self.__bump_distance)

            elif node.bl_idname == 'ShaderNodeMixRGB':
                if node.hops.roughness_mix == True:
                    node.inputs[0].default_value = self.__roughness
                    node.inputs[2].default_value = (self.__roughness_value, self.__roughness_value, self.__roughness_value, 1)
                    node.blend_type = self.roughness_blend

                elif node.hops.color_mix == True:
                    node.inputs[1].default_value = self.color_1
                    node.inputs[2].default_value = self.color_2
                    node.blend_type = self.color_blend

                elif node.hops.metal_mix == True:
                    node.inputs[0].default_value = self.__metalness
                    node.inputs[2].default_value = (self.__metalness_value, self.__metalness_value, self.__metalness_value, 1)
                    node.blend_type = self.metalness_blend


    def stored_nodes(self):
        all_nodes = []
        for mat_name, nodes in self.node_groups.items():
            all_nodes.extend(nodes)
        return all_nodes


    def close(self, op, with_cancel=False):
        if with_cancel:
            self.original.close(op)

    # --- Modal --- #

    def adjust_move(self, offset=0):
        for node in self.stored_nodes():
            if node.bl_idname == 'ShaderNodeMapping':
                current = node.inputs[1].default_value
                node.inputs[1].default_value = (current[0] + offset, current[1] + offset, current[2])
                self.__location = current[0] + offset


    def adjust_scale(self, offset=0):
        for node in self.stored_nodes():
            if node.bl_idname == 'ShaderNodeMapping':
                current = node.inputs[3].default_value
                self.__scale = current[0] + offset
                node.inputs[3].default_value = (self.__scale, self.__scale, self.__scale)


    def adjust_rotation(self, offset=0):
        for node in self.stored_nodes():
            if node.bl_idname == 'ShaderNodeMapping':
                current = node.inputs[2].default_value
                node.inputs[2].default_value = (current[0] + offset, current[1] + offset, current[2])


class Original_Nodes:
    def __init__(self):
        self.revert_nodes = []


    def setup(self, op):
        data = op.data
        mats = data.nodes.materials(op)

        for mat in mats:
            tree = mat.node_tree
            captured_principled = False
            for node in tree.nodes:
                node.hops.just_created = False

                if node.bl_idname == 'ShaderNodeBsdfPrincipled' and captured_principled == False:
                    captured_principled = True
                    self.set_revert_data(mat, node)

                if node.hops.maps_system == False: continue
                self.set_revert_data(mat, node)


    def set_revert_data(self, mat, node):
        node_data = Revert_Node_Data()
        node_data.material = mat
        node_data.node = node

        # Input
        for index, puts in enumerate(node.inputs):
            # Values
            if type(puts.default_value) == float:
                node_data.input_values[index] = puts.default_value
            else:
                node_data.input_values[index] = puts.default_value[:]
            # Links
            for link in puts.links:
                if not link.from_socket or not link.to_socket: continue
                node_data.input_links.append((link.from_socket, link.to_socket))

        # Image
        if node.bl_idname == 'ShaderNodeTexImage':
            if node.image != None:
                node_data.image_name = node.image.name

        self.revert_nodes.append(node_data)


    def close(self, op):
        mats = op.data.nodes.materials(op)

        # Revert
        for revert in self.revert_nodes:
            node = revert.node
            mat = revert.material
            tree = mat.node_tree

            # Values
            for index, value in revert.input_values.items():
                node.inputs[index].default_value = value
                
            # Input Links
            for puts in node.inputs:
                for link in puts.links:
                    tree.links.remove(link)

            for link in revert.input_links:
                if len(link) != 2: continue
                tree.links.new(link[0], link[1])

            # Image
            if node.bl_idname == 'ShaderNodeTexImage':
                if type(revert.image_name) == str:
                    if revert.image_name in bpy.data.images:
                        node.image = bpy.data.images[revert.image_name]

        # Delete
        for mat in mats:
            tree = mat.node_tree
            for node in tree.nodes:
                if node.hops.just_created:
                    tree.nodes.remove(node)


class Revert_Node_Data:
    def __init__(self):
        self.material = None
        self.node = None
        self.input_values = dict()
        self.input_links = []
        self.image_name = None


class HOPS_OT_Map_Scroll(bpy.types.Operator):
    bl_idname = "hops.map_scroll"
    bl_label = "Map Scroll"
    bl_description = DESC
    bl_options = {"REGISTER", "UNDO", "BLOCKING"}

    @classmethod
    def poll(cls, context):

        if not addon.preference().property.maps_folder: return False

        folder = Path(addon.preference().property.maps_folder).resolve()
        if not folder.exists():
            return False

        types = {'.png', '.jpg', '.jpeg', '.bmp', '.tiff'}
        valid = False
        for root, dirs, files in os.walk(folder):
            if valid: break
            if len(files) > 0:
                for f in files:
                    path = Path(os.path.join(root, f))
                    suffix = path.suffix
                    if suffix in types:
                        valid = True
                        break

        if not valid: return False

        return any(obj.type in OBJ_TYPES for obj in context.selected_objects if obj)


    def invoke(self, context, event):

        # Mode
        self.exit_to_edit = False
        if context.mode != 'OBJECT':
            self.exit_to_edit = True
            objs = context.objects_in_mode
            active = context.active_object
            bpy.ops.object.mode_set(mode='OBJECT')
            for obj in objs:
                obj.select_set(True)
            active.select_set(True)

        # Data
        self.data = Data()

        # Dirs
        if len(self.data.dirs.scroll_dirs) < 1:
            bpy.ops.hops.display_notification(info="No files found")
            return {'CANCELLED'} 

        # Setup
        valid = self.data.setup(self, context)
        if valid == False:
            return {'CANCELLED'} 

        # Controls
        self.tweak = Tweaks.SCROLL

        # Form
        self.form_exit = False
        self.form = None
        self.setup_form(context, event)

        # VP
        self.og_vp = context.space_data.shading.type
        context.space_data.shading.type = 'MATERIAL'

        # Base Systems
        self.master = Master(context=context)
        self.master.only_use_fast_ui = True
        self.base_controls = Base_Modal_Controls(context, event)
        self.original_tool_shelf, self.original_n_panel = collapse_3D_view_panels()
        self.draw_handle_2D = bpy.types.SpaceView3D.draw_handler_add(self.safe_draw_2D, (context,), 'WINDOW', 'POST_PIXEL')

        context.window_manager.modal_handler_add(self)
        return {"RUNNING_MODAL"}


    def modal(self, context, event):

        # --- Base Systems --- #
        self.master.receive_event(event=event)
        self.base_controls.update(context, event)
        self.form.update(context, event)
        form_active = self.form.active()

        # --- Base Controls --- #
        if not self.form.is_dot_open():
            mouse_warp(context, event)

        if self.base_controls.pass_through:
            if not form_active:
                return {'PASS_THROUGH'}

        if not form_active:
            if self.form.is_dot_open():
                if event.type in {'WHEELUPMOUSE', 'WHEELDOWNMOUSE'}:
                    return {'PASS_THROUGH'}

        if self.base_controls.cancel:
            if not form_active:
                return self.cancel_exit(context)

        elif self.base_controls.confirm:
            if not form_active:
                return self.confirm_exit(context)

        if self.form_exit:
            return self.confirm_exit(context)

        if event.type == 'TAB' and event.value == 'PRESS':
            if self.form.is_dot_open():
                self.form.close_dot()
            else:
                self.form.open_dot()

        # --- Actions --- #
        if not form_active:
            self.actions(context, event)

        self.interface(context=context)
        context.area.tag_redraw()
        return {"RUNNING_MODAL"}


    def interface(self, context):
        self.master.setup()

        # --- Fast UI --- #
        if self.master.should_build_fast_ui():

            # Main
            win_list = []
            win_list.append("Map-Scroll")
            
            img_name = self.data.imgs.cur_tex.split('.')[0]
            if img_name == "": win_list.append("No Image Loaded")
            else: win_list.append(img_name)
            
            if self.data.nodes.use_normal:
                win_list.append(f"Bump : {self.data.nodes.bump_distance}")

            # Help
            help_items = {"GLOBAL" : [], "STANDARD" : []}

            help_items["GLOBAL"] = [
                ("M", "Toggle mods list"),
                ("H", "Toggle help"),
                ("~", "Toggle UI Display Type"),
                ("O", "Toggle viewport rendering")]

            if not self.form.is_dot_open():
                help_items["STANDARD"] = [
                    ("G"                           , "Move"),
                    ("S"                           , "Scale"),
                    ("R"                           , "Rotate"),
                    ("E"                           , "Random Map"),
                    ("N"                           , f"Bump {'OFF' if self.data.nodes.use_normal else 'ON'}"),
                    ("Scroll / Move Shift + Ctrl"  , "Bump"),
                    ("Scroll / Move Alt"           , "Contrast"),
                    ("Scroll / Move Ctrl"          , "Scale"),
                    ("Scroll / Move Shift"         , "Brightness"),
                    ("Scroll"                      , "Maps"),
                    ("TAB"                         , "DOT UI")]

            else:
                help_items["STANDARD"] = [
                    ("TAB", "DOT UI")]

            self.master.receive_fast_ui(win_list=win_list, help_list=help_items, image="QGui")

        self.master.finished()

    # --- FORM --- #

    def setup_form(self, context, event):
        self.form = form.Form(context, event, dot_open=False)

        self.form.dot_calls(
            scroll_func=self.data.dirs.next_available,
            scroll_pos_args=(self, 0, True),
            scroll_neg_args=(self, 0, True),
            tips=["Scroll for random texture"])

        def spacer(height=10):
            row = self.form.row()
            row.add_element(form.Spacer(height=height))
            self.form.row_insert(row)

        def divide_bar(label='', active=False):
            row = self.form.row()
            row.add_element(form.Spacer(width=270, height=10, draw_bar=True))
            self.form.row_insert(row, label=label, active=active)

        # Header
        row = self.form.row()
        row.add_element(form.Label(text="Map Scroll", width=250))
        row.add_element(form.Button(text="X", width=20, tips=["Finalize and Exit"], callback=self.exit_button))
        self.form.row_insert(row)

        spacer()

        # Navigation / Maps
        row = self.form.row()
        row.add_element(form.Button(img="TriLeft", width=20, height=20, tips=["Go back directory"], callback=self.data.dirs.go_back_dir, pos_args=(self,), neg_args=(self,)))
        row.add_element(form.Spacer(width=5))
        row.add_element(form.Button(text="Home", width=50, height=20, callback=self.data.dirs.go_home, pos_args=(self,), neg_args=(self,), use_padding=False))

        row.add_element(form.Spacer(width=100))
        nodes = self.data.nodes

        row.add_element(form.Button(text="R", width=20, tips=["Toggle Roughness Channel", "Shift Click : Toggle Viewer Mode"], 
            callback=nodes.set_roughness_channel, pos_args=(self,), neg_args=(self, True), highlight_hook=nodes.roughness_channel_hook))
       
        row.add_element(form.Spacer(width=5))
     
        row.add_element(form.Button(text="M", width=20, tips=["Toggle Metallic Channel", "Shift Click : Toggle Viewer Mode"], 
            callback=nodes.set_metallic_channel, pos_args=(self,), neg_args=(self, True), highlight_hook=nodes.metallic_channel_hook))
       
        row.add_element(form.Spacer(width=5))
      
        row.add_element(form.Button(text="N", width=20, tips=["Toggle Normal Map Channel", "Shift Click : Toggle Viewer Mode"], 
            callback=nodes.set_normal_channel, pos_args=(self,), neg_args=(self, True), highlight_hook=nodes.normal_channel_hook))
        
        row.add_element(form.Spacer(width=5))
       
        row.add_element(form.Button(text="C", width=20, tips=["Toggle Color Channel", "Shift Click : Toggle Viewer Mode"], 
            callback=nodes.set_color_channel, pos_args=(self,), neg_args=(self, True), highlight_hook=nodes.color_channel_hook))
        
        self.form.row_insert(row)

        spacer()

        # Folder Box
        row = self.form.row()
        group = self.folder_group()
        self.folder_box = form.Scroll_Box(width=270, height=20 * addon.preference().property.map_scroll_folder_count, scroll_group=group, view_scroll_enabled=True)
        row.add_element(self.folder_box)
        self.form.row_insert(row)

        spacer()

        # Filter
        row = self.form.row()
        row.add_element(form.Spacer(height=25))
        row.add_element(form.Label(text="Filter", width=50))
        index = IMG_TYPES.index(self.data.dirs.filter)
        row.add_element(form.Dropdown(width=75, options=IMG_TYPES, callback=self.set_map_type, update_hook=self.get_map_type, index=index))
        self.form.row_insert(row)

        spacer()

        # File Box
        row = self.form.row()
        group = self.file_group()
        self.file_box = form.Scroll_Box(width=270, height=20 * addon.preference().property.map_scroll_file_count, scroll_group=group, view_scroll_enabled=True)
        row.add_element(self.file_box)
        self.form.row_insert(row)

        spacer()

        NODES = self.data.nodes

        # Global
        row = self.form.row()
        row.add_element(form.Label(text="Scale", width=60, height=20))
        row.add_element(form.Input(obj=NODES, attr="scale", width=60, increment=.025, font_size=12))
        row.add_element(form.Spacer(width=10))
        row.add_element(form.Label(text="Loc", width=60, height=20))
        row.add_element(form.Input(obj=NODES, attr="location", width=60, increment=.025, font_size=12))
        self.form.row_insert(row)

        divide_bar(active=True)

        row = self.form.row()
        row.add_element(form.Label(text="Contrast", width=60, height=20))
        row.add_element(form.Input(obj=NODES, attr="contrast", width=60, increment=.025, font_size=12))
        row.add_element(form.Spacer(width=10))
        row.add_element(form.Label(text="Bright", width=60, height=20))
        row.add_element(form.Input(obj=NODES, attr="bright", width=60, increment=.025, font_size=12))
        self.form.row_insert(row)

        # Roughness
        divide_bar(label='ROUGHNESS', active=NODES.use_roughness)

        row = self.form.row()
        row.add_element(form.Label(text="R Blend", width=60, height=20))
        row.add_element(form.Input(obj=NODES, attr="roughness", width=60, increment=.025, font_size=12))
        row.add_element(form.Spacer(width=5))
        row.add_element(form.Input(obj=NODES, attr="roughness_value", width=60, increment=.025, font_size=12, tips=["Roughness value"]))
        row.add_element(form.Spacer(width=5))
        row.add_element(form.Dropdown(width=80, options=BLEND_MODES, callback=NODES.set_roughness_blend, update_hook=NODES.roughness_blend_hook, index=BLEND_MODES.index(NODES.roughness_blend)))
        self.form.row_insert(row, label='ROUGHNESS', active=NODES.use_roughness)

        # Metal
        divide_bar(label='METALNESS', active=NODES.use_metallic)

        row = self.form.row()
        row.add_element(form.Label(text="M Blend", width=60, height=20))
        row.add_element(form.Input(obj=NODES, attr="metalness", width=60, increment=.025, font_size=12))
        row.add_element(form.Spacer(width=5))
        row.add_element(form.Input(obj=NODES, attr="metalness_value", width=60, increment=.025, font_size=12, tips=["Metalness value"]))
        row.add_element(form.Spacer(width=5))
        row.add_element(form.Dropdown(width=80, options=BLEND_MODES, callback=NODES.set_metalness_blend, update_hook=NODES.metalness_blend_hook, index=BLEND_MODES.index(NODES.metalness_blend)))
        self.form.row_insert(row, label='METALNESS', active=NODES.use_metallic)

        # Bump
        divide_bar(label='BUMP', active=NODES.use_normal)

        row = self.form.row()
        row.add_element(form.Label(text="Strength", width=60, height=20))
        row.add_element(form.Input(obj=NODES, attr="bump_strength", width=60, increment=.0125, font_size=12))
        row.add_element(form.Label(text="Distance", width=60, height=20))
        row.add_element(form.Input(obj=NODES, attr="bump_distance", width=60, increment=.0125, font_size=12))
        self.form.row_insert(row, label='BUMP', active=NODES.use_normal)

        # Color
        divide_bar(label='COLOR', active=NODES.use_color)

        row = self.form.row()
        row.add_element(form.Label(text="Mix 1", width=50, height=20))
        row.add_element(form.Color(obj=NODES, attr="color_1", width=40, height=8, pref_color=1, callback=NODES.set_params))
        row.add_element(form.Spacer(width=5))
        row.add_element(form.Label(text="Mix 2", width=50, height=20))
        row.add_element(form.Color(obj=NODES, attr="color_2", width=40, height=8, pref_color=2, callback=NODES.set_params))
        row.add_element(form.Spacer(width=5))
        row.add_element(form.Dropdown(width=80, options=BLEND_MODES, callback=NODES.set_color_blend, update_hook=NODES.color_blend_hook, index=0))
        self.form.row_insert(row, label='COLOR', active=NODES.use_color)

        self.form.build()


    def folder_group(self):
        folders = self.data.dirs.folders(adjacent=True)
        group = form.Scroll_Group()
        for folder in folders:
            row = group.row()

            text = form.shortened_text(folder, width=220, font_size=12)
            tip = [] if text == folder else [folder]

            row.add_element(form.Button(
                scroll_enabled=False, text=text, tips=tip,
                width=250, height=20, use_padding=False,
                callback=self.data.dirs.set_folder, pos_args=(self, folder),
                highlight_hook=self.data.dirs.highlight_hook, highlight_hook_args=(folder,)))
            group.row_insert(row)
        return group


    def file_group(self):
        files = self.data.dirs.files()
        group = form.Scroll_Group()
        for texture in files:
            row = group.row()

            text = form.shortened_text(texture, width=220, font_size=12)
            tip = [] if text == texture else [texture]

            row.add_element(form.Button(
                text=text, tips=tip, width=250, height=20, use_padding=False, scroll_enabled=False,
                callback=self.data.imgs.load, pos_args=(self, texture),
                highlight_hook=self.data.imgs.highlight_hook, highlight_hook_args=(texture,)))
            group.row_insert(row)
        return group


    def reload_folders(self):
        self.folder_box.scroll_group.clear(bpy.context)
        group = self.folder_group()
        self.folder_box.scroll_group = group


    def reload_files(self):
        self.file_box.scroll_group.clear(bpy.context)
        group = self.file_group()
        self.file_box.scroll_group = group


    def set_map_type(self, opt=''):
        self.data.dirs.filter = opt
        self.data.dirs.build_scroll_dirs()
        self.reload_files()
        self.form.build()


    def get_map_type(self):
        return self.data.dirs.filter


    def exit_button(self):
        self.form_exit = True

    # --- MODAL --- #

    def actions(self, context, event):

        # Random Map
        if event.type == 'E' and event.value == 'PRESS':
            self.data.dirs.next_available(self, use_random=True)
            return

        # Move
        elif event.type == 'G' and event.value == 'PRESS':
            if self.tweak == Tweaks.MOVE:
                self.tweak = Tweaks.SCROLL
            else:
                self.tweak = Tweaks.MOVE

        # Scale
        elif event.type == 'S' and event.value == 'PRESS':
            if self.tweak == Tweaks.SCALE:
                self.tweak = Tweaks.SCROLL
            else:
                self.tweak = Tweaks.SCALE

        # Rotation
        elif event.type == 'R' and event.value == 'PRESS':
            if self.tweak == Tweaks.ROTATE:
                self.tweak = Tweaks.SCROLL
            else:
                self.tweak = Tweaks.ROTATE

        # Bump Toggle
        elif event.type == 'N' and event.value == 'PRESS':
            self.data.nodes.set_normal_channel(self)

        # Update Tweaks
        if self.tweak == Tweaks.SCROLL:
            self.scrolls(context, event)
        elif self.tweak == Tweaks.MOVE:
            self.data.nodes.adjust_move(offset=self.base_controls.mouse)
        elif self.tweak == Tweaks.SCALE:
            self.data.nodes.adjust_scale(offset=self.base_controls.mouse)
        elif self.tweak == Tweaks.ROTATE:
            self.data.nodes.adjust_rotation(offset=self.base_controls.mouse)
 

    def scrolls(self, context, event):
        scroll = self.base_controls.scroll
        mouse = self.base_controls.mouse

        # Texture
        if scroll:
            if not event.shift and not event.ctrl and not event.alt:
                self.data.dirs.next_available(self, direction=scroll)

        if scroll == 0 and mouse == 0: return

        # Bright
        if event.shift and not event.ctrl and not event.alt:
            bright = self.data.nodes.bright
            if scroll > 0:
                bright += .05
            elif scroll < 0:
                bright -= .05
            elif mouse:
                bright += mouse
            self.data.nodes.bright = bright
        # Scale
        elif not event.shift and event.ctrl and not event.alt:
            scale = self.data.nodes.scale
            if scroll > 0:
                scale += .05
            elif scroll < 0:
                scale -= .05
            elif mouse:
                scale += mouse
            self.data.nodes.scale = scale
        # Contrast
        elif not event.shift and not event.ctrl and event.alt:
            contrast = self.data.nodes.contrast
            if scroll > 0:
                contrast += .05
            elif scroll < 0:
                contrast -= .05
            elif mouse:
                contrast += mouse
            self.data.nodes.contrast = contrast
        # Bump
        elif event.shift and event.ctrl and not event.alt:
            bump = self.data.nodes.bump_distance
            if scroll > 0:
                bump += .01
            elif scroll < 0:
                bump -= .01
            elif mouse:
                bump += mouse
            
            if not self.data.nodes.use_normal:
                self.data.nodes.set_normal_channel(self)

            self.data.nodes.bump_distance = bump

    # --- EXITS --- #

    def common_exit(self, context):
        self.form.shut_down(context)
        self.remove_shaders()
        collapse_3D_view_panels(self.original_tool_shelf, self.original_n_panel)
        self.master.run_fade()

        context.space_data.shading.type = self.og_vp

        self.data.nodes.connect_principled_to_matout()

        if self.exit_to_edit:
            bpy.ops.object.mode_set(mode='EDIT')


    def confirm_exit(self, context):
        self.data.close(self, context, with_cancel=False)
        self.common_exit(context)
        return {'FINISHED'}


    def cancel_exit(self, context):
        self.data.close(self, context, with_cancel=True)
        self.common_exit(context)
        return {'CANCELLED'}

    # --- SHADERS --- #

    def remove_shaders(self):
        if self.draw_handle_2D:
            self.draw_handle_2D = bpy.types.SpaceView3D.draw_handler_remove(self.draw_handle_2D, "WINDOW")


    def safe_draw_2D(self, context):
        method_handler(self.draw_shader_2D,
            arguments = (context,),
            identifier = 'Modal Shader 2D',
            exit_method = self.remove_shaders)


    def draw_shader_2D(self, context):
        self.form.draw()

        if not self.form.is_dot_open():
            draw_modal_frame(context)

# --- UTILS --- #

def remove_materials(obj):
    obj.data.materials.clear()


def assign_material_object(obj, mat):
    obj.data.materials.append(mat)
    index = len(obj.data.materials) - 1
    obj.active_material_index = index
    return index


def create_node(tree, x, y, bl_idname=''):
    node = tree.nodes.new(type=bl_idname)
    node.hops.just_created = True
    node.location = (x, y)
    return node


def socket_connect(tree, node_out=None, out_socket='', node_in=None, in_socket=''):
    # Validate
    if out_socket not in node_out.outputs: return
    if in_socket not in node_in.inputs: return
    # Link
    socket_out = node_out.outputs[out_socket]
    socket_in = node_in.inputs[in_socket]
    tree.links.new(socket_out, socket_in)


def get_node(tree, bl_idname='', hops_param='', index=-1):

    nodes = [n for n in tree.nodes if n.bl_idname == bl_idname]

    if hops_param != '':
        nodes = [n for n in nodes if getattr(n.hops, hops_param) == True]

    if not nodes or index > len(nodes) - 1:
        return None

    return nodes[index]


def create_principled_material():
    mat = bpy.data.materials.new('Material')
    mat.use_nodes = True
    tree = mat.node_tree
    tree.nodes.clear()

    principled = create_node(tree, x=0, y=0, bl_idname='ShaderNodeBsdfPrincipled')
    x = principled.width + 20
    output = create_node(tree, x=x, y=0, bl_idname='ShaderNodeOutputMaterial')
    socket_connect(tree, principled, 'BSDF', output, 'Surface')
    return mat


def create_principled():
    mat = bpy.data.materials.new('Maps Principled')
    mat.use_nodes = True
    return mat