From 686bc0bda10fd5e47e650a8844e8feed6cda3a8b Mon Sep 17 00:00:00 2001 From: Yusarina Date: Tue, 4 Feb 2025 04:06:34 +0000 Subject: [PATCH] Added back texture Atlas - Now working with Alpha 2. - Did some changed but it should still work, did some basic testing. - Do want to make further changes and make the system better where possible. --- core/common.py | 46 ++++- core/packer/rectangle_packer.py | 152 ++++++++++++++++ core/properties.py | 100 ++++++++++- functions/atlas_materials.py | 290 ++++++++++++++++++++++++++++++ resources/translations/en_US.json | 20 +++ resources/translations/ja_JP.json | 20 +++ resources/translations/ko_KR.json | 20 +++ ui/atlas_materials_panel.py | 188 +++++++++++++++++++ 8 files changed, 834 insertions(+), 2 deletions(-) create mode 100644 core/packer/rectangle_packer.py create mode 100644 functions/atlas_materials.py create mode 100644 ui/atlas_materials_panel.py diff --git a/core/common.py b/core/common.py index c232856..45aecab 100644 --- a/core/common.py +++ b/core/common.py @@ -10,7 +10,7 @@ import numpy.typing as npt from typing import Optional, Tuple, List, Set, Dict, Any, Generator, Callable, Union, Type from mathutils import Vector, Matrix -from bpy.types import (Context, Object, Modifier, EditBone, Operator, +from bpy.types import (Context, Object, Modifier, EditBone, Operator, Material, VertexGroup, ShapeKey, Bone, Mesh, Armature, PropertyGroup) from functools import lru_cache from bpy.props import PointerProperty, IntProperty, StringProperty @@ -19,6 +19,50 @@ from ..core.logging_setup import logger from ..core.translations import t from ..core.dictionaries import bone_names +class SceneMatClass(PropertyGroup): + mat: PointerProperty(type=Material) + +register_class(SceneMatClass) + +class MaterialListBool: + #For the love that is holy do not ever touch these. If this was java I would make these private + #They should only be accessed via context.scene.texture_atlas_Has_Mat_List_Shown + #This is so we know if the materials are up to date. messing with these variables directly will make the thing blow up. + + #The only exception to this is the ExpandSection_Materials operator which populates this with new data once the materials have changed and need reloading. + old_list: dict[str,list[Material]] = {} + bool_material_list_expand: dict[str,bool] = {} + + def set_bool(self, value: bool) -> None: + MaterialListBool.bool_material_list_expand[bpy.context.scene.name] = value + if value == False: + MaterialListBool.old_list[bpy.context.scene.name] = [] + + def get_bool(self) -> bool: + newlist: list[Material] = [] + for obj in bpy.context.scene.objects: + if len(obj.material_slots)>0: + for mat_slot in obj.material_slots: + if mat_slot.material: + if mat_slot.material not in newlist: + newlist.append(mat_slot.material) + + still_the_same: bool = True + if bpy.context.scene.name in MaterialListBool.old_list: + for item in newlist: + if item not in MaterialListBool.old_list[bpy.context.scene.name]: + still_the_same = False + break + for item in MaterialListBool.old_list[bpy.context.scene.name]: + if item not in newlist: + still_the_same = False + break + else: + still_the_same = False + MaterialListBool.bool_material_list_expand[bpy.context.scene.name] = still_the_same + + return MaterialListBool.bool_material_list_expand[bpy.context.scene.name] + class ProgressTracker: """Universal progress tracking for Avatar Toolkit operations""" diff --git a/core/packer/rectangle_packer.py b/core/packer/rectangle_packer.py new file mode 100644 index 0000000..f4fe5ad --- /dev/null +++ b/core/packer/rectangle_packer.py @@ -0,0 +1,152 @@ +# thank you https://stackoverflow.com/a/71432759 +from __future__ import annotations + + +from typing import Optional +from bpy.types import Image, Material + + +# 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. + +class Rectangle_Obj: + x: int = 0 + y: int = 0 + w: int = 0 + h: int = 0 + down: Rectangle_Obj = None + used: bool = False + right: Rectangle_Obj = None + + def __init__(self, x:int, y:int, w:int, h:int, down=None, used =False, right=None): + self.x = x + self.y = y + self.w = w + self.h = h + self.down = down + self.used = used + self.right = right + + def split(self, w, h) -> Rectangle_Obj: + self.used = True + self.down = Rectangle_Obj(x=self.x, y=self.y + h, w=self.w, h=self.h - h) + self.right = Rectangle_Obj(x=self.x + w, y=self.y, w=self.w - w, h=h) + return self + + def find(self, w, h) -> Optional[Rectangle_Obj]: + if self.used: + return self.right.find(w, h) or self.down.find(w, h) + elif (w <= self.w) and (h <= self.h): + return self + return None + +class MaterialImageList: + albedo: Image + normal: Image + emission: Image + ambient_occlusion: Image + height: Image + roughness: Image + fit: Rectangle_Obj + material: Material + + def __init__(self): + pass + + x: int = 0 + y: int = 0 + w: int = 0 + h: int = 0 + + + + + +class BinPacker(object): + root: Rectangle_Obj + bin: list[MaterialImageList] = [] + def __init__(self, structure: list[MaterialImageList]): + self.root = None + self.bin = structure + + def fit(self): + structure = self.bin + structure_len = len(self.bin) + w: int = 0 + h: int = 0 + if structure_len > 0: + w = structure[0].w + h = structure[0].h + self.root = Rectangle_Obj(x=0, y=0, w=w, h=h) + for img in structure: + w = img.w + h = img.h + node = self.root.find(w, h) + if node: + img.fit = node.split(w, h) + else: + img.fit = self.grow_node(w, h) + return structure + + def grow_node(self, w, h) -> Optional[Rectangle_Obj]: + 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: + return self.grow_right(w, h) + elif should_grow_down: + return self.grow_down(w, h) + elif can_grow_right: + return self.grow_right(w, h) + elif can_grow_down: + return self.grow_down(w, h) + return None + + def grow_right(self, w, h) -> Optional[Rectangle_Obj]: + self.root = Rectangle_Obj( + used=True, + x=0, + y=0, + w=self.root.w + w, + h=self.root.h, + down=self.root, + right=Rectangle_Obj(x=self.root.w, y=0, w=w, h=self.root.h)) + node = self.root.find(w, h) + if node: + return node.split(w, h) + return None + + def grow_down(self, w, h) -> Optional[Rectangle_Obj]: + self.root = Rectangle_Obj( + used=True, + x=0, + y=0, + w=self.root.w, + h=self.root.h + h, + down=Rectangle_Obj(x=0, y=self.root.h, w=self.root.w, h=h), + right=self.root + ) + node = self.root.find(w, h) + if node: + return node.split(w, h) + return None \ No newline at end of file diff --git a/core/properties.py b/core/properties.py index 2ab83e6..969f1b4 100644 --- a/core/properties.py +++ b/core/properties.py @@ -14,7 +14,7 @@ from .logging_setup import logger from .translations import t, get_languages_list, update_language from .addon_preferences import get_preference, save_preference from .updater import get_version_list -from .common import get_armature_list, get_active_armature, get_all_meshes +from .common import get_armature_list, get_active_armature, get_all_meshes, SceneMatClass from ..functions.visemes import VisemePreview from ..functions.eye_tracking import set_rotation @@ -367,6 +367,104 @@ class AvatarToolkitSceneProperties(PropertyGroup): default=True ) + material_search_filter: StringProperty( + name=t("TextureAtlas.search_materials"), + description=t("TextureAtlas.search_materials_desc"), + default="" + ) + + def get_texture_node_list(self: Material, context: Context) -> list[tuple]: + if self.use_nodes: + Object.Enum = [((i.image.name if i.image else i.name+"_image"), + (i.image.name if i.image else "node with no image..."), + (i.image.name if i.image else i.name),index+1) + for index,i in enumerate(self.node_tree.nodes) + if i.bl_idname == "ShaderNodeTexImage"] + if not len(Object.Enum): + Object.Enum = [(t("TextureAtlas.error.label"), + t("TextureAtlas.no_images_error.desc"), + t("TextureAtlas.error.label"), 0)] + else: + Object.Enum = [(t("TextureAtlas.error.label"), + t("TextureAtlas.no_nodes_error.desc"), + t("TextureAtlas.error.label"), 0)] + Object.Enum.append((t("TextureAtlas.none.label"), + t("TextureAtlas.none.label"), + t("TextureAtlas.none.label"), 0)) + return Object.Enum + + Material.texture_atlas_albedo = EnumProperty( + name=t("TextureAtlas.albedo"), + description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.albedo").lower()), + default=0, + items=get_texture_node_list + ) + + Material.texture_atlas_normal = EnumProperty( + name=t("TextureAtlas.normal"), + description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.normal").lower()), + default=0, + items=get_texture_node_list + ) + + Material.texture_atlas_emission = EnumProperty( + name=t("TextureAtlas.emission"), + description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.emission").lower()), + default=0, + items=get_texture_node_list + ) + + Material.texture_atlas_ambient_occlusion = EnumProperty( + name=t("TextureAtlas.ambient_occlusion"), + description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.ambient_occlusion").lower()), + default=0, + items=get_texture_node_list + ) + + Material.texture_atlas_height = EnumProperty( + name=t("TextureAtlas.height"), + description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.height").lower()), + default=0, + items=get_texture_node_list + ) + + Material.texture_atlas_roughness = EnumProperty( + name=t("TextureAtlas.roughness"), + description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.roughness").lower()), + default=0, + items=get_texture_node_list + ) + + Material.include_in_atlas = BoolProperty( + name=t("TextureAtlas.include_in_atlas"), + description=t("TextureAtlas.include_in_atlas_desc"), + default=False + ) + + Material.material_expanded = BoolProperty( + name=t("TextureAtlas.material_expanded"), + description=t("TextureAtlas.material_expanded_desc"), + default=False + ) + + texture_atlas_Has_Mat_List_Shown: BoolProperty( + name=t("TextureAtlas.list_shown"), + description=t("TextureAtlas.list_shown_desc"), + default=False + ) + + texture_atlas_material_index: IntProperty( + default=-1, + get=lambda self: -1, + set=lambda self, context: None + ) + + materials: CollectionProperty( + type=SceneMatClass + ) + + + def register() -> None: """Register the Avatar Toolkit property group""" logger.info("Registering Avatar Toolkit properties") diff --git a/functions/atlas_materials.py b/functions/atlas_materials.py new file mode 100644 index 0000000..ee807cf --- /dev/null +++ b/functions/atlas_materials.py @@ -0,0 +1,290 @@ +from pathlib import Path +import numpy +import bpy +import os +from typing import List, Optional +from bpy.types import Material, Operator, Context, Object, Image, Mesh, MeshUVLoopLayer, Float2AttributeValue, ShaderNodeTexImage, ShaderNodeBsdfPrincipled, ShaderNodeNormalMap +from ..core.common import SceneMatClass, MaterialListBool, ProgressTracker +from ..core.packer.rectangle_packer import MaterialImageList, BinPacker +from ..core.translations import t +from ..core.logging_setup import logger + +class MaterialImageList: + def __init__(self): + self.albedo: Image = None + self.normal: Image = None + self.emission: Image = None + self.ambient_occlusion: Image = None + self.height: Image = None + self.roughness: Image = None + self.material: Material = None + self.parent_mesh: Object = None + self.w: int = 0 + self.h: int = 0 + self.fit = None + +def scale_images_to_largest(images: List[Image]) -> tuple[int, int]: + x: int = 0 + y: int = 0 + + valid_images = [img for img in images if img and img.has_data] + + if not valid_images: + return 0, 0 + + for image in valid_images: + x = max(x, image.size[0]) + y = max(y, image.size[1]) + + for image in valid_images: + image.scale(width=int(x), height=int(y)) + + return x, y + +def MaterialImageList_to_Image_list(classitem: MaterialImageList) -> List[Image]: + return [ + classitem.albedo, + classitem.normal, + classitem.emission, + classitem.ambient_occlusion, + classitem.height, + classitem.roughness + ] + +def get_material_images_from_scene(context: Context) -> list[MaterialImageList]: + material_image_list: list[MaterialImageList] = [] + + with ProgressTracker(context, len(context.scene.objects), "Processing Materials") as progress: + for obj in context.scene.objects: + if obj.type == 'MESH': + for mat_slot in obj.material_slots: + # Only process materials that are selected for atlas + if mat_slot.material and mat_slot.material.include_in_atlas is True: + new_mat_image_item = MaterialImageList() + try: + new_mat_image_item.albedo = bpy.data.images[mat_slot.material.texture_atlas_albedo] + except Exception: + name = mat_slot.material.name + "_albedo_replacement" + if name in bpy.data.images: + bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True) + new_mat_image_item.albedo = bpy.data.images.new(name=name, width=32, height=32, alpha=True) + new_mat_image_item.albedo.pixels[:] = numpy.tile(numpy.array([0.0,0.0,0.0,1.0]), 32*32) + try: + new_mat_image_item.normal = bpy.data.images[mat_slot.material.texture_atlas_normal] + except Exception: + name = mat_slot.material.name + "_normal_replacement" + if name in bpy.data.images: + bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True) + new_mat_image_item.normal = bpy.data.images.new(name=name, width=32, height=32, alpha=True) + new_mat_image_item.normal.pixels[:] = numpy.tile(numpy.array([0.5,0.5,1.0,1.0]), 32*32) + try: + new_mat_image_item.emission = bpy.data.images[mat_slot.material.texture_atlas_emission] + except Exception: + name = mat_slot.material.name + "_emission_replacement" + if name in bpy.data.images: + bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True) + new_mat_image_item.emission = bpy.data.images.new(name=name, width=32, height=32, alpha=True) + new_mat_image_item.emission.pixels[:] = numpy.tile(numpy.array([0.0,0.0,0.0,1.0]), 32*32) + try: + new_mat_image_item.ambient_occlusion = bpy.data.images[mat_slot.material.texture_atlas_ambient_occlusion] + except Exception: + name = mat_slot.material.name + "_ambient_occlusion_replacement" + if name in bpy.data.images: + bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True) + new_mat_image_item.ambient_occlusion = bpy.data.images.new(name=name, width=32, height=32, alpha=True) + new_mat_image_item.ambient_occlusion.pixels[:] = numpy.tile(numpy.array([1.0,1.0,1.0,1.0]), 32*32) + try: + new_mat_image_item.height = bpy.data.images[mat_slot.material.texture_atlas_height] + except Exception: + name = mat_slot.material.name + "_height_replacement" + if name in bpy.data.images: + bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True) + new_mat_image_item.height = bpy.data.images.new(name=name, width=32, height=32, alpha=True) + new_mat_image_item.height.pixels[:] = numpy.tile(numpy.array([0.5,0.5,0.5,1.0]), 32*32) + try: + new_mat_image_item.roughness = bpy.data.images[mat_slot.material.texture_atlas_roughness] + except Exception: + name = mat_slot.material.name + "_roughness_replacement" + if name in bpy.data.images: + bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True) + new_mat_image_item.roughness = bpy.data.images.new(name=name, width=32, height=32, alpha=True) + new_mat_image_item.roughness.pixels[:] = numpy.tile(numpy.array([1.0,1.0,1.0,0.0]), 32*32) + + new_mat_image_item.material = mat_slot.material + new_mat_image_item.parent_mesh = obj + material_image_list.append(new_mat_image_item) + + progress.step(f"Processed {obj.name}") + + return material_image_list + +def prep_images_in_scene(context: Context) -> List[MaterialImageList]: + preped_images = get_material_images_from_scene(context) + + with ProgressTracker(context, len(preped_images), "Preparing Images") as progress: + for MaterialImageClass in preped_images: + ImageList = MaterialImageList_to_Image_list(MaterialImageClass) + MaterialImageClass.w, MaterialImageClass.h = scale_images_to_largest(ImageList) + progress.step(f"Scaled images for {MaterialImageClass.material.name}") + + return preped_images + +class AvatarToolKit_OT_AtlasMaterials(Operator): + bl_idname = "avatar_toolkit.atlas_materials" + bl_label = t("TextureAtlas.atlas_materials") + bl_description = t("TextureAtlas.atlas_materials_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + return context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown + + def execute(self, context: Context) -> set: + try: + selected_materials = [m for m in prep_images_in_scene(context) + if m.material and m.material.include_in_atlas] + + if not selected_materials: + self.report({'WARNING'}, t("TextureAtlas.no_materials_selected")) + return {'CANCELLED'} + + logger.info("Starting material atlas creation") + + packer = BinPacker(selected_materials) + mat_images = packer.fit() + + size = [ + max([matimg.fit.w + matimg.albedo.size[0] for matimg in mat_images]), + max([matimg.fit.h + matimg.albedo.size[1] for matimg in mat_images]) + ] + + atlased_mat = MaterialImageList() + + # UV Remapping + with ProgressTracker(context, len(bpy.data.objects), "Remapping UVs") as progress: + for mat in mat_images: + x, y = int(mat.fit.x), int(mat.fit.y) + w, h = int(mat.albedo.size[0]), int(mat.albedo.size[1]) + + for obj in bpy.data.objects: + if obj.type == 'MESH': + mesh = obj.data + for layer in mesh.polygons: + if (obj.material_slots[layer.material_index].material and + obj.material_slots[layer.material_index].material == mat.material): + for loop_idx in layer.loop_indices: + for layer_loops in mesh.uv_layers: + uv_item = layer_loops.uv[loop_idx] + uv_item.vector.x = (uv_item.vector.x*(w/size[0]))+(x/size[0]) + uv_item.vector.y = (uv_item.vector.y*(h/size[1]))+(y/size[1]) + progress.step(f"Processed UVs for {obj.name}") + + # Create atlas textures + texture_types = ["albedo", "normal", "emission", "ambient_occlusion", "height", "roughness"] + + with ProgressTracker(context, len(texture_types), "Creating Atlas Textures") as progress: + for type_name in texture_types: + new_image_name = f"Atlas_{type_name}_{context.scene.name}_{Path(bpy.data.filepath).stem}" + logger.debug(f"Processing {type_name} atlas image") + + if new_image_name in bpy.data.images: + bpy.data.images.remove(bpy.data.images[new_image_name]) + + canvas = bpy.data.images.new(name=new_image_name, width=int(size[0]), + height=int(size[1]), alpha=True) + c_w = canvas.size[0] + canvas_pixels = list(canvas.pixels[:]) + + for mat in mat_images: + x, y = int(mat.fit.x), int(mat.fit.y) + w, h = int(mat.albedo.size[0]), int(mat.albedo.size[1]) + image_var = getattr(mat, type_name) + image_pixels = list(image_var.pixels[:]) + + for k in range(h): + for i in range(w): + for channel in range(4): + canvas_pixels[int((((k+y)*c_w)+(i+x))*4)+channel] = \ + image_pixels[int(((k*w)+i)*4)+channel] + + canvas.pixels[:] = canvas_pixels[:] + canvas.save(filepath=os.path.join(os.path.dirname(bpy.data.filepath), + new_image_name+".png")) + setattr(atlased_mat, type_name, canvas) + progress.step(f"Created {type_name} atlas") + + # Create material nodes + atlased_mat.material = bpy.data.materials.new( + name=f"Atlas_Final_{context.scene.name}_{Path(bpy.data.filepath).stem}") + atlased_mat.material.use_nodes = True + atlased_mat.material.node_tree.nodes.clear() + + principled_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeBsdfPrincipled") + principled_node.location.x = 7.29706335067749 + principled_node.location.y = 298.918212890625 + + output_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeOutputMaterial") + output_node.location.x = 297.29705810546875 + output_node.location.y = 298.918212890625 + + albedo_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage") + albedo_node.location.x = -588.6177978515625 + albedo_node.location.y = 414.1948547363281 + albedo_node.image = atlased_mat.albedo + + emission_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage") + emission_node.location.x = -588.6177978515625 + emission_node.location.y = -173.9259033203125 + emission_node.image = atlased_mat.emission + + normal_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage") + normal_node.location.x = -941.4189453125 + normal_node.location.y = -20.8391780853271 + normal_node.image = atlased_mat.normal + + normal_map_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeNormalMap") + normal_map_node.location.x = -545.550537109375 + normal_map_node.location.y = -0.7543716430664062 + + roughness_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage") + roughness_node.location.x = -592.1703491210938 + roughness_node.location.y = 206.74075317382812 + roughness_node.image = atlased_mat.roughness + + ambient_occlusion_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage") + ambient_occlusion_node.location.x = -906.4371337890625 + ambient_occlusion_node.location.y = -389.9602355957031 + ambient_occlusion_node.image = atlased_mat.ambient_occlusion + + height_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage") + height_node.location.x = -1222.383056640625 + height_node.location.y = -375.48406982421875 + height_node.image = atlased_mat.height + + atlased_mat.material.node_tree.links.new(principled_node.inputs["Base Color"], albedo_node.outputs["Color"]) + atlased_mat.material.node_tree.links.new(principled_node.inputs["Metallic"], roughness_node.outputs["Alpha"]) + atlased_mat.material.node_tree.links.new(principled_node.inputs["Roughness"], roughness_node.outputs["Color"]) + atlased_mat.material.node_tree.links.new(principled_node.inputs["Alpha"], albedo_node.outputs["Alpha"]) + atlased_mat.material.node_tree.links.new(principled_node.inputs["Normal"], normal_map_node.outputs["Normal"]) + atlased_mat.material.node_tree.links.new(principled_node.inputs["Emission Color"], emission_node.outputs["Color"]) + atlased_mat.material.node_tree.links.new(output_node.inputs["Surface"], principled_node.outputs["BSDF"]) + atlased_mat.material.node_tree.links.new(normal_map_node.inputs["Color"], normal_node.outputs["Color"]) + + # Update materials + with ProgressTracker(context, len(context.scene.objects), "Updating Materials") as progress: + for obj in context.scene.objects: + if obj.type == 'MESH': + mesh = obj.data + for i, mat_slot in enumerate(obj.material_slots): + if mat_slot.material and mat_slot.material.include_in_atlas: + mesh.materials[i] = atlased_mat.material + progress.step(f"Updated materials for {obj.name}") + + logger.info("Material atlas creation completed successfully") + self.report({'INFO'}, t("TextureAtlas.atlas_completed")) + return {"FINISHED"} + + except Exception as e: + logger.error(f"Error creating material atlas: {str(e)}", exc_info=True) + self.report({'ERROR'}, t("TextureAtlas.atlas_error")) + raise e diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index dc60653..5f23be2 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -380,6 +380,26 @@ "MergeArmature.cleanup_shape_keys": "Clean Shape Keys", "MergeArmature.cleanup_shape_keys_desc": "Remove unused shape keys", + "TextureAtlas.atlas_completed": "Texture atlas creation completed", + "TextureAtlas.atlas_error": "An error occurred during texture atlas creation", + "TextureAtlas.atlas_materials": "Atlas Materials", + "TextureAtlas.atlas_materials_desc": "Atlas materials to optimize the model", + "TextureAtlas.label": "Texture Atlasing", + "TextureAtlas.loaded_list": "Loaded Texture Atlas Material List", + "TextureAtlas.material_list_label": "Texture Atlas Material List Material", + "TextureAtlas.reload_list": "Reload Texture Atlas Material List", + "TextureAtlas.error.label": "ERROR", + "TextureAtlas.none.label": "None", + "TextureAtlas.no_nodes_error.desc": "THIS MATERIAL DOES NOT USE NODES!", + "TextureAtlas.no_images_error.desc": "THIS MATERIAL HAS NO IMAGES!", + "TextureAtlas.texture_use_atlas.desc": "The texture that will be used for the {name} map atlas", + "TextureAtlas.albedo": "Albedo", + "TextureAtlas.normal": "Normal", + "TextureAtlas.emission": "Emission", + "TextureAtlas.ambient_occlusion": "Ambient Occlusion", + "TextureAtlas.height": "Height", + "TextureAtlas.roughness": "Roughness", + "Settings.label": "Settings", "Settings.language": "Language", "Settings.language_desc": "Select interface language", diff --git a/resources/translations/ja_JP.json b/resources/translations/ja_JP.json index 2a7f445..d66ff9b 100644 --- a/resources/translations/ja_JP.json +++ b/resources/translations/ja_JP.json @@ -380,6 +380,26 @@ "MergeArmature.cleanup_shape_keys": "シェイプキーをクリーン", "MergeArmature.cleanup_shape_keys_desc": "未使用のシェイプキーを削除", + "TextureAtlas.atlas_completed": "テクスチャアトラスの作成が完了しました", + "TextureAtlas.atlas_error": "テクスチャアトラスの作成中にエラーが発生しました", + "TextureAtlas.atlas_materials": "マテリアルをアトラス化", + "TextureAtlas.atlas_materials_desc": "モデルを最適化するためにマテリアルをアトラス化", + "TextureAtlas.label": "テクスチャアトラス化", + "TextureAtlas.loaded_list": "テクスチャアトラスマテリアルリストを読み込み済み", + "TextureAtlas.material_list_label": "テクスチャアトラスマテリアルリスト", + "TextureAtlas.reload_list": "テクスチャアトラスマテリアルリストを再読み込み", + "TextureAtlas.error.label": "エラー", + "TextureAtlas.none.label": "なし", + "TextureAtlas.no_nodes_error.desc": "このマテリアルはノードを使用していません!", + "TextureAtlas.no_images_error.desc": "このマテリアルには画像がありません!", + "TextureAtlas.texture_use_atlas.desc": "{name}マップアトラスに使用されるテクスチャ", + "TextureAtlas.albedo": "アルベド", + "TextureAtlas.normal": "法線", + "TextureAtlas.emission": "発光", + "TextureAtlas.ambient_occlusion": "アンビエントオクルージョン", + "TextureAtlas.height": "高さ", + "TextureAtlas.roughness": "ラフネス", + "Settings.label": "設定", "Settings.language": "言語", "Settings.language_desc": "インターフェース言語を選択", diff --git a/resources/translations/ko_KR.json b/resources/translations/ko_KR.json index 28ce1ed..81b09bf 100644 --- a/resources/translations/ko_KR.json +++ b/resources/translations/ko_KR.json @@ -379,6 +379,26 @@ "MergeArmature.remove_zero_weights_desc": "가중치가 없는 버텍스 그룹 제거", "MergeArmature.cleanup_shape_keys": "쉐이프 키 정리", "MergeArmature.cleanup_shape_keys_desc": "미사용 쉐이프 키 제거", + + "TextureAtlas.atlas_completed": "텍스처 아틀라스 생성이 완료되었습니다", + "TextureAtlas.atlas_error": "텍스처 아틀라스 생성 중 오류가 발생했습니다", + "TextureAtlas.atlas_materials": "재질 아틀라스화", + "TextureAtlas.atlas_materials_desc": "모델을 최적화하기 위해 재질을 아틀라스화", + "TextureAtlas.label": "텍스처 아틀라스화", + "TextureAtlas.loaded_list": "텍스처 아틀라스 재질 목록 로드됨", + "TextureAtlas.material_list_label": "텍스처 아틀라스 재질 목록", + "TextureAtlas.reload_list": "텍스처 아틀라스 재질 목록 새로고침", + "TextureAtlas.error.label": "오류", + "TextureAtlas.none.label": "없음", + "TextureAtlas.no_nodes_error.desc": "이 재질은 노드를 사용하지 않습니다!", + "TextureAtlas.no_images_error.desc": "이 재질에는 이미지가 없습니다!", + "TextureAtlas.texture_use_atlas.desc": "{name} 맵 아틀라스에 사용될 텍스처", + "TextureAtlas.albedo": "알베도", + "TextureAtlas.normal": "노말", + "TextureAtlas.emission": "이미션", + "TextureAtlas.ambient_occlusion": "앰비언트 오클루전", + "TextureAtlas.height": "높이", + "TextureAtlas.roughness": "거칠기", "Settings.label": "설정", "Settings.language": "언어", diff --git a/ui/atlas_materials_panel.py b/ui/atlas_materials_panel.py new file mode 100644 index 0000000..2d9b70c --- /dev/null +++ b/ui/atlas_materials_panel.py @@ -0,0 +1,188 @@ +from bpy.types import UIList, Panel, UILayout, Object, Context, Material, Operator +import bpy +from math import sqrt +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from ..core.common import SceneMatClass, MaterialListBool, get_active_armature +from ..functions.atlas_materials import AvatarToolKit_OT_AtlasMaterials +from ..core.translations import t + +class AvatarToolKit_OT_SelectAllMaterials(Operator): + bl_idname = 'avatar_toolkit.select_all_materials' + bl_label = "Select All" + bl_description = "Select all materials for atlas" + + def execute(self, context): + for item in context.scene.avatar_toolkit.materials: + item.mat.include_in_atlas = True + return {'FINISHED'} + +class AvatarToolKit_OT_SelectNoneMaterials(Operator): + bl_idname = 'avatar_toolkit.select_none_materials' + bl_label = "Select None" + bl_description = "Deselect all materials" + + def execute(self, context): + for item in context.scene.avatar_toolkit.materials: + item.mat.include_in_atlas = False + return {'FINISHED'} + +class AvatarToolKit_OT_ExpandAllMaterials(Operator): + bl_idname = 'avatar_toolkit.expand_all_materials' + bl_label = "Expand All" + bl_description = "Expand all material settings" + + def execute(self, context): + for item in context.scene.avatar_toolkit.materials: + item.mat.material_expanded = True + return {'FINISHED'} + +class AvatarToolKit_OT_CollapseAllMaterials(Operator): + bl_idname = 'avatar_toolkit.collapse_all_materials' + bl_label = "Collapse All" + bl_description = "Collapse all material settings" + + def execute(self, context): + for item in context.scene.avatar_toolkit.materials: + item.mat.material_expanded = False + return {'FINISHED'} + +class AvatarToolKit_OT_ExpandSectionMaterials(Operator): + bl_idname = 'avatar_toolkit.expand_section_materials' + bl_label = "" + bl_description = "" + + @classmethod + def poll(cls, context: Context) -> bool: + return True + + def execute(self, context: Context) -> set: + if not context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown: + context.scene.avatar_toolkit.materials.clear() + newlist: list[Material] = [] + for obj in context.scene.objects: + if len(obj.material_slots) > 0: + for mat_slot in obj.material_slots: + if mat_slot.material: + if mat_slot.material not in newlist: + newlist.append(mat_slot.material) + newitem: SceneMatClass = context.scene.avatar_toolkit.materials.add() + newitem.mat = mat_slot.material + MaterialListBool.old_list[context.scene.name] = newlist + context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown = True + else: + context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown = False + return {'FINISHED'} + +class AvatarToolKit_UL_MaterialTextureAtlasProperties(UIList): + bl_label = t("TextureAtlas.material_list_label") + bl_idname = "Material_UL_avatar_toolkit_texture_atlas_mat_list_mat" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + + def draw_header(self, context): + layout = self.layout + row = layout.row(align=True) + + row.operator("avatar_toolkit.select_all_materials", text="", icon='CHECKBOX_HLT') + row.operator("avatar_toolkit.select_none_materials", text="", icon='CHECKBOX_DEHLT') + row.operator("avatar_toolkit.expand_all_materials", text="", icon='DISCLOSURE_TRI_DOWN') + row.operator("avatar_toolkit.collapse_all_materials", text="", icon='DISCLOSURE_TRI_RIGHT') + row.prop(context.scene.avatar_toolkit, "material_search_filter", text="", icon='VIEWZOOM') + + box = layout.box() + row = box.row() + row.label(text=f"Estimated Atlas Size: {self.calculate_atlas_size(context)}px") + + def draw_item(self, context: Context, layout: UILayout, data: Object, item: SceneMatClass, icon, active_data, active_propname, index): + if context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown: + if (context.scene.avatar_toolkit.material_search_filter and + context.scene.avatar_toolkit.material_search_filter.lower() not in item.mat.name.lower()): + return + + row = layout.row() + + row.prop(item.mat, "include_in_atlas", text="", icon='CHECKBOX_HLT' if item.mat.include_in_atlas else 'CHECKBOX_DEHLT') + + row.prop(item.mat, "material_expanded", + text=item.mat.name, + icon='DOWNARROW_HLT' if item.mat.material_expanded else 'RIGHTARROW', + emboss=False) + + if item.mat.material_expanded and item.mat.include_in_atlas: + box = layout.box() + col = box.column(align=True) + self.draw_texture_row(col, item.mat, "texture_atlas_albedo", "IMAGE_RGB") + self.draw_texture_row(col, item.mat, "texture_atlas_normal", "NORMALS_FACE") + self.draw_texture_row(col, item.mat, "texture_atlas_emission", "LIGHT") + self.draw_texture_row(col, item.mat, "texture_atlas_ambient_occlusion", "SHADING_SOLID") + self.draw_texture_row(col, item.mat, "texture_atlas_height", "IMAGE_ZDEPTH") + self.draw_texture_row(col, item.mat, "texture_atlas_roughness", "MATERIAL") + + col.separator(factor=0.5) + + def draw_texture_row(self, layout, material, prop_name, icon): + row = layout.row() + row.prop(material, prop_name, icon=icon) + if getattr(material, prop_name): + row.label(text="", icon='CHECKMARK') + else: + row.label(text="", icon='X') + + def is_material_ready(self, material): + return bool(material.texture_atlas_albedo or + material.texture_atlas_normal or + material.texture_atlas_emission) + + def calculate_atlas_size(self, context): + total_size = 0 + for mat in context.scene.avatar_toolkit.materials: + if mat.mat.include_in_atlas: + if mat.mat.texture_atlas_albedo: + img = bpy.data.images[mat.mat.texture_atlas_albedo] + total_size += img.size[0] * img.size[1] + return f"{int(sqrt(total_size))}x{int(sqrt(total_size))}" + +class AvatarToolKit_PT_TextureAtlasPanel(Panel): + bl_label = t("TextureAtlas.label") + bl_idname = "OBJECT_PT_avatar_toolkit_texture_atlas" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = CATEGORY_NAME + bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname + bl_order = 6 + + def draw(self, context: Context): + layout = self.layout + armature = get_active_armature(context) + + if armature: + layout.label(text=t("TextureAtlas.label"), icon='TEXTURE') + layout.separator(factor=0.5) + + box = layout.box() + row = box.row() + direction_icon = 'RIGHTARROW' if not context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown else 'DOWNARROW_HLT' + row.operator(AvatarToolKit_OT_ExpandSectionMaterials.bl_idname, + text=(t("TextureAtlas.reload_list") if not context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown else t("TextureAtlas.loaded_list")), + icon=direction_icon) + + if context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown: + row = box.row() + row.template_list(AvatarToolKit_UL_MaterialTextureAtlasProperties.bl_idname, + 'material_list', + context.scene.avatar_toolkit, + 'materials', + context.scene.avatar_toolkit, + 'texture_atlas_material_index', + rows=12, + type='DEFAULT') + + layout.separator(factor=1.0) + + row = layout.row() + row.scale_y = 1.5 + row.operator(AvatarToolKit_OT_AtlasMaterials.bl_idname, + text=t("TextureAtlas.atlas_materials"), + icon='NODE_TEXTURE') + else: + layout.label(text=t("Tools.select_armature"), icon='ERROR')