diff --git a/CHANGELOG.md b/CHANGELOG.md index ed166da..928d649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/OP_copy_paste.py b/OP_copy_paste.py index 3e5ed6a..a4db3a6 100644 --- a/OP_copy_paste.py +++ b/OP_copy_paste.py @@ -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 diff --git a/OP_layer_manager.py b/OP_layer_manager.py index 9438be2..0db67e4 100644 --- a/OP_layer_manager.py +++ b/OP_layer_manager.py @@ -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"} diff --git a/OP_stroke_picker.py b/OP_layer_picker.py similarity index 98% rename from OP_stroke_picker.py rename to OP_layer_picker.py index 81292e2..0f93e4c 100644 --- a/OP_stroke_picker.py +++ b/OP_layer_picker.py @@ -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): diff --git a/UI_tools.py b/UI_tools.py index 9c378c3..44112a2 100644 --- a/UI_tools.py +++ b/UI_tools.py @@ -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) diff --git a/__init__.py b/__init__.py index f7cbc14..078af0d 100755 --- a/__init__.py +++ b/__init__.py @@ -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() diff --git a/utils.py b/utils.py index d0142d0..8ac8bb1 100644 --- a/utils.py +++ b/utils.py @@ -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) \ No newline at end of file + 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) \ No newline at end of file