diff --git a/__init__.py b/__init__.py index ab93ab7..4f93020 100644 --- a/__init__.py +++ b/__init__.py @@ -4,12 +4,14 @@ if "bpy" not in locals(): import bpy from . import ui from . import core + from . import functions from .core import register from .core.register import __bl_ordered_classes else: import importlib importlib.reload(ui) importlib.reload(core) + importlib.reload(functions) def register(): diff --git a/core/common.py b/core/common.py new file mode 100644 index 0000000..b73995b --- /dev/null +++ b/core/common.py @@ -0,0 +1,41 @@ +import bpy +import numpy as np + +from bpy.types import Object, ShapeKey +from functools import lru_cache + +### Clean up material names in the given mesh by removing the '.001' suffix. +def clean_material_names(mesh): + for j, mat in enumerate(mesh.material_slots): + if mat.name.endswith(('.0+', ' 0+')): + mesh.active_material_index = j + mesh.active_material.name = mat.name[:-len(mat.name.rstrip('0')) - 1] + + +# This will fix faulty uv coordinates, cats did this a other way which can have unintended consequences, +# this is the best way i could of think of doing this for the time being, however may need improvements. + +def fix_uv_coordinates(context): + obj = context.object + + # Check if the object is in Edit Mode + if obj.mode != 'EDIT': + bpy.ops.object.mode_set(mode='EDIT') + + # Check if the object has any mesh data + if obj.type == 'MESH' and obj.data: + bpy.context.view_layer.objects.active = obj + bpy.ops.mesh.select_all(action='SELECT') + bpy.ops.uv.average_islands_scale() + + # Switch back to Object Mode + bpy.ops.object.mode_set(mode='OBJECT') + else: + print("Object is not a valid mesh with UV data") + +def has_shapekeys(mesh_obj: Object) -> bool: + return mesh_obj.data.shape_keys is not None + +@lru_cache(maxsize=None) +def _get_shape_key_co(shape_key: ShapeKey) -> np.ndarray: + return np.array([v.co for v in shape_key.data]) diff --git a/functions/__init__.py b/functions/__init__.py new file mode 100644 index 0000000..afece68 --- /dev/null +++ b/functions/__init__.py @@ -0,0 +1,18 @@ +from ..core.register import register_wrap + +#to reload all things in this directory and import them properly - @989onan +if "bpy" not in locals(): + import bpy + import glob + import os + from os.path import dirname, basename, isfile, join + modules = glob.glob(join(dirname(__file__), "*.py")) + for module_name in [ basename(f)[:-3] for f in modules if isfile(f) and not f.endswith('__init__.py')]: + exec("from . import "+module_name) + print("importing " +module_name) +else: + import importlib + modules = glob.glob(join(dirname(__file__), "*.py")) + for module_name in [ basename(f)[:-3] for f in modules if isfile(f) and not f.endswith('__init__.py')]: + exec("importlib.reload("+module_name+")") + print("reloading " +module_name) diff --git a/functions/combine_materials.py b/functions/combine_materials.py new file mode 100644 index 0000000..1741fc8 --- /dev/null +++ b/functions/combine_materials.py @@ -0,0 +1,124 @@ +import bpy +import re +from ..core.common import clean_material_names +from ..core.register import register_wrap + +def textures_match(tex1, tex2): + return tex1.image == tex2.image and tex1.extension == tex2.extension + +def consolidate_nodes(node1, node2): + node2.color_space = node1.color_space + node2.coordinates = node1.coordinates + +def copy_tex_nodes(mat1, mat2): + for node1 in mat1.node_tree.nodes: + if node1.type == 'TEX_IMAGE': + node2 = mat2.node_tree.nodes.get(node1.name) + if node2: + node2.mapping = node1.mapping + node2.projection = node1.projection + +def consolidate_textures(mat1, mat2): + if mat1.node_tree and mat2.node_tree: + for node1 in mat1.node_tree.nodes: + if node1.type == 'TEX_IMAGE': + if node1.node_tree: + consolidate_textures(node1.node_tree, mat2.node_tree) + + for node2 in mat2.node_tree.nodes: + if (node2.type == 'TEX_IMAGE' and + node1.image == node2.image): + consolidate_nodes(node1, node2) + node2.image = node1.image + copy_tex_nodes(mat1, mat2) + +def color_match(col1, col2, tolerance=0.01): + return abs(col1[0] - col2[0]) < tolerance + +def materials_match(mat1, mat2, tolerance=0.01): + if not color_match(mat1.diffuse_color, mat2.diffuse_color, tolerance): + return False + + if mat1.roughness != mat2.roughness: + return False + + consolidate_textures(mat1, mat2) + + return True + +def get_base_name(name): + mat_match = re.match(r"^(.*)\.\d{3}$", name) + return mat_match.group(1) if mat_match else name + +def report_consolidated(self, num_combined): + self.report({'INFO'}, f"Combined {num_combined} materials") + +@register_wrap +class CombineMaterials(bpy.types.Operator): + bl_idname = "avatar_toolkit.combine_materials" + bl_label = "Combine Materials" + bl_description = "Combine similar materials to optimize the model" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return context.active_object is not None + + def execute(self, context): + bpy.ops.object.mode_set(mode='OBJECT') + + armature = next((obj for obj in bpy.data.objects if obj.type == 'ARMATURE'), None) + if not armature: + return {'CANCELLED'} + + meshes = [obj for obj in bpy.data.objects if obj.type == 'MESH' and 'Armature' in obj.modifiers and obj.modifiers['Armature'].object == armature] + if not meshes: + return {'CANCELLED'} + + bpy.ops.object.mode_set(mode='OBJECT') + self.consolidate_materials(meshes) + self.remove_unused_materials() + self.cleanmatslots() + self.clean_material_names() + bpy.ops.object.mode_set(mode='OBJECT') + bpy.context.view_layer.objects.active = armature + + return {'FINISHED'} + + def consolidate_materials(self, objects): + mat_mapping = {} + num_combined = 0 + for ob in objects: + for slot in ob.material_slots: + mat = slot.material + if mat: + base_name = get_base_name(mat.name) + + if base_name in mat_mapping: + base_mat = mat_mapping[base_name] + if materials_match(base_mat, mat): + consolidate_textures(base_mat, mat) + num_combined += 1 + slot.material = base_mat + else: + mat_mapping[base_name] = mat + + report_consolidated(self, num_combined) + + def remove_unused_materials(self): + for mat in bpy.data.materials: + if not any(obj for obj in bpy.data.objects if obj.material_slots and mat.name in obj.material_slots): + bpy.data.materials.remove(mat, do_unlink=True) + + def cleanmatslots(self): + for obj in bpy.data.objects: + if obj.type == 'MESH': + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + bpy.ops.object.material_slot_remove_unused() + obj.select_set(False) + + def clean_material_names(self): + for obj in bpy.data.objects: + if obj.type == 'MESH': + clean_material_names(obj) diff --git a/functions/join_meshes.py b/functions/join_meshes.py new file mode 100644 index 0000000..9e57789 --- /dev/null +++ b/functions/join_meshes.py @@ -0,0 +1,80 @@ +import bpy +from ..core.register import register_wrap +from ..core.common import fix_uv_coordinates + +@register_wrap +class JoinAllMeshes(bpy.types.Operator): + bl_idname = "avatar_toolkit.join_all_meshes" + bl_label = "Join All Meshes" + bl_description = "Join all meshes in the scene" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return context.mode == 'OBJECT' + + def execute(self, context): + self.join_all_meshes(context) + return {'FINISHED'} + + def join_all_meshes(self, context): + if not bpy.data.objects: + self.report({'INFO'}, "No objects in the scene") + return + + bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.select_all(action='DESELECT') + + meshes = [obj for obj in bpy.data.objects if obj.type == 'MESH'] + for mesh in meshes: + mesh.select_set(True) + + if bpy.context.selected_objects: + bpy.context.view_layer.objects.active = bpy.context.selected_objects[0] + bpy.ops.object.join() + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + fix_uv_coordinates(context) + bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.select_all(action='DESELECT') + self.report({'INFO'}, "Meshes joined successfully") + else: + self.report({'WARNING'}, "No mesh objects selected") + +@register_wrap +class JoinSelectedMeshes(bpy.types.Operator): + bl_idname = "avatar_toolkit.join_selected_meshes" + bl_label = "Join Selected Meshes" + bl_description = "Join selected meshes" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return context.mode == 'OBJECT' + + def execute(self, context): + self.join_selected_meshes(context) + return {'FINISHED'} + + def join_selected_meshes(self, context): + selected_objects = [obj for obj in bpy.context.selected_objects if obj.type == 'MESH'] + + if not selected_objects: + self.report({'WARNING'}, "No mesh objects selected") + return + + bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.select_all(action='DESELECT') + + for obj in selected_objects: + obj.select_set(True) + + if bpy.context.selected_objects: + bpy.context.view_layer.objects.active = bpy.context.selected_objects[0] + bpy.ops.object.join() + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + fix_uv_coordinates(context) + bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.select_all(action='DESELECT') + self.report({'INFO'}, "Selected meshes joined successfully") + else: + self.report({'WARNING'}, "No mesh objects selected") diff --git a/ui/optimization.py b/ui/optimization.py index 54c5d0a..54e7ce2 100644 --- a/ui/optimization.py +++ b/ui/optimization.py @@ -14,4 +14,14 @@ class AvatarToolkitOptimizationPanel(bpy.types.Panel): def draw(self, context): layout = self.layout layout.label(text="Optimization Options") + + row = layout.row() + row.operator("avatar_toolkit.combine_materials", text="Combine Materials") + + row = layout.row() + row.operator("avatar_toolkit.join_all_meshes", text="Join All Meshes") + + row = layout.row() + row.operator("avatar_toolkit.join_selected_meshes", text="Join Selected Meshes") + # Add optimization options here