feat: Add KoikatsuBlenderPipeline addon with import/export functionality

- 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
This commit is contained in:
2025-12-06 15:26:19 +08:00
commit 5d9e09e9c3
122 changed files with 22683 additions and 0 deletions

Binary file not shown.

Binary file not shown.

657
exporting/bakematerials.py Normal file
View File

@@ -0,0 +1,657 @@
# BAKE MATERIAL TO TEXTURE SCRIPT
# Bakes all materials of an object into image textures (to use in other programs)
# Will only bake a material if an image node is present in the green texture group
# If no image is present, a low resolution failsafe image will be baked to account for
# fully opaque or transparent materials that don't rely on a texture file
# If there are multiple image resolutions, only the highest resolution be baked
# Materials are baked to an 8-bit PNG with an alpha channel.
# (optional) Creates a copy of the model and generates a material atlas to the copy
# Notes:
# - This script deletes all camera objects in the scene
# - fillerplane driver + shader code taken from https://blenderartists.org/t/scripts-create-camera-image-plane/580839
# - material combiner code taken from https://github.com/Grim-es/material-combiner-addon/
import bpy, os, traceback, time, pathlib, subprocess
from .. import common as c
from ..interface.dictionary_en import t
def print_memory_usage(stri):
run = subprocess.run('wmic OS get FreePhysicalMemory', capture_output=True)
c.kklog((stri, '\n mem usage ', 16000 - int(run.stdout.split(b'\r')[2].split(b'\n')[1])/1000))
#setup and return a camera
def setup_camera():
#Delete all cameras in the scene
for obj in bpy.context.scene.objects:
if obj.type == 'CAMERA':
obj.select_set(True)
else:
obj.select_set(False)
bpy.ops.object.delete()
for block in bpy.data.cameras:
if block.users == 0:
bpy.data.cameras.remove(block)
#Add a new camera
bpy.ops.object.camera_add(enter_editmode=False, align='VIEW', location=(0, 0, 1), rotation=(0, 0, 0))
#save it for later
camera = bpy.context.active_object
#and set it as the active one
bpy.context.scene.camera=camera
#Set camera to orthographic
bpy.data.cameras[camera.name].type='ORTHO'
bpy.data.cameras[camera.name].ortho_scale=6
bpy.context.scene.render.pixel_aspect_y=1
bpy.context.scene.render.pixel_aspect_x=1
return camera
def setup_geometry_nodes_and_fillerplane(camera: bpy.types.Object):
object_to_bake = bpy.context.active_object
#create fillerplane
bpy.ops.mesh.primitive_plane_add()
bpy.ops.object.material_slot_add()
fillerplane = bpy.context.active_object
fillerplane.data.uv_layers[0].name = 'uv_main'
fillerplane.name = "fillerplane"
bpy.ops.object.editmode_toggle()
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.transform.resize(value=(0.5,0.5,0.5))
bpy.ops.uv.reset()
bpy.ops.object.editmode_toggle()
fillerplane.location = (0,0,-0.0001)
def setup_driver_variables(driver, camera):
cam_ortho_scale = driver.variables.new()
cam_ortho_scale.name = 'cOS'
cam_ortho_scale.type = 'SINGLE_PROP'
cam_ortho_scale.targets[0].id_type = 'CAMERA'
cam_ortho_scale.targets[0].id = bpy.data.cameras[camera.name]
cam_ortho_scale.targets[0].data_path = 'ortho_scale'
resolution_x = driver.variables.new()
resolution_x.name = 'r_x'
resolution_x.type = 'SINGLE_PROP'
resolution_x.targets[0].id_type = 'SCENE'
resolution_x.targets[0].id = bpy.context.scene
resolution_x.targets[0].data_path = 'render.resolution_x'
resolution_y = driver.variables.new()
resolution_y.name = 'r_y'
resolution_y.type = 'SINGLE_PROP'
resolution_y.targets[0].id_type = 'SCENE'
resolution_y.targets[0].id = bpy.context.scene
resolution_y.targets[0].data_path = 'render.resolution_y'
#setup X scale for bake object and plane
driver = object_to_bake.driver_add('scale',0).driver
driver.type = 'SCRIPTED'
setup_driver_variables(driver, camera)
driver.expression = "((r_x)/(r_y)*(cOS)) if (((r_x)/(r_y)) < 1) else (cOS)"
driver = fillerplane.driver_add('scale',0).driver
driver.type = 'SCRIPTED'
setup_driver_variables(driver, camera)
driver.expression = "((r_x)/(r_y)*(cOS)) if (((r_x)/(r_y)) < 1) else (cOS)"
#setup drivers for object's Y scale
driver = object_to_bake.driver_add('scale',1).driver
driver.type = 'SCRIPTED'
setup_driver_variables(driver, camera)
driver.expression = "((r_y)/(r_x)*(cOS)) if (((r_y)/(r_x)) < 1) else (cOS)"
driver = fillerplane.driver_add('scale',1).driver
driver.type = 'SCRIPTED'
setup_driver_variables(driver, camera)
driver.expression = "((r_y)/(r_x)*(cOS)) if (((r_y)/(r_x)) < 1) else (cOS)"
###########################
#import the premade flattener node to unwrap the mesh into the UV structure
c.import_from_library_file('NodeTree', ['.Geometry Nodes'])
#give the object a geometry node modifier
geonodes_mod = object_to_bake.modifiers.new('Flattener', 'NODES')
geonodes_mod.node_group = bpy.data.node_groups['.Geometry Nodes']
identifier = [str(i) for i in geonodes_mod.keys()][0]
geonodes_mod[identifier+'_attribute_name'] = 'uv_main'
geonodes_mod[identifier+'_use_attribute'] = True
#Make the originally selected object active again
c.switch(object_to_bake, 'OBJECT')
##############################
#Changes the material of the image plane to the material of the object,
# and then puts a render of the image plane into the specified folder
def sanitizeMaterialName(text: str) -> str:
'''Mat names need to be sanitized else you can't delete the files with windows explorer'''
for ch in ['\\','`','*','<','>','.',':','?','|','/','\"']:
if ch in text:
text = text.replace(ch,'')
return text
def bake_pass(folderpath: str, bake_type: str):
'''Folds the body / clothes / hair down to a UV rectangle
Places a filler plane right below it to fill in the rest of the image
Bakes all materials on this object down to an image using the orthographic camera
'''
#get the currently selected object as the active object
object_to_bake = bpy.context.active_object
#remember what order the materials are in for later
original_material_order = []
for matslot in object_to_bake.material_slots:
original_material_order.append(matslot.name)
#if this is a light or dark pass, make sure the color output is a constant light or dark
combine = bpy.data.node_groups['.Combine colors']
combine.links.remove(combine.nodes['mix'].inputs[0].links[0])
combine.nodes['mix'].inputs[0].default_value = 1 if bake_type == 'light' else 0
#go through each material slot
for index, current_material in enumerate(object_to_bake.data.materials):
#Don't bake this material if it doesn't have the bake tag
if not current_material.get('bake'):
c.kklog(f'Detected material that cannot be finalized. Skipping: {current_material.name}')
continue
nodes = current_material.node_tree.nodes
links = current_material.node_tree.links
#Turn off the normals for the toon_shading shading node group input if this isn't a normal pass
if nodes.get('textures') and bake_type != 'normal':
toon_shading = nodes.get('textures').node_tree.nodes.get('shade')
if toon_shading:
original_normal_state = toon_shading.inputs[1].default_value
toon_shading.inputs[1].default_value = 0
#if this is a normal pass, attach the normal passthrough to the output
elif nodes.get('textures') and bake_type == 'normal':
if len(nodes['textures'].outputs):
links.remove(nodes['out'].inputs[0].links[0])
links.new(nodes['textures'].outputs[-1], nodes['out'].inputs[0])
if nodes.get('textures'):
#Go through each of the textures loaded into the textures group and get the highest resolution one
highest_resolution = [0, 0]
for image_node in nodes['textures'].node_tree.nodes:
if image_node.type == 'TEX_IMAGE' and image_node.image:
image_size = image_node.image.size[0] * image_node.image.size[1]
largest_so_far = highest_resolution[0] * highest_resolution[1]
if image_size > largest_so_far:
highest_resolution = image_node.image.size
resolution_multiplier = bpy.context.scene.kkbp.bake_mult
#Render an image using the highest dimensions
if highest_resolution:
bpy.context.scene.render.resolution_x=highest_resolution[0] * resolution_multiplier
bpy.context.scene.render.resolution_y=highest_resolution[1] * resolution_multiplier
else:
#if no images were found, render a 64px failsafe image anyway to catch
# materials that are a solid color, don't rely on textures, or are completely transparent
bpy.context.scene.render.resolution_x=64
bpy.context.scene.render.resolution_y=64
#set every material slot except the current material to be transparent
for matslot in object_to_bake.material_slots:
if matslot.material != current_material:
matslot.material = bpy.data.materials['KK Eyeline kage ' + c.get_name()]
#set the filler plane to the current material
bpy.data.objects['fillerplane'].material_slots[0].material = current_material
#then render it
matname = sanitizeMaterialName(current_material.name)
matname = matname[:-4] if matname[-4:] == '-ORG' else matname
bpy.context.scene.render.filepath = folderpath + matname + ' ' + bake_type
bpy.context.scene.render.image_settings.file_format='PNG'
bpy.context.scene.render.image_settings.color_mode='RGBA'
print('Rendering {} / {}'.format(index+1, len(object_to_bake.data.materials)))
bpy.ops.render.render(write_still = True)
#reset folderpath after render
bpy.context.scene.render.filepath = folderpath
#Restore the value in the toon_shading shading node group for the normals
if nodes.get('textures') and bake_type != 'normal':
toon_shading = nodes.get('textures').node_tree.nodes.get('shade')
if toon_shading:
toon_shading.inputs[1].default_value = original_normal_state
#Restore the links if they were edited for the normal pass
elif nodes.get('textures') and bake_type == 'normal':
if len(nodes['textures'].outputs):
links.remove(nodes['out'].inputs[0].links[0])
links.new(nodes['combine'].outputs[0], nodes['out'].inputs[0])
#reset material slots to their original order
for material_index in range(len(original_material_order)):
object_to_bake.material_slots[material_index].material = bpy.data.materials[original_material_order[material_index]]
#reset the color output group link
combine = bpy.data.node_groups['.Combine colors']
combine.links.new(combine.nodes['input'].outputs[2], combine.nodes['mix'].inputs[0])
def cleanup():
# Deselect all objects
bpy.ops.object.select_all(action='DESELECT')
#Select the camera
for camera in [o for o in bpy.data.objects if o.type == 'CAMERA']:
bpy.data.objects.remove(camera)
#Select fillerplane
for fillerplane in [o for o in bpy.data.objects if 'fillerplane' in o.name]:
bpy.data.objects.remove(fillerplane)
#delete orphan data
for block in bpy.data.meshes:
if block.users == 0:
bpy.data.meshes.remove(block)
for block in bpy.data.cameras:
if block.users == 0:
bpy.data.cameras.remove(block)
for ob in [obj for obj in bpy.context.view_layer.objects if obj and obj.type == 'MESH']:
#delete the geometry modifier
if ob.modifiers.get('Flattener'):
ob.modifiers.remove(ob.modifiers['Flattener'])
#delete the two scale drivers
ob.animation_data.drivers.remove(ob.animation_data.drivers[0])
ob.animation_data.drivers.remove(ob.animation_data.drivers[0])
ob.scale = (1,1,1)
bpy.data.node_groups.remove(bpy.data.node_groups['.Geometry Nodes'])
def replace_all_baked_materials(folderpath: str, bake_object: bpy.types.Object):
#load all baked images into blender
fileList = pathlib.Path(folderpath).glob('*.png')
files = [file for file in fileList if file.is_file()]
for file in files:
try:
image = bpy.data.images.load(filepath=str(file))
image.pack()
#if there was an older version of this image, get rid of it
if image.name[-4:] == '.001':
if bpy.data.images.get(image.name[:-4]):
bpy.data.images[image.name[:-4]].user_remap(image)
bpy.data.images.remove(bpy.data.images[image.name[:-4]])
image.name = image.name[:-4]
except:
c.kklog(f'Could not load in file because the name exceeds 64 characters: {file}')
#now all needed images are loaded into the file. Match each material to it's image textures
for bake_type in ['light', 'dark', 'normal']:
for mat in bake_object.material_slots:
image = bpy.data.images.get(mat.material.name.replace('-ORG', '') + f' {bake_type}.png', '')
if image:
#the simplified material already exists and is loaded into the material slot, so just load in the image
if mat.material.get('simple'):
simple = mat.material
textures_group = simple.node_tree.nodes['textures'].node_tree
textures_group.nodes[bake_type].image = image
#the simplified material already exists, but the user swapped it back to the -ORG version to rebake it,
# so load the material back into the material slot and load in the image
elif mat.material.get('bake') and '-ORG' in mat.material.name and bpy.data.materials.get(mat.material.name.replace('-ORG','')):
simple = bpy.data.materials[mat.material.name.replace('-ORG','')]
mat.material = simple
textures_group = simple.node_tree.nodes['textures'].node_tree
print(mat.material.name)
textures_group.nodes[bake_type].image = image
#check if a simplified version of this material exists yet. If it doesn't, create it
elif mat.material.get('bake'):
#rename the original material to "material_name-ORG" and create the simplified material
mat.material.name += '-ORG'
try:
simple = bpy.data.materials['KK Simple'].copy()
except:
c.import_from_library_file('Material', ['KK Simple'], use_fake_user = False)
simple = bpy.data.materials['KK Simple'].copy()
simple.name = mat.material.name.replace('-ORG', '')
textures_group = simple.node_tree.nodes['textures'].node_tree.copy()
textures_group.name = simple.name
simple.node_tree.nodes['textures'].node_tree = textures_group
textures_group.nodes[bake_type].image = image
# you have the ability to only bake the light textures, but it looks weird if there is no dark texture to go along with it,
# put the light image into the dark slot. it will be overwritten if the dark texture exists on the next loop
if bake_type == 'light':
textures_group.nodes['dark'].image = image
#and then replace the original material with this new simplified one
mat.material.use_fake_user = True
def replace_mat():
if bpy.app.version[0] > 3:
blend_method = mat.material.surface_render_method
mat.material = simple
mat.material.surface_render_method = blend_method
mat.material.use_transparency_overlap = True if ('KK Eyewhites (sirome) ' + c.get_name() in mat.name) else False
else:
blend_method = mat.material.blend_method
mat.material = simple
mat.material.blend_method = blend_method
mat.material.show_transparent_back = False
simple['simple'] = True
replace_mat()
#load the Eevee Mod simple shader if using Eevee Mod
if bpy.context.scene.kkbp.shader_dropdown == 'C':
try:
simple = bpy.data.node_groups['.Simple Shader (Eevee Mod)'].copy()
except:
c.import_from_library_file('NodeTree', ['.Simple Shader (Eevee Mod)'], use_fake_user = False)
simple = bpy.data.node_groups['.Simple Shader (Eevee Mod)'].copy()
#and then replace the original material with this new simplified one
mat.material.use_fake_user = True
replace_mat()
def create_material_atlas(folderpath: str):
'''Merges all the finalized material png files into a single atlas file, copies the current model and applies the atlas to the copy'''
# https://blender.stackexchange.com/questions/127403/change-active-collection
#Recursivly transverse layer_collection for a particular name
def recurLayerCollection(layerColl, collName):
found = None
if (layerColl.name == collName):
return layerColl
for layer in layerColl.children:
found = recurLayerCollection(layer, collName)
if found:
return found
def remove_orphan_data():
#revert the image back from the atlas file to the baked file
for mat in bpy.data.materials:
if mat.name[-4:] == '-ORG':
simplified_name = mat.name[:-4]
if bpy.data.materials.get(simplified_name):
simplified_mat = bpy.data.materials[simplified_name]
for bake_type in ['light', 'dark', 'normal']:
simplified_mat.node_tree.nodes['textures'].node_tree.nodes[bake_type].image = bpy.data.images.get(simplified_name + ' ' + bake_type + '.png')
#delete orphan data
for cat in [bpy.data.armatures, bpy.data.objects, bpy.data.meshes, bpy.data.materials, bpy.data.images, bpy.data.node_groups]:
for block in cat:
if block.users == 0:
cat.remove(block)
if bpy.data.collections.get(c.get_name() + ' atlas'):
c.kklog(f'deleting previous collection "{c.get_name()} atlas" and regenerating atlas model...')
def del_collection(coll):
for c in coll.children:
del_collection(c)
bpy.data.collections.remove(coll,do_unlink=True)
del_collection(bpy.data.collections[c.get_name() + ' atlas'])
remove_orphan_data()
#show the original collection again
c.show_layer_collection(c.get_name(), False)
#Change the Active LayerCollection to the character collection
layer_collection = bpy.context.view_layer.layer_collection
layerColl = recurLayerCollection(layer_collection, c.get_name())
bpy.context.view_layer.active_layer_collection = layerColl
# https://blender.stackexchange.com/questions/157828/how-to-duplicate-a-certain-collection-using-python
from collections import defaultdict
def copy_objects(from_col, to_col, linked, dupe_lut):
for o in from_col.objects:
dupe = o.copy()
if not linked and o.data:
dupe.data = dupe.data.copy()
to_col.objects.link(dupe)
dupe_lut[o] = dupe
def copy(parent, collection, linked=False):
dupe_lut = defaultdict(lambda : None)
def _copy(parent, collection, linked=False):
cc = bpy.data.collections.new(collection.name)
copy_objects(collection, cc, linked, dupe_lut)
for c in collection.children:
_copy(cc, c, linked)
parent.children.link(cc)
return cc
the_copy = _copy(parent, collection, linked)
for o, dupe in tuple(dupe_lut.items()):
parent = dupe_lut[o.parent]
if parent:
dupe.parent = parent
return the_copy
context = bpy.context
scene = context.scene
col = context.collection
assert(col is not scene.collection)
copied_collection = copy(scene.collection, col)
copied_collection.name = c.get_name() + ' atlas'
#setup materials for the combiner script
for obj in [o for o in bpy.data.collections[c.get_name() + ' atlas'].all_objects if o.type == 'MESH']:
for mat in [mat_slot.material for mat_slot in obj.material_slots if mat_slot.material.get('simple')]:
nodes = mat.node_tree.nodes
links = mat.node_tree.links
emissive_node = nodes.new('ShaderNodeEmission')
emissive_node.name = 'Emission'
image_node = nodes.new('ShaderNodeTexImage')
image_node.name = 'Image Texture'
links.new(emissive_node.inputs[0], image_node.outputs[0])
image_node.image = nodes['textures'].node_tree.nodes['light'].image
context.view_layer.objects.active = obj
bpy.ops.object.material_slot_remove_unused()
#call the material combiner script
bpy.ops.kkbp.combiner()
#replace all images with the atlas in a new atlas material
bake_types = []
if scene.kkbp.bake_light_bool:
bake_types.append('light')
if scene.kkbp.bake_dark_bool:
bake_types.append('dark')
if scene.kkbp.bake_norm_bool:
bake_types.append('normal')
for index, obj in enumerate([o for o in bpy.data.collections[c.get_name() + ' atlas'].all_objects if o.type == 'MESH']):
#fix modifiers for all objects in this collection
for mod in obj.modifiers:
if mod.type == 'ARMATURE':
#fix the armature modifier to use the copied aramture
copied_armature = [o for o in bpy.data.collections[c.get_name() + ' atlas'].all_objects if o.type == 'ARMATURE'][0]
mod.object = copied_armature
elif mod.type == 'SOLIDIFY':
#disable the outline on the atlased object because I don't feel like fixing it
obj.modifiers['Outline Modifier'].show_render = False
obj.modifiers['Outline Modifier'].show_viewport = False
elif mod.type == 'UV_WARP':
#fix the UV warp modifier to use the copied armature
copied_armature = [o for o in bpy.data.collections[c.get_name() + ' atlas'].all_objects if o.type == 'ARMATURE'][0]
mod.object_from = copied_armature
mod.object_to = copied_armature
#check if this object had any atlas-able materials to begin with. If not, skip
if not [mat_slot.material for mat_slot in obj.material_slots if mat_slot.material.get('simple')]:
continue
for bake_type in bake_types:
#check for atlas dupes
atlas_image_name = f'{sanitizeMaterialName(obj.name).replace("001","")}_{bake_type}.png'
if bpy.data.images.get(atlas_image_name):
bpy.data.images.remove(bpy.data.images.get(atlas_image_name))
#the atlas image is originally named after the index of the object. Rename it to the object name
original_image_path = os.path.join(context.scene.kkbp.import_dir, 'atlas_files', f'{index}_{bake_type}.png')
new_image_path = os.path.join(context.scene.kkbp.import_dir, 'atlas_files', atlas_image_name)
if os.path.exists(original_image_path):
try:
os.rename(original_image_path, new_image_path)
except:
#rename failed because the file already exists. Delete the old one and try again
os.remove(new_image_path)
os.rename(original_image_path, new_image_path)
#then load it into blender
atlas_image = bpy.data.images.load(new_image_path)
bpy.data.images.remove(bpy.data.images.get(f'{index}_{bake_type}.png'))
for material in [mat_slot.material for mat_slot in obj.material_slots if mat_slot.material.get('simple')]:
image = material.node_tree.nodes['textures'].node_tree.nodes[bake_type].image
if image:
if image.name == 'Template: Pattern Placeholder':
image = None
if not image:
print(image)
continue
else:
if not bpy.data.materials.get('{} Atlas'.format(material.name)):
#remove the emission nodes from earlier
if material.node_tree.nodes.get('Emission'):
material.node_tree.nodes.remove(material.node_tree.nodes['Image Texture'])
material.node_tree.nodes.remove(material.node_tree.nodes['Emission'])
atlas_material = material.copy()
atlas_material['simple'] = False
atlas_material['atlas'] = True
atlas_material.name = '{} Atlas'.format(material.name)
new_group = atlas_material.node_tree.nodes['textures'].node_tree.copy()
new_group.name = '{} Atlas'.format(material.name)
else:
atlas_material = bpy.data.materials.get('{} Atlas'.format(material.name))
new_group = bpy.data.node_groups.get('{} Atlas'.format(material.name))
atlas_material.node_tree.nodes['textures'].node_tree = new_group
new_group.nodes[bake_type].image = atlas_image
#load in the light image to the dark slot to make it look better when only the light colors are baked.
# This will be overwritten with the dark image in the next loop if the user baked it
if bake_type == 'light':
new_group.nodes['dark'].image = atlas_image
#replace all images with the atlas in a new atlas material
for mat_slot in [m for m in obj.material_slots if m.material.get('simple')]:
material = mat_slot.material
atlas_material = bpy.data.materials.get('{} Atlas'.format(material.name))
mat_slot.material = atlas_material
#setup the new collection for exporting
if bpy.app.version[0] > 3:
layer_collection = bpy.context.view_layer.layer_collection
layerColl = recurLayerCollection(layer_collection, c.get_name() + ' atlas')
bpy.context.view_layer.active_layer_collection = layerColl
bpy.ops.collection.exporter_add(name="IO_FH_fbx")
bpy.data.collections[c.get_name() + ' atlas'].exporters[0].export_properties.object_types = {'EMPTY', 'ARMATURE', 'MESH', 'OTHER'}
bpy.data.collections[c.get_name() + ' atlas'].exporters[0].export_properties.use_mesh_modifiers = False
bpy.data.collections[c.get_name() + ' atlas'].exporters[0].export_properties.add_leaf_bones = False
bpy.data.collections[c.get_name() + ' atlas'].exporters[0].export_properties.bake_anim = False
bpy.data.collections[c.get_name() + ' atlas'].exporters[0].export_properties.apply_scale_options = 'FBX_SCALE_ALL'
bpy.data.collections[c.get_name() + ' atlas'].exporters[0].export_properties.path_mode = 'COPY'
bpy.data.collections[c.get_name() + ' atlas'].exporters[0].export_properties.embed_textures = False
bpy.data.collections[c.get_name() + ' atlas'].exporters[0].export_properties.mesh_smooth_type = 'OFF'
bpy.data.collections[c.get_name() + ' atlas'].exporters[0].export_properties.filepath = os.path.join(folderpath.replace('baked_files', 'atlas_files'), f'{sanitizeMaterialName(c.get_name())} exported model atlas.fbx')
#hide the new collection
c.show_layer_collection('Bone Widgets', True)
c.show_layer_collection('Rigged tongue ' + c.get_name(), True)
c.show_layer_collection('Rigged tongue ' + c.get_name() + '.001', True)
c.show_layer_collection('Bone Widgets.001', True)
c.show_layer_collection(c.get_name() + ' atlas', True)
remove_orphan_data()
class bake_materials(bpy.types.Operator):
bl_idname = "kkbp.bakematerials"
bl_label = "Bake and generate atlased model"
bl_description = t('bake_mats_tt')
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
try:
#just use the pmx folder for the baked files
scene = context.scene.kkbp
folderpath = os.path.join(context.scene.kkbp.import_dir, 'baked_files', '')
last_step = time.time()
c.toggle_console()
c.reset_timer()
c.kklog('Switching to EEVEE for material baking...')
bpy.context.scene.render.engine = 'BLENDER_EEVEE_NEXT' if bpy.app.version[0] > 3 else 'BLENDER_EEVEE'
c.switch(c.get_body(), 'OBJECT')
c.set_viewport_shading('SOLID')
#enable transparency
bpy.context.scene.render.film_transparent = True
bpy.context.scene.render.filter_size = 0.50
for bake_object in c.get_all_bakeable_objects():
#do a quick check to make sure this object has any materials that can be baked
worth_baking = [m for m in bake_object.material_slots if m.material.get('bake')]
if not worth_baking:
c.kklog(f'Not finalizing object because there were no materials worth baking: {bake_object.name}')
continue
#make sure the collection for this object is enabled in the outliner if it is a clothing item
if bake_object != c.get_body():
original_collection_state = c.get_layer_collection_state(bake_object.users_collection[0].name)
c.show_layer_collection(bake_object.users_collection[0].name, False)
#hide all objects except this one
for obj in [o for o in bpy.context.view_layer.objects if o]:
obj.hide_render = True
#unhide the object to bake (but only if the old baking system is not used)
if not bpy.context.scene.kkbp.old_bake_bool:
bake_object.hide_render = False
camera = setup_camera()
c.switch(bake_object)
setup_geometry_nodes_and_fillerplane(camera)
bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1)
#perform the baking operation
bake_types = []
if scene.bake_light_bool:
bake_types.append('light')
if scene.bake_dark_bool:
bake_types.append('dark')
if scene.bake_norm_bool:
bake_types.append('normal')
for bake_type in bake_types:
bake_pass(folderpath, bake_type)
cleanup()
#restore the original collection state
if bake_object != c.get_body():
c.show_layer_collection(bake_object.users_collection[0].name, original_collection_state)
#disable transparency
bpy.context.scene.render.film_transparent = False
bpy.context.scene.render.filter_size = 1.5
for bake_object in c.get_all_bakeable_objects():
replace_all_baked_materials(folderpath, bake_object)
#show all objects again
for obj in bpy.context.view_layer.objects:
obj.hide_render = False
if scene.use_atlas:
create_material_atlas(folderpath)
#setup the original collection for exporting
# https://blender.stackexchange.com/questions/127403/change-active-collection
#Recursively transverse layer_collection for a particular name
def recurLayerCollection(layerColl, collName):
found = None
if (layerColl.name == collName):
return layerColl
for layer in layerColl.children:
found = recurLayerCollection(layer, collName)
if found:
return found
layer_collection = bpy.context.view_layer.layer_collection
layerColl = recurLayerCollection(layer_collection, c.get_name())
bpy.context.view_layer.active_layer_collection = layerColl
if bpy.app.version[0] != 3:
if not bpy.data.collections[c.get_name()].exporters:
bpy.ops.collection.exporter_add(name="IO_FH_fbx")
bpy.data.collections[c.get_name()].exporters[0].export_properties.object_types = {'EMPTY', 'ARMATURE', 'MESH', 'OTHER'}
bpy.data.collections[c.get_name()].exporters[0].export_properties.use_mesh_modifiers = False
bpy.data.collections[c.get_name()].exporters[0].export_properties.add_leaf_bones = False
bpy.data.collections[c.get_name()].exporters[0].export_properties.bake_anim = False
bpy.data.collections[c.get_name()].exporters[0].export_properties.apply_scale_options = 'FBX_SCALE_ALL'
bpy.data.collections[c.get_name()].exporters[0].export_properties.path_mode = 'COPY'
bpy.data.collections[c.get_name()].exporters[0].export_properties.embed_textures = False
bpy.data.collections[c.get_name()].exporters[0].export_properties.mesh_smooth_type = 'OFF'
bpy.data.collections[c.get_name()].exporters[0].export_properties.filepath = os.path.join(folderpath.replace('baked_files', 'atlas_files'), f'{sanitizeMaterialName(c.get_name())} exported model.fbx')
c.toggle_console()
c.kklog('Finished in ' + str(time.time() - last_step)[0:4] + 's')
c.set_viewport_shading('SOLID')
return {'FINISHED'}
except:
c.kklog('Unknown python error occurred', type = 'error')
c.kklog(traceback.format_exc())
c.set_viewport_shading('SOLID')
self.report({'ERROR'}, traceback.format_exc())
return {"CANCELLED"}

