select/set color/prefix improved

1.7.5

- feat: Select/set by color and by prefix now works on every displayed dopesheet layer (and react correctly to filters)
- ui: exposed user prefs `Channel Group Color` prop in dopesheet > sidebar > View > Display panel
gpv2
Pullusb 2021-10-28 14:33:37 +02:00
parent 56cbc04c65
commit 44ccb3d146
7 changed files with 272 additions and 36 deletions

View File

@ -1,8 +1,15 @@
# Changelog # Changelog
1.7.5
- feat: Select/set by color and by prefix now works on every displayed dopesheet layer (and react correctly to filters)
- ui: exposed user prefs `Channel Group Color` prop in dopesheet > sidebar > View > Display panel
- add undo step for `W`'s select layer from closest stroke
1.7.4 1.7.4
- added: Pick layer from closest stroke in paint mode using quick press on `W` for stroke (and `alt+W` for fills) - added: Pick layer from closest stroke in paint mode using quick press on `W` for stroke (and `alt+W` for fills)
- fix: copy-paste keymap error on background rendering
1.7.3 1.7.3

View File

@ -153,38 +153,38 @@ def copycut_strokes(layers=None, copy=True, keep_empty=True):# (mayber allow fil
# if not color:#get active color name # if not color:#get active color name
# color = gp.palettes.active.colors.active.name # color = gp.palettes.active.colors.active.name
if not layers: if not layers:
#by default all visible layers # by default all visible layers
layers = [l for l in gpl if not l.hide and not l.lock]#[] layers = [l for l in gpl if not l.hide and not l.lock] # []
if not isinstance(layers, list): if not isinstance(layers, list):
#if a single layer object is send put in a list # if a single layer object is send put in a list
layers = [layers] layers = [layers]
stroke_list = []#one stroke list for all layers. stroke_list = [] # one stroke list for all layers.
for l in layers: for l in layers:
f = l.active_frame f = l.active_frame
if f:#active frame can be None if f: # active frame can be None
if not copy: if not copy:
staylist = []#init part of strokes that must survive on this layer staylist = [] # init part of strokes that must survive on this layer
for s in f.strokes: for s in f.strokes:
if s.select: if s.select:
# separate in multiple stroke if parts of the strokes a selected. # separate in multiple stroke if parts of the strokes a selected.
sel = [i for i, p in enumerate(s.points) if p.select] sel = [i for i, p in enumerate(s.points) if p.select]
substrokes = []# list of list containing isolated selection substrokes = [] # list of list containing isolated selection
for k, g in groupby(enumerate(sel), lambda x:x[0]-x[1]):# continuity stroke have same substract result between point index and enumerator for k, g in groupby(enumerate(sel), lambda x:x[0]-x[1]):# continuity stroke have same substract result between point index and enumerator
group = list(map(itemgetter(1), g)) group = list(map(itemgetter(1), g))
substrokes.append(group) substrokes.append(group)
for ss in substrokes: for ss in substrokes:
if len(ss) > 1:#avoid copy isolated points if len(ss) > 1: # avoid copy isolated points
stroke_list.append(dump_gp_stroke_range(s,ss,l,obj)) stroke_list.append(dump_gp_stroke_range(s,ss,l,obj))
#Cutting operation # Cutting operation
if not copy: if not copy:
maxindex = len(s.points)-1 maxindex = len(s.points)-1
if len(substrokes) == maxindex+1:#si un seul substroke, c'est le stroke entier if len(substrokes) == maxindex+1: # if only one substroke, then it's the full stroke
f.strokes.remove(s) f.strokes.remove(s)
else: else:
neg = [i for i, p in enumerate(s.points) if not p.select] neg = [i for i, p in enumerate(s.points) if not p.select]
@ -192,7 +192,7 @@ def copycut_strokes(layers=None, copy=True, keep_empty=True):# (mayber allow fil
staying = [] staying = []
for k, g in groupby(enumerate(neg), lambda x:x[0]-x[1]): for k, g in groupby(enumerate(neg), lambda x:x[0]-x[1]):
group = list(map(itemgetter(1), g)) group = list(map(itemgetter(1), g))
#extend group to avoid gap when cut, a bit dirty # extend group to avoid gap when cut, a bit dirty
if group[0] > 0: if group[0] > 0:
group.insert(0,group[0]-1) group.insert(0,group[0]-1)
if group[-1] < maxindex: if group[-1] < maxindex:
@ -202,7 +202,7 @@ def copycut_strokes(layers=None, copy=True, keep_empty=True):# (mayber allow fil
for ns in staying: for ns in staying:
if len(ns) > 1: if len(ns) > 1:
staylist.append(dump_gp_stroke_range(s,ns,l,obj)) staylist.append(dump_gp_stroke_range(s,ns,l,obj))
#make a negative list containing all last index # make a negative list containing all last index
'''#full stroke version '''#full stroke version

