Skip to content
Snippets Groups Projects
paint_palette.py 25.83 KiB
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright 2011 Dany Lebel (Axon_D)

bl_info = {
    "name": "Paint Palettes",
    "author": "Dany Lebel (Axon D)",
    "version": (0, 9, 4),
    "blender": (2, 80, 0),
    "location": "Image Editor and 3D View > Any Paint mode > Color Palette or Weight Palette panel",
    "description": "Palettes for color and weight paint modes",
    "warning": "",
    "doc_url": "{BLENDER_MANUAL_URL}/addons/paint/paint_palettes.html",
    "category": "Paint",
}

"""
This add-on brings palettes to the paint modes.

    * Color Palette for Image Painting, Texture Paint and Vertex Paint modes.
    * Weight Palette for the Weight Paint mode.

Set a number of colors (or weights according to the mode) and then associate it
with the brush by using the button under the color.
"""

import bpy
from bpy.types import (
    Operator,
    Menu,
    Panel,
    PropertyGroup,
)
from bpy.props import (
    BoolProperty,
    FloatProperty,
    FloatVectorProperty,
    IntProperty,
    StringProperty,
    PointerProperty,
    CollectionProperty,
)


def update_panels():
    pp = bpy.context.scene.palette_props
    current_color = pp.colors[pp.current_color_index].color
    pp.color_name = pp.colors[pp.current_color_index].name
    brush = current_brush()
    brush.color = current_color
    pp.index = pp.current_color_index


def sample():
    pp = bpy.context.scene.palette_props
    current_color = pp.colors[pp.current_color_index]
    brush = current_brush()
    current_color.color = brush.color
    return None


def current_brush():
    context = bpy.context
    if context.area.type == 'VIEW_3D' and context.vertex_paint_object:
        brush = context.tool_settings.vertex_paint.brush
    elif context.area.type == 'VIEW_3D' and context.image_paint_object:
        brush = context.tool_settings.image_paint.brush
    elif context.area.type == 'IMAGE_EDITOR' and context.space_data.mode == 'PAINT':
        brush = context.tool_settings.image_paint.brush
    else:
        brush = None
    return brush


def update_weight_value():
    pp = bpy.context.scene.palette_props
    tt = bpy.context.tool_settings
    tt.unified_paint_settings.weight = pp.weight_value
    return None


def check_path_return():
    from os.path import normpath
    preset_path = bpy.path.abspath(bpy.context.scene.palette_props.presets_folder)
    paths = normpath(preset_path)

    return paths if paths else ""


class PALETTE_MT_menu(Menu):
    bl_label = "Presets"
    preset_subdir = ""
    preset_operator = "palette.load_gimp_palette"

    def path_menu(self, searchpaths, operator, props_default={}):
        layout = self.layout
        # hard coded to set the operators 'filepath' to the filename.
        import os
        import bpy.utils

        layout = self.layout

        if bpy.data.filepath == "":
            layout.label(text="*Please save the .blend file first*")
            return

        if not searchpaths[0]:
            layout.label(text="* Missing Paths *")
            return

        # collect paths
        files = []
        for directory in searchpaths:
            files.extend([(f, os.path.join(directory, f)) for f in os.listdir(directory)])

        files.sort()

        for f, filepath in files:

            if f.startswith("."):
                continue
            # do not load everything from the given folder, only .gpl files
            if f[-4:] != ".gpl":
                continue

            preset_name = bpy.path.display_name(f)
            props = layout.operator(operator, text=preset_name)

            for attr, value in props_default.items():
                setattr(props, attr, value)

            props.filepath = filepath
            if operator == "palette.load_gimp_palette":
                props.menu_idname = self.bl_idname

    def draw_preset(self, context):
        paths = check_path_return()
        self.path_menu([paths], self.preset_operator)

    draw = draw_preset


