greasepencil-addon icon indicating copy to clipboard operation
greasepencil-addon copied to clipboard

Feature: Material version of layer navigator

Open Pullusb opened this issue 4 months ago • 1 comments

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.

materialnavigator

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)

Pullusb avatar Oct 12 '25 13:10 Pullusb