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
		
			
				
	
	
		
			468 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			468 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| 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 == '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)
 | ||
| 
 | ||
|     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,
 | ||
| # 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
 |