338
exporting/exportprep.py Normal file
View File

@@ -0,0 +1,338 @@
#simplfies bone count using the merge weights function in CATS
import bpy, traceback, time
from .. import common as c
from ..interface.dictionary_en import t
def main(prep_type, simp_type):
try:
#always try to use the atlased model first
body = bpy.data.objects['Body ' + c.get_name() + '.001']
bpy.context.view_layer.objects.active=body
body_name = body.name
armature_name = 'Armature.001'
if not bpy.data.objects[armature_name].data.bones.get('Pelvis'):
#the atlased body has already been modified. Skip.
c.kklog('Model with atlas has already been prepped. Skipping export prep functions...', type='warn')
return False
except:
#fallback to the non-atlased model if the atlased model collection is not visible
body = bpy.data.objects['Body ' + c.get_name()]
bpy.context.view_layer.objects.active=body
body_name = body.name
armature_name = 'Armature'
armature = bpy.data.objects[armature_name]
c.kklog('\nPrepping for export...')
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.select_all(action='DESELECT')
#Assume hidden items are unused and move them to their own collection
c.kklog('Moving unused objects to their own collection...')
no_move_objects = ['Hitboxes ' + c.get_name(), body_name, armature_name]
for object in bpy.context.scene.objects:
try:
move_this_one = object.name not in no_move_objects and 'Widget' not in object.name and object.hide_get()
if move_this_one:
object.hide_set(False)
object.select_set(True)
bpy.context.view_layer.objects.active=object
except:
c.kklog("During export prep, couldn't move object '{}' for some reason...".format(object), type='error')
if bpy.context.selected_objects:
bpy.ops.object.move_to_collection(collection_index=0, is_new=True, new_collection_name='Unused clothing items')
#hide the new collection
try:
bpy.context.scene.view_layers[0].active_layer_collection = bpy.context.view_layer.layer_collection.children['Unused clothing items']
bpy.context.scene.view_layers[0].active_layer_collection.exclude = True
except:
try:
#maybe the collection is in the default Collection collection
bpy.context.scene.view_layers[0].active_layer_collection = bpy.context.view_layer.layer_collection.children['Collection'].children['Unused clothing items']
bpy.context.scene.view_layers[0].active_layer_collection.exclude = True
except:
#maybe the collection is already hidden, or doesn't exist
pass
c.kklog('Removing object outline modifier...')
for ob in bpy.data.objects:
if ob.modifiers.get('Outline Modifier'):
ob.modifiers['Outline Modifier'].show_render = False
ob.modifiers['Outline Modifier'].show_viewport = False
#remove the outline materials because they won't be baked
if ob in [obj for obj in bpy.context.view_layer.objects if obj.type == 'MESH']:
ob.select_set(True)
bpy.context.view_layer.objects.active=ob
bpy.ops.object.material_slot_remove_unused()
bpy.ops.object.select_all(action='DESELECT')
body = bpy.data.objects[body_name]
bpy.context.view_layer.objects.active=body
body.select_set(True)
#Select the armature and make it active
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.select_all(action='DESELECT')
bpy.data.objects[armature_name].hide_set(False)
bpy.data.objects[armature_name].select_set(True)
bpy.context.view_layer.objects.active=bpy.data.objects[armature_name]
bpy.ops.object.mode_set(mode='POSE')
# If exporting for Unreal...
if prep_type == 'E':
armature = bpy.data.objects[armature_name]
bpy.context.view_layer.objects.active = armature
bpy.ops.armature.collection_show_all()
bpy.ops.object.mode_set(mode='EDIT')
#Clear IK, it won't work in unreal
for bone in armature.pose.bones:
for constraint in bone.constraints:
bone.constraints.remove(constraint)
#Rename some bones to make it match Mannequin skeleton
#Not necessary, but allows Unreal automatically recognize and match bone names when retargeting
ue_rename_dict = {
'Hips': 'pelvis',
'Spine': 'spine_01',
'Chest': 'spine_02',
'Upper Chest': 'spine_03',
'Neck': 'neck',
'Head': 'head',
'Left shoulder': 'clavicle_l',
'Right shoulder': 'clavicle_r',
'Left arm': 'upperarm_l',
'Right arm': 'upperarm_r',
'Left elbow': 'lowerarm_l',
'Right elbow': 'lowerarm_r',
'Left wrist': 'hand_l',
'Right wrist': 'hand_r',
'Left leg': 'thigh_l',
'Right leg': 'thigh_r',
'Left knee': 'calf_l',
'Right knee': 'calf_r',
'cf_j_leg03_L': 'foot_l',
'cf_j_leg03_R': 'foot_r',
'Left toe': 'ball_l',
'Right toe': 'ball_r',
}
for bone in ue_rename_dict:
if armature.data.bones.get(bone):
armature.data.bones[bone].name = ue_rename_dict[bone]
bpy.ops.object.mode_set(mode='EDIT')
#Make all the bones on the legs face the same direction, otherwise IK won't work in Unreal
armature.data.edit_bones["calf_l"].tail.z = armature.data.edit_bones["calf_l"].head.z + 0.1
armature.data.edit_bones["calf_l"].head.y += 0.01
armature.data.edit_bones["calf_r"].tail.z = armature.data.edit_bones["calf_r"].head.z + 0.1
armature.data.edit_bones["calf_r"].head.y += 0.01
armature.data.edit_bones["ball_l"].tail.z = armature.data.edit_bones["ball_l"].head.z
armature.data.edit_bones["ball_l"].tail.y = armature.data.edit_bones["ball_l"].head.y - 0.05
armature.data.edit_bones["ball_r"].tail.z = armature.data.edit_bones["ball_r"].head.z
armature.data.edit_bones["ball_r"].tail.y = armature.data.edit_bones["ball_r"].head.y - 0.05
bpy.ops.object.mode_set(mode='POSE')
#If simplifying the bones...
if simp_type in ['A', 'B']:
#show all bones on the armature
bpy.ops.armature.collection_show_all()
bpy.ops.pose.select_all(action='DESELECT')
#Move pupil bones to layer 1
armature = bpy.data.objects[armature_name]
if armature.data.bones.get('Left Eye'):
armature.data.bones['Left Eye'].collections.clear()
armature.data.collections['0'].assign(armature.data.bones.get('Left Eye'))
armature.data.bones['Right Eye'].collections.clear()
armature.data.collections['0'].assign(armature.data.bones.get('Right Eye'))
#Select bones on layer 11
for bone in armature.data.bones:
if bone.collections.get('10'):
bone.select = True
#if very simple selected, also get 3-5,12,17-19
if simp_type in ['A']:
for bone in armature.data.bones:
select_bool = (bone.collections.get('2') or
bone.collections.get('3') or
bone.collections.get('4') or
bone.collections.get('11') or
bone.collections.get('12') or
bone.collections.get('16') or
bone.collections.get('17') or
bone.collections.get('18')
)
if select_bool:
bone.select = True
c.kklog('Using the merge weights function in CATS to simplify bones...')
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.kkbp.cats_merge_weights()
#If exporting for VRM or VRC...
if prep_type in ['A', 'D']:
c.kklog('Editing armature for VRM...')
bpy.context.view_layer.objects.active=armature
bpy.ops.object.mode_set(mode='EDIT')
#Rearrange bones to match CATS output
if armature.data.edit_bones.get('Pelvis'):
armature.data.edit_bones['Pelvis'].parent = None
armature.data.edit_bones['Spine'].parent = armature.data.edit_bones['Pelvis']
armature.data.edit_bones['Hips'].name = 'dont need lol'
armature.data.edit_bones['Pelvis'].name = 'Hips'
armature.data.edit_bones['Left leg'].parent = armature.data.edit_bones['Hips']
armature.data.edit_bones['Right leg'].parent = armature.data.edit_bones['Hips']
armature.data.edit_bones['Left ankle'].parent = armature.data.edit_bones['Left knee']
armature.data.edit_bones['Right ankle'].parent = armature.data.edit_bones['Right knee']
armature.data.edit_bones['Left shoulder'].parent = armature.data.edit_bones['Upper Chest']
armature.data.edit_bones['Right shoulder'].parent = armature.data.edit_bones['Upper Chest']
armature.data.edit_bones.remove(armature.data.edit_bones['dont need lol'])
bpy.ops.object.mode_set(mode='POSE')
bpy.ops.pose.select_all(action='DESELECT')
#Merge specific bones for unity rig autodetect
armature = bpy.data.objects[armature_name]
merge_these = ['cf_j_waist02', 'cf_s_waist01', 'cf_s_hand_L', 'cf_s_hand_R']
#Delete the upper chest for VR chat models, since it apparently causes errors with eye tracking
if prep_type == 'D':
merge_these.append('Upper Chest')
for bone in armature.data.bones:
if bone.name in merge_these:
bone.select = True
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.kkbp.cats_merge_weights()
#If exporting for MMD...
if prep_type == 'C':
#Create the empty
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.empty_add(type='PLAIN_AXES', align='WORLD', location=(0, 0, 0))
empty = bpy.data.objects['Empty']
bpy.ops.object.select_all(action='DESELECT')
armature.parent = empty
bpy.context.view_layer.objects.active = armature
#rename bones to stock
if armature.data.bones.get('Center'):
bpy.ops.kkbp.switcharmature('INVOKE_DEFAULT')
#then rename bones to japanese
pmx_rename_dict = {
'全ての親':'cf_n_height',
'センター':'cf_j_hips',
'上半身':'cf_j_spine01',
'上半身2':'cf_j_spine02',
'上半身3':'cf_j_spine03',
'':'cf_j_neck',
'':'cf_j_head',
'両目':'Eyesx',
'左目':'cf_J_hitomi_tx_L',
'右目':'cf_J_hitomi_tx_R',
'左腕':'cf_j_arm00_L',
'右腕':'cf_j_arm00_R',
'左ひじ':'cf_j_forearm01_L',
'右ひじ':'cf_j_forearm01_R',
'左肩':'cf_j_shoulder_L',
'右肩':'cf_j_shoulder_R',
'左手首':'cf_j_hand_L',
'右手首':'cf_j_hand_R',
'左親指0':'cf_j_thumb01_L',
'左親指1':'cf_j_thumb02_L',
'左親指2':'cf_j_thumb03_L',
'左薬指1':'cf_j_ring01_L',
'左薬指2':'cf_j_ring02_L',
'左薬指3':'cf_j_ring03_L',
'左中指1':'cf_j_middle01_L',
'左中指2':'cf_j_middle02_L',
'左中指3':'cf_j_middle03_L',
'左小指1':'cf_j_little01_L',
'左小指2':'cf_j_little02_L',
'左小指3':'cf_j_little03_L',
'左人指1':'cf_j_index01_L',
'左人指2':'cf_j_index02_L',
'左人指3':'cf_j_index03_L',
'右親指0':'cf_j_thumb01_R',
'右親指1':'cf_j_thumb02_R',
'右親指2':'cf_j_thumb03_R',
'右薬指1':'cf_j_ring01_R',
'右薬指2':'cf_j_ring02_R',
'右薬指3':'cf_j_ring03_R',
'右中指1':'cf_j_middle01_R',
'右中指2':'cf_j_middle02_R',
'右中指3':'cf_j_middle03_R',
'右小指1':'cf_j_little01_R',
'右小指2':'cf_j_little02_R',
'右小指3':'cf_j_little03_R',
'右人指1':'cf_j_index01_R',
'右人指2':'cf_j_index02_R',
'右人指3':'cf_j_index03_R',
'下半身':'cf_j_waist01',
'左足':'cf_j_thigh00_L',
'右足':'cf_j_thigh00_R',
'左ひざ':'cf_j_leg01_L',
'右ひざ':'cf_j_leg01_R',
'左足首':'cf_j_leg03_L',
'右足首':'cf_j_leg03_R',
}
for bone in pmx_rename_dict:
armature.data.bones[pmx_rename_dict[bone]].name = bone
#Rearrange bones to match a random pmx model I found
bpy.ops.object.mode_set(mode='EDIT')
armature.data.edit_bones['左肩'].parent = armature.data.edit_bones['上半身3']
armature.data.edit_bones['右肩'].parent = armature.data.edit_bones['上半身3']
armature.data.edit_bones['左足'].parent = armature.data.edit_bones['下半身']
armature.data.edit_bones['右足'].parent = armature.data.edit_bones['下半身']
#refresh the vertex groups? Bones will act as if they're detached if this isn't done
body.vertex_groups.active=body.vertex_groups['BodyTop']
#combine all objects into one
#create leg IKs?
c.kklog('Using CATS to simplify more bones for MMD...')
#use mmd_tools to convert
bpy.ops.mmd_tools.convert_to_mmd_model()
bpy.ops.object.mode_set(mode='OBJECT')
#only disable the prep button if the non-atlas model has been modified.
#This is because the model with atlas can be regenerated with the bake materials button
return armature_name == 'Armature'
class export_prep(bpy.types.Operator):
bl_idname = "kkbp.exportprep"
bl_label = "Prep for target application"
bl_description = t('export_prep_tt')
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
scene = context.scene.kkbp
prep_type = scene.prep_dropdown
simp_type = scene.simp_dropdown
last_step = time.time()
try:
c.toggle_console()
if main(prep_type, simp_type):
scene.plugin_state = 'prepped'
c.kklog('Finished in ' + str(time.time() - last_step)[0:4] + 's')
c.toggle_console()
return {'FINISHED'}
except:
c.kklog('Unknown python error occurred', type = 'error')
c.kklog(traceback.format_exc())
self.report({'ERROR'}, traceback.format_exc())
return {"CANCELLED"}

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 shotariya
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,126 @@
from collections import defaultdict
from typing import List
from typing import Set
from typing import cast
import bpy
from bpy.props import *
from . import globs
from .type_annotations import CombineListData
from .type_annotations import Scene
from .materials import get_materials
class RefreshObData(bpy.types.Operator):
bl_idname = 'kkbp.refresh_ob_data'
bl_label = 'Combine List'
bl_description = 'Updates the material list'
@staticmethod
def execute(self, context: bpy.types.Context) -> Set[str]:
scn = context.scene
ob_list = [ob for ob in context.visible_objects if
ob.type == 'MESH' and ob.data.uv_layers.active and ob.data.materials]
combine_list_data = self._cache_previous_values(scn)
self._rebuild_items_list(scn, ob_list, combine_list_data)
return {'FINISHED'}
@staticmethod
def _cache_previous_values(scn: Scene) -> CombineListData:
combine_list_data = cast(CombineListData, defaultdict(lambda: {
'used': True,
'mats': defaultdict(lambda: {
'used': True,
'layer': 1,
}),
}))
for item in scn.kkbp_ob_data:
if item.type == globs.CL_OBJECT:
combine_list_data[item.ob]['used'] = item.used
elif item.type == globs.CL_MATERIAL:
mat_data = combine_list_data[item.ob]['mats'][item.mat]
mat_data.update({'used': item.used, 'layer': item.layer})
return combine_list_data
def _rebuild_items_list(self, scn: Scene, ob_list: Set[bpy.types.Object],
combine_list_data: CombineListData) -> None:
scn.kkbp_ob_data.clear()
for ob_id, ob in enumerate(ob_list):
ob_data = combine_list_data[ob]
ob_used = ob_data['used']
self._create_ob_item(scn, ob, ob_id, ob_used)
for mat in get_materials(ob):
if globs.is_blender_3_or_newer and not mat.preview:
mat.preview_ensure()
mat_data = ob_data['mats'][mat]
mat_used = ob_used and mat_data['used']
mat_layer = mat_data['layer']
self._create_mat_item(scn, ob, ob_id, mat, mat_used, mat_layer)
self._create_separator_item(scn)
@staticmethod
def _create_ob_item(scn: Scene, ob: bpy.types.Object, ob_id: int, used: bool) -> None:
item = scn.kkbp_ob_data.add()
item.ob = ob
item.ob_id = ob_id
item.type = 0
item.used = used
@staticmethod
def _create_mat_item(scn: Scene, ob: bpy.types.Object, ob_id: int, mat: bpy.types.Material, used: bool,
layer: int) -> None:
item = scn.kkbp_ob_data.add()
item.ob = ob
item.ob_id = ob_id
item.mat = mat
item.type = 1
item.used = used
item.layer = layer
@staticmethod
def _create_separator_item(scn: Scene) -> None:
item = scn.kkbp_ob_data.add()
item.type = 2
class CombineSwitch(bpy.types.Operator):
bl_idname = 'kkbp.combine_switch'
bl_label = 'Add Item'
bl_description = 'Selected materials will be combined into one texture atlas'
list_id = IntProperty(default=0)
def execute(self, context: bpy.types.Context) -> Set[str]:
scn = context.scene
data = scn.kkbp_ob_data
item = data[self.list_id]
if item.type == globs.CL_OBJECT:
self._switch_ob_state(data, item)
elif item.type == globs.CL_MATERIAL:
self._switch_mat_state(data, item)
return {'FINISHED'}
@staticmethod
def _switch_ob_state(data: List[bpy.types.PropertyGroup], item: bpy.types.PropertyGroup) -> None:
mat_list = [mat for mat in data if mat.ob_id == item.ob_id and mat.type == globs.CL_MATERIAL]
if not mat_list:
return
item.used = not item.used
for mat in mat_list:
mat.used = item.used
@staticmethod
def _switch_mat_state(data: List[bpy.types.PropertyGroup], item: bpy.types.PropertyGroup) -> None:
ob = next((ob for ob in data if ob.ob_id == item.ob_id and ob.type == globs.CL_OBJECT), None)
if not ob:
return
if not item.used:
ob.used = True
item.used = not item.used

