blender-addons/paint_palette.py
Campbell Barton e8da6131fd License headers: use SPDX-FileCopyrightText for all addons
Move copyright text to SPDX-FileCopyrightText or set to the
Blender Foundation so "make check_licenses" now runs without warnings.
2023-06-15 16:54:05 +10:00

881 lines
26 KiB
Python

# SPDX-FileCopyrightText: 2011 Dany Lebel (Axon_D)
#
# SPDX-License-Identifier: GPL-2.0-or-later
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()