Start of the Major Overhaul

I decided to go through each function and UI section one by one, improving and overhauling things. Each function and section is going to be fully tested and not rushed out.

This is the best way to catch things, but also include the code base as much as possible.
This commit is contained in:
Yusarina
2024-12-03 22:58:17 +00:00
parent 7f9dc20564
commit ff23d23cfc
38 changed files with 604 additions and 4765 deletions
View File
+40 -27
View File
@@ -7,6 +7,7 @@ import pkgutil
import tomllib
import importlib
from pathlib import Path
from typing import List, Dict, Set, Optional, Any, Type, Tuple, Generator, TypeVar
__all__ = (
"init",
@@ -14,10 +15,12 @@ __all__ = (
"unregister",
)
modules = None
ordered_classes = None
T = TypeVar('T')
modules: Optional[List[Any]] = None
ordered_classes: Optional[List[Type]] = None
def init():
def init() -> None:
"""Initialize the auto-loader by discovering modules and classes"""
global modules
global ordered_classes
print("Auto-load init starting")
@@ -26,7 +29,8 @@ def init():
print(f"Found modules: {modules}")
print(f"Found classes: {ordered_classes}")
def register():
def register() -> None:
"""Register all discovered classes and modules"""
print("Registering classes")
for cls in ordered_classes:
print(f"Registering: {cls}")
@@ -41,7 +45,8 @@ def register():
if hasattr(module, "register"):
module.register()
def unregister():
def unregister() -> None:
"""Unregister all classes and modules in reverse order"""
for cls in reversed(ordered_classes):
bpy.utils.unregister_class(cls)
@@ -51,13 +56,15 @@ def unregister():
if hasattr(module, "unregister"):
module.unregister()
def get_manifest_id():
def get_manifest_id() -> str:
"""Get the addon ID from the manifest file"""
manifest_path = Path(__file__).parent.parent / "blender_manifest.toml"
with open(manifest_path, "rb") as f:
manifest = tomllib.load(f)
return manifest["id"]
def get_all_submodules(directory):
def get_all_submodules(directory: Path) -> List[Any]:
"""Discover and import all submodules in the given directory"""
modules = []
addon_id = get_manifest_id()
for root, dirs, files in os.walk(directory):
@@ -73,66 +80,75 @@ def get_all_submodules(directory):
modules.append(importlib.import_module(f".{name}", package_name))
return modules
def iter_submodules(path, package_name):
def iter_submodules(path: Path, package_name: str) -> Generator[Any, None, None]:
"""Iterate through submodules in a package"""
for name in sorted(iter_module_names(path)):
yield importlib.import_module("." + name, package_name)
def iter_module_names(path):
print(f"Scanning path: {path}") # Debug path
def iter_module_names(path: Path) -> Generator[str, None, None]:
"""Iterate through module names in a directory"""
print(f"Scanning path: {path}")
modules_list = list(pkgutil.iter_modules([str(path)]))
print(f"Found these modules: {modules_list}") # Debug modules
print(f"Found these modules: {modules_list}")
for _, module_name, is_pkg in modules_list:
if not is_pkg:
print(f"Found module: {module_name}")
yield module_name
def get_ordered_classes_to_register(modules):
def get_ordered_classes_to_register(modules: List[Any]) -> List[Type]:
"""Get a topologically sorted list of classes to register"""
return toposort(get_register_deps_dict(modules))
def get_register_deps_dict(modules):
def get_register_deps_dict(modules: List[Any]) -> Dict[Type, Set[Type]]:
"""Get dependencies dictionary for class registration"""
deps_dict = {}
classes_to_register = set(iter_classes_to_register(modules))
for cls in classes_to_register:
deps_dict[cls] = set(iter_own_register_deps(cls, classes_to_register))
return deps_dict
def iter_own_register_deps(cls, classes_to_register):
def iter_own_register_deps(cls: Type, classes_to_register: Set[Type]) -> Generator[Type, None, None]:
"""Iterate through a class's own registration dependencies"""
yield from (dep for dep in iter_register_deps(cls) if dep in classes_to_register)
def iter_register_deps(cls):
def iter_register_deps(cls: Type) -> Generator[Type, None, None]:
"""Iterate through all registration dependencies of a class"""
for value in typing.get_type_hints(cls, {}, {}).values():
dependency = get_dependency_from_annotation(value)
if dependency is not None:
yield dependency
def get_dependency_from_annotation(value):
def get_dependency_from_annotation(value: Any) -> Optional[Type]:
"""Get dependency type from a type annotation"""
if isinstance(value, tuple) and len(value) == 2:
if value[0] in (bpy.props.PointerProperty, bpy.props.CollectionProperty):
return value[1]["type"]
return None
def iter_classes_to_register(modules):
def iter_classes_to_register(modules: List[Any]) -> Generator[Type, None, None]:
"""Iterate through classes that need to be registered"""
base_types = get_register_base_types()
for cls in get_classes_in_modules(modules):
if any(base in base_types for base in cls.__bases__):
if not getattr(cls, "_is_registered", False):
yield cls
def get_classes_in_modules(modules):
def get_classes_in_modules(modules: List[Any]) -> Set[Type]:
"""Get all classes defined in the modules"""
classes = set()
for module in modules:
for cls in iter_classes_in_module(module):
classes.add(cls)
return classes
def iter_classes_in_module(module):
def iter_classes_in_module(module: Any) -> Generator[Type, None, None]:
"""Iterate through classes defined in a module"""
for value in module.__dict__.values():
if inspect.isclass(value):
yield value
def get_register_base_types():
def get_register_base_types() -> Set[Type]:
"""Get set of base types that need registration"""
return set(getattr(bpy.types, name) for name in [
"Panel", "Operator", "PropertyGroup",
"AddonPreferences", "Header", "Menu",
@@ -140,24 +156,22 @@ def get_register_base_types():
"UIList", "RenderEngine"
])
def toposort(deps_dict):
def toposort(deps_dict: Dict[Type, Set[Type]]) -> List[Type]:
"""Topologically sort classes based on their dependencies"""
sorted_list = []
sorted_values = set()
# First pass: Register panels without parents
panels_to_sort = [(value, deps) for value, deps in deps_dict.items()
if hasattr(value, 'bl_parent_id')]
base_panels = [(value, deps) for value, deps in deps_dict.items()
if not hasattr(value, 'bl_parent_id')]
# Add base panels first
for value, deps in base_panels:
if len(deps) == 0:
sorted_list.append(value)
sorted_values.add(value)
# Then add child panels
while len(deps_dict) > len(sorted_values):
unsorted = []
for value, deps in deps_dict.items():
@@ -169,4 +183,3 @@ def toposort(deps_dict):
unsorted.append(value)
return sorted_list
+72 -413
View File
@@ -1,268 +1,83 @@
import bpy
import numpy as np
from .dictionaries import bone_names
import threading
import time
import webbrowser
import typing
from bpy.types import Context, Object
from typing import Optional, Tuple, List, Set
from ..core.translations import t
from ..core.dictionaries import bone_names
from typing import List, Optional, Tuple
from bpy.types import Object, ShapeKey, Mesh, Context, Material, PropertyGroup
from functools import lru_cache
from bpy.props import PointerProperty, IntProperty, StringProperty
from bpy.utils import register_class
class SceneMatClass(PropertyGroup):
mat: PointerProperty(type=Material)
register_class(SceneMatClass)
class MaterialListBool:
#For the love that is holy do not ever touch these. If this was java I would make these private
#They should only be accessed via context.scene.texture_atlas_Has_Mat_List_Shown
#This is so we know if the materials are up to date. messing with these variables directly will make the thing blow up.
#The only exception to this is the ExpandSection_Materials operator which populates this with new data once the materials have changed and need reloading.
old_list: dict[str,list[Material]] = {}
bool_material_list_expand: dict[str,bool] = {}
def set_bool(self, value: bool) -> None:
MaterialListBool.bool_material_list_expand[bpy.context.scene.name] = value
if value == False:
MaterialListBool.old_list[bpy.context.scene.name] = []
def get_bool(self) -> bool:
newlist: list[Material] = []
for obj in bpy.context.scene.objects:
if len(obj.material_slots)>0:
for mat_slot in obj.material_slots:
if mat_slot.material:
if mat_slot.material not in newlist:
newlist.append(mat_slot.material)
still_the_same: bool = True
if bpy.context.scene.name in MaterialListBool.old_list:
for item in newlist:
if item not in MaterialListBool.old_list[bpy.context.scene.name]:
still_the_same = False
break
for item in MaterialListBool.old_list[bpy.context.scene.name]:
if item not in newlist:
still_the_same = False
break
else:
still_the_same = False
MaterialListBool.bool_material_list_expand[bpy.context.scene.name] = still_the_same
return MaterialListBool.bool_material_list_expand[bpy.context.scene.name]
### Clean up material names in the given mesh by removing the '.001' suffix.
def clean_material_names(mesh: Mesh) -> None:
for j, mat in enumerate(mesh.material_slots):
if mat.name.endswith(('.0+', ' 0+')):
mesh.active_material_index = j
mesh.active_material.name = mat.name[:-len(mat.name.rstrip('0')) - 1]
# This will fix faulty uv coordinates, cats did this a other way which can have unintended consequences,
# this is the best way i could of think of doing this for the time being, however may need improvements.
def fix_uv_coordinates(context: Context) -> None:
obj = context.object
# Store current mode and selection
current_mode = context.mode
current_active = context.view_layer.objects.active
current_selected = context.selected_objects.copy()
# Ensure we're in object mode and select the object
bpy.ops.object.mode_set(mode='OBJECT')
obj.select_set(True)
context.view_layer.objects.active = obj
# Check if the object has any mesh data
if obj.type == 'MESH' and obj.data:
# Switch to Edit Mode
bpy.ops.object.mode_set(mode='EDIT')
# Select all UVs
bpy.ops.mesh.select_all(action='SELECT')
# Try to find UV Editor area, fall back to 3D View if not found
area = next((area for area in context.screen.areas if area.type == 'UV_EDITOR'), None)
if not area:
area = next((area for area in context.screen.areas if area.type == 'VIEW_3D'), None)
# Get the region and space data
region = next((region for region in area.regions if region.type == 'WINDOW'), None)
space_data = area.spaces.active
# Create a context override
override = {
'area': area,
'region': region,
'space_data': space_data,
'edit_object': obj,
'active_object': obj,
'selected_objects': [obj],
'mode': 'EDIT_MESH',
}
try:
# Ensure UVs are selected
bpy.ops.uv.select_all(override, action='SELECT')
# Average UV island scales
bpy.ops.uv.average_islands_scale(override)
except Exception as e:
print(f"UV Fix - Error during UV scaling: {str(e)}")
# Switch back to Object Mode
bpy.ops.object.mode_set(mode='OBJECT')
print("UV Fix - Switched back to Object Mode")
# Restore previous selection and active object
for sel_obj in current_selected:
sel_obj.select_set(True)
context.view_layer.objects.active = current_active
else:
print("UV Fix - Object is not a valid mesh with UV data")
def has_shapekeys(mesh_obj: Object) -> bool:
return mesh_obj.data.shape_keys is not None
@lru_cache(maxsize=None)
def _get_shape_key_co(shape_key: ShapeKey) -> np.ndarray:
return np.array([v.co for v in shape_key.data])
def simplify_bonename(n: str) -> str:
return n.lower().translate(dict.fromkeys(map(ord, u" _.")))
def get_armature(context: Context, armature_name: Optional[str] = None) -> Optional[Object]:
if armature_name:
obj = bpy.data.objects[armature_name]
if obj.type == "ARMATURE":
return obj
else:
return None
if context.view_layer.objects.active:
obj = context.view_layer.objects.active
if obj.type == "ARMATURE":
return obj
return next((obj for obj in context.view_layer.objects if obj.type == 'ARMATURE'), None)
def get_armatures(self, context: Context) -> List[Tuple[str, str, str]]:
armatures = [(obj.name, obj.name, "") for obj in bpy.data.objects if obj.type == 'ARMATURE']
if not armatures:
return [('NONE', 'No Armature', '')]
return armatures
def get_armatures_that_are_not_selected(self, context: Context) -> List[Tuple[str, str, str]]:
armatures = [(obj.name, obj.name, "") for obj in bpy.data.objects if ((obj.type == 'ARMATURE') and (obj.name != context.scene.avatar_toolkit.selected_armature))]
if not armatures:
return [('NONE', 'No Other Armature', '')]
return armatures
def get_selected_armature(context: Context) -> Optional[Object]:
try:
if hasattr(context.scene, 'avatar_toolkit'):
armature_name = context.scene.avatar_toolkit.selected_armature
if isinstance(armature_name, bytes):
try:
armature_name = armature_name.decode('utf-8')
except UnicodeDecodeError:
try:
armature_name = armature_name.decode('gbk') # For Chinese characters
except UnicodeDecodeError:
try:
armature_name = armature_name.decode('shift-jis')
except UnicodeDecodeError:
armature_name = armature_name.decode('latin1')
if armature_name:
armature = bpy.data.objects.get(str(armature_name))
if is_valid_armature(armature):
return armature
except Exception:
pass
def get_active_armature(context: bpy.types.Context) -> Optional[bpy.types.Object]:
"""Get the currently selected armature from Avatar Toolkit properties"""
armature_name = context.scene.avatar_toolkit.active_armature
if armature_name and armature_name != 'NONE':
return bpy.data.objects.get(armature_name)
return None
def get_merge_armature_source(context: Context) -> Optional[Object]:
try:
if hasattr(context.scene, 'merge_armature_source'):
source_name = context.scene.merge_armature_source
if isinstance(source_name, bytes):
try:
source_name = source_name.decode('utf-8')
except UnicodeDecodeError:
try:
source_name = source_name.decode('shift-jis')
except UnicodeDecodeError:
source_name = source_name.decode('latin1', errors='ignore')
def set_active_armature(context: bpy.types.Context, armature: bpy.types.Object) -> None:
"""Set the active armature for Avatar Toolkit operations"""
context.scene.avatar_toolkit.active_armature = armature
def get_armature_list(self=None, context: bpy.types.Context = None) -> List[Tuple[str, str, str]]:
"""Get list of all armature objects in the scene"""
if context is None:
context = bpy.context
armatures = [(obj.name, obj.name, "") for obj in context.scene.objects if obj.type == 'ARMATURE']
if not armatures:
return [('NONE', t("Armature.validation.no_armature"), '')]
return armatures
def validate_armature(armature: bpy.types.Object) -> Tuple[bool, str]:
"""
Validate if the selected object is a proper armature and has required bones
Returns tuple of (is_valid, message)
"""
if not armature:
return False, t("Armature.validation.no_armature")
if armature.type != 'ARMATURE':
return False, t("Armature.validation.not_armature")
if not armature.data.bones:
return False, t("Armature.validation.no_bones")
essential_bones: Set[str] = {'hips', 'spine', 'chest', 'neck', 'head'}
found_bones: Set[str] = {bone.name.lower() for bone in armature.data.bones}
for bone in essential_bones:
if not any(alt_name in found_bones for alt_name in bone_names[bone]):
return False, t("Armature.validation.missing_bone", bone=bone)
if source_name:
return bpy.data.objects.get(str(source_name))
except Exception:
pass
return None
return True, t("QuickAccess.valid_armature")
def set_selected_armature(context: Context, armature: Optional[Object]) -> None:
context.scene.avatar_toolkit.selected_armature = armature.name if armature else ""
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:
set_active_armature(context, armatures[0])
def is_valid_armature(armature: Object) -> bool:
if not armature or armature.type != 'ARMATURE':
return False
if not armature.data or not armature.data.bones:
return False
return True
def clear_default_objects() -> None:
"""Removes default Blender objects (cube, light, camera)"""
default_names: Set[str] = {'Cube', 'Light', 'Camera'}
for obj in bpy.data.objects:
if obj.name.split('.')[0] in default_names:
bpy.data.objects.remove(obj, do_unlink=True)
def select_current_armature(context: Context) -> bool:
armature = get_selected_armature(context)
def get_armature_stats(armature: bpy.types.Object) -> dict:
"""Get statistics about the armature"""
return {
'bone_count': len(armature.data.bones),
'has_pose': bool(armature.pose),
'visible': not armature.hide_viewport,
'name': armature.name
}
def get_all_meshes(context: Context) -> List[Object]:
armature = get_active_armature(context)
if armature:
bpy.ops.object.select_all(action='DESELECT')
armature.select_set(True)
context.view_layer.objects.active = armature
return True
return False
return [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == armature]
return []
def apply_shapekey_to_basis(context: bpy.types.Context, obj: bpy.types.Object, shape_key_name: str, delete_old: bool = False) -> bool:
if shape_key_name not in obj.data.shape_keys.key_blocks:
return False
shapekeynum = obj.data.shape_keys.key_blocks.find(shape_key_name)
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action='SELECT')
obj.active_shape_key_index = 0
bpy.ops.mesh.blend_from_shape(shape = shape_key_name, add=True, blend=1)
obj.active_shape_key_index = shapekeynum
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.blend_from_shape(shape = shape_key_name, add=True, blend=-2)
bpy.ops.mesh.select_all(action='DESELECT')
bpy.ops.object.mode_set(mode="OBJECT")
print("blended!")
if delete_old:
obj.active_shape_key_index = shapekeynum
bpy.ops.object.shape_key_remove(all=False)
else:
mesh: bpy.types.Mesh = obj.data
mesh.shape_keys.key_blocks[shape_key_name].name = shape_key_name + "_reversed"
return True
def apply_pose_as_rest(context: Context, armature_obj: Object, meshes: list[Object]) -> bool:
def apply_pose_as_rest(context: Context, armature_obj: Object, meshes: List[Object]) -> bool:
for mesh_obj in meshes:
if not mesh_obj.data:
continue
if mesh_obj.data.shape_keys and mesh_obj.data.shape_keys.key_blocks:
if len(mesh_obj.data.shape_keys.key_blocks) == 1:
basis = mesh_obj.data.shape_keys.key_blocks[0]
@@ -274,11 +89,10 @@ def apply_pose_as_rest(context: Context, armature_obj: Object, meshes: list[Obje
apply_armature_to_mesh_with_shapekeys(armature_obj, mesh_obj, context)
else:
apply_armature_to_mesh(armature_obj, mesh_obj)
bpy.ops.object.mode_set(mode='POSE')
bpy.ops.pose.armature_apply(selected=False)
bpy.ops.object.mode_set(mode='OBJECT')
return True
def apply_armature_to_mesh(armature_obj: Object, mesh_obj: Object) -> None:
@@ -290,7 +104,7 @@ def apply_armature_to_mesh(armature_obj: Object, mesh_obj: Object) -> None:
else:
for _ in range(len(mesh_obj.modifiers) - 1):
bpy.ops.object.modifier_move_up(modifier=armature_mod.name)
with bpy.context.temp_override(object=mesh_obj):
bpy.ops.object.modifier_apply(modifier=armature_mod.name)
@@ -298,10 +112,11 @@ def apply_armature_to_mesh_with_shapekeys(armature_obj: Object, mesh_obj: Object
old_active_index = mesh_obj.active_shape_key_index
old_show_only = mesh_obj.show_only_shape_key
mesh_obj.show_only_shape_key = True
shape_keys = mesh_obj.data.shape_keys.key_blocks
vertex_groups = []
mutes = []
for sk in shape_keys:
vertex_groups.append(sk.vertex_group)
sk.vertex_group = ''
@@ -316,7 +131,7 @@ def apply_armature_to_mesh_with_shapekeys(armature_obj: Object, mesh_obj: Object
arm_mod = mesh_obj.modifiers.new('PoseToRest', 'ARMATURE')
arm_mod.object = armature_obj
co_length = len(mesh_obj.data.vertices) * 3
eval_cos = np.empty(co_length, dtype=np.single)
@@ -333,6 +148,7 @@ def apply_armature_to_mesh_with_shapekeys(armature_obj: Object, mesh_obj: Object
for mod in disabled_mods:
mod.show_viewport = True
mesh_obj.modifiers.remove(arm_mod)
for sk, vg, mute in zip(shape_keys, vertex_groups, mutes):
@@ -341,160 +157,3 @@ def apply_armature_to_mesh_with_shapekeys(armature_obj: Object, mesh_obj: Object
mesh_obj.active_shape_key_index = old_active_index
mesh_obj.show_only_shape_key = old_show_only
def get_all_meshes(context: Context) -> List[Object]:
armature = get_selected_armature(context)
if armature and is_valid_armature(armature):
return [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == armature]
return []
def get_mesh_items(self, context):
return [(obj.name, obj.name, "") for obj in get_all_meshes(context)]
def open_web_after_delay_multi_threaded(delay: typing.Optional[float] = 1.0, url: typing.Union[str, typing.Any] = ""):
thread = threading.Thread(target=open_web_after_delay,args=[delay,url],name="open_browser_thread")
thread.start()
def open_web_after_delay(delay, url):
print("opening browser in "+str(delay)+" seconds.")
time.sleep(delay)
webbrowser.open_new_tab(url)
def duplicatebone(b: bpy.types.EditBone) -> bpy.types.EditBone:
arm = bpy.context.object.data
cb = arm.edit_bones.new(b.name)
cb.head = b.head
cb.tail = b.tail
cb.matrix = b.matrix
cb.parent = b.parent
return cb
def has_shapekeys(mesh_obj: Object) -> bool:
return mesh_obj.data.shape_keys is not None
def sort_shape_keys(mesh: Object) -> None:
print("Starting shape key sorting...")
if not has_shapekeys(mesh):
print("No shape keys found. Exiting sort function.")
return
# Set the mesh as the active object
bpy.context.view_layer.objects.active = mesh
bpy.ops.object.mode_set(mode='OBJECT')
order = [
'Basis',
'vrc.blink_left',
'vrc.blink_right',
'vrc.lowerlid_left',
'vrc.lowerlid_right',
'vrc.v_aa',
'vrc.v_ch',
'vrc.v_dd',
'vrc.v_e',
'vrc.v_ff',
'vrc.v_ih',
'vrc.v_kk',
'vrc.v_nn',
'vrc.v_oh',
'vrc.v_ou',
'vrc.v_pp',
'vrc.v_rr',
'vrc.v_sil',
'vrc.v_ss',
'vrc.v_th',
]
shape_keys = mesh.data.shape_keys.key_blocks
print(f"Total shape keys: {len(shape_keys)}")
# Create a list of shape key names in their current order
current_order = [key.name for key in shape_keys]
# Create a new order list
new_order = []
# First, add all the keys that are in the predefined order
for name in order:
if name in current_order:
new_order.append(name)
current_order.remove(name)
# Then add any remaining keys that weren't in the predefined order
new_order.extend(current_order)
print("New order:", new_order)
# Now, rearrange the shape keys based on the new order
for i, name in enumerate(new_order):
index = shape_keys.find(name)
if index != i:
print(f"Moving {name} from index {index} to {i}")
mesh.active_shape_key_index = index
while mesh.active_shape_key_index > i:
bpy.ops.object.shape_key_move(type='UP')
print("Shape key sorting completed.")
def get_shapekeys(mesh: Object, prefix: str = '') -> List[tuple]:
if not has_shapekeys(mesh):
return []
return [(key.name, key.name, key.name) for key in mesh.data.shape_keys.key_blocks if key.name != 'Basis' and key.name.startswith(prefix)]
def remove_default_objects():
for obj in bpy.data.objects:
if obj.name in ["Camera", "Light", "Cube"]:
bpy.data.objects.remove(obj, do_unlink=True)
def init_progress(context, steps):
context.window_manager.progress_begin(0, 100)
context.scene.avatar_toolkit.progress_steps = steps
context.scene.avatar_toolkit.progress_current = 0
def update_progress(self, context, message):
context.scene.avatar_toolkit.progress_current += 1
progress = (context.scene.avatar_toolkit.progress_current / context.scene.avatar_toolkit.progress_steps) * 100
context.window_manager.progress_update(progress)
context.area.header_text_set(message)
self.report({'INFO'}, message)
def finish_progress(context):
context.window_manager.progress_end()
context.area.header_text_set(None)
def transfer_vertex_weights(context: Context, obj: bpy.types.Object, source_group: str, target_group: str, delete_source_group: bool = True) -> bool:
# Create and configure the Vertex Weight Mix modifier
modifier = obj.modifiers.new(name="merge_weights", type="VERTEX_WEIGHT_MIX")
modifier.show_viewport = True
modifier.show_render = True
modifier.mix_set = 'B'
modifier.vertex_group_a = target_group
modifier.vertex_group_b = source_group
modifier.mask_constant = 1.0
# Ensure we're in Object Mode
bpy.ops.object.mode_set(mode='OBJECT')
# Deselect all objects and select only our target object
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
context.view_layer.objects.active = obj
# Move modifier to the top of the stack
if len(obj.modifiers) > 1:
obj.modifiers.move(obj.modifiers.find(modifier.name), 0)
# Apply modifier with correct syntax
with context.temp_override(active_object=obj):
bpy.ops.object.modifier_apply(modifier=modifier.name)
# Clean up
if delete_source_group and source_group in obj.vertex_groups:
obj.vertex_groups.remove(obj.vertex_groups[source_group])
return True
+2 -3
View File
@@ -1,6 +1,6 @@
import bpy
from typing import List, Optional
from ...core.common import get_armature
from ...core.common import get_active_armature
from bpy.types import Object, ShapeKey, Mesh, Context, Operator
from functools import lru_cache
from ...core.translations import t
@@ -12,10 +12,9 @@ class AvatarToolKit_OT_ExportResonite(Operator):
bl_options = {'REGISTER', 'UNDO'}
filepath: bpy.props.StringProperty()
@classmethod
def poll(cls, context: Context):
if get_armature(context) is None:
if get_active_armature(context) is None:
return False
return True
+119 -44
View File
@@ -1,54 +1,129 @@
import bpy
# Importers which don't need much code should be added here, however if a importer needs alot of code
# Like the PMX and PMD importers, they should be added to their own files and referenced in the import_types str->lambda dictionary.
#See below comments on how the system works. - @989onan
import importlib.util
import logging
import os
import typing
from typing import Optional, Callable, Dict, List, Union, Set
from ..common import clear_default_objects
from .import_pmx import import_pmx
from .import_pmd import import_pmd
if importlib.util.find_spec("io_scene_valvesource") is not None:
#from .....scripts.addons.io_scene_valvesource.import_smd import SmdImporter #<- use this to check if your IDE is working properly. idfk
from io_scene_valvesource.import_smd import SmdImporter #ignore IDE bitching this is fine, trust me, also above comment should be okay to an IDE usually if set up right. ^_^ - @989onan
# Configure logging
logging.basicConfig(level=logging.INFO)
logger: logging.Logger = logging.getLogger(__name__)
def import_multi_files(method = None, directory: typing.Optional[str] = None, files: list[dict[str,str]] = None, filepath: typing.Optional[str] = ""):
if not files:
method(directory, filepath)
else:
for file in files:
fullpath = os.path.join(directory,os.path.basename(file["name"]))
print("run method!")
method(directory, fullpath)
#each import should map to a type. even in the case that multiple methods should import together, or have the same import method. Make sure the lambdas match so they get grouped together
#In the case of a file importer that takes only one file argument and each one needs individual import, use above method. (example of it in use is ".dae" format)
import_types: dict[str, typing.Callable[[str, list[dict[str,str]], str], None]] = {
"fbx": (lambda directory, files, filepath : bpy.ops.import_scene.fbx(files=files, directory=directory, filepath=filepath,automatic_bone_orientation=False,use_prepost_rot=False,use_anim=False)),
"smd": (lambda directory, files, filepath : eval("bpy."+SmdImporter.bl_idname+".(files=files, directory=directory, filepath=filepath)")),
"dmx": (lambda directory, files, filepath: eval("bpy."+SmdImporter.bl_idname+".(files=files, directory=directory, filepath=filepath)")),
"gltf": (lambda directory, files, filepath : bpy.ops.import_scene.gltf(files=files, filepath=filepath)),
"glb": (lambda directory, files, filepath : bpy.ops.import_scene.gltf(files=files, filepath=filepath)),
"qc": (lambda directory, files, filepath : eval("bpy."+SmdImporter.bl_idname+".(files=files, directory=directory, filepath=filepath)")),
"obj": (lambda directory, files, filepath : bpy.ops.wm.obj_import(files=files, directory=directory, filepath=filepath)),
"dae": (lambda directory, files, filepath : import_multi_files(directory=directory, files=files, filepath=filepath, method = (lambda directory, filepath: bpy.ops.wm.collada_import(filepath=filepath, auto_connect = True, find_chains = True, fix_orientation = True)))),
"3ds": (lambda directory, files, filepath : bpy.ops.import_scene.max3ds(files=files, directory=directory, filepath=filepath)),
"stl": (lambda directory, files, filepath : bpy.ops.import_mesh.stl(files=files, directory=directory, filepath=filepath)),
"mtl": (lambda directory, files, filepath : bpy.ops.wm.obj_import(files=files, directory=directory, filepath=filepath)),
"x3d": (lambda directory, files, filepath : bpy.ops.import_scene.x3d(files=files, directory=directory, filepath=filepath)),
"wrl": (lambda directory, files, filepath : bpy.ops.import_scene.x3d(files=files, directory=directory, filepath=filepath)),
"vmd": (lambda directory, files, filepath : import_multi_files(directory=directory, files=files, filepath=filepath, method = (lambda directory, filepath: bpy.ops.tuxedo.import_mmd_animation(directory=directory, filepath=filepath)))),
"vrm": (lambda directory, files, filepath: bpy.ops.import_scene.vrm(filepath=filepath)),
"pmx": (lambda directory, files, filepath : import_pmx(filepath)),
"pmd": (lambda directory, files, filepath : import_pmd(filepath)),
import importlib.util
if importlib.util.find_spec("io_scene_valvesource") is not None:
from io_scene_valvesource.import_smd import SmdImporter
class ImportProgress:
"""Tracks and logs the progress of multi-file imports"""
def __init__(self, total_files: int):
self.total: int = total_files
self.current: int = 0
def update(self, filename: str) -> None:
"""Update import progress and log current file"""
self.current += 1
logger.info(f"Importing {filename} ({self.current}/{self.total})")
def validate_file(filepath: str) -> bool:
"""
Validate if a file exists and is accessible
Returns: True if file is valid, False otherwise
"""
if not os.path.exists(filepath):
logger.error(f"File not found: {filepath}")
return False
if not os.path.isfile(filepath):
logger.error(f"Not a file: {filepath}")
return False
return True
def import_multi_files(
method: Optional[Callable] = None,
directory: Optional[str] = None,
files: Optional[List[Dict[str, str]]] = None,
filepath: str = "",
progress_callback: Optional[Callable[[str], None]] = None
) -> None:
"""
Import multiple files using the specified import method
Args:
method: Import method to use
directory: Directory containing files
files: List of files to import
filepath: Single file path to import
progress_callback: Callback for progress updates
"""
try:
if not method:
raise ValueError("Import method not specified")
if not files:
if not validate_file(filepath):
return
method(directory, filepath)
if progress_callback:
progress_callback(filepath)
else:
progress = ImportProgress(len(files))
for file in files:
fullpath: str = os.path.join(directory, os.path.basename(file["name"]))
if not validate_file(fullpath):
continue
logger.info(f"Importing file: {fullpath}")
method(directory, fullpath)
if progress_callback:
progress_callback(fullpath)
progress.update(file["name"])
except Exception as e:
logger.error(f"Import failed: {str(e)}", exc_info=True)
raise
ImportMethod = Callable[[str, List[Dict[str, str]], str], None]
import_types: Dict[str, ImportMethod] = {
"fbx": lambda directory, files, filepath: bpy.ops.import_scene.fbx(
files=files, directory=directory, filepath=filepath,
automatic_bone_orientation=False, use_prepost_rot=False, use_anim=False
),
"smd": lambda directory, files, filepath: eval("bpy."+SmdImporter.bl_idname+".(files=files, directory=directory, filepath=filepath)"),
"dmx": lambda directory, files, filepath: eval("bpy."+SmdImporter.bl_idname+".(files=files, directory=directory, filepath=filepath)"),
"gltf": lambda directory, files, filepath: bpy.ops.import_scene.gltf(files=files, filepath=filepath),
"glb": lambda directory, files, filepath: bpy.ops.import_scene.gltf(files=files, filepath=filepath),
"qc": lambda directory, files, filepath: eval("bpy."+SmdImporter.bl_idname+".(files=files, directory=directory, filepath=filepath)"),
"obj": lambda directory, files, filepath: bpy.ops.wm.obj_import(files=files, directory=directory, filepath=filepath),
"dae": lambda directory, files, filepath: import_multi_files(
directory=directory,
files=files,
filepath=filepath,
method=lambda directory, filepath: bpy.ops.wm.collada_import(
filepath=filepath, auto_connect=True, find_chains=True, fix_orientation=True
)
),
"3ds": lambda directory, files, filepath: bpy.ops.import_scene.max3ds(files=files, directory=directory, filepath=filepath),
"stl": lambda directory, files, filepath: bpy.ops.import_mesh.stl(files=files, directory=directory, filepath=filepath),
"mtl": lambda directory, files, filepath: bpy.ops.wm.obj_import(files=files, directory=directory, filepath=filepath),
"x3d": lambda directory, files, filepath: bpy.ops.import_scene.x3d(files=files, directory=directory, filepath=filepath),
"wrl": lambda directory, files, filepath: bpy.ops.import_scene.x3d(files=files, directory=directory, filepath=filepath),
"vmd": lambda directory, files, filepath: import_multi_files(
directory=directory,
files=files,
filepath=filepath,
method=lambda directory, filepath: bpy.ops.tuxedo.import_mmd_animation(directory=directory, filepath=filepath)
),
"vrm": lambda directory, files, filepath: bpy.ops.import_scene.vrm(filepath=filepath),
"pmx": lambda directory, files, filepath: import_pmx(filepath),
"pmd": lambda directory, files, filepath: import_pmd(filepath),
}
def concat_imports_filter(imports):
names = ""
for importer in imports.keys():
names = names+"*."+importer+";"
return names
def concat_imports_filter(imports: Dict[str, ImportMethod]) -> str:
"""Create a file filter string from import types"""
return "".join(f"*.{importer};" for importer in imports.keys())
imports = concat_imports_filter(import_types)
imports: str = concat_imports_filter(import_types)
-152
View File
@@ -1,152 +0,0 @@
# thank you https://stackoverflow.com/a/71432759
from __future__ import annotations
from typing import Optional
from bpy.types import Image, Material
# Copyright (c) 2011, 2012, 2013, 2014, 2015, 2016 Jake Gordon and contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
class Rectangle_Obj:
x: int = 0
y: int = 0
w: int = 0
h: int = 0
down: Rectangle_Obj = None
used: bool = False
right: Rectangle_Obj = None
def __init__(self, x:int, y:int, w:int, h:int, down=None, used =False, right=None):
self.x = x
self.y = y
self.w = w
self.h = h
self.down = down
self.used = used
self.right = right
def split(self, w, h) -> Rectangle_Obj:
self.used = True
self.down = Rectangle_Obj(x=self.x, y=self.y + h, w=self.w, h=self.h - h)
self.right = Rectangle_Obj(x=self.x + w, y=self.y, w=self.w - w, h=h)
return self
def find(self, w, h) -> Optional[Rectangle_Obj]:
if self.used:
return self.right.find(w, h) or self.down.find(w, h)
elif (w <= self.w) and (h <= self.h):
return self
return None
class MaterialImageList:
albedo: Image
normal: Image
emission: Image
ambient_occlusion: Image
height: Image
roughness: Image
fit: Rectangle_Obj
material: Material
def __init__(self):
pass
x: int = 0
y: int = 0
w: int = 0
h: int = 0
class BinPacker(object):
root: Rectangle_Obj
bin: list[MaterialImageList] = []
def __init__(self, structure: list[MaterialImageList]):
self.root = None
self.bin = structure
def fit(self):
structure = self.bin
structure_len = len(self.bin)
w: int = 0
h: int = 0
if structure_len > 0:
w = structure[0].w
h = structure[0].h
self.root = Rectangle_Obj(x=0, y=0, w=w, h=h)
for img in structure:
w = img.w
h = img.h
node = self.root.find(w, h)
if node:
img.fit = node.split(w, h)
else:
img.fit = self.grow_node(w, h)
return structure
def grow_node(self, w, h) -> Optional[Rectangle_Obj]:
can_grow_right = (h <= self.root.h)
can_grow_down = (w <= self.root.w)
should_grow_right = can_grow_right and (self.root.h >= (self.root.w + w))
should_grow_down = can_grow_down and (self.root.w >= (self.root.h + h))
if should_grow_right:
return self.grow_right(w, h)
elif should_grow_down:
return self.grow_down(w, h)
elif can_grow_right:
return self.grow_right(w, h)
elif can_grow_down:
return self.grow_down(w, h)
return None
def grow_right(self, w, h) -> Optional[Rectangle_Obj]:
self.root = Rectangle_Obj(
used=True,
x=0,
y=0,
w=self.root.w + w,
h=self.root.h,
down=self.root,
right=Rectangle_Obj(x=self.root.w, y=0, w=w, h=self.root.h))
node = self.root.find(w, h)
if node:
return node.split(w, h)
return None
def grow_down(self, w, h) -> Optional[Rectangle_Obj]:
self.root = Rectangle_Obj(
used=True,
x=0,
y=0,
w=self.root.w,
h=self.root.h + h,
down=Rectangle_Obj(x=0, y=self.root.h, w=self.root.w, h=h),
right=self.root
)
node = self.root.find(w, h)
if node:
return node.split(w, h)
return None
+23 -173
View File
@@ -1,189 +1,39 @@
import bpy
from .translations import t, get_languages_list, update_language
from typing import List, Tuple, Optional
from bpy.types import PropertyGroup, Material, Scene, Object, Context
from bpy.props import (StringProperty, BoolProperty, EnumProperty,
IntProperty, FloatProperty, CollectionProperty,
PointerProperty)
from bpy.props import (
StringProperty,
BoolProperty,
EnumProperty,
IntProperty,
FloatProperty,
CollectionProperty,
PointerProperty
)
from .translations import t, get_languages_list, update_language
from .addon_preferences import get_preference
from .common import SceneMatClass, MaterialListBool, get_armatures, get_mesh_items, get_armatures_that_are_not_selected
from .updater import get_version_list
from .common import get_armature_list
class AvatarToolkitSceneProperties(PropertyGroup):
language: EnumProperty(
name="Language",
description="Select the language for the addon",
items=get_languages_list,
update=update_language
)
"""Property group containing Avatar Toolkit scene-level settings and properties"""
selected_mesh: EnumProperty(
items=get_mesh_items,
name="Selected Mesh",
description="Select mesh to modify"
)
material_search_filter: StringProperty(
name="Search Materials",
description="Filter materials by name",
default=""
)
merge_armature_apply_transforms: BoolProperty(
default=False,
name="Apply Transforms",
description="Apply transforms when merging armatures"
)
merge_armature_align_bones: BoolProperty(
default=False,
name="Align Bones",
description="Align bones when merging armatures"
)
progress_steps: IntProperty(default=0)
progress_current: IntProperty(default=0)
language_changed: BoolProperty(default=False)
mouth_a: StringProperty(
name="Mouth A",
description="Shape key for A sound"
)
mouth_o: StringProperty(
name="Mouth O",
description="Shape key for O sound"
)
mouth_ch: StringProperty(
name="Mouth CH",
description="Shape key for CH sound"
)
shape_intensity: FloatProperty(
name="Shape Intensity",
description="Intensity of shape key modifications",
default=1.0,
min=0.0,
max=2.0
)
merge_twist_bones: BoolProperty(
name="Merge Twist Bones",
description="Merge twist bones during processing",
default=True
)
selected_armature: EnumProperty(
items=get_armatures,
name="Selected Armature",
description="Select the armature to work with"
)
merge_armature_source: EnumProperty(
items=get_armatures_that_are_not_selected,
name="Source Armature",
description="Select the source armature for merging"
)
texture_atlas_material_index: IntProperty(
default=-1,
get=lambda self: -1,
set=lambda self, context: None
)
materials: CollectionProperty(type=SceneMatClass)
texture_atlas_Has_Mat_List_Shown: BoolProperty(
default=False,
get=MaterialListBool.get_bool,
set=MaterialListBool.set_bool
)
avatar_toolkit_updater_version_list: EnumProperty(
items=get_version_list,
name="Version List",
description="List of available versions"
name=t("Scene.avatar_toolkit_updater_version_list.name"),
description=t("Scene.avatar_toolkit_updater_version_list.description")
)
class AvatarToolkitMaterialProperties(PropertyGroup):
material_expanded: BoolProperty(
name="Expand Material",
description="Show/hide material properties",
default=False
active_armature: EnumProperty(
items=get_armature_list,
name=t("QuickAccess.select_armature"),
description=t("QuickAccess.select_armature")
)
include_in_atlas: BoolProperty(
name="Include in Atlas",
description="Include this material in texture atlas",
default=True
)
def get_texture_node_list(self, context):
# Access the material through the property group's id_data
material = self.id_data
if material and material.use_nodes:
nodes = [(i.image.name if i.image else i.name+"_image",
i.image.name if i.image else "node with no image...",
i.image.name if i.image else i.name, index+1)
for index, i in enumerate(material.node_tree.nodes)
if i.bl_idname == "ShaderNodeTexImage"]
if not nodes:
nodes = [("Error", "No images found", "Error", 0)]
else:
nodes = [("Error", "No node tree found", "Error", 0)]
nodes.append(("None", "None", "None", 0))
return nodes
texture_atlas_albedo: EnumProperty(
name="Albedo",
description="Albedo texture for atlas",
items=get_texture_node_list
)
texture_atlas_normal: EnumProperty(
name="Normal",
description="Normal map for atlas",
items=get_texture_node_list
)
texture_atlas_emission: EnumProperty(
name="Emission",
description="Emission texture for atlas",
items=get_texture_node_list
)
texture_atlas_ambient_occlusion: EnumProperty(
name="Ambient Occlusion",
description="AO texture for atlas",
items=get_texture_node_list
)
texture_atlas_height: EnumProperty(
name="Height",
description="Height map for atlas",
items=get_texture_node_list
)
texture_atlas_roughness: EnumProperty(
name="Roughness",
description="Roughness map for atlas",
items=get_texture_node_list
)
class AvatarToolkitObjectProperties(PropertyGroup):
material_group_expanded: BoolProperty(
name="Expand Material Group",
description="Show/hide materials for this mesh",
default=False
)
def register():
def register() -> None:
"""Register the Avatar Toolkit property group"""
bpy.types.Scene.avatar_toolkit = PointerProperty(type=AvatarToolkitSceneProperties)
bpy.types.Material.avatar_toolkit = PointerProperty(type=AvatarToolkitMaterialProperties)
bpy.types.Object.avatar_toolkit = PointerProperty(type=AvatarToolkitObjectProperties)
def unregister():
def unregister() -> None:
"""Unregister the Avatar Toolkit property group"""
del bpy.types.Scene.avatar_toolkit
del bpy.types.Material.avatar_toolkit
del bpy.types.Object.avatar_toolkit
+34 -8
View File
@@ -276,22 +276,48 @@ def get_version_list(self, context: bpy.types.Context) -> List[Tuple[str, str, s
return [(v, v, '') for v in version_list.keys()] if version_list else []
def draw_updater_panel(context: bpy.types.Context, layout: bpy.types.UILayout) -> None:
col = layout.column(align=True)
box = layout.box()
col = box.column(align=True)
# Header
row = col.row()
row.scale_y = 1.2
row.label(text=t('Updater.label'), icon='DOWNARROW_HLT')
col.separator()
# Update check/status section
if is_checking_for_update:
col.operator(AvatarToolkit_OT_CheckForUpdate.bl_idname, text=t('Updater.CheckForUpdateButton.label'))
col.operator(AvatarToolkit_OT_CheckForUpdate.bl_idname,
text=t('Updater.CheckForUpdateButton.label'),
icon='SORTTIME')
elif update_needed:
col.operator(AvatarToolkit_OT_UpdateToLatest.bl_idname, text=t('Updater.UpdateToLatestButton.label', name=latest_version_str))
update_row = col.row(align=True)
update_row.scale_y = 1.5
update_row.alert = True
update_row.operator(AvatarToolkit_OT_UpdateToLatest.bl_idname,
text=t('Updater.UpdateToLatestButton.label', name=latest_version_str),
icon='IMPORT')
else:
col.operator(AvatarToolkit_OT_CheckForUpdate.bl_idname, text=t('Updater.CheckForUpdateButton.label_alt'))
col.operator(AvatarToolkit_OT_CheckForUpdate.bl_idname,
text=t('Updater.CheckForUpdateButton.label_alt'),
icon='FILE_REFRESH')
# Version selection section
col.separator()
row = col.row(align=True)
box_inner = col.box()
box_inner.label(text=t('Updater.selectVersion'), icon='SETTINGS')
row = box_inner.row(align=True)
row.prop(context.scene.avatar_toolkit, 'avatar_toolkit_updater_version_list', text='')
row.operator(AvatarToolkit_OT_UpdateToLatest.bl_idname, text=t('Updater.UpdateToSelectedButton.label'))
row.operator(AvatarToolkit_OT_UpdateToLatest.bl_idname,
text=t('Updater.UpdateToSelectedButton.label'),
icon='IMPORT')
# Current version info
col.separator()
col.label(text=t('Updater.currentVersion').format(name=get_current_version()))
curr_ver_row = col.row()
curr_ver_row.label(text=t('Updater.currentVersion').format(name=get_current_version()),
icon='CHECKMARK')
def ui_refresh() -> None:
for windowManager in bpy.data.window_managers: