diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/common.py b/core/common.py index bec7810..b5ee6ce 100644 --- a/core/common.py +++ b/core/common.py @@ -146,12 +146,12 @@ def validate_symmetry(bones: Dict[str, bpy.types.Bone], base: str, left: str, ri right_exists = any(pattern in bones for pattern in right_patterns) return left_exists and right_exists - - + def auto_select_single_armature(context: bpy.types.Context) -> None: """Automatically select armature if only one exists in scene""" armatures = get_armature_list(context) - if len(armatures) == 1: + if len(armatures) == 1 and armatures[0][0] != 'NONE': + toolkit = context.scene.avatar_toolkit set_active_armature(context, armatures[0]) def clear_default_objects() -> None: @@ -305,4 +305,83 @@ def apply_armature_to_mesh_with_shapekeys(armature_obj: Object, mesh_obj: Object sk.mute = mute mesh_obj.active_shape_key_index = old_active_index - mesh_obj.show_only_shape_key = old_show_only \ No newline at end of file + mesh_obj.show_only_shape_key = old_show_only + +def validate_meshes(meshes: List[Object]) -> Tuple[bool, str]: + """Validates a list of mesh objects to ensure they are suitable for joining operations""" + if not meshes: + return False, t("Optimization.no_meshes") + if not all(mesh.data for mesh in meshes): + return False, t("Optimization.invalid_mesh_data") + if not all(mesh.type == 'MESH' for mesh in meshes): + return False, t("Optimization.non_mesh_objects") + return True, "" + +def join_mesh_objects(context: Context, meshes: List[Object], progress: Optional[ProgressTracker] = None) -> Tuple[bool, str]: + """Combines multiple mesh objects into a single mesh with proper cleanup and UV fixing""" + try: + bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.select_all(action='DESELECT') + + for mesh in meshes: + mesh.select_set(True) + + if context.selected_objects: + context.view_layer.objects.active = context.selected_objects[0] + + if progress: + progress.step(t("Optimization.joining_meshes")) + bpy.ops.object.join() + + if progress: + progress.step(t("Optimization.applying_transforms")) + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + + if progress: + progress.step(t("Optimization.fixing_uvs")) + fix_uv_coordinates(context) + + return True, t("Optimization.meshes_joined") + + return False, t("Optimization.no_mesh_selected") + + except Exception as e: + logger.error(f"Failed to join meshes: {str(e)}") + return False, str(e) + +def fix_uv_coordinates(context: Context) -> None: + """Normalizes and fixes UV coordinates for the active mesh object""" + obj: Object = context.object + current_mode: str = context.mode + current_active: Object = context.view_layer.objects.active + current_selected: List[Object] = context.selected_objects.copy() + + try: + bpy.ops.object.mode_set(mode='OBJECT') + obj.select_set(True) + context.view_layer.objects.active = obj + bpy.ops.object.mode_set(mode='EDIT') + + bpy.ops.mesh.select_all(action='SELECT') + + with context.temp_override(active_object=obj): + bpy.ops.uv.select_all(action='SELECT') + bpy.ops.uv.average_islands_scale() + + logger.debug(f"UV Fix - Successfully processed {obj.name}") + + except Exception as e: + logger.warning(f"UV Fix - Skipped processing for {obj.name}: {str(e)}") + + finally: + bpy.ops.object.mode_set(mode='OBJECT') + for sel_obj in current_selected: + sel_obj.select_set(True) + context.view_layer.objects.active = current_active + +def clear_unused_data_blocks(self) -> int: + """Removes all unused data blocks from the current Blender file""" + initial_count: int = sum(len(getattr(bpy.data, attr)) for attr in dir(bpy.data) if isinstance(getattr(bpy.data, attr), bpy.types.bpy_prop_collection)) + bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) + final_count: int = sum(len(getattr(bpy.data, attr)) for attr in dir(bpy.data) if isinstance(getattr(bpy.data, attr), bpy.types.bpy_prop_collection)) + return initial_count - final_count diff --git a/core/exporters/__init__.py b/core/exporters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/importers/__init__.py b/core/importers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/properties.py b/core/properties.py index 0b2e0a6..edbc96d 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 +from .common import get_armature_list, get_active_armature, get_all_meshes def update_validation_mode(self, context): logger.info(f"Updating validation mode to: {self.validation_mode}") @@ -38,7 +38,7 @@ class AvatarToolkitSceneProperties(PropertyGroup): active_armature: EnumProperty( items=get_armature_list, name=t("QuickAccess.select_armature"), - description=t("QuickAccess.select_armature") + description=t("QuickAccess.select_armature"), ) language: EnumProperty( @@ -72,6 +72,20 @@ class AvatarToolkitSceneProperties(PropertyGroup): default=False ) + remove_doubles_merge_distance: FloatProperty( + name=t("Optimization.merge_distance"), + description=t("Optimization.merge_distance_desc"), + default=0.0001, + min=0.00001, + max=0.1 + ) + + remove_doubles_advanced: BoolProperty( + name=t("Optimization.remove_doubles_advanced"), + description=t("Optimization.remove_doubles_advanced_desc"), + default=False + ) + def register() -> None: """Register the Avatar Toolkit property group""" logger.info("Registering Avatar Toolkit properties") @@ -83,4 +97,3 @@ def unregister() -> None: logger.info("Unregistering Avatar Toolkit properties") del bpy.types.Scene.avatar_toolkit logger.debug("Properties unregistered successfully") - diff --git a/core/updater.py b/core/updater.py index 66a9f5c..96e55c8 100644 --- a/core/updater.py +++ b/core/updater.py @@ -76,7 +76,7 @@ class AvatarToolkit_PT_UpdaterPanel(bpy.types.Panel): bl_region_type = 'UI' bl_category = CATEGORY_NAME bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order = 1 + bl_order = 3 def draw(self, context: bpy.types.Context) -> None: layout = self.layout diff --git a/functions/__init__.py b/functions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/functions/optimization/__init__.py b/functions/optimization/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/functions/optimization/materials_tools.py b/functions/optimization/materials_tools.py new file mode 100644 index 0000000..77a6d19 --- /dev/null +++ b/functions/optimization/materials_tools.py @@ -0,0 +1,175 @@ +import bpy +import re +from typing import Set, Dict, List, Optional, Tuple +from bpy.types import ( + Operator, + Context, + Object, + Material, + NodeTree, + ShaderNodeTexImage +) +from ...core.logging_setup import logger +from ...core.translations import t +from ...core.common import ( + get_active_armature, + get_all_meshes, + validate_armature, + clear_unused_data_blocks, + ProgressTracker +) + +def textures_match(tex1: ShaderNodeTexImage, tex2: ShaderNodeTexImage) -> bool: + """Compare two texture nodes for matching properties and image data""" + return tex1.image == tex2.image and tex1.extension == tex2.extension + +def consolidate_nodes(node1: ShaderNodeTexImage, node2: ShaderNodeTexImage) -> None: + """Transfer properties from one texture node to another to ensure consistency""" + node2.color_space = node1.color_space + node2.coordinates = node1.coordinates + +def consolidate_textures(node_tree1: NodeTree, node_tree2: NodeTree) -> None: + """Synchronize texture nodes between two material node trees""" + for node1 in node_tree1.nodes: + if node1.type == 'TEX_IMAGE': + for node2 in node_tree2.nodes: + if (node2.type == 'TEX_IMAGE' and node1.image == node2.image): + consolidate_nodes(node1, node2) + node2.image = node1.image + elif node1.type == 'GROUP': + if node1.node_tree and node2.node_tree: + consolidate_textures(node1.node_tree, node2.node_tree) + +def color_match(col1: Tuple[float, ...], col2: Tuple[float, ...], tolerance: float = 0.01) -> bool: + """Compare two color values within a specified tolerance""" + return all(abs(c1 - c2) < tolerance for c1, c2 in zip(col1, col2)) + +def materials_match(mat1: Material, mat2: Material, tolerance: float = 0.01) -> bool: + """Compare two materials for matching properties within tolerance""" + if not color_match(mat1.diffuse_color, mat2.diffuse_color, tolerance): + return False + + if abs(mat1.roughness - mat2.roughness) > tolerance: + return False + + if abs(mat1.metallic - mat2.metallic) > tolerance: + return False + + if abs(mat1.alpha_threshold - mat2.alpha_threshold) > tolerance: + return False + + if not color_match(mat1.emission_color, mat2.emission_color, tolerance): + return False + + if mat1.node_tree and mat2.node_tree: + consolidate_textures(mat1.node_tree, mat2.node_tree) + + return True + +def get_base_name(name: str) -> str: + """Extract the base material name by removing numeric suffixes""" + mat_match = re.match(r"^(.*)\.\d{3}$", name) + return mat_match.group(1) if mat_match else name + +class AvatarToolkit_OT_CombineMaterials(Operator): + """Operator for combining similar materials to reduce duplicate materials""" + bl_idname: str = "avatar_toolkit.combine_materials" + bl_label: str = t("Optimization.combine_materials") + bl_description: str = t("Optimization.combine_materials_desc") + bl_options: Set[str] = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + """Check if the operator can be executed""" + armature = get_active_armature(context) + if not armature: + return False + valid, _ = validate_armature(armature) + return valid + + def execute(self, context: Context) -> Set[str]: + """Execute the material combination operation""" + try: + armature = get_active_armature(context) + meshes = get_all_meshes(context) + + if not meshes: + self.report({'WARNING'}, t("Optimization.no_meshes")) + return {'CANCELLED'} + + if not any(mesh.material_slots for mesh in meshes): + self.report({'WARNING'}, t("Optimization.no_materials")) + return {'CANCELLED'} + + with ProgressTracker(context, 4, "Combining Materials") as progress: + try: + num_combined = self.consolidate_materials(meshes) + except Exception as e: + logger.error(f"Material consolidation failed: {str(e)}") + self.report({'ERROR'}, t("Optimization.error.consolidation")) + return {'CANCELLED'} + progress.step("Consolidated materials") + + try: + num_cleaned = self.clean_material_slots(meshes) + except Exception as e: + logger.error(f"Material slot cleanup failed: {str(e)}") + self.report({'ERROR'}, t("Optimization.error.slot_cleanup")) + return {'CANCELLED'} + progress.step("Cleaned material slots") + + try: + num_removed = clear_unused_data_blocks(self) + except Exception as e: + logger.error(f"Data block cleanup failed: {str(e)}") + self.report({'ERROR'}, t("Optimization.error.data_cleanup")) + return {'CANCELLED'} + progress.step("Removed unused data blocks") + + self.report({'INFO'}, t("Optimization.materials_combined", + combined=num_combined, + cleaned=num_cleaned, + removed=num_removed)) + + return {'FINISHED'} + + except Exception as e: + logger.error(f"Failed to combine materials: {str(e)}") + self.report({'ERROR'}, t("Optimization.error.combine_materials", error=str(e))) + return {'CANCELLED'} + + def consolidate_materials(self, meshes: List[Object]) -> int: + """Consolidate similar materials across all meshes""" + mat_mapping: Dict[str, Material] = {} + num_combined: int = 0 + + for mesh in meshes: + for slot in mesh.material_slots: + mat: Optional[Material] = slot.material + if mat: + base_name: str = get_base_name(mat.name) + + if base_name in mat_mapping: + base_mat: Material = mat_mapping[base_name] + try: + if materials_match(base_mat, mat): + consolidate_textures(base_mat.node_tree, mat.node_tree) + num_combined += 1 + slot.material = base_mat + except AttributeError: + logger.warning(f"Material attribute mismatch: {mat.name}") + continue + else: + mat_mapping[base_name] = mat + + return num_combined + + def clean_material_slots(self, meshes: List[Object]) -> int: + """Remove unused material slots from meshes""" + cleaned_slots = 0 + for obj in meshes: + initial_slots = len(obj.material_slots) + bpy.context.view_layer.objects.active = obj + bpy.ops.object.material_slot_remove_unused() + cleaned_slots += initial_slots - len(obj.material_slots) + return cleaned_slots diff --git a/functions/optimization/mesh_tools.py b/functions/optimization/mesh_tools.py new file mode 100644 index 0000000..086bdc9 --- /dev/null +++ b/functions/optimization/mesh_tools.py @@ -0,0 +1,103 @@ +import bpy +from typing import Set, List, Tuple, ClassVar +from bpy.types import Operator, Context, Object +from ...core.logging_setup import logger +from ...core.translations import t +from ...core.common import ( + get_active_armature, + get_all_meshes, + validate_armature, + validate_meshes, + join_mesh_objects, + ProgressTracker +) + +class AvatarToolkit_OT_JoinAllMeshes(Operator): + """Operator to join all meshes in the scene""" + bl_idname: ClassVar[str] = "avatar_toolkit.join_all_meshes" + bl_label: ClassVar[str] = t("Optimization.join_all_meshes") + bl_description: ClassVar[str] = t("Optimization.join_all_meshes_desc") + bl_options: ClassVar[Set[str]] = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature: Object | None = get_active_armature(context) + if not armature: + return False + valid: bool + valid, _ = validate_armature(armature) + return valid + + def execute(self, context: Context) -> Set[str]: + try: + armature: Object = get_active_armature(context) + meshes: List[Object] = get_all_meshes(context) + + valid: bool + message: str + valid, message = validate_meshes(meshes) + if not valid: + self.report({'WARNING'}, message) + return {'CANCELLED'} + + with ProgressTracker(context, 5, "Joining All Meshes") as progress: + success: bool + success, message = join_mesh_objects(context, meshes, progress) + + if success: + context.view_layer.objects.active = armature + self.report({'INFO'}, message) + return {'FINISHED'} + else: + self.report({'ERROR'}, message) + return {'CANCELLED'} + + except Exception as e: + logger.error(f"Failed to join meshes: {str(e)}") + self.report({'ERROR'}, t("Optimization.error.join_meshes", error=str(e))) + return {'CANCELLED'} + +class AvatarToolkit_OT_JoinSelectedMeshes(Operator): + """Operator to join selected meshes""" + bl_idname: ClassVar[str] = "avatar_toolkit.join_selected_meshes" + bl_label: ClassVar[str] = t("Optimization.join_selected_meshes") + bl_description: ClassVar[str] = t("Optimization.join_selected_meshes_desc") + bl_options: ClassVar[Set[str]] = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature: Object | None = get_active_armature(context) + if not armature: + return False + valid: bool + valid, _ = validate_armature(armature) + return (valid and + context.mode == 'OBJECT' and + len([obj for obj in context.selected_objects if obj.type == 'MESH']) > 1) + + def execute(self, context: Context) -> Set[str]: + try: + selected_meshes: List[Object] = [obj for obj in context.selected_objects if obj.type == 'MESH'] + + valid: bool + message: str + valid, message = validate_meshes(selected_meshes) + if not valid: + self.report({'WARNING'}, message) + return {'CANCELLED'} + + with ProgressTracker(context, 5, "Joining Selected Meshes") as progress: + success: bool + success, message = join_mesh_objects(context, selected_meshes, progress) + + if success: + self.report({'INFO'}, message) + return {'FINISHED'} + else: + self.report({'ERROR'}, message) + return {'CANCELLED'} + + except Exception as e: + logger.error(f"Failed to join selected meshes: {str(e)}") + self.report({'ERROR'}, t("Optimization.error.join_selected", error=str(e))) + return {'CANCELLED'} diff --git a/functions/optimization/remove_doubles.py b/functions/optimization/remove_doubles.py new file mode 100644 index 0000000..8714e5b --- /dev/null +++ b/functions/optimization/remove_doubles.py @@ -0,0 +1,281 @@ +import bpy +import numpy as np +from typing import List, TypedDict, Any, Literal, TypeAlias, cast +from bpy.types import Operator, Context, Object, Event +from ...core.logging_setup import logger +from ...core.translations import t +from ...core.common import ( + get_active_armature, + get_all_meshes, + validate_armature +) + +# Constants +MERGE_ITERATION_COUNT = 20 +MERGE_DISTANCE_DEFAULT = 0.0001 + +# Type definitions +ModalReturnType: TypeAlias = Literal['RUNNING_MODAL', 'FINISHED', 'CANCELLED'] + +class MeshEntry(TypedDict): + mesh: Object + shapekeys: list[str] + vertices: int + cur_vertex_pass: int + +def create_duplicate_for_merge(context: Context, mesh: Object, shapekey_name: str) -> Object: + """Creates a duplicate mesh object for merge testing""" + context.view_layer.objects.active = mesh + bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.select_all(action='DESELECT') + mesh.select_set(True) + bpy.ops.object.duplicate() + bpy.ops.object.shape_key_move(type='TOP') + + duplicate = context.view_layer.objects.active + duplicate.name = f"{shapekey_name}_object_is_{mesh.name}" + return duplicate + +def process_vertex_merging(mesh_data: bpy.types.Mesh, vertices_original: dict[int, Any], current_vertex: int) -> list[int]: + """Process vertex merging and return merged vertex indices""" + merged_vertices = [] + i, j = 0, 0 + + while i < len(vertices_original): + if j + 1 > len(mesh_data.vertices): + merged_vertices.append(i) + j = j - 1 + elif mesh_data.vertices[j].co.xyz != vertices_original[i]: + merged_vertices.append(i) + j = j - 1 + elif vertices_original[i] == vertices_original[current_vertex]: + merged_vertices.append(i) + i, j = i + 1, j + 1 + + return merged_vertices + +class AvatarToolkit_OT_RemoveDoublesAdvanced(Operator): + bl_idname = "avatar_toolkit.remove_doubles_advanced" + bl_label = t("Optimization.remove_doubles_advanced") + bl_description = t("Optimization.remove_doubles_advanced_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + """Check if the operator can be executed""" + armature = get_active_armature(context) + if not armature: + return False + valid, _ = validate_armature(armature) + return valid + + def execute(self, context: Context) -> set[str]: + """Execute the advanced remove doubles operator""" + context.scene.avatar_toolkit.remove_doubles_advanced = True + bpy.ops.avatar_toolkit.remove_doubles('INVOKE_DEFAULT') + return {'RUNNING_MODAL'} + +class AvatarToolkit_OT_RemoveDoubles(Operator): + bl_idname = "avatar_toolkit.remove_doubles" + bl_label = t("Optimization.remove_doubles") + bl_description = t("Optimization.remove_doubles_desc") + bl_options = {'REGISTER', 'UNDO'} + + objects_to_do: list[MeshEntry] = [] + + @classmethod + def poll(cls, context: Context) -> bool: + """Check if the operator can be executed""" + armature = get_active_armature(context) + if not armature: + return False + valid, _ = validate_armature(armature) + return valid + + def draw(self, context: Context) -> None: + """Draw the operator's UI""" + layout = self.layout + layout.prop(context.scene.avatar_toolkit, "remove_doubles_merge_distance") + layout.label(text=t("Optimization.remove_doubles_warning")) + layout.label(text=t("Optimization.remove_doubles_wait")) + + def invoke(self, context: Context, event: Event) -> set[str]: + """Initialize the operator""" + logger.info("Starting modal execution of merge doubles safely") + return context.window_manager.invoke_props_dialog(self) + + def setup_mesh_entry(self, mesh: Object) -> MeshEntry: + """Set up mesh entry data structure""" + mesh_entry: MeshEntry = { + "mesh": mesh, + "shapekeys": [], + "vertices": len(mesh.data.vertices), + "cur_vertex_pass": 0 + } + + if mesh.data.shape_keys: + mesh_entry["shapekeys"] = [shape.name for shape in mesh.data.shape_keys.key_blocks] + + return mesh_entry + + def execute(self, context: Context) -> set[str]: + """Execute the remove doubles operator""" + try: + armature = get_active_armature(context) + if not armature: + self.report({'WARNING'}, t("Optimization.no_armature")) + return {'CANCELLED'} + + bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.select_all(action='DESELECT') + + objects = get_all_meshes(context) + self.objects_to_do = [] + + for mesh in objects: + if mesh.data.name not in [obj["mesh"].data.name for obj in self.objects_to_do]: + logger.debug(f"Setting up data for object {mesh.name}") + mesh_entry = self.setup_mesh_entry(mesh) + self.objects_to_do.append(mesh_entry) + + context.window_manager.modal_handler_add(self) + return {'RUNNING_MODAL'} + + except Exception as e: + logger.error(f"Error in execute: {str(e)}") + return {'CANCELLED'} + + def modify_mesh(self, context: Context, mesh: MeshEntry) -> None: + """Basic mesh modification for simple cases""" + try: + mesh["mesh"].select_set(True) + context.view_layer.objects.active = mesh["mesh"] + mesh_data = mesh["mesh"].data + + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.object.mode_set(mode='OBJECT') + + # Select vertices with different positions in shape keys + for index, point in enumerate(mesh["mesh"].active_shape_key.points): + if point.co.xyz != mesh_data.shape_keys.key_blocks[0].points[index].co.xyz: + mesh_data.vertices[index].select = True + logger.debug(f"Shapekey has moved vertex at index {index}") + + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.object.mode_set(mode='OBJECT') + mesh["mesh"].select_set(False) + + except Exception as e: + logger.error(f"Error in modify_mesh: {str(e)}") + + def modify_mesh_advanced(self, context: Context, mesh_entry: MeshEntry) -> bool: + """Advanced mesh modification with shape key handling""" + try: + final_merged_vertex_group = [] + initialized_final = False + merge_distance = context.scene.avatar_toolkit.remove_doubles_merge_distance + + for shapekey_name in mesh_entry["shapekeys"]: + duplicate = create_duplicate_for_merge(context, mesh_entry["mesh"], shapekey_name) + vertices_original = {i: v.co.xyz for i, v in enumerate(duplicate.data.vertices)} + + # Process merging + merged_vertices = process_vertex_merging(duplicate.data, vertices_original, mesh_entry["cur_vertex_pass"]) + + if not initialized_final: + final_merged_vertex_group = merged_vertices.copy() + initialized_final = True + else: + final_merged_vertex_group = [v for v in final_merged_vertex_group if v in merged_vertices] + + bpy.ops.object.delete() + + # Apply final merging + if final_merged_vertex_group: + self.apply_final_merging(context, mesh_entry, final_merged_vertex_group, merge_distance) + + return not (len(final_merged_vertex_group) > 1) + + except Exception as e: + logger.error(f"Error in modify_mesh_advanced: {str(e)}") + return True + + def apply_final_merging(self, context: Context, mesh_entry: MeshEntry, vertex_group: list[int], merge_distance: float) -> None: + """Apply final vertex merging operations""" + mesh = mesh_entry["mesh"] + context.view_layer.objects.active = mesh + mesh.select_set(True) + + bpy.ops.object.mode_set(mode='OBJECT') + select_target_group = [False] * len(mesh.data.vertices) + for vertex_index in vertex_group: + select_target_group[vertex_index] = True + + mesh.data.vertices.foreach_set("select", select_target_group) + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.remove_doubles(threshold=merge_distance, use_unselected=False) + bpy.ops.object.mode_set(mode='OBJECT') + + def process_simple_mesh(self, context: Context, mesh: MeshEntry, merge_distance: float) -> None: + """Process mesh without shapekeys using simple merge operation""" + logger.debug(f"Processing mesh without shapekeys: {mesh['mesh'].name}") + mesh["mesh"].select_set(True) + context.view_layer.objects.active = mesh["mesh"] + bpy.ops.object.mode_set(mode='EDIT') + mesh["mesh"].data.vertices.foreach_set("select", [False] * len(mesh["mesh"].data.vertices)) + + bpy.ops.mesh.select_all(action="INVERT") + bpy.ops.mesh.remove_doubles(threshold=merge_distance, use_unselected=False) + bpy.ops.object.mode_set(mode='OBJECT') + mesh["mesh"].select_set(False) + + def finish_mesh_processing(self, context: Context, mesh: MeshEntry, advanced: bool, merge_distance: float) -> None: + """Complete the mesh processing by performing final merge operations""" + logger.debug("Finishing mesh processing") + + if not advanced: + mesh["mesh"].select_set(True) + context.view_layer.objects.active = mesh["mesh"] + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action="INVERT") + bpy.ops.mesh.remove_doubles(threshold=merge_distance, use_unselected=False) + + bpy.ops.object.mode_set(mode='OBJECT') + mesh["mesh"].select_set(False) + + def modal(self, context: Context, event: Event) -> set[ModalReturnType]: + """Modal operator execution""" + try: + if not self.objects_to_do: + self.report({'INFO'}, t("Optimization.remove_doubles_completed")) + logger.info("Finishing modal execution of merge doubles safely") + return {'FINISHED'} + + mesh = self.objects_to_do[0] + mesh_data = mesh["mesh"].data + advanced = context.scene.avatar_toolkit.remove_doubles_advanced + merge_distance = context.scene.avatar_toolkit.remove_doubles_merge_distance + + if len(mesh['shapekeys']) > 0 and not advanced: + shapekeyname = mesh['shapekeys'].pop(0) + mesh["mesh"].active_shape_key_index = mesh_data.shape_keys.key_blocks.find(shapekeyname) + logger.debug(f"Processing shapekey {shapekeyname}") + self.modify_mesh(context, mesh) + + elif not mesh_data.shape_keys: + self.process_simple_mesh(context, mesh, merge_distance) + self.objects_to_do.pop(0) + + elif not (mesh["cur_vertex_pass"] > mesh["vertices"]) and advanced: + if self.modify_mesh_advanced(context, mesh): + mesh["cur_vertex_pass"] += 1 + + else: + self.finish_mesh_processing(context, mesh, advanced, merge_distance) + self.objects_to_do.pop(0) + + return {'RUNNING_MODAL'} + + except Exception as e: + logger.error(f"Error in modal: {str(e)}") + return {'CANCELLED'} diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index f445e83..8bdc720 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -80,6 +80,51 @@ "Scene.avatar_toolkit_updater_version_list.name": "Version List", "Scene.avatar_toolkit_updater_version_list.description": "List of available versions", + "Optimization.label": "Optimization", + "Optimization.materials_title": "Materials", + "Optimization.cleanup_title": "Mesh Cleanup", + "Optimization.join_meshes_title": "Join Meshes", + "Optimization.combine_materials": "Combine Materials", + "Optimization.combine_materials_desc": "Combine similar materials to reduce draw calls", + "Optimization.remove_doubles": "Remove Doubles", + "Optimization.remove_doubles_desc": "Remove duplicate vertices", + "Optimization.remove_doubles_advanced": "Advanced", + "Optimization.remove_doubles_advanced_desc": "Remove duplicate vertices with advanced options", + "Optimization.join_all_meshes": "Join All", + "Optimization.join_all_meshes_desc": "Join all meshes in the scene", + "Optimization.join_selected_meshes": "Join Selected", + "Optimization.join_selected_meshes_desc": "Join only selected meshes", + "Optimization.no_meshes": "No meshes found to optimize", + "Optimization.materials_combined": "Combined {combined} materials, cleaned {cleaned} slots, and removed {removed} unused data blocks", + "Optimization.error.combine_materials": "Failed to combine materials: {error}", + "Optimization.materials_total": "Total Materials: {count}", + "Optimization.materials_duplicates": "Potential Duplicates: {count}", + "Optimization.no_materials": "No materials found on meshes", + "Optimization.error.consolidation": "Failed to consolidate materials. Check console for details", + "Optimization.combining_materials": "Combining similar materials...", + "Optimization.cleaning_slots": "Cleaning material slots...", + "Optimization.removing_unused": "Removing unused materials...", + "Optimization.selecting_meshes": "Selecting meshes...", + "Optimization.joining_meshes": "Joining meshes...", + "Optimization.applying_transforms": "Applying transforms...", + "Optimization.fixing_uvs": "Fixing UV coordinates...", + "Optimization.finalizing": "Finalizing...", + "Optimization.meshes_joined": "All meshes joined successfully", + "Optimization.selected_meshes_joined": "Selected meshes joined successfully", + "Optimization.no_mesh_selected": "No meshes selected", + "Optimization.select_at_least_two": "Please select at least two meshes", + "Optimization.error.join_meshes": "Failed to join meshes: {error}", + "Optimization.error.join_selected": "Failed to join selected meshes: {error}", + "Optimization.merge_distance": "Merge Distance", + "Optimization.merge_distance_desc": "Distance within which vertices will be merged", + "Optimization.remove_doubles_warning": "This process may take a long time", + "Optimization.remove_doubles_wait": "Blender may seem unresponsive during this operation", + "Optimization.error.remove_doubles": "Failed to remove doubles: {error}", + "Optimization.no_armature": "No armature selected", + "Optimization.processing_mesh": "Processing mesh: {name}", + "Optimization.processing_shapekey": "Processing shape key: {name}", + "Optimization.remove_doubles_completed": "Remove doubles completed successfully", + "Settings.label": "Settings", "Settings.language": "Language", "Settings.language_desc": "Select interface language", diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/optimization_panel.py b/ui/optimization_panel.py new file mode 100644 index 0000000..2e65ec1 --- /dev/null +++ b/ui/optimization_panel.py @@ -0,0 +1,50 @@ +import bpy +from typing import Set +from bpy.types import Panel, Context, UILayout, Operator +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from ..core.translations import t + +class AvatarToolKit_PT_OptimizationPanel(Panel): + """Panel containing mesh and material optimization tools for avatar optimization""" + bl_label: str = t("Optimization.label") + bl_idname: str = "OBJECT_PT_avatar_toolkit_optimization" + bl_space_type: str = 'VIEW_3D' + bl_region_type: str = 'UI' + bl_category: str = CATEGORY_NAME + bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname + bl_order: int = 1 + + def draw(self, context: Context) -> None: + """Draws the optimization panel interface with material, mesh cleanup and join mesh tools""" + layout: UILayout = self.layout + + # Materials Box + materials_box: UILayout = layout.box() + col: UILayout = materials_box.column(align=True) + col.label(text=t("Optimization.materials_title"), icon='MATERIAL') + col.separator(factor=0.5) + + # Material Operations + col.operator("avatar_toolkit.combine_materials", icon='MATERIAL') + + # Mesh Cleanup Box + cleanup_box: UILayout = layout.box() + col: UILayout = cleanup_box.column(align=True) + col.label(text=t("Optimization.cleanup_title"), icon='MESH_DATA') + col.separator(factor=0.5) + + # Remove Doubles Row + row: UILayout = col.row(align=True) + row.operator("avatar_toolkit.remove_doubles", icon='MESH_DATA') + row.operator("avatar_toolkit.remove_doubles_advanced", icon='PREFERENCES') + + # Join Meshes Box + join_box: UILayout = layout.box() + col: UILayout = join_box.column(align=True) + col.label(text=t("Optimization.join_meshes_title"), icon='OBJECT_DATA') + col.separator(factor=0.5) + + # Join Meshes Row + row: UILayout = col.row(align=True) + row.operator("avatar_toolkit.join_all_meshes", icon='OBJECT_DATA') + row.operator("avatar_toolkit.join_selected_meshes", icon='RESTRICT_SELECT_OFF') diff --git a/ui/settings_panel.py b/ui/settings_panel.py index a948ef3..df86987 100644 --- a/ui/settings_panel.py +++ b/ui/settings_panel.py @@ -36,7 +36,7 @@ class AvatarToolKit_PT_SettingsPanel(Panel): bl_region_type: str = 'UI' bl_category: str = CATEGORY_NAME bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order: int = 2 + bl_order: int = 4 def draw(self, context: Context) -> None: """Draw the settings panel layout with language selection"""