582 lines
22 KiB
Python
582 lines
22 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 == '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
|