- Add core addon files (__init__.py, KKPanel.py, preferences.py, common.py) - Add import pipeline with armature, mesh, and material modification modules - Add export pipeline with material baking and FBX preparation utilities - Add material combiner tool for texture atlas generation and optimization - Add extras utilities for animation, rigging, and asset management - Add bone orientation data from better_fbx for accurate skeletal structure - Add comprehensive documentation and wiki with multi-language support (EN, JP, ZH) - Add animation library retargeting lists for ARP and Rokoko motion capture - Add Rigify integration scripts for advanced rigging workflows - Add shader file (KK Shader V8.0.blend) for material rendering - Add manifest and license files for addon distribution - Add changelog documenting version history and improvements - Initialize complete Blender addon project for Koikatsu character import/export
437 lines
18 KiB
Python
437 lines
18 KiB
Python
import bpy, json, datetime, traceback
|
|
from pathlib import Path
|
|
|
|
|
|
class JsonFileManager:
|
|
'''
|
|
Manages json files to avoid loading files repeatedly. The instance is declared at the bottom of the file
|
|
'''
|
|
|
|
def __init__(self):
|
|
self.json_files = {}
|
|
self.smr_materials_data = {}
|
|
self.materials_data = {}
|
|
|
|
def clear(self):
|
|
self.json_files.clear()
|
|
self.smr_materials_data.clear()
|
|
|
|
def init(self):
|
|
'''
|
|
Recording json filenames in the import directory, but only load MaterialDataComplete.json
|
|
Also record material information for MaterialDataComplete.json
|
|
'''
|
|
self.clear()
|
|
for file in Path(bpy.context.scene.kkbp.import_dir).glob('*.json'):
|
|
self.json_files[file.name] = str(file)
|
|
|
|
raw_data = self.get_json_file('KK_MaterialDataComplete.json')
|
|
|
|
for item in raw_data:
|
|
if material_infos := item['MaterialInformation']:
|
|
if (smr_data := self.smr_materials_data.get(item['SMRName'])) is None:
|
|
smr_data = []
|
|
self.smr_materials_data[item['SMRName']] = smr_data
|
|
smr_data.append(item)
|
|
for material_info in material_infos:
|
|
if (materials := self.materials_data.get(material_info['MaterialName'])) is None:
|
|
materials = []
|
|
self.materials_data[material_info['MaterialName']] = materials
|
|
materials.append(material_info)
|
|
|
|
def get_json_file(self, filename: str) -> json:
|
|
'''
|
|
Returns the json data by filename. Include the .json in the filename argument
|
|
'''
|
|
if json_data := self.json_files.get(filename): # Returns None if the file is not found (will likely cause an error)
|
|
if isinstance(json_data, str):
|
|
json_data = self.json_files[filename] = json.load(open(json_data))
|
|
return json_data
|
|
|
|
def get_material_info_by_smr(self, smr_name: str) -> list[dict]:
|
|
return self.smr_materials_data.get(smr_name)
|
|
|
|
def get_materials_info(self) -> dict[str, list[dict]]:
|
|
return self.smr_materials_data
|
|
|
|
def get_color(self, material_name: str, color: str) -> dict[str: float]:
|
|
'''Find the material material_name and return an RGBA dict list of the desired color ranging from 0-1'''
|
|
if material_name := bpy.data.materials[material_name].get('id'):
|
|
material_colors = self.materials_data.get(material_name)
|
|
for material_color in material_colors:
|
|
color_dict = zip(material_color['ShaderPropNames'], material_color['ShaderPropColorValues'])
|
|
for pair in color_dict:
|
|
if color in pair[0]:
|
|
return pair[1]
|
|
kklog(f"Couldn't find {color} for {material_name}", 'warn')
|
|
return {'r': 1, 'g': 1, 'b': 1, 'a': 1}
|
|
|
|
def get_shadow_color(self, material_name: str) -> dict[str, float]:
|
|
'''Find the material material_name and return an RGBA float list ranging from 0-1'''
|
|
# get original name
|
|
if material_name := bpy.data.materials[material_name].get('id'):
|
|
material_colors = self.materials_data.get(material_name)
|
|
for material_color in material_colors:
|
|
color_dict = zip(material_color['ShaderPropNames'], material_color['ShaderPropColorValues'])
|
|
for pair in color_dict:
|
|
if '_shadowcolor' in pair[0].lower():
|
|
return pair[1]
|
|
# return a default color if not found
|
|
kklog(f'Couldn\'t find shadow color for {material_name}', 'warn')
|
|
return {'r': 0.764, 'g': 0.880, 'b': 1}
|
|
|
|
|
|
|
|
def toggle_console():
|
|
'''toggle the console. will do nothing on Linux or Mac'''
|
|
try:
|
|
bpy.ops.wm.console_toggle()
|
|
except:
|
|
return # only available on windows so it might error out for other platforms
|
|
|
|
|
|
def kklog(log_text: str, type=''):
|
|
'''Log to the KKBP Log text in the scripting tab. Also prints to console. type can be error or warn'''
|
|
if not bpy.data.texts.get('KKBP Log'):
|
|
bpy.data.texts.new(name='KKBP Log')
|
|
if bpy.data.screens.get('Scripting'):
|
|
for area in bpy.data.screens['Scripting'].areas:
|
|
if area.type == 'TEXT_EDITOR':
|
|
area.spaces[0].text = bpy.data.texts['KKBP Log']
|
|
bpy.data.texts['KKBP Log'].write('==== KKBP Log ====\n')
|
|
if type == 'error':
|
|
log_text = '\nError: ' + str(log_text)
|
|
elif type == 'warn':
|
|
log_text = 'Warning: ' + str(log_text)
|
|
bpy.data.texts['KKBP Log'].write(str(log_text) + '\n')
|
|
print(str(log_text))
|
|
|
|
|
|
def set_viewport_shading(type='MATERIAL'):
|
|
'''set the viewport shading in the layout tab. Accepts 'WIREFRAME' 'SOLID' 'MATERIAL' or 'RENDERED' '''
|
|
for area in bpy.context.workspace.screens[0].areas:
|
|
for space in area.spaces:
|
|
if space.type == 'VIEW_3D':
|
|
space.shading.type = type
|
|
|
|
|
|
def get_hairs() -> list[bpy.types.Object]:
|
|
'''Returns a list of all the hair objects for this import'''
|
|
hairs = [o for o in bpy.data.objects if
|
|
o.type == 'MESH' and o.get('hair') and o.get('name') == bpy.context.scene.kkbp.character_name]
|
|
return hairs
|
|
|
|
|
|
def get_outfits() -> list[bpy.types.Object]:
|
|
'''Returns a list of all the outfit objects for this import'''
|
|
outfits = [o for o in bpy.data.objects if
|
|
o.type == 'MESH' and o.get('outfit') and o.get('name') == bpy.context.scene.kkbp.character_name]
|
|
return outfits
|
|
|
|
|
|
def get_alts() -> list[bpy.types.Object]:
|
|
'''Returns a list of all the alternate outfit objects for this import'''
|
|
alts = [o for o in bpy.data.objects if
|
|
o.type == 'MESH' and o.get('alt') and o.get('name') == bpy.context.scene.kkbp.character_name]
|
|
return alts
|
|
|
|
|
|
def get_hitboxes() -> list[bpy.types.Object]:
|
|
'''Returns a list of all the hitbox objects for this import'''
|
|
hits = [o for o in bpy.data.objects if
|
|
o.type == 'MESH' and o.get('hitbox') and o.get('name') == bpy.context.scene.kkbp.character_name]
|
|
return hits
|
|
|
|
|
|
def get_body() -> bpy.types.Object:
|
|
'''Returns the body object for this import'''
|
|
bodies = [o for o in bpy.data.objects if o.get('body') and o.get('name') == bpy.context.scene.kkbp.character_name]
|
|
return bodies[0] if bodies else None
|
|
|
|
|
|
def get_armature() -> bpy.types.Object:
|
|
'''Returns the armature object for this import'''
|
|
arms = [o for o in bpy.data.objects if o.get('armature') and o.get('name') == bpy.context.scene.kkbp.character_name]
|
|
return arms[0] if arms else None
|
|
|
|
|
|
def get_rig() -> bpy.types.Object:
|
|
'''Returns the rigify armature object for this import'''
|
|
arms = [o for o in bpy.data.objects if o.get('rig') and o.get('name') == bpy.context.scene.kkbp.character_name]
|
|
return arms[0] if arms else None
|
|
|
|
|
|
def get_empties() -> list[bpy.types.Object]:
|
|
'''Returns a list of all empty objects for this import'''
|
|
empties = [o for o in bpy.data.objects if
|
|
o.type == 'EMPTY' and o.get('name') == bpy.context.scene.kkbp.character_name]
|
|
return empties
|
|
|
|
|
|
def get_tears() -> bpy.types.Object:
|
|
'''Returns the tears object for this import'''
|
|
tears = [o for o in bpy.data.objects if o.get('tears') and o.get('name') == bpy.context.scene.kkbp.character_name]
|
|
return tears[0] if tears else None
|
|
|
|
|
|
def get_gags() -> bpy.types.Object:
|
|
'''Returns the gag eyes object for this import'''
|
|
gags = [o for o in bpy.data.objects if o.get('gag') and o.get('name') == bpy.context.scene.kkbp.character_name]
|
|
return gags[0] if gags else None
|
|
|
|
|
|
def get_tongue() -> bpy.types.Object:
|
|
'''Returns the rigged tongue object for this import'''
|
|
tongues = [o for o in bpy.data.objects if
|
|
o.get('tongue') and o.get('name') == bpy.context.scene.kkbp.character_name]
|
|
return tongues[0] if tongues else None
|
|
|
|
|
|
def get_all_objects() -> list[bpy.types.Object]:
|
|
'''Returns all objects associated with this import'''
|
|
everything = get_outfits()
|
|
everything.extend(get_alts())
|
|
everything.append(get_body())
|
|
everything.extend(get_hairs())
|
|
everything.append(get_tears())
|
|
everything.append(get_gags())
|
|
everything.append(get_tongue())
|
|
return everything
|
|
|
|
|
|
def get_all_bakeable_objects() -> list[bpy.types.Object]:
|
|
'''Returns all objects associated with this import that can be baked'''
|
|
everything = get_outfits()
|
|
everything.extend(get_alts())
|
|
everything.append(get_body())
|
|
everything.extend(get_hairs())
|
|
return everything
|
|
|
|
|
|
def get_name() -> str:
|
|
'''Returns the character name'''
|
|
return bpy.context.scene.kkbp.character_name
|
|
|
|
|
|
def get_import_path() -> str:
|
|
'''Returns the import path'''
|
|
return bpy.context.scene.kkbp.import_dir
|
|
|
|
|
|
def get_material_names(smr_name: str) -> list[str] | None:
|
|
'''Returns a list of the material names this smr object is using'''
|
|
material_data = json_file_manager.get_material_info_by_smr(smr_name)
|
|
if material_data is None:
|
|
kklog(f'Could not find smr object: {smr_name}', 'warn')
|
|
return []
|
|
materials = [
|
|
sub_item['MaterialName']
|
|
for item in material_data
|
|
for sub_item in item['MaterialInformation']
|
|
if sub_item.get('MaterialName')
|
|
]
|
|
|
|
# remove dupes and sort
|
|
materials = list(set(materials))
|
|
return sorted(materials)
|
|
|
|
|
|
def get_shader_name(material_name: str) -> str:
|
|
'''Returns the shader name for this material'''
|
|
material_data = json_file_manager.get_json_file('KK_MaterialDataComplete.json')
|
|
material_infos = [m['MaterialInformation'] for m in material_data if m.get('MaterialInformation')]
|
|
shaders = []
|
|
for material_info in material_infos:
|
|
shaders.extend([m.get('ShaderName') for m in material_info if m.get('MaterialName') == material_name])
|
|
return shaders[0] if shaders else None
|
|
|
|
|
|
def get_color(material_name: str, color: str) -> dict[float]:
|
|
'''Find the material material_name and return an RGBA dict list of the specified color ranging from 0-1. If material_name contains a space and the character name, it will be filtered out.'''
|
|
material_name = bpy.data.materials[material_name].get('id')
|
|
if material_name:
|
|
material_data = json_file_manager.get_json_file('KK_MaterialDataComplete.json')
|
|
material_infos = [m['MaterialInformation'] for m in material_data if m.get('MaterialInformation')]
|
|
# get all the colors
|
|
material_colors = []
|
|
for material_info in material_infos:
|
|
material_colors.extend([m for m in material_info if m.get('MaterialName') == material_name])
|
|
for material_color in material_colors:
|
|
# then zip them and find the shadow color
|
|
color_dict = zip(material_color['ShaderPropNames'], material_color['ShaderPropColorValues'])
|
|
# key names are not consistent, so look through all of them
|
|
for pair in color_dict:
|
|
if color in pair[0]:
|
|
return pair[1]
|
|
kklog(f"Couldn't find {color} for {material_name}", 'warn')
|
|
return {'r': 1, 'g': 1, 'b': 1, 'a': 1}
|
|
|
|
|
|
def get_body_materials() -> list[bpy.types.Material]:
|
|
'''Returns a list of all the body materials'''
|
|
materials = [m for m in bpy.data.materials if
|
|
m.get('body') and m.get('name') == bpy.context.scene.kkbp.character_name]
|
|
return materials
|
|
|
|
|
|
def get_hair_materials() -> list[bpy.types.Material]:
|
|
'''Returns a list of all the body materials'''
|
|
materials = [m for m in bpy.data.materials if
|
|
m.get('hair') and m.get('name') == bpy.context.scene.kkbp.character_name]
|
|
return materials
|
|
|
|
|
|
def get_outfit_materials() -> list[bpy.types.Material]:
|
|
'''Returns a list of all the outfit materials'''
|
|
materials = [m for m in bpy.data.materials if
|
|
m.get('outfit') and m.get('name') == bpy.context.scene.kkbp.character_name]
|
|
return materials
|
|
|
|
|
|
def initialize_timer():
|
|
bpy.context.scene.kkbp.total_timer = datetime.datetime.now().minute * 60 + datetime.datetime.now().second + datetime.datetime.now().microsecond / 1e6
|
|
bpy.context.scene.kkbp.timer = datetime.datetime.now().minute * 60 + datetime.datetime.now().second + datetime.datetime.now().microsecond / 1e6
|
|
|
|
|
|
def reset_timer():
|
|
bpy.context.scene.kkbp.timer = datetime.datetime.now().minute * 60 + datetime.datetime.now().second + datetime.datetime.now().microsecond / 1e6
|
|
|
|
|
|
def print_timer(operation_name: str):
|
|
'''Prints the time between now and the last operation that was timed'''
|
|
kklog('{} operation took {} seconds'.format(
|
|
operation_name,
|
|
abs(round(((datetime.datetime.now().minute * 60 + datetime.datetime.now().second + datetime.datetime.now().microsecond / 1e6) - bpy.context.scene.kkbp.timer),
|
|
3))))
|
|
reset_timer()
|
|
|
|
|
|
def handle_error(error_causer: bpy.types.Operator, error: Exception):
|
|
kklog(
|
|
'Unknown python error occurred. \n Make sure the default model imports correctly before troubleshooting on this model!\n\n\n',
|
|
type='error')
|
|
kklog(traceback.format_exc())
|
|
error_causer.report({'ERROR'}, traceback.format_exc())
|
|
|
|
|
|
def switch(object: bpy.types.Object, mode='OBJECT'):
|
|
'''Switches blender mode on a blender object. Valid modes are 'object', 'edit' and 'pose' '''
|
|
bpy.context.view_layer.objects.active = object
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
object.select_set(True)
|
|
bpy.context.view_layer.objects.active = object
|
|
|
|
mode = mode.upper()
|
|
bpy.ops.object.mode_set(mode=mode)
|
|
|
|
if mode == 'POSE':
|
|
bpy.ops.pose.select_all(action='DESELECT')
|
|
elif mode == 'EDIT':
|
|
if object.type == 'MESH':
|
|
bpy.ops.mesh.select_all(action='DESELECT')
|
|
else:
|
|
bpy.ops.armature.select_all(action='DESELECT')
|
|
elif mode == 'OBJECT':
|
|
pass
|
|
else:
|
|
kklog('INVALID MODE CHOICE', type='error')
|
|
raise ('INVALID MODE CHOICE')
|
|
|
|
|
|
def move_and_hide_collection(objects: bpy.types.Object, new_collection: str, hide=True):
|
|
'''Move the objects into a new collection called "new_collection" and hide the new collection'''
|
|
if not objects:
|
|
return
|
|
switch(objects[0], 'object')
|
|
# unlink from old collection, then move to new collection
|
|
object_collection = bpy.data.collections.new(new_collection)
|
|
for object in objects:
|
|
object.users_collection[0].objects.unlink(object)
|
|
object_collection.objects.link(object)
|
|
bpy.context.scene.collection.children[get_name()].children.link(object_collection)
|
|
# then hide the new collection
|
|
try:
|
|
bpy.context.scene.view_layers[0].active_layer_collection = \
|
|
bpy.context.view_layer.layer_collection.children[get_name()].children[new_collection]
|
|
bpy.context.scene.view_layers[0].active_layer_collection.exclude = hide
|
|
except:
|
|
kklog(f'Failed to move and hide collection: {new_collection}', type='error')
|
|
|
|
|
|
def get_layer_collection_from_name(base_collection: bpy.types.LayerCollection,
|
|
search_term: str) -> bpy.types.LayerCollection:
|
|
'''Returns the view layer collection object by name'''
|
|
# check if this is it
|
|
if (base_collection.name == search_term):
|
|
return base_collection
|
|
# If not, recursively go through the collection's children for the search term
|
|
for child in base_collection.children:
|
|
if child.name == search_term:
|
|
return child
|
|
else:
|
|
recursive_result = get_layer_collection_from_name(child, search_term)
|
|
if recursive_result:
|
|
return recursive_result
|
|
|
|
|
|
def get_layer_collection_state(collection_name: str) -> bool:
|
|
'''Gets the exclude state of a view layer collection'''
|
|
base_collection = bpy.context.view_layer.layer_collection
|
|
collection = get_layer_collection_from_name(base_collection, collection_name)
|
|
return collection.exclude
|
|
|
|
|
|
def show_layer_collection(collection_name: str, state: bool):
|
|
'''Sets the exclude state of a view layer collection'''
|
|
base_collection = bpy.context.view_layer.layer_collection
|
|
collection = get_layer_collection_from_name(base_collection, collection_name)
|
|
collection.exclude = state
|
|
|
|
|
|
def clean_orphaned_data():
|
|
'''clean data that is no longer being used'''
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
for block in bpy.data.meshes:
|
|
if block.users == 0 and not block.use_fake_user:
|
|
bpy.data.meshes.remove(block)
|
|
for block in bpy.data.cameras:
|
|
if block.users == 0 and not block.use_fake_user:
|
|
bpy.data.cameras.remove(block)
|
|
for block in bpy.data.lights:
|
|
if block.users == 0 and not block.use_fake_user:
|
|
bpy.data.lights.remove(block)
|
|
for block in bpy.data.materials:
|
|
if block.users == 0 and not block.use_fake_user:
|
|
bpy.data.materials.remove(block)
|
|
|
|
|
|
def import_from_library_file(category, list_of_items, use_fake_user=False):
|
|
'''Import items from the KKBP library file. The category is 'Armature', 'Brush', 'Collection' etc and
|
|
the list_of_items is an array with all of the item names that you want to import.
|
|
This will try to import the material templates from the KK Shader.blend file in the PMX import folder.
|
|
If there's no KK Shader.blend file in the PMX folder, it will default to the one that comes with the plugin'''
|
|
fileList = Path(bpy.context.scene.kkbp.import_dir).glob('*.*')
|
|
files = [file for file in fileList if file.is_file()]
|
|
blend_file_missing = True
|
|
for file in files:
|
|
if '.blend' in str(file) and '.blend1' not in str(file) and 'KK Shader' in str(file):
|
|
directory = Path(file).resolve()
|
|
blend_file_missing = False
|
|
if blend_file_missing:
|
|
# grab it from the plugin directory
|
|
directory = Path(__file__)
|
|
filename = 'KK Shader V8.0.blend/'
|
|
|
|
library_path = (Path(directory).parent / filename).resolve()
|
|
template_list = [{'name': item} for item in list_of_items]
|
|
bpy.ops.wm.append(
|
|
filepath=str((library_path / category).resolve()),
|
|
directory=str(library_path / category) + '/',
|
|
files=template_list,
|
|
set_fake=use_fake_user
|
|
)
|
|
|
|
|
|
json_file_manager = JsonFileManager()
|