feat: Add KoikatsuBlenderPipeline addon with import/export functionality

- Add core addon files (__init__.py, KKPanel.py, preferences.py, common.py)
- Add import pipeline with armature, mesh, and material modification modules
- Add export pipeline with material baking and FBX preparation utilities
- Add material combiner tool for texture atlas generation and optimization
- Add extras utilities for animation, rigging, and asset management
- Add bone orientation data from better_fbx for accurate skeletal structure
- Add comprehensive documentation and wiki with multi-language support (EN, JP, ZH)
- Add animation library retargeting lists for ARP and Rokoko motion capture
- Add Rigify integration scripts for advanced rigging workflows
- Add shader file (KK Shader V8.0.blend) for material rendering
- Add manifest and license files for addon distribution
- Add changelog documenting version history and improvements
- Initialize complete Blender addon project for Koikatsu character import/export
This commit is contained in:
2025-12-06 15:26:19 +08:00
commit 5d9e09e9c3
122 changed files with 22683 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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