View File

@@ -0,0 +1,71 @@
import bpy
from bpy.props import *
from .combiner_ops import *
from .packer import BinPacker
from ... import common as c
class Combiner(bpy.types.Operator):
bl_idname = 'kkbp.combiner'
bl_label = 'Create Atlas'
bl_description = 'Combine materials'
bl_options = {'UNDO', 'INTERNAL'}
def execute(self, context: bpy.types.Context) -> Set[str]:
#from invoke
scn = context.scene
bpy.ops.kkbp.refresh_ob_data()
for index, object in enumerate([o for o in bpy.data.collections[c.get_name() + ' atlas'].all_objects if o.type == 'MESH' and not o.hide_get()]):
#check if this object is worth doing anything with
if not [mat_slot.material for mat_slot in object.material_slots if mat_slot.material.get('simple')]:
continue
set_ob_mode(context.view_layer, scn.kkbp_ob_data)
self.data = get_data(scn.kkbp_ob_data, object)
self.mats_uv = get_mats_uv(scn, self.data)
clear_empty_mats(scn, self.data, self.mats_uv)
get_duplicates(self.mats_uv)
self.structure = get_structure(scn, self.data, self.mats_uv)
#from execute
scn.kkbp_save_path = os.path.join(context.scene.kkbp.import_dir, 'atlas_files')
self.structure = BinPacker(get_size(scn, self.structure)).fit()
size = get_atlas_size(self.structure)
atlas_size = calculate_adjusted_size(scn, size)
if max(atlas_size, default=0) > 20000:
text = 'The output image size of {0}x{1}px is too large'.format(*atlas_size)
c.kklog(text)
self.report({'ERROR'}, text)
return {'FINISHED'}
bake_types = []
if scn.kkbp.bake_light_bool:
bake_types.append('light')
if scn.kkbp.bake_dark_bool:
bake_types.append('dark')
if scn.kkbp.bake_norm_bool:
bake_types.append('normal')
for type in bake_types:
#replace all images
for material in [mat_slot.material for mat_slot in object.material_slots if mat_slot.material.get('simple')]:
image = material.node_tree.nodes['textures'].node_tree.nodes[type].image
if image:
if image.name == 'Template: Placeholder':
image = None
if not image:
continue
else:
material.node_tree.nodes['Image Texture'].image = image
#then run the atlas creation
atlas = get_atlas(scn, self.structure, atlas_size)
comb_mats = get_comb_mats(scn, atlas, self.mats_uv, type, index)
c.print_timer(f'save atlas for {object.name} {type}')
align_uvs(scn, self.structure, atlas.size, size)
bpy.ops.kkbp.refresh_ob_data()
return {'FINISHED'}

