greasepencil-addon
greasepencil-addon copied to clipboard
Feature: Material version of layer navigator
Original issue made on Blender project extension repo by @henrikvilhelmberglund
It would be interesting to have a material version of the layer navigator.
I wrote one with the help of AI (so maybe sketchy code) but it works and is really useful. It only works for actual materials (so not vertex colors) and the list only fits about 30ish materials.
I saw that you don't use submitted code so this is just inspiration if you feel like something like this would be useful.
Code
# SPDX-FileCopyrightText: 2023 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
import blf, gpu
import math
from gpu_extras.batch import batch_for_shader
from mathutils import Vector, Matrix
from time import time
from pathlib import Path
from bpy.props import (BoolProperty, IntProperty)
from .prefs import get_addon_prefs
def rectangle_tris_from_coords(quad_list):
'''Get a list of Vector corner for a triangle
return a list of TRI for gpu drawing'''
return [
# tri 1
quad_list[0],
quad_list[1],
quad_list[2],
# tri 2
quad_list[0],
quad_list[3],
quad_list[2]
]
def round_to_ceil_even(f):
if (math.floor(f) % 2 == 0):
return math.floor(f)
else:
return math.floor(f) + 1
def get_reduced_area_coord(context):
w, h = context.region.width, context.region.height
## minus tool leftbar + sidebar right
regs = context.area.regions
toolbar = next((r for r in regs if r.type == 'TOOLS'), None)
sidebar = next((r for r in regs if r.type == 'UI'), None)
header = next((r for r in regs if r.type == 'HEADER'), None)
tool_header = next((r for r in regs if r.type == 'TOOL_HEADER'), None)
up_margin = down_margin = 0
if tool_header.alignment == 'TOP':
up_margin += tool_header.height
else:
down_margin += tool_header.height
## set corner values
left_down = (toolbar.width, down_margin+2)
right_down = (w - sidebar.width, down_margin+2)
left_up = (toolbar.width, h - up_margin-1)
right_up = (w - sidebar.width, h - up_margin-1)
return left_down, right_down, left_up, right_up
import mathutils
def draw_callback_px(self, context):
if context.area != self.current_area:
return
font_id = 0
shader = gpu.shader.from_builtin('UNIFORM_COLOR')
gpu.state.blend_set('ALPHA')
gpu.state.line_width_set(1.0)
shader.bind()
def ensure_rgba(color, default=(0.8, 0.8, 0.8, 1.0)):
if isinstance(color, (list, tuple)):
if len(color) == 3:
return (*color, 1.0)
elif len(color) == 4:
return tuple(color)
return default
def linear_to_srgb(linear):
"""Convert linear RGB color to sRGB color space"""
srgb = []
for c in linear:
if c <= 0.0031308:
srgb.append(c * 12.92)
else:
srgb.append(1.055 * (c ** (1.0 / 2.4)) - 0.055)
return srgb
bg_color_rgba = ensure_rgba(self.bg_color)
lines_color_rgba = ensure_rgba(self.lines_color)
active_material_color_rgba = ensure_rgba(self.active_material_color)
other_material_color_rgba = ensure_rgba(self.other_material_color)
rects = []
active_case = []
active_width = float(round_to_ceil_even(4.0 * context.preferences.system.ui_scale))
# Swatch settings
swatch_size = int(20 * context.preferences.system.ui_scale)
swatch_gap = int(5 * context.preferences.system.ui_scale)
stroke_width = int(2 * context.preferences.system.ui_scale)
# Separate lists for filled swatches (TRIS) and outline swatches (LINES)
filled_swatches = [] # List of (tris_list, color_rgba) for filled materials
outline_swatches = [] # List of (line_vertices, color_rgba) for stroke-only materials
preview_colors = {}
# REVERSED ORDER: First material (index 0) at top, last at bottom
for i, mat in enumerate(self.gpl):
# Top-down positioning: index 0 at top, index n-1 at bottom
y_pos = self.top - self.px_h - (i * self.px_h)
corner = Vector((self.left, y_pos))
# Main rect
rect_coords = [v + corner for v in self.case]
this_rects = rectangle_tris_from_coords(rect_coords)
rects.extend(this_rects)
# Active highlight
if i == self.ui_idx:
flattened_line_pairs = []
for j in range(len(rect_coords)):
flattened_line_pairs += [rect_coords[j], rect_coords[(j + 1) % len(rect_coords)]]
px_offset = int(active_width / 2)
case_px_offsets = [
Vector((0, -px_offset)), Vector((0, px_offset)),
Vector((-px_offset, 0)), Vector((px_offset, 0)),
Vector((0, px_offset)), Vector((0, -px_offset)),
Vector((px_offset, 0)), Vector((-px_offset, 0)),
]
active_case = [v + offset for v, offset in zip(flattened_line_pairs, case_px_offsets)]
# Get Grease Pencil material color and type
try:
gp_settings = mat.grease_pencil
show_stroke = gp_settings.show_stroke
show_fill = gp_settings.show_fill
# Priority: Use stroke color if stroke is shown, otherwise use fill color
if show_stroke:
# Convert from linear to sRGB color space
linear_color = gp_settings.color[:3]
srgb_color = linear_to_srgb(linear_color)
base_color_rgba = (*srgb_color, 1.0)
is_filled = show_fill # If both stroke and fill, show as filled square
elif show_fill:
# Convert from linear to sRGB color space
linear_color = gp_settings.fill_color[:3]
srgb_color = linear_to_srgb(linear_color)
base_color_rgba = (*srgb_color, 1.0)
is_filled = True
else:
# Fallback if neither stroke nor fill is shown
base_color_rgba = (0.5, 0.5, 0.5, 1.0)
is_filled = True
except Exception as e:
base_color_rgba = (0.8, 0.8, 0.8, 1.0)
is_filled = True
preview_colors[mat] = base_color_rgba
# Swatch geometry - position to the left of the material box
y_center = y_pos + self.mid_height
swatch_corner = Vector((self.left - swatch_size - swatch_gap, y_center - swatch_size / 2))
# Create swatch based on material type
if is_filled:
# Filled square for materials with fill
swatch_quad = [
swatch_corner,
swatch_corner + Vector((swatch_size, 0)),
swatch_corner + Vector((swatch_size, swatch_size)),
swatch_corner + Vector((0, swatch_size))
]
swatch_tris = rectangle_tris_from_coords(swatch_quad)
filled_swatches.append((swatch_tris, base_color_rgba))
else:
# Outline square for stroke-only materials (drawn as lines)
swatch_lines = [
# Bottom line
swatch_corner,
swatch_corner + Vector((swatch_size, 0)),
# Right line
swatch_corner + Vector((swatch_size, 0)),
swatch_corner + Vector((swatch_size, swatch_size)),
# Top line
swatch_corner + Vector((swatch_size, swatch_size)),
swatch_corner + Vector((0, swatch_size)),
# Left line
swatch_corner + Vector((0, swatch_size)),
swatch_corner
]
outline_swatches.append((swatch_lines, base_color_rgba))
### --- Draw Main Rects
try:
shader.uniform_float("color", bg_color_rgba)
if rects:
batch_rects = batch_for_shader(shader, 'TRIS', {"pos": rects})
batch_rects.draw(shader)
except Exception as e:
print(f"Error drawing rects: {e}")
### --- Draw Filled Swatches (TRIS)
for swatch_tris, color_rgba in filled_swatches:
try:
if len(color_rgba) != 4:
continue
shader.uniform_float("color", color_rgba)
batch_swatch = batch_for_shader(shader, 'TRIS', {"pos": swatch_tris})
batch_swatch.draw(shader)
except Exception as e:
print(f"Error drawing filled swatch: {e}")
### --- Draw Outline Swatches (LINES)
# Set thicker line width for outline swatches
gpu.state.line_width_set(stroke_width)
for swatch_lines, color_rgba in outline_swatches:
try:
if len(color_rgba) != 4:
continue
shader.uniform_float("color", color_rgba)
batch_outline = batch_for_shader(shader, 'LINES', {"pos": swatch_lines})
batch_outline.draw(shader)
except Exception as e:
print(f"Error drawing outline swatch: {e}")
# Reset line width for other drawing
gpu.state.line_width_set(1.0)
### --- Draw Lines (UI contours)
gpu.state.line_width_set(2.0)
# Contour lines
try:
shader.uniform_float("color", lines_color_rgba)
self.batch_lines.draw(shader)
except Exception as e:
print(f"Error drawing batch_lines: {e}")
# Active highlight
if active_case:
try:
gpu.state.line_width_set(active_width)
shader.uniform_float("color", active_material_color_rgba)
batch_active = batch_for_shader(shader, 'LINES', {"pos": active_case})
batch_active.draw(shader)
except Exception as e:
print(f"Error drawing active_case: {e}")
gpu.state.line_width_set(1.0)
gpu.state.blend_set('NONE')
### --- Draw Texts
for i, mat in enumerate(self.gpl):
text_y = self.text_pos[i]
preview_rgba = preview_colors.get(mat, other_material_color_rgba)
# if i == self.ui_idx:
# text_rgba = active_material_color_rgba
# else:
text_rgba = preview_rgba
try:
if len(text_rgba) != 4:
text_rgba = other_material_color_rgba
blf.position(font_id, self.text_x, text_y, 0)
blf.size(font_id, self.text_size)
blf.color(font_id, *text_rgba)
display_name = mat.name if len(mat.name) <= self.text_char_limit else mat.name[:self.text_char_limit-3] + '...'
blf.draw(font_id, display_name)
except Exception as e:
print(f"Error drawing text for {mat.name}: {e}")
# Drag text
if self.dragging and self.drag_text:
try:
blf.position(font_id, self.mouse.x + 5, self.mouse.y + 5, 0)
blf.size(font_id, self.text_size)
blf.color(font_id, 1.0, 1.0, 1.0, 1.0)
blf.draw(font_id, self.drag_text)
except Exception as e:
print(f"Error drawing drag text: {e}")
def material_active_index(gpl):
return bpy.context.object.active_material_index
class GPT_OT_viewport_material_nav_osd(bpy.types.Operator):
bl_idname = "gpencil.viewport_material_nav_osd"
bl_label = "GP material Navigator Pop up"
bl_description = "Change active GP material with a viewport interactive OSD"
bl_options = {'REGISTER', 'INTERNAL', 'UNDO'}
@classmethod
def poll(cls, context):
return context.object is not None and context.object.type == 'GREASEPENCIL'
lapse = 0
text = ''
color = ''
ct = 0
bg_color = (0.1, 0.1, 0.1, 0.96)
lines_color = (0.5, 0.5, 0.5, 1.0)
other_material_color = (0.8, 0.8, 0.8, 1.0)
active_material_color = (0.28, 0.45, 0.7, 1.0)
def get_icon(self, img_name):
store_name = '.' + img_name
img = bpy.data.images.get(store_name)
if not img:
icon_folder = Path(__file__).parent / 'icons'
img = bpy.data.images.load(filepath=str((icon_folder / img_name).with_suffix('.png')), check_existing=False)
img.name = store_name
return img
def setup(self, context):
ui_scale = bpy.context.preferences.system.ui_scale
self.material_list = [(l.name, l) for l in self.gpl]
self.ui_idx = self.org_index = material_active_index(self.gpl)
self.id_num = len(self.material_list)
self.dragging = False
self.drag_mode = None
self.drag_text = None
self.pressed = False
self.id_src = self.click_src = None
max_w = self.px_w + self.add_box
mid_square = int(self.px_w / 2)
# REVERSED ORDER: Calculate positions for top-down display
# Active material should appear at mouse position, with materials above and below it
bottom_base = self.init_mouse.y - ((self.id_num - 1 - self.org_index) * self.px_h)
self.text_bottom = bottom_base - int(self.text_size / 2)
self.mid_height = int(self.px_h / 2)
self.bottom = bottom_base - self.mid_height
self.top = self.bottom + (self.px_h * self.id_num)
if self.left_handed:
self.left = self.init_mouse.x - int(self.px_w / 10)
else:
self.left = self.init_mouse.x - mid_square
## Push from viewport borders if needed
BL, BR, _1, _2 = get_reduced_area_coord(context)
over_right = (self.left + max_w) - (BR[0] + 10 * ui_scale)
if over_right > 0:
self.left = self.left - over_right
over_left = BL[0] - self.left
if over_left > 0:
self.left = self.left + over_left
self.right = self.left + self.px_w
self.text_x = (self.left + mid_square) - int(self.px_w / 3)
self.lines = []
self.text_pos = []
self.ranges = []
# REVERSED ORDER: Create ranges and positions from top to bottom
for i in range(self.id_num):
# Top-down: index 0 at top, index n-1 at bottom
y_coord = self.top - self.px_h - (i * self.px_h)
self.lines += [(self.left, y_coord), (self.right, y_coord)]
# Text position centered in the box
text_y = y_coord + self.mid_height - int(self.text_size / 2)
self.text_pos.append(text_y)
self.ranges.append((y_coord, y_coord + self.px_h))
## Add contour lines (using top-down coordinates)
self.lines += [Vector((self.left, self.top)), Vector((self.right, self.top)),
Vector((self.left, self.bottom)), Vector((self.right, self.bottom)),
Vector((self.left, self.top)), Vector((self.left, self.bottom)),
Vector((self.right, self.top)), Vector((self.right, self.bottom))]
shader = gpu.shader.from_builtin('UNIFORM_COLOR')
self.batch_lines = batch_for_shader(shader, 'LINES', {"pos": self.lines[2:]})
self.case = [
Vector((0, 0)),
Vector((0, self.px_h)),
Vector((self.px_w, self.px_h)),
Vector((self.px_w, 0)),
]
def invoke(self, context, event):
self.gpl = context.object.data.materials
if not len(self.gpl):
self.report({'WARNING'}, "No material to display")
return {'CANCELLED'}
self.key = event.type
self.mouse = self.init_mouse = Vector((event.mouse_region_x, event.mouse_region_y))
ui_scale = bpy.context.preferences.system.ui_scale
prefs = get_addon_prefs().nav
self.px_h = int(prefs.box_height * ui_scale)
self.px_w = int(prefs.box_width * ui_scale)
self.add_box = int(22 * ui_scale)
self.text_size = int(prefs.text_size * ui_scale)
self.text_char_limit = round((self.px_w + 10 * ui_scale) / self.text_size)
self.left_handed = prefs.left_handed
ret = self.setup(context)
if ret is not None:
return ret
self.current_area = context.area
wm = context.window_manager
args = (self, context)
self._handle = bpy.types.SpaceView3D.draw_handler_add(draw_callback_px, args, 'WINDOW', 'POST_PIXEL')
wm.modal_handler_add(self)
context.area.tag_redraw()
return {'RUNNING_MODAL'}
def id_from_coord(self, v):
# REVERSED ORDER: Check ranges from top to bottom
for i, (bottom, top) in enumerate(self.ranges):
if bottom < v.y < top:
return i
if v.y < self.ranges[0][0]:
return 0
return len(self.ranges) - 1
def id_from_mouse(self):
return self.id_from_coord(self.mouse)
def click(self, context):
'''Handle click in ui, returning True stop the modal'''
# REVERSED ORDER: Check if clicked within material boxes (top-down coordinates)
if (self.mouse.x <= self.right) and (self.bottom <= self.mouse.y <= self.top):
self.id_src = self.id_from_mouse()
self.click_src = self.mouse.copy()
self.drag_text = self.gpl[self.id_src].name
self.drag_mode = 'material'
return False
def modal(self, context, event):
context.area.tag_redraw()
self.mouse = Vector((event.mouse_region_x, event.mouse_region_y))
current_idx = material_active_index(context.object.data.materials)
if event.type in {'RIGHTMOUSE', 'ESC'}:
self.stop_mod(context)
context.object.active_material_index = self.org_index
return {'CANCELLED'}
if event.type == self.key and event.value == 'RELEASE':
self.stop_mod(context)
return {'FINISHED'}
if event.type == 'LEFTMOUSE' and event.value == 'PRESS':
self.pressed = True
stop = self.click(context)
if stop:
self.stop_mod(context)
return {'FINISHED'}
if self.pressed and self.click_src:
if (self.mouse - self.click_src).length > 4:
self.dragging = True
if event.type == 'LEFTMOUSE' and event.value == 'RELEASE':
self.pressed = self.dragging = False
self.click_src = self.drag_text = self.drag_mode = None
# REVERSED ORDER: Detect which material is under mouse (top-down)
for i, (bottom, top) in enumerate(self.ranges):
if bottom < self.mouse.y < top:
self.ui_idx = i
break
if self.ui_idx != current_idx:
context.object.active_material_index = self.ui_idx
return {'RUNNING_MODAL'}
def stop_mod(self, context):
wm = context.window_manager
bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW')
context.area.tag_redraw()
# ... rest of the file remains the same (PropertyGroup, keymap functions, etc.)
class GPNAV_material_navigator_settings(bpy.types.PropertyGroup):
# sizes
box_height: IntProperty(
name="material Box Height",
description="Individual material box height.\
\na big size take more screen space but allow better targeting",
default=30,
min=10,
max=200,
soft_min=26,
soft_max=120,
step=1,
subtype='PIXEL')
box_width: IntProperty(
name="material Box Width",
description="Individual material box width.\
\na big size take more screen space but allow better targeting",
default=250,
min=120,
max=500,
soft_min=150,
soft_max=350,
step=1,
subtype='PIXEL')
text_size: IntProperty(
name="Label Size",
description="material name label size",
default=12,
min=4,
max=40,
soft_min=8,
soft_max=20,
step=1,
subtype='PIXEL')
left_handed: BoolProperty(
name='Left Handed',
description="Pop-up appear offseted at the right of the mouse pointer\
\nto avoif hand occluding material label",
default=False)
def _indented_layout(layout, level):
indentpx = 16
if level == 0:
level = 0.0001 # Tweak so that a percentage of 0 won't split by half
indent = level * indentpx / bpy.context.region.width
split = layout.split(factor=indent)
col = split.column()
col = split.column()
return col
def draw_keymap_ui_custom(km, kmi, layout):
# col = layout.column()
col = _indented_layout(layout, 0)
if kmi.show_expanded:
col = col.column(align=True)
box = col.box()
else:
box = col.column()
split = box.split()
row = split.row(align=True)
row.prop(kmi, "show_expanded", text="", emboss=False)
row.prop(kmi, "active", text="", emboss=False)
row.label(text=kmi.name)
row = split.row()
map_type = kmi.map_type
row.prop(kmi, "map_type", text="")
if map_type == 'KEYBOARD':
row.prop(kmi, "type", text="", full_event=True)
elif map_type == 'MOUSE':
row.prop(kmi, "type", text="", full_event=True)
elif map_type == 'NDOF':
row.prop(kmi, "type", text="", full_event=True)
elif map_type == 'TWEAK':
subrow = row.row()
subrow.prop(kmi, "type", text="")
subrow.prop(kmi, "value", text="")
elif map_type == 'TIMER':
row.prop(kmi, "type", text="")
else:
row.label()
if (not kmi.is_user_defined) and kmi.is_user_modified:
ops = row.operator("gp.restore_keymap_item", text="", icon='BACK') # modified
ops.km_name = km.name
ops.kmi_name = kmi.idname
else:
row.label(text='', icon='BLANK1')
# Expanded, additional event settings
if kmi.show_expanded:
col = col.column()
box = col.box()
split = box.column()
if map_type not in {'TEXTINPUT', 'TIMER'}:
sub = split.column()
subrow = sub.row(align=True)
if map_type == 'KEYBOARD':
subrow.prop(kmi, "type", text="", event=True)
## Hide value (Should always be Press)
# subrow.prop(kmi, "value", text="")
## Hide repeat
# subrow_repeat = subrow.row(align=True)
# subrow_repeat.active = kmi.value in {'ANY', 'PRESS'}
# subrow_repeat.prop(kmi, "repeat", text="Repeat")
elif map_type in {'MOUSE', 'NDOF'}:
subrow.prop(kmi, "type", text="")
subrow.prop(kmi, "value", text="")
if map_type in {'KEYBOARD', 'MOUSE'} and kmi.value == 'CLICK_DRAG':
subrow = sub.row()
subrow.prop(kmi, "direction")
sub = box.column()
subrow = sub.row()
subrow.scale_x = 0.75
subrow.prop(kmi, "any", toggle=True)
if bpy.app.version >= (3,0,0):
subrow.prop(kmi, "shift_ui", toggle=True)
subrow.prop(kmi, "ctrl_ui", toggle=True)
subrow.prop(kmi, "alt_ui", toggle=True)
subrow.prop(kmi, "oskey_ui", text="Cmd", toggle=True)
else:
subrow.prop(kmi, "shift", toggle=True)
subrow.prop(kmi, "ctrl", toggle=True)
subrow.prop(kmi, "alt", toggle=True)
subrow.prop(kmi, "oskey", text="Cmd", toggle=True)
subrow.prop(kmi, "key_modifier", text="", event=True)
def draw_nav_pref(prefs, layout):
# - General settings
layout.label(text='material Navigator:')
col = layout.column()
row = col.row()
row.prop(prefs, 'box_height')
row.prop(prefs, 'box_width')
row = col.row()
row.prop(prefs, 'text_size')
row.prop(prefs, 'left_handed')
# -/ Keymap -
if not addon_keymaps:
return
layout.separator()
layout.label(text='Keymap:')
for akm, akmi in addon_keymaps:
km = bpy.context.window_manager.keyconfigs.user.keymaps.get(akm.name)
if not km:
continue
kmi = km.keymap_items.get(akmi.idname)
if not kmi:
continue
draw_keymap_ui_custom(km, kmi, layout)
# draw_kmi_custom(km, kmi, box)
addon_keymaps = []
def register_keymaps():
kc = bpy.context.window_manager.keyconfigs.addon
if kc is None:
return
km = kc.keymaps.new(name = "Grease Pencil", space_type = "EMPTY", region_type='WINDOW')
kmi = km.keymap_items.new('gpencil.viewport_material_nav_osd', type='Y', value='PRESS')
kmi.repeat = False
addon_keymaps.append((km, kmi))
def unregister_keymaps():
for km, kmi in addon_keymaps:
km.keymap_items.remove(kmi)
addon_keymaps.clear()
classes = (
GPT_OT_viewport_material_nav_osd,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
register_keymaps()
def unregister():
unregister_keymaps()
for cls in reversed(classes):
bpy.utils.unregister_class(cls)