View File

@ -1,10 +1,11 @@
from os import error from os import error
import bpy import bpy
import re import re
from bpy.types import Operator from bpy.types import Operator
from bpy.props import StringProperty, BoolProperty, EnumProperty from bpy.props import StringProperty, BoolProperty, EnumProperty
from bpy.app.handlers import persistent from bpy.app.handlers import persistent
from .utils import get_addon_prefs from .utils import get_addon_prefs, is_vector_close
# --- OPS --- # --- OPS ---
@ -227,6 +228,37 @@ def refresh_areas():
for area in bpy.context.screen.areas: for area in bpy.context.screen.areas:
area.tag_redraw() area.tag_redraw()
def build_layers_targets_from_dopesheet(context):
'''Return all selected layers on context GP dopesheet according to seelction and filters'''
ob = context.object
gpl = context.object.data.layers
act = gpl.active
dopeset = context.space_data.dopesheet
if dopeset.show_only_selected:
pool = [o for o in context.selected_objects if o.type == 'GPENCIL']
else:
pool = [o for o in context.scene.objects if o.type == 'GPENCIL']
if not dopeset.show_hidden:
pool = [o for o in pool if o.visible_get()]
layer_pool = [l for o in pool for l in o.data.layers]
layer_pool = list(set(layer_pool)) # remove dupli-layers from same data source with
# apply search filter
if dopeset.filter_text:
layer_pool = [l for l in layer_pool if (dopeset.filter_text.lower() in l.info.lower()) ^ dopeset.use_filter_invert]
return layer_pool
def build_dope_gp_list(layer_list):
'''Take a list of GP layers return a dict with pairs {gp data : own layer list}'''
from collections import defaultdict
gps = defaultdict(list)
for l in layer_list:
gps[l.id_data].append(l)
return gps
class GPTB_OT_select_set_same_prefix(Operator): class GPTB_OT_select_set_same_prefix(Operator):
bl_idname = "gp.select_same_prefix" bl_idname = "gp.select_same_prefix"
@ -253,14 +285,35 @@ class GPTB_OT_select_set_same_prefix(Operator):
def execute(self, context): def execute(self, context):
prefs = get_addon_prefs() prefs = get_addon_prefs()
sep = prefs.separator # '_' sep = prefs.separator # '_'
gpl = context.object.data.layers
act = gpl.active gp = context.object.data
act = gp.layers.active
pool = build_layers_targets_from_dopesheet(context)
if not pool:
self.report({'ERROR'}, 'No layers found in current GP dopesheet')
return {"CANCELLED"}
gp_dic = build_dope_gp_list(pool)
if not act:
# Check in other displayed layer if there is an active one
for gp, _layer_list in gp_dic.items():
if gp.layers.active:
# overwrite gp variable at the same time
act = gp.layers.active
break
if not act:
self.report({'ERROR'}, 'No active layer to base action')
return {"CANCELLED"}
print(f'Select/Set ref layer: {gp.name} > {gp.layers.active.info}')
res = re.search(PATTERN, act.info) res = re.search(PATTERN, act.info)
if not res: if not res:
# self.report({'ERROR'}, f'Error scanning {act.info}') self.report({'ERROR'}, f'Error scanning {act.info}')
return {"CANCELLED"} return {"CANCELLED"}
namespace = res.group(1)
namespace = res.group('tag')
if not namespace: if not namespace:
self.report({'WARNING'}, f'No prefix detected in {act.info} with separator {sep}') self.report({'WARNING'}, f'No prefix detected in {act.info} with separator {sep}')
return {"CANCELLED"} return {"CANCELLED"}
@ -270,12 +323,22 @@ class GPTB_OT_select_set_same_prefix(Operator):
# namespace = act.info.split(sep,1)[0] # namespace = act.info.split(sep,1)[0]
# namespace_bool_list = [l.info.split(sep,1)[0] == namespace for l in gpl] # namespace_bool_list = [l.info.split(sep,1)[0] == namespace for l in gpl]
## with reg ## with reg # only active
namespace_bool_list = [l.info.split(sep,1)[0] + sep == namespace for l in gpl] # namespace_bool_list = [l.info.split(sep,1)[0] + sep == namespace for l in gpl]
gpl.foreach_set('select', namespace_bool_list) # gpl.foreach_set('select', namespace_bool_list)
## don't work Need Foreach set per gp
# for l in pool:
# l.select = l.info.split(sep,1)[0] + sep == namespace
for gp, layers in gp_dic.items():
# check namespace + restrict selection to visible layers according to filters
# TODO : Should use the regex pattern to detect and compare r.group('tag')
namespace_bool_list = [(l in layers) and (l.info.split(sep,1)[0] + sep == namespace) for l in gp.layers]
gp.layers.foreach_set('select', namespace_bool_list)
elif self.mode == 'SET': elif self.mode == 'SET':
for l in gpl: for l in pool:
if not l.select or l == act: if not l.select or l == act:
continue continue
layer_name_build(l, prefix=namespace.strip(sep)) layer_name_build(l, prefix=namespace.strip(sep))
@ -284,6 +347,7 @@ class GPTB_OT_select_set_same_prefix(Operator):
return {"FINISHED"} return {"FINISHED"}
class GPTB_OT_select_set_same_color(Operator): class GPTB_OT_select_set_same_color(Operator):
bl_idname = "gp.select_same_color" bl_idname = "gp.select_same_color"
bl_label = "Select Same Color" bl_label = "Select Same Color"
@ -307,19 +371,60 @@ class GPTB_OT_select_set_same_color(Operator):
return self.execute(context) return self.execute(context)
def execute(self, context): def execute(self, context):
gpl = context.object.data.layers gp = context.object.data
act = gpl.active act = gp.layers.active
pool = build_layers_targets_from_dopesheet(context)
if not pool:
self.report({'ERROR'}, 'No layers found in current GP dopesheet')
return {"CANCELLED"}
gp_dic = build_dope_gp_list(pool)
if not act:
# Check in other displayed layer if there is an active one
for gp, _layer_list in gp_dic.items():
if gp.layers.active:
# overwrite gp variable at the same time
act = gp.layers.active
break
if not act:
self.report({'ERROR'}, 'No active layer to base action')
return {"CANCELLED"}
print(f'Select/Set ref layer: {gp.name} > {gp.layers.active.info}')
color = act.channel_color
if self.mode == 'SELECT': if self.mode == 'SELECT':
same_color_bool = [l.channel_color == act.channel_color for l in gpl] ## NEED FOREACH TO APPLY SELECT
gpl.foreach_set('select', same_color_bool)
## Only on active object
# same_color_bool = [l.channel_color == act.channel_color for l in gpl]
# gpl.foreach_set('select', same_color_bool) # only
# On multiple objects -- don't work, need foreach
# for l in pool:
# print(l.id_data.name, l.info, l.channel_color == act.channel_color)
# l.select = l.channel_color == act.channel_color
"""
gps = []
for l in pool:
if l.id_data not in gps:
gps.append(l.id_data)
for gp in gps:
same_color_bool = [(l in pool) and is_vector_close(l.channel_color, color) for l in gp.layers]
gp.layers.foreach_set('select', same_color_bool)
"""
for gp, layers in gp_dic.items():
# check color and restrict selection to visible layers according to filters
same_color_bool = [(l in layers) and is_vector_close(l.channel_color, color) for l in gp.layers]
gp.layers.foreach_set('select', same_color_bool)
elif self.mode == 'SET': elif self.mode == 'SET':
activate_channel_group_color(context) activate_channel_group_color(context)
for l in gpl: for l in pool: # only on active object use gpl
if not l.select or l == act: if not l.select or l == act:
continue continue
l.channel_color = act.channel_color l.channel_color = color
refresh_areas() refresh_areas()
return {"FINISHED"} return {"FINISHED"}