View File

@@ -0,0 +1,482 @@
import io
import itertools
import math
import os
import random
import re
from collections import OrderedDict
from collections import defaultdict
from itertools import chain
from typing import Dict
from typing import List
from typing import Sequence
from typing import Set
from typing import Tuple
from typing import Union
from typing import cast
import bpy
import numpy as np
from ... import common as c
from . import globs
from .type_annotations import CombMats
from .type_annotations import Diffuse
from .type_annotations import MatsUV
from .type_annotations import ObMats
from .type_annotations import SMCObData
from .type_annotations import SMCObDataItem
from .type_annotations import Scene
from .type_annotations import Structure
from .type_annotations import StructureItem
from .images import get_packed_file
from .materials import get_diffuse
from .materials import get_shader_type
from .materials import shader_image_nodes
from .materials import sort_materials
from .objects import align_uv
from .objects import get_polys
from .objects import get_uv
try:
from PIL import Image
ImageType = Image.Image
except ImportError:
Image = None
ImageType = None
try:
from PIL import ImageChops
except ImportError:
ImageChops = None
try:
from PIL import ImageFile
except ImportError:
ImageFile = None
if Image:
Image.MAX_IMAGE_PIXELS = None
try:
resampling = Image.LANCZOS
except AttributeError:
resampling = Image.ANTIALIAS
if ImageFile:
ImageFile.LOAD_TRUNCATED_IMAGES = True
atlas_prefix = 'atlas_'
atlas_texture_prefix = 'texture_atlas_'
atlas_material_prefix = 'material_atlas_'
def set_ob_mode(scn: Scene, data: SMCObData) -> None:
scn.objects.active = bpy.data.objects['Body ' + c.get_name() + '.001']
bpy.ops.object.mode_set(mode='OBJECT')
def get_data(data: Sequence[bpy.types.PropertyGroup], object) -> SMCObData:
mats = defaultdict(dict)
if object.type == 'MESH':
for mat in [m for m in object.data.materials if 'Outline ' not in m.name]:
mats[object.name][mat] = 1 #layer, just set to always 1
return mats
def get_mats_uv(scn: Scene, data: SMCObData) -> MatsUV:
mats_uv = defaultdict(lambda: defaultdict(list))
for ob_n, item in data.items():
ob = scn.objects[ob_n]
for idx, polys in get_polys(ob).items():
mat = ob.data.materials[idx]
if mat not in item:
continue
for poly in polys:
mats_uv[ob_n][mat].extend(align_uv(get_uv(ob, poly)))
return mats_uv
def clear_empty_mats(scn: Scene, data: SMCObData, mats_uv: MatsUV) -> None:
for ob_n, item in data.items():
ob = scn.objects[ob_n]
for mat in item:
if mat not in mats_uv[ob_n]:
_delete_material(ob, mat.name)
def _delete_material(ob: bpy.types.Object, mat_name: str) -> None:
ob_mats = ob.data.materials
mat_idx = ob_mats.find(mat_name)
if mat_idx > -1:
ob_mats.pop(index=mat_idx)
def get_duplicates(mats_uv: MatsUV) -> None:
mat_list = list(chain.from_iterable(mats_uv.values()))
sorted_mat_list = sort_materials(mat_list)
for mats in sorted_mat_list:
kkbp_root_mat = mats[0]
for mat in mats[1:]:
mat.kkbp_root_mat = kkbp_root_mat
def get_structure(scn: Scene, data: SMCObData, mats_uv: MatsUV) -> Structure:
structure = defaultdict(lambda: {
'gfx': {
'img_or_color': None,
'size': (),
'uv_size': ()
},
'dup': [],
'ob': [],
'uv': []
})
for ob_n, item in data.items():
ob = scn.objects[ob_n]
for mat in item:
if mat.name not in ob.data.materials:
continue
kkbp_root_mat = mat.kkbp_root_mat or mat
if mat.kkbp_root_mat and mat.name not in structure[kkbp_root_mat]['dup']:
structure[kkbp_root_mat]['dup'].append(mat.name)
if ob.name not in structure[kkbp_root_mat]['ob']:
structure[kkbp_root_mat]['ob'].append(ob.name)
structure[kkbp_root_mat]['uv'].extend(mats_uv[ob_n][mat])
return structure
def clear_duplicates(scn: Scene, data: Structure) -> None:
for item in data.values():
for ob_n in item['ob']:
ob = scn.objects[ob_n]
for dup_name in item['dup']:
_delete_material(ob, dup_name)
def get_size(scn: Scene, data: Structure) -> Dict:
for mat, item in data.items():
img = _get_image(mat)
packed_file = get_packed_file(img)
max_x, max_y = _get_max_uv_coordinates(item['uv'])
item['gfx']['uv_size'] = (np.clip(max_x, 1, 25), np.clip(max_y, 1, 25))
if not scn.kkbp_crop:
item['gfx']['uv_size'] = tuple(math.ceil(x) for x in item['gfx']['uv_size'])
if packed_file:
img_size = _get_image_size(mat, img)
item['gfx']['size'] = _calculate_size(img_size, item['gfx']['uv_size'], scn.kkbp_gaps)
else:
item['gfx']['size'] = (scn.kkbp_diffuse_size + scn.kkbp_gaps,) * 2
return OrderedDict(sorted(data.items(), key=_size_sorting, reverse=True))
def _size_sorting(item: Sequence[StructureItem]) -> Tuple[int, int, int, Union[str, Diffuse, None]]:
gfx = item[1]['gfx']
size_x, size_y = gfx['size']
img_or_color = gfx['img_or_color']
name_or_color = None
if isinstance(img_or_color, tuple):
name_or_color = gfx['img_or_color']
elif isinstance(img_or_color, bpy.types.PackedFile):
name_or_color = img_or_color.id_data.name
return max(size_x, size_y), size_x * size_y, size_x, name_or_color
def _get_image(mat: bpy.types.Material) -> Union[bpy.types.Image, None]:
shader = get_shader_type(mat) if mat else None
node = mat.node_tree.nodes.get(shader_image_nodes.get(shader, ''))
return node.image if node else None
def _get_image_size(mat: bpy.types.Material, img: bpy.types.Image) -> Tuple[int, int]:
return (
(
min(mat.kkbp_size_width, img.size[0]),
min(mat.kkbp_size_height, img.size[1]),
)
if mat.kkbp_size
else cast(Tuple[int, int], img.size)
)
def _get_max_uv_coordinates(uv_loops: List[bpy.types.MeshUVLoop]) -> Tuple[float, float]:
max_x = 1
max_y = 1
for uv in uv_loops:
if not math.isnan(uv.x):
max_x = max(max_x, uv.x)
if not math.isnan(uv.y):
max_y = max(max_y, uv.y)
return max_x, max_y
def _calculate_size(img_size: Tuple[int, int], uv_size: Tuple[int, int], gaps: int) -> Tuple[int, int]:
return cast(Tuple[int, int], tuple(s * uv_s + gaps for s, uv_s in zip(img_size, uv_size)))
def get_atlas_size(structure: Structure) -> Tuple[int, int]:
max_x = 1
max_y = 1
for item in structure.values():
max_x = max(max_x, item['gfx']['fit']['x'] + item['gfx']['size'][0])
max_y = max(max_y, item['gfx']['fit']['y'] + item['gfx']['size'][1])
return int(max_x), int(max_y)
def calculate_adjusted_size(scn: Scene, size: Tuple[int, int]) -> Tuple[int, int]:
if scn.kkbp_size == 'PO2':
return cast(Tuple[int, int], tuple(1 << int(x - 1).bit_length() for x in size))
elif scn.kkbp_size == 'QUAD':
return (int(max(size)),) * 2
return size
def get_atlas(scn: Scene, data: Structure, atlas_size: Tuple[int, int]) -> ImageType:
#create new atlas image
kkbp_size = (scn.kkbp_size_width, scn.kkbp_size_height)
img = Image.new('RGBA', atlas_size)
half_gaps = int(scn.kkbp_gaps / 2)
#for every material in data items,
for mat, item in data.items():
_set_image_or_color(item, mat)
_paste_gfx(scn, item, mat, img, half_gaps)
if scn.kkbp_size in ['CUST', 'STRICTCUST']:
img.thumbnail(kkbp_size, resampling)
if scn.kkbp_size == 'STRICTCUST':
canvas_img = Image.new('RGBA', kkbp_size)
canvas_img.paste(img)
return canvas_img
return img
def _set_image_or_color(item: StructureItem, mat: bpy.types.Material) -> None:
shader = get_shader_type(mat) if mat else None
node_name = shader_image_nodes.get(shader)
item['gfx']['img_or_color'] = get_packed_file(mat.node_tree.nodes.get(node_name).image) if node_name else None
if not item['gfx']['img_or_color']:
item['gfx']['img_or_color'] = get_diffuse(mat)
def _paste_gfx(scn: Scene, item: StructureItem, mat: bpy.types.Material, img: ImageType, half_gaps: int) -> None:
if not item['gfx']['fit']:
return
img.paste(
_get_gfx(scn, mat, item, item['gfx']['img_or_color']),
(int(item['gfx']['fit']['x'] + half_gaps), int(item['gfx']['fit']['y'] + half_gaps))
)
def _get_gfx(scn: Scene, mat: bpy.types.Material, item: StructureItem,
img_or_color: Union[bpy.types.PackedFile, Tuple, None]) -> ImageType:
size = cast(Tuple[int, int], tuple(int(size - scn.kkbp_gaps) for size in item['gfx']['size']))
if not img_or_color:
return Image.new('RGBA', size, (1, 1, 1, 1))
if isinstance(img_or_color, tuple):
return Image.new('RGBA', size, img_or_color)
img = Image.open(io.BytesIO(img_or_color.data))
if img.size != size:
img.resize(size, resampling)
if mat.kkbp_size:
img.thumbnail((mat.kkbp_size_width, mat.kkbp_size_height), resampling)
if max(item['gfx']['uv_size'], default=0) > 1:
img = _get_uv_image(item, img, size)
if mat.kkbp_diffuse:
diffuse_img = Image.new(img.mode, size, get_diffuse(mat))
img = ImageChops.multiply(img, diffuse_img)
return img
def _get_uv_image(item: StructureItem, img: ImageType, size: Tuple[int, int]) -> ImageType:
uv_img = Image.new('RGBA', size)
size_height = size[1]
img_width, img_height = img.size
uv_width, uv_height = (math.ceil(x) for x in item['gfx']['uv_size'])
for h in range(uv_height):
y = size_height - img_height - h * img_height
for w in range(uv_width):
x = w * img_width
uv_img.paste(img, (x, y))
return uv_img
def align_uvs(scn: Scene, data: Structure, atlas_size: Tuple[int, int], size: Tuple[int, int]) -> None:
size_width, size_height = size
scaled_width, scaled_height = _get_scale_factors(atlas_size, size)
margin = scn.kkbp_gaps + (0 if scn.kkbp_pixel_art else 2)
border_margin = int(scn.kkbp_gaps / 2) + (0 if scn.kkbp_pixel_art else 1)
for item in data.values():
gfx_size = item['gfx']['size']
gfx_height = gfx_size[1]
gfx_width_margin, gfx_height_margin = (x - margin for x in gfx_size)
uv_width, uv_height = item['gfx']['uv_size']
x_offset = item['gfx']['fit']['x'] + border_margin
y_offset = item['gfx']['fit']['y'] - border_margin
for uv in item['uv']:
reset_x = uv.x / uv_width * gfx_width_margin
reset_y = uv.y / uv_height * gfx_height_margin - gfx_height
uv_x = (reset_x + x_offset) / size_width
uv_y = (reset_y - y_offset) / size_height
uv.x = uv_x * scaled_width
uv.y = uv_y * scaled_height + 1
def _get_scale_factors(atlas_size: Tuple[int, int], size: Tuple[int, int]) -> Tuple[float, float]:
scaled_factors = tuple(x / y for x, y in zip(size, atlas_size))
if all(factor <= 1 for factor in scaled_factors):
return cast(Tuple[float, float], scaled_factors)
atlas_width, atlas_height = atlas_size
size_width, size_height = size
aspect_ratio = (size_width * atlas_height) / (size_height * atlas_width)
return (1, 1 / aspect_ratio) if aspect_ratio > 1 else (aspect_ratio, 1)
def get_comb_mats(scn: Scene, atlas: ImageType, mats_uv: MatsUV, type: str, atlas_index) -> CombMats:
layers = _get_layers(scn, mats_uv)
path = _save_atlas(scn, atlas, atlas_index, type)
texture = _create_texture(path, atlas_index)
return cast(CombMats, {idx: _create_material(texture, atlas_index, idx) for idx in layers})
def _get_layers(scn: Scene, mats_uv: MatsUV) -> Set[int]:
return {}
def _get_unique_id(scn: Scene) -> str:
existed_ids = set()
_add_its_from_existing_materials(scn, existed_ids)
if not os.path.isdir(scn.kkbp_save_path):
return _generate_random_unique_id(existed_ids)
_add_ids_from_existing_files(scn, existed_ids)
unique_id = next(x for x in itertools.count(start=1) if x not in existed_ids)
return '{:05d}'.format(unique_id)
def _add_its_from_existing_materials(scn: Scene, existed_ids: Set[int]) -> None:
atlas_material_pattern = re.compile(r'{0}(\d+)_\d+'.format(atlas_material_prefix))
for item in scn.kkbp_ob_data:
if item.type != globs.CL_MATERIAL:
continue
match = atlas_material_pattern.fullmatch(item.mat.name)
if match:
existed_ids.add(int(match.group(1)))
def _generate_random_unique_id(existed_ids: Set[int]) -> str:
unused_ids = set(range(10000, 99999)) - existed_ids
return str(random.choice(list(unused_ids)))
def _add_ids_from_existing_files(scn: Scene, existed_ids: Set[int]) -> None:
atlas_file_pattern = re.compile(r'{0}(\d+).png'.format(atlas_prefix))
for file_name in os.listdir(scn.kkbp_save_path):
match = atlas_file_pattern.fullmatch(file_name)
if match:
existed_ids.add(int(match.group(1)))
def _save_atlas(scn: Scene, atlas: ImageType, atlas_index: str, type: str) -> str:
path = os.path.join(scn.kkbp_save_path, f'{atlas_index}_{type}.png')
try:
atlas.save(path)
except:
#atlas folder didn't exist
os.mkdir(scn.kkbp_save_path)
atlas.save(path)
return path
def _create_texture(path: str, unique_id: str) -> bpy.types.Texture:
texture = bpy.data.textures.new('{0}{1}'.format(atlas_texture_prefix, unique_id), 'IMAGE')
image = bpy.data.images.load(path)
texture.image = image
return texture
def _create_material(texture: bpy.types.Texture, unique_id: str, idx: int) -> bpy.types.Material:
mat = bpy.data.materials.new(name='{0}{1}_{2}'.format(atlas_material_prefix, unique_id, idx))
_configure_material(mat, texture)
return mat
def _configure_material(mat: bpy.types.Material, texture: bpy.types.Texture) -> None:
mat['atlas'] = True
mat.blend_method = 'CLIP'
mat.use_backface_culling = True
mat.use_nodes = True
node_texture = mat.node_tree.nodes.new(type='ShaderNodeTexImage')
node_texture.image = texture.image
node_texture.label = 'Material Combiner Texture'
node_texture.location = -300, 300
mat.node_tree.links.new(node_texture.outputs['Color'],
mat.node_tree.nodes['Principled BSDF'].inputs['Base Color'])
mat.node_tree.links.new(node_texture.outputs['Alpha'],
mat.node_tree.nodes['Principled BSDF'].inputs['Alpha'])
def assign_comb_mats(scn: Scene, data: SMCObData, comb_mats: CombMats) -> None:
for ob_n, item in data.items():
ob = scn.objects[ob_n]
ob_materials = ob.data.materials
_assign_mats(item, comb_mats, ob_materials)
_assign_mats_to_polys(item, comb_mats, ob, ob_materials)
def _assign_mats(item: SMCObDataItem, comb_mats: CombMats, ob_materials: ObMats) -> None:
for idx in set(item.values()):
if idx in comb_mats:
ob_materials.append(comb_mats[idx])
def _assign_mats_to_polys(item: SMCObDataItem, comb_mats: CombMats, ob: bpy.types.Object, ob_materials: ObMats) -> None:
for idx, polys in get_polys(ob).items():
if ob_materials[idx] not in item:
continue
mat_name = comb_mats[item[ob_materials[idx]]].name
mat_idx = ob_materials.find(mat_name)
for poly in polys:
poly.material_index = mat_idx
def clear_mats(scn: Scene, mats_uv: MatsUV) -> None:
for ob_n, item in mats_uv.items():
ob = scn.objects[ob_n]
for mat in item:
_delete_material(ob, mat.name)