class PALETTE_OT_load_gimp_palette(Operator):
    """Execute a preset"""
    bl_idname = "palette.load_gimp_palette"
    bl_label = "Load a Gimp palette"

    filepath: StringProperty(
        name="Path",
        description="Path of the .gpl file to load",
        default=""
    )
    menu_idname: StringProperty(
        name="Menu ID Name",
        description="ID name of the menu this was called from",
        default=""
    )

    def execute(self, context):
        from os.path import basename
        import re
        filepath = self.filepath

        palette_props = bpy.context.scene.palette_props
        palette_props.current_color_index = 0

        # change the menu title to the most recently chosen option
        preset_class = getattr(bpy.types, self.menu_idname)
        preset_class.bl_label = bpy.path.display_name(basename(filepath))

        palette_props.columns = 0
        error_palette = False  # errors found
        error_import = []      # collect exception messages
        start_color_index = 0  # store the starting line for color definitions

        if filepath[-4:] != ".gpl":
            error_palette = True
        else:
            gpl = open(filepath, "r")
            lines = gpl.readlines()
            palette_props.notes = ''
            has_color = False
            for index_0, line in enumerate(lines):
                if not line or (line[:12] == "GIMP Palette"):
                    pass
                elif line[:5] == "Name:":
                    palette_props.palette_name = line[5:]
                elif line[:8] == "Columns:":
                    palette_props.columns = int(line[8:])
                elif line[0] == "#":
                    palette_props.notes += line
                elif line[0] == "\n":
                    pass
                else:
                    has_color = True
                    start_color_index = index_0
                    break
            i = -1
            if has_color:
                for i, ln in enumerate(lines[start_color_index:]):
                    try:
                        palette_props.colors[i]
                    except IndexError:
                        palette_props.colors.add()
                    try:
                        # get line - find keywords with re.split, remove the empty ones with filter
                        get_line = list(filter(None, re.split(r'\t+|\s+', ln.rstrip('\n'))))
                        extract_colors = get_line[:3]
                        get_color_name = [str(name) for name in get_line[3:]]
                        color = [float(rgb) / 255 for rgb in extract_colors]
                        palette_props.colors[i].color = color
                        palette_props.colors[i].name = " ".join(get_color_name) or "Color " + str(i)
                    except Exception as e:
                        error_palette = True
                        error_import.append(".gpl file line: {}, error: {}".format(i + 1 + start_color_index, e))
                        pass

            exceeding = i + 1
            while palette_props.colors.__len__() > exceeding:
                palette_props.colors.remove(exceeding)

            if has_color:
                update_panels()
            gpl.close()
            pass

        message = "Loaded palette from file: {}".format(filepath)

        if error_palette:
            message = "Not supported palette format for file: {}".format(filepath)
            if error_import:
                message = "Some of the .gpl palette data can not be parsed. See Console for more info"
                print("\n[Paint Palette]\nOperator: palette.load_gimp_palette\nErrors: %s\n" %
                     ('\n'.join(error_import)))

        self.report({'INFO'}, message)

        return {'FINISHED'}


