diff --git a/CHANGELOG.md b/CHANGELOG.md index 001640a..789b057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +1.6.0 + +- feat: Namespace upgrade + - support pseudo group naming + - add group and indent button +- feat: Fill tool shortcut for material color picker (from closest stroke) + - `S` : get material closest *fill* stroke + - `Àlt+S` : get closest stroke (fill or stroke) + 1.5.8 - feat: Namespace improvement: diff --git a/OP_layer_manager.py b/OP_layer_manager.py index 05c32af..45572c1 100644 --- a/OP_layer_manager.py +++ b/OP_layer_manager.py @@ -9,24 +9,76 @@ from .utils import get_addon_prefs # --- OPS --- -# pattern = r'([A-Z]{2})?_?([A-Z]{2})?_?(.*)' # bad ! match whithout separator +# 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'^([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 + +def layer_name_build(layer, prefix='', desc='', suffix=''): + '''GET a layer and argumen to build and assign name''' + + + prefs = get_addon_prefs() + sep = prefs.separator + name = layer.info + + 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.info = 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) ''' - global pattern prefs = get_addon_prefs() sep = prefs.separator name = layer.info - + pattern = pattern.replace('_', sep) # set separator res = re.search(pattern, name.strip()) @@ -55,6 +107,7 @@ def layer_name_build(layer, prefix='', prefix2='', desc='', suffix=''): new = f'{p1}{p2}{p3}{p4}' layer.info = new +""" ## multi-prefix solution (Caps letters) class GPTB_OT_layer_name_build(Operator): @@ -68,7 +121,7 @@ class GPTB_OT_layer_name_build(Operator): return True prefix : StringProperty(default='', options={'SKIP_SAVE'}) - prefix2 : StringProperty(default='', options={'SKIP_SAVE'}) + # prefix2 : StringProperty(default='', options={'SKIP_SAVE'}) desc : StringProperty(default='', options={'SKIP_SAVE'}) suffix : StringProperty(default='', options={'SKIP_SAVE'}) @@ -82,7 +135,69 @@ class GPTB_OT_layer_name_build(Operator): for l in gpl: if l.select or l == act: - layer_name_build(l, prefix=self.prefix, prefix2=self.prefix2, desc=self.desc, suffix=self.suffix) + layer_name_build(l, prefix=self.prefix, desc=self.desc, suffix=self.suffix) + return {"FINISHED"} + + +def grp_toggle(l): + grp_item_id = ' - ' + res = re.search(r'^(\s{1,3}-\s{0,3})(.*)', l.info) + if not res: + # add group prefix after stripping all space and dash + l.info = grp_item_id + l.info.lstrip(' -') + else: + # delete group prefix + l.info = res.group(2) + +class GPTB_OT_layer_group_toggle(Operator): + bl_idname = "gp.layer_group_toggle" + bl_label = "Group Toggle" + bl_description = "Group or ungroup a layer" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return True + + # group : StringProperty(default='', options={'SKIP_SAVE'}) + + def execute(self, context): + ob = context.object + gpl = ob.data.layers + act = gpl.active + if not act: + self.report({'ERROR'}, 'no layer active') + return {"CANCELLED"} + for l in gpl: + if l.select or l == act: + grp_toggle(l) + return {"FINISHED"} + +class GPTB_OT_layer_new_group(Operator): + bl_idname = "gp.layer_new_group" + bl_label = "New Group" + bl_description = "Create a group from active layer" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + ob = context.object + gpl = ob.data.layers + act = gpl.active + if not act: + self.report({'ERROR'}, 'no layer active') + return {"CANCELLED"} + res = re.search(PATTERN, act.info) + if not res: + self.report({'ERROR'}, 'Could not create a group name, create a layer manually') + return {"CANCELLED"} + name = res.group('name') + if not name: + self.report({'ERROR'}, f'No name found in {act.info}') + return {"CANCELLED"} + if name in [l.info for l in gpl]: + self.report({'WARNING'}, f'Name already exists: {act.info}') + return {"FINISHED"} + gpl.new(name, set_active=False) return {"FINISHED"} @@ -120,13 +235,12 @@ class GPTB_OT_select_set_same_prefix(Operator): return self.execute(context) def execute(self, context): - global pattern prefs = get_addon_prefs() sep = prefs.separator # '_' gpl = context.object.data.layers act = gpl.active - res = re.search(pattern, act.info) + res = re.search(PATTERN, act.info) if not res: # self.report({'ERROR'}, f'Error scanning {act.info}') return {"CANCELLED"} @@ -288,7 +402,7 @@ class GPTB_OT_rename_gp_layer(Operator): ## --- UI layer panel--- -def layer_name_builder(self, context): +def layer_name_builder_ui(self, context): '''appended to DATA_PT_gpencil_layers''' prefs = get_addon_prefs() @@ -319,6 +433,8 @@ def layer_name_builder(self, context): ## name (description) row = col.row(align=True) row.prop(context.scene.gptoolprops, 'layer_name', text='') + 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 @@ -328,6 +444,8 @@ def layer_name_builder(self, context): 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' + + ## --- UI dopesheet --- @@ -360,13 +478,16 @@ def obj_layer_name_callback(): return if not ob.data.layers.active: return - pattern = r'^([A-Z]{1,6}_)?([A-Z]{1,6}_)?(.*?)(_[A-Z]{2})?$' - res = re.search(pattern, ob.data.layers.active.info) + + for l in ob.data.layers: + l.select = l == ob.data.layers.active + + res = re.search(PATTERN, ob.data.layers.active.info) if not res: return - if not res.group(3): + if not res.group('name'): return - bpy.context.scene.gptoolprops['layer_name'] = res.group(3) + bpy.context.scene.gptoolprops['layer_name'] = res.group('name') @persistent def subscribe_handler(dummy): @@ -387,6 +508,8 @@ def subscribe_handler(dummy): classes=( GPTB_OT_rename_gp_layer, GPTB_OT_layer_name_build, + GPTB_OT_layer_group_toggle, + GPTB_OT_layer_new_group, GPTB_OT_select_set_same_prefix, GPTB_OT_select_set_same_color, ) @@ -395,7 +518,7 @@ def register(): for cls in classes: bpy.utils.register_class(cls) - bpy.types.DATA_PT_gpencil_layers.prepend(layer_name_builder) + bpy.types.DATA_PT_gpencil_layers.prepend(layer_name_builder_ui) bpy.types.DOPESHEET_HT_header.append(gpencil_dopesheet_header) bpy.types.GPENCIL_MT_layer_context_menu.append(gpencil_layer_dropdown_menu) bpy.app.handlers.load_post.append(subscribe_handler) # need to restart after first activation @@ -404,7 +527,7 @@ def unregister(): bpy.app.handlers.load_post.remove(subscribe_handler) bpy.types.GPENCIL_MT_layer_context_menu.remove(gpencil_layer_dropdown_menu) bpy.types.DOPESHEET_HT_header.remove(gpencil_dopesheet_header) - bpy.types.DATA_PT_gpencil_layers.remove(layer_name_builder) + bpy.types.DATA_PT_gpencil_layers.remove(layer_name_builder_ui) for cls in reversed(classes): bpy.utils.unregister_class(cls) diff --git a/OP_material_picker.py b/OP_material_picker.py new file mode 100644 index 0000000..c108fb3 --- /dev/null +++ b/OP_material_picker.py @@ -0,0 +1,202 @@ +import bpy +from bpy.types import Operator +import mathutils +from mathutils import Vector, Matrix, geometry +from bpy_extras import view3d_utils +from .utils import get_gp_draw_plane, location_to_region, region_to_location + +### passing by 2D projection +def get_3d_coord_on_drawing_plane_from_2d(context, co): + plane_co, plane_no = get_gp_draw_plane(context) + rv3d = context.region_data + view_mat = rv3d.view_matrix.inverted() + if not plane_no: + plane_no = Vector((0,0,1)) + plane_no.rotate(view_mat) + depth_3d = view_mat @ Vector((0, 0, -1000)) + org = region_to_location(co, view_mat.to_translation()) + view_point = region_to_location(co, depth_3d) + hit = geometry.intersect_line_plane(org, view_point, plane_co, plane_no) + + if hit and plane_no: + return context.object, hit, plane_no + + return None, None, None + +class GP_OT_pick_closest_material(Operator): + bl_idname = "gp.pick_closest_material" + bl_label = "Get Closest Stroke Material" + bl_description = "Pick closest stroke material" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.object and context.object.type == 'GPENCIL' and context.mode == 'PAINT_GPENCIL' + + fill_only : bpy.props.BoolProperty(default=False, options={'SKIP_SAVE'}) + + def filter_stroke(self, context): + # get stroke under mouse using kdtree + point_pair = [(p.co, s) for s in self.stroke_list for p in s.points] # local space + + kd = mathutils.kdtree.KDTree(len(point_pair)) + for i, pair in enumerate(point_pair): + kd.insert(pair[0], i) + kd.balance() + + ## Get 3D coordinate on drawing plane according to mouse 2d.co on flat 2d drawing + _ob, hit, _plane_no = get_3d_coord_on_drawing_plane_from_2d(context, self.init_mouse) + + if not hit: + return 'No hit on drawing plane', None + + mouse_3d = hit + mouse_local = self.inv_mat @ mouse_3d # local space + co, index, _dist = kd.find(mouse_local) # local space + # co, index, _dist = kd.find(mouse_3d) # world space + # context.scene.cursor.location = co # world space + s = point_pair[index][1] + + ## find point index in stroke + self.idx = None + for i, p in enumerate(s.points): + if p.co == co: + self.idx = i + break + + del point_pair + return s, self.ob.matrix_world @ co + + def invoke(self, context, event): + # self.prefs = get_addon_prefs() + self.ob = context.object + self.gp = self.ob.data + + self.stroke_list = [] + self.inv_mat = self.ob.matrix_world.inverted() + + if self.gp.use_multiedit: + for l in self.gp.layers: + if l.lock or l.hide: + continue + for f in l.frames: + if not f.select: + continue + for s in f.strokes: + self.stroke_list.append(s) + + else: + # [s for l in self.gp.layers if not l.lock and not l.hide for s in l.active_frame.stokes] + for l in self.gp.layers: + if l.lock or l.hide or not l.active_frame: + continue + for s in l.active_frame.strokes: + self.stroke_list.append(s) + + if self.fill_only: + self.stroke_list = [s for s in self.stroke_list if self.ob.data.materials[s.material_index].grease_pencil.show_fill] + + if not self.stroke_list: + self.report({'ERROR'}, 'No stroke found, maybe layers are locked or hidden') + return {'CANCELLED'} + + + self.init_mouse = Vector((event.mouse_region_x, event.mouse_region_y)) + self.stroke, self.coord = self.filter_stroke(context) + if isinstance(self.stroke, str): + self.report({'ERROR'}, self.stroke) + return {'CANCELLED'} + + + del self.stroke_list + + if self.idx is None: + self.report({'WARNING'}, 'No coord found') + return {'CANCELLED'} + + self.depth = self.ob.matrix_world @ self.stroke.points[self.idx].co + self.init_pos = [p.co.copy() for p in self.stroke.points] # need a copy otherwise vector is updated + ## directly use world position ? + # self.pos_world = [self.ob.matrix_world @ co for co in self.init_pos] + self.pos_2d = [location_to_region(self.ob.matrix_world @ co) for co in self.init_pos] + self.plen = len(self.stroke.points) + + # context.scene.cursor.location = self.coord #Dbg + return self.execute(context) + # context.window_manager.modal_handler_add(self) + # return {'RUNNING_MODAL'} + + def execute(self, context): + self.ob.active_material_index = self.stroke.material_index + self.report({'INFO'}, f'Mat: {self.ob.data.materials[self.stroke.material_index].name}') + return {'FINISHED'} + + # def modal(self, context, event): + # if event.type == 'MOUSEMOVE': + # mouse = Vector((event.mouse_region_x, event.mouse_region_y)) + # delta = mouse - self.init_mouse + + # if event.type == 'LEFTMOUSE' and event.value == 'RELEASE': + # print(f'{self.stroke}, points num {len(self.stroke.points)}, material index:{self.stroke.material_index}') + # return {'FINISHED'} + + # if event.type in {'RIGHTMOUSE', 'ESC'}: + # # for i, p in enumerate(self.stroke.points): # reset position + # # self.stroke.points[i].co = self.init_pos[i] + # context.area.tag_redraw() + # return {'CANCELLED'} + + # return {'RUNNING_MODAL'} + + +addon_keymaps = [] +def register_keymaps(): + addon = bpy.context.window_manager.keyconfigs.addon + # km = addon.keymaps.new(name = "Grease Pencil Stroke Paint (Draw brush)", space_type = "EMPTY", region_type='WINDOW') + # km = addon.keymaps.new(name = "Grease Pencil Stroke Paint Mode", space_type = "EMPTY", region_type='WINDOW') + km = addon.keymaps.new(name = "Grease Pencil Stroke Paint (Fill)", space_type = "EMPTY", region_type='WINDOW') + kmi = km.keymap_items.new( + # name="", + idname="gp.pick_closest_material", + type="S", # type="LEFTMOUSE", + value="PRESS", + shift=False, + ctrl=False, + alt = False, + oskey=False, + # key_modifier='S', # S like Sample + ) + kmi.properties.fill_only = True + addon_keymaps.append((km, kmi)) + + kmi = km.keymap_items.new( + # name="", + idname="gp.pick_closest_material", + type="S", # type="LEFTMOUSE", + value="PRESS", + alt = True, + # key_modifier='S', # S like Sample + ) + + # kmi = km.keymap_items.new('catname.opsname', type='F5', value='PRESS') + addon_keymaps.append((km, kmi)) + +def unregister_keymaps(): + for km, kmi in addon_keymaps: + km.keymap_items.remove(kmi) + addon_keymaps.clear() + + +classes=( +GP_OT_pick_closest_material, +) + +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) \ No newline at end of file diff --git a/__init__.py b/__init__.py index 34830ae..6241a9c 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, 5, 7), +"version": (1, 6, 0), "blender": (2, 91, 0), "location": "Sidebar (N menu) > Gpencil > Toolbox / Gpencil properties", "warning": "", @@ -49,6 +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_material_picker from . import OP_eraser_brush from . import TOOL_eraser_brush from . import handler_draw_cam @@ -564,6 +565,7 @@ def register(): OP_key_duplicate_send.register() OP_layer_manager.register() OP_eraser_brush.register() + OP_material_picker.register() TOOL_eraser_brush.register() handler_draw_cam.register() UI_tools.register() @@ -589,6 +591,7 @@ def unregister(): bpy.utils.unregister_class(cls) UI_tools.unregister() handler_draw_cam.unregister() + OP_material_picker.unregister() OP_eraser_brush.unregister() TOOL_eraser_brush.unregister() OP_layer_manager.unregister()