View File

@@ -0,0 +1,141 @@
import bpy
from bpy.props import *
class CombineList(bpy.types.PropertyGroup):
ob = PointerProperty(
name='Current Object',
type=bpy.types.Object,
)
ob_id = IntProperty(default=0)
mat = PointerProperty(
name='Current Object Material',
type=bpy.types.Material,
)
layer = IntProperty(
name='Material Layers',
description='Materials with the same number will be merged together.'
'\nUse this to create multiple materials linked to the same atlas file',
min=1,
max=99,
step=1,
default=1,
)
used = BoolProperty(default=True)
type = IntProperty(default=0)
def register_smc_types():
bpy.types.Scene.kkbp_ob_data = CollectionProperty(type=CombineList)
bpy.types.Scene.kkbp_ob_data_id = IntProperty(default=0)
bpy.types.Scene.kkbp_list_id = IntProperty(default=0)
bpy.types.Scene.kkbp_size = EnumProperty(
name='Atlas size',
items=[
('PO2', 'Power of 2', 'Combined image size is power of 2'),
('QUAD', 'Quadratic', 'Combined image has same width and height'),
('AUTO', 'Automatic', 'Combined image has minimal size'),
('CUST', 'Custom', 'Combined image has proportionally scaled to fit in custom size'),
('STRICTCUST', 'Strict Custom', 'Combined image has exact custom width and height'),
],
description='Select atlas size',
default='QUAD',
)
bpy.types.Scene.kkbp_size_width = IntProperty(
name='Max width (px)',
description='Select max width for combined image',
min=8,
max=8192,
step=1,
default=4096,
)
bpy.types.Scene.kkbp_size_height = IntProperty(
name='Max height (px)',
description='Select max height for combined image',
min=8,
max=8192,
step=1,
default=4096,
)
bpy.types.Scene.kkbp_crop = BoolProperty(
name='Crop outside images by UV',
description='Crop images by UV if materials UV outside of bounds',
default=True,
)
bpy.types.Scene.kkbp_pixel_art = BoolProperty(
name='Pixel Art / Small Textures',
description='Avoids 1-pixel UV scaling for small textures.'
'\nDisable for larger textures to avoid blending with nearby pixels',
default=False,
)
bpy.types.Scene.kkbp_diffuse_size = IntProperty(
name='Size of materials without image',
description='Select the size of materials that only consist of a color',
min=8,
max=256,
step=1,
default=32,
)
bpy.types.Scene.kkbp_gaps = IntProperty(
name='Size of gaps between images',
description='Select size of gaps between images',
min=0,
max=32,
step=200,
default=0,
options={'HIDDEN'},
)
bpy.types.Scene.kkbp_save_path = StringProperty(
description='Select the directory in which the generated texture atlas will be saved',
default='',
)
bpy.types.Material.kkbp_root_mat = PointerProperty(
name='Material Root',
type=bpy.types.Material,
)
bpy.types.Material.kkbp_diffuse = BoolProperty(
name='Multiply image with diffuse color',
description='Multiply the materials image with its diffuse color.'
'\nINFO: If this color is white the final image will be the same',
default=True,
)
bpy.types.Material.kkbp_size = BoolProperty(
name='Custom image size',
description='Select the max size for this materials image in the texture atlas',
default=False,
)
bpy.types.Material.kkbp_size_width = IntProperty(
name='Max width (px)',
description='Select max width for material image',
min=8,
max=8192,
step=1,
default=2048,
)
bpy.types.Material.kkbp_size_height = IntProperty(
name='Max height (px)',
description='Select max height for material image',
min=8,
max=8192,
step=1,
default=2048,
)
def unregister_smc_types() -> None:
del bpy.types.Scene.kkbp_ob_data
del bpy.types.Scene.kkbp_ob_data_id
del bpy.types.Scene.kkbp_list_id
del bpy.types.Scene.kkbp_size
del bpy.types.Scene.kkbp_size_width
del bpy.types.Scene.kkbp_size_height
del bpy.types.Scene.kkbp_crop
del bpy.types.Scene.kkbp_pixel_art
del bpy.types.Scene.kkbp_diffuse_size
del bpy.types.Scene.kkbp_gaps
del bpy.types.Scene.kkbp_save_path
del bpy.types.Material.kkbp_root_mat
del bpy.types.Material.kkbp_diffuse
del bpy.types.Material.kkbp_size
del bpy.types.Material.kkbp_size_width
del bpy.types.Material.kkbp_size_height

