From 6d9f751a163c4ce73ae0559e22687cfd35235f27 Mon Sep 17 00:00:00 2001 From: 989onan Date: Thu, 10 Jul 2025 18:44:42 -0400 Subject: [PATCH] Housekeeping (bug fixes) NEW FEATURES: - added apply shapekey to basis from Cats - now that pesky thing I keep going back to cats for is in Avatar Toolkit. BUG FIXES: - now we push armature santizers into functions where they are needed - this prevents the methods from mirroring changes while working, causing them to blow up when mirror mode is on - more changes to come for armature setting santitizers - fixed error reporting - now methods when catching errors will return full error tracebacks - this will help make debugging and finding user issues easier. --- __init__.py | 5 + core/common.py | 37 +- core/importers/importer.py | 9 +- core/mmd/core/camera.py | 21 +- core/mmd/operators/material.py | 5 +- core/resonite_utils.py | 11 +- functions/atlas_materials.py | 3 +- functions/custom_tools/__init__.py | 0 functions/custom_tools/armature_merging.py | 9 + .../custom_tools/force_apply_modifier.py | 11 +- functions/custom_tools/mesh_attachment.py | 16 +- functions/eye_tracking.py | 15 +- functions/optimization/materials_tools.py | 19 +- functions/optimization/mesh_tools.py | 13 +- functions/optimization/remove_doubles.py | 4 +- functions/pose_mode.py | 25 +- functions/tools/additional_tools.py | 13 +- functions/tools/apply_shapekey_to_basis.py | 451 ++++++++++++++++++ functions/tools/bone_tools.py | 37 +- functions/tools/merge_tools.py | 47 +- functions/tools/mesh_separation.py | 9 +- functions/tools/rigify_converter.py | 7 +- functions/tools/standardize_armature.py | 17 +- functions/tools/uv_tools.py | 5 +- functions/visemes.py | 7 +- resources/translations/en_US.json | 5 + ui/atlas_materials_panel.py | 5 +- 27 files changed, 663 insertions(+), 143 deletions(-) create mode 100644 functions/custom_tools/__init__.py create mode 100644 functions/tools/apply_shapekey_to_basis.py diff --git a/__init__.py b/__init__.py index 1b5d169..2b8b692 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,7 @@ import bpy from bpy.app.handlers import persistent + modules = None ordered_classes = None @@ -42,6 +43,10 @@ def register(): log_level = get_preference("log_level", "WARNING") configure_logging(get_preference("enable_logging", False), log_level) + #this needs to be done last, or at least after whatever things this uses is imported - @989onan + from .functions.tools.apply_shapekey_to_basis import add_to_menu + bpy.types.MESH_MT_shape_key_context_menu.append(add_to_menu) + print("Registration complete") def unregister(): diff --git a/core/common.py b/core/common.py index 2220f9e..d1785b2 100644 --- a/core/common.py +++ b/core/common.py @@ -1,3 +1,4 @@ +import traceback import bpy import numpy as np import threading @@ -199,9 +200,9 @@ def apply_pose_as_rest(context: Context, armature_obj: Object, meshes: List[Obje return True, t("Operation.pose_applied") - except Exception as e: - logger.error(f"Error applying pose as rest: {str(e)}") - return False, str(e) + except Exception: + logger.error(f"Error applying pose as rest: {traceback.format_exc()}") + return False, traceback.format_exc() def apply_armature_to_mesh(armature_obj: Object, mesh_obj: Object) -> None: """Apply armature deformation to mesh""" @@ -335,8 +336,8 @@ def join_mesh_objects(context: Context, meshes: List[Object], progress: Optional return joined_mesh - except Exception as e: - logger.error(f"Failed to join meshes: {str(e)}") + except Exception: + logger.error(f"Failed to join meshes: {traceback.format_exc()}") return None @@ -365,8 +366,8 @@ def fix_uv_coordinates(context: Context) -> None: 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)}") + except Exception: + logger.warning(f"UV Fix - Skipped processing for {obj.name}: {traceback.format_exc()}") finally: bpy.ops.object.mode_set(mode='OBJECT') @@ -488,7 +489,6 @@ def fix_zero_length_bones(armature: Object) -> None: """Fix zero length bones by setting a minimum length""" if not armature: return - bpy.ops.object.mode_set(mode='EDIT') for bone in armature.data.edit_bones: if bone.length < 0.001: @@ -631,6 +631,7 @@ def get_objects() -> bpy.types.BlendData: def duplicate_bone(bone: EditBone) -> EditBone: """Create a duplicate of the given bone""" + new_bone: EditBone = bone.id_data.edit_bones.new(bone.name + "_copy") new_bone.head = bone.head.copy() new_bone.tail = bone.tail.copy() @@ -642,14 +643,18 @@ def duplicate_bone(bone: EditBone) -> EditBone: new_bone.use_deform = bone.use_deform return new_bone -#Binary tools - - - - -#encoding FrooxEngine/C# types in binary: - - + + +class ArmatureData(Tuple[bool,bool]): + pass + +def store_breaking_settings_armature(armature: bpy.types.Object) -> ArmatureData: + armature_data: bpy.types.Armature = armature.data + return (armature_data.use_mirror_x, armature.pose.use_mirror_x) + +def restore_breaking_settings_armature(armature: bpy.types.Object, data: ArmatureData) -> None: + armature_data: bpy.types.Armature = armature.data + armature_data.use_mirror_x, armature.pose.use_mirror_x = data diff --git a/core/importers/importer.py b/core/importers/importer.py index 237fc92..d8000bb 100644 --- a/core/importers/importer.py +++ b/core/importers/importer.py @@ -9,6 +9,7 @@ from typing import Optional, Callable, Dict, List, Union, Set from ..common import clear_default_objects from ..translations import t from ..mmd.core.pmx.importer import PMXImporter +import traceback # Configure logging logging.basicConfig(level=logging.INFO) @@ -84,8 +85,8 @@ def import_multi_files( progress_callback(fullpath) progress.update(file["name"]) - except Exception as e: - logger.error(f"Import failed: {str(e)}", exc_info=True) + except Exception: + logger.error(f"Import failed: {traceback.format_exc()}", exc_info=True) raise ImportMethod = Callable[[str, List[Dict[str, str]], str], None] @@ -230,6 +231,6 @@ def import_pmx_file(filepath: str) -> None: try: importer.execute(**import_settings) logger.info(f"Successfully imported PMX file: {filepath}") - except Exception as e: - logger.error(f"Failed to import PMX file: {str(e)}", exc_info=True) + except Exception: + logger.error(f"Failed to import PMX file: {traceback.format_exc()}", exc_info=True) raise diff --git a/core/mmd/core/camera.py b/core/mmd/core/camera.py index 4c45d80..3c6da6e 100644 --- a/core/mmd/core/camera.py +++ b/core/mmd/core/camera.py @@ -11,6 +11,7 @@ from typing import Optional, List, Tuple, Callable, Any, Union import bpy from bpy.types import Object, ID, Camera, Context from mathutils import Vector, Matrix, Euler +import traceback from ..bpyutils import FnContext, Props from ....core.logging_setup import logger @@ -87,8 +88,8 @@ class FnCamera: __add_driver(camera_object.data, "type", "not $is_perspective") __add_driver(camera_object.data, "lens", "$sensor_height/tan($angle/2)/2") logger.debug(f"Successfully added drivers to camera: {camera_object.name}") - except Exception as e: - logger.error(f"Failed to add drivers to camera {camera_object.name}: {str(e)}") + except Exception: + logger.error(f"Failed to add drivers to camera {camera_object.name}: {traceback.format_exc()}") @staticmethod def remove_drivers(camera_object: Object) -> None: @@ -100,8 +101,8 @@ class FnCamera: camera_object.data.driver_remove("ortho_scale") camera_object.data.driver_remove("lens") logger.debug(f"Successfully removed drivers from camera: {camera_object.name}") - except Exception as e: - logger.error(f"Failed to remove drivers from camera {camera_object.name}: {str(e)}") + except Exception: + logger.error(f"Failed to remove drivers from camera {camera_object.name}: {traceback.format_exc()}") class MigrationFnCamera: @@ -124,8 +125,8 @@ class MigrationFnCamera: FnCamera.remove_drivers(camera_object) FnCamera.add_drivers(camera_object) updated_count += 1 - except Exception as e: - logger.error(f"Failed to update MMD camera {camera_object.name}: {str(e)}") + except Exception: + logger.error(f"Failed to update MMD camera {camera_object.name}: {traceback.format_exc()}") logger.info(f"Updated {updated_count} MMD cameras") @@ -197,8 +198,8 @@ class MMDCamera: logger.info(f"Successfully converted {cameraObj.name} to MMD camera") return MMDCamera(empty) - except Exception as e: - logger.error(f"Failed to convert camera {cameraObj.name} to MMD camera: {str(e)}") + except Exception: + logger.error(f"Failed to convert camera {cameraObj.name} to MMD camera: {traceback.format_exc()}") raise @staticmethod @@ -305,8 +306,8 @@ class MMDCamera: logger.info(f"Successfully created MMD camera animation with {frame_count} frames") return MMDCamera(mmd_cam_root) - except Exception as e: - logger.error(f"Failed to create MMD camera animation: {str(e)}") + except Exception: + logger.error(f"Failed to create MMD camera animation: {traceback.format_exc()}") raise def object(self) -> Object: diff --git a/core/mmd/operators/material.py b/core/mmd/operators/material.py index a6ea15a..caa76fe 100644 --- a/core/mmd/operators/material.py +++ b/core/mmd/operators/material.py @@ -16,6 +16,7 @@ from ..core.exceptions import MaterialNotFoundError from ..core.material import FnMaterial from ..core.shader import _NodeGroupUtils from ....core.logging_setup import logger +import traceback class ConvertMaterialsForCycles(Operator): @@ -50,8 +51,8 @@ class ConvertMaterialsForCycles(Operator): def execute(self, context: Context) -> Set[str]: try: context.scene.render.engine = "CYCLES" - except Exception as e: - logger.error(f"Failed to change to Cycles render engine: {str(e)}") + except Exception: + logger.error(f"Failed to change to Cycles render engine: {traceback.format_exc()}") self.report({"ERROR"}, " * Failed to change to Cycles render engine.") return {"CANCELLED"} diff --git a/core/resonite_utils.py b/core/resonite_utils.py index 02d56ed..b5e539f 100644 --- a/core/resonite_utils.py +++ b/core/resonite_utils.py @@ -4,6 +4,7 @@ import bpy_extras from numpy import double from typing import Set, Dict import re +import traceback from .common import get_active_armature, ProgressTracker, identify_bones from bpy.types import Context, Operator @@ -91,16 +92,16 @@ class AvatarToolkit_OT_ConvertResonite(Operator): progress.step(t("Tools.convert_resonite.processing", name=bone.name)) - except Exception as e: - logger.error(f"Error during Resonite conversion: {str(e)}") - self.report({'ERROR'}, str(e)) + except Exception: + logger.error(f"Error during Resonite conversion: {traceback.format_exc()}") + self.report({'ERROR'}, traceback.format_exc()) return {'CANCELLED'} finally: try: bpy.ops.object.mode_set(mode='OBJECT') - except Exception as e: - logger.warning(f"Error returning to object mode: {str(e)}") + except Exception: + logger.warning(f"Error returning to object mode: {traceback.format_exc()}") if translate_bone_fails > 0: logger.info(f"Conversion completed with {translate_bone_fails} untranslated bones") diff --git a/functions/atlas_materials.py b/functions/atlas_materials.py index 8c38f9e..c50cf17 100644 --- a/functions/atlas_materials.py +++ b/functions/atlas_materials.py @@ -8,6 +8,7 @@ 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 +import traceback class MaterialImageList: def __init__(self): @@ -306,6 +307,6 @@ class AvatarToolKit_OT_AtlasMaterials(Operator): return {"FINISHED"} except Exception as e: - logger.error(f"Error creating material atlas: {str(e)}", exc_info=True) + logger.error(f"Error creating material atlas: {traceback.format_exc()}", exc_info=True) self.report({'ERROR'}, t("TextureAtlas.atlas_error")) raise e diff --git a/functions/custom_tools/__init__.py b/functions/custom_tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/functions/custom_tools/armature_merging.py b/functions/custom_tools/armature_merging.py index c305d13..d65afa2 100644 --- a/functions/custom_tools/armature_merging.py +++ b/functions/custom_tools/armature_merging.py @@ -13,6 +13,8 @@ from ...core.common import ( join_mesh_objects, remove_unused_shapekeys, identify_bones, + store_breaking_settings_armature, + restore_breaking_settings_armature, ) from ...core.dictionaries import simplify_bonename @@ -42,6 +44,9 @@ class AvatarToolkit_OT_MergeArmature(bpy.types.Operator): logger.error(f"Armature not found: {merge_armature_name}") self.report({'ERROR'}, t('MergeArmature.error.not_found', name=merge_armature_name)) return {'CANCELLED'} + #Store current armature settings that can mess us up. + data_breaking_base = store_breaking_settings_armature(base_armature) + data_breaking_merge = store_breaking_settings_armature(merge_armature) # Remove Rigid Bodies and Joints delete_rigidbodies_and_joints(base_armature) @@ -70,7 +75,11 @@ class AvatarToolkit_OT_MergeArmature(bpy.types.Operator): wm.progress_update(100) wm.progress_end() + restore_breaking_settings_armature(base_armature, data_breaking_base) + restore_breaking_settings_armature(merge_armature, data_breaking_merge) + self.report({'INFO'}, t('MergeArmature.success')) + return {'FINISHED'} except Exception as e: diff --git a/functions/custom_tools/force_apply_modifier.py b/functions/custom_tools/force_apply_modifier.py index d0446e3..266c263 100644 --- a/functions/custom_tools/force_apply_modifier.py +++ b/functions/custom_tools/force_apply_modifier.py @@ -117,14 +117,11 @@ class AvatarToolkit_OT_ApplyModifierForShapkeyObj(bpy.types.Operator): obj.select_set(True) context.view_layer.objects.active = obj bpy.ops.object.join_shapes() - except Exception as e: + except Exception: self.report({'ERROR'}, f"Shapekey joining failed!!") print(f"Shapekey joining failed!!") - print(traceback.format_exc(e)) - #clean up after critical failure - for shape in shapes: - bpy.data.objects.remove(shape)#faster than ops delete + print(traceback.format_exc()) #final clean up for shape in shapes: @@ -136,4 +133,6 @@ class AvatarToolkit_OT_ApplyModifierForShapkeyObj(bpy.types.Operator): - return {'FINISHED'} \ No newline at end of file + return {'FINISHED'} + + diff --git a/functions/custom_tools/mesh_attachment.py b/functions/custom_tools/mesh_attachment.py index d660e76..20d4900 100644 --- a/functions/custom_tools/mesh_attachment.py +++ b/functions/custom_tools/mesh_attachment.py @@ -2,6 +2,7 @@ import bpy from bpy.types import Operator, Context, Object, ArmatureModifier, VertexGroup from mathutils import Vector from typing import Set, Optional, List, Any +import traceback from ...core.logging_setup import logger from ...core.translations import t @@ -10,7 +11,9 @@ from ...core.common import ( get_all_meshes, ProgressTracker, calculate_bone_orientation, - add_armature_modifier + add_armature_modifier, + store_breaking_settings_armature, + restore_breaking_settings_armature, ) from ...core.armature_validation import validate_armature @@ -83,11 +86,11 @@ class AvatarToolkit_OT_AttachMesh(Operator): attach_to_bone = armature.data.edit_bones.get(attach_bone_name) if not attach_to_bone: raise ValueError(t("AttachMesh.error.bone_not_found", bone=attach_bone_name)) - + data_breaking = store_breaking_settings_armature(armature) mesh_bone = armature.data.edit_bones.new(mesh_name) mesh_bone.parent = attach_to_bone progress.step(t("AttachMesh.create_bone")) - + # Calculate bone placement verts_in_group: List[Any] = [v for v in mesh.data.vertices for g in v.groups if g.group == vg.index] @@ -104,6 +107,7 @@ class AvatarToolkit_OT_AttachMesh(Operator): mesh_bone.head = center mesh_bone.tail = center + Vector((0, 0, max(0.1, dimensions.z))) mesh_bone.roll = roll_angle + restore_breaking_settings_armature(armature, data_breaking) progress.step(t("AttachMesh.position_bone")) bpy.ops.object.mode_set(mode='OBJECT') @@ -114,9 +118,9 @@ class AvatarToolkit_OT_AttachMesh(Operator): self.report({'INFO'}, t("AttachMesh.success")) return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to attach mesh: {str(e)}") - self.report({'ERROR'}, str(e)) + except Exception: + logger.error(f"Failed to attach mesh: {traceback.format_exc()}") + self.report({'ERROR'}, traceback.format_exc()) return {'CANCELLED'} def validate_mesh_transforms(mesh: Optional[Object]) -> tuple[bool, str]: diff --git a/functions/eye_tracking.py b/functions/eye_tracking.py index e720286..0dcf653 100644 --- a/functions/eye_tracking.py +++ b/functions/eye_tracking.py @@ -10,6 +10,7 @@ from typing import Optional, Dict, Tuple, Set, List, Any, Union, ClassVar from collections import OrderedDict from random import random from itertools import chain +import traceback from ..core.logging_setup import logger from ..core.translations import t @@ -104,8 +105,8 @@ class CreateEyesAV3Button(bpy.types.Operator): self.report({'INFO'}, t('EyeTracking.success')) return {'FINISHED'} - except Exception as e: - logger.error(f"Eye tracking setup failed: {str(e)}") + except Exception: + logger.error(f"Eye tracking setup failed: {traceback.format_exc()}") return {'CANCELLED'} class CreateEyesSDK2Button(bpy.types.Operator): @@ -197,7 +198,7 @@ class CreateEyesSDK2Button(bpy.types.Operator): return {'FINISHED'} except Exception as e: - logger.error(f"Eye tracking setup failed: {str(e)}") + logger.error(f"Eye tracking setup failed: {traceback.format_exc()}") return {'CANCELLED'} class EyeTrackingBackup: @@ -222,8 +223,8 @@ class EyeTrackingBackup: with open(self.backup_path, 'w') as f: json.dump(self.bone_positions, f) return True - except Exception as e: - logger.error(f"Backup failed: {str(e)}") + except Exception: + logger.error(f"Backup failed: {traceback.format_exc()}") return False def restore_bone_positions(self, armature) -> bool: @@ -243,8 +244,8 @@ class EyeTrackingBackup: bone.tail = positions['tail'] return True - except Exception as e: - logger.error(f"Restore failed: {str(e)}") + except Exception: + logger.error(f"Restore failed: {traceback.format_exc()}") return False class EyeTrackingValidator: diff --git a/functions/optimization/materials_tools.py b/functions/optimization/materials_tools.py index b6983d4..9dfee9b 100644 --- a/functions/optimization/materials_tools.py +++ b/functions/optimization/materials_tools.py @@ -18,6 +18,7 @@ from ...core.common import ( ProgressTracker ) from ...core.armature_validation import validate_armature +import traceback def textures_match(tex1: ShaderNodeTexImage, tex2: ShaderNodeTexImage) -> bool: """Compare two texture nodes for matching properties and image data""" @@ -112,24 +113,24 @@ class AvatarToolkit_OT_CombineMaterials(Operator): 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)}") + except Exception: + logger.error(f"Material consolidation failed: {traceback.format_exc()}") 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)}") + except Exception: + logger.error(f"Material slot cleanup failed: {traceback.format_exc()}") self.report({'ERROR'}, t("Optimization.error.slot_cleanup")) return {'CANCELLED'} progress.step("Cleaned material slots") try: num_removed = clear_unused_data_blocks() - except Exception as e: - logger.error(f"Data block cleanup failed: {str(e)}") + except Exception: + logger.error(f"Data block cleanup failed: {traceback.format_exc()}") self.report({'ERROR'}, t("Optimization.error.data_cleanup")) return {'CANCELLED'} progress.step("Removed unused data blocks") @@ -141,9 +142,9 @@ class AvatarToolkit_OT_CombineMaterials(Operator): 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))) + except Exception: + logger.error(f"Failed to combine materials: {traceback.format_exc()}") + self.report({'ERROR'}, t("Optimization.error.combine_materials", error=traceback.format_exc())) return {'CANCELLED'} def consolidate_materials(self, meshes: List[Object]) -> int: diff --git a/functions/optimization/mesh_tools.py b/functions/optimization/mesh_tools.py index 825b493..1870871 100644 --- a/functions/optimization/mesh_tools.py +++ b/functions/optimization/mesh_tools.py @@ -11,6 +11,7 @@ from ...core.common import ( ProgressTracker ) from ...core.armature_validation import validate_armature +import traceback class AvatarToolkit_OT_JoinAllMeshes(Operator): """Operator to join all meshes in the scene""" @@ -51,9 +52,9 @@ class AvatarToolkit_OT_JoinAllMeshes(Operator): self.report({'ERROR'}, t("Optimization.error.join_meshes")) 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))) + except Exception: + logger.error(f"Failed to join meshes: {traceback.format_exc()}") + self.report({'ERROR'}, t("Optimization.error.join_meshes", error=traceback.format_exc())) return {'CANCELLED'} class AvatarToolkit_OT_JoinSelectedMeshes(Operator): @@ -95,7 +96,7 @@ class AvatarToolkit_OT_JoinSelectedMeshes(Operator): self.report({'ERROR'}, t("Optimization.error.join_selected")) 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))) + except Exception: + logger.error(f"Failed to join selected meshes: {traceback.format_exc()}") + self.report({'ERROR'}, t("Optimization.error.join_selected", error=traceback.format_exc())) return {'CANCELLED'} diff --git a/functions/optimization/remove_doubles.py b/functions/optimization/remove_doubles.py index 4240d93..c7aede2 100644 --- a/functions/optimization/remove_doubles.py +++ b/functions/optimization/remove_doubles.py @@ -119,8 +119,8 @@ class AvatarToolkit_OT_RemoveDoubles(Operator): context.window_manager.modal_handler_add(self) return {'RUNNING_MODAL'} - except Exception as e: - logger.error(f"Error in execute: {str(e)}") + except Exception: + logger.error(f"Error in execute: {traceback.format_exc()}") return {'CANCELLED'} def modal(self, context: Context, event: Event) -> set[ModalReturnType]: diff --git a/functions/pose_mode.py b/functions/pose_mode.py index 6cf2b00..1a15e19 100644 --- a/functions/pose_mode.py +++ b/functions/pose_mode.py @@ -14,6 +14,7 @@ from ..core.common import ( process_armature_modifiers, ProgressTracker ) +import traceback from ..core.armature_validation import validate_armature class BatchPoseOperationMixin: @@ -62,9 +63,9 @@ class AvatarToolkit_OT_StartPoseMode(Operator): bpy.ops.object.mode_set(mode='POSE') return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to start pose mode: {str(e)}") - self.report({'ERROR'}, t("PoseMode.error.start", error=str(e))) + except Exception: + logger.error(f"Failed to start pose mode: {traceback.format_exc()}") + self.report({'ERROR'}, t("PoseMode.error.start", error=traceback.format_exc())) return {'CANCELLED'} class AvatarToolkit_OT_StopPoseMode(Operator): @@ -85,9 +86,9 @@ class AvatarToolkit_OT_StopPoseMode(Operator): bpy.ops.pose.select_all(action="INVERT") bpy.ops.object.mode_set(mode='OBJECT') return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to stop pose mode: {str(e)}") - self.report({'ERROR'}, t("PoseMode.error.stop", error=str(e))) + except Exception: + logger.error(f"Failed to stop pose mode: {traceback.format_exc()}") + self.report({'ERROR'}, t("PoseMode.error.stop", error=traceback.format_exc())) return {'CANCELLED'} class AvatarToolkit_OT_ApplyPoseAsRest(Operator, BatchPoseOperationMixin): @@ -129,9 +130,9 @@ class AvatarToolkit_OT_ApplyPoseAsRest(Operator, BatchPoseOperationMixin): progress.step(f"Processed {mesh_obj.name}") return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to apply pose as shape key: {str(e)}") - self.report({'ERROR'}, t("PoseMode.error.shapekey", error=str(e))) + except Exception: + logger.error(f"Failed to apply pose as shape key: {traceback.format_exc()}") + self.report({'ERROR'}, t("PoseMode.error.shapekey", error=traceback.format_exc())) return {'CANCELLED'} class AvatarToolkit_OT_ApplyPoseAsShapekey(Operator, BatchPoseOperationMixin): @@ -160,7 +161,7 @@ class AvatarToolkit_OT_ApplyPoseAsShapekey(Operator, BatchPoseOperationMixin): logger.info("Successfully applied pose as rest") return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to apply pose as rest: {str(e)}") - self.report({'ERROR'}, t("PoseMode.error.rest_pose", error=str(e))) + except Exception: + logger.error(f"Failed to apply pose as rest: {traceback.format_exc()}") + self.report({'ERROR'}, t("PoseMode.error.rest_pose", error=traceback.format_exc())) return {'CANCELLED'} diff --git a/functions/tools/additional_tools.py b/functions/tools/additional_tools.py index 91afaee..c7830d7 100644 --- a/functions/tools/additional_tools.py +++ b/functions/tools/additional_tools.py @@ -6,6 +6,7 @@ from ...core.translations import t from ...core.logging_setup import logger from ...core.common import get_active_armature, get_all_meshes, remove_unused_shapekeys from ...core.armature_validation import validate_armature +import traceback class AvatarToolkit_OT_ApplyTransforms(Operator): """Apply all transformations to armature and associated meshes""" @@ -42,9 +43,9 @@ class AvatarToolkit_OT_ApplyTransforms(Operator): self.report({'INFO'}, t("Tools.transforms_applied")) return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to apply transforms: {str(e)}") - self.report({'ERROR'}, str(e)) + except Exception: + logger.error(f"Failed to apply transforms: {traceback.format_exc()}") + self.report({'ERROR'}, traceback.format_exc()) return {'CANCELLED'} class AvatarToolkit_OT_CleanShapekeys(Operator): @@ -86,7 +87,7 @@ class AvatarToolkit_OT_CleanShapekeys(Operator): self.report({'INFO'}, t("Tools.shapekeys_removed", count=removed_count)) return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to clean shape keys: {str(e)}") - self.report({'ERROR'}, str(e)) + except Exception: + logger.error(f"Failed to clean shape keys: {traceback.format_exc()}") + self.report({'ERROR'}, traceback.format_exc()) return {'CANCELLED'} diff --git a/functions/tools/apply_shapekey_to_basis.py b/functions/tools/apply_shapekey_to_basis.py new file mode 100644 index 0000000..1e74aba --- /dev/null +++ b/functions/tools/apply_shapekey_to_basis.py @@ -0,0 +1,451 @@ +# GPL License +import bpy +import numpy as np +from ...core.translations import t +from typing import Set + +class AvatarToolkit_OT_ShapeKeyApplier(bpy.types.Operator): + # Applies the currently active shape key with its current value and vertex group to the 'Basis' shape key and all + # shape keys recursively relative to the 'Basis' shape key. + # Turns the currently active shape key into a shape key that reverts the original application if applied. + bl_idname: str = "avatar_toolkit.shape_key_to_basis" + bl_label: str = t('Tools.shapekey_to_basis.label') + bl_description: str = t('Tools.shapekey_to_basis.desc') + bl_options: Set[str] = {'REGISTER', 'UNDO', 'INTERNAL'} + + @classmethod + def poll(cls, context): + # Note that context.object.active_shape_key_index is 0 if there are no shape keys + # So context.object.active_shape_key_index > 0 simultaneously checks that there are shape keys and that the + # active shape key isn't the first one + return (context.mode == 'OBJECT' and + context.object and + # Could be extended to other types that have shape keys, but only MESH supported for now + context.object.type == 'MESH' and + # If the active shape key is the basis, nothing would be done + context.object.active_shape_key_index > 0 and + # If the shapes aren't relative, using relative keys to apply to the basis and all affected keys would + # be wrong and the idea of having a key to revert the change doesn't make sense + context.object.data.shape_keys.use_relative and + # If the active shape key is relative to itself, then it does nothing + context.object.active_shape_key.relative_key != context.object.active_shape_key) + + def execute(self, context): + # If an object other than the active object is to be used, it can be specified using a context override + mesh = context.object + + # Get shapekey which will be the new basis + new_basis_shapekey = mesh.active_shape_key + + # Create a map of key : [keys relative to key] + # Effectively the reverse of the key.relative_key relation + reverse_relative_map = AvatarToolkit_OT_ShapeKeyApplier.ReverseRelativeMap(mesh) + + # new_basis_shapekey will only be included if it's relative to itself (new_basis_shapekey cannot be the first shape key as poll() ensures + # that the index of the active shape key is greater than 0) + keys_relative_recursive_to_new_basis = reverse_relative_map.get_relative_recursive_keys(new_basis_shapekey) + + # Cancel execution if the new basis shape key is relative to itself (via a loop, since poll already returns false for being immediately relative to itself since that will always do nothing) + # If the relative keys loop back around, then if the key is turned into its reverse after applying, it would affect all keys that it's relative to + # Key1 relative -> Key2 + # Key2 relative -> Key1 + # If Key1 is applied to Basis, Key1 should be changed to a reverted key in order to undo the application. + # Since Key2 is relative to Key1, it has to be modified to account for the change in Key1 so that its relative movement to Key1 stays the same. + # Since Key1 is relative to Key2, it has to be modified to account for the change in Key2 so that its relative movement to Key2 stays the same, but that creates an infinite loop + # + # Another way of looking at it is if Key1 moves a vertex by +1, then Key2 MUST move that same vertex by -1 since they are relative to each other + # If Key1 is applied to the basis, it should become a reverted key that moves a vertex by -1 instead so that when it's re-applied, it undoes initial application + # But that would mean that Key2 would have to become a key that moves a vertex by +1, and we want the key to keep its original relative movement of -1 + if new_basis_shapekey in keys_relative_recursive_to_new_basis: + self.report({'ERROR_INVALID_INPUT'}, t('ShapeKeyApplier.error.recursiveRelativeToLoop', name=new_basis_shapekey.name)) + return {'CANCELLED'} + + # It should work to pick a different key as a basis, so long as that key is immediately relative to itself (key.relative_key == key) + # On the off chance that old_basis_shapekey is not relative to itself, ReverseRelativeMap(mesh) has special handling that treats it as if it always is + old_basis_shapekey = mesh.data.shape_keys.key_blocks[0] + + # old_basis_shapekey will be included if it's relative to itself or if it's the first shape key, + # so it's always included in this case + keys_relative_recursive_to_old_basis = reverse_relative_map.get_relative_recursive_keys(old_basis_shapekey) + + # 0.0 would have no effect, so set to 1.0 + if new_basis_shapekey.value == 0.0: + new_basis_shapekey.value = 1.0 + + AvatarToolkit_OT_ShapeKeyApplier.apply_key_to_basis(mesh=mesh, + new_basis_shapekey=new_basis_shapekey, + keys_relative_recursive_to_new_basis=keys_relative_recursive_to_new_basis, + keys_relative_recursive_to_basis=keys_relative_recursive_to_old_basis) + + # The active key is now a key that reverts to the old relative key so rename it as such + reverted_string = ' - Reverted' + reverted_string_len = len(reverted_string) + old_name = new_basis_shapekey.name + + if new_basis_shapekey.name[-reverted_string_len:] == reverted_string: + # If the last letters of the name are the reverted_string, remove them + new_basis_shapekey.name = new_basis_shapekey.name[:-reverted_string_len] + reverted = True + else: + # Add the reverted_string to the end of the name, so it's clear that this shape key now reverts + new_basis_shapekey.name = new_basis_shapekey.name + reverted_string + reverted = False + + # Setting the value to zero will make the mesh appear unchanged in overall shape and help to show that the operator has worked correctly + new_basis_shapekey.value = 0.0 + new_basis_shapekey.slider_min = 0.0 + # Regardless of what the max was before, 1.0 will now fully undo the applied shape key + new_basis_shapekey.slider_max = 1.0 + + response_message = 'ShapeKeyApplier.successRemoved' if reverted else 'ShapeKeyApplier.successSet' + self.report({'INFO'}, t(response_message, name=old_name)) + return {'FINISHED'} + + class ReverseRelativeMap: + def __init__(self, obj): + reverse_relative_map = {} + + basis_key = obj.data.shape_keys.key_blocks[0] + for key in obj.data.shape_keys.key_blocks: + # Special handling for basis shape key to treat it as if its always relative to itself + relative_key = basis_key if key == basis_key else key.relative_key + keys_relative_to_relative_key = reverse_relative_map.get(relative_key) + if keys_relative_to_relative_key is None: + keys_relative_to_relative_key = {key} + reverse_relative_map[relative_key] = keys_relative_to_relative_key + else: + keys_relative_to_relative_key.add(key) + self.reverse_relative_map = reverse_relative_map + + # + def get_relative_recursive_keys(self, shape_key): + shape_set = set() + + # Pretty much a depth-first search, but with loop prevention + def inner_recursive_loop(key, checked_set): + # Prevent infinite loops by maintaining a set of shapes that we've checked + if key not in checked_set: + # Need to add the current key to the set of shapes we've checked before the recursive call + checked_set.add(key) + keys_relative_to_shape_key_inner = self.reverse_relative_map.get(key) + if keys_relative_to_shape_key_inner: + for relative_to_inner in keys_relative_to_shape_key_inner: + shape_set.add(relative_to_inner) + inner_recursive_loop(relative_to_inner, checked_set) + + inner_recursive_loop(shape_key, set()) + return shape_set + + @staticmethod + # Isolate the active shape key such that afterwards, creating a new shape from mix will create a shape key that at + # a value of 1.0 is the same movement as the active shape key at its current value and vertex group + # Returns a function that restores the data that got affected due to the isolation + def isolate_active_shape(obj_with_shapes): + active_shape = obj_with_shapes.active_shape_key + restore_data = {} + + # When the value is 1.0, we can simply enable show_only_shape_key on the object + if active_shape.value == 1.0: + if obj_with_shapes.show_only_shape_key: + # Don't need to do anything, it's already isolated + pass + else: + # Store the current .show_only_shape_key value, so it can be restored later + restore_data['show_only_shape_key'] = False + obj_with_shapes.show_only_shape_key = True + # When the value is not 1.0, the next simplest method is to mute all the other shapes on the object + else: + # Mute all shapes and save their current .mute value, so it can be restored later + shapekey_mutes = [] + for key_block in obj_with_shapes.data.shape_keys.key_blocks: + shapekey_mutes.append(key_block.mute) + key_block.mute = True + # Unmute the active shape key + active_shape.mute = False + + restore_data['mutes'] = shapekey_mutes + + # show_only_shape_key acts as if active_shape.value is always 1.0, so it needs to be disabled if it's enabled + if obj_with_shapes.show_only_shape_key: + # store the current value so it can be restored + restore_data['show_only_shape_key'] = True + obj_with_shapes.show_only_shape_key = False + + # closure to restore + def restore_function(): + if restore_data: + mutes = restore_data.get('mutes') + if mutes: + # Restore shape key mutes + for mute, shape in zip(mutes, obj_with_shapes.data.shape_keys.key_blocks): + shape.mute = mute + show_only_shape_key = restore_data.get('show_only_shape_key') + # show_only_shape_key can be False so need to explicitly check for None + if show_only_shape_key is not None: + # Restore show_only_shape_key + obj_with_shapes.show_only_shape_key = show_only_shape_key + + return restore_function + + # Figures out what needs to be added to each affected key, then iterates through all the affected keys, getting the current shape, + # adding the corresponding amount to it and then setting that as the new shape. + # Gets and sets shape key positions manually with foreach_get and foreach_set + # The slowest part of this function when the number of vertices increase are the shape_key.data.foreach_set() and + # shape_key.data.foreach_get() calls, so the number of calls of those should be minimised for performance + @staticmethod + def apply_key_to_basis(*, mesh, new_basis_shapekey, keys_relative_recursive_to_new_basis, keys_relative_recursive_to_basis): + data = mesh.data + num_verts = len(data.vertices) + + new_basis_shapekey_vertex_group_name = new_basis_shapekey.vertex_group + if new_basis_shapekey_vertex_group_name: + new_basis_shapekey_vertex_group = mesh.vertex_groups.get(new_basis_shapekey_vertex_group_name) + else: + new_basis_shapekey_vertex_group = None + + new_basis_affected_by_own_application = new_basis_shapekey in keys_relative_recursive_to_basis + + # Array of Vector type is flattened by foreach_get into a sequence so the length needs to be multiplied by 3 + flattened_co_length = num_verts * 3 + + # Store shape key vertex positions for new_basis + # There's no need to initialise the elements to anything since they will all be overwritten + # The ShapeKeyPoint type's 'co' property is a FloatProperty type, these are single precision floats + # It's extremely important for performance that the correct float type (np.single/np.float32) is used + # Using the wrong type could result in 3-5 times slower performance (depending on array length) due to Blender + # being required to iterate through each element in the data first instead of immediately setting/getting all + # the data directly + # See foreach_getset in bpy.rna.c of the Blender source for the implementation + new_basis_co_flat = np.empty(flattened_co_length, dtype=np.single) + new_basis_relative_co_flat = np.empty(flattened_co_length, dtype=np.single) + + new_basis_shapekey.data.foreach_get('co', new_basis_co_flat) + new_basis_shapekey.relative_key.data.foreach_get('co', new_basis_relative_co_flat) + + # This is movement of the active shape key at a value of 1.0 + difference_co_flat = np.subtract(new_basis_co_flat, new_basis_relative_co_flat) + + # Scale the difference based on the value of the active key + difference_co_flat_value_scaled = np.multiply(difference_co_flat, new_basis_shapekey.value) + + # We can reuse these arrays over and over instead of creating new ones each time + temp_co_array = np.empty(flattened_co_length, dtype=np.single) + temp_co_array2 = np.empty(flattened_co_length, dtype=np.single) + + # Scale the difference based on the vertex group of the active key + # Ideally, we would scale difference_co_flat by the weight of each vertex in new_basis_shapekey.vertex_group. + # Unfortunately, Blender has no efficient way to get all the weights for a particular vertex group, so it's + # pretty much always a few times faster to create a new shape from mix and get its 'co' with foreach_get(...) + # https://developer.blender.org/D6227 has the sort of function we're after, which could make it into Blender + # one day. + # + # For reference, the ways to get all vertex weights that you can find on stackoverflow: + # Weights from vertices: + # This scales really poorly when lots of vertices are in multiple vertex groups, especially when the vertices are not in the vertex group we want to check, + # because for every vertex v, v.groups has to be iterated until either the vertex group is found or iteration finishes without finding the vertex group + # vertex_weights = [next((g.weight for g in v.groups if g.group == vertex_group_index), 0) for v in data.vertices] + # Equivalent to: + # vertex_weights = [] + # for v in data.vertices: + # weight = 0 + # for g in v.groups: + # if g.group == vertex_group_index: + # weight = g.weight + # break + # vertex_weights.append(weight) + # + # Weights from vertex group: + # This doesn't scale poorly with lots of vertex groups like the other way does, but, if most of the vertices aren't in the vertex group, relying on catching + # the exception is really slow. If Blender had a similar method that returned a default value or even just None instead of throwing an exception, this would + # be much faster, though likely still slower than creating a new key from mix. + # Ideally we'd want a fast access method like foreach_get(...) instead of having to iterate through all the vertices individually + # vertex_weights = [] + # for i in range(num_verts): + # try: + # weight = vertex_group.weight(i) + # except: + # weight = 0 + # vertex_weights.append(weight) + if new_basis_shapekey_vertex_group: + # Need to isolate the active shape key, so that when a new shape is created from mix, it's only the active shape key + restore_function = AvatarToolkit_OT_ShapeKeyApplier.isolate_active_shape(mesh) + # This new shape key has the effect of new_basis.value and new_basis.vertex_group applied + new_basis_mixed = mesh.shape_key_add(name="temp shape (you shouldn't see this)", from_mix=True) + # Restore whatever got changed in order to isolate the active shape key + restore_function() + + # Use the temp array, new name for convenience + temp_shape_co_flat = temp_co_array + + new_basis_mixed.data.foreach_get('co', temp_shape_co_flat) + + # Often, the relative keys are the same, e.g. they're both the 'basis', but if they're not we'll need to get its data + if new_basis_mixed.relative_key == new_basis_shapekey.relative_key: + temp_shape_relative_co_flat = new_basis_relative_co_flat + else: + new_basis_mixed.relative_key.data.foreach_get('co', temp_co_array2) + temp_shape_relative_co_flat = temp_co_array2 + + difference_co_flat_scaled = np.subtract(temp_shape_co_flat, temp_shape_relative_co_flat) + + # Remove new_basis_mixed + active_index = mesh.active_shape_key_index + mesh.shape_key_remove(new_basis_mixed) + mesh.active_shape_key_index = active_index + else: + difference_co_flat_scaled = difference_co_flat_value_scaled + + if new_basis_affected_by_own_application: + # All keys in keys_recursive_relative_to_new_basis must also be in keys_recursive_relative_to_basis + # All the keys that will have only difference_co_flat_scaled added to them are those which are neither + # new_basis nor relative recursive to new_basis + keys_not_relative_recursive_to_new_basis_and_not_new_basis = (keys_relative_recursive_to_basis - keys_relative_recursive_to_new_basis) - {new_basis_shapekey} + + # This for loop is where most of the execution will happen for 'normal' setups of lots of shape keys relative to the first shape + # I looked into using multiprocessing to parallelise this, but type(key_block) and type(key_block.data) can't be pickled, + # i.e. you can't parallelise a list of either of them + # + # Add difference between new_basis_shapekey and new_basis_shapekey.relative_key (scaled according to the value and vertex_group of new_basis_shapekey) + # We already have the co array for new_basis_shapekey.relative_key, so do it separately to save a foreach_get call + new_basis_shapekey.relative_key.data.foreach_set('co', np.add(new_basis_relative_co_flat, difference_co_flat_scaled, out=temp_co_array)) + # And now the rest of the shape keys + for key_block in keys_not_relative_recursive_to_new_basis_and_not_new_basis - {new_basis_shapekey.relative_key}: + key_block.data.foreach_get('co', temp_co_array) + key_block.data.foreach_set('co', np.add(temp_co_array, difference_co_flat_scaled, out=temp_co_array)) + + # Shorthand key: + # NB = new_basis_shapekey + # NB.r = new_basis_shapekey.relative_key + # r(NB) = reverted(new_basis_shapekey) + # r(NB).r = reverted(new_basis_shapekey).relative_key + # NB.v = new_basis_shapekey.value + # NB.vg = new_basis_shapekey.vertex_group + # + # We need the difference between r(NB) and r(NB).r to be the negative of + # (r(NB) - r(NB).r) * NB.vg = -((NB - NB.r) * NB.v * NB.vg) + # = -(NB - NB.r) * NB.v * NB.vg + # NB.vg cancels on both sides, leaving: + # r(NB) - r(NB).r = -(NB - NB.r) * NB.v + # Rearranging for r(NB) gives: + # r(NB) = r(NB).r - (NB - NB.r) * NB.v + # Note that (NB - NB.r) * NB.v = difference_co_flat_value_scaled so: + # r(NB) = r(NB).r - difference_co_flat_value_scaled + # Note that r(NB).r = NB.r + difference_co_flat_scaled as we've added that to it + # r(NB) = NB.r + difference_co_flat_scaled - difference_co_flat_value_scaled + # Note that r(NB) = NB + X where X is what we want to find to add to NB (and all keys relative to it + # so that their relative differences remain the same) + # NB + X = NB.r + difference_co_flat_scaled - difference_co_flat_value_scaled + # X = NB.r - NB + difference_co_flat_scaled - difference_co_flat_value_scaled + # X = -(NB - NB.r) + difference_co_flat_scaled - difference_co_flat_value_scaled + # Fully expanding out would give: + # X = -(NB - NB.r) + (NB - NB.r) * NB.v * NB.vg - (NB - NB.r) * NB.v + # + # In the case of there being a vertex group, it's too costly to calculate NB.vg on its own, so we'll leave it at + # X = -(NB - NB.r) + difference_co_flat_scaled - (NB - NB.r) * NB.v + # Which we can either factor to + # X = (NB - NB.r)(-1 - NB.v) + difference_co_flat_scaled + # X = difference_co_flat * (-1 - NB.v) + difference_co_flat_scaled + # Or, as NB - NB.r = difference_co_flat, calculate as + # X = -difference_co_flat + difference_co_flat_scaled - difference_co_flat_value_scaled + # + # The numpy functions take close to a negligible amount of the total function time, so the choice isn't very + # important, however, from my own benchmarks, np.multiply(array1, scalar, out=output_array) starts to scale + # slightly better than np.add(array1, array2, out=output_array) once array1 gets to around 9000 elements or + # more + # I guess this is due to the fact that the add operation needs to do 1 extra array access per element, and + # that eventually surpasses the effect of the multiply operation being more expensive than the add + # operation + # In this case, the array length is 3*num_verts, meaning the multiplication option gets better at around + # 3000 vertices. We'll use the multiplication option + if new_basis_shapekey_vertex_group: + np.multiply(difference_co_flat, -1 - new_basis_shapekey.value, out=temp_co_array2) + np.add(temp_co_array2, difference_co_flat_scaled, out=temp_co_array2) + + # We already have the co array for new_basis_shapekey, so we can do it separately from the others to + # save a foreach_get call + new_basis_shapekey.data.foreach_set('co', np.add(new_basis_co_flat, temp_co_array2, out=temp_co_array)) + + # Now add to the rest of the keys + for key_block in keys_relative_recursive_to_new_basis: + key_block.data.foreach_get('co', temp_co_array) + key_block.data.foreach_set('co', np.add(temp_co_array, temp_co_array2, out=temp_co_array)) + # But for there not being a vertex group, the NB.vg term can be eliminated as it becomes effectively 1.0 + # X = -(NB - NB.r) + (NB - NB.r) * NB.v - (NB - NB.r) * NB.v + # Then the last part cancels out + # X = -(NB - NB.r) + # Giving X = -difference_co_flat + else: + # Instead of adding the difference_co_flat_scaled to each key it will be subtracted from each key instead + # We already have the co array for new_basis_shapekey, so we can do it separately to avoid a foreach_get + # Note that + # difference_co_flat = NB - NB.r + # Rearrange for NB.r + # NB.r = NB - difference_co_flat + # Instead of doing np.subtract(new_basis_co_flat, difference_co_flat) we can simply set NB to NB.r + new_basis_shapekey.data.foreach_set('co', new_basis_relative_co_flat) + # And the rest of the shape keys + for key_block in keys_relative_recursive_to_new_basis: + key_block.data.foreach_get('co', temp_co_array) + key_block.data.foreach_set('co', np.subtract(temp_co_array, difference_co_flat, out=temp_co_array)) + else: + # New basis isn't relative to Basis so keys New basis is recursively relative to will remain unchanged + # Keys recursively relative to Basis and Keys recursively relative to new basis will be mutually exclusive + # Typical user setups have all the shape keys immediately relative to Basis, so this won't be used much + + # Add the difference between new_basis_shapekey and new_basis_shapekey.relative_key (scaled according to the + # value and vertex_group of new_basis_shapekey) + for key_block in keys_relative_recursive_to_basis: + key_block.data.foreach_get('co', temp_co_array) + key_block.data.foreach_set('co', np.add(temp_co_array, difference_co_flat_scaled, out=temp_co_array)) + + # The difference between the reverted key and its relative key needs to equal the negative of the + # difference between new_basis and new_basis.relative_key multiplied + # new_basis.vertex_group should be present on both + # (r(NB) - r(NB).r) * NB.vg = -((NB - NB.r) * NB.v * NB.vg) + # = -(NB - NB.r) * NB.v * NB.vg + # NB.vg cancels on both sides, leaving: + # r(NB) - r(NB).r = -(NB - NB.r) * NB.v + # r(NB).r is unchanged, meaning r(NB).r = NB.r + # r(NB) - NB.r = -(NB - NB.r) * NB.v + # r(NB) = X + NB where X is what we want to find to add + # X + NB - NB.r = -(NB - NB.r) * NB.v + # Rearrange for X + # X = -(NB - NB.r) - (NB - NB.r) * NB.v + # + # (NB - NB.r) can be factorised + # X = (NB - NB.r)(-1 - NB.v) + # Note that (NB - NB.r) is difference_co_flat, giving + # X = difference_co_flat * (-1 - NB.v) + # + # Alternatively, instead of factorising, note that (NB - NB.r) * NB.v is difference_co_flat_value_scaled + # X = -(NB - NB.r) - difference_co_flat_value_scaled + # Note that (NB - NB.r) is difference_co_flat, giving + # X = -difference_co_flat - difference_co_flat_value_scaled + # Or + # X = -(difference_co_flat + difference_co_flat_value_scaled) + # + # Since NB.vg isn't present, it doesn't matter whether new_basis_shapekey has a vertex_group or not + # + # As with before, we'll use the multiplication option due to it scaling slightly better with a larger + # number of vertices + # X = difference_co_flat * (-1 - NB.v) + np.multiply(difference_co_flat, -1 - new_basis_shapekey.value, out=temp_co_array2) + + # We already have the co array for new_basis_shapekey, so we can do it separately from the others to + # save a foreach_get call + new_basis_shapekey.data.foreach_set('co', np.add(new_basis_co_flat, temp_co_array2, out=temp_co_array)) + # And now the rest of the shape keys + for key_block in keys_relative_recursive_to_new_basis: + key_block.data.foreach_get('co', temp_co_array) + key_block.data.foreach_set('co', np.add(temp_co_array, temp_co_array2, out=temp_co_array)) + + # Update mesh vertices to avoid basis shape key and mesh vertices being desynced until Edit mode has been + # entered and exited, which can cause odd behaviour when creating shape keys with from_mix=False or when + # removing all shape keys. + data.shape_keys.reference_key.data.foreach_get('co', temp_co_array) + data.vertices.foreach_set('co', temp_co_array) + + +def add_to_menu(self, context): + self.layout.separator() + self.layout.operator(AvatarToolkit_OT_ShapeKeyApplier.bl_idname, text=t('Tools.shapekey_to_basis.label'), icon="KEY_HLT") \ No newline at end of file diff --git a/functions/tools/bone_tools.py b/functions/tools/bone_tools.py index ae5de3b..e29b3ff 100644 --- a/functions/tools/bone_tools.py +++ b/functions/tools/bone_tools.py @@ -10,19 +10,13 @@ from ...core.common import ( restore_bone_transforms, remove_unused_vertex_groups, identify_bones, + duplicate_bone, + store_breaking_settings_armature, + restore_breaking_settings_armature, ) import traceback from ...core.armature_validation import validate_armature, validate_bone_hierarchy -def duplicate_bone(bone: EditBone) -> EditBone: - """Create a duplicate of the given bone""" - arm = bone.id_data - new_bone = arm.edit_bones.new(bone.name + "_copy") - new_bone.head = bone.head - new_bone.tail = bone.tail - new_bone.roll = bone.roll - new_bone.parent = bone.parent - return new_bone class AvatarToolKit_OT_CreateDigitigradeLegs(Operator): """Operator to convert standard legs to digitigrade setup""" @@ -39,13 +33,15 @@ class AvatarToolKit_OT_CreateDigitigradeLegs(Operator): return False valid, _, _ = validate_armature(armature) return (valid and - context.mode == 'EDIT_ARMATURE' and + (context.mode == 'EDIT_ARMATURE' or context.mode == 'POSE') and context.selected_editable_bones is not None and len(context.selected_editable_bones) == 2) def process_leg_chain(self, digi0: EditBone) -> bool: """Process a single leg bone chain""" try: + bpy.ops.object.mode_set(mode='EDIT') + # Get bone chain digi1: EditBone = digi0.children[0] digi2: EditBone = digi1.children[0] @@ -83,23 +79,23 @@ class AvatarToolKit_OT_CreateDigitigradeLegs(Operator): for bone in [digi1, digi2]: if "" not in bone.name: bone.name = bone.name.split('.')[0] + "" - return True except Exception as e: self.report({'ERROR'}, t("Tools.digitigrade_error", error=traceback.format_exc())) + return False def execute(self, context: Context) -> set[str]: """Execute the digitigrade conversion""" bpy.ops.object.mode_set(mode='EDIT') - + data_breaking = store_breaking_settings_armature(context.armature) with ProgressTracker(context, len(context.selected_editable_bones), t("Tools.digitigrade")) as progress: for digi0 in context.selected_editable_bones: progress.step(t("Tools.processing_leg", bone=digi0.name)) if not self.process_leg_chain(digi0): return {'CANCELLED'} - + restore_breaking_settings_armature(context.armature, data_breaking) self.report({'INFO'}, t("Tools.digitigrade_success")) return {'FINISHED'} @@ -125,6 +121,8 @@ class AvatarToolKit_OT_DeleteBoneConstraints(Operator): armature = get_active_armature(context) bpy.ops.object.select_all(action='DESELECT') armature.select_set(True) + data_breaking = store_breaking_settings_armature(armature) + context.view_layer.objects.active = armature bpy.ops.object.mode_set(mode='POSE') @@ -135,6 +133,7 @@ class AvatarToolKit_OT_DeleteBoneConstraints(Operator): constraints_removed += 1 bpy.ops.object.mode_set(mode='OBJECT') + restore_breaking_settings_armature(armature, data_breaking) self.report({'INFO'}, t("Tools.clean_constraints_success", count=constraints_removed)) return {'FINISHED'} @@ -187,6 +186,8 @@ class AvatarToolKit_OT_RemoveZeroWeightBones(Operator): # Store initial transforms bpy.ops.object.mode_set(mode='EDIT') initial_transforms: Dict[str, Dict[str, Any]] = {} + data_breaking = store_breaking_settings_armature(armature) + for bone in armature.data.edit_bones: initial_transforms[bone.name] = { 'head': bone.head.copy(), @@ -246,7 +247,7 @@ class AvatarToolKit_OT_RemoveZeroWeightBones(Operator): if context.scene.avatar_toolkit.list_only_mode: self.populate_bone_list(context, zero_weight_bones) return {'FINISHED'} - + restore_breaking_settings_armature(armature, data_breaking) self.report({'INFO'}, t("Tools.clean_weights_success", count=removed_count)) return {'FINISHED'} @@ -276,6 +277,7 @@ class AvatarToolKit_OT_RemoveSelectedBones(Operator): def execute(self, context: Context) -> set[str]: armature = get_active_armature(context) + data_breaking = store_breaking_settings_armature(armature) toolkit = context.scene.avatar_toolkit selected_bones = [item.name for item in toolkit.zero_weight_bones @@ -288,7 +290,7 @@ class AvatarToolKit_OT_RemoveSelectedBones(Operator): bpy.ops.object.mode_set(mode='OBJECT') toolkit.zero_weight_bones.clear() - + restore_breaking_settings_armature(armature, data_breaking) self.report({'INFO'}, t("Tools.bones_removed", count=len(selected_bones))) return {'FINISHED'} @@ -315,7 +317,8 @@ class AvatarToolKit_OT_FlipCurrentKeyFrames(Operator): def execute(self, context: Context) -> set[str]: armature = get_active_armature(context) - + data_breaking = store_breaking_settings_armature(armature) + armature_data: bpy.types.Armature = armature.data @@ -380,6 +383,7 @@ class AvatarToolKit_OT_FlipCurrentKeyFrames(Operator): #if armature.keyframe_insert(data_path=new_path, index=curve.array_index, frame=time): continue self.report({'ERROR'}, f"Keyframe insertion for key with data path \"{curve.data_path}\" and frame {time} failed!") + restore_breaking_settings_armature(armature, data_breaking) return {'FINISHED'} @@ -397,4 +401,5 @@ class AvatarToolKit_OT_FlipCurrentKeyFrames(Operator): # restore selection armature_data.bones.foreach_set("select", selected) + restore_breaking_settings_armature(armature, data_breaking) return {'FINISHED'} diff --git a/functions/tools/merge_tools.py b/functions/tools/merge_tools.py index 4078f91..837bd6f 100644 --- a/functions/tools/merge_tools.py +++ b/functions/tools/merge_tools.py @@ -4,8 +4,9 @@ from typing import Set, List from bpy.types import Operator, Context, Armature, EditBone from ...core.translations import t from ...core.logging_setup import logger -from ...core.common import get_active_armature, get_all_meshes, get_vertex_weights, transfer_vertex_weights +from ...core.common import get_active_armature, get_all_meshes, get_vertex_weights, transfer_vertex_weights, store_breaking_settings_armature, restore_breaking_settings_armature from ...core.armature_validation import validate_armature +import traceback class AvatarToolkit_OT_ConnectBones(Operator): """Connect disconnected bones in chain""" @@ -23,8 +24,12 @@ class AvatarToolkit_OT_ConnectBones(Operator): return valid def execute(self, context: Context) -> Set[str]: + armature = get_active_armature(context) + data_breaking = store_breaking_settings_armature(armature) try: - armature = get_active_armature(context) + + + logger.info("Starting bone connection operation") bpy.ops.object.mode_set(mode='EDIT') @@ -47,12 +52,14 @@ class AvatarToolkit_OT_ConnectBones(Operator): bones_connected += 1 bpy.ops.object.mode_set(mode='OBJECT') + restore_breaking_settings_armature(armature, data_breaking) self.report({'INFO'}, t("Tools.connect_bones_success", count=bones_connected)) return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to connect bones: {str(e)}") - self.report({'ERROR'}, str(e)) + except Exception: + logger.error(f"Failed to connect bones: {traceback.format_exc()}") + self.report({'ERROR'}, traceback.format_exc()) + restore_breaking_settings_armature(armature, data_breaking) return {'CANCELLED'} class AvatarToolkit_OT_MergeToActive(Operator): @@ -67,11 +74,15 @@ class AvatarToolkit_OT_MergeToActive(Operator): armature = get_active_armature(context) if not armature: return False - return context.mode == 'EDIT_ARMATURE' and context.active_bone + return (context.mode == 'EDIT_ARMATURE' or context.mode == 'POSE') and context.active_bone def execute(self, context: Context) -> Set[str]: + armature = get_active_armature(context) + data_breaking = store_breaking_settings_armature(armature) + try: - armature = get_active_armature(context) + bpy.ops.object.mode_set(mode='EDIT') + active_bone = context.active_bone selected_bones = [b for b in context.selected_editable_bones if b != active_bone] @@ -102,11 +113,13 @@ class AvatarToolkit_OT_MergeToActive(Operator): armature.data.edit_bones.remove(bone) self.report({'INFO'}, t("Tools.merge_to_active_success", count=len(selected_bones))) + restore_breaking_settings_armature(armature, data_breaking) return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to merge bones: {str(e)}") - self.report({'ERROR'}, str(e)) + except Exception: + logger.error(f"Failed to merge bones: {traceback.format_exc()}") + self.report({'ERROR'}, traceback.format_exc()) + restore_breaking_settings_armature(armature, data_breaking) return {'CANCELLED'} class AvatarToolkit_OT_MergeToParent(Operator): @@ -121,11 +134,13 @@ class AvatarToolkit_OT_MergeToParent(Operator): armature = get_active_armature(context) if not armature: return False - return context.mode == 'EDIT_ARMATURE' + return (context.mode == 'EDIT_ARMATURE' or context.mode == 'POSE') def execute(self, context: Context) -> Set[str]: + armature = get_active_armature(context) + data_breaking = store_breaking_settings_armature(armature) try: - armature = get_active_armature(context) + bpy.ops.object.mode_set(mode='EDIT') selected_bones = [b for b in context.selected_editable_bones if b.parent] if not selected_bones: @@ -153,10 +168,12 @@ class AvatarToolkit_OT_MergeToParent(Operator): armature.data.edit_bones.remove(bone) merged_count += 1 + restore_breaking_settings_armature(armature, data_breaking) self.report({'INFO'}, t("Tools.merge_to_parent_success", count=merged_count)) return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to merge bones: {str(e)}") - self.report({'ERROR'}, str(e)) + except Exception: + logger.error(f"Failed to merge bones: {traceback.format_exc()}") + self.report({'ERROR'}, traceback.format_exc()) + restore_breaking_settings_armature(armature, data_breaking) return {'CANCELLED'} diff --git a/functions/tools/mesh_separation.py b/functions/tools/mesh_separation.py index 96d8881..c2223c7 100644 --- a/functions/tools/mesh_separation.py +++ b/functions/tools/mesh_separation.py @@ -3,6 +3,7 @@ from bpy.types import Operator, Context from ...core.translations import t from ...core.common import get_active_armature from ...core.armature_validation import validate_armature +import traceback class AvatarToolKit_OT_SeparateByMaterials(Operator): """Operator to separate mesh by materials""" @@ -32,8 +33,8 @@ class AvatarToolKit_OT_SeparateByMaterials(Operator): bpy.ops.object.mode_set(mode='OBJECT') self.report({'INFO'}, t("Tools.separate_materials_success")) return {'FINISHED'} - except Exception as e: - self.report({'ERROR'}, str(e)) + except Exception: + self.report({'ERROR'}, traceback.format_exc()) return {'CANCELLED'} class AvatarToolKit_OT_SeparateByLooseParts(Operator): @@ -64,6 +65,6 @@ class AvatarToolKit_OT_SeparateByLooseParts(Operator): bpy.ops.object.mode_set(mode='OBJECT') self.report({'INFO'}, t("Tools.separate_loose_success")) return {'FINISHED'} - except Exception as e: - self.report({'ERROR'}, str(e)) + except Exception: + self.report({'ERROR'}, traceback.format_exc()) return {'CANCELLED'} diff --git a/functions/tools/rigify_converter.py b/functions/tools/rigify_converter.py index 8737454..7607b7c 100644 --- a/functions/tools/rigify_converter.py +++ b/functions/tools/rigify_converter.py @@ -6,6 +6,7 @@ from ...core.logging_setup import logger from ...core.translations import t from ...core.dictionaries import rigify_unity_names, rigify_basic_unity_names, rigify_unnecessary_bones from ...core.armature_validation import validate_armature +import traceback class AvatarToolkit_OT_ConvertRigifyToUnity(Operator): """Convert Rigify armature to Unity-compatible format""" @@ -56,9 +57,9 @@ class AvatarToolkit_OT_ConvertRigifyToUnity(Operator): self.report({'INFO'}, t("Tools.rigify_converted")) return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to convert Rigify: {str(e)}", exc_info=True) - self.report({'ERROR'}, str(e)) + except Exception: + logger.error(f"Failed to convert Rigify: {traceback.format_exc()}", exc_info=True) + self.report({'ERROR'}, traceback.format_exc()) return {'CANCELLED'} def cleanup_extra_bones(self, armature: Object) -> None: diff --git a/functions/tools/standardize_armature.py b/functions/tools/standardize_armature.py index f7ad52a..e88558d 100644 --- a/functions/tools/standardize_armature.py +++ b/functions/tools/standardize_armature.py @@ -1,3 +1,4 @@ +import traceback import bpy import math from typing import Dict, List, Set, Tuple, Optional, Any, Union @@ -25,7 +26,7 @@ class AvatarToolkit_OT_StandardizeArmature(Operator): @classmethod def poll(cls, context: Context) -> bool: armature: Optional[Object] = get_active_armature(context) - return armature is not None and context.mode in {'OBJECT', 'EDIT_ARMATURE'} + return armature is not None and context.mode in {'OBJECT', 'EDIT_ARMATURE', 'POSE'} def invoke(self, context: Context, event: Any) -> Set[str]: logger.debug("Invoking standardize armature dialog") @@ -99,20 +100,24 @@ class AvatarToolkit_OT_StandardizeArmature(Operator): if original_mode == 'EDIT_ARMATURE': bpy.ops.object.mode_set(mode='EDIT') + if original_mode == 'POSE': + bpy.ops.object.mode_set(mode='POSE') return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to standardize armature: {str(e)}") - self.report({'ERROR'}, str(e)) + except Exception: + logger.error(f"Failed to standardize armature: {traceback.format_exc()}") + self.report({'ERROR'}, traceback.format_exc()) try: if original_mode == 'EDIT_ARMATURE': bpy.ops.object.mode_set(mode='EDIT') + if original_mode == 'POSE': + bpy.ops.object.mode_set(mode='POSE') else: bpy.ops.object.mode_set(mode='OBJECT') - except Exception as restore_error: - logger.error(f"Failed to restore original mode: {str(restore_error)}") + except Exception: + logger.error(f"Failed to restore original mode: {traceback.format_exc()}") return {'CANCELLED'} diff --git a/functions/tools/uv_tools.py b/functions/tools/uv_tools.py index 6002d73..2f5f3e5 100644 --- a/functions/tools/uv_tools.py +++ b/functions/tools/uv_tools.py @@ -6,6 +6,7 @@ import numpy as np import math from ...core.translations import t from ...core.logging_setup import logger +import traceback class GenerateLoopTreeResult(TypedDict): tree: Dict[str, Set[str]] @@ -247,8 +248,8 @@ class AvatarToolkit_OT_AlignUVEdgesToTarget(Operator): logger.info(f"Finished mesh {source} for UV's") - except Exception as e: - logger.error(f"Error processing source {source}: {str(e)}") + except Exception: + logger.error(f"Error processing source {source}: {traceback.format_exc()}") return {'CANCELLED'} bpy.ops.object.mode_set(mode=prev_mode) diff --git a/functions/visemes.py b/functions/visemes.py index a7365a8..418ccb1 100644 --- a/functions/visemes.py +++ b/functions/visemes.py @@ -11,6 +11,7 @@ from ..core.common import ( get_all_meshes, validate_mesh_for_pose ) +import traceback class VisemeCache: """Manages caching of generated viseme shape data for performance optimization""" @@ -211,9 +212,9 @@ class AvatarToolkit_OT_CreateVisemes(Operator): self.create_visemes(context, mesh) self.report({'INFO'}, t("Visemes.success")) return {'FINISHED'} - except Exception as e: - logger.error(f"Error creating visemes: {str(e)}") - self.report({'ERROR'}, str(e)) + except Exception: + logger.error(f"Error creating visemes: {traceback.format_exc()}") + self.report({'ERROR'}, traceback.format_exc()) return {'CANCELLED'} def create_visemes(self, context: Context, mesh: Object) -> None: diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index efc827d..e4d2136 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -228,6 +228,11 @@ "Tools.explode_mesh.distance_desc": "Scale factor for distance between exploded items on model.", "Tools.explode_mesh.split_on_seams_desc":"Split model on UV seams to separate islands from each other.", "Tools.explode_mesh.split_on_seams":"Split on Seams", + "Tools.shapekey_to_basis.label":"Apply Selected Shapekey to Basis", + "Tools.shapekey_to_basis.desc":"Applies the selected shape key to the new Basis at it's current strength and creates a reverted shape key from the selected one.", + "ShapeKeyApplier.error.recursiveRelativeToLoop":"Shapekey \"{name}\" is recursively relative to itself, so cannot be applied to the Basis", + "ShapeKeyApplier.successRemoved":"Successfully removed shapekey \"{name}\" from the Basis.", + "ShapeKeyApplier.successSet":"Successfully applied shapekey \"{name}\" to the Basis.", "Tools.apply_modifier_on_shapekey_obj":"Apply Modifier on Shapekey Object", "Tools.apply_modifier_on_shapekey_obj_desc":"Applies a modifier on an object regardless of it having shapekeys.", "Tools.merge_title": "Merge Tools", diff --git a/ui/atlas_materials_panel.py b/ui/atlas_materials_panel.py index 35e6316..2d1a31e 100644 --- a/ui/atlas_materials_panel.py +++ b/ui/atlas_materials_panel.py @@ -6,6 +6,7 @@ from ..core.common import SceneMatClass, MaterialListBool, get_active_armature from ..functions.atlas_materials import AvatarToolKit_OT_AtlasMaterials from ..core.translations import t from ..core.logging_setup import logger +import traceback class AvatarToolKit_OT_SelectAllMaterials(Operator): bl_idname = 'avatar_toolkit.select_all_materials' @@ -80,8 +81,8 @@ class AvatarToolKit_OT_ExpandSectionMaterials(Operator): logger.debug("Hiding material list") return {'FINISHED'} - except Exception as e: - logger.error(f"Error loading materials: {str(e)}", exc_info=True) + except Exception: + logger.error(f"Error loading materials: {traceback.format_exc()}", exc_info=True) self.report({'ERROR'}, t("TextureAtlas.load_error")) return {'CANCELLED'}