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, is_vector_close # --- OPS --- # PATTERN = r'([A-Z]{2})?_?([A-Z]{2})?_?(.*)' # bad ! match whithout separator # pattern = r'(?:(^[A-Z]{2})_)?(?:([A-Z]{2})_)?(.*)' # matching only two letter # pattern = r'^([A-Z]{2}_)?([A-Z]{2}_)?(.*)' # matching letters with separator # pattern = r'^([A-Z]{1,6}_)?([A-Z]{1,6}_)?(.*)' # matching capital letters from one to six # pattern = r'^([A-Z]{1,6}_)?([A-Z]{1,6}_)?(.*?)(_[A-Z]{2})?$' # 2 letter suffix # pattern = r'^(?P[A-Z]{1,6}_)?(?P[A-Z]{1,6}_)?(?P.*?)(?P_[A-Z]{2})?$' # named # pattern = r'^(?P-\s)?(?P[A-Z]{2}_)?(?P[A-Z]{1,6}_)?(?P.*?)(?P_[A-Z]{2})?$' # group start ' - ' # PATTERN = r'^(?P-\s)?(?P[A-Z]{2}_)?(?P[A-Z]{1,6}_)?(?P.*?)(?P_[A-Z]{2})?(?P\.\d{3})?$' # numering PATTERN = r'^(?P-\s)?(?P[A-Z]{2}_)?(?P.*?)(?P_[A-Z]{2})?(?P\.\d{3})?$' # numering # TODO: allow a more flexible prefix pattern def layer_name_build(layer, prefix='', desc='', suffix=''): '''GET a layer and argument to build and assign name return new name ''' prefs = get_addon_prefs() sep = prefs.separator name = old = layer.name pattern = PATTERN.replace('_', sep) # set separator res = re.search(pattern, name.strip()) # prefix -> tag # prefix2 -> tag2 # desc -> name # suffix -> sfix grp = '' if res.group('grp') is None else res.group('grp') tag = '' if res.group('tag') is None else res.group('tag') # tag2 = '' if res.group('tag2') is None else res.group('tag2') name = '' if res.group('name') is None else res.group('name') sfix = '' if res.group('sfix') is None else res.group('sfix') inc = '' if res.group('inc') is None else res.group('inc') if grp: grp = ' ' + grp # name is strip(), so grp first spaces are gones. if prefix: if prefix == 'prefixkillcode': tag = '' else: tag = prefix.upper().strip() + sep # if prefix2: # tag2 = prefix2.upper().strip() + sep if desc: name = desc if suffix: if suffix == 'suffixkillcode': sfix = '' else: sfix = sep + suffix.upper().strip() # check if name is available without the increment ending new = f'{grp}{tag}{name}{sfix}' layer.name = new ## update name in modifier targets if old != new: # find objects using this GP datablock for ob_user in [o for o in bpy.data.objects if o.data == layer.id_data]: # bpy.context.scene.objects # maybe a more elegant way exists to find all objects users ? # update Gpencil modifier targets for mod in ob_user.modifiers: if not hasattr(mod, 'layer_filter'): continue if mod.layer_filter == old: mod.layer_filter = new """ def layer_name_build(layer, prefix='', prefix2='', desc='', suffix=''): '''GET a layer and infos to build name Can take one or two prefix and description/name of the layer) ''' prefs = get_addon_prefs() sep = prefs.separator name = layer.name pattern = pattern.replace('_', sep) # set separator res = re.search(pattern, name.strip()) p1 = '' if res.group(1) is None else res.group(1) p2 = '' if res.group(2) is None else res.group(2) p3 = '' if res.group(3) is None else res.group(3) p4 = '' if res.group(4) is None else res.group(4) if prefix: if prefix == 'prefixkillcode': p1 = '' else: p1 = prefix.upper().strip() + sep if prefix2: p2 = prefix2.upper().strip() + sep if desc: p3 = desc if suffix: if suffix == 'suffixkillcode': p4 = '' else: p4 = sep + suffix.upper().strip() new = f'{p1}{p2}{p3}{p4}' layer.name = new """ ## multi-prefix solution (Caps letters) class GPTB_OT_layer_name_build(Operator): bl_idname = "gp.layer_name_build" bl_label = "Layer Name Build" bl_description = "Change prefix of layer name" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context): return True prefix : StringProperty(default='', options={'SKIP_SAVE'}) # prefix2 : StringProperty(default='', options={'SKIP_SAVE'}) desc : StringProperty(default='', options={'SKIP_SAVE'}) suffix : StringProperty(default='', options={'SKIP_SAVE'}) tooltip : StringProperty(default='', options={'SKIP_SAVE'}) @classmethod def description(cls, context, properties): tag = properties.prefix if properties.prefix else properties.suffix if properties.tooltip: return f"Use prefix: {tag} ({properties.tooltip})" else: return f"Use prefix: {tag}" def execute(self, context): ob = context.object gpl = ob.data.layers act = gpl.active if not act: act = ob.data.layer_groups.active if not act: self.report({'ERROR'}, 'No layer active') return {"CANCELLED"} layer_name_build(act, prefix=self.prefix, desc=self.desc, suffix=self.suffix) ## /!\ Deactivate multi-selection on layer ! ## Somethimes it affect a random layer that is still considered selected # for l in gpl: # if l.select or l == act: # layer_name_build(l, prefix=self.prefix, desc=self.desc, suffix=self.suffix) return {"FINISHED"} #-## SELECTION MANAGEMENT ##-# def activate_channel_group_color(context): if not context.preferences.edit.use_anim_channel_group_colors: context.preferences.edit.use_anim_channel_group_colors = True 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 == 'GREASEPENCIL'] else: pool = [o for o in context.scene.objects if o.type == 'GREASEPENCIL'] 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.name.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" bl_label = "Select Same Prefix" bl_description = "Select layers that have the same prefix as active\nSet with ctrl+clic" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context): return context.object and context.object.type == 'GREASEPENCIL' mode : EnumProperty(default='SELECT', options={'SKIP_SAVE'}, items=( ('SELECT', "Select", "Select layer with same prefix as active"), ('SET', "Set", "Set prefix on selected layer to the same as active"), ), ) def invoke(self, context, event): if event.ctrl: self.mode = 'SET' return self.execute(context) def execute(self, context): prefs = get_addon_prefs() sep = prefs.separator # '_' 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.name}') res = re.search(PATTERN, act.name) if not res: self.report({'ERROR'}, f'Error scanning {act.name}') return {"CANCELLED"} namespace = res.group('tag') if not namespace: self.report({'WARNING'}, f'No prefix detected in {act.name} with separator {sep}') return {"CANCELLED"} if self.mode == 'SELECT': ## with split # namespace = act.name.split(sep,1)[0] # namespace_bool_list = [l.name.split(sep,1)[0] == namespace for l in gpl] ## with reg # only active # namespace_bool_list = [l.name.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.name.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.name.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 pool: if not l.select or l == act: continue layer_name_build(l, prefix=namespace.strip(sep)) refresh_areas() return {"FINISHED"} class GPTB_OT_select_set_same_color(Operator): bl_idname = "gp.select_same_color" bl_label = "Select Same Color" bl_description = "Select layers that have the same color as active\nSet with ctrl+clic" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context): return context.object and context.object.type == 'GREASEPENCIL' mode : EnumProperty(default='SELECT', options={'SKIP_SAVE'}, items=( ('SELECT', "Select", "Select layer with same prefix as active"), ('SET', "Set", "Set prefix on selected layer to the same as active"), ), ) def invoke(self, context, event): if event.ctrl: self.mode = 'SET' return self.execute(context) def execute(self, context): 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.name}') color = act.channel_color if self.mode == 'SELECT': ## 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.name, 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 pool: # only on active object use gpl if not l.select or l == act: continue l.channel_color = color refresh_areas() return {"FINISHED"} def replace_layer_name(target, replacement, selected_only=True, prefix_only=True, regex=False): prefs = get_addon_prefs() sep = prefs.separator if not target: return gpl = bpy.context.object.data.layers if selected_only: lays = [l for l in gpl if l.select] # exclude : l.name != 'background' else: lays = [l for l in gpl] # exclude : if l.name != 'background' ct = 0 for l in lays: old = l.name if regex: new = re.sub(target, replacement, l.name) if old != new: l.name = new print('rename:', old, '-->', new) ct += 1 continue if prefix_only: if not sep in l.name: # only if separator exists continue splited = l.name.split(sep) prefix = splited[0] new_prefix = prefix.replace(target, replacement) if prefix != new_prefix: splited[0] = new_prefix l.name = sep.join(splited) print('rename:', old, '-->', l.name) ct += 1 else: new = l.name.replace(target, replacement) if old != new: l.name = new print('rename:', old, '-->', new) ct += 1 return ct class GPTB_OT_rename_gp_layer(Operator): '''rename GP layers based on a search and replace''' bl_idname = "gp.rename_gp_layers" bl_label = "Rename Gp Layers" bl_description = "Search/Replace string in all GP layers" @classmethod def poll(cls, context): return context.object and context.object.type == 'GREASEPENCIL' find: StringProperty(name="Find", description="Name to replace", default="", maxlen=0, options={'ANIMATABLE'}, subtype='NONE') replace: StringProperty(name="Repl", description="New name placed", default="", maxlen=0, options={'ANIMATABLE'}, subtype='NONE') selected: BoolProperty(name="Selected Only", description="Affect only selected layers", default=False) prefix: BoolProperty(name="Prefix Only", description="Affect only prefix of name (skip layer without separator in name)", default=False) use_regex: BoolProperty(name="Regex", description="use regular expression (advanced), equivalent to python re.sub()", default=False) def execute(self, context): count = replace_layer_name(self.find, self.replace, selected_only=self.selected, prefix_only=self.prefix, regex=self.use_regex) if count: mess = str(count) + ' layers renamed' self.report({'INFO'}, mess) else: self.report({'WARNING'}, 'No text found !') return{'FINISHED'} def invoke(self, context, event): return context.window_manager.invoke_props_dialog(self) def draw(self, context): layout = self.layout row = layout.row() row_a= row.row() row_a.prop(self, "selected") row_b= row.row() row_b.prop(self, "prefix") row_c= row.row() row_c.prop(self, "use_regex") row_b.active = not self.use_regex layout.prop(self, "find") layout.prop(self, "replace") ## --- UI layer panel--- def layer_name_builder_ui(self, context): '''appended to DATA_PT_grease_pencil_layers''' prefs = get_addon_prefs() if not prefs.show_prefix_buttons: return if not len(prefs.prefixes.namespaces) and not len(prefs.suffixes.namespaces): return layout = self.layout # {'EDIT_GREASE_PENCIL', 'PAINT_GREASE_PENCIL','SCULPT_GREASE_PENCIL','WEIGHT_GREASE_PENCIL', 'VERTEX_GPENCIL'} # layout.separator() col = layout.column() line_limit = 8 if len(prefs.prefixes.namespaces): ct = 0 # can't use enumerate cause there can be hided prefix for namespace in prefs.prefixes.namespaces: if namespace.hide: continue if ct % line_limit == 0: row = col.row(align=True) ct += 1 op = row.operator("gp.layer_name_build", text=namespace.tag) op.prefix = namespace.tag op.tooltip = namespace.name if ct > 0: row.operator("gp.layer_name_build", text='', icon='X').prefix = 'prefixkillcode' ## old single string prefix method """ if prefs.prefixes: p = prefs.prefixes.split(',') for i, prefix in enumerate(all_prefixes): if i % line_limit == 0: row = col.row(align=True) row.operator("gp.layer_name_build", text=prefix.upper() ).prefix = prefix row.operator("gp.layer_name_build", text='', icon='X').prefix = 'prefixkillcode' ## secondary prefix ? if prefs.suffixes: all_suffixes = prefs.suffixes.split(',') for i, suffix in enumerate(all_suffixes): if i % line_limit == 0: row = col.row(align=True) row.operator("gp.layer_name_build", text=suffix.upper() ).suffix = suffix row.operator("gp.layer_name_build", text='', icon='X').suffix = 'suffixkillcode' """ ## name (description of layer content) row = col.row(align=True) row.prop(context.scene.gptoolprops, 'layer_name', text='') ## mimic groups using dash (disabled for now) # row.operator("gp.layer_new_group", text='', icon='COLLECTION_NEW') # row.operator("gp.layer_group_toggle", text='', icon='OUTLINER_OB_GROUP_INSTANCE') ## no need for desc ops, already trigerred from update # row.operator("gp.layer_name_build", text='', icon='EVENT_RETURN').desc = context.scene.gptoolprops.layer_name if len(prefs.suffixes.namespaces): ct = 0 # can't use enumerate cause there can be hided prefix for namespace in prefs.suffixes.namespaces: if namespace.hide: continue if ct % line_limit == 0: row = col.row(align=True) ct += 1 op = row.operator("gp.layer_name_build", text=namespace.tag) op.suffix = namespace.tag op.tooltip = namespace.name if ct > 0: row.operator("gp.layer_name_build", text='', icon='X').suffix = 'suffixkillcode' ## --- UI dopesheet --- def gpencil_dopesheet_header(self, context): '''to append in DOPESHEET_HT_header''' layout = self.layout st = context.space_data if st.mode != 'GREASEPENCIL': return row = layout.row(align=True) # row.operator('gp.active_channel_color_to_selected', text='', icon='RESTRICT_COLOR_ON') row.operator('gp.select_same_prefix', text='', icon='SYNTAX_OFF') # SORTALPHA, SMALL_CAPS row.operator('gp.select_same_color', text='', icon='RESTRICT_COLOR_ON') ## --- UI context menu --- def gpencil_layer_dropdown_menu(self, context): '''to append in GPENCIL_MT_layer_context_menu''' self.layout.operator('gp.create_empty_frames', icon='KEYFRAME') self.layout.operator('gp.rename_gp_layers', icon='BORDERMOVE') ## handler and msgbus def obj_layer_name_callback(): '''assign layer name properties so user an tweak it''' ob = bpy.context.object if not ob or ob.type != 'GREASEPENCIL': return if not ob.data.layers.active: return ## Set selection to active object ot avoid un-sync selection on Layers stack ## (happen when an objet is selected but not active with 'lock object mode') for l in ob.data.layers: l.select = l == ob.data.layers.active res = re.search(PATTERN, ob.data.layers.active.name.strip()) if not res: return if not res.group('name'): return # print('grp:', res.group('grp')) # print('tag:', res.group('tag')) # print('name:', res.group('name')) # print('sfix:', res.group('sfix')) # print('inc:', res.group('inc')) bpy.context.scene.gptoolprops['layer_name'] = res.group('name') ## old gpv2 # def subscribe_layer_change(): # subscribe_to = (bpy.types.GreasePencilLayers, "active_index") # bpy.msgbus.subscribe_rna( # key=subscribe_to, # # owner of msgbus subcribe (for clearing later) # # owner=handle, # owner=bpy.types.GreasePencil, # <-- can attach to an ID during all it's lifetime... # # Args passed to callback function (tuple) # args=(), # # Callback function for property update # notify=obj_layer_name_callback, # options={'PERSISTENT'}, # ) def subscribe_layer_change(): subscribe_to = (bpy.types.GreasePencilv3Layers, "active") bpy.msgbus.subscribe_rna( key=subscribe_to, # owner of msgbus subcribe (for clearing later) # owner=handle, owner=bpy.types.GreasePencilv3, # <-- can attach to an ID during all it's lifetime... # Args passed to callback function (tuple) args=(), # Callback function for property update notify=obj_layer_name_callback, options={'PERSISTENT'}, ) @persistent def subscribe_layer_change_handler(dummy): subscribe_layer_change() ##--- Add layers class GPTB_PT_layer_name_ui(bpy.types.Panel): bl_space_type = 'TOPBAR' # dummy bl_region_type = 'HEADER' bl_options = {'INSTANCED'} bl_label = 'Layer Rename' bl_ui_units_x = 14 def invoke(self, context, event): # all_addons_l = get_modifier_list() wm = context.window_manager wm.invoke_props_dialog(self) # , width=600 return {'FINISHED'} def draw(self, context): layout = self.layout # def row_with_icon(layout, icon): # # Edit first editable button in popup # row = layout.row() # row.activate_init = True # row.label(icon=icon) # return row # row = row_with_icon(layout, 'OUTLINER_DATA_GP_LAYER') row = layout.row() row.activate_init = True row.label(icon='OUTLINER_DATA_GP_LAYER') row.prop(context.object.data.layers.active, 'name', text='') def add_layer(context): bpy.ops.gpencil.layer_add() context.object.data.layers.active.use_lights = False class GPTB_OT_add_gp_layer_with_rename(Operator): bl_idname = "gp.add_layer_rename" bl_label = "Add Rename GPencil Layer" bl_description = "Create a new gp layer with use light toggled off and popup a rename box" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context): return context.object and context.object.type == 'GREASEPENCIL' def execute(self, context): add_layer(context) bpy.ops.wm.call_panel(name="GPTB_PT_layer_name_ui", keep_open = False) return {"FINISHED"} class GPTB_OT_add_gp_layer(Operator): bl_idname = "gp.add_layer" bl_label = "Add GPencil Layer" bl_description = "Create a new gp layer with use light toggled off" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context): return context.object and context.object.type == 'GREASEPENCIL' def execute(self, context): add_layer(context) return {"FINISHED"} addon_keymaps = [] def register_keymaps(): if bpy.app.background: return addon = bpy.context.window_manager.keyconfigs.addon ##---# Insert Layers ## Insert new gp layer (with no use_light) km = addon.keymaps.new(name = "Grease Pencil", space_type = "EMPTY") # global (only paint ?) kmi = km.keymap_items.new('gp.add_layer', type='INSERT', value='PRESS') addon_keymaps.append((km, kmi)) ## Insert new gp layer (with no use_light and immediately pop up a box to rename) # km = addon.keymaps.new(name = "Grease Pencil", space_type = "EMPTY") # global (only paint ?) kmi = km.keymap_items.new('gp.add_layer_rename', type='INSERT', value='PRESS', shift=True) addon_keymaps.append((km, kmi)) ##---# F2 rename calls ## Direct rename active layer in Paint mode km = addon.keymaps.new(name = "Grease Pencil Paint Mode", space_type = "EMPTY") kmi = km.keymap_items.new('wm.call_panel', type='F2', value='PRESS') kmi.properties.name = 'GPTB_PT_layer_name_ui' kmi.properties.keep_open = False addon_keymaps.append((km, kmi)) ## Same in edit mode km = addon.keymaps.new(name = "Grease Pencil Stroke Edit Mode", space_type = "EMPTY") kmi = km.keymap_items.new('wm.call_panel', type='F2', value='PRESS') kmi.properties.name = 'GPTB_PT_layer_name_ui' kmi.properties.keep_open = False addon_keymaps.append((km, kmi)) def unregister_keymaps(): if bpy.app.background: return for km, kmi in addon_keymaps: km.keymap_items.remove(kmi) addon_keymaps.clear() classes=( GPTB_OT_rename_gp_layer, GPTB_OT_layer_name_build, GPTB_OT_select_set_same_prefix, GPTB_OT_select_set_same_color, ## Layer add and pop-up rename GPTB_PT_layer_name_ui, # pop-up GPTB_OT_add_gp_layer_with_rename, # shift+Ins GPTB_OT_add_gp_layer, # Ins ) def register(): for cls in classes: bpy.utils.register_class(cls) bpy.types.DATA_PT_grease_pencil_layers.prepend(layer_name_builder_ui) bpy.types.DOPESHEET_HT_header.append(gpencil_dopesheet_header) bpy.types.GREASE_PENCIL_MT_grease_pencil_add_layer_extra.append(gpencil_layer_dropdown_menu) bpy.app.handlers.load_post.append(subscribe_layer_change_handler) register_keymaps() # Directly set msgbus to work at first addon activation bpy.app.timers.register(subscribe_layer_change, first_interval=1) def unregister(): unregister_keymaps() bpy.app.handlers.load_post.remove(subscribe_layer_change_handler) bpy.types.GREASE_PENCIL_MT_grease_pencil_add_layer_extra.remove(gpencil_layer_dropdown_menu) bpy.types.DOPESHEET_HT_header.remove(gpencil_dopesheet_header) bpy.types.DATA_PT_grease_pencil_layers.remove(layer_name_builder_ui) for cls in reversed(classes): bpy.utils.unregister_class(cls) # Delete layer index trigger # /!\ can remove msgbus made for other functions or other addons using same owner bpy.msgbus.clear_by_owner(bpy.types.GreasePencilv3)