class WriteGimpPalette():
    """Base preset class, only for subclassing
    subclasses must define
     - preset_values
     - preset_subdir """
    bl_options = {'REGISTER'}  # only because invoke_props_popup requires

    name: StringProperty(
        name="Name",
        description="Name of the preset, used to make the path name",
        maxlen=64,
        options={'SKIP_SAVE'},
        default=""
    )
    remove_active: BoolProperty(
        default=False,
        options={'HIDDEN'}
    )

    @staticmethod
    def as_filename(name):  # could reuse for other presets
        for char in " !@#$%^&*(){}:\";'[]<>,.\\/?":
            name = name.replace(char, '_')
        return name.lower().strip()

    def execute(self, context):
        import os
        pp = bpy.context.scene.palette_props

        if hasattr(self, "pre_cb"):
            self.pre_cb(context)

        preset_menu_class = getattr(bpy.types, self.preset_menu)
        target_path = check_path_return()

        if not target_path:
            self.report({'WARNING'}, "Failed to create presets path")
            return {'CANCELLED'}

        if not os.path.exists(target_path):
            self.report({'WARNING'},
                        "Failure to open the saved Palettes Folder. Check if the path exists")
            return {'CANCELLED'}

        if not self.remove_active:
            if not self.name:
                self.report({'INFO'},
                            "No name is given for the preset entry. Operation Cancelled")
                return {'FINISHED'}

            filename = self.as_filename(self.name)
            filepath = os.path.join(target_path, filename) + ".gpl"
            file_preset = open(filepath, 'wb')
            gpl = "GIMP Palette\n"
            gpl += "Name: %s\n" % filename
            gpl += "Columns: %d\n" % pp.columns
            gpl += pp.notes
            if pp.colors.items():
                for i, color in enumerate(pp.colors):
                    gpl += "%3d%4d%4d %s" % (color.color.r * 255, color.color.g * 255,
                                             color.color.b * 255, color.name + '\n')
            file_preset.write(bytes(gpl, 'UTF-8'))

            file_preset.close()

            pp.palette_name = filename
            preset_menu_class.bl_label = bpy.path.display_name(filename)

            self.report({'INFO'}, "Created Palette: {}".format(filepath))

        else:
            preset_active = preset_menu_class.bl_label
            filename = self.as_filename(preset_active)

            filepath = os.path.join(target_path, filename) + ".gpl"

            if not filepath or not os.path.exists(filepath):
                self.report({'WARNING'}, "Preset could not be found. Operation Cancelled")
                self.reset_preset_name(preset_menu_class, pp)
                return {'CANCELLED'}

            if hasattr(self, "remove"):
                self.remove(context, filepath)
            else:
                try:
                    os.remove(filepath)
                    self.report({'INFO'}, "Deleted palette: {}".format(filepath))
                except:
                    import traceback
                    traceback.print_exc()

        self.reset_preset_name(preset_menu_class, pp)

        if hasattr(self, "post_cb"):
            self.post_cb(context)

        return {'FINISHED'}

    @staticmethod
    def reset_preset_name(presets, props):
        # XXX, still stupid!
        presets.bl_label = "Presets"
        props.palette_name = ""

    def check(self, context):
        self.name = self.as_filename(self.name)

    def invoke(self, context, event):
        if not self.remove_active:
            wm = context.window_manager
            return wm.invoke_props_dialog(self)

        return self.execute(context)


class PALETTE_OT_preset_add(WriteGimpPalette, Operator):
    bl_idname = "palette.preset_add"
    bl_label = "Add Palette Preset"
    preset_menu = "PALETTE_MT_menu"
    bl_description = "Add a Palette Preset"

    preset_defines = []
    preset_values = []
    preset_subdir = "palette"


class PALETTE_OT_add_color(Operator):
    bl_idname = "palette_props.add_color"
    bl_label = ""
    bl_description = "Add a Color to the Palette"

    def execute(self, context):
        pp = bpy.context.scene.palette_props
        new_index = 0
        if pp.colors.items():
            new_index = pp.current_color_index + 1
        pp.colors.add()

        last = pp.colors.__len__() - 1

        pp.colors.move(last, new_index)
        pp.current_color_index = new_index
        sample()
        update_panels()

        return {'FINISHED'}


class PALETTE_OT_remove_color(Operator):
    bl_idname = "palette_props.remove_color"
    bl_label = ""
    bl_description = "Remove Selected Color"

    @classmethod
    def poll(cls, context):
        pp = bpy.context.scene.palette_props
        return bool(pp.colors.items())

    def execute(self, context):
        pp = context.scene.palette_props
        i = pp.current_color_index
        pp.colors.remove(i)

        if pp.current_color_index >= pp.colors.__len__():
            pp.index = pp.current_color_index = pp.colors.__len__() - 1

        return {'FINISHED'}


class PALETTE_OT_sample_tool_color(Operator):
    bl_idname = "palette_props.sample_tool_color"
    bl_label = ""
    bl_description = "Sample Tool Color"

    def execute(self, context):
        pp = context.scene.palette_props
        brush = current_brush()
        pp.colors[pp.current_color_index].color = brush.color

        return {'FINISHED'}


class IMAGE_OT_select_color(Operator):
    bl_idname = "paint.select_color"
    bl_label = ""
    bl_description = "Select this color"
    bl_options = {'UNDO'}

    color_index: IntProperty()

    def invoke(self, context, event):
        palette_props = context.scene.palette_props
        palette_props.current_color_index = self.color_index

        update_panels()

        return {'FINISHED'}