View File

@@ -0,0 +1,30 @@
import os
import subprocess
import sys
from typing import Set
from . import globs
from ... import common as c
from ...interface.dictionary_en import t
import bpy
class InstallPIL(bpy.types.Operator):
bl_idname = 'kkbp.get_pillow'
bl_label = 'Install Pillow'
bl_description = t('pillow_tt')
def execute(self, context: bpy.types.Context) -> Set[str]:
try:
from PIL import Image, ImageChops
except ImportError:
self._install_pillow()
globs.pil_exist = 'restart'
self.report({'INFO'}, 'Installation complete')
return {'FINISHED'}
@staticmethod
def _install_pillow() -> None:
from pip import _internal
_internal.main(['install', 'pip', 'setuptools', 'wheel', '-U', '--user'])
_internal.main(['install', 'Pillow', '--user'])

View File

@@ -0,0 +1,23 @@
import bpy
import sys
import site
sys.path.insert(0, site.getusersitepackages())
try:
from PIL import Image
from PIL import ImageChops
pil_exist = 'yup'
except ImportError:
pil_exist = 'no'
is_blender_2_79_or_older = bpy.app.version < (2, 80, 0)
is_blender_2_80_or_newer = bpy.app.version >= (2, 80, 0)
is_blender_2_92_or_newer = bpy.app.version >= (2, 92, 0)
is_blender_3_or_newer = bpy.app.version >= (3, 0, 0)
smc_pi = False
CL_OBJECT = 0
CL_MATERIAL = 1
CL_SEPARATOR = 2

