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
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
- 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

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
# color = gp.palettes.active.colors.active.name
if not layers:
#by default all visible layers
layers = [l for l in gpl if not l.hide and not l.lock]#[]
# by default all visible layers
layers = [l for l in gpl if not l.hide and not l.lock] # []
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]
stroke_list = []#one stroke list for all layers.
stroke_list = [] # one stroke list for all layers.
for l in layers:
f = l.active_frame
if f:#active frame can be None
if f: # active frame can be None
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:
if s.select:
# separate in multiple stroke if parts of the strokes a selected.
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
group = list(map(itemgetter(1), g))
substrokes.append(group)
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))
#Cutting operation
# Cutting operation
if not copy:
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)
else:
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 = []
for k, g in groupby(enumerate(neg), lambda x:x[0]-x[1]):
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:
group.insert(0,group[0]-1)
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:
if len(ns) > 1:
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

View File

@ -1,10 +1,11 @@
from os import error
import bpy
import re
from bpy.types import Operator
from bpy.props import StringProperty, BoolProperty, EnumProperty
from bpy.app.handlers import persistent
from .utils import get_addon_prefs
from .utils import get_addon_prefs, is_vector_close
# --- OPS ---
@ -227,6 +228,37 @@ def refresh_areas():
for area in bpy.context.screen.areas:
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):
bl_idname = "gp.select_same_prefix"
@ -253,14 +285,35 @@ class GPTB_OT_select_set_same_prefix(Operator):
def execute(self, context):
prefs = get_addon_prefs()
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)
if not res:
# self.report({'ERROR'}, f'Error scanning {act.info}')
self.report({'ERROR'}, f'Error scanning {act.info}')
return {"CANCELLED"}
namespace = res.group(1)
namespace = res.group('tag')
if not namespace:
self.report({'WARNING'}, f'No prefix detected in {act.info} with separator {sep}')
return {"CANCELLED"}
@ -270,12 +323,22 @@ class GPTB_OT_select_set_same_prefix(Operator):
# namespace = act.info.split(sep,1)[0]
# namespace_bool_list = [l.info.split(sep,1)[0] == namespace for l in gpl]
## with reg
namespace_bool_list = [l.info.split(sep,1)[0] + sep == namespace for l in gpl]
gpl.foreach_set('select', namespace_bool_list)
## with reg # only active
# namespace_bool_list = [l.info.split(sep,1)[0] + sep == namespace for l in gpl]
# 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':
for l in gpl:
for l in pool:
if not l.select or l == act:
continue
layer_name_build(l, prefix=namespace.strip(sep))
@ -284,6 +347,7 @@ class GPTB_OT_select_set_same_prefix(Operator):
return {"FINISHED"}
class GPTB_OT_select_set_same_color(Operator):
bl_idname = "gp.select_same_color"
bl_label = "Select Same Color"
@ -307,19 +371,60 @@ class GPTB_OT_select_set_same_color(Operator):
return self.execute(context)
def execute(self, context):
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}')
color = act.channel_color
if self.mode == 'SELECT':
same_color_bool = [l.channel_color == act.channel_color for l in gpl]
gpl.foreach_set('select', same_color_bool)
## NEED FOREACH TO APPLY SELECT
## 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':
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:
continue
l.channel_color = act.channel_color
l.channel_color = color
refresh_areas()
return {"FINISHED"}

View File

@ -10,7 +10,7 @@ class GP_OT_pick_closest_layer(Operator):
bl_idname = "gp.pick_closest_layer"
bl_label = "Active Closest Stroke Layer"
bl_description = "Pick closest stroke layer"
bl_options = {"REGISTER"} # , "UNDO"
bl_options = {"REGISTER", "UNDO"}
@classmethod
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'}
"""
def palette_manager_menu(self, context):
"""Palette menu to append in existing 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')
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 = (
GPTB_PT_sidebar_panel,
GPTB_PT_checker,
@ -385,8 +392,10 @@ def register():
for cls in classes:
bpy.utils.register_class(cls)
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():
bpy.types.DOPESHEET_PT_gpencil_layer_display.remove(expose_use_channel_color_pref)
bpy.types.GPENCIL_MT_material_context_menu.remove(palette_manager_menu)
for cls in reversed(classes):
bpy.utils.unregister_class(cls)

View File

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

115
utils.py
View File

@ -3,6 +3,7 @@ import numpy as np
import bmesh
import mathutils
from mathutils import Vector
import math
from math import sqrt
from sys import platform
import subprocess
@ -702,6 +703,11 @@ def detect_OS():
print("Cannot detect OS, python 'sys.platform' give :", myOS)
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):
'''Convert given value to a Json serializable format'''
if isinstance(Attr, (mathutils.Vector,mathutils.Color)):
@ -741,4 +747,111 @@ def show_message_box(_message = "", _title = "Message Box", _icon = 'INFO'):
if isinstance(_message, str):
_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)