def color_palette_draw(self, context):
    palette_props = context.scene.palette_props

    layout = self.layout

    row = layout.row(align=True)
    row.menu("PALETTE_MT_menu", text=PALETTE_MT_menu.bl_label)
    row.operator("palette.preset_add", text="", icon='ADD').remove_active = False
    row.operator("palette.preset_add", text="", icon='REMOVE').remove_active = True

    col = layout.column(align=True)
    row = col.row(align=True)
    row.operator("palette_props.add_color", icon='ADD')
    row.prop(palette_props, "index")
    row.operator("palette_props.remove_color", icon="PANEL_CLOSE")

    row = col.row(align=True)
    row.prop(palette_props, "columns")
    if palette_props.colors.items():
        layout = col.box()
        row = layout.row(align=True)
        row.prop(palette_props, "color_name")
        row.operator("palette_props.sample_tool_color", icon="COLOR")

    laycol = layout.column(align=False)

    if palette_props.columns:
        columns = palette_props.columns
    else:
        columns = 16

    for i, color in enumerate(palette_props.colors):
        if not i % columns:
            row1 = laycol.row(align=True)
            row1.scale_y = 0.8
            row2 = laycol.row(align=True)
            row2.scale_y = 0.8

        active = True if i == palette_props.current_color_index else False
        icons = "LAYER_ACTIVE" if active else "LAYER_USED"
        row1.prop(palette_props.colors[i], "color", event=True, toggle=True)
        row2.operator("paint.select_color", text="  ",
                      emboss=active, icon=icons).color_index = i

    layout = self.layout
    row = layout.row()
    row.prop(palette_props, "presets_folder", text="")


class BrushButtonsPanel():
    bl_space_type = 'IMAGE_EDITOR'
    bl_region_type = 'UI'

    @classmethod
    def poll(cls, context):
        sima = context.space_data
        toolsettings = context.tool_settings.image_paint
        return sima.show_paint and toolsettings.brush


class PaintPanel():
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category = 'Paint'

    @staticmethod
    def paint_settings(context):
        ts = context.tool_settings

        if context.vertex_paint_object:
            return ts.vertex_paint
        elif context.weight_paint_object:
            return ts.weight_paint
        elif context.texture_paint_object:
            return ts.image_paint
        return None


class IMAGE_PT_color_palette(BrushButtonsPanel, Panel):
    bl_label = "Color Palette"
    bl_options = {'DEFAULT_CLOSED'}

    def draw(self, context):
        color_palette_draw(self, context)


class VIEW3D_PT_color_palette(PaintPanel, Panel):
    bl_label = "Color Palette"
    bl_options = {'DEFAULT_CLOSED'}

    @classmethod
    def poll(cls, context):
        return (context.image_paint_object or context.vertex_paint_object)

    def draw(self, context):
        color_palette_draw(self, context)


class VIEW3D_OT_select_weight(Operator):
    bl_idname = "paint.select_weight"
    bl_label = ""
    bl_description = "Select this weight value slot"
    bl_options = {'UNDO'}

    weight_index: IntProperty()

    def current_weight(self):
        pp = bpy.context.scene.palette_props
        if self.weight_index == 0:
            weight = pp.weight_0
        elif self.weight_index == 1:
            weight = pp.weight_1
        elif self.weight_index == 2:
            weight = pp.weight_2
        elif self.weight_index == 3:
            weight = pp.weight_3
        elif self.weight_index == 4:
            weight = pp.weight_4
        elif self.weight_index == 5:
            weight = pp.weight_5
        elif self.weight_index == 6:
            weight = pp.weight_6
        elif self.weight_index == 7:
            weight = pp.weight_7
        elif self.weight_index == 8:
            weight = pp.weight_8
        elif self.weight_index == 9:
            weight = pp.weight_9
        elif self.weight_index == 10:
            weight = pp.weight_10
        return weight

    def invoke(self, context, event):
        palette_props = context.scene.palette_props
        palette_props.current_weight_index = self.weight_index

        if self.weight_index == 0:
            weight = palette_props.weight_0
        elif self.weight_index == 1:
            weight = palette_props.weight_1
        elif self.weight_index == 2:
            weight = palette_props.weight_2
        elif self.weight_index == 3:
            weight = palette_props.weight_3
        elif self.weight_index == 4:
            weight = palette_props.weight_4
        elif self.weight_index == 5:
            weight = palette_props.weight_5
        elif self.weight_index == 6:
            weight = palette_props.weight_6
        elif self.weight_index == 7:
            weight = palette_props.weight_7
        elif self.weight_index == 8:
            weight = palette_props.weight_8
        elif self.weight_index == 9:
            weight = palette_props.weight_9
        elif self.weight_index == 10:
            weight = palette_props.weight_10
        palette_props.weight = weight

        return {'FINISHED'}


