diff --git a/core/packer/rectangle_packer.py b/core/packer/rectangle_packer.py new file mode 100644 index 0000000..b8b8b0a --- /dev/null +++ b/core/packer/rectangle_packer.py @@ -0,0 +1,151 @@ + +# thank you https://stackoverflow.com/a/71432759 +from __future__ import annotations + + +from typing import Optional +from bpy.types import Image + + +# 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 + fit: Rectangle_Obj + + 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 abe3e17..5cb1141 100644 --- a/core/properties.py +++ b/core/properties.py @@ -6,13 +6,18 @@ from bpy.utils import register_class class material_list_bool: - old_list: list[Material] = [] - bool_material_list_expand: bool = False + #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: - material_list_bool.bool_material_list_expand = value + material_list_bool.bool_material_list_expand[bpy.context.scene.name] = value if value == False: - material_list_bool.old_list = [] + material_list_bool.old_list[bpy.context.scene.name] = [] def get_bool(self) -> bool: newlist: list[Material] = [] @@ -24,18 +29,20 @@ class material_list_bool: newlist.append(mat_slot.material) still_the_same: bool = True - for item in newlist: - if item not in material_list_bool.old_list: - still_the_same = False - break - for item in material_list_bool.old_list: - if item not in newlist: - still_the_same = False - break - - material_list_bool.bool_material_list_expand = still_the_same + if bpy.context.scene.name in material_list_bool.old_list: + for item in newlist: + if item not in material_list_bool.old_list[bpy.context.scene.name]: + still_the_same = False + break + for item in material_list_bool.old_list[bpy.context.scene.name]: + if item not in newlist: + still_the_same = False + break + else: + still_the_same = False + material_list_bool.bool_material_list_expand[bpy.context.scene.name] = still_the_same - return material_list_bool.bool_material_list_expand + return material_list_bool.bool_material_list_expand[bpy.context.scene.name] class SceneMatClass(PropertyGroup): mat: PointerProperty(type=Material) @@ -48,7 +55,7 @@ def register_properties(): #happy with how compressed this get_texture_node_list method is - @989onan def get_texture_node_list(self: Material, context: Context) -> list[set[3]]: if self.use_nodes: - Object.Enum = [(i.name+"_image",(i.image.name if i.image else "node with no image..."),i.name,index+1) for index,i in enumerate(self.node_tree.nodes) if i.bl_idname == "ShaderNodeTexImage"] + 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 = [("ERROR", "THIS MATERIAL HAS NO IMAGES!", "ERROR", 0)] else: @@ -57,8 +64,9 @@ def register_properties(): return Object.Enum - Material.texture_atlas_normal = EnumProperty(name="Normal", description="The texture that will be used for the normal map atlas", default=0, items=get_texture_node_list) + Material.texture_atlas_albedo = EnumProperty(name="Albedo", description="The texture that will be used for the albedo map atlas", default=0, items=get_texture_node_list) + Material.texture_atlas_normal = EnumProperty(name="Normal", description="The texture that will be used for the normal map atlas", default=0, items=get_texture_node_list) Material.texture_atlas_emission = EnumProperty(name="Emission", description="The texture that will be used for the emission map atlas", default=0, items=get_texture_node_list) Material.texture_atlas_ambient_occlusion = EnumProperty(name="Ambient Occlusion", description="The texture that will be used for the ambient occlusion map atlas", default=0, items=get_texture_node_list) Material.texture_atlas_height = EnumProperty(name="Height", description="The texture that will be used for the height map atlas", default=0, items=get_texture_node_list) diff --git a/core/register.py b/core/register.py index 0d91a8a..2c20729 100644 --- a/core/register.py +++ b/core/register.py @@ -32,7 +32,7 @@ def toposort(deps_dict): unsorted.append(value) deps_dict = {value : deps_dict[value] - sorted_values for value in unsorted} - sort_order(sorted_list) #to sort by 'bl_order' so we can choose how things may appear in the ui + #sort_order(sorted_list) #to sort by 'bl_order' so we can choose how things may appear in the ui return sorted_list diff --git a/functions/atlas_materials.py b/functions/atlas_materials.py new file mode 100644 index 0000000..39f3aa3 --- /dev/null +++ b/functions/atlas_materials.py @@ -0,0 +1,178 @@ +from pathlib import Path +import bpy +import re +import os +from typing import List, Tuple, Optional +from bpy.types import Material, Operator, Context, Object, Image +from ..core.register import register_wrap +from ..core.properties import material_list_bool, SceneMatClass +from ..core.packer.rectangle_packer import MaterialImageList, BinPacker + + + + + +def scale_images_to_largest(images:list[Image]) -> set: + print([image.name for image in images]) + x: int=0 + y: int=0 + for image in images: + x = max(x,image.size[0]) + y = max(y,image.size[1]) + print(x,y) + + for image in images: + image.scale(width=int(x), height=int(y)) + + return x,y + +def MaterialImageList_to_Image_list(classitem: MaterialImageList) -> list[Image]: + list_of_images: list[Image] = [] + + list_of_images.append(classitem.albedo) + list_of_images.append(classitem.normal) + list_of_images.append(classitem.emission) + list_of_images.append(classitem.ambient_occlusion) + list_of_images.append(classitem.height) + + return list_of_images + + +def get_material_images_from_scene(context: Context) -> list[MaterialImageList]: + mat: SceneMatClass = None + material_image_list: list[MaterialImageList] = [] + for mat in context.scene.materials: + new_mat_image_item: MaterialImageList = MaterialImageList() + try: + new_mat_image_item.albedo = bpy.data.images[mat.mat.texture_atlas_albedo] + except Exception as e: + name: str = mat.mat.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) + try: + new_mat_image_item.normal = bpy.data.images[mat.mat.texture_atlas_normal] + except Exception: + name: str = mat.mat.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) + try: + new_mat_image_item.emission = bpy.data.images[mat.mat.texture_atlas_emission] + except Exception: + name: str = mat.mat.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) + + try: + new_mat_image_item.ambient_occlusion = bpy.data.images[mat.mat.texture_atlas_ambient_occlusion] + except Exception: + name: str = mat.mat.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) + try: + new_mat_image_item.height = bpy.data.images[mat.mat.texture_atlas_height] + except Exception: + name: str = mat.mat.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) + material_image_list.append(new_mat_image_item) + return material_image_list + + +def prep_images_in_scene(context: Context) -> list[MaterialImageList]: + preped_images: list[MaterialImageList] = get_material_images_from_scene(context) + for MaterialImageClass in preped_images: + ImageList: list[Image] = MaterialImageList_to_Image_list(MaterialImageClass) + + MaterialImageClass.w, MaterialImageClass.h = scale_images_to_largest(ImageList) + + + + return preped_images + + + + + + + + + + + + + +@register_wrap +class Atlas_Materials(Operator): + + bl_idname = "avatar_toolkit.atlas_materials" + bl_label = "Atlas Materials" + bl_description = "Atlas materials to optimize the model" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + return context.scene.texture_atlas_Has_Mat_List_Shown + + def execute(self, context: Context) -> set: + try: + mat_images: list[MaterialImageList] = prep_images_in_scene(context) + + packer: BinPacker = BinPacker(mat_images) + + mat_images = packer.fit() + + + size: list[int] = [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])] + print([matimg.fit.w + matimg.albedo.size[1] for matimg in mat_images]) + + for type in ["albedo","normal", "emission","ambient_occlusion","height"]: + new_image_name: str= "Atlas_"+type+"_"+bpy.context.scene.name+"_"+Path(bpy.data.filepath).stem + + print("Processing "+type+" atlas image") + + if new_image_name in bpy.data.images: + bpy.data.images.remove(bpy.data.images[new_image_name]) + + canvas: Image = bpy.data.images.new(name=new_image_name, width=int(size[0]),height=int(size[1]), alpha=True) + c_w = canvas.size[0] + c_h = canvas.size[1] + canvas_pixels: list[float] = list(canvas.pixels[:]) + for mat in mat_images: + x: int = int(mat.fit.x) + y: int = int(mat.fit.y) + w: int = int(mat.albedo.size[0]) + h: int = int(mat.albedo.size[1]) + + image_var: Image = eval("mat."+type) + + image_pixels: list[float] = list(image_var.pixels[:]) + + print("writing image \""+image_var.name+"\" to canvas.") + print("x: \""+str(x)+"\" "+"y: \""+str(y)+"\" "+"w: \""+str(w)+"\" "+"h: \""+str(h)+"\" ") + for k in range(0,h): + for i in range(0, w): + for channel in range(0,4): + canvas_pixels[ + int((((k+y)*c_w) + + + (i+x))*4) + +int(channel) + ] = image_pixels[ + int(( + (k*w) + +i)*4) + +int(channel)] + + canvas.pixels[:] = canvas_pixels[:] + canvas.save(filepath=os.path.join(os.path.dirname(bpy.data.filepath),new_image_name+".png")) + return {"FINISHED"} + except Exception as e: + raise e + return {"FINISHED"} + \ No newline at end of file diff --git a/functions/texture_atlas.py b/functions/texture_atlas.py index e303838..90a4aec 100644 --- a/functions/texture_atlas.py +++ b/functions/texture_atlas.py @@ -16,8 +16,8 @@ rather than just duct taping the textures together like material combiner and th bl_options = {'REGISTER', 'UNDO'} def execute(self, context: Context) -> set: - + return {'FINISHED'} diff --git a/ui/atlas_materials.py b/ui/atlas_materials.py index f0c8018..4a9b245 100644 --- a/ui/atlas_materials.py +++ b/ui/atlas_materials.py @@ -3,6 +3,7 @@ import bpy from ..core.register import register_wrap from .panel import AvatarToolkitPanel from ..core.properties import SceneMatClass, material_list_bool +from ..functions.atlas_materials import Atlas_Materials @register_wrap @@ -29,7 +30,7 @@ class ExpandSection_Materials(Operator): newlist.append(mat_slot.material) newitem: SceneMatClass = context.scene.materials.add() newitem.mat = mat_slot.material - material_list_bool.old_list = newlist + material_list_bool.old_list[context.scene.name] = newlist else: context.scene.texture_atlas_Has_Mat_List_Shown = False return {'FINISHED'} @@ -85,4 +86,5 @@ class TextureAtlasPanel(Panel): row = boxoutter.row() row.template_list(MaterialTextureAtlasProperties.bl_idname, 'material_list', context.scene, 'materials', context.scene, 'texture_atlas_material_index', rows=12, type='DEFAULT') - \ No newline at end of file + row = layout.row() + row.operator(Atlas_Materials.bl_idname, text="Atlas Materials!") \ No newline at end of file