View File

@ -10,7 +10,7 @@ class GP_OT_pick_closest_layer(Operator):
bl_idname = "gp.pick_closest_layer" bl_idname = "gp.pick_closest_layer"
bl_label = "Active Closest Stroke Layer" bl_label = "Active Closest Stroke Layer"
bl_description = "Pick closest stroke layer" bl_description = "Pick closest stroke layer"
bl_options = {"REGISTER"} # , "UNDO" bl_options = {"REGISTER", "UNDO"}
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):

View File

@ -355,7 +355,6 @@ class GPTB_PT_cam_ref_panel(bpy.types.Panel):
row.prop(bg_img, 'show_background_image', text='')# options={'HIDDEN'} row.prop(bg_img, 'show_background_image', text='')# options={'HIDDEN'}
""" """
def palette_manager_menu(self, context): def palette_manager_menu(self, context):
"""Palette menu to append in existing menu""" """Palette menu to append in existing menu"""
# GPENCIL_MT_material_context_menu # GPENCIL_MT_material_context_menu
@ -371,6 +370,14 @@ def palette_manager_menu(self, context):
layout.operator("gp.clean_material_stack", text='Clean material Stack', icon='NODE_MATERIAL') layout.operator("gp.clean_material_stack", text='Clean material Stack', icon='NODE_MATERIAL')
def expose_use_channel_color_pref(self, context):
# add in GreasePencilLayerDisplayPanel (gp dopesheet View > Display)
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
layout.label(text='Use Channel Colors (User preferences):')
layout.prop(context.preferences.edit, 'use_anim_channel_group_colors')
classes = ( classes = (
GPTB_PT_sidebar_panel, GPTB_PT_sidebar_panel,
GPTB_PT_checker, GPTB_PT_checker,
@ -385,8 +392,10 @@ def register():
for cls in classes: for cls in classes:
bpy.utils.register_class(cls) bpy.utils.register_class(cls)
bpy.types.GPENCIL_MT_material_context_menu.append(palette_manager_menu) bpy.types.GPENCIL_MT_material_context_menu.append(palette_manager_menu)
bpy.types.DOPESHEET_PT_gpencil_layer_display.append(expose_use_channel_color_pref)
def unregister(): def unregister():
bpy.types.DOPESHEET_PT_gpencil_layer_display.remove(expose_use_channel_color_pref)
bpy.types.GPENCIL_MT_material_context_menu.remove(palette_manager_menu) bpy.types.GPENCIL_MT_material_context_menu.remove(palette_manager_menu)
for cls in reversed(classes): for cls in reversed(classes):
bpy.utils.unregister_class(cls) bpy.utils.unregister_class(cls)

View File

@ -15,7 +15,7 @@ bl_info = {
"name": "GP toolbox", "name": "GP toolbox",
"description": "Set of tools for Grease Pencil in animation production", "description": "Set of tools for Grease Pencil in animation production",
"author": "Samuel Bernou, Christophe Seux", "author": "Samuel Bernou, Christophe Seux",
"version": (1, 7, 4), "version": (1, 7, 5),
"blender": (2, 91, 0), "blender": (2, 91, 0),
"location": "Sidebar (N menu) > Gpencil > Toolbox / Gpencil properties", "location": "Sidebar (N menu) > Gpencil > Toolbox / Gpencil properties",
"warning": "", "warning": "",
@ -26,8 +26,9 @@ bl_info = {
# from . import addon_updater_ops # from . import addon_updater_ops
from .utils import * # from .utils import *
from .functions import * from .utils import get_addon_prefs, draw_kmi
# from .functions import *
## GMIC ## GMIC
from .GP_guided_colorize import GP_colorize from .GP_guided_colorize import GP_colorize
@ -48,7 +49,7 @@ from . import OP_realign
from . import OP_depth_move from . import OP_depth_move
from . import OP_key_duplicate_send from . import OP_key_duplicate_send
from . import OP_layer_manager from . import OP_layer_manager
from . import OP_stroke_picker from . import OP_layer_picker
from . import OP_material_picker from . import OP_material_picker
from . import OP_eraser_brush from . import OP_eraser_brush
from . import TOOL_eraser_brush from . import TOOL_eraser_brush
@ -61,6 +62,7 @@ from . import UI_tools
from .properties import GP_PG_ToolsSettings, GP_PG_FixSettings from .properties import GP_PG_ToolsSettings, GP_PG_FixSettings
from bpy.props import (FloatProperty, from bpy.props import (FloatProperty,
BoolProperty, BoolProperty,
EnumProperty, EnumProperty,
@ -580,7 +582,7 @@ def register():
OP_layer_manager.register() OP_layer_manager.register()
OP_eraser_brush.register() OP_eraser_brush.register()
OP_material_picker.register() OP_material_picker.register()
OP_stroke_picker.register() OP_layer_picker.register()
TOOL_eraser_brush.register() TOOL_eraser_brush.register()
handler_draw_cam.register() handler_draw_cam.register()
UI_tools.register() UI_tools.register()
@ -607,7 +609,7 @@ def unregister():
UI_tools.unregister() UI_tools.unregister()
handler_draw_cam.unregister() handler_draw_cam.unregister()
TOOL_eraser_brush.unregister() TOOL_eraser_brush.unregister()
OP_stroke_picker.unregister() OP_layer_picker.unregister()
OP_material_picker.unregister() OP_material_picker.unregister()
OP_eraser_brush.unregister() OP_eraser_brush.unregister()
OP_layer_manager.unregister() OP_layer_manager.unregister()

113
utils.py
View File

@ -3,6 +3,7 @@ import numpy as np
import bmesh import bmesh
import mathutils import mathutils
from mathutils import Vector from mathutils import Vector
import math
from math import sqrt from math import sqrt
from sys import platform from sys import platform
import subprocess import subprocess
@ -702,6 +703,11 @@ def detect_OS():
print("Cannot detect OS, python 'sys.platform' give :", myOS) print("Cannot detect OS, python 'sys.platform' give :", myOS)
return None return None
def is_vector_close(a, b, rel_tol=1e-03):
'''compare Vector or sequence of value
by default tolerance is set on 1e-03 (0.001)'''
return all([math.isclose(i, j, rel_tol=rel_tol) for i, j in zip(a,b)])
def convert_attr(Attr): def convert_attr(Attr):
'''Convert given value to a Json serializable format''' '''Convert given value to a Json serializable format'''
if isinstance(Attr, (mathutils.Vector,mathutils.Color)): if isinstance(Attr, (mathutils.Vector,mathutils.Color)):
@ -742,3 +748,110 @@ def show_message_box(_message = "", _title = "Message Box", _icon = 'INFO'):
if isinstance(_message, str): if isinstance(_message, str):
_message = [_message] _message = [_message]
bpy.context.window_manager.popup_menu(draw, title = _title, icon = _icon) bpy.context.window_manager.popup_menu(draw, title = _title, icon = _icon)
### UI utils
## kmi draw for addon without delete button
def draw_kmi(km, kmi, layout):
map_type = kmi.map_type
# col = _indented_layout(layout)
col = layout.column()
if kmi.show_expanded:
col = col.column(align=True)
box = col.box()
else:
box = col.column()
split = box.split()
# header bar
row = split.row(align=True)
row.prop(kmi, "show_expanded", text="", emboss=False)
row.prop(kmi, "active", text="", emboss=False)
if km.is_modal:
row.separator()
row.prop(kmi, "propvalue", text="")
else:
row.label(text=kmi.name)
row = split.row()
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()
### / Hided delete button
if (not kmi.is_user_defined) and kmi.is_user_modified:
row.operator("preferences.keyitem_restore", text="", icon='BACK').item_id = kmi.id
else:
pass ### NO REMOVB
# row.operator(
# "preferences.keyitem_remove",
# text="",
# # Abusing the tracking icon, but it works pretty well here.
# icon=('TRACKING_CLEAR_BACKWARDS' if kmi.is_user_defined else 'X')
# ).item_id = kmi.id
### Hided delete button /
# Expanded, additional event settings
if kmi.show_expanded:
box = col.box()
split = box.split(factor=0.4)
sub = split.row()
if km.is_modal:
sub.prop(kmi, "propvalue", text="")
else:
# One day...
# sub.prop_search(kmi, "idname", bpy.context.window_manager, "operators_all", text="")
sub.prop(kmi, "idname", text="")
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)
subrow.prop(kmi, "value", text="")
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="")
subrow = sub.row()
subrow.scale_x = 0.75
subrow.prop(kmi, "any", toggle=True)
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)
# Operator properties
box.template_keymap_item_properties(kmi)
## Modal key maps attached to this operator
# if not km.is_modal:
# kmm = kc.keymaps.find_modal(kmi.idname)
# if kmm:
# draw_km(display_keymaps, kc, kmm, None, layout + 1)
# layout.context_pointer_set("keymap", km)