class VIEW3D_OT_reset_weight_palette(Operator):
    bl_idname = "paint.reset_weight_palette"
    bl_label = ""
    bl_description = "Reset the active Weight slot to it's default value"

    def execute(self, context):
        try:
            palette_props = context.scene.palette_props
            dict_defs = {
                0: 0.0, 1: 0.1, 2: 0.25,
                3: 0.333, 4: 0.4, 5: 0.5,
                6: 0.6, 7: 0.6666, 8: 0.75,
                9: 0.9, 10: 1.0
            }
            current_idx = palette_props.current_weight_index
            palette_props.weight = dict_defs[current_idx]

            var_name = "weight_" + str(current_idx)
            var_to_change = getattr(palette_props, var_name, None)
            if var_to_change:
                var_to_change = dict_defs[current_idx]

            return {'FINISHED'}

        except Exception as e:
            self.report({'WARNING'},
                        "Reset Weight palette could not be completed (See Console for more info)")
            print("\n[Paint Palette]\nOperator: paint.reset_weight_palette\nError: %s\n" % e)

            return {'CANCELLED'}


class VIEW3D_PT_weight_palette(PaintPanel, Panel):
    bl_label = "Weight Palette"
    bl_options = {'DEFAULT_CLOSED'}

    @classmethod
    def poll(cls, context):
        return context.weight_paint_object

    def draw(self, context):
        palette_props = context.scene.palette_props

        layout = self.layout
        row = layout.row()
        row.prop(palette_props, "weight", slider=True)
        box = layout.box()

        selected_weight = palette_props.current_weight_index
        for props in range(0, 11):
            embossed = False if props == selected_weight else True
            prop_name = "weight_" + str(props)
            prop_value = getattr(palette_props, prop_name, "")
            if props in (0, 10):
                row = box.row(align=True)
            elif (props + 2) % 3 == 0:
                col = box.column(align=True)
                row = col.row(align=True)
            else:
                if props == 1:
                    row = box.row(align=True)
                row = row.row(align=True)

            row.operator("paint.select_weight", text="%.2f" % prop_value,
                     emboss=embossed).weight_index = props

        row = layout.row()
        row.operator("paint.reset_weight_palette", text="Reset")


class PALETTE_Colors(PropertyGroup):
    """Class for colors CollectionProperty"""
    color: FloatVectorProperty(
        name="",
        description="",
        default=(0.8, 0.8, 0.8),
        min=0, max=1,
        step=1, precision=3,
        subtype='COLOR_GAMMA',
        size=3
    )


