import bpy import re import json import os from bpy_extras.io_utils import ImportHelper, ExportHelper from pathlib import Path from . import utils # from . import blendfile from bpy.types import ( Panel, Operator, PropertyGroup, UIList, ) from bpy.props import ( IntProperty, BoolProperty, StringProperty, FloatProperty, EnumProperty, 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 == 'GREASEPENCIL'] if not selection: self.report({'ERROR'}, 'Need to have at least one GP object selected in scene') return {"CANCELLED"} prefs = utils.get_addon_prefs() exclusions = [name.strip() for name in prefs.mat_link_exclude.split(',')] if prefs.mat_link_exclude else [] # 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'} if not Path(blend_path).exists(): utils.show_message_box([['gp.palettes_reload_blends', 'Invalid blend path! Click here to refresh source blends', 'FILE_REFRESH']], 'Invalid Palette', 'ERROR') 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 != 'GREASEPENCIL': 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: ## filter mat if src_mat.name in exclusions: continue 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"} ## Unused for now, all libs are linked to one library data. need to replace material links one by one. class GPTB_OT_palette_version_update(Operator): bl_idname = "gptb.palette_version_update" bl_label = "Update Palette Version" bl_description = "Update linked material to selected palette version if curent link has same basename" bl_options = {"REGISTER"} mat_scope : EnumProperty( name='Targeted Materials', items=(('ALL', "All Materials", "Update all linked material in file to next version"), ('SELECTED', "Selected Objects", "Update all linked material on selected gp objects"), ), default='ALL', description='Choose material targeted for library update' ) mat_type : EnumProperty( name='Materials Type', items=(('ALL', "All Materials", "Update both gp and obj materials"), ('GP', "Gpencil Materials", "update only grease pencil materials"), ('OBJ', "Non-Gpencil Materials", "update only non-gpencil objects materials"), ), default='GP', description='Filter material type for library update' ) def invoke(self, context, event): self.bl_props = context.scene.bl_palettes_props if not self.bl_props.blends or not self.bl_props.blends[0].blend_path: self.report({'ERROR'}, 'No blend selected') return {"CANCELLED"} return context.window_manager.invoke_props_dialog(self, width=450) def draw(self, context): layout = self.layout layout.label(text=f'Update links path to palette: {self.bl_props.blends[self.bl_props.bl_idx].blend_name}', icon='LINK_BLEND') self.bl_props layout.prop(self, 'mat_scope') layout.prop(self, 'mat_type') col = layout.column(align=True) col.label(text='Does not check if material exists in target blend', icon='INFO') col.label(text='Just change source filepath if different version of same source name is found') # col.label(text='version of same source name is found') def execute(self, context): if self.mat_scope == 'SELECTED' and not context.selected_objects: self.report({'ERROR'}, 'No selected objects') return {"CANCELLED"} bl_props = context.scene.bl_palettes_props bl = bl_props.blends[bl_props.bl_idx] bl_name, bl_path = bl.blend_name, bl.blend_path if not Path(bl_path).exists(): self.report({'ERROR'}, f'Current selected blend source seem unreachable, try to refresh\ninvalid path: {bl_path}') return {"CANCELLED"} reversion = re.compile(r'\d{2,4}$') # version padding from 2 to 4 bl_relpath = bpy.path.relpath(bl_path) if self.mat_scope == 'SELECTED': pool = [] for o in context.selected_objects: for m in o.data.materials: pool.append(m) elif self.mat_scope == 'ALL': pool = [m for m in bpy.data.materials] ct = 0 for m in pool: if not m.library: continue if self.mat_type == 'GP' and not m.is_grease_pencil: continue if self.mat_type == 'OBJ' and m.is_grease_pencil: continue cur_fp = m.library.filepath if not cur_fp: print(f'! {m.name} has an empty library filepath !') continue p_cur_fp = Path(cur_fp) if p_cur_fp.stem == bl_name: continue # already good if reversion.sub('', p_cur_fp.stem) != reversion.sub('', bl_name): continue # not same stem base # Same stem without version, can update to this one print(f'{m.name}: {p_cur_fp} >> {bl_relpath}') ct += 1 m.library.filepath = bl_relpath if ct: self.report({'INFO'}, f'{ct} material link path updated') else: self.report({'WARNING'}, 'No material path updated') return {"FINISHED"} #--- UI LIST class GPTB_UL_blend_list(UIList): # order_by_distance : BoolProperty(default=True) def draw_item(self, context, layout, data, item, icon, active_data, active_propname): layout.label(text=item.blend_name) def draw_filter(self, context, layout): row = layout.row() subrow = row.row(align=True) subrow.prop(self, "filter_name", text="") # Only show items matching this name (use ‘*’ as wildcard) # reverse order 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 def filter_items(self, context, data, propname): # example : https://docs.blender.org/api/blender_python_api_current/bpy.types.UIList.html # This function gets the collection property (as the usual tuple (data, propname)), and must return two lists: # * The first one is for filtering, it must contain 32bit integers were self.bitflag_filter_item marks the # matching item as filtered (i.e. to be shown), and 31 other bits are free for custom needs. Here we use the # * The second one is for reordering, it must return a list containing the new indices of the items (which # gives us a mapping org_idx -> new_idx). # Please note that the default UI_UL_list defines helper functions for common tasks (see its doc for more info). # If you do not make filtering and/or ordering, return empty list(s) (this will be more efficient than # returning full lists doing nothing!). collec = getattr(data, propname) helper_funcs = bpy.types.UI_UL_list # Default return values. flt_flags = [] flt_neworder = [] # Filtering by name #not working damn ! if self.filter_name: flt_flags = helper_funcs.filter_items_by_name(self.filter_name, self.bitflag_filter_item, collec, "name", reverse=self.use_filter_sort_reverse)#self.use_filter_name_reverse) return flt_flags, flt_neworder 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): row = layout.row() 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 def filter_items(self, context, data, propname): collec = getattr(data, propname) helper_funcs = bpy.types.UI_UL_list # Default return values. flt_flags = [] flt_neworder = [] if self.filter_name: flt_flags = helper_funcs.filter_items_by_name(self.filter_name, self.bitflag_filter_item, collec, "name", reverse=self.use_filter_sort_reverse) return flt_flags, flt_neworder def reload_blends(self, context): scn = context.scene pl_prop = scn.bl_palettes_props uilist = scn.bl_palettes_props.blends uilist.clear() pl_prop['bl_idx'] = 0 prefs = utils.get_addon_prefs() if pl_prop.use_project_path: palette_fp = prefs.palette_path else: palette_fp = pl_prop.custom_dir if not palette_fp: # singular item = uilist.add() item.blend_name = 'No Palette Path Specified' reload_objects(self, context) return 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 # 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, "")) ## 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 item = uilist.add() scn.bl_palettes_props['bl_idx'] = len(uilist) - 1 # don't trigger updates item.blend_path = bl[0] item.blend_name = bl[1] scn.bl_palettes_props.bl_idx = len(uilist) - 1 # trigger update () # reload_objects(self, context) # triggered by above assignation # return len(blends) # return value must be None class GPTB_OT_palettes_reload_blends(Operator): bl_idname = "gp.palettes_reload_blends" bl_label = "Reload Palette Blends" bl_description = "Reload the blends in UI list of palettes linker" bl_options = {"REGISTER"} # , "INTERNAL" def execute(self, context): reload_blends(self, context) # ret = reload_blends(self, context) # if ret is None: # self.report({'ERROR'}, 'No blend scanned, check palette path') # else: # self.report({'INFO'}, f'{ret} blends found') return {"FINISHED"} def reload_objects(self, context): scn = context.scene prefs = utils.get_addon_prefs() pal_prop = scn.bl_palettes_props blend_uil = pal_prop.blends obj_uil = pal_prop.objects 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' return if not blend_uil[pal_prop.bl_idx].blend_path: item = obj_uil.add() item.name = 'Selected blend has no path' return path_to_blend = Path(blend_uil[pal_prop.bl_idx].blend_path) ## 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 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 #--- PROPERTIES class GPTB_PG_blend_prop(PropertyGroup): blend_name : StringProperty() # stem of the path 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 blends : bpy.props.CollectionProperty(type=GPTB_PG_blend_prop) ob_idx : IntProperty() objects : bpy.props.CollectionProperty(type=GPTB_PG_object_prop) use_project_path : BoolProperty(name='Use Project Palettes', default=True, description='Use palettes directory specified in gp toolbox addon preferences', update=reload_blends) show_path : BoolProperty(name='Show path', default=True, description='Show Palette directoty filepath') custom_dir : StringProperty(name='Custom Palettes Directory', subtype='DIR_PATH', description='Use choosen directory to load blend palettes', update=reload_blends) import_type : EnumProperty( name="Import Type", description="Choose inmport type: link, append, append reuse (keep existing materials)", default='LINK', options={'ANIMATABLE'}, update=None, get=None, set=None, items=( ('LINK', 'Link', 'Link materials to selected object', 0), ('APPEND', 'Append', 'Append materials to selected objects', 1), ('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...) classes = ( # blend list GPTB_PG_blend_prop, 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, # prop containing two above GPTB_PG_palette_settings, GPTB_OT_import_obj_palette, # GPTB_OT_palette_version_update, # TEST_OT_import_obj_palette_test, ) def register(): for cls in classes: bpy.utils.register_class(cls) bpy.types.Scene.bl_palettes_props = bpy.props.PointerProperty(type=GPTB_PG_palette_settings) def unregister(): for cls in reversed(classes): bpy.utils.unregister_class(cls) del bpy.types.Scene.bl_palettes_props