diff --git a/CHANGELOG.md b/CHANGELOG.md index 50effd1..1789138 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog + +1.8.0 + +- feat: palette linker (beta), with pop-up from material stack dropdown +- feat: palette name fuzzy match +- code: add an open addon preference ops + 1.7.8 - fix: reset rotation in draw cam mode keep view in the same place (counter camera rotation) diff --git a/OP_file_checker.py b/OP_file_checker.py index 14441a3..76573b3 100755 --- a/OP_file_checker.py +++ b/OP_file_checker.py @@ -352,7 +352,7 @@ class GPTB_OT_links_checker(bpy.types.Operator): split.label(text=l[0], icon=l[1]) # layout.label(text=l[0], icon=l[1]) else: - split=layout.split(factor=0.75, align=True) + split=layout.split(factor=0.70, align=True) split.label(text=l[0], icon=l[1]) ## resolve() return somethin different than os.path.abspath. # split.operator('wm.path_open', text='Open folder', icon='FILE_FOLDER').filepath = Path(bpy.path.abspath(l[0])).resolve().parent.as_posix() diff --git a/OP_helpers.py b/OP_helpers.py index d8bf281..f70c036 100644 --- a/OP_helpers.py +++ b/OP_helpers.py @@ -323,8 +323,6 @@ class GPTB_OT_reset_cam_rot(bpy.types.Operator): def execute(self, context): cam = context.scene.camera - - # self.cam_init_euler = self.cam.rotation_euler.copy() if not cam.parent or cam.parent.type != 'CAMERA': self.report({'ERROR'}, "No parents to refer to for rotation reset") return {"CANCELLED"} @@ -362,40 +360,8 @@ class GPTB_OT_reset_cam_rot(bpy.types.Operator): new_cam_offset = mathutils.Vector((new_cam_offset[0], new_cam_offset[1] * self.ratio_inv)) # restore screen ratio context.space_data.region_3d.view_camera_offset = new_cam_offset - - return {"FINISHED"} -""" # old simple cam draw fit to cam -class GPTB_OT_reset_cam_rot(bpy.types.Operator): - bl_idname = "gp.reset_cam_rot" - bl_label = "Reset rotation" - bl_description = "Reset rotation of the draw manipulation camera" - bl_options = {"REGISTER"} - - @classmethod - def poll(cls, context): - return context.scene.camera and not context.scene.camera.name.startswith('Cam') - # return context.region_data.view_perspective == 'CAMERA'# check if in camera - - def execute(self, context): - # dcam_name = 'draw_cam' - # camcol_name = 'manip_cams' - drawcam = context.scene.camera - if drawcam.parent.type == 'CAMERA': - ## align to parent camera - drawcam.matrix_world = drawcam.parent.matrix_world#wrong, get the parent rotation offset - # drawcam.rotation_euler = drawcam.parent.rotation_euler#wrong, get the parent rotation offset - elif drawcam.parent: - ## there is a parent, so align the Y of the camera to object's Z - # drawcam.rotation_euler.rotate(drawcam.parent.matrix_world)# wrong - pass - else: - self.report({'ERROR'}, "No parents to refer to for rotation reset") - return {"CANCELLED"} - return {"FINISHED"} -""" - class GPTB_OT_toggle_mute_animation(bpy.types.Operator): bl_idname = "gp.toggle_mute_animation" bl_label = "Toggle Animation Mute" @@ -630,6 +596,16 @@ class GPTB_OT_check_canvas_alignement(bpy.types.Operator): # self.report({ret}, message) return {'FINISHED'} +class GPTB_OT_open_addon_prefs(bpy.types.Operator): + bl_idname = "gptb.open_addon_prefs" + bl_label = "Open Addon Prefs" + bl_description = "Open user preferences window in addon tab and prefill the search with addon name" + bl_options = {"REGISTER", "INTERNAL"} + + def execute(self, context): + utils.open_addon_prefs() + return {'FINISHED'} + classes = ( GPTB_OT_copy_text, GPTB_OT_flipx_view, @@ -642,6 +618,7 @@ GPTB_OT_toggle_hide_gp_modifier, GPTB_OT_list_disabled_anims, GPTB_OT_clear_active_frame, GPTB_OT_check_canvas_alignement, +GPTB_OT_open_addon_prefs, ) def register(): diff --git a/OP_key_duplicate_send.py b/OP_key_duplicate_send.py index 6d05dab..d2254c6 100644 --- a/OP_key_duplicate_send.py +++ b/OP_key_duplicate_send.py @@ -114,6 +114,9 @@ def register_keymaps(): km = addon.keymaps.new(name = "Dopesheet", space_type = "DOPESHEET_EDITOR") kmi = km.keymap_items.new('gp.duplicate_send_to_layer', type='D', value="PRESS", ctrl=True, shift=True) addon_keymaps.append((km,kmi)) + + + # km = addon.keymaps.new(name = "Dopesheet", space_type = "DOPESHEET_EDITOR") # try duplicating km (seem to be error at unregsiter) kmi = km.keymap_items.new('gp.duplicate_send_to_layer', type='X', value="PRESS", ctrl=True, shift=True) kmi.properties.delete_source = True addon_keymaps.append((km,kmi)) @@ -130,13 +133,17 @@ GPTB_OT_duplicate_send_to_layer, ) def register(): - if not bpy.app.background: - for cls in classes: - bpy.utils.register_class(cls) - register_keymaps() + if bpy.app.background: + return + + for cls in classes: + bpy.utils.register_class(cls) + register_keymaps() def unregister(): - if not bpy.app.background: - unregister_keymaps() - for cls in reversed(classes): - bpy.utils.unregister_class(cls) \ No newline at end of file + if bpy.app.background: + return + + unregister_keymaps() + for cls in reversed(classes): + bpy.utils.unregister_class(cls) \ No newline at end of file diff --git a/OP_palettes.py b/OP_palettes.py index de189f4..774980d 100644 --- a/OP_palettes.py +++ b/OP_palettes.py @@ -57,7 +57,7 @@ class GPTB_OT_load_default_palette(bpy.types.Operator): line.name = 'line' # load json - pfp = Path(bpy.path.abspath(get_addon_prefs().palettes_path)) + pfp = Path(bpy.path.abspath(get_addon_prefs().palette_path)) if not pfp.exists(): self.report({'ERROR'}, f'Palette path not found') return {"CANCELLED"} @@ -164,9 +164,7 @@ class GPTB_OT_save_palette(bpy.types.Operator, ExportHelper): def load_blend_palette(context, filepath): '''Load materials on current active object from current chosen blend''' - #from pathlib import Path - #palette_fp = C.preferences.addons['GP_toolbox'].preferences['palettes_path'] - #fp = Path(palette_fp) / 'christina.blend' + print(f'-- import palette from : {filepath} --') for ob in context.selected_objects: if ob.type != 'GPENCIL': diff --git a/OP_palettes_linker.py b/OP_palettes_linker.py index bb3fc9e..e343b53 100644 --- a/OP_palettes_linker.py +++ b/OP_palettes_linker.py @@ -23,6 +23,182 @@ from bpy.props import ( PointerProperty, ) +#--- OPERATORS +def print_materials_sources(ob): + for m in ob.data.materials: + if m.library: + print(f'{m.name} - {Path(m.library.filepath).name}') + else: + print(m.name) + +def replace_mat_slots(src_mat, obj): + for ms in obj.material_slots: + if ms.material.name == src_mat.name: + # Only on different linked, else mat.name differ (.001)) + ms.material = src_mat + +class GPTB_OT_import_obj_palette(Operator): + bl_idname = "gp.import_obj_palette" + bl_label = "Import Object Palette" + bl_description = "Import object palette from blend" + bl_options = {"REGISTER", "INTERNAL"} + + def execute(self, context): + ## get targets + selection = [o for o in context.selected_objects if o.type == 'GPENCIL'] + if not selection: + self.report({'ERROR'}, 'Need to have at least one GP object selected in scene') + return {"CANCELLED"} + + # Avoid looping on linked duplicate + objs = [] + datas = [] + for o in selection: + if o.data in datas: + continue + objs.append(o) + datas.append(o.data) + del datas # datas.clear() + + pl_prop = context.scene.bl_palettes_props + blend_path = pl_prop.blends[pl_prop.bl_idx].blend_path + target_objs = [pl_prop.objects[pl_prop.ob_idx].name] + # Future improvement + # target_objs = [o.name for o in pl_prop.objects if o.select] + if not target_objs: + self.report({'ERROR'}, 'Need at least one palette source selected') + return {"CANCELLED"} + + mode = pl_prop.import_type + + if mode == 'LINK' and not bpy.data.is_saved: # autorise for absolute path + self.report({'ERROR'}, 'Blend file must be saved to use link mode') + return {"CANCELLED"} + + if mode != 'LINK': + self.report({'ERROR'}, 'Not supported yet, use link') + return {'CANCELLED'} + + # get relative path + blend_path = bpy.path.relpath(blend_path) + + # TODO append object to list all material that belongs to it... + + linked_objs = utils.link_objects_in_blend(blend_path, target_objs, link=True) + + if not linked_objs: + self.report({'ERROR'}, f'Could not link/append obj from {blend_path}') + return {"CANCELLED"} + + for i in range(len(linked_objs))[::-1]: # reversed(range(len(l))) / range(len(l))[::-1] + if linked_objs[i].type != 'GPENCIL': + print(f'{linked_objs[i].name} type is "{linked_objs[i].type}"') + bpy.data.objects.remove(linked_objs.pop(i)) + + if not linked_objs: + self.report({'ERROR'}, f'Linked object was not a Grease Pencil') + return {"CANCELLED"} + + + print('blend_path: ', blend_path) + # if materials have been renamed, there must be already be appended / linked + + # to_clear = [] + ct = 0 + for src_ob in linked_objs: + ct += len(src_ob.data.materials) + + if mode == 'LINK': # link new mats and update already linked ones + ## link mats + for ob in objs: + for src_ob in linked_objs: + for src_mat in src_ob.data.materials: + print(f'- {src_mat.name}') + mat = ob.data.materials.get(src_mat.name) + + if mat and mat.library == src_mat.library: + # print('already exists') + continue # same material, skip + + elif mat: + # print('already but not same lib') + ## same material but not from same lib + ## remap_user will replace this mat in all objects blend... + mat.user_remap(src_mat) + ## (we might want to keep links in other objects untouched ?) + ## else use a basic material slot swap (loop, can be added on multiple slots) + # replace_mat_slots(ob, src_mat) + + else: + # print('Not in dest') + ## material not in dest, append + ob.data.materials.append(src_mat) + + elif mode == 'APPEND': + ## append, overwrite all already existing materials with new ones + pass + + # ct = 0 + # for o in selection: + # for mat in ob.data.materials: + # if mat in o.data.materials[:]: + # continue + # o.data.materials.append(mat) + # ct += 1 + + elif mode == 'APPEND_REUSE': + ## append, Skip existing material + pass + + if ct: + self.report({'INFO'}, f'{ct} Materials appended') + # else: + # self.report({'WARNING'}, 'All materials are already in other selected object') + + # unlink objects and their gp data + for src_ob in linked_objs: + bpy.data.grease_pencils.remove(src_ob.data) + + + return {"FINISHED"} + + +class GPTB_OT_palette_fuzzy_search_obj(Operator): + bl_idname = "gptb.palette_fuzzy_search_obj" + bl_label = "Palette Fuzzy Match" + bl_description = "Try to find a palette with name closest to active object" + bl_options = {"REGISTER"} + + def execute(self, context): + if not context.object: + self.report({'ERROR'}, 'No active object to search name from') + return {"CANCELLED"} + + bl_props = context.scene.bl_palettes_props + + final_ratio = 0 + new_idx = None + for i, o in enumerate(bl_props.objects): + ratio = utils.fuzzy_match_ratio(context.object.name, o.name, case_sensitive=False) + if ratio > final_ratio: + new_idx = i + final_ratio = ratio + + limit = 0.3 + if final_ratio < limit: + self.report({'ERROR'}, f'Could not find a name matching at least {limit*100:.0f}% "{context.object.name}"') + return {"CANCELLED"} + + if new_idx is None: + self.report({'ERROR'}, f'Could not find match') + return {"CANCELLED"} + + bl_props.ob_idx = new_idx + self.report({'INFO'}, f'Select {bl_props.objects[bl_props.ob_idx].name} (match at {final_ratio*100:.1f}% with "{context.object.name}")') + return {"FINISHED"} + +#--- UI LIST + class GPTB_UL_blend_list(UIList): # order_by_distance : BoolProperty(default=True) @@ -68,6 +244,7 @@ class GPTB_UL_object_list(UIList): # order_by_distance : BoolProperty(default=True) def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + self.use_filter_show = True # force open the search feature layout.label(text=item.name) def draw_filter(self, context, layout): @@ -75,6 +252,7 @@ class GPTB_UL_object_list(UIList): subrow = row.row(align=True) subrow.prop(self, "filter_name", text="") # Only show items matching this name (use ‘*’ as wildcard) # reverse order + subrow.operator('gptb.palette_fuzzy_search_obj', text='', icon='ZOOM_SELECTED') # built-in reverse icon = 'SORT_DESC' if self.use_filter_sort_reverse else 'SORT_ASC' subrow.prop(self, "use_filter_sort_reverse", text="", icon=icon) # built-in reverse @@ -109,20 +287,37 @@ def reload_blends(self, context): item.blend_name = 'No Palette Path Specified' reload_objects(self, context) return - - palettes_dir = Path(palette_fp) + + + palettes_dir = Path(os.path.abspath(bpy.path.abspath(palette_fp))) if not palettes_dir.exists(): item = uilist.add() item.blend_name = 'Palette Path not found' reload_objects(self, context) return - blends = [(o.path, Path(o).stem, "") for o in os.scandir(palettes_dir) - if o.is_file() - and o.name.endswith('.blend') - and re.search(r'v\d{3}', o.name, re.I)] + # list blends + pattern = r'[vV](\d{2,3})' # rightest = r'[vV](\d+)(?!.*[vV]\d)' + blends = [] # recursive + for root, _dirs, files in os.walk(palettes_dir): + for f in files: + fp = Path(root) / f + if not f.endswith('.blend'): + continue + if not re.search(pattern, f): + continue + if not fp.is_file(): + continue + blends.append((str(fp), fp.stem, "")) - blends.sort(key=lambda x: x[1], reverse=False) + ## only in palette folder. + # blends = [(o.path, Path(o).stem, "") for o in os.scandir(palettes_dir) + # if o.is_file() + # and o.name.endswith('.blend') + # and re.search(pattern, o.name)] + + # blends.sort(key=lambda x: x[1], reverse=False) # sort alphabetically + blends.sort(key=lambda x: int(re.search(pattern, x[1]).group(1)), reverse=False) # sort by version # print('blends found', len(blends)) for bl in blends: # populate list @@ -161,6 +356,8 @@ def reload_objects(self, context): obj_uil.clear() pal_prop['ob_idx'] = 0 + file_libs = [l.filepath for l in bpy.data.libraries if l.filepath] + if not len(blend_uil) or (len(blend_uil) == 1 and not bool(blend_uil[0].blend_path)): item = obj_uil.add() item.name = 'No blend to list object' @@ -173,31 +370,41 @@ def reload_objects(self, context): path_to_blend = Path(blend_uil[pal_prop.bl_idx].blend_path) - ob_list = utils.check_objects_in_blend(str(path_to_blend)) # get list of string of all object except camera + ## get list of string of all object except camera + ob_list = utils.check_objects_in_blend(str(path_to_blend), avoid_camera=True) - ob_list.sort(reverse=False) # filter object by name ? - # remove camera - # print('blends found', len(blends)) + ob_list.sort(reverse=False) # filter object by name for ob_name in ob_list: # populate list item = obj_uil.add() item.name = ob_name + # print('path_to_blend: ', path_to_blend) item.path = str(path_to_blend / 'Object' / ob_name) pal_prop.ob_idx = len(obj_uil) - 1 + + ## those temp libraries are not saved (auto-cleared) + ## But best to keep library list tidy while file is opened + for lib in reversed(bpy.data.libraries): + if lib.filepath and not lib.users_id: + if lib.filepath not in file_libs: + bpy.data.libraries.remove(lib) + # return len(ob_list) # must return None if used in update + del ob_list -## PROPS +#--- PROPERTIES class GPTB_PG_blend_prop(PropertyGroup): blend_name : StringProperty() # stem of the path - blend_path : StringProperty() # ful path - # select: BoolProperty(update=update_select) # use and update to set the plane selection + blend_path : StringProperty() # full path class GPTB_PG_object_prop(PropertyGroup): name : StringProperty() # stem of the path path : StringProperty() # Object / Material ? + ## select feature to get multiple at once + # select : BoolProperty(default=False) # Object / Material ? class GPTB_PG_palette_settings(PropertyGroup): bl_idx : IntProperty(update=reload_objects) # update_on_index_change to reload object @@ -226,88 +433,9 @@ class GPTB_PG_palette_settings(PropertyGroup): ('APPEND_REUSE', 'Append (Reuse)', 'Append materials to selected objects\nkeep those already there', 2), ) ) - - # fav_blend: StringProperty() ## mark a blend as prefered ? (need to be stored in prefereneces to restore in other blend...) -class GPTB_OT_import_obj_palette(Operator): - bl_idname = "gp.import_obj_palette" - bl_label = "Import Object Palette" - bl_description = "Import object palette from blend" - bl_options = {"REGISTER", "INTERNAL"} - - def execute(self, context): - pl_prop = context.scene.bl_palettes_props - path = pl_prop.objects[pl_prop.ob_idx].path - self.report({'INFO'}, f'Path to object: {path}') - return {"FINISHED"} - -## PANEL - -class GPTB_PT_palettes_linker_ui(Panel): - bl_idname = 'GPTB_PT_palettes_linker_ui' - bl_space_type = "VIEW_3D" - bl_region_type = "UI" - bl_category = "Gpencil"#Dev - bl_label = "Palettes Mat Linker" - - def draw(self, context): - layout = self.layout - scn = bpy.context.scene - pl_prop = scn.bl_palettes_props - col= layout.column() - prefs = utils.get_addon_prefs() - ## Here put the path thing (only to use a non-library) - - # maybe in submenu... - row = col.row() - expand_icon = 'TRIA_DOWN' if pl_prop.show_path else 'TRIA_RIGHT' - row.prop(pl_prop, 'show_path', text='', icon=expand_icon, emboss=False) - row.prop(pl_prop, 'use_project_path', text='Use Project Palettes') - row.operator("gp.palettes_reload_blends", icon="FILE_REFRESH", text="") - - if pl_prop.use_project_path: - ## gp toolbox addon prefs path - if not prefs.palette_path: - col.label(text='Gp Toolbox Palette Directory Needed') - col.label(text='(saved with preferences)') - if pl_prop.show_path or not prefs.palette_path: - col.prop(prefs, 'palette_path', text='Project Dir') - else: - ## local path - if not pl_prop.custom_dir: - col.label(text='Need to specify directory') - if pl_prop.show_path or not pl_prop.custom_dir: - col.prop(pl_prop, 'custom_dir', text='Custom Dir') - - - row = col.row() - row.template_list("GPTB_UL_blend_list", "", pl_prop, "blends", pl_prop, "bl_idx", - rows=2) - # side panel - # subcol = row.column(align=True) - # subcol.operator("gp.palettes_reload_blends", icon="FILE_REFRESH", text="") - - ## Show object UI list only once blend Uilist is filled ? - if not len(pl_prop.blends) or (len(pl_prop.blends) == 1 and not bool(pl_prop.blends[0].blend_path)): - col.label(text='Select a blend to list available object') - - row = col.row() - row.template_list("GPTB_UL_object_list", "", pl_prop, "objects", pl_prop, "ob_idx", - rows=4) - - ## Show link button in the border of the UI list ? - # col.prop(pl_prop, 'import_type') - split = col.split(align=True, factor=0.4 ) - split.prop(pl_prop, 'import_type', text='') - - split.enabled = len(pl_prop.objects) and bool(pl_prop.objects[pl_prop.ob_idx].path) - split.operator('gp.import_obj_palette', text='Palette') - - # button to launch link with combined props (active only if the two items are valids) - # str(Path(self.blends) / 'Object' / self.objects - classes = ( # blend list @@ -316,6 +444,7 @@ GPTB_UL_blend_list, GPTB_OT_palettes_reload_blends, # object in blend list +GPTB_OT_palette_fuzzy_search_obj, GPTB_PG_object_prop, GPTB_UL_object_list, @@ -323,7 +452,7 @@ GPTB_UL_object_list, GPTB_PG_palette_settings, GPTB_OT_import_obj_palette, -GPTB_PT_palettes_linker_ui, +# TEST_OT_import_obj_palette_test, ) def register(): @@ -335,4 +464,4 @@ def unregister(): for cls in reversed(classes): bpy.utils.unregister_class(cls) - del bpy.types.Scene.bl_palettes_props \ No newline at end of file + del bpy.types.Scene.bl_palettes_props diff --git a/UI_tools.py b/UI_tools.py index 3bbd44d..2552868 100644 --- a/UI_tools.py +++ b/UI_tools.py @@ -2,13 +2,13 @@ from .utils import get_addon_prefs import bpy from pathlib import Path - +from bpy.types import Panel ## UI in properties ### dataprop_panel not used --> transferred to sidebar """ -class GPTB_PT_dataprop_panel(bpy.types.Panel): +class GPTB_PT_dataprop_panel(Panel): bl_space_type = 'PROPERTIES' bl_region_type = 'WINDOW' # bl_space_type = 'VIEW_3D' @@ -35,7 +35,7 @@ class GPTB_PT_dataprop_panel(bpy.types.Panel): ## UI in Gpencil sidebar menu -class GPTB_PT_sidebar_panel(bpy.types.Panel): +class GPTB_PT_sidebar_panel(Panel): bl_label = "GP Toolbox" bl_space_type = "VIEW_3D" bl_region_type = "UI" @@ -165,7 +165,7 @@ class GPTB_PT_sidebar_panel(bpy.types.Panel): # row.operator("my_operator.multi_op", text='', icon='TRIA_LEFT').left = 1 # row.operator("my_operator.multi_op", text='', icon='TRIA_RIGHT').left = 0 -class GPTB_PT_anim_manager(bpy.types.Panel): +class GPTB_PT_anim_manager(Panel): bl_label = "Animation Manager" bl_space_type = "VIEW_3D" bl_region_type = "UI" @@ -221,7 +221,7 @@ class GPTB_PT_anim_manager(bpy.types.Panel): text, icon = ('Cursor Follow On', 'PIVOT_CURSOR') if context.scene.gptoolprops.cursor_follow else ('Cursor Follow Off', 'CURSOR') col.prop(context.scene.gptoolprops, 'cursor_follow', text=text, icon=icon) -class GPTB_PT_toolbox_playblast(bpy.types.Panel): +class GPTB_PT_toolbox_playblast(Panel): bl_label = "Playblast" bl_space_type = "VIEW_3D" bl_region_type = "UI" @@ -243,7 +243,7 @@ class GPTB_PT_toolbox_playblast(bpy.types.Panel): # row.operator('render.playblast_anim', text = 'Playblast', icon = 'RENDER_ANIMATION').use_view = False # old (but robust) blocking playblast row.operator('render.playblast_anim', text = 'Viewport').use_view = True -class GPTB_PT_tint_layers(bpy.types.Panel): +class GPTB_PT_tint_layers(Panel): bl_label = "Tint Layers" bl_space_type = "VIEW_3D" bl_region_type = "UI" @@ -268,7 +268,7 @@ class GPTB_PT_tint_layers(bpy.types.Panel): col.operator("gp.auto_tint_gp_layers", text = "Reset tint", icon = "COLOR").reset = True -class GPTB_PT_checker(bpy.types.Panel): +class GPTB_PT_checker(Panel): bl_label = "Checker" bl_space_type = "VIEW_3D" bl_region_type = "UI" @@ -298,7 +298,7 @@ class GPTB_PT_checker(bpy.types.Panel): row.operator('gp.links_checker', text = 'Check links', icon = 'UNLINKED') -class GPTB_PT_color(bpy.types.Panel): +class GPTB_PT_color(Panel): bl_label = "Color" bl_space_type = "VIEW_3D" bl_region_type = "UI" @@ -310,11 +310,14 @@ class GPTB_PT_color(bpy.types.Panel): layout = self.layout col = layout.column() ## Create empty frame on layer - col.operator('gp.palette_linker', text=f'Link Materials Palette TO Object', icon='COLOR') ## ops + + # Material panel as a pop-up (would work if palette dir is separated) + # col.operator("wm.call_panel", text="Link Materials Palette", icon='COLOR').name = "GPTB_PT_palettes_linker_ui" col.operator('gp.create_empty_frames', icon='DECORATE_KEYFRAME') + # col.operator("wm.call_panel", text="Link Material Palette", icon='COLOR').name = "GPTB_PT_palettes_list_popup" """ # unused : added in Animation Manager -class GPTB_PT_extra(bpy.types.Panel): +class GPTB_PT_extra(Panel): bl_label = "Extra" bl_space_type = "VIEW_3D" bl_region_type = "UI" @@ -331,7 +334,7 @@ class GPTB_PT_extra(bpy.types.Panel): """ ## unused -- (integrated in sidebar_panel) -class GPTB_PT_cam_ref_panel(bpy.types.Panel): +class GPTB_PT_cam_ref_panel(Panel): bl_label = "Background imgs" bl_space_type = "VIEW_3D" bl_region_type = "UI" @@ -366,10 +369,13 @@ def palette_manager_menu(self, context): prefs = get_addon_prefs() layout.operator("gp.copy_active_to_selected_palette", text='Append Materials To Selected', icon='MATERIAL') + layout.operator("gp.clean_material_stack", text='Clean material Stack', icon='NODE_MATERIAL') + layout.separator() + layout.operator("wm.call_panel", text="Pop Palette Linker", icon='COLOR').name = "GPTB_PT_palettes_list_popup" + layout.operator("gp.load_blend_palette", text='Load Mats From Single Blend', icon='RESTRICT_COLOR_ON').filepath = prefs.palette_path + layout.separator() layout.operator("gp.load_palette", text='Load json Palette', icon='IMPORT').filepath = prefs.palette_path layout.operator("gp.save_palette", text='Save json Palette', icon='EXPORT').filepath = prefs.palette_path - layout.operator("gp.load_blend_palette", text='Load color Palette', icon='COLOR').filepath = prefs.palette_path - layout.operator("gp.clean_material_stack", text='Clean material Stack', icon='NODE_MATERIAL') def expose_use_channel_color_pref(self, context): @@ -380,6 +386,150 @@ def expose_use_channel_color_pref(self, context): layout.label(text='Use Channel Colors (User preferences):') layout.prop(context.preferences.edit, 'use_anim_channel_group_colors') + +#--- Palette Linker Panels + +def palettes_path_ui(self, context): + layout = self.layout + scn = bpy.context.scene + pl_prop = scn.bl_palettes_props + col= layout.column() + prefs = get_addon_prefs() + ## Here put the path thing (only to use a non-library) + + # maybe in submenu... + row = col.row() + # expand_icon = 'TRIA_DOWN' if pl_prop.show_path else 'TRIA_RIGHT' + # row.prop(pl_prop, 'show_path', text='', icon=expand_icon, emboss=False) + row.prop(pl_prop, 'use_project_path', text='Use Project Palettes') + # row.operator("gp.palettes_reload_blends", icon="FILE_REFRESH", text="") + + if pl_prop.use_project_path: + ## gp toolbox addon prefs path + if not prefs.palette_path: + col.label(text='GP toolbox Palette Directory Needed', icon='INFO') + col.operator('gptb.open_addon_prefs', icon='PREFERENCES') + + # if not prefs.palette_path: # or pl_prop.show_path + # col.prop(prefs, 'palette_path', text='Project Dir') + #col.label(text='(saved with preferences)') + else: + ## local path + if not pl_prop.custom_dir: + col.label(text='Need to specify directory') + col.prop(pl_prop, 'custom_dir', text='Custom Dir') + # if not pl_prop.custom_dir or pl_prop.show_path: + # col.prop(pl_prop, 'custom_dir', text='Custom Dir') + + +def palettes_lists_ui(self, context, popup=False): + layout = self.layout + scn = bpy.context.scene + pl_prop = scn.bl_palettes_props + col= layout.column() + row=col.row() + # refresh button + txt = 'Project Palettes' if pl_prop.use_project_path else 'Custom Palettes' + row.label(text=txt) + row.operator("gp.palettes_reload_blends", icon="FILE_REFRESH", text="") + + col= layout.column() + row = col.row() + + if popup: + blends_minimum_row = 5 + objects_minimum_row = 25 + else: + blends_minimum_row = 2 + objects_minimum_row = 4 + row.template_list("GPTB_UL_blend_list", "", pl_prop, "blends", pl_prop, "bl_idx", + rows=blends_minimum_row) + # side panel + # subcol = row.column(align=True) + # subcol.operator("gp.palettes_reload_blends", icon="FILE_REFRESH", text="") + + ## Show object UI list only once blend Uilist is filled ? + if not len(pl_prop.blends) or (len(pl_prop.blends) == 1 and not bool(pl_prop.blends[0].blend_path)): + col.label(text='Select blend refresh available objects') + + row = col.row() + row.template_list("GPTB_UL_object_list", "", pl_prop, "objects", pl_prop, "ob_idx", + rows=objects_minimum_row) + + ## Show link button in the border of the UI list ? + # col.prop(pl_prop, 'import_type') + split = col.split(align=True, factor=0.4) + split.prop(pl_prop, 'import_type', text='') + + split.enabled = len(pl_prop.objects) and bool(pl_prop.objects[pl_prop.ob_idx].path) + split.operator('gp.import_obj_palette', text='Palette') + + # button to launch link with combined props (active only if the two items are valids) + # str(Path(self.blends) / 'Object' / self.objects + + +class GPTB_PT_palettes_linker_main_ui(Panel): + bl_space_type = 'TOPBAR' # dummy + bl_region_type = 'HEADER' + # bl_space_type = "VIEW_3D" + # bl_region_type = "UI" + # bl_category = "Gpencil" + bl_label = "Palettes Mat Linker" + + def draw(self, context): + layout = self.layout + ## link button for tests + # layout.operator('gp.import_obj_palette', text='Palette') + + # Subpanel are appended to this main UI + + ## Or just as One fat panel + # palettes_path_ui(self, context) + # palettes_lists_ui(self, context) + +class GPTB_PT_palettes_path_ui(Panel): + bl_space_type = "VIEW_3D" + bl_region_type = "UI" + bl_category = "Gpencil" + bl_label = "Palettes Source" # Source Path + # bl_parent_id = "GPTB_PT_palettes_linker_main_ui" + bl_parent_id = "GPTB_PT_color" + bl_options = {'DEFAULT_CLOSED'} + + def draw(self, context): + layout = self.layout + palettes_path_ui(self, context) + # layout.label() + +# pop-up version of object lists +class GPTB_PT_palettes_list_popup(Panel): + bl_space_type = 'TOPBAR' # dummy + bl_region_type = 'HEADER' + bl_category = "Gpencil" + bl_label = "Palettes Lists" + bl_ui_units_x = 18 + + def draw(self, context): + palettes_lists_ui(self, context, popup=True) + +class GPTB_PT_palettes_list_ui(Panel): + bl_space_type = "VIEW_3D" + bl_region_type = "UI" + bl_category = "Gpencil" + bl_label = "Palettes Lists" + bl_parent_id = "GPTB_PT_color" + bl_options = {'DEFAULT_CLOSED'} + + def draw_header(self, context): + layout = self.layout + # layout.label(text="My Select Panel") + layout.operator("wm.call_panel", text="", icon='COLOR').name = "GPTB_PT_palettes_list_popup" + + def draw(self, context): + palettes_lists_ui(self, context, popup=False) + + +## bl 3+ UI def asset_browser_ui(self, context): '''Only shows in blender >= 3.0.0''' @@ -429,6 +579,12 @@ GPTB_PT_anim_manager, GPTB_PT_color, GPTB_PT_tint_layers, GPTB_PT_toolbox_playblast, + +# palettes linker +GPTB_PT_palettes_linker_main_ui, # main panel +GPTB_PT_palettes_list_popup, # popup (dummy region) +GPTB_PT_palettes_path_ui, # subpanels +GPTB_PT_palettes_list_ui, # subpanels # GPTB_PT_extra, ) @@ -467,34 +623,61 @@ def GPdata_toolbox_panel(self, context): col.prop(settings, 'autotint_offset') col.operator("gp.auto_tint_gp_layers", icon = "COLOR").reset = False col.operator("gp.auto_tint_gp_layers", text = "Reset tint", icon = "COLOR").reset = True -""" +""" + +""" +Put back UI for interpolate +# Grease Pencil stroke interpolation tools native pop hover panel from 2.92 +class VIEW3D_PT_tools_grease_pencil_interpolate(Panel): + bl_space_type = 'VIEW_3D' + bl_region_type = 'HEADER' + bl_label = "Interpolate" -### old + @classmethod + def poll(cls, context): + if context.gpencil_data is None: + return False -""" - col = layout.column(align = True) - col.operator("gpencil.stroke_change_color", text="Move to Color",icon = "COLOR") - col.operator("transform.shear", text="Shear") - col.operator("gpencil.stroke_cyclical_set", text="Toggle Cyclic").type = 'TOGGLE' - col.operator("gpencil.stroke_subdivide", text="Subdivide",icon = "OUTLINER_DATA_MESH") + gpd = context.gpencil_data + valid_mode = bool(gpd.use_stroke_edit_mode or gpd.is_stroke_paint_mode) + return bool(context.editable_gpencil_strokes) and valid_mode - row = layout.row(align = True) - row.operator("gpencil.stroke_join", text="Join").type = 'JOIN' - row.operator("grease_pencil.stroke_separate", text = "Separate") - col.operator("gpencil.stroke_flip", text="Flip Direction",icon = "ARROW_LEFTRIGHT") + def draw(self, context): + layout = self.layout + settings = context.tool_settings.gpencil_interpolate - col = layout.column(align = True) - col.operator("gptools.randomise",icon = 'RNDCURVE') - col.operator("gptools.thickness",icon = 'LINE_DATA') - col.operator("gptools.angle_split",icon = 'MOD_BEVEL',text='Angle Splitting') - col.operator("gptools.stroke_uniform_density",icon = 'MESH_DATA',text = 'Density') + col = layout.column(align=True) + col.label(text="Interpolate Strokes") + col.operator("gpencil.interpolate", text="Interpolate") + col.operator("gpencil.interpolate_sequence", text="Sequence") + col.operator("gpencil.interpolate_reverse", text="Remove Breakdowns") - row = layout.row(align = True) - row.prop(settings,"extra_tools",text='',icon = "DOWNARROW_HLT" if settings.extra_tools else "RIGHTARROW",emboss = False) - row.label("Extra tools") + col = layout.column(align=True) + col.label(text="Options:") + col.prop(settings, "interpolate_all_layers") - if settings.extra_tools : - layout.operator_menu_enum("gpencil.stroke_arrange", text="Arrange Strokes...", property="direction") - """ + gpd = context.gpencil_data + if gpd.use_stroke_edit_mode: + col.prop(settings, "interpolate_selected_only") + + col = layout.column(align=True) + col.label(text="Sequence Options:") + col.prop(settings, "step") + col.prop(settings, "type") + if settings.type == 'CUSTOM': + # TODO: Options for loading/saving curve presets? + col.template_curve_mapping(settings, "interpolation_curve", brush=True, + use_negative_slope=True) + elif settings.type != 'LINEAR': + col.prop(settings, "easing") + + if settings.type == 'BACK': + layout.prop(settings, "back") + elif settings.type == 'ELASTIC': + sub = layout.column(align=True) + sub.prop(settings, "amplitude") + sub.prop(settings, "period") + +""" diff --git a/__init__.py b/__init__.py index 9d2e235..c508db3 100755 --- a/__init__.py +++ b/__init__.py @@ -13,9 +13,9 @@ bl_info = { "name": "GP toolbox", -"description": "Set of tools for Grease Pencil in animation production", +"description": "Tool set for Grease Pencil in animation production", "author": "Samuel Bernou, Christophe Seux", -"version": (1, 7, 8), +"version": (1, 8, 0), "blender": (2, 91, 0), "location": "Sidebar (N menu) > Gpencil > Toolbox / Gpencil properties", "warning": "", @@ -633,7 +633,7 @@ def register(): OP_playblast_bg.register() OP_playblast.register() OP_palettes.register() - # OP_palettes_linker.register() + OP_palettes_linker.register() OP_brushes.register() OP_cursor_snap_canvas.register() OP_copy_paste.register() @@ -680,7 +680,7 @@ def unregister(): OP_copy_paste.unregister() OP_cursor_snap_canvas.unregister() OP_brushes.unregister() - # OP_palettes_linker.unregister() + OP_palettes_linker.unregister() OP_palettes.unregister() OP_file_checker.unregister() OP_helpers.unregister() diff --git a/utils.py b/utils.py index 0743e58..6825731 100644 --- a/utils.py +++ b/utils.py @@ -252,7 +252,9 @@ def remapping(value, leftMin, leftMax, rightMin, rightMax): # Convert the 0-1 range into a value in the right range. return rightMin + (valueScaled * rightSpan) -#### GP funcs +# ----------------- +### GP funcs +# ----------------- def get_gp_draw_plane(context, obj=None): ''' return tuple with plane coordinate and normal @@ -546,15 +548,20 @@ def extrapolate_points_by_length(a,b, length): ### Vector utils 2d # ----------------- + +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 single_vector_length_2d(v): return sqrt((v[0] * v[0]) + (v[1] * v[1])) - def vector_length_2d(A,B): ''''take two Vector and return length''' return sqrt((A[0] - B[0])**2 + (A[1] - B[1])**2) - def vector_length_coeff_2d(size, A, B): ''' Calculate the vector lenght @@ -569,7 +576,7 @@ def vector_length_coeff_2d(size, A, B): def cross_vector_coord_2d(foo, bar, size): '''Return the coord in space of a cross vector between the two point with specified size''' - ###middle location between 2 vector is calculated by adding the two vector and divide by two + ##middle location between 2 vector is calculated by adding the two vector and divide by two ##mid = (foo + bar) / 2 between = foo - bar #create a generic Up vector (on Y or Z) @@ -646,6 +653,17 @@ def get_addon_prefs(): addon_prefs = preferences.addons[addon_name].preferences return (addon_prefs) +def open_addon_prefs(): + '''Open addon prefs windows with focus on current addon''' + from .__init__ import bl_info + wm = bpy.context.window_manager + wm.addon_filter = 'All' + if not 'COMMUNITY' in wm.addon_support: # reactivate community + wm.addon_support = set([i for i in wm.addon_support] + ['COMMUNITY']) + wm.addon_search = bl_info['name'] + bpy.context.preferences.active_section = 'ADDONS' + bpy.ops.preferences.addon_expand(module=__package__) + bpy.ops.screen.userpref_show('INVOKE_DEFAULT') def open_file(file_path) : '''Open filepath with default browser''' @@ -703,10 +721,24 @@ 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 fuzzy_match(s1, s2, tol=0.8, case_sensitive=False): + '''Tell if two strings are similar using a similarity ratio (0 to 1) value passed as third arg''' + from difflib import SequenceMatcher + # can also use difflib.get_close_matches(word, possibilities, n=3, cutoff=0.6) + if case_sensitive: + similarity = SequenceMatcher(None, s1, s2) + else: + similarity = SequenceMatcher(None, s1.lower(), s2.lower()) + return similarity.ratio() > tol + +def fuzzy_match_ratio(s1, s2, case_sensitive=False): + '''Tell how much two passed strings are similar 1.0 being exactly similar''' + from difflib import SequenceMatcher + if case_sensitive: + similarity = SequenceMatcher(None, s1, s2) + else: + similarity = SequenceMatcher(None, s1.lower(), s2.lower()) + return similarity.ratio() def convert_attr(Attr): '''Convert given value to a Json serializable format''' @@ -749,8 +781,9 @@ def show_message_box(_message = "", _title = "Message Box", _icon = 'INFO'): _message = [_message] 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): @@ -857,17 +890,36 @@ def draw_kmi(km, kmi, layout): # draw_km(display_keymaps, kc, kmm, None, layout + 1) # layout.context_pointer_set("keymap", km) +# ----------------- +### linking utility +# ----------------- + +""" def link_objects_in_blend(filepath, obj_name, link=True): '''Link an object by name from a file, if link is False, append instead of linking''' with bpy.data.libraries.load(filepath, link=link) as (data_from, data_to): data_to.objects = [o for o in data_from.objects if o == obj_name] # c.startswith(obj_name) return data_to.objects + """ +def link_objects_in_blend(filepath, obj_name_list, link=True): + '''Link an object by name from a file, if link is False, append instead of linking''' + if isinstance(obj_name_list, str): + obj_name_list = [obj_name_list] + with bpy.data.libraries.load(filepath, link=link) as (data_from, data_to): + data_to.objects = [o for o in data_from.objects if o in obj_name_list] + return data_to.objects + +def check_materials_in_blend(filepath): + '''Return a list of all material in remote blend file''' + with bpy.data.libraries.load(filepath, link=False) as (data_from, data_to): + l = [m for m in data_from.materials] + return l def check_objects_in_blend(filepath, avoid_camera=True): - '''return a list of object name in file''' + '''Return a list of object name in remote blend file''' with bpy.data.libraries.load(filepath, link=False) as (data_from, data_to): if avoid_camera: - l = [o for o in data_from.objects if not 'camera' in o.lower()] + l = [o for o in data_from.objects if not any(x in o.lower() for x in ('camera', 'draw_cam', 'obj_cam'))] else: l = [o for o in data_from.objects] return l \ No newline at end of file