class PALETTE_Props(PropertyGroup):

    def update_color_name(self, context):
        pp = bpy.context.scene.palette_props
        pp.colors[pp.current_color_index].name = pp.color_name
        return None

    def move_color(self, context):
        pp = bpy.context.scene.palette_props
        if pp.colors.items() and pp.current_color_index != pp.index:
            if pp.index >= pp.colors.__len__():
                pp.index = pp.colors.__len__() - 1

            pp.colors.move(pp.current_color_index, pp.index)
            pp.current_color_index = pp.index
        return None

    def update_weight(self, context):
        pp = context.scene.palette_props
        weight = pp.weight
        if pp.current_weight_index == 0:
            pp.weight_0 = weight
        elif pp.current_weight_index == 1:
            pp.weight_1 = weight
        elif pp.current_weight_index == 2:
            pp.weight_2 = weight
        elif pp.current_weight_index == 3:
            pp.weight_3 = weight
        elif pp.current_weight_index == 4:
            pp.weight_4 = weight
        elif pp.current_weight_index == 5:
            pp.weight_5 = weight
        elif pp.current_weight_index == 6:
            pp.weight_6 = weight
        elif pp.current_weight_index == 7:
            pp.weight_7 = weight
        elif pp.current_weight_index == 8:
            pp.weight_8 = weight
        elif pp.current_weight_index == 9:
            pp.weight_9 = weight
        elif pp.current_weight_index == 10:
            pp.weight_10 = weight
        bpy.context.tool_settings.unified_paint_settings.weight = weight
        return None

    palette_name: StringProperty(
        name="Palette Name",
        default="Preset",
        subtype='FILE_NAME'
    )
    color_name: StringProperty(
        name="",
        description="Color Name",
        default="Untitled",
        update=update_color_name
    )
    columns: IntProperty(
        name="Columns",
        description="Number of Columns",
        min=0, max=16,
        default=0
    )
    index: IntProperty(
        name="Index",
        description="Move Selected Color",
        min=0,
        update=move_color
    )
    notes: StringProperty(
        name="Palette Notes",
        default="#\n"
    )
    current_color_index: IntProperty(
        name="Current Color Index",
        description="",
        default=0,
        min=0
    )
    current_weight_index: IntProperty(
        name="Current Color Index",
        description="",
        default=10,
        min=-1
    )
    presets_folder: StringProperty(name="",
        description="Palettes Folder",
        subtype="DIR_PATH",
        default="//"
    )
    colors: CollectionProperty(
        type=PALETTE_Colors
    )
    weight: FloatProperty(
        name="Weight",
        description="Modify the active Weight preset slot value",
        default=0.0,
        min=0.0, max=1.0,
        precision=3,
        update=update_weight
    )
    weight_0: FloatProperty(
        default=0.0,
        min=0.0, max=1.0,
        precision=3
    )
    weight_1: FloatProperty(
        default=0.1,
        min=0.0, max=1.0,
        precision=3
    )
    weight_2: FloatProperty(
        default=0.25,
        min=0.0, max=1.0,
        precision=3
    )
    weight_3: FloatProperty(
        default=0.333,
        min=0.0, max=1.0,
        precision=3
    )
    weight_4: FloatProperty(
        default=0.4,
        min=0.0, max=1.0,
        precision=3
    )
    weight_5: FloatProperty(
        default=0.5,
        min=0.0, max=1.0,
        precision=3
    )
    weight_6: FloatProperty(
        default=0.6,
        min=0.0, max=1.0,
        precision=3
    )
    weight_7: FloatProperty(
        default=0.6666,
        min=0.0, max=1.0,
        precision=3
    )
    weight_8: FloatProperty(
        default=0.75,
        min=0.0, max=1.0,
        precision=3
    )
    weight_9: FloatProperty(
        default=0.9,
        min=0.0, max=1.0,
        precision=3
    )
    weight_10: FloatProperty(
        default=1.0,
        min=0.0, max=1.0,
        precision=3
    )


classes = (
    PALETTE_MT_menu,
    PALETTE_OT_load_gimp_palette,
    PALETTE_OT_preset_add,
    PALETTE_OT_add_color,
    PALETTE_OT_remove_color,
    PALETTE_OT_sample_tool_color,
    IMAGE_OT_select_color,
    IMAGE_PT_color_palette,
    VIEW3D_PT_color_palette,
    VIEW3D_OT_select_weight,
    VIEW3D_OT_reset_weight_palette,
    VIEW3D_PT_weight_palette,
    PALETTE_Colors,
    PALETTE_Props,
)


def register():
    for cls in classes:
        bpy.utils.register_class(cls)

    bpy.types.Scene.palette_props = PointerProperty(
        type=PALETTE_Props,
        name="Palette Props",
        description=""
    )


def unregister():
    for cls in reversed(classes):
        bpy.utils.unregister_class(cls)

    del bpy.types.Scene.palette_props


if __name__ == "__main__":
    register()