View File

@@ -0,0 +1,19 @@
import os
from typing import Union
import bpy
def get_image(tex: bpy.types.Texture) -> bpy.types.Image:
return tex.image if tex and hasattr(tex, 'image') and tex.image else None
def get_packed_file(image: Union[bpy.types.Image, None]) -> Union[bpy.types.PackedFile, None]:
if image and not image.packed_file and _get_image_path(image):
image.pack()
return image.packed_file if image and image.packed_file else None
def _get_image_path(img: Union[bpy.types.Image, None]) -> Union[str, None]:
path = os.path.abspath(bpy.path.abspath(img.filepath)) if img else ''
return path if os.path.isfile(path) and not path.lower().endswith(('.spa', '.sph')) else None

View File

@@ -0,0 +1,131 @@
from collections import OrderedDict
from collections import defaultdict
from typing import List
from typing import Union
from typing import ValuesView
from typing import cast
import bpy
import numpy as np
from .images import get_image
from .images import get_packed_file
from .textures import get_texture
from . import globs
from .type_annotations import Diffuse
from .type_annotations import MatDict
from .type_annotations import MatDictItem
shader_types = OrderedDict([
('mmd', {'mmd_shader', 'mmd_base_tex'}),
('mmdCol', {'mmd_shader'}),
('mtoon', {'Mtoon1BaseColorTexture.Image'}),
('mtoonCol', {'Mtoon1Material.Mtoon1Output'}),
('principled', {'Principled BSDF', 'Image Texture'}),
('principledCol', {'Principled BSDF'}),
('diffuse', {'Diffuse BSDF', 'Image Texture'}),
('diffuseCol', {'Diffuse BSDF'}),
('emission', {'Emission', 'Image Texture'}),
('emissionCol', {'Emission'}),
])
shader_image_nodes = {
'mmd': 'mmd_base_tex',
'mtoon': 'Mtoon1BaseColorTexture.Image',
'vrm': 'Image Texture',
'xnalara': 'Image Texture',
'principled': 'Image Texture',
'diffuse': 'Image Texture',
'emission': 'Image Texture',
}
def get_materials(ob: bpy.types.Object) -> List[bpy.types.Material]:
return [mat_slot.material for mat_slot in ob.material_slots if mat_slot.material]
def get_shader_type(mat: bpy.types.Material) -> Union[str, None]:
if not mat.node_tree or not mat.node_tree.nodes:
return
node_tree = mat.node_tree.nodes
if 'Group' in node_tree:
node_tree_name = node_tree['Group'].node_tree.name
if node_tree_name == 'Group':
return 'xnalaraNewCol'
if node_tree_name == 'MToon_unversioned':
return 'vrm' if 'Image Texture' in node_tree else 'vrmCol'
elif node_tree_name == 'XPS Shader' and 'Image Texture' in node_tree:
return 'xnalara'
node_names_set = set(node_tree.keys())
return next(
(
shader_type
for shader_type, node_names in shader_types.items()
if node_names.issubset(node_names_set)
),
None,
)
def sort_materials(mat_list: List[bpy.types.Material]) -> ValuesView[MatDictItem]:
for mat in bpy.data.materials:
mat.kkbp_root_mat = None
mat_dict = cast(MatDict, defaultdict(list))
for mat in mat_list:
node_tree = mat.node_tree if mat else None
packed_file = None
if globs.is_blender_2_79_or_older:
packed_file = get_packed_file(get_image(get_texture(mat)))
elif node_tree:
shader = get_shader_type(mat)
node_name = shader_image_nodes.get(shader)
if node_name:
packed_file = get_packed_file(node_tree.nodes[node_name].image)
if packed_file:
mat_dict[(packed_file, get_diffuse(mat) if mat.kkbp_diffuse else None)].append(mat)
else:
mat_dict[get_diffuse(mat)].append(mat)
return mat_dict.values()
def rgb_to_255_scale(diffuse: Diffuse) -> Diffuse:
rgb = np.empty(shape=(0,), dtype=int)
for c in diffuse:
if c < 0.0:
srgb = 0
elif c < 0.0031308:
srgb = c * 12.92
else:
srgb = 1.055 * pow(c, 1.0 / 2.4) - 0.055
rgb = np.append(rgb, np.clip(round(srgb * 255), 0, 255))
return tuple(rgb)
def get_diffuse(mat: bpy.types.Material) -> Diffuse:
if globs.is_blender_2_79_or_older:
return rgb_to_255_scale(mat.diffuse_color)
shader = get_shader_type(mat) if mat else False
if shader == 'mmdCol':
return rgb_to_255_scale(mat.node_tree.nodes['mmd_shader'].inputs['Diffuse Color'].default_value)
elif shader == 'mtoonCol':
return rgb_to_255_scale(mat.node_tree.nodes['Mtoon1PbrMetallicRoughness.BaseColorFactor'].color)
elif shader == 'vrm':
return rgb_to_255_scale(mat.node_tree.nodes['RGB'].outputs[0].default_value)
elif shader == 'vrmCol':
return rgb_to_255_scale(mat.node_tree.nodes['Group'].inputs[10].default_value)
elif shader == 'diffuseCol':
return rgb_to_255_scale(mat.node_tree.nodes['Diffuse BSDF'].inputs['Color'].default_value)
elif shader == 'xnalaraNewCol':
return rgb_to_255_scale(mat.node_tree.nodes['Group'].inputs['Diffuse'].default_value)
elif shader in ['principledCol', 'xnalaraCol']:
return rgb_to_255_scale(mat.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value)
return 255, 255, 255

