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:
BIN
exporting/__pycache__/bakematerials.cpython-311.pyc
Normal file
BIN
exporting/__pycache__/bakematerials.cpython-311.pyc
Normal file
Binary file not shown.
BIN
exporting/__pycache__/exportprep.cpython-311.pyc
Normal file
BIN
exporting/__pycache__/exportprep.cpython-311.pyc
Normal file
Binary file not shown.
657
exporting/bakematerials.py
Normal file
657
exporting/bakematerials.py
Normal 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
338
exporting/exportprep.py
Normal 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"}
|
||||
|
||||
21
exporting/material_combiner/LICENSE
Normal file
21
exporting/material_combiner/LICENSE
Normal 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.
|
||||
Binary file not shown.
BIN
exporting/material_combiner/__pycache__/combiner.cpython-311.pyc
Normal file
BIN
exporting/material_combiner/__pycache__/combiner.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
exporting/material_combiner/__pycache__/globs.cpython-311.pyc
Normal file
BIN
exporting/material_combiner/__pycache__/globs.cpython-311.pyc
Normal file
Binary file not shown.
BIN
exporting/material_combiner/__pycache__/images.cpython-311.pyc
Normal file
BIN
exporting/material_combiner/__pycache__/images.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
exporting/material_combiner/__pycache__/objects.cpython-311.pyc
Normal file
BIN
exporting/material_combiner/__pycache__/objects.cpython-311.pyc
Normal file
Binary file not shown.
BIN
exporting/material_combiner/__pycache__/packer.cpython-311.pyc
Normal file
BIN
exporting/material_combiner/__pycache__/packer.cpython-311.pyc
Normal file
Binary file not shown.
BIN
exporting/material_combiner/__pycache__/textures.cpython-311.pyc
Normal file
BIN
exporting/material_combiner/__pycache__/textures.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
126
exporting/material_combiner/combine_list.py
Normal file
126
exporting/material_combiner/combine_list.py
Normal 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
|
||||
71
exporting/material_combiner/combiner.py
Normal file
71
exporting/material_combiner/combiner.py
Normal 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'}
|
||||
|
||||
482
exporting/material_combiner/combiner_ops.py
Normal file
482
exporting/material_combiner/combiner_ops.py
Normal 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)
|
||||
141
exporting/material_combiner/extend_types.py
Normal file
141
exporting/material_combiner/extend_types.py
Normal 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
|
||||
30
exporting/material_combiner/get_pillow.py
Normal file
30
exporting/material_combiner/get_pillow.py
Normal 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'])
|
||||
23
exporting/material_combiner/globs.py
Normal file
23
exporting/material_combiner/globs.py
Normal 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
|
||||
19
exporting/material_combiner/images.py
Normal file
19
exporting/material_combiner/images.py
Normal 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
|
||||
131
exporting/material_combiner/materials.py
Normal file
131
exporting/material_combiner/materials.py
Normal 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
|
||||
38
exporting/material_combiner/objects.py
Normal file
38
exporting/material_combiner/objects.py
Normal 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
|
||||
96
exporting/material_combiner/packer.py
Normal file
96
exporting/material_combiner/packer.py
Normal 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
|
||||
13
exporting/material_combiner/textures.py
Normal file
13
exporting/material_combiner/textures.py
Normal 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]}
|
||||
39
exporting/material_combiner/type_annotations.py
Normal file
39
exporting/material_combiner/type_annotations.py
Normal 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]]
|
||||
Reference in New Issue
Block a user