Files
KKBP_Importer/exporting/material_combiner/combiner_ops.py
小煜 5d9e09e9c3 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
2025-12-06 15:26:19 +08:00

483 lines
16 KiB
Python

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)