View File

@@ -0,0 +1,38 @@
import math
from collections import defaultdict
from typing import List, Dict
import bpy
from mathutils import Vector
def get_polys(ob: bpy.types.Object) -> Dict[int, bpy.types.MeshPolygon]:
polys = defaultdict(list)
for poly in ob.data.polygons:
polys[poly.material_index].append(poly)
return polys
def get_uv(ob: bpy.types.Object, poly: bpy.types.MeshPolygon) -> List[Vector]:
data = ob.data.uv_layers.active.data
return [data[loop_idx].uv if loop_idx < len(data) else Vector((0, 0, 0)) for loop_idx in poly.loop_indices]
def align_uv(face_uv: List[Vector]) -> List[Vector]:
min_x = float('inf')
min_y = float('inf')
for uv in face_uv:
if not math.isnan(uv.x):
min_x = min(min_x, uv.x)
if not math.isnan(uv.y):
min_y = min(min_y, uv.y)
min_x = math.floor(min_x)
min_y = math.floor(min_y)
if min_x != 0 or min_y != 0:
for uv in face_uv:
uv.x -= min_x
uv.y -= min_y
return face_uv

View File

@@ -0,0 +1,96 @@
# Copyright (c) 2011, 2012, 2013, 2014, 2015, 2016 Jake Gordon and contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from typing import Union
from typing import Dict
class BinPacker(object):
def __init__(self, images: Dict) -> None:
self.root = {}
self.bin = images
def fit(self) -> Dict:
self.root = {'x': 0, 'y': 0, 'w': 0, 'h': 0}
if not self.bin:
return self.bin
self.root['w'], self.root['h'] = next(iter(self.bin.values()))['gfx']['size']
for img in self.bin.values():
w, h = img['gfx']['size']
node = self.find_node(self.root, w, h)
img['gfx']['fit'] = self.split_node(node, w, h) if node else self.grow_node(w, h)
return self.bin
def find_node(self, root: Dict, w: int, h: int) -> Union[Dict, None]:
if 'used' in root and root['used']:
return self.find_node(root['right'], w, h) or self.find_node(root['down'], w, h)
elif w <= root['w'] and h <= root['h']:
return root
return None
@staticmethod
def split_node(node: Dict, w: int, h: int) -> Dict:
node['used'] = True
node['down'] = {'x': node['x'], 'y': node['y'] + h, 'w': node['w'], 'h': node['h'] - h}
node['right'] = {'x': node['x'] + w, 'y': node['y'], 'w': node['w'] - w, 'h': h}
return node
def grow_node(self, w: int, h: int) -> Union[Dict, None]:
can_grow_right = h <= self.root['h']
can_grow_down = w <= self.root['w']
should_grow_right = can_grow_right and self.root['h'] >= self.root['w'] + w
should_grow_down = can_grow_down and self.root['w'] >= self.root['h'] + h
if should_grow_right or not should_grow_down and can_grow_right:
return self.grow_right(w, h)
elif should_grow_down or can_grow_down:
return self.grow_down(w, h)
return None
def grow_right(self, w: int, h: int) -> Union[Dict, None]:
self.root = {
'used': True,
'x': 0,
'y': 0,
'w': self.root['w'] + w,
'h': self.root['h'],
'down': self.root,
'right': {'x': self.root['w'], 'y': 0, 'w': w, 'h': self.root['h']}
}
node = self.find_node(self.root, w, h)
return self.split_node(node, w, h) if node else None
def grow_down(self, w: int, h: int) -> Union[Dict, None]:
self.root = {
'used': True,
'x': 0,
'y': 0,
'w': self.root['w'],
'h': self.root['h'] + h,
'down': {'x': 0, 'y': self.root['h'], 'w': self.root['w'], 'h': h},
'right': self.root
}
node = self.find_node(self.root, w, h)
return self.split_node(node, w, h) if node else None

View File

@@ -0,0 +1,13 @@
from typing import Dict
import bpy
def get_texture(mat: bpy.types.Material) -> bpy.types.Texture:
return next((slot.texture for idx, slot in enumerate(mat.texture_slots) if
slot is not None and mat.use_textures[idx]), None)
def get_textures(mat: bpy.types.Material) -> Dict[int, bpy.types.Texture]:
return {idx: slot.texture for idx, slot in enumerate(mat.texture_slots) if
slot is not None and mat.use_textures[idx]}

View File

@@ -0,0 +1,39 @@
from typing import DefaultDict
from typing import Dict
from typing import List
from typing import Tuple
from typing import Union
import bpy
from mathutils import Vector
from . import globs
BlClasses = Union[
bpy.types.Panel, bpy.types.Operator, bpy.types.PropertyGroup, bpy.types.AddonPreferences, bpy.types.UIList
]
# SMCIcons = Union[bpy.utils.previews.ImagePreviewCollection, Dict[str, bpy.types.ImagePreview], None]
Scene = bpy.types.ViewLayer if globs.is_blender_2_80_or_newer else bpy.types.Scene
SMCObDataItem = Dict[bpy.types.Material, int]
SMCObData = Dict[str, SMCObDataItem]
MatsUV = Dict[str, DefaultDict[bpy.types.Material, List[Vector]]]
StructureItem = Dict[str, Union[List, Dict[str, Union[Dict[str, int], Tuple, bpy.types.PackedFile, None]]]]
Structure = Dict[bpy.types.Material, StructureItem]
ObMats = Union[bpy.types.bpy_prop_collection, List[bpy.types.Material]]
CombMats = Dict[int, bpy.types.Material]
MatDictItem = List[bpy.types.Material]
MatDict = DefaultDict[Tuple, MatDictItem]
CombineListDataMat = Dict[str, Union[int, bool]]
CombineListDataItem = Dict[str, Union[Dict[bpy.types.Material, CombineListDataMat], bool]]
CombineListData = Dict[bpy.types.Object, CombineListDataItem]
Diffuse = Union[bpy.types.bpy_prop_collection, Tuple[float, float, float], Tuple[int, int, int]]