gp_toolbox/OP_palettes_linker.py

582 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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