Compare commits
252 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b7f6632ea | |||
| 24b489f7a2 | |||
| 1e734a518e | |||
| 4b538cb8b2 | |||
| d85231b62b | |||
| b13ca15ece | |||
| 84bacca923 | |||
| 53d2ac10b7 | |||
| 95cb726485 | |||
| cb5b891d0d | |||
| aedd83e078 | |||
| 5719a55ae5 | |||
| 659f3eb91e | |||
| ff19a895dc | |||
| e6e5a98e58 | |||
| 3fe00da569 | |||
| 108f9d3bc8 | |||
| 1847628dc8 | |||
| 25a43afdbc | |||
| baaf4049f6 | |||
| 7ef86b68fa | |||
| 27e18b5656 | |||
| b61283b9d5 | |||
| fbcf709ffc | |||
| 299800e5c2 | |||
| f6197ccbbf | |||
| fd01c39cf9 | |||
| 117ce4f41d | |||
| f11e9d35fb | |||
| 7f1decc644 | |||
| a929f68ad4 | |||
| f0bda259d3 | |||
| f4d93a8180 | |||
| 303707adf7 | |||
| ef84478af7 | |||
| 56005c5d37 | |||
| fe122f9f13 | |||
| 17fb0fcadd | |||
| 1d9c186613 | |||
| 49f5bf7063 | |||
| daef1298d4 | |||
| 86406efc6b | |||
| 734d5fe401 | |||
| 5029ba8724 | |||
| 3545951fae | |||
| 0b5bff9222 | |||
| 862849c032 | |||
| e060186716 | |||
| 07c4dd501f | |||
| e80c0c034d | |||
| f40b2faacb | |||
| d2b98716ff | |||
| e4f3cdbf17 | |||
| 1d34ac2dd8 | |||
| 3bb533ff64 | |||
| 69cae02160 | |||
| 5496078a39 | |||
| dbf2fb77f9 | |||
| 3de600cf64 | |||
| ba9d579176 | |||
| 35458f9aed | |||
| d2c30caef5 | |||
| 54a1dff122 | |||
| 7dc74964e8 | |||
| 00a015a8d3 | |||
| b9f7a4acd0 | |||
| e626bdc5c5 | |||
| da2bfeb2fc | |||
| 2b53146e83 | |||
| 444554528d | |||
| cae6ce4301 | |||
| 74716b187f | |||
| fbb4569e99 | |||
| 56967fc9a9 | |||
| 5881180e69 | |||
| 4ba594d712 | |||
| 031b78ee7b | |||
| 61c77cf756 | |||
| e19dd78557 | |||
| d820edfc64 | |||
| b39e20e647 | |||
| 929cadd596 | |||
| f90efb549a | |||
| 3e8ab41ab9 | |||
| c28cfe1d1d | |||
| 15ce911256 | |||
| 2f3b8ab0ee | |||
| 7b58f25913 | |||
| d25543d95b | |||
| ba9a7a8af3 | |||
| 408d3f24f7 | |||
| bd33efe7ae | |||
| c50f275b1b | |||
| 1ddda1336a | |||
| 634563afb3 | |||
| 543869218c | |||
| e5e09e2cf3 | |||
| 29f728442a | |||
| 8c2c52f882 | |||
| 6f5e7a394d | |||
| 6eb253be17 | |||
| 5276aa0fe0 | |||
| c830938dce | |||
| 60ba1b363f | |||
| e3052d867d | |||
| 08082501c9 | |||
| a8482a87f3 | |||
| 482fe1b593 | |||
| c95c7e596c | |||
| b9c0a34065 | |||
| c055d60053 | |||
| f8ef79e7cc | |||
| 6d9f751a16 | |||
| 89fc8bc9c8 | |||
| d31519a51d | |||
| 1fcd1ad07d | |||
| 5be65501b4 | |||
| 9e00234f0d | |||
| 8937077e3a | |||
| 80dfaf2cce | |||
| ebbebf33f4 | |||
| 316b125fa8 | |||
| 9a84cf52b5 | |||
| e2c26a20fa | |||
| cfe760e8df | |||
| 61e4269764 | |||
| bf92ca905b | |||
| d1af3fffed | |||
| 19c2ede791 | |||
| e88a952c84 | |||
| bb5a314796 | |||
| 567f5fe541 | |||
| c31d25dd01 | |||
| d25c95fc73 | |||
| 69cc03098f | |||
| 161684dcac | |||
| 6bafc7d7ac | |||
| cf2a5a22cc | |||
| 88e88b94a3 | |||
| 046ebfa72d | |||
| 036e260dd6 | |||
| ce2b38b5fe | |||
| d1912d2dba | |||
| 6e06f73174 | |||
| 3414ad8917 | |||
| 3e3e245a4f | |||
| f28e1866a9 | |||
| 71b22813a8 | |||
| f16105517e | |||
| 199551a505 | |||
| 5cad28a41b | |||
| e4d3f676a2 | |||
| 3ada550067 | |||
| 9dd54cd976 | |||
| c1536f8e06 | |||
| 82a7e67d7e | |||
| 87c40c02d6 | |||
| 2ad5393f06 | |||
| 12083c28c5 | |||
| c67f30fb97 | |||
| 5a43a9d66d | |||
| 1ca45ad901 | |||
| bc034c5308 | |||
| eba18d72a6 | |||
| 416fbe40e7 | |||
| d296d548e8 | |||
| feb2f5ac85 | |||
| 0ff2dc1c38 | |||
| a407e99ebd | |||
| ff5efc9639 | |||
| 345ba44463 | |||
| b67d94e89d | |||
| 64a78dbbb2 | |||
| 1e8784d0e4 | |||
| c0943e0d20 | |||
| af5b79e314 | |||
| 334f299e0e | |||
| 9a5f13f858 | |||
| 77b7b429a5 | |||
| 9a0521dad5 | |||
| d7fee2c961 | |||
| 357aa1b6d9 | |||
| 02c73ccd2a | |||
| 940854cade | |||
| 7a1531fbd6 | |||
| 672517a771 | |||
| 4a039421f5 | |||
| 15101fa887 | |||
| 5411acca45 | |||
| eeb41dec40 | |||
| 10fb112de7 | |||
| c532e2a6a0 | |||
| 4df5369cbb | |||
| 36550a42e5 | |||
| 2dc3a19283 | |||
| 30115eeaac | |||
| 285c331f79 | |||
| 57ded41f2f | |||
| 948b1bb352 | |||
| 9104bfae67 | |||
| 23b4462f7e | |||
| dd3d21d9d5 | |||
| 73c2404010 | |||
| 546fec6039 | |||
| c90bf4e36c | |||
| 5b9acb496f | |||
| 08a65f9fa7 | |||
| ba85666d9f | |||
| 52a51ea6a2 | |||
| 47e3ea2d29 | |||
| 12d06638fe | |||
| 2a2c3d3973 | |||
| c65bed3ff4 | |||
| abc1fe955b | |||
| dac25e0dc0 | |||
| 6d71669849 | |||
| b946041ec1 | |||
| 07adaa590b | |||
| 855bb84e76 | |||
| fbb07aec10 | |||
| dd36ccaece | |||
| 017633696a | |||
| 71cba9a40f | |||
| 53cc5c28ae | |||
| 21ddc20119 | |||
| 2524634ef4 | |||
| bf6a32febb | |||
| 4576b27b53 | |||
| 6412b6f619 | |||
| f043c6099e | |||
| a20a306582 | |||
| 4b59147649 | |||
| 6038177383 | |||
| 251c006498 | |||
| 3eb0029b5e | |||
| 686bc0bda1 | |||
| 1482632405 | |||
| 2a7cb16fea | |||
| 1187949280 | |||
| af9c597dd2 | |||
| fc9b1e42a2 | |||
| af311d7d2e | |||
| 239e212cf4 | |||
| 1333b4d2d4 | |||
| 7fb1b9a8a4 | |||
| 2283a44579 | |||
| c7318fbd0c | |||
| f376b06caf | |||
| 4b69832ca1 | |||
| 071b8186c9 | |||
| 146dec71f8 | |||
| 44593813b2 |
@@ -1,3 +1,4 @@
|
||||
|
||||
*.pyc
|
||||
.vscode/settings.json
|
||||
core/preferences.json
|
||||
|
||||
Vendored
+5
-5
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"python.analysis.extraPaths": [
|
||||
"D:\\SteamLibrary\\steamapps\\common\\Blender\\4.3\\scripts\\addons",
|
||||
"C:\\Users\\Onan\\AppData\\Roaming\\Blender Foundation\\Blender\\4.3\\extensions\\user_default\\",//C:/Users/Onan/AppData/Roaming/Blender Foundation/Blender/4.0/scripts/addons
|
||||
"D:\\SteamLibrary\\steamapps\\common\\Blender\\4.4\\scripts\\addons",
|
||||
"C:\\Users\\Onan\\AppData\\Roaming\\Blender Foundation\\Blender\\4.4\\extensions\\user_default\\",//C:/Users/Onan/AppData/Roaming/Blender Foundation/Blender/4.0/scripts/addons
|
||||
"D:\\blender stuff\\blendercodestuff\\4.3",
|
||||
"D:\\SteamLibrary\\steamapps\\common\\Blender\\4.3\\python\\lib\\site-packages",
|
||||
"/Users/frankche/Documents/blendercoding/4.1/",
|
||||
"/Users/frankche/Library/Application Support/Blender/4.3/extensions/user_default/"
|
||||
"D:\\SteamLibrary\\steamapps\\common\\Blender\\4.4\\python\\lib\\site-packages",
|
||||
"/Users/frankche/Documents/blendercoding/4.3/",
|
||||
"/Users/frankche/Library/Application Support/Blender/4.4/extensions/user_default/"
|
||||
],
|
||||
"python.analysis.diagnosticSeverityOverrides": {
|
||||
"reportInvalidTypeForm": "none"
|
||||
|
||||
@@ -1,30 +1,40 @@
|
||||
# Avatar Toolkit
|
||||
We are aware the wiki is down and are working on a new one, please don't report this.
|
||||
|
||||
## Avatar Toolkit is in Alpha, There will be issues, please ensure you report them!. If using a Alpha plugin isn't your fancy you can find Cats Blender Plugin [HERE](https://github.com/unofficalcats/Cats-Blender-Plugin-Unofficial-)!
|
||||
#### Avatar Toolkit is in Alpha and will contain issues, please ensure you report them!
|
||||
|
||||
A new modern tool designed to shorten steps needed to import and optimize models into VRChat, Resonite and other similar games.
|
||||
Avatar Toolkit is a modern, Blender addon designed to streamline the process of preparing 3D avatars for virtual platforms including VRChat, ChilloutVR, Resonite, and other similar applications.
|
||||
|
||||
With the Avatar Toolkit it only takes a few minutes to upload your model into VRChat, Resonite and other similar games.
|
||||
## What is Avatar Toolkit?
|
||||
Avatar Toolkit simplifies the workflow for avatar creation and optimization by providing an all-in-one solution that:
|
||||
- Automates complex optimization processes like mesh joining and vertex merging.
|
||||
- Provides advanced tools for eye tracking setup and viseme configuration.
|
||||
- Offers specialized armature utilities including bone name conversion for different platforms.
|
||||
- Includes performance-focused optimization tools so you can optimize your avatar for platforms like VRChat and ChilloutVR.
|
||||
|
||||
Join the Neoneko Discord here: https://discord.catsblenderplugin.xyz
|
||||
The addon is built with a focus on user experience, reducing the number of steps needed to prepare avatars while offering powerful customization options for advanced users. Avatar Toolkit aims to be a complete replacement for Cats Blender Plugin and its unofficial variants, with a modern codebase designed specifically for current Blender versions and minimal dependencies on third-party plugins.
|
||||
|
||||
Join the Neoneko Discord here: https://discord.neoneko.xyz
|
||||
|
||||
Need a more stable toolset while Avatar Toolkit is in Alpha? Then please use Blender 4.x and use our Unofficial Cats Blender Plugin which you can find [here](https://github.com/unofficalcats/Cats-Blender-Plugin-Unofficial-).
|
||||
|
||||
### Support us:
|
||||
If you like what we do and want to help support the development of cats you can do it on our pally.gg [here](https://pally.gg/p/teamneoneko) all money is split automatically between all developers and any support is appreciated.
|
||||
|
||||
## Blender version support policies.
|
||||
|
||||
You can find them on the wiki here [HERE](https://avatartoolkit.xyz/wiki.html?version=0.1.0#what-is-avatar-toolkits-version-support-policy)
|
||||
You can find them on the wiki here [HERE](https://avatartoolkit.xyz/legacywiki.html?version=0.2.1#what-is-avatar-toolkits-version-support-policy)
|
||||
|
||||
## Features
|
||||
|
||||
See everything Avatar Toolkit has ot offer [here](https://avatartoolkit.xyz/wiki.html)
|
||||
See everything Avatar Toolkit has ot offer [here](https://avatartoolkit.xyz/legacywiki.html)
|
||||
|
||||
## Requirements
|
||||
|
||||
1) Blender Version
|
||||
- Blender 4.3 or newer is required
|
||||
- Blender 4.3 is the current recommended version
|
||||
|
||||
- Blender 4.5 or newer is required
|
||||
- Blender 4.5 is the current recommended version
|
||||
|
||||
2) Python Requirements
|
||||
- If using a custom Python installation with Blender, ensure NumPy is installed
|
||||
@@ -32,13 +42,23 @@ See everything Avatar Toolkit has ot offer [here](https://avatartoolkit.xyz/wiki
|
||||
|
||||
3) Recommended Setup
|
||||
- Download Blender directly from https://blender.org
|
||||
- Use Blender 4.3 for the best experience
|
||||
- Use Blender 4.5 for the best experience
|
||||
|
||||
#### Unfortunately, due to the increased number of people complaining to me (yes, we get DMs about this) that AT or CATS is broken when it's not, we are going to have to be a bit more strict about which Blender releases we will provide support for.
|
||||
|
||||
#### We only support the following Blender releases:
|
||||
- Steam release
|
||||
- The Blender website releases (there are downloads for Linux, Mac, and Windows)
|
||||
|
||||
#### We do not support the following what so ever and we will not give help if your running the following.
|
||||
- We do not support the Windows Store due to it causing issues, and we also don't support the Snap Store for Linux.
|
||||
- We do not support package manager releases on Linux. This is because package managers are normally run by the distro, and a lot of the time the distro will build Blender themselves and make their own changes which are not sanctioned by Blender (for example, bundling a newer version of Python which tends to break plugins). If you report a bug from anything apart from the Blender versions we support, you will be told we can't help you from now on.
|
||||
|
||||
#### Additional Plugins Requirements.
|
||||
Currently None.
|
||||
|
||||
## Installation
|
||||
You can find out how to install Avatar Toolkit [here](https://avatartoolkit.xyz/wiki.html?version=0.1.0#how-to-install-avatar-toolkit)
|
||||
You can find out how to install Avatar Toolkit [here](https://avatartoolkit.xyz/legacywiki.html?version=0.2.0#how-to-install-avatar-toolkit)
|
||||
|
||||
## Help
|
||||
|
||||
|
||||
+42
-15
@@ -1,25 +1,52 @@
|
||||
import bpy
|
||||
from bpy.app.handlers import persistent
|
||||
|
||||
|
||||
modules = None
|
||||
ordered_classes = None
|
||||
|
||||
def register():
|
||||
# Add wheel installation check
|
||||
try:
|
||||
import lz4
|
||||
except ImportError:
|
||||
import sys
|
||||
import os
|
||||
import site
|
||||
import pip
|
||||
wheels_dir = os.path.join(os.path.dirname(__file__), "wheels")
|
||||
for wheel in os.listdir(wheels_dir):
|
||||
if wheel.endswith(".whl"):
|
||||
pip.main(['install', os.path.join(wheels_dir, wheel)])
|
||||
site.addsitedir(site.getsitepackages()[0])
|
||||
def show_version_error_popup():
|
||||
def draw(self, context):
|
||||
self.layout.label(text="Sorry, this version of Avatar Toolkit does not work on this version of Blender.")
|
||||
self.layout.label(text="Please check the GitHub repository for the correct version for your Blender.")
|
||||
self.layout.operator("wm.url_open", text="Open GitHub Repository").url = "https://github.com/teamneoneko/Avatar-Toolkit"
|
||||
|
||||
bpy.context.window_manager.popup_menu(draw, title="Avatar Toolkit Version Error", icon='ERROR')
|
||||
|
||||
def register():
|
||||
import bpy
|
||||
version = bpy.app.version
|
||||
if version[0] > 5 or (version[0] == 5 and version[1] >= 3):
|
||||
show_version_error_popup()
|
||||
return
|
||||
|
||||
from .core import auto_load
|
||||
print("Starting registration")
|
||||
|
||||
# Import modules using relative imports
|
||||
from . import core
|
||||
from .core import auto_load
|
||||
from .core.logging_setup import configure_logging
|
||||
from .core.addon_preferences import get_preference
|
||||
|
||||
# Initialize logging
|
||||
configure_logging(False)
|
||||
|
||||
auto_load.init()
|
||||
auto_load.register()
|
||||
|
||||
# Verify property registration
|
||||
if not hasattr(bpy.types.Scene, "avatar_toolkit"):
|
||||
from .core.properties import register as register_properties
|
||||
register_properties()
|
||||
|
||||
if hasattr(bpy.types.Scene, "avatar_toolkit"):
|
||||
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():
|
||||
|
||||
@@ -3,21 +3,25 @@
|
||||
schema_version = "1.0.0"
|
||||
|
||||
id = "avatar_toolkit"
|
||||
version = "0.1.2"
|
||||
version = "0.6.0"
|
||||
name = "Avatar Toolkit"
|
||||
tagline = "A modern tool for importing and optimizing models for VRChat, Resonite, and other similar games."
|
||||
maintainer = "Team NekoNeo"
|
||||
type = "add-on"
|
||||
|
||||
blender_version_min = "4.3.0"
|
||||
blender_version_min = "5.0.0"
|
||||
|
||||
license = [
|
||||
"SPDX:GPL-3.0-or-later",
|
||||
]
|
||||
|
||||
wheels = [
|
||||
"./wheels/lz4-4.3.3-cp311-cp311-macosx_11_0_arm64.whl",
|
||||
"./wheels/lz4-4.3.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
|
||||
"./wheels/lz4-4.3.3-cp311-cp311-win_amd64.whl"
|
||||
"./wheels/lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl",
|
||||
"./wheels/lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl",
|
||||
"./wheels/lz4-4.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
|
||||
"./wheels/lz4-4.4.5-cp311-cp311-win_amd64.whl"
|
||||
]
|
||||
|
||||
[permissions]
|
||||
network = "For the auto updater to work, you need to allow network access"
|
||||
files = "Import/Export files, saving atlas images, saving preferences"
|
||||
|
||||
@@ -6,9 +6,14 @@ from ..core.logging_setup import logger
|
||||
from bpy.types import AddonPreferences
|
||||
from typing import Any, Dict
|
||||
|
||||
# Get the directory of the current file
|
||||
PREFERENCES_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PREFERENCES_FILE = os.path.join(PREFERENCES_DIR, "preferences.json")
|
||||
# Get the user preferences directory instead of addon directory
|
||||
def get_preferences_path():
|
||||
user_path = bpy.utils.resource_path('USER')
|
||||
addon_prefs_dir = os.path.join(user_path, "config", "avatar_toolkit_prefs")
|
||||
os.makedirs(addon_prefs_dir, exist_ok=True)
|
||||
return os.path.join(addon_prefs_dir, "preferences.json")
|
||||
|
||||
PREFERENCES_FILE = get_preferences_path()
|
||||
|
||||
def get_current_version():
|
||||
main_dir = os.path.dirname(os.path.dirname(__file__))
|
||||
@@ -58,5 +63,6 @@ def get_addon_preferences(context):
|
||||
# Initialize preferences if the file doesn't exist
|
||||
if not os.path.exists(PREFERENCES_FILE):
|
||||
save_preference("language", 0) # Set default language to 0 (auto)
|
||||
save_preference("validation_mode", "STRICT") # Set default validation mode
|
||||
save_preference("validation_mode", "NONE") # Set default validation mode to NONE (off by default)
|
||||
save_preference("enable_logging", False) # Set default logging mode
|
||||
save_preference("highlight_problem_bones", True) # Set default bone highlighting
|
||||
|
||||
@@ -0,0 +1,845 @@
|
||||
import bpy
|
||||
import math
|
||||
from mathutils import Vector, Color
|
||||
from typing import Tuple, List, Dict, Set, Optional, Union
|
||||
from bpy.types import Object, Bone, Operator
|
||||
from ..core.common import get_armature_list, get_active_armature
|
||||
from ..core.translations import t
|
||||
from ..core.dictionaries import (
|
||||
standard_bones,
|
||||
bone_hierarchy,
|
||||
finger_hierarchy,
|
||||
acceptable_bone_hierarchy,
|
||||
acceptable_bone_names,
|
||||
simplify_bonename
|
||||
)
|
||||
from ..core.logging_setup import logger
|
||||
|
||||
def is_pmx_model(armature: Object) -> bool:
|
||||
"""
|
||||
Check if the armature is a PMX/MMD model.
|
||||
PMX models have an mmd_type attribute set to 'ROOT' on the root object.
|
||||
"""
|
||||
if not armature:
|
||||
return False
|
||||
|
||||
# Check if armature itself has mmd_type set to ROOT
|
||||
if hasattr(armature, 'mmd_type') and armature.mmd_type == 'ROOT':
|
||||
return True
|
||||
|
||||
# Check if parent has mmd_type set to ROOT (parent container model)
|
||||
if hasattr(armature, 'parent') and armature.parent:
|
||||
parent = armature.parent
|
||||
if hasattr(parent, 'mmd_type') and parent.mmd_type == 'ROOT':
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def validate_armature(armature: Object, detailed_messages: bool = False, override_mode: Optional[str] = None) -> Union[Tuple[bool, List[str], bool], Tuple[bool, List[str], bool, List[str], List[str], List[str]]]:
|
||||
"""
|
||||
Validates armature and returns validation results
|
||||
"""
|
||||
logger.debug(f"Validating armature: {armature.name if armature else 'None'}")
|
||||
validation_mode = override_mode if override_mode else bpy.context.scene.avatar_toolkit.validation_mode
|
||||
messages: List[str] = []
|
||||
hierarchy_messages: List[str] = []
|
||||
non_standard_messages: List[str] = []
|
||||
scale_messages: List[str] = []
|
||||
|
||||
# Check if this is a PMX model
|
||||
pmx_model = is_pmx_model(armature)
|
||||
if pmx_model:
|
||||
logger.debug("Detected PMX model, using specialized validation")
|
||||
|
||||
if validation_mode == 'NONE':
|
||||
logger.debug("Validation mode is NONE, skipping validation")
|
||||
if detailed_messages:
|
||||
return True, [t("Validation.mode.none")], False, [], [], []
|
||||
else:
|
||||
return True, [t("Validation.mode.none")], False
|
||||
|
||||
if not armature or armature.type != 'ARMATURE' or not armature.data.bones:
|
||||
logger.warning("Basic armature check failed")
|
||||
if detailed_messages:
|
||||
return False, [t("Armature.validation.basic_check_failed")], False, [], [], []
|
||||
else:
|
||||
return False, [t("Armature.validation.basic_check_failed")], False
|
||||
|
||||
found_bones: Dict[str, Bone] = {bone.name: bone for bone in armature.data.bones}
|
||||
logger.debug(f"Found {len(found_bones)} bones in armature")
|
||||
is_acceptable = check_acceptable_standards(found_bones)
|
||||
|
||||
# List all bones in armature
|
||||
bone_list = "\n".join([f"- {bone}" for bone in found_bones.keys()])
|
||||
messages.append(t("Armature.validation.found_bones", bones=bone_list))
|
||||
|
||||
# Basic validation for both STRICT and LIMITED modes
|
||||
# Check for missing required bones
|
||||
essential_bones = {standard_bones[key] for key in ['hips', 'spine', 'chest', 'neck', 'head']}
|
||||
missing_bones = [bone for bone in essential_bones if bone not in found_bones]
|
||||
|
||||
if missing_bones:
|
||||
missing_list = "\n".join([f"- {bone}" for bone in missing_bones])
|
||||
logger.warning(f"Missing essential bones: {', '.join(missing_bones)}")
|
||||
hierarchy_messages.append(t("Armature.validation.missing_bones", bones=missing_list))
|
||||
|
||||
if validation_mode == 'STRICT':
|
||||
logger.debug("Performing strict validation")
|
||||
# Add scale issue detection in STRICT mode
|
||||
scale_issues = detect_scale_issues(found_bones)
|
||||
if scale_issues:
|
||||
logger.warning(f"Found {len(scale_issues)} scale issues")
|
||||
# CHANGE: Don't combine into a single string, keep as separate items
|
||||
scale_messages.extend(scale_issues)
|
||||
|
||||
# Validate bone hierarchy
|
||||
for parent, child in bone_hierarchy:
|
||||
if parent in found_bones and child in found_bones:
|
||||
if not validate_bone_hierarchy(found_bones, parent, child):
|
||||
logger.warning(f"Invalid hierarchy: {parent} -> {child}")
|
||||
hierarchy_messages.append(t("Armature.validation.invalid_hierarchy",
|
||||
parent=parent, child=child))
|
||||
|
||||
# Validate symmetry
|
||||
logger.debug("Validating bone symmetry")
|
||||
symmetry_pairs = [('arm', 'L', 'R'), ('leg', 'L', 'R')]
|
||||
for base, left, right in symmetry_pairs:
|
||||
if not validate_symmetry(found_bones, base, left, right):
|
||||
logger.warning(f"Asymmetric bones found: {base}")
|
||||
hierarchy_messages.append(t("Armature.validation.asymmetric_bones", bone=base))
|
||||
|
||||
if (not validate_symmetry(found_bones, 'hand', 'L', 'R') and
|
||||
not validate_symmetry(found_bones, 'wrist', 'L', 'R')):
|
||||
logger.warning("Asymmetric hand/wrist bones found")
|
||||
hierarchy_messages.append(t("Armature.validation.asymmetric_hand_wrist"))
|
||||
|
||||
# Validate finger hierarchies
|
||||
logger.debug("Validating finger hierarchies")
|
||||
for side in ['left', 'right']:
|
||||
for finger_chain in finger_hierarchy[side]:
|
||||
if all(bone in found_bones for bone in finger_chain):
|
||||
if not validate_finger_chain(found_bones, finger_chain):
|
||||
logger.warning(f"Invalid finger hierarchy: {finger_chain[0]}")
|
||||
hierarchy_messages.append(t("Armature.validation.invalid_finger", finger=finger_chain[0]))
|
||||
|
||||
# Non-standard bones check
|
||||
non_standard_bones = []
|
||||
|
||||
# Bones to ignore
|
||||
ignore_patterns = [
|
||||
'tail', 'skirt', 'dress', 'hair', 'ribbon', 'bow', 'hat', 'cap',
|
||||
'butt', 'breast', 'boob', 'chest_', 'belly', 'stomach',
|
||||
'wing', 'fin', 'horn', 'ear_', 'accessory', 'extra',
|
||||
'cloth', 'fabric', 'cape', 'coat', 'jacket', 'shirt',
|
||||
'pants', 'shoe', 'boot', 'sock', 'glove', 'mitten',
|
||||
'belt', 'strap', 'buckle', 'button', 'zipper',
|
||||
'jewel', 'gem', 'ring', 'necklace', 'earring',
|
||||
'flower', 'leaf', 'feather', 'fur', 'scale',
|
||||
'bangs', 'sideburn', 'bell', 'leash', 'ears', 'chain',
|
||||
'headband', 'necklace', 'necktie', 'strapNeck', 'ring',
|
||||
'pin', 'hair',
|
||||
|
||||
]
|
||||
|
||||
# Create normalized lookup sets for faster comparison
|
||||
normalized_standard_bones = {simplify_bonename(name) for name in standard_bones.values()}
|
||||
normalized_acceptable_bones = set()
|
||||
for names in acceptable_bone_names.values():
|
||||
normalized_acceptable_bones.update(simplify_bonename(name) for name in names)
|
||||
|
||||
for bone_name in found_bones:
|
||||
# Normalize bone name for comparison
|
||||
normalized_bone_name = simplify_bonename(bone_name)
|
||||
|
||||
# Check if bone should be ignored (accessory bone)
|
||||
is_ignored = any(pattern in normalized_bone_name for pattern in ignore_patterns)
|
||||
|
||||
if not is_ignored:
|
||||
# Check if bone is in standard or acceptable lists
|
||||
is_standard = normalized_bone_name in normalized_standard_bones
|
||||
is_acceptable_bone = normalized_bone_name in normalized_acceptable_bones
|
||||
|
||||
if not (is_standard or is_acceptable_bone):
|
||||
non_standard_bones.append(bone_name)
|
||||
|
||||
if non_standard_bones:
|
||||
non_standard_list = "\n".join([f"- {bone}" for bone in non_standard_bones])
|
||||
non_standard_messages.append(t("Armature.validation.non_standard_bones", bones=non_standard_list))
|
||||
|
||||
non_standard_messages.append(t("Armature.validation.accessory_bones_note.line1"))
|
||||
non_standard_messages.append(t("Armature.validation.accessory_bones_note.line2"))
|
||||
non_standard_messages.append(t("Armature.validation.accessory_bones_note.line3"))
|
||||
non_standard_messages.append(t("Armature.validation.accessory_bones_note.line4"))
|
||||
non_standard_messages.append("") # Add a blank line for spacing
|
||||
non_standard_messages.append(t("Armature.validation.standardize_note.line1"))
|
||||
non_standard_messages.append(t("Armature.validation.standardize_note.line2"))
|
||||
non_standard_messages.append(t("Armature.validation.standardize_note.line3"))
|
||||
|
||||
# Special handling for PMX models
|
||||
if pmx_model:
|
||||
logger.info("PMX model detected, applying specialized validation")
|
||||
# For PMX models, we'll be more lenient with validation
|
||||
# and provide specific guidance for these models
|
||||
if not messages:
|
||||
messages = [t("Armature.validation.pmx_model_detected")]
|
||||
|
||||
# Add PMX-specific messages
|
||||
if validation_mode == 'STRICT':
|
||||
messages.append(t("Armature.validation.pmx_model_strict"))
|
||||
messages.append(t("Armature.validation.pmx_model_standardize"))
|
||||
else:
|
||||
messages.append(t("Armature.validation.pmx_model_basic"))
|
||||
|
||||
# Combine messages in correct order
|
||||
messages.extend(non_standard_messages)
|
||||
|
||||
is_valid = len(non_standard_messages) == 0 and len(hierarchy_messages) == 0 and len(scale_messages) == 0
|
||||
|
||||
if not is_valid and is_acceptable:
|
||||
if non_standard_bones:
|
||||
logger.info("Armature has non-standard bones but is acceptable")
|
||||
if detailed_messages:
|
||||
return False, messages, False, hierarchy_messages, scale_messages, non_standard_messages
|
||||
else:
|
||||
return False, messages, False
|
||||
|
||||
logger.info("Armature meets acceptable standards")
|
||||
messages = [
|
||||
t("Armature.validation.acceptable_standard.success"),
|
||||
t("Armature.validation.acceptable_standard.note"),
|
||||
t("Armature.validation.acceptable_standard.option")
|
||||
]
|
||||
if detailed_messages:
|
||||
return True, messages, True, [], [], []
|
||||
else:
|
||||
return True, messages, True
|
||||
|
||||
# Ensure messages has at least one element
|
||||
if not messages:
|
||||
messages = [t("Armature.validation.unknown_format")]
|
||||
|
||||
logger.info(f"Armature validation complete. Valid: {is_valid}")
|
||||
if detailed_messages:
|
||||
return is_valid, messages, False, hierarchy_messages, scale_messages, non_standard_messages
|
||||
else:
|
||||
return is_valid, messages, False
|
||||
|
||||
def validate_bone_hierarchy(bones: Dict[str, Bone], parent_name: str, child_name: str) -> bool:
|
||||
"""Validate if there is a valid parent-child relationship between bones"""
|
||||
if parent_name not in bones or child_name not in bones:
|
||||
return False
|
||||
return bones[child_name].parent == bones[parent_name]
|
||||
|
||||
def extract_bone_side_info(bone_name: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Extract base bone name and side indicator from a bone name.
|
||||
Returns (base_name, side) where side is 'L', 'R', or ''
|
||||
"""
|
||||
normalized = simplify_bonename(bone_name)
|
||||
original = bone_name
|
||||
|
||||
# Common left/right patterns to check
|
||||
left_patterns = [
|
||||
'left', 'l', 'lft', 'lt',
|
||||
'.l', '_l', '-l', ' l',
|
||||
'左', 'ひだり'
|
||||
]
|
||||
|
||||
right_patterns = [
|
||||
'right', 'r', 'rgt', 'rt',
|
||||
'.r', '_r', '-r', ' r',
|
||||
'右', 'みぎ'
|
||||
]
|
||||
|
||||
# Check for left patterns
|
||||
for pattern in left_patterns:
|
||||
pattern_norm = simplify_bonename(pattern)
|
||||
if normalized.startswith(pattern_norm):
|
||||
base = normalized[len(pattern_norm):]
|
||||
if base: # Make sure there's something left
|
||||
return base, 'L'
|
||||
elif normalized.endswith(pattern_norm):
|
||||
base = normalized[:-len(pattern_norm)]
|
||||
if base:
|
||||
return base, 'L'
|
||||
elif pattern_norm in normalized:
|
||||
# Handle cases like ArmLeft
|
||||
parts = normalized.split(pattern_norm)
|
||||
if len(parts) == 2:
|
||||
base = parts[0] + parts[1]
|
||||
if base:
|
||||
return base, 'L'
|
||||
|
||||
# Check for right patterns
|
||||
for pattern in right_patterns:
|
||||
pattern_norm = simplify_bonename(pattern)
|
||||
if normalized.startswith(pattern_norm):
|
||||
base = normalized[len(pattern_norm):]
|
||||
if base:
|
||||
return base, 'R'
|
||||
elif normalized.endswith(pattern_norm):
|
||||
base = normalized[:-len(pattern_norm)]
|
||||
if base:
|
||||
return base, 'R'
|
||||
elif pattern_norm in normalized:
|
||||
parts = normalized.split(pattern_norm)
|
||||
if len(parts) == 2:
|
||||
base = parts[0] + parts[1]
|
||||
if base:
|
||||
return base, 'R'
|
||||
|
||||
return normalized, ''
|
||||
|
||||
def find_symmetric_bone_pairs(bones: Dict[str, Bone]) -> Dict[str, Tuple[List[str], List[str]]]:
|
||||
"""
|
||||
Automatically find symmetric bone pairs in the armature.
|
||||
Returns dict mapping base_name to (left_bones, right_bones)
|
||||
"""
|
||||
bone_groups = {}
|
||||
|
||||
for bone_name in bones.keys():
|
||||
base, side = extract_bone_side_info(bone_name)
|
||||
|
||||
if side:
|
||||
if base not in bone_groups:
|
||||
bone_groups[base] = {'L': [], 'R': []}
|
||||
bone_groups[base][side].append(bone_name)
|
||||
|
||||
symmetric_pairs = {}
|
||||
for base, sides in bone_groups.items():
|
||||
if sides['L'] and sides['R']:
|
||||
symmetric_pairs[base] = (sides['L'], sides['R'])
|
||||
|
||||
return symmetric_pairs
|
||||
|
||||
def validate_armature_symmetry(armature: Object) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Comprehensive symmetry validation that provides detailed feedback
|
||||
"""
|
||||
if not armature or armature.type != 'ARMATURE':
|
||||
return False, ["Invalid armature"]
|
||||
|
||||
bones = {bone.name: bone for bone in armature.data.bones}
|
||||
symmetric_pairs = find_symmetric_bone_pairs(bones)
|
||||
|
||||
messages = []
|
||||
is_symmetric = True
|
||||
|
||||
if symmetric_pairs:
|
||||
messages.append("Found symmetric bone pairs:")
|
||||
for base, (left_bones, right_bones) in symmetric_pairs.items():
|
||||
left_count = len(left_bones)
|
||||
right_count = len(right_bones)
|
||||
|
||||
if left_count == right_count:
|
||||
messages.append(f" ✓ {base}: {left_count} bones on each side")
|
||||
for l_bone, r_bone in zip(sorted(left_bones), sorted(right_bones)):
|
||||
messages.append(f" {l_bone} ↔ {r_bone}")
|
||||
else:
|
||||
is_symmetric = False
|
||||
messages.append(f" ✗ {base}: {left_count} left, {right_count} right bones")
|
||||
messages.append(f" Left: {', '.join(sorted(left_bones))}")
|
||||
messages.append(f" Right: {', '.join(sorted(right_bones))}")
|
||||
else:
|
||||
messages.append("No symmetric bone pairs detected")
|
||||
is_symmetric = False
|
||||
|
||||
return is_symmetric, messages
|
||||
|
||||
def validate_symmetry(bones: Dict[str, Bone], base: str, left: str, right: str) -> bool:
|
||||
"""Validate if matching left and right bones exist for a given base bone name"""
|
||||
# First try the new intelligent detection
|
||||
symmetric_pairs = find_symmetric_bone_pairs(bones)
|
||||
|
||||
# Look for bones that match the requested base type
|
||||
matching_left_bones = []
|
||||
matching_right_bones = []
|
||||
|
||||
# Check each detected symmetric pair
|
||||
for pair_base, (left_bones, right_bones) in symmetric_pairs.items():
|
||||
if base.lower() in pair_base.lower() or pair_base.lower() in base.lower():
|
||||
matching_left_bones.extend(left_bones)
|
||||
matching_right_bones.extend(right_bones)
|
||||
|
||||
if matching_left_bones or matching_right_bones:
|
||||
left_bases = {}
|
||||
right_bases = {}
|
||||
|
||||
for bone_name in matching_left_bones:
|
||||
bone_base, side = extract_bone_side_info(bone_name)
|
||||
if bone_base not in left_bases:
|
||||
left_bases[bone_base] = []
|
||||
left_bases[bone_base].append(bone_name)
|
||||
|
||||
for bone_name in matching_right_bones:
|
||||
bone_base, side = extract_bone_side_info(bone_name)
|
||||
if bone_base not in right_bases:
|
||||
right_bases[bone_base] = []
|
||||
right_bases[bone_base].append(bone_name)
|
||||
|
||||
all_bases = set(left_bases.keys()) | set(right_bases.keys())
|
||||
for bone_base in all_bases:
|
||||
left_count = len(left_bases.get(bone_base, []))
|
||||
right_count = len(right_bases.get(bone_base, []))
|
||||
if left_count != right_count:
|
||||
return False
|
||||
|
||||
return len(all_bases) > 0
|
||||
|
||||
# Fallback to original dictionary-based method
|
||||
left_bone_names = set()
|
||||
right_bone_names = set()
|
||||
|
||||
# Normalize bone names in the bones dict for comparison
|
||||
normalized_bones = {simplify_bonename(name): name for name in bones.keys()}
|
||||
|
||||
# Add standard bones
|
||||
for key, value in standard_bones.items():
|
||||
if base in key.lower():
|
||||
if '_l' in key.lower():
|
||||
left_bone_names.add(simplify_bonename(value))
|
||||
elif '_r' in key.lower():
|
||||
right_bone_names.add(simplify_bonename(value))
|
||||
|
||||
# Add acceptable bones
|
||||
for key, names in acceptable_bone_names.items():
|
||||
if base in key.lower():
|
||||
if '_l' in key.lower():
|
||||
left_bone_names.update(simplify_bonename(name) for name in names)
|
||||
elif '_r' in key.lower():
|
||||
right_bone_names.update(simplify_bonename(name) for name in names)
|
||||
|
||||
# Check if at least one pair exists and matches
|
||||
left_exists = any(name in normalized_bones for name in left_bone_names)
|
||||
right_exists = any(name in normalized_bones for name in right_bone_names)
|
||||
|
||||
return left_exists == right_exists
|
||||
|
||||
def validate_finger_chain(bones: Dict[str, Bone], chain: Tuple[str, ...]) -> bool:
|
||||
"""Validate if a finger bone chain has correct hierarchy"""
|
||||
for i in range(len(chain) - 1):
|
||||
if not validate_bone_hierarchy(bones, chain[i], chain[i + 1]):
|
||||
return False
|
||||
return True
|
||||
|
||||
def check_acceptable_standards(bones: Dict[str, Bone]) -> bool:
|
||||
"""Check if armature matches acceptable non-standard hierarchy"""
|
||||
logger.debug("Checking for acceptable standards")
|
||||
|
||||
# Create normalized lookup for existing bones
|
||||
normalized_bones = {simplify_bonename(name): name for name in bones.keys()}
|
||||
|
||||
# Check if bones exist in acceptable list
|
||||
for bone_category, acceptable_names in acceptable_bone_names.items():
|
||||
found = False
|
||||
for name in acceptable_names:
|
||||
normalized_name = simplify_bonename(name)
|
||||
if normalized_name in normalized_bones:
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
logger.debug(f"Missing acceptable bone for category: {bone_category}")
|
||||
return False
|
||||
|
||||
# Validate acceptable hierarchy using normalized names
|
||||
for parent, child in acceptable_bone_hierarchy:
|
||||
parent_normalized = simplify_bonename(parent)
|
||||
child_normalized = simplify_bonename(child)
|
||||
|
||||
# Find actual bone names from normalized names
|
||||
actual_parent = normalized_bones.get(parent_normalized)
|
||||
actual_child = normalized_bones.get(child_normalized)
|
||||
|
||||
if actual_parent and actual_child:
|
||||
if not validate_bone_hierarchy(bones, actual_parent, actual_child):
|
||||
logger.debug(f"Invalid acceptable hierarchy: {actual_parent} -> {actual_child}")
|
||||
return False
|
||||
|
||||
logger.debug("Armature meets acceptable standards")
|
||||
return True
|
||||
|
||||
def validate_tpose(armature):
|
||||
"""Validate if armature is in a proper T-pose"""
|
||||
logger.debug(f"Validating T-pose for armature: {armature.name if armature else 'None'}")
|
||||
if not armature or armature.type != 'ARMATURE':
|
||||
logger.warning("No valid armature for T-pose validation")
|
||||
return False, [t("Validation.tpose.no_armature")]
|
||||
|
||||
issues = []
|
||||
|
||||
if armature.mode == 'POSE':
|
||||
bones_collection = armature.pose.bones
|
||||
get_direction = lambda bone: bone.matrix.to_3x3().col[1].normalized()
|
||||
else:
|
||||
bones_collection = armature.data.bones
|
||||
get_direction = lambda bone: bone.y_axis
|
||||
|
||||
# Get left and right upper arm bones using standard bone names
|
||||
left_arm = None
|
||||
right_arm = None
|
||||
|
||||
left_arm_candidates = [standard_bones['left_arm']] # UpperArm.L
|
||||
if 'arm_l' in acceptable_bone_names:
|
||||
left_arm_candidates.extend(acceptable_bone_names['arm_l'])
|
||||
|
||||
right_arm_candidates = [standard_bones['right_arm']] # UpperArm.R
|
||||
if 'arm_r' in acceptable_bone_names:
|
||||
right_arm_candidates.extend(acceptable_bone_names['arm_r'])
|
||||
|
||||
for name in left_arm_candidates:
|
||||
if name in armature.data.bones:
|
||||
left_arm = armature.data.bones[name]
|
||||
logger.debug(f"Found left arm bone: {name}")
|
||||
break
|
||||
|
||||
for name in right_arm_candidates:
|
||||
if name in armature.data.bones:
|
||||
right_arm = armature.data.bones[name]
|
||||
logger.debug(f"Found right arm bone: {name}")
|
||||
break
|
||||
|
||||
# Check arm bones are horizontal
|
||||
if left_arm:
|
||||
direction = left_arm.y_axis
|
||||
if abs(direction.x) < 0.7: # Not pointing mostly along X axis
|
||||
logger.warning("Left arm is not horizontal")
|
||||
issues.append(t("Validation.tpose.left_arm_not_horizontal"))
|
||||
|
||||
if right_arm:
|
||||
direction = right_arm.y_axis
|
||||
if abs(direction.x) < 0.7: # Not pointing mostly along X axis
|
||||
logger.warning("Right arm is not horizontal")
|
||||
issues.append(t("Validation.tpose.right_arm_not_horizontal"))
|
||||
|
||||
spine = None
|
||||
spine_candidates = [standard_bones['spine']] # Spine
|
||||
if 'spine' in acceptable_bone_names:
|
||||
spine_candidates.extend(acceptable_bone_names['spine'])
|
||||
|
||||
for name in spine_candidates:
|
||||
if name in armature.data.bones:
|
||||
spine = armature.data.bones[name]
|
||||
logger.debug(f"Found spine bone: {name}")
|
||||
break
|
||||
|
||||
if spine:
|
||||
direction = spine.y_axis
|
||||
if abs(direction.z) < 0.7: # Not pointing mostly along Z axis
|
||||
logger.warning("Spine is not vertical")
|
||||
issues.append(t("Validation.tpose.spine_not_vertical"))
|
||||
|
||||
if issues:
|
||||
logger.warning(f"T-pose validation failed with {len(issues)} issues")
|
||||
return False, issues
|
||||
|
||||
logger.info("T-pose validation successful")
|
||||
return True, []
|
||||
|
||||
def detect_scale_issues(bones):
|
||||
"""Detect bones with abnormal scale (too small or too large)"""
|
||||
logger.debug("Detecting scale issues")
|
||||
scale_issues = []
|
||||
|
||||
# Calculate median bone length for reference (more robust than average)
|
||||
lengths = [bone.length for bone in bones.values()]
|
||||
lengths.sort()
|
||||
|
||||
if not lengths:
|
||||
logger.debug("No bones with length found")
|
||||
return []
|
||||
|
||||
median_length = lengths[len(lengths) // 2]
|
||||
|
||||
# Filter out zero-length bones for standard deviation calculation
|
||||
non_zero_lengths = [l for l in lengths if l > 0.0001]
|
||||
|
||||
if not non_zero_lengths:
|
||||
logger.debug("No non-zero length bones found")
|
||||
return []
|
||||
|
||||
mean = sum(non_zero_lengths) / len(non_zero_lengths)
|
||||
variance = sum((l - mean) ** 2 for l in non_zero_lengths) / len(non_zero_lengths)
|
||||
std_dev = math.sqrt(variance)
|
||||
|
||||
small_threshold = max(median_length * 0.05, mean - 3 * std_dev)
|
||||
large_threshold = min(median_length * 15, mean + 5 * std_dev)
|
||||
|
||||
logger.debug(f"Scale thresholds - small: {small_threshold}, large: {large_threshold}")
|
||||
|
||||
# Get finger bones from standard and acceptable bone dictionaries
|
||||
finger_bone_names = set()
|
||||
|
||||
for key in standard_bones:
|
||||
if any(finger in key.lower() for finger in ['thumb', 'index', 'middle', 'ring', 'pinky', 'finger']):
|
||||
finger_bone_names.add(standard_bones[key])
|
||||
|
||||
for key, names in acceptable_bone_names.items():
|
||||
if any(finger in key.lower() for finger in ['thumb', 'index', 'middle', 'ring', 'pinky', 'finger']):
|
||||
finger_bone_names.update(names)
|
||||
|
||||
for name, bone in bones.items():
|
||||
is_finger = (name in finger_bone_names or
|
||||
any(finger in name.lower() for finger in ['thumb', 'index', 'middle', 'ring', 'pinky', 'finger']))
|
||||
|
||||
if bone.length < small_threshold and not is_finger:
|
||||
logger.debug(f"Bone {name} is too small: {bone.length}")
|
||||
scale_issues.append(f"- {name}: {t('Validation.scale_issue.too_small')} ({bone.length:.4f})")
|
||||
elif bone.length > large_threshold:
|
||||
logger.debug(f"Bone {name} is too large: {bone.length}")
|
||||
scale_issues.append(f"- {name}: {t('Validation.scale_issue.too_large')} ({bone.length:.4f})")
|
||||
|
||||
logger.debug(f"Found {len(scale_issues)} scale issues")
|
||||
return scale_issues
|
||||
|
||||
def clear_bone_highlighting(armature: Object) -> None:
|
||||
"""Clear bone highlighting by removing bone collections and resetting colors"""
|
||||
logger.debug(f"Clearing bone highlighting for armature: {armature.name if armature else 'None'}")
|
||||
if not armature or armature.type != 'ARMATURE':
|
||||
logger.warning("No valid armature for clearing bone highlighting")
|
||||
return
|
||||
|
||||
current_mode = bpy.context.mode
|
||||
|
||||
collection_name = "Problem Bones"
|
||||
if collection_name in armature.data.collections:
|
||||
problem_collection = armature.data.collections[collection_name]
|
||||
armature.data.collections.remove(problem_collection)
|
||||
logger.debug("Removed problem bones collection")
|
||||
|
||||
for bone in armature.data.bones:
|
||||
bone.color.palette = 'DEFAULT'
|
||||
|
||||
if len(armature.data.collections) == 0:
|
||||
armature.data.show_bone_colors = False
|
||||
logger.debug("Disabled bone colors display")
|
||||
|
||||
logger.info("Bone highlighting cleared")
|
||||
return
|
||||
|
||||
class AvatarToolkit_OT_HighlightProblemBones(Operator):
|
||||
"""Highlight bones that fail validation in the 3D viewport"""
|
||||
bl_idname = "avatar_toolkit.highlight_problem_bones"
|
||||
bl_label = t("Validation.highlight_problem_bones")
|
||||
bl_description = t("Validation.highlight_problem_bones_desc")
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return get_active_armature(context) is not None
|
||||
|
||||
def execute(self, context):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
logger.warning("No active armature found for highlighting problem bones")
|
||||
self.report({'ERROR'}, t("Validation.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
logger.info(f"Highlighting problem bones for armature: {armature.name}")
|
||||
|
||||
current_mode = context.mode
|
||||
|
||||
if current_mode != 'OBJECT':
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
context.view_layer.objects.active = armature
|
||||
|
||||
# First remove all bone collections
|
||||
collection_name = "Problem Bones"
|
||||
if collection_name in armature.data.collections:
|
||||
problem_collection = armature.data.collections[collection_name]
|
||||
armature.data.collections.remove(problem_collection)
|
||||
logger.debug("Removed existing problem bones collection")
|
||||
|
||||
is_valid, messages, _ = validate_armature(armature)
|
||||
|
||||
if is_valid:
|
||||
logger.info("No validation issues found")
|
||||
self.report({'INFO'}, t("Validation.no_issues"))
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
return {'FINISHED'}
|
||||
|
||||
problem_collection = armature.data.collections.new(name="Problem Bones")
|
||||
logger.debug("Created new problem bones collection")
|
||||
armature.data.show_bone_colors = True
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
# Extract bone names from validation messages
|
||||
problem_bones = self._extract_problem_bones(messages)
|
||||
|
||||
# Assign bones to collection and set colors
|
||||
highlighted_count = 0
|
||||
for category, bone_names in problem_bones.items():
|
||||
for bone_name in bone_names:
|
||||
if bone_name in armature.data.edit_bones:
|
||||
bone = armature.data.edit_bones[bone_name]
|
||||
problem_collection.assign(bone)
|
||||
|
||||
if 'hierarchy' in category.lower():
|
||||
bone.color.palette = 'THEME09' # Orange
|
||||
elif 'scale' in category.lower():
|
||||
bone.color.palette = 'THEME03' # Yellow
|
||||
else:
|
||||
bone.color.palette = 'THEME01' # Red
|
||||
|
||||
highlighted_count += 1
|
||||
|
||||
logger.info(f"Highlighted {highlighted_count} problem bones")
|
||||
self.report({'INFO'}, t("Validation.highlighting_complete"))
|
||||
return {'FINISHED'}
|
||||
|
||||
def _extract_problem_bones(self, messages):
|
||||
problem_bones = {
|
||||
"Hierarchy Issues": [],
|
||||
"Scale Issues": [],
|
||||
"Missing Bones": []
|
||||
}
|
||||
|
||||
# Extract bone names from validation messages
|
||||
for message in messages:
|
||||
if isinstance(message, str):
|
||||
# Parse message to extract bone names
|
||||
for line in message.split('\n'):
|
||||
if '- ' in line:
|
||||
bone_name = line.split('- ')[1].strip()
|
||||
if ':' in bone_name: # Handle "bone_name: message" format
|
||||
bone_name = bone_name.split(':')[0].strip()
|
||||
|
||||
if 'hierarchy' in message.lower():
|
||||
problem_bones["Hierarchy Issues"].append(bone_name)
|
||||
elif 'scale' in message.lower():
|
||||
problem_bones["Scale Issues"].append(bone_name)
|
||||
else:
|
||||
problem_bones["Missing Bones"].append(bone_name)
|
||||
|
||||
logger.debug(f"Extracted problem bones: {problem_bones}")
|
||||
return problem_bones
|
||||
|
||||
class AvatarToolkit_OT_ValidateTPose(Operator):
|
||||
"""Validate if armature is in a proper T-pose"""
|
||||
bl_idname = "avatar_toolkit.validate_tpose"
|
||||
bl_label = t("Validation.tpose.label")
|
||||
bl_description = t("Validation.tpose.desc")
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return get_active_armature(context) is not None
|
||||
|
||||
def execute(self, context):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
logger.warning("No active armature found for T-pose validation")
|
||||
self.report({'ERROR'}, t("Validation.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
logger.info(f"Validating T-pose for armature: {armature.name}")
|
||||
is_valid, messages = validate_tpose(armature)
|
||||
props = context.scene.avatar_toolkit
|
||||
props.tpose_validation_result = is_valid
|
||||
props.tpose_validation_messages.clear()
|
||||
|
||||
for msg in messages:
|
||||
item = props.tpose_validation_messages.add()
|
||||
item.name = msg
|
||||
|
||||
props.show_tpose_validation = True
|
||||
|
||||
if is_valid:
|
||||
logger.info("T-pose validation successful")
|
||||
self.report({'INFO'}, t("Validation.tpose.valid"))
|
||||
else:
|
||||
for msg in messages:
|
||||
self.report({'WARNING'}, msg)
|
||||
logger.warning("T-pose validation failed")
|
||||
self.report({'WARNING'}, t("Validation.tpose.warning"))
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
class AvatarToolkit_OT_ClearBoneHighlighting(Operator):
|
||||
"""Clear bone highlighting and reset bone colors"""
|
||||
bl_idname = "avatar_toolkit.clear_bone_highlighting"
|
||||
bl_label = t("Validation.clear_bone_highlighting")
|
||||
bl_description = t("Validation.clear_bone_highlighting_desc")
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return get_active_armature(context) is not None
|
||||
|
||||
def execute(self, context):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
logger.warning("No active armature found for clearing bone highlighting")
|
||||
self.report({'ERROR'}, t("Validation.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
logger.info(f"Clearing bone highlighting for armature: {armature.name}")
|
||||
current_mode = context.mode
|
||||
|
||||
# Switch to object mode as collection editing is not possible in edit mode
|
||||
if current_mode != 'OBJECT':
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
context.view_layer.objects.active = armature
|
||||
|
||||
collection_name = "Problem Bones"
|
||||
if collection_name in armature.data.collections:
|
||||
# Remove the collection
|
||||
problem_collection = armature.data.collections[collection_name]
|
||||
armature.data.collections.remove(problem_collection)
|
||||
logger.debug("Removed problem bones collection")
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
# Reset all bone colors
|
||||
for bone in armature.data.edit_bones:
|
||||
bone.color.palette = 'DEFAULT'
|
||||
|
||||
# Turn off bone colors display if no other collections are using it
|
||||
if len(armature.data.collections) == 0:
|
||||
armature.data.show_bone_colors = False
|
||||
logger.debug("Disabled bone colors display")
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
logger.info("Bone highlighting cleared")
|
||||
self.report({'INFO'}, t("Validation.highlighting_cleared"))
|
||||
return {'FINISHED'}
|
||||
|
||||
class AvatarToolkit_OT_ValidateArmatureManual(Operator):
|
||||
"""Manually validate armature and show results"""
|
||||
bl_idname = "avatar_toolkit.validate_armature_manual"
|
||||
bl_label = t("Validation.validate_now", "Validate Armature Now")
|
||||
bl_description = t("Validation.validate_now_desc", "Run armature validation and display detailed results")
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return get_active_armature(context) is not None
|
||||
|
||||
def execute(self, context):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
logger.warning("No active armature found for validation")
|
||||
self.report({'ERROR'}, t("Validation.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
logger.info(f"Running manual validation for armature: {armature.name}")
|
||||
|
||||
# Clear the validation cache to force a refresh
|
||||
from ..ui.quick_access_panel import clear_armature_caches
|
||||
clear_armature_caches()
|
||||
|
||||
# Toggle the show_validation_results flag to display results
|
||||
props = context.scene.avatar_toolkit
|
||||
props.show_validation_results = True
|
||||
|
||||
# Run validation
|
||||
is_valid, messages, is_acceptable = validate_armature(armature, detailed_messages=False)
|
||||
|
||||
if is_valid:
|
||||
if is_acceptable:
|
||||
self.report({'INFO'}, t("Armature.validation.acceptable_standard.success"))
|
||||
else:
|
||||
self.report({'INFO'}, t("QuickAccess.valid_armature"))
|
||||
else:
|
||||
self.report({'WARNING'}, t("Validation.status.failed"))
|
||||
|
||||
logger.info("Manual validation complete")
|
||||
return {'FINISHED'}
|
||||
+61
-63
@@ -1,6 +1,5 @@
|
||||
import os
|
||||
import bpy
|
||||
import sys
|
||||
import typing
|
||||
import inspect
|
||||
import pkgutil
|
||||
@@ -24,22 +23,32 @@ def init() -> None:
|
||||
global modules
|
||||
global ordered_classes
|
||||
|
||||
# Configure logging first
|
||||
from .logging_setup import configure_logging
|
||||
configure_logging(False)
|
||||
|
||||
from .addon_preferences import get_preference
|
||||
configure_logging(get_preference("enable_logging", False))
|
||||
log_level = get_preference("log_level", "WARNING")
|
||||
configure_logging(get_preference("enable_logging", False), log_level)
|
||||
|
||||
print("Auto-load init starting")
|
||||
modules = get_all_submodules(Path(__file__).parent.parent)
|
||||
|
||||
package_name = __package__.rsplit('.', 1)[0]
|
||||
directory = Path(__file__).parent.parent
|
||||
modules = get_all_submodules(directory, package_name)
|
||||
ordered_classes = get_ordered_classes_to_register(modules)
|
||||
print(f"Found modules: {modules}")
|
||||
print(f"Found classes: {ordered_classes}")
|
||||
|
||||
def register() -> None:
|
||||
"""Register all discovered classes and modules"""
|
||||
global modules, ordered_classes
|
||||
|
||||
print("Registering classes")
|
||||
|
||||
if not ordered_classes:
|
||||
print("Warning: No classes to register")
|
||||
ordered_classes = []
|
||||
|
||||
for cls in ordered_classes:
|
||||
print(f"Registering: {cls}")
|
||||
try:
|
||||
@@ -47,6 +56,10 @@ def register() -> None:
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if not modules:
|
||||
print("Warning: No modules to register")
|
||||
modules = []
|
||||
|
||||
for module in modules:
|
||||
if module.__name__ == __name__:
|
||||
continue
|
||||
@@ -67,44 +80,29 @@ def unregister() -> None:
|
||||
if hasattr(module, "unregister"):
|
||||
module.unregister()
|
||||
|
||||
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: Path) -> List[Any]:
|
||||
def get_all_submodules(directory: Path, package_name: str) -> 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):
|
||||
if "__pycache__" in root:
|
||||
continue
|
||||
path = Path(root)
|
||||
if path == directory:
|
||||
package_name = f"bl_ext.user_default.{addon_id}"
|
||||
else:
|
||||
relative_path = path.relative_to(directory).as_posix().replace('/', '.')
|
||||
package_name = f"bl_ext.user_default.{addon_id}.{relative_path}"
|
||||
for name in sorted(iter_module_names(path)):
|
||||
modules.append(importlib.import_module(f".{name}", package_name))
|
||||
return modules
|
||||
return list(iter_submodules(directory, package_name))
|
||||
|
||||
def iter_submodules(path: Path, package_name: str) -> Generator[Any, None, None]:
|
||||
def iter_submodules(directory: Path, package_name: str) -> Generator[Any, None, None]:
|
||||
"""Iterate through submodules in a package"""
|
||||
for name in sorted(iter_module_names(path)):
|
||||
for name in sorted(iter_submodule_names(directory)):
|
||||
try:
|
||||
yield importlib.import_module("." + name, package_name)
|
||||
print(f"Successfully imported {name} from {package_name}")
|
||||
except ImportError as e:
|
||||
print(f"Error importing {name} from {package_name}: {e}")
|
||||
|
||||
def iter_module_names(path: Path) -> Generator[str, None, None]:
|
||||
def iter_submodule_names(path: Path, root: str = "") -> 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}")
|
||||
for _, module_name, is_pkg in modules_list:
|
||||
if not is_pkg:
|
||||
print(f"Found module: {module_name}")
|
||||
yield module_name
|
||||
for _, module_name, is_package in pkgutil.iter_modules([str(path)]):
|
||||
if is_package:
|
||||
sub_path = path / module_name
|
||||
sub_root = root + module_name + "."
|
||||
yield from iter_submodule_names(sub_path, sub_root)
|
||||
else:
|
||||
yield root + module_name
|
||||
|
||||
def get_ordered_classes_to_register(modules: List[Any]) -> List[Type]:
|
||||
"""Get a topologically sorted list of classes to register"""
|
||||
@@ -112,28 +110,37 @@ def get_ordered_classes_to_register(modules: List[Any]) -> List[Type]:
|
||||
|
||||
def get_register_deps_dict(modules: List[Any]) -> Dict[Type, Set[Type]]:
|
||||
"""Get dependencies dictionary for class registration"""
|
||||
my_classes = set(iter_classes_to_register(modules))
|
||||
my_classes_by_idname = {cls.bl_idname: cls for cls in my_classes if hasattr(cls, "bl_idname")}
|
||||
|
||||
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))
|
||||
for cls in my_classes:
|
||||
deps_dict[cls] = set()
|
||||
deps_dict[cls].update(iter_deps_from_annotations(cls, my_classes))
|
||||
deps_dict[cls].update(iter_deps_from_parent_id(cls, my_classes_by_idname))
|
||||
|
||||
return deps_dict
|
||||
|
||||
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: Type) -> Generator[Type, None, None]:
|
||||
"""Iterate through all registration dependencies of a class"""
|
||||
def iter_deps_from_annotations(cls: Type, my_classes: Set[Type]) -> Generator[Type, None, None]:
|
||||
"""Iterate through dependencies from class annotations"""
|
||||
for value in typing.get_type_hints(cls, {}, {}).values():
|
||||
dependency = get_dependency_from_annotation(value)
|
||||
if dependency is not None:
|
||||
if dependency is not None and dependency in my_classes:
|
||||
yield dependency
|
||||
|
||||
def iter_deps_from_parent_id(cls: Type, my_classes_by_idname: Dict[str, Type]) -> Generator[Type, None, None]:
|
||||
"""Iterate through dependencies from panel parent IDs"""
|
||||
if bpy.types.Panel in cls.__bases__:
|
||||
parent_idname = getattr(cls, "bl_parent_id", None)
|
||||
if parent_idname is not None:
|
||||
parent_cls = my_classes_by_idname.get(parent_idname)
|
||||
if parent_cls is not None:
|
||||
yield parent_cls
|
||||
|
||||
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"]
|
||||
if isinstance(value, bpy.props._PropertyDeferred):
|
||||
return value.keywords.get("type")
|
||||
return None
|
||||
|
||||
def iter_classes_to_register(modules: List[Any]) -> Generator[Type, None, None]:
|
||||
@@ -164,7 +171,8 @@ def get_register_base_types() -> Set[Type]:
|
||||
"Panel", "Operator", "PropertyGroup",
|
||||
"AddonPreferences", "Header", "Menu",
|
||||
"Node", "NodeSocket", "NodeTree",
|
||||
"UIList", "RenderEngine"
|
||||
"UIList", "RenderEngine",
|
||||
"Gizmo", "GizmoGroup",
|
||||
])
|
||||
|
||||
def toposort(deps_dict: Dict[Type, Set[Type]]) -> List[Type]:
|
||||
@@ -172,25 +180,15 @@ def toposort(deps_dict: Dict[Type, Set[Type]]) -> List[Type]:
|
||||
sorted_list = []
|
||||
sorted_values = set()
|
||||
|
||||
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')]
|
||||
|
||||
for value, deps in base_panels:
|
||||
if len(deps) == 0:
|
||||
sorted_list.append(value)
|
||||
sorted_values.add(value)
|
||||
|
||||
while len(deps_dict) > len(sorted_values):
|
||||
while len(deps_dict) > 0:
|
||||
unsorted = []
|
||||
for value, deps in deps_dict.items():
|
||||
if value not in sorted_values:
|
||||
if len(deps - sorted_values) == 0:
|
||||
if len(deps) == 0:
|
||||
sorted_list.append(value)
|
||||
sorted_values.add(value)
|
||||
else:
|
||||
unsorted.append(value)
|
||||
|
||||
deps_dict = {value: deps_dict[value] - sorted_values for value in unsorted}
|
||||
|
||||
return sorted_list
|
||||
|
||||
+268
-130
@@ -1,3 +1,4 @@
|
||||
import traceback
|
||||
import bpy
|
||||
import numpy as np
|
||||
import threading
|
||||
@@ -10,7 +11,7 @@ import numpy.typing as npt
|
||||
|
||||
from typing import Optional, Tuple, List, Set, Dict, Any, Generator, Callable, Union, Type
|
||||
from mathutils import Vector, Matrix
|
||||
from bpy.types import (Context, Object, Modifier, EditBone, Operator,
|
||||
from bpy.types import (Context, Object, Modifier, EditBone, Operator, Material,
|
||||
VertexGroup, ShapeKey, Bone, Mesh, Armature, PropertyGroup)
|
||||
from functools import lru_cache
|
||||
from bpy.props import PointerProperty, IntProperty, StringProperty
|
||||
@@ -18,6 +19,48 @@ from bpy.utils import register_class
|
||||
from ..core.logging_setup import logger
|
||||
from ..core.translations import t
|
||||
from ..core.dictionaries import bone_names
|
||||
from .dictionaries import reverse_bone_lookup, bone_names, simplify_bonename
|
||||
|
||||
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]
|
||||
|
||||
class ProgressTracker:
|
||||
"""Universal progress tracking for Avatar Toolkit operations"""
|
||||
@@ -49,106 +92,132 @@ class ProgressTracker:
|
||||
|
||||
def get_active_armature(context: Context) -> Optional[Object]:
|
||||
"""Get the currently selected armature from Avatar Toolkit properties"""
|
||||
armature_name = str(context.scene.avatar_toolkit.active_armature)
|
||||
if armature_name and armature_name != 'NONE':
|
||||
return bpy.data.objects.get(armature_name)
|
||||
try:
|
||||
# Get the safe identifier from the enum property
|
||||
armature_id = context.scene.avatar_toolkit.active_armature
|
||||
|
||||
if not armature_id or armature_id == 'NONE':
|
||||
return None
|
||||
|
||||
# The identifier format is "ARM_{pointer_value}"
|
||||
if armature_id.startswith('ARM_'):
|
||||
try:
|
||||
pointer_str = armature_id[4:]
|
||||
pointer_value = int(pointer_str)
|
||||
|
||||
# Find the armature with this pointer value
|
||||
for obj in context.scene.objects:
|
||||
if obj.type == 'ARMATURE' and obj.as_pointer() == pointer_value:
|
||||
return obj
|
||||
|
||||
logger.warning(f"Armature with pointer {pointer_value} not found")
|
||||
except (ValueError, AttributeError) as e:
|
||||
logger.error(f"Failed to parse armature identifier: {e}")
|
||||
|
||||
# Fallback for old-style identifiers (direct name)
|
||||
# This handles backward compatibility
|
||||
return bpy.data.objects.get(armature_id)
|
||||
|
||||
except (UnicodeDecodeError, UnicodeEncodeError, AttributeError) as e:
|
||||
# Handle encoding issues as a last resort
|
||||
logger.warning(f"Encoding issue with active_armature property: {e}")
|
||||
|
||||
# Final fallback: return active object if it's an armature, or first armature found
|
||||
if context.view_layer.objects.active and context.view_layer.objects.active.type == 'ARMATURE':
|
||||
return context.view_layer.objects.active
|
||||
|
||||
for obj in context.scene.objects:
|
||||
if obj.type == 'ARMATURE':
|
||||
logger.info(f"Falling back to first armature found: {obj.name}")
|
||||
return obj
|
||||
|
||||
return None
|
||||
|
||||
def set_active_armature(context: Context, armature: Object) -> None:
|
||||
"""Set the active armature for Avatar Toolkit operations"""
|
||||
context.scene.avatar_toolkit.active_armature = armature
|
||||
"""Set the active armature for Avatar Toolkit operations using safe identifier"""
|
||||
if armature and armature.type == 'ARMATURE':
|
||||
# Use the same safe identifier format as get_armature_list
|
||||
safe_id = f"ARM_{armature.as_pointer()}"
|
||||
context.scene.avatar_toolkit.active_armature = safe_id
|
||||
else:
|
||||
context.scene.avatar_toolkit.active_armature = 'NONE'
|
||||
|
||||
def get_mesh_from_identifier(mesh_id: str) -> Optional[Object]:
|
||||
"""Get mesh object from safe identifier
|
||||
|
||||
Args:
|
||||
mesh_id: Safe identifier in format "MESH_{pointer}" or direct object name
|
||||
|
||||
Returns:
|
||||
Mesh object or None if not found
|
||||
"""
|
||||
if not mesh_id or mesh_id == 'NONE':
|
||||
return None
|
||||
|
||||
# Handle new-style identifiers (MESH_{pointer})
|
||||
if mesh_id.startswith('MESH_'):
|
||||
try:
|
||||
pointer_str = mesh_id[5:] # Remove "MESH_" prefix
|
||||
target_pointer = int(pointer_str)
|
||||
|
||||
# Search for object with matching pointer
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type == 'MESH' and obj.as_pointer() == target_pointer:
|
||||
return obj
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
# Fallback for old-style identifiers (direct name)
|
||||
return bpy.data.objects.get(mesh_id)
|
||||
|
||||
def clear_enum_caches() -> None:
|
||||
"""Clear all enum property caches to force refresh of dropdown lists"""
|
||||
if hasattr(get_armature_list, '_cache_key'):
|
||||
delattr(get_armature_list, '_cache_key')
|
||||
if hasattr(get_armature_list, '_cached_items'):
|
||||
delattr(get_armature_list, '_cached_items')
|
||||
|
||||
def get_armature_list(self: Optional[Any] = None, context: Optional[Context] = None) -> List[Tuple[str, str, str]]:
|
||||
"""Get list of all armature objects in the scene"""
|
||||
"""Get list of all armature objects in the scene
|
||||
|
||||
Returns tuples of (identifier, display_name, description) where:
|
||||
- identifier: ASCII-safe unique ID (uses object's memory address)
|
||||
- display_name: The actual object name (can contain Japanese characters)
|
||||
- description: Empty string
|
||||
|
||||
Uses caching to prevent encoding issues with Blender's EnumProperty system
|
||||
"""
|
||||
if context is None:
|
||||
context = bpy.context
|
||||
armatures = [(obj.name, obj.name, "") for obj in context.scene.objects if obj.type == 'ARMATURE']
|
||||
|
||||
# Create a cache key based on armature objects in scene
|
||||
armature_objects = [obj for obj in context.scene.objects if obj.type == 'ARMATURE']
|
||||
cache_key = tuple((obj.name, obj.as_pointer()) for obj in armature_objects)
|
||||
|
||||
# Check if we have a cached result
|
||||
if hasattr(get_armature_list, '_cache_key') and get_armature_list._cache_key == cache_key:
|
||||
if hasattr(get_armature_list, '_cached_items'):
|
||||
return get_armature_list._cached_items
|
||||
|
||||
# Build the list
|
||||
armatures = []
|
||||
for obj in armature_objects:
|
||||
# Create a safe ASCII identifier using the object pointer
|
||||
safe_id = f"ARM_{obj.as_pointer()}"
|
||||
# Use the name directly - Blender should handle Unicode in display names
|
||||
display_name = obj.name
|
||||
armatures.append((safe_id, display_name, ""))
|
||||
|
||||
if not armatures:
|
||||
return [('NONE', t("Armature.validation.no_armature"), '')]
|
||||
return armatures
|
||||
result = [('NONE', t("Armature.validation.no_armature"), '')]
|
||||
else:
|
||||
result = armatures
|
||||
|
||||
def validate_armature(armature: Object) -> Tuple[bool, List[str]]:
|
||||
"""Enhanced armature validation with multiple validation modes"""
|
||||
validation_mode = bpy.context.scene.avatar_toolkit.validation_mode
|
||||
messages: List[str] = []
|
||||
# Cache the result
|
||||
get_armature_list._cache_key = cache_key
|
||||
get_armature_list._cached_items = result
|
||||
|
||||
if validation_mode == 'NONE':
|
||||
return True, []
|
||||
|
||||
if not armature or armature.type != 'ARMATURE' or not armature.data.bones:
|
||||
return False, [t("Armature.validation.basic_check_failed")]
|
||||
|
||||
found_bones: Dict[str, Bone] = {bone.name.lower(): bone for bone in armature.data.bones}
|
||||
essential_bones: Set[str] = {'hips', 'spine', 'chest', 'neck', 'head'}
|
||||
|
||||
missing_bones: List[str] = []
|
||||
for bone in essential_bones:
|
||||
if not any(alt_name in found_bones for alt_name in bone_names[bone]):
|
||||
missing_bones.append(bone)
|
||||
|
||||
if missing_bones:
|
||||
messages.append(t("Armature.validation.missing_bones", bones=", ".join(missing_bones)))
|
||||
|
||||
if validation_mode == 'STRICT':
|
||||
hierarchy: List[Tuple[str, str]] = [
|
||||
('hips', 'spine'), ('spine', 'chest'),
|
||||
('chest', 'neck'), ('neck', 'head')
|
||||
]
|
||||
for parent, child in hierarchy:
|
||||
if not validate_bone_hierarchy(found_bones, parent, child):
|
||||
messages.append(t("Armature.validation.invalid_hierarchy",
|
||||
parent=parent, child=child))
|
||||
|
||||
symmetry_pairs: List[Tuple[str, str, str]] = [('arm', 'l', 'r'), ('leg', 'l', 'r')]
|
||||
for base, left, right in symmetry_pairs:
|
||||
if not validate_symmetry(found_bones, base, left, right):
|
||||
messages.append(t("Armature.validation.asymmetric_bones", bone=base))
|
||||
|
||||
if (not validate_symmetry(found_bones, 'hand', 'l', 'r') and
|
||||
not validate_symmetry(found_bones, 'wrist', 'l', 'r')):
|
||||
messages.append(t("Armature.validation.asymmetric_hand_wrist"))
|
||||
|
||||
is_valid: bool = len(messages) == 0
|
||||
return is_valid, messages
|
||||
|
||||
def validate_bone_hierarchy(bones: Dict[str, Bone], parent_name: str, child_name: str) -> bool:
|
||||
"""Validate if there is a valid parent-child relationship between bones"""
|
||||
parent_bone: Optional[Bone] = None
|
||||
child_bone: Optional[Bone] = None
|
||||
|
||||
for alt_name in bone_names[parent_name]:
|
||||
if alt_name in bones:
|
||||
parent_bone = bones[alt_name]
|
||||
break
|
||||
|
||||
for alt_name in bone_names[child_name]:
|
||||
if alt_name in bones:
|
||||
child_bone = bones[alt_name]
|
||||
break
|
||||
|
||||
if not parent_bone or not child_bone:
|
||||
return False
|
||||
|
||||
return child_bone.parent == parent_bone
|
||||
|
||||
def validate_symmetry(bones: Dict[str, Bone], base: str, left: str, right: str) -> bool:
|
||||
"""Validate if matching left and right bones exist for a given base bone name"""
|
||||
left_patterns: List[str] = [
|
||||
f"{base}.{left}",
|
||||
f"{base}_{left}",
|
||||
f"{left}_{base}"
|
||||
]
|
||||
|
||||
right_patterns: List[str] = [
|
||||
f"{base}.{right}",
|
||||
f"{base}_{right}",
|
||||
f"{right}_{base}"
|
||||
]
|
||||
|
||||
left_exists: bool = any(pattern in bones for pattern in left_patterns)
|
||||
right_exists: bool = any(pattern in bones for pattern in right_patterns)
|
||||
|
||||
return left_exists and right_exists
|
||||
return result
|
||||
|
||||
def auto_select_single_armature(context: Context) -> None:
|
||||
"""Automatically select armature if only one exists in scene"""
|
||||
@@ -180,6 +249,12 @@ def get_all_meshes(context: Context) -> List[Object]:
|
||||
return [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == armature]
|
||||
return []
|
||||
|
||||
def get_meshes_for_armature(armature: Object) -> List[Object]:
|
||||
"""Get all mesh objects parented to a specific armature"""
|
||||
if armature and armature.type == 'ARMATURE':
|
||||
return [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == armature]
|
||||
return []
|
||||
|
||||
def validate_mesh_for_pose(mesh_obj: Object) -> Tuple[bool, str]:
|
||||
"""Validate mesh object for pose operations"""
|
||||
if not mesh_obj.data:
|
||||
@@ -241,9 +316,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"""
|
||||
@@ -320,50 +395,67 @@ def validate_meshes(meshes: List[Object]) -> Tuple[bool, str]:
|
||||
return False, t("Optimization.non_mesh_objects")
|
||||
return True, ""
|
||||
|
||||
def fast_uv_fix(obj: Object) -> None:
|
||||
"""Fast UV coordinate fixing for joined meshes"""
|
||||
if not obj or not obj.data or not obj.data.uv_layers:
|
||||
return
|
||||
|
||||
current_mode = bpy.context.mode
|
||||
|
||||
if current_mode != 'EDIT_MESH':
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
|
||||
# Process all UV layers at once
|
||||
bpy.ops.uv.select_all(action='SELECT')
|
||||
bpy.ops.uv.pack_islands(margin=0.001)
|
||||
|
||||
if current_mode != 'EDIT_MESH':
|
||||
bpy.ops.object.mode_set(mode=current_mode)
|
||||
|
||||
def join_mesh_objects(context: Context, meshes: List[Object], progress: Optional[ProgressTracker] = None) -> Optional[Object]:
|
||||
"""Combines multiple mesh objects into a single mesh with proper cleanup and UV fixing"""
|
||||
"""Combines multiple mesh objects into a single mesh with optimized performance"""
|
||||
try:
|
||||
# Store UV maps before joining
|
||||
uv_maps_data = {}
|
||||
for mesh in meshes:
|
||||
uv_maps_data[mesh.name] = {uv.name: uv.data.copy() for uv in mesh.data.uv_layers}
|
||||
if not meshes:
|
||||
return None
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
for mesh in meshes:
|
||||
mesh.select_set(True)
|
||||
# Create a list of valid meshes
|
||||
valid_meshes = [mesh for mesh in meshes if mesh.name in bpy.data.objects]
|
||||
if not valid_meshes:
|
||||
return None
|
||||
|
||||
if context.selected_objects:
|
||||
context.view_layer.objects.active = context.selected_objects[0]
|
||||
for mesh in valid_meshes:
|
||||
mesh.select_set(True)
|
||||
mesh.hide_set(False)
|
||||
|
||||
context.view_layer.objects.active = valid_meshes[0]
|
||||
|
||||
if progress:
|
||||
progress.step(t("Optimization.joining_meshes"))
|
||||
|
||||
bpy.ops.object.join()
|
||||
joined_mesh = context.active_object
|
||||
|
||||
if progress:
|
||||
progress.step(t("Optimization.applying_transforms"))
|
||||
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
|
||||
if progress:
|
||||
progress.step(t("Optimization.fixing_uvs"))
|
||||
fix_uv_coordinates(context)
|
||||
|
||||
# Restore UV maps after joining
|
||||
joined_mesh = context.active_object
|
||||
for uv_name, uv_data in uv_maps_data.items():
|
||||
for map_name, map_data in uv_data.items():
|
||||
if map_name not in joined_mesh.data.uv_layers:
|
||||
joined_mesh.data.uv_layers.new(name=map_name)
|
||||
joined_mesh.data.uv_layers[map_name].data.foreach_set("uv", map_data)
|
||||
fast_uv_fix(joined_mesh)
|
||||
|
||||
return context.active_object
|
||||
return joined_mesh
|
||||
|
||||
except Exception:
|
||||
logger.error(f"Failed to join meshes: {traceback.format_exc()}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to join meshes: {str(e)}")
|
||||
return None
|
||||
|
||||
def fix_uv_coordinates(context: Context) -> None:
|
||||
"""Normalizes and fixes UV coordinates for the active mesh object"""
|
||||
@@ -390,8 +482,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')
|
||||
@@ -408,9 +500,17 @@ def clear_unused_data_blocks() -> int:
|
||||
if isinstance(getattr(bpy.data, attr), bpy.types.bpy_prop_collection))
|
||||
return initial_count - final_count
|
||||
|
||||
def simplify_bonename(name: str) -> str:
|
||||
"""Simplify bone name by removing spaces, underscores, dots and converting to lowercase"""
|
||||
return name.lower().translate(dict.fromkeys(map(ord, u" _.")))
|
||||
def identify_bones(arm_data: bpy.types.Armature) -> Dict[str,str]:
|
||||
"""Identify bone names in an armature based on our reverse dictionary, so there is no confusion to what a bone is.
|
||||
Essentially makes a dictionary of keys from dictionaries.bone_names like "hips", and the corosponding value is the bone that can be mapped to that key."""
|
||||
returned: Dict[str,str] = {}
|
||||
for bone in arm_data.bones:
|
||||
|
||||
simplified_name = simplify_bonename(bone.name)
|
||||
|
||||
if simplified_name in reverse_bone_lookup:
|
||||
returned[reverse_bone_lookup[simplified_name]] = bone.name
|
||||
return returned
|
||||
|
||||
def duplicate_bone_chain(bones: List[EditBone]) -> List[EditBone]:
|
||||
"""Duplicate a chain of bones while preserving hierarchy"""
|
||||
@@ -505,13 +605,30 @@ 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:
|
||||
bone.length = 0.001
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
def remove_unused_vertex_groups(mesh: Object) -> int:
|
||||
"""Remove vertex groups with no weights"""
|
||||
removed: int = 0
|
||||
for vg in mesh.vertex_groups:
|
||||
has_weights: bool = False
|
||||
for vert in mesh.data.vertices:
|
||||
for group in vert.groups:
|
||||
if group.group == vg.index and group.weight > 0.001:
|
||||
has_weights = True
|
||||
break
|
||||
if has_weights:
|
||||
break
|
||||
if not has_weights:
|
||||
mesh.vertex_groups.remove(vg)
|
||||
removed = removed+1
|
||||
|
||||
return removed
|
||||
|
||||
def calculate_bone_orientation(mesh: Object, vertices: List[Any]) -> Tuple[Vector, float]:
|
||||
"""Calculate optimal bone orientation based on mesh geometry"""
|
||||
if not vertices:
|
||||
@@ -535,6 +652,18 @@ def add_armature_modifier(mesh: Object, armature: Object) -> None:
|
||||
modifier: Modifier = mesh.modifiers.new('Armature', 'ARMATURE')
|
||||
modifier.object = armature
|
||||
|
||||
def get_modifiers(self: Optional[Any] = None, context: Optional[Context] = None) -> List[Tuple[str, str, str]]:
|
||||
returned: List[Tuple[str, str, str]] = []
|
||||
if context.active_object == None:
|
||||
return returned
|
||||
if context.active_object.type != "MESH":
|
||||
return returned
|
||||
for mod in context.active_object.modifiers:
|
||||
returned.append((mod.name,mod.name,""))
|
||||
|
||||
return returned
|
||||
|
||||
|
||||
def get_shapekeys(context: Context,
|
||||
names: List[str],
|
||||
is_mouth: bool,
|
||||
@@ -618,6 +747,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()
|
||||
@@ -625,18 +755,26 @@ def duplicate_bone(bone: EditBone) -> EditBone:
|
||||
new_bone.use_connect = bone.use_connect
|
||||
new_bone.use_local_location = bone.use_local_location
|
||||
new_bone.use_inherit_rotation = bone.use_inherit_rotation
|
||||
new_bone.use_inherit_scale = bone.use_inherit_scale
|
||||
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
|
||||
data: ArmatureData = (armature_data.use_mirror_x, armature.pose.use_mirror_x)
|
||||
armature_data.use_mirror_x, armature.pose.use_mirror_x = (False, False)
|
||||
return data
|
||||
|
||||
def restore_breaking_settings_armature(armature: bpy.types.Object, data: ArmatureData) -> None:
|
||||
# Check if armature object is still valid (not removed)
|
||||
if not armature or armature.name not in bpy.data.objects:
|
||||
return
|
||||
armature_data: bpy.types.Armature = armature.data
|
||||
armature_data.use_mirror_x, armature.pose.use_mirror_x = data
|
||||
|
||||
|
||||
|
||||
|
||||
+777
-20
@@ -1,9 +1,14 @@
|
||||
# GPL Licence
|
||||
|
||||
|
||||
# Bone names from https://github.com/triazo/immersive_scaler/
|
||||
# Note from @989onan: Please make sure to make your names are lowercase in this array. I banged my head metaphorically till I figured that out...
|
||||
# Note from @989onan: Please make sure to make your names are lowercase in this array, or it will never find a match. I banged my head metaphorically till I figured that out...
|
||||
# Note2: Remove all "_", ".", and " " (space) from your values array or it will also not ever find a match!!!!
|
||||
# Taken from Tuxedo/Cats
|
||||
|
||||
def simplify_bonename(name: str) -> str:
|
||||
"""Simplify bone name by removing spaces, underscores, dots and converting to lowercase"""
|
||||
return name.lower().translate(dict.fromkeys(map(ord, u" _.")))
|
||||
|
||||
bone_names = {
|
||||
# Right side bones
|
||||
"right_shoulder": [
|
||||
@@ -250,30 +255,115 @@ bone_names = {
|
||||
"right_eye": [
|
||||
"eyeright", "righteye", "eyer", "reye", "右目", "ik_右目"
|
||||
],
|
||||
"breast_1_l": [
|
||||
"j_sec_l_bust1", "breast1_l", "leftbreast1", "lbreast1", "bust1_l"
|
||||
],
|
||||
"breast_2_l": [
|
||||
"j_sec_l_bust2", "breast2_l", "leftbreast2", "lbreast2", "bust2_l"
|
||||
],
|
||||
"breast_3_l": [
|
||||
"j_sec_l_bust3", "breast3_l", "leftbreast3", "lbreast3", "bust3_l"
|
||||
],
|
||||
"breast_1_r": [
|
||||
"j_sec_r_bust1", "breast1_r", "rightbreast1", "rbreast1", "bust1_r"
|
||||
],
|
||||
"breast_2_r": [
|
||||
"j_sec_r_bust2", "breast2_r", "rightbreast2", "rbreast2", "bust2_r"
|
||||
],
|
||||
"breast_3_r": [
|
||||
"j_sec_r_bust3", "breast3_r", "rightbreast3", "rbreast3", "bust3_r"
|
||||
]
|
||||
}
|
||||
|
||||
# Add VRM bone name variations
|
||||
bone_names.update({
|
||||
'hips': bone_names['hips'] + ['j_bip_c_hips', 'j_hips', 'vrm_hips'],
|
||||
'spine': bone_names['spine'] + ['j_bip_c_spine', 'j_spine', 'vrm_spine'],
|
||||
'chest': bone_names['chest'] + ['j_bip_c_chest', 'j_chest', 'vrm_chest'],
|
||||
'upper_chest': bone_names['upper_chest'] + ['j_bip_c_upper_chest', 'j_upper_chest', 'vrm_upperchest'],
|
||||
'neck': bone_names['neck'] + ['j_bip_c_neck', 'j_neck', 'vrm_neck'],
|
||||
'head': bone_names['head'] + ['j_bip_c_head', 'j_head', 'vrm_head'],
|
||||
'hips': bone_names['hips'] + ['jbipchips', 'jhips', 'vrmhips', 'leftupperleg', 'rightupperleg'],
|
||||
'spine': bone_names['spine'] + ['jbipcspine', 'jspine', 'vrmspine'],
|
||||
'chest': bone_names['chest'] + ['jbipcchest', 'jchest', 'vrmchest', 'upperchest'],
|
||||
'upper_chest': bone_names['upper_chest'] + ['jbipcupperchest', 'jupperchest', 'vrmupperchest', 'upperchest'],
|
||||
'neck': bone_names['neck'] + ['jbipcneck', 'jneck', 'vrmneck'],
|
||||
'head': bone_names['head'] + ['jbipchead', 'jhead', 'vrmhead', 'lefteye', 'righteye'],
|
||||
|
||||
# VRM specific finger naming
|
||||
'thumb_0_l': bone_names['thumb_0_l'] + ['thumb_metacarpal_l', 'j_thumb1_l'],
|
||||
'index_0_l': bone_names['index_0_l'] + ['index_metacarpal_l', 'j_index1_l'],
|
||||
'middle_0_l': bone_names['middle_0_l'] + ['middle_metacarpal_l', 'j_middle1_l'],
|
||||
'ring_0_l': bone_names['ring_0_l'] + ['ring_metacarpal_l', 'j_ring1_l'],
|
||||
'pinkie_0_l': bone_names['pinkie_0_l'] + ['little_metacarpal_l', 'j_little1_l'],
|
||||
# VRM arms - both simplified patterns
|
||||
'left_shoulder': bone_names['left_shoulder'] + ['jbipllshoulder', 'jlshoulder', 'jbiplshoulder', 'leftshoulder', 'jbipllclavicle'],
|
||||
'left_arm': bone_names['left_arm'] + ['jbiplupperarm', 'jlupperarm', 'leftupperarm'],
|
||||
'left_elbow': bone_names['left_elbow'] + ['jbipllforearm', 'jlforearm', 'jbipllowerarm', 'leftlowerarm'],
|
||||
'left_wrist': bone_names['left_wrist'] + ['jbipllhand', 'jlhand', 'jbiplhand', 'lefthand'],
|
||||
|
||||
# Mirror for right side
|
||||
'thumb_0_r': bone_names['thumb_0_r'] + ['thumb_metacarpal_r', 'j_thumb1_r'],
|
||||
'index_0_r': bone_names['index_0_r'] + ['index_metacarpal_r', 'j_index1_r'],
|
||||
'middle_0_r': bone_names['middle_0_r'] + ['middle_metacarpal_r', 'j_middle1_r'],
|
||||
'ring_0_r': bone_names['ring_0_r'] + ['ring_metacarpal_r', 'j_ring1_r'],
|
||||
'pinkie_0_r': bone_names['pinkie_0_r'] + ['little_metacarpal_r', 'j_little1_r']
|
||||
'right_shoulder': bone_names['right_shoulder'] + ['jbiprlshoulder', 'jrshoulder', 'jbiprshoulder', 'rightshoulder', 'jbiprrclavicle'],
|
||||
'right_arm': bone_names['right_arm'] + ['jbiprrupperarm', 'jrupperarm', 'jbiprupperarm', 'rightupperarm'],
|
||||
'right_elbow': bone_names['right_elbow'] + ['jbiprrforearm', 'jrforearm', 'jbiprforearm', 'jbiprlowerarm', 'rightlowerarm'],
|
||||
'right_wrist': bone_names['right_wrist'] + ['jbiprrhand', 'jrhand', 'jbiprhand', 'righthand'],
|
||||
|
||||
# VRM legs - both simplified patterns
|
||||
'left_leg': bone_names['left_leg'] + ['jbiplupperleg', 'jlupperleg', 'leftupperleg'],
|
||||
'left_knee': bone_names['left_knee'] + ['jbipllowerleg', 'jllowerleg', 'leftlowerleg'],
|
||||
'left_ankle': bone_names['left_ankle'] + ['jbipllfoot', 'jlfoot', 'jbiplfoot', 'leftfoot'],
|
||||
'left_toe': bone_names['left_toe'] + ['jbiplltoe', 'jltoe', 'jbipltoebase', 'lefttoes'],
|
||||
|
||||
'right_leg': bone_names['right_leg'] + ['jbiprrupperleg', 'jrupperleg', 'jbiprupperleg', 'rightupperleg'],
|
||||
'right_knee': bone_names['right_knee'] + ['jbiprrlowerleg', 'jrlowerleg', 'jbiprlowerleg', 'rightlowerleg'],
|
||||
'right_ankle': bone_names['right_ankle'] + ['jbiprrfoot', 'jrfoot', 'jbiprfoot', 'rightfoot'],
|
||||
'right_toe': bone_names['right_toe'] + ['jbiprrtoe', 'jrtoe', 'jbiprtoebase', 'righttoes'],
|
||||
|
||||
# VRM eyes
|
||||
'left_eye': bone_names['left_eye'] + ['jbipcleye', 'jleye', 'jadjlfaceeye'],
|
||||
'right_eye': bone_names['right_eye'] + ['jbipcreye', 'jreye', 'jadjrfaceeye'],
|
||||
|
||||
# VRM jaw
|
||||
'jaw': ['jaw', 'mandible', 'lowerjaw', 'chin', 'あご', 'ik_あご'],
|
||||
|
||||
# Breast bones
|
||||
'breast_1_l': bone_names['breast_1_l'] + ['jbipcbreast1l', 'jlbreast1', 'jseclbust1'],
|
||||
'breast_2_l': bone_names['breast_2_l'] + ['jbipcbreast2l', 'jlbreast2', 'jseclbust2'],
|
||||
'breast_3_l': bone_names['breast_3_l'] + ['jbipcbreast3l', 'jlbreast3', 'jseclbust3'],
|
||||
'breast_1_r': bone_names['breast_1_r'] + ['jbipcbreast1r', 'jrbreast1', 'jsecrbust1'],
|
||||
'breast_2_r': bone_names['breast_2_r'] + ['jbipcbreast2r', 'jrbreast2', 'jsecrbust2'],
|
||||
'breast_3_r': bone_names['breast_3_r'] + ['jbipcbreast3r', 'jrbreast3', 'jsecrbust3'],
|
||||
|
||||
# VRM fingers - Left (including Little finger variations)
|
||||
'thumb_0_l': bone_names['thumb_0_l'] + ['jbipllthumb0', 'jlthumb0', 'jbipllthumbmetacarpal', 'jlthumbmetacarpal', 'leftthumbmetacarpal'],
|
||||
'thumb_1_l': bone_names['thumb_1_l'] + ['jbipllthumb1', 'jlthumb1', 'jbiplthumb1', 'leftthumbproximal'],
|
||||
'thumb_2_l': bone_names['thumb_2_l'] + ['jbipllthumb2', 'jlthumb2', 'jbiplthumb2', 'leftthumbintermediate'],
|
||||
'thumb_3_l': bone_names['thumb_3_l'] + ['jbipllthumb3', 'jlthumb3', 'jbiplthumb3', 'leftthumbdistal'],
|
||||
|
||||
'index_1_l': bone_names['index_1_l'] + ['jbipllindex1', 'jlindex1', 'jbiplindex1', 'leftindexproximal'],
|
||||
'index_2_l': bone_names['index_2_l'] + ['jbipllindex2', 'jlindex2', 'jbiplindex2', 'leftindexintermediate'],
|
||||
'index_3_l': bone_names['index_3_l'] + ['jbipllindex3', 'jlindex3', 'jbiplindex3', 'leftindexdistal'],
|
||||
|
||||
'middle_1_l': bone_names['middle_1_l'] + ['jbipllmiddle1', 'jlmiddle1', 'jbiplmiddle1', 'leftmiddleproximal'],
|
||||
'middle_2_l': bone_names['middle_2_l'] + ['jbipllmiddle2', 'jlmiddle2', 'jbiplmiddle2', 'leftmiddleintermediate'],
|
||||
'middle_3_l': bone_names['middle_3_l'] + ['jbipllmiddle3', 'jlmiddle3', 'jbiplmiddle3', 'leftmiddledistal'],
|
||||
|
||||
'ring_1_l': bone_names['ring_1_l'] + ['jbipllring1', 'jlring1', 'jbiplring1', 'leftringproximal'],
|
||||
'ring_2_l': bone_names['ring_2_l'] + ['jbipllring2', 'jlring2', 'jbiplring2', 'leftringintermediate'],
|
||||
'ring_3_l': bone_names['ring_3_l'] + ['jbipllring3', 'jlring3', 'jbiplring3', 'leftringdistal'],
|
||||
|
||||
'pinkie_1_l': bone_names['pinkie_1_l'] + ['jbipllpinky1', 'jlpinky1', 'jbipllittle1', 'jbipllpinkie1', 'leftlittleproximal'],
|
||||
'pinkie_2_l': bone_names['pinkie_2_l'] + ['jbipllpinky2', 'jlpinky2', 'jbipllittle2', 'jbipllpinkie2', 'leftlittleintermediate'],
|
||||
'pinkie_3_l': bone_names['pinkie_3_l'] + ['jbipllpinky3', 'jlpinky3', 'jbipllittle3', 'jbipllpinkie3', 'leftlittledistal'],
|
||||
|
||||
# VRM fingers - Right (including Little finger variations)
|
||||
'thumb_0_r': bone_names['thumb_0_r'] + ['jbiprthumb0', 'jrthumb0', 'jbiprthumbmetacarpal', 'jrthumbmetacarpal', 'rightthumbmetacarpal'],
|
||||
'thumb_1_r': bone_names['thumb_1_r'] + ['jbiprthumb1', 'jrthumb1', 'jbiprrrthumb1', 'rightthumbproximal'],
|
||||
'thumb_2_r': bone_names['thumb_2_r'] + ['jbiprthumb2', 'jrthumb2', 'jbiprrrthumb2', 'rightthumbintermediate'],
|
||||
'thumb_3_r': bone_names['thumb_3_r'] + ['jbiprthumb3', 'jrthumb3', 'jbiprrrthumb3', 'rightthumbdistal'],
|
||||
|
||||
'index_1_r': bone_names['index_1_r'] + ['jbiprindex1', 'jrindex1', 'jbiprrrindex1', 'rightindexproximal'],
|
||||
'index_2_r': bone_names['index_2_r'] + ['jbiprindex2', 'jrindex2', 'jbiprrrindex2', 'rightindexintermediate'],
|
||||
'index_3_r': bone_names['index_3_r'] + ['jbiprindex3', 'jrindex3', 'jbiprrrindex3', 'rightindexdistal'],
|
||||
|
||||
'middle_1_r': bone_names['middle_1_r'] + ['jbiprmiddle1', 'jrmiddle1', 'jbiprrmiddle1', 'rightmiddleproximal'],
|
||||
'middle_2_r': bone_names['middle_2_r'] + ['jbiprmiddle2', 'jrmiddle2', 'jbiprrmiddle2', 'rightmiddleintermediate'],
|
||||
'middle_3_r': bone_names['middle_3_r'] + ['jbiprmiddle3', 'jrmiddle3', 'jbiprrmiddle3', 'rightmiddledistal'],
|
||||
|
||||
'ring_1_r': bone_names['ring_1_r'] + ['jbiprring1', 'jrring1', 'jbiprrrring1', 'rightringproximal'],
|
||||
'ring_2_r': bone_names['ring_2_r'] + ['jbiprring2', 'jrring2', 'jbiprrrring2', 'rightringintermediate'],
|
||||
'ring_3_r': bone_names['ring_3_r'] + ['jbiprring3', 'jrring3', 'jbiprrrring3', 'rightringdistal'],
|
||||
|
||||
'pinkie_1_r': bone_names['pinkie_1_r'] + ['jbiprpinky1', 'jrpinky1', 'jbiprlittle1', 'jbiprrrpinky1', 'rightlittleproximal'],
|
||||
'pinkie_2_r': bone_names['pinkie_2_r'] + ['jbiprpinky2', 'jrpinky2', 'jbiprlittle2', 'jbiprrrpinky2', 'rightlittleintermediate'],
|
||||
'pinkie_3_r': bone_names['pinkie_3_r'] + ['jbiprpinky3', 'jrpinky3', 'jbiprlittle3', 'jbiprrrpinky3', 'rightlittledistal']
|
||||
})
|
||||
|
||||
# array taken from cats
|
||||
@@ -354,3 +444,670 @@ resonite_translations = {
|
||||
'thumb_2_r': "thumb2.R",
|
||||
'thumb_3_r': "thumb3.R"
|
||||
}
|
||||
|
||||
|
||||
|
||||
standard_bones = {
|
||||
# Core Structure
|
||||
'hips': 'Hips',
|
||||
'spine': 'Spine',
|
||||
'chest': 'Chest',
|
||||
'upper_chest': 'UpperChest',
|
||||
'neck': 'Neck',
|
||||
'head': 'Head',
|
||||
|
||||
# Arms
|
||||
'left_shoulder': 'Shoulder_L',
|
||||
'left_arm': 'UpperArm_L',
|
||||
'left_elbow': 'LowerArm_L',
|
||||
'left_wrist': 'Hand_L',
|
||||
'right_shoulder': 'Shoulder_R',
|
||||
'right_arm': 'UpperArm_R',
|
||||
'right_elbow': 'LowerArm_R',
|
||||
'right_wrist': 'Hand_R',
|
||||
|
||||
# Legs
|
||||
'left_leg': 'UpperLeg_L',
|
||||
'left_knee': 'LowerLeg_L',
|
||||
'left_ankle': 'Foot_L',
|
||||
'left_toe': 'Toe_L',
|
||||
'right_leg': 'UpperLeg_R',
|
||||
'right_knee': 'LowerLeg_R',
|
||||
'right_ankle': 'Foot_R',
|
||||
'right_toe': 'Toe_R',
|
||||
|
||||
# Fingers Left
|
||||
'thumb_1_l': 'Thumb_L',
|
||||
'thumb_2_l': 'Thumb_L.001',
|
||||
'thumb_3_l': 'Thumb_L.002',
|
||||
'index_1_l': 'Index_L',
|
||||
'index_2_l': 'Index_L.001',
|
||||
'index_3_l': 'Index_L.002',
|
||||
'middle_1_l': 'Middle_L',
|
||||
'middle_2_l': 'Middle_L.001',
|
||||
'middle_3_l': 'Middle_L.002',
|
||||
'ring_1_l': 'Ring_L',
|
||||
'ring_2_l': 'Ring_L.001',
|
||||
'ring_3_l': 'Ring_L.002',
|
||||
'pinkie_1_l': 'Pinky_L',
|
||||
'pinkie_2_l': 'Pinky_L.001',
|
||||
'pinkie_3_l': 'Pinky_L.002',
|
||||
|
||||
# Fingers Right
|
||||
'thumb_1_r': 'Thumb_R',
|
||||
'thumb_2_r': 'Thumb_R.001',
|
||||
'thumb_3_r': 'Thumb_R.002',
|
||||
'index_1_r': 'Index_R',
|
||||
'index_2_r': 'Index_R.001',
|
||||
'index_3_r': 'Index_R.002',
|
||||
'middle_1_r': 'Middle_R',
|
||||
'middle_2_r': 'Middle_R.001',
|
||||
'middle_3_r': 'Middle_R.002',
|
||||
'ring_1_r': 'Ring_R',
|
||||
'ring_2_r': 'Ring_R.001',
|
||||
'ring_3_r': 'Ring_R.002',
|
||||
'pinkie_1_r': 'Pinky_R',
|
||||
'pinkie_2_r': 'Pinky_R.001',
|
||||
'pinkie_3_r': 'Pinky_R.002',
|
||||
|
||||
# Eyes
|
||||
'left_eye': 'Eye_L',
|
||||
'right_eye': 'Eye_R',
|
||||
|
||||
# Breast bones
|
||||
'breast_1_l': 'Breast1_L',
|
||||
'breast_2_l': 'Breast2_L',
|
||||
'breast_3_l': 'Breast3_L',
|
||||
'breast_1_r': 'Breast1_R',
|
||||
'breast_2_r': 'Breast2_R',
|
||||
'breast_3_r': 'Breast3_R'
|
||||
}
|
||||
|
||||
bone_hierarchy = [
|
||||
('Hips', 'Spine'),
|
||||
('Spine', 'Chest'),
|
||||
('Chest', 'UpperChest'),
|
||||
('UpperChest', 'Neck'),
|
||||
('Neck', 'Head'),
|
||||
('Head', 'Eye_L'),
|
||||
('Head', 'Eye_R'),
|
||||
|
||||
# Left Arm Chain
|
||||
('UpperChest', 'Shoulder_L'),
|
||||
('Shoulder_L', 'UpperArm_L'),
|
||||
('UpperArm_L', 'LowerArm_L'),
|
||||
('LowerArm_L', 'Hand_L'),
|
||||
|
||||
# Right Arm Chain
|
||||
('UpperChest', 'Shoulder_R'),
|
||||
('Shoulder_R', 'UpperArm_R'),
|
||||
('UpperArm_R', 'LowerArm_R'),
|
||||
('LowerArm_R', 'Hand_R'),
|
||||
|
||||
# Left Leg Chain
|
||||
('Hips', 'UpperLeg_L'),
|
||||
('UpperLeg_L', 'LowerLeg_L'),
|
||||
('LowerLeg_L', 'Foot_L'),
|
||||
('Foot_L', 'Toe_L'),
|
||||
|
||||
# Right Leg Chain
|
||||
('Hips', 'UpperLeg_R'),
|
||||
('UpperLeg_R', 'LowerLeg_R'),
|
||||
('LowerLeg_R', 'Foot_R'),
|
||||
('Foot_R', 'Toe_R')
|
||||
]
|
||||
|
||||
finger_hierarchy = {
|
||||
'left': [
|
||||
('Hand_L', 'Thumb_L', 'Thumb_L.001', 'Thumb_L.002'),
|
||||
('Hand_L', 'Index_L', 'Index_L.001', 'Index_L.002'),
|
||||
('Hand_L', 'Middle_L', 'Middle_L.001', 'Middle_L.002'),
|
||||
('Hand_L', 'Ring_L', 'Ring_L.001', 'Ring_L.002'),
|
||||
('Hand_L', 'Pinky_L', 'Pinky_L.001', 'Pinky_L.002')
|
||||
],
|
||||
'right': [
|
||||
('Hand_R', 'Thumb_R', 'Thumb_R.001', 'Thumb_R.002'),
|
||||
('Hand_R', 'Index_R', 'Index_R.001', 'Index_R.002'),
|
||||
('Hand_R', 'Middle_R', 'Middle_R.001', 'Middle_R.002'),
|
||||
('Hand_R', 'Ring_R', 'Ring_R.001', 'Ring_R.002'),
|
||||
('Hand_R', 'Pinky_R', 'Pinky_R.001', 'Pinky_R.002')
|
||||
]
|
||||
}
|
||||
|
||||
acceptable_bone_hierarchy = [
|
||||
# Right side chain
|
||||
('Hips', 'Chest'),
|
||||
('Chest', 'Shoulder.R'),
|
||||
('Shoulder.R', 'Arm.R'),
|
||||
('Arm.R', 'Elbow.R'),
|
||||
('Elbow.R', 'Wrist.R'),
|
||||
('Hips', 'Leg.R'),
|
||||
('Leg.R', 'Knee.R'),
|
||||
('Knee.R', 'Foot.R'),
|
||||
('Foot.R', 'Toes.R'),
|
||||
|
||||
# Left side chain
|
||||
('Chest', 'Shoulder.L'),
|
||||
('Shoulder.L', 'Arm.L'),
|
||||
('Arm.L', 'Elbow.L'),
|
||||
('Elbow.L', 'Wrist.L'),
|
||||
('Hips', 'Leg.L'),
|
||||
('Leg.L', 'Knee.L'),
|
||||
('Knee.L', 'Foot.L'),
|
||||
('Foot.L', 'Toes.L'),
|
||||
|
||||
# Head and Eyes
|
||||
('Chest', 'Neck'),
|
||||
('Neck', 'Head'),
|
||||
('Head', 'Eye_L'),
|
||||
('Head', 'Eye_R'),
|
||||
('Head', 'LeftEye'),
|
||||
('Head', 'RightEye'),
|
||||
('Head', 'Eye.L'),
|
||||
('Head', 'Eye.R'),
|
||||
|
||||
# Unity humanoid naming
|
||||
('Hips', 'Spine'),
|
||||
('Spine', 'Chest'),
|
||||
('Chest', 'UpperChest'),
|
||||
('UpperChest', 'Neck'),
|
||||
('Neck', 'Head'),
|
||||
('Head', 'LeftEye'),
|
||||
('Head', 'RightEye'),
|
||||
|
||||
# Old standard bone hierarchy patterns
|
||||
('UpperChest', 'UpperArm.L'),
|
||||
('UpperArm.L', 'LowerArm.L'),
|
||||
('LowerArm.L', 'Hand.L'),
|
||||
('UpperChest', 'UpperArm.R'),
|
||||
('UpperArm.R', 'LowerArm.R'),
|
||||
('LowerArm.R', 'Hand.R'),
|
||||
('Hips', 'UpperLeg.L'),
|
||||
('UpperLeg.L', 'LowerLeg.L'),
|
||||
('LowerLeg.L', 'Foot.L'),
|
||||
('Foot.L', 'Toes.L'),
|
||||
('Hips', 'UpperLeg.R'),
|
||||
('UpperLeg.R', 'LowerLeg.R'),
|
||||
('LowerLeg.R', 'Foot.R'),
|
||||
('Foot.R', 'Toes.R'),
|
||||
|
||||
# New standard bone hierarchy patterns (with shoulders)
|
||||
('UpperChest', 'Shoulder_L'),
|
||||
('Shoulder_L', 'UpperArm_L'),
|
||||
('UpperArm_L', 'LowerArm_L'),
|
||||
('LowerArm_L', 'Hand_L'),
|
||||
('UpperChest', 'Shoulder_R'),
|
||||
('Shoulder_R', 'UpperArm_R'),
|
||||
('UpperArm_R', 'LowerArm_R'),
|
||||
('LowerArm_R', 'Hand_R'),
|
||||
('Hips', 'UpperLeg_L'),
|
||||
('UpperLeg_L', 'LowerLeg_L'),
|
||||
('LowerLeg_L', 'Foot_L'),
|
||||
('Foot_L', 'Toe_L'),
|
||||
('Hips', 'UpperLeg_R'),
|
||||
('UpperLeg_R', 'LowerLeg_R'),
|
||||
('LowerLeg_R', 'Foot_R'),
|
||||
('Foot_R', 'Toe_R'),
|
||||
|
||||
]
|
||||
|
||||
acceptable_bone_names = {
|
||||
'hips': ['Hips', 'pelvis', 'root', 'Root', 'ROOT'],
|
||||
'chest': ['Chest', 'spine1', 'Spine1', 'spine_01', 'SPINE1', 'Spine01'],
|
||||
'neck': ['Neck', 'neck_01', 'Neck01'],
|
||||
'head': ['Head', 'head_01', 'Head01'],
|
||||
'eye_l': ['Eye_L', 'LeftEye', 'lefteye', 'eye_left', 'EyeLeft', 'Eye.L'],
|
||||
'eye_r': ['Eye_R', 'RightEye', 'righteye', 'eye_right', 'EyeRight', 'Eye.R'],
|
||||
|
||||
'shoulder_r': ['Shoulder.R', 'clavicle_r', 'ClavicleRight', 'RightShoulder', 'Shoulder_R'],
|
||||
'arm_r': ['Arm.R', 'upperarm_r', 'UpperArmRight', 'RightArm', 'UpperArm.R', 'UpperArm_R'],
|
||||
'elbow_r': ['Elbow.R', 'lowerarm_r', 'ForearmRight', 'RightForeArm', 'LowerArm.R', 'LowerArm_R'],
|
||||
'wrist_r': ['Wrist.R', 'hand_r', 'HandRight', 'RightHand', 'Hand.R', 'Hand_R'],
|
||||
'leg_r': ['Leg.R', 'thigh_r', 'ThighRight', 'RightLeg', 'RightUpLeg', 'UpperLeg.R', 'UpperLeg_R'],
|
||||
'knee_r': ['Knee.R', 'calf_r', 'CalfRight', 'RightShin', 'RightLowerLeg', 'LowerLeg.R', 'LowerLeg_R'],
|
||||
'foot_r': ['Foot.R', 'foot_r', 'FootRight', 'RightFoot', 'Foot_R'],
|
||||
'toes_r': ['Toes.R', 'ball_r', 'ToeRight', 'RightToeBase', 'Toe_R'],
|
||||
|
||||
'shoulder_l': ['Shoulder.L', 'clavicle_l', 'ClavicleLeft', 'LeftShoulder', 'Shoulder_L'],
|
||||
'arm_l': ['Arm.L', 'upperarm_l', 'UpperArmLeft', 'LeftArm', 'UpperArm.L', 'UpperArm_L'],
|
||||
'elbow_l': ['Elbow.L', 'lowerarm_l', 'ForearmLeft', 'LeftForeArm', 'LowerArm.L', 'LowerArm_L'],
|
||||
'wrist_l': ['Wrist.L', 'hand_l', 'HandLeft', 'LeftHand', 'Hand.L', 'Hand_L'],
|
||||
'leg_l': ['Leg.L', 'thigh_l', 'ThighLeft', 'LeftLeg', 'LeftUpLeg', 'UpperLeg.L', 'UpperLeg_L'],
|
||||
'knee_l': ['Knee.L', 'calf_l', 'CalfLeft', 'LeftShin', 'LeftLowerLeg', 'LowerLeg.L', 'LowerLeg_L'],
|
||||
'foot_l': ['Foot.L', 'foot_l', 'FootLeft', 'LeftFoot', 'Foot_L'],
|
||||
'toes_l': ['Toes.L', 'ball_l', 'ToeLeft', 'LeftToeBase', 'Toe_L'],
|
||||
|
||||
# Add finger bones for left hand
|
||||
'thumb_0_l': ['Thumb0_L', 'Thumb0.L'],
|
||||
'thumb_1_l': ['Thumb1_L', 'Thumb1.L', 'Thumb_L'],
|
||||
'thumb_2_l': ['Thumb2_L', 'Thumb2.L', 'Thumb_L.001'],
|
||||
'thumb_3_l': ['Thumb3_L', 'Thumb3.L', 'Thumb_L.002'],
|
||||
'index_1_l': ['IndexFinger1_L', 'IndexFinger1.L', 'Index1.L', 'Index_L'],
|
||||
'index_2_l': ['IndexFinger2_L', 'IndexFinger2.L', 'Index2.L', 'Index_L.001'],
|
||||
'index_3_l': ['IndexFinger3_L', 'IndexFinger3.L', 'Index3.L', 'Index_L.002'],
|
||||
'middle_1_l': ['MiddleFinger1_L', 'MiddleFinger1.L', 'Middle1.L', 'Middle_L'],
|
||||
'middle_2_l': ['MiddleFinger2_L', 'MiddleFinger2.L', 'Middle2.L', 'Middle_L.001'],
|
||||
'middle_3_l': ['MiddleFinger3_L', 'MiddleFinger3.L', 'Middle3.L', 'Middle_L.002'],
|
||||
'ring_1_l': ['RingFinger1_L', 'RingFinger1.L', 'Ring1.L', 'Ring_L'],
|
||||
'ring_2_l': ['RingFinger2_L', 'RingFinger2.L', 'Ring2.L', 'Ring_L.001'],
|
||||
'ring_3_l': ['RingFinger3_L', 'RingFinger3.L', 'Ring3.L', 'Ring_L.002'],
|
||||
'pinky_1_l': ['Pinky1_L', 'Pinky1.L', 'Pinky_L'],
|
||||
'pinky_2_l': ['Pinky2_L', 'Pinky2.L', 'Pinky_L.001'],
|
||||
'pinky_3_l': ['Pinky3_L', 'Pinky3.L', 'Pinky_L.002'],
|
||||
|
||||
# Add finger bones for right hand
|
||||
'thumb_0_r': ['Thumb0_R', 'Thumb0.R', 'ThumbO_R'],
|
||||
'thumb_1_r': ['Thumb1_R', 'Thumb1.R', 'Thumb_R'],
|
||||
'thumb_2_r': ['Thumb2_R', 'Thumb2.R', 'Thumb_R.001'],
|
||||
'thumb_3_r': ['Thumb3_R', 'Thumb3.R', 'Thumb_R.002'],
|
||||
'index_1_r': ['IndexFinger1_R', 'IndexFinger1.R', 'Index1.R', 'Index_R'],
|
||||
'index_2_r': ['IndexFinger2_R', 'IndexFinger2.R', 'Index2.R', 'Index_R.001'],
|
||||
'index_3_r': ['IndexFinger3_R', 'IndexFinger3.R', 'Index3.R', 'Index_R.002'],
|
||||
'middle_1_r': ['MiddleFinger1_R', 'MiddleFinger1.R', 'Middle1.R', 'Middle_R'],
|
||||
'middle_2_r': ['MiddleFinger2_R', 'MiddleFinger2.R', 'Middle2.R', 'Middle_R.001'],
|
||||
'middle_3_r': ['MiddleFinger3_R', 'MiddleFinger3.R', 'Middle3.R', 'Middle_R.002'],
|
||||
'ring_1_r': ['RingFinger1_R', 'RingFinger1.R', 'Ring1.R', 'Ring_R'],
|
||||
'ring_2_r': ['RingFinger2_R', 'RingFinger2.R', 'Ring2.R', 'Ring_R.001'],
|
||||
'ring_3_r': ['RingFinger3_R', 'RingFinger3.R', 'Ring3.R', 'Ring_R.002'],
|
||||
'pinky_1_r': ['Pinky1_R', 'Pinky1.R', 'Pinky_R'],
|
||||
'pinky_2_r': ['Pinky2_R', 'Pinky2.R', 'Pinky_R.001'],
|
||||
'pinky_3_r': ['Pinky3_R', 'Pinky3.R', 'Pinky_R.002'],
|
||||
|
||||
'breast_upper_1_l': ['BreastUpper1_L', 'BreastUpper1.L'],
|
||||
'breast_upper_2_l': ['BreastUpper2_L', 'BreastUpper2.L'],
|
||||
'breast_upper_1_r': ['BreastUpper1_R', 'BreastUpper1.R'],
|
||||
'breast_upper_2_r': ['BreastUpper2_R', 'BreastUpper2.R'],
|
||||
|
||||
# Little finger bones
|
||||
'little_finger_1_l': ['LittleFinger1_L', 'LittleFinger1.L'],
|
||||
'little_finger_2_l': ['LittleFinger2_L', 'LittleFinger2.L'],
|
||||
'little_finger_3_l': ['LittleFinger3_L', 'LittleFinger3.L'],
|
||||
'little_finger_1_r': ['LittleFinger1_R', 'LittleFinger1.R'],
|
||||
'little_finger_2_r': ['LittleFinger2_R', 'LittleFinger2.R'],
|
||||
'little_finger_3_r': ['LittleFinger3_R', 'LittleFinger3.R'],
|
||||
|
||||
'ear_upper_l': ['UpperEar.L', 'Upper Ear.L', 'Upper Ear_L'],
|
||||
'ear_upper_r': ['UpperEar.R', 'Upper Ear.R', 'Upper Ear_R'],
|
||||
'ear_lower_l': ['LowerEar.L', 'Lower Ear.L', 'Lower Ear_L'],
|
||||
'ear_lower_r': ['LowerEar.R', 'Lower Ear.R', 'Lower Ear_R'],
|
||||
|
||||
'ears_upper': ['Ears Upper', 'EarsUpper', 'ears_upper'],
|
||||
'ears_lower': ['Ears Lower', 'EarsLower', 'ears_lower']
|
||||
}
|
||||
|
||||
rigify_unity_names = {
|
||||
"DEF-spine": "Hips",
|
||||
"DEF-spine.001": "Spine",
|
||||
"DEF-spine.002": "Chest",
|
||||
"DEF-spine.003": "UpperChest",
|
||||
"DEF-neck": "Neck",
|
||||
"DEF-head": "Head",
|
||||
"DEF-shoulder.L": "LeftShoulder",
|
||||
"DEF-upper_arm.L": "LeftUpperArm",
|
||||
"DEF-forearm.L": "LeftLowerArm",
|
||||
"DEF-hand.L": "LeftHand",
|
||||
"DEF-shoulder.R": "RightShoulder",
|
||||
"DEF-upper_arm.R": "RightUpperArm",
|
||||
"DEF-forearm.R": "RightLowerArm",
|
||||
"DEF-hand.R": "RightHand",
|
||||
"DEF-thigh.L": "LeftUpperLeg",
|
||||
"DEF-shin.L": "LeftLowerLeg",
|
||||
"DEF-foot.L": "LeftFoot",
|
||||
"DEF-toe.L": "LeftToes",
|
||||
"DEF-thigh.R": "RightUpperLeg",
|
||||
"DEF-shin.R": "RightLowerLeg",
|
||||
"DEF-foot.R": "RightFoot",
|
||||
"DEF-toe.R": "RightToes"
|
||||
}
|
||||
|
||||
rigify_basic_unity_names = {
|
||||
"spine": "Hips",
|
||||
"spine.001": "Spine",
|
||||
"spine.002": "Chest",
|
||||
"spine.003": "UpperChest",
|
||||
"neck": "Neck",
|
||||
"head": "Head",
|
||||
"shoulder.L": "LeftShoulder",
|
||||
"upper_arm.L": "LeftUpperArm",
|
||||
"forearm.L": "LeftLowerArm",
|
||||
"hand.L": "LeftHand",
|
||||
"shoulder.R": "RightShoulder",
|
||||
"upper_arm.R": "RightUpperArm",
|
||||
"forearm.R": "RightLowerArm",
|
||||
"hand.R": "RightHand",
|
||||
"thigh.L": "LeftUpperLeg",
|
||||
"shin.L": "LeftLowerLeg",
|
||||
"foot.L": "LeftFoot",
|
||||
"toe.L": "LeftToes",
|
||||
"thigh.R": "RightUpperLeg",
|
||||
"shin.R": "RightLowerLeg",
|
||||
"foot.R": "RightFoot",
|
||||
"toe.R": "RightToes"
|
||||
}
|
||||
|
||||
rigify_unnecessary_bones = [
|
||||
'face',
|
||||
'ear.l', 'ear.r',
|
||||
'forehead',
|
||||
'cheek.t.l', 'cheek.t.r',
|
||||
'cheek.b.l', 'cheek.b.r',
|
||||
'brow.t.l', 'brow.t.r',
|
||||
'brow.b.l', 'brow.b.r',
|
||||
'jaw',
|
||||
'chin',
|
||||
'nose',
|
||||
'temple.l', 'temple.r',
|
||||
'teeth',
|
||||
'lip',
|
||||
'lid',
|
||||
'heel',
|
||||
'pelvis.'
|
||||
]
|
||||
|
||||
# Non-standard bone mappings to standard bones
|
||||
non_standard_mappings = {
|
||||
'hips': [
|
||||
'mixamorig:Hips', 'mixamorig_Hips',
|
||||
'ORG-spine', 'spine', 'root',
|
||||
'hip', 'pelvis'
|
||||
],
|
||||
'spine': [
|
||||
'mixamorig:Spine', 'mixamorig_Spine',
|
||||
'ORG-spine.001', 'spine.001',
|
||||
'abdomenLower', 'lowerback'
|
||||
],
|
||||
'chest': [
|
||||
'mixamorig:Spine1', 'mixamorig_Spine1',
|
||||
'ORG-spine.002', 'spine.002',
|
||||
'abdomenUpper', 'upperback', 'spine1'
|
||||
],
|
||||
'upper_chest': [
|
||||
'mixamorig:Spine2', 'mixamorig_Spine2',
|
||||
'ORG-spine.003', 'spine.003',
|
||||
'chestLower', 'chest', 'spine2'
|
||||
],
|
||||
'neck': [
|
||||
'mixamorig:Neck', 'mixamorig_Neck',
|
||||
'ORG-spine.004', 'spine.004', 'neck',
|
||||
'neckLower'
|
||||
],
|
||||
'head': [
|
||||
'mixamorig:Head', 'mixamorig_Head',
|
||||
'ORG-spine.005', 'spine.005', 'face', 'head'
|
||||
],
|
||||
|
||||
'left_shoulder': [
|
||||
'mixamorig:LeftShoulder', 'mixamorig_LeftShoulder',
|
||||
'ORG-shoulder.L', 'shoulder.L',
|
||||
'lCollar', 'lShldr', 'lClavicle'
|
||||
],
|
||||
'left_arm': [
|
||||
'mixamorig:LeftArm', 'mixamorig_LeftArm',
|
||||
'ORG-upper_arm.L', 'upper_arm.L',
|
||||
'lShldrBend', 'lShldrTwist', 'lArm', 'UpperArm.L'
|
||||
],
|
||||
'left_elbow': [
|
||||
'mixamorig:LeftForeArm', 'mixamorig_LeftForeArm',
|
||||
'ORG-forearm.L', 'forearm.L',
|
||||
'lForearmBend', 'lElbow', 'lForeArm', 'LowerArm.L'
|
||||
],
|
||||
'left_wrist': [
|
||||
'mixamorig:LeftHand', 'mixamorig_LeftHand',
|
||||
'ORG-hand.L', 'hand.L',
|
||||
'lHand', 'lWrist', 'Hand.L'
|
||||
],
|
||||
|
||||
'right_shoulder': [
|
||||
'mixamorig:RightShoulder', 'mixamorig_RightShoulder',
|
||||
'ORG-shoulder.R', 'shoulder.R',
|
||||
'rCollar', 'rShldr', 'rClavicle'
|
||||
],
|
||||
'right_arm': [
|
||||
'mixamorig:RightArm', 'mixamorig_RightArm',
|
||||
'ORG-upper_arm.R', 'upper_arm.R',
|
||||
'rShldrBend', 'rShldrTwist', 'rArm', 'UpperArm.R'
|
||||
],
|
||||
'right_elbow': [
|
||||
'mixamorig:RightForeArm', 'mixamorig_RightForeArm',
|
||||
'ORG-forearm.R', 'forearm.R',
|
||||
'rForearmBend', 'rElbow', 'rForeArm', 'LowerArm.R'
|
||||
],
|
||||
'right_wrist': [
|
||||
'mixamorig:RightHand', 'mixamorig_RightHand',
|
||||
'ORG-hand.R', 'hand.R',
|
||||
'rHand', 'rWrist', 'Hand.R'
|
||||
],
|
||||
|
||||
'left_leg': [
|
||||
'mixamorig:LeftUpLeg', 'mixamorig_LeftUpLeg',
|
||||
'ORG-thigh.L', 'thigh.L',
|
||||
'lThighBend', 'lThigh', 'UpperLeg.L',
|
||||
'LeftUpperLeg'
|
||||
],
|
||||
'left_knee': [
|
||||
'mixamorig:LeftLeg', 'mixamorig_LeftLeg',
|
||||
'ORG-shin.L', 'shin.L',
|
||||
'lShin', 'lKnee', 'lLeg', 'LowerLeg.L'
|
||||
],
|
||||
'left_ankle': [
|
||||
'mixamorig:LeftFoot', 'mixamorig_LeftFoot',
|
||||
'ORG-foot.L', 'foot.L',
|
||||
'lFoot', 'lAnkle', 'Foot.L'
|
||||
],
|
||||
'left_toe': [
|
||||
'mixamorig:LeftToeBase', 'mixamorig_LeftToeBase',
|
||||
'ORG-toe.L', 'toe.L',
|
||||
'lToe', 'Toes.L', 'LeftToeBase'
|
||||
],
|
||||
|
||||
'right_leg': [
|
||||
'mixamorig:RightUpLeg', 'mixamorig_RightUpLeg',
|
||||
'ORG-thigh.R', 'thigh.R',
|
||||
'rThighBend', 'rThigh', 'UpperLeg.R',
|
||||
'RightUpperLeg'
|
||||
],
|
||||
'right_knee': [
|
||||
'mixamorig:RightLeg', 'mixamorig_RightLeg',
|
||||
'ORG-shin.R', 'shin.R',
|
||||
'rShin', 'rKnee', 'rLeg', 'LowerLeg.R'
|
||||
],
|
||||
'right_ankle': [
|
||||
'mixamorig:RightFoot', 'mixamorig_RightFoot',
|
||||
'ORG-foot.R', 'foot.R',
|
||||
'rFoot', 'rAnkle', 'Foot.R'
|
||||
],
|
||||
'right_toe': [
|
||||
'mixamorig:RightToeBase', 'mixamorig_RightToeBase',
|
||||
'ORG-toe.R', 'toe.R',
|
||||
'rToe', 'Toes.R', 'RightToeBase'
|
||||
],
|
||||
|
||||
'thumb_1_l': [
|
||||
'mixamorig:LeftHandThumb1', 'mixamorig_LeftHandThumb1',
|
||||
'ORG-thumb.01.L', 'thumb.01.L',
|
||||
'lThumb1'
|
||||
],
|
||||
'thumb_2_l': [
|
||||
'mixamorig:LeftHandThumb2', 'mixamorig_LeftHandThumb2',
|
||||
'ORG-thumb.02.L', 'thumb.02.L',
|
||||
'lThumb2'
|
||||
],
|
||||
'thumb_3_l': [
|
||||
'mixamorig:LeftHandThumb3', 'mixamorig_LeftHandThumb3',
|
||||
'ORG-thumb.03.L', 'thumb.03.L',
|
||||
'lThumb3'
|
||||
],
|
||||
|
||||
'index_1_l': [
|
||||
'mixamorig:LeftHandIndex1', 'mixamorig_LeftHandIndex1',
|
||||
'ORG-f_index.01.L', 'f_index.01.L',
|
||||
'lIndex1'
|
||||
],
|
||||
'index_2_l': [
|
||||
'mixamorig:LeftHandIndex2', 'mixamorig_LeftHandIndex2',
|
||||
'ORG-f_index.02.L', 'f_index.02.L',
|
||||
'lIndex2'
|
||||
],
|
||||
'index_3_l': [
|
||||
'mixamorig:LeftHandIndex3', 'mixamorig_LeftHandIndex3',
|
||||
'ORG-f_index.03.L', 'f_index.03.L',
|
||||
'lIndex3'
|
||||
],
|
||||
|
||||
'middle_1_l': [
|
||||
'mixamorig:LeftHandMiddle1', 'mixamorig_LeftHandMiddle1',
|
||||
'ORG-f_middle.01.L', 'f_middle.01.L',
|
||||
'lMid1'
|
||||
],
|
||||
'middle_2_l': [
|
||||
'mixamorig:LeftHandMiddle2', 'mixamorig_LeftHandMiddle2',
|
||||
'ORG-f_middle.02.L', 'f_middle.02.L',
|
||||
'lMid2'
|
||||
],
|
||||
'middle_3_l': [
|
||||
'mixamorig:LeftHandMiddle3', 'mixamorig_LeftHandMiddle3',
|
||||
'ORG-f_middle.03.L', 'f_middle.03.L',
|
||||
'lMid3'
|
||||
],
|
||||
|
||||
'ring_1_l': [
|
||||
'mixamorig:LeftHandRing1', 'mixamorig_LeftHandRing1',
|
||||
'ORG-f_ring.01.L', 'f_ring.01.L',
|
||||
'lRing1'
|
||||
],
|
||||
'ring_2_l': [
|
||||
'mixamorig:LeftHandRing2', 'mixamorig_LeftHandRing2',
|
||||
'ORG-f_ring.02.L', 'f_ring.02.L',
|
||||
'lRing2'
|
||||
],
|
||||
'ring_3_l': [
|
||||
'mixamorig:LeftHandRing3', 'mixamorig_LeftHandRing3',
|
||||
'ORG-f_ring.03.L', 'f_ring.03.L',
|
||||
'lRing3'
|
||||
],
|
||||
|
||||
'pinkie_1_l': [
|
||||
'mixamorig:LeftHandPinky1', 'mixamorig_LeftHandPinky1',
|
||||
'ORG-f_pinky.01.L', 'f_pinky.01.L',
|
||||
'lPinky1'
|
||||
],
|
||||
'pinkie_2_l': [
|
||||
'mixamorig:LeftHandPinky2', 'mixamorig_LeftHandPinky2',
|
||||
'ORG-f_pinky.02.L', 'f_pinky.02.L',
|
||||
'lPinky2'
|
||||
],
|
||||
'pinkie_3_l': [
|
||||
'mixamorig:LeftHandPinky3', 'mixamorig_LeftHandPinky3',
|
||||
'ORG-f_pinky.03.L', 'f_pinky.03.L',
|
||||
'lPinky3'
|
||||
],
|
||||
|
||||
'thumb_1_r': [
|
||||
'mixamorig:RightHandThumb1', 'mixamorig_RightHandThumb1',
|
||||
'ORG-thumb.01.R', 'thumb.01.R',
|
||||
'rThumb1'
|
||||
],
|
||||
'thumb_2_r': [
|
||||
'mixamorig:RightHandThumb2', 'mixamorig_RightHandThumb2',
|
||||
'ORG-thumb.02.R', 'thumb.02.R',
|
||||
'rThumb2'
|
||||
],
|
||||
'thumb_3_r': [
|
||||
'mixamorig:RightHandThumb3', 'mixamorig_RightHandThumb3',
|
||||
'ORG-thumb.03.R', 'thumb.03.R',
|
||||
'rThumb3'
|
||||
],
|
||||
|
||||
'index_1_r': [
|
||||
'mixamorig:RightHandIndex1', 'mixamorig_RightHandIndex1',
|
||||
'ORG-f_index.01.R', 'f_index.01.R',
|
||||
'rIndex1'
|
||||
],
|
||||
'index_2_r': [
|
||||
'mixamorig:RightHandIndex2', 'mixamorig_RightHandIndex2',
|
||||
'ORG-f_index.02.R', 'f_index.02.R',
|
||||
'rIndex2'
|
||||
],
|
||||
'index_3_r': [
|
||||
'mixamorig:RightHandIndex3', 'mixamorig_RightHandIndex3',
|
||||
'ORG-f_index.03.R', 'f_index.03.R',
|
||||
'rIndex3'
|
||||
],
|
||||
|
||||
'middle_1_r': [
|
||||
'mixamorig:RightHandMiddle1', 'mixamorig_RightHandMiddle1',
|
||||
'ORG-f_middle.01.R', 'f_middle.01.R',
|
||||
'rMid1'
|
||||
],
|
||||
'middle_2_r': [
|
||||
'mixamorig:RightHandMiddle2', 'mixamorig_RightHandMiddle2',
|
||||
'ORG-f_middle.02.R', 'f_middle.02.R',
|
||||
'rMid2'
|
||||
],
|
||||
'middle_3_r': [
|
||||
'mixamorig:RightHandMiddle3', 'mixamorig_RightHandMiddle3',
|
||||
'ORG-f_middle.03.R', 'f_middle.03.R',
|
||||
'rMid3'
|
||||
],
|
||||
|
||||
'ring_1_r': [
|
||||
'mixamorig:RightHandRing1', 'mixamorig_RightHandRing1',
|
||||
'ORG-f_ring.01.R', 'f_ring.01.R',
|
||||
'rRing1'
|
||||
],
|
||||
'ring_2_r': [
|
||||
'mixamorig:RightHandRing2', 'mixamorig_RightHandRing2',
|
||||
'ORG-f_ring.02.R', 'f_ring.02.R',
|
||||
'rRing2'
|
||||
],
|
||||
'ring_3_r': [
|
||||
'mixamorig:RightHandRing3', 'mixamorig_RightHandRing3',
|
||||
'ORG-f_ring.03.R', 'f_ring.03.R',
|
||||
'rRing3'
|
||||
],
|
||||
|
||||
'pinkie_1_r': [
|
||||
'mixamorig:RightHandPinky1', 'mixamorig_RightHandPinky1',
|
||||
'ORG-f_pinky.01.R', 'f_pinky.01.R',
|
||||
'rPinky1'
|
||||
],
|
||||
'pinkie_2_r': [
|
||||
'mixamorig:RightHandPinky2', 'mixamorig_RightHandPinky2',
|
||||
'ORG-f_pinky.02.R', 'f_pinky.02.R',
|
||||
'rPinky2'
|
||||
],
|
||||
'pinkie_3_r': [
|
||||
'mixamorig:RightHandPinky3', 'mixamorig_RightHandPinky3',
|
||||
'ORG-f_pinky.03.R', 'f_pinky.03.R',
|
||||
'rPinky3'
|
||||
],
|
||||
|
||||
'left_eye': [
|
||||
'mixamorig:LeftEye', 'mixamorig_LeftEye',
|
||||
'ORG-eye.L', 'eye.L',
|
||||
'lEye', 'Eye.L'
|
||||
],
|
||||
'right_eye': [
|
||||
'mixamorig:RightEye', 'mixamorig_RightEye',
|
||||
'ORG-eye.R', 'eye.R',
|
||||
'rEye', 'Eye.R'
|
||||
]
|
||||
}
|
||||
|
||||
for category, mappings in non_standard_mappings.items():
|
||||
if category in bone_names:
|
||||
bone_names[category].extend(mappings)
|
||||
else:
|
||||
bone_names[category] = mappings
|
||||
|
||||
|
||||
# Since data set is very poisoned by bone names that aren't simplified (And as such will not map properly using the function) we will just force convert them to the proper format at the end here. - @989onan
|
||||
for standard, mappings in bone_names.items():
|
||||
for i in range(len(mappings)):
|
||||
bone_names[standard][i] = simplify_bonename(mappings[i])
|
||||
|
||||
# Create reverse lookup dictionary (conversion/translation)
|
||||
reverse_bone_lookup = {}
|
||||
for preferred_name, name_list in bone_names.items():
|
||||
for name in name_list:
|
||||
reverse_bone_lookup[name] = preferred_name
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
# GPL License
|
||||
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
from .dictionaries import bone_names, reverse_bone_lookup, simplify_bonename
|
||||
from .logging_setup import logger
|
||||
|
||||
# Enhanced dictionaries for comprehensive translation support
|
||||
|
||||
# Shapekey/Morph name translations (Japanese to English)
|
||||
shapekey_names: Dict[str, List[str]] = {
|
||||
# Basic facial expressions
|
||||
"neutral": ["ニュートラル", "中立", "通常", "普通", "デフォルト", "basis"],
|
||||
"smile": ["笑顔", "スマイル", "えがお", "笑い", "にこり", "ほほえみ", "smile", "happy"],
|
||||
"angry": ["怒り", "怒る", "アングリー", "いかり", "おこり", "むかつき", "angry", "mad"],
|
||||
"sad": ["悲しい", "かなしい", "悲哀", "サッド", "sad", "sorrow"],
|
||||
"surprised": ["驚き", "びっくり", "おどろき", "サプライズ", "surprised", "shock"],
|
||||
"disgusted": ["嫌悪", "いやがり", "きもち悪い", "disgusted"],
|
||||
"fearful": ["恐怖", "怖い", "こわい", "恐れ", "fearful", "scared"],
|
||||
"blink": ["瞬き", "まばたき", "ブリンク", "目閉じ", "blink", "eyeclose"],
|
||||
"wink_left": ["ウィンク左", "左目ウィンク", "ひだりめうぃんく", "winkleft", "wink_l"],
|
||||
"wink_right": ["ウィンク右", "右目ウィンク", "みぎめうぃんく", "winkright", "wink_r"],
|
||||
"eye_close": ["目閉じ", "目を閉じる", "めとじ", "eyeclose", "closedeyes"],
|
||||
"eye_wide": ["目見開き", "目を見開く", "びっくり目", "eyewide", "wideeyes"],
|
||||
"eye_narrow": ["細目", "目細め", "ほそめ", "eyenarrow", "narroweyes"],
|
||||
"mouth_open": ["口開け", "口を開ける", "くちあけ", "mouthopen", "openmouth"],
|
||||
"mouth_smile": ["口角上げ", "口笑顔", "くちえがお", "mouthsmile"],
|
||||
"mouth_frown": ["口角下げ", "への字口", "くちしかめ", "mouthfrown"],
|
||||
"mouth_pout": ["すぼめ口", "とがらせ口", "mouthpout"],
|
||||
"eyebrow_up": ["眉上げ", "眉毛上げ", "まゆあげ", "eyebrowup", "raiseeyebrow"],
|
||||
"eyebrow_down": ["眉下げ", "眉寄せ", "まゆさげ", "eyebrowdown", "lowereyebrow"],
|
||||
"eyebrow_angry": ["怒り眉", "眉怒り", "まゆいかり", "angrybrow"],
|
||||
"cheek_puff": ["頬膨らまし", "ほほふくらまし", "cheekpuff"],
|
||||
"cheek_suck": ["頬すぼめ", "ほほすぼめ", "cheeksuck"],
|
||||
"joy": ["喜び", "よろこび", "ジョイ", "joy", "happiness"],
|
||||
"contempt": ["軽蔑", "けいべつ", "contempt"],
|
||||
"confusion": ["困惑", "こんわく", "confusion", "confused"],
|
||||
"concentration": ["集中", "しゅうちゅう", "concentration", "focused"],
|
||||
|
||||
# VRC Visemes
|
||||
"viseme_sil": ["無音", "むおん", "サイレンス", "silence", "sil"],
|
||||
"viseme_aa": ["あ", "aa", "mouth_a"],
|
||||
"viseme_ih": ["い", "ih", "mouth_i"],
|
||||
"viseme_ou": ["う", "ou", "mouth_u"],
|
||||
"viseme_e": ["え", "e", "mouth_e"],
|
||||
"viseme_oh": ["お", "oh", "mouth_o"],
|
||||
"viseme_ch": ["ち", "ch"],
|
||||
"viseme_dd": ["だ", "dd"],
|
||||
"viseme_ff": ["ふ", "ff"],
|
||||
"viseme_kk": ["か", "kk"],
|
||||
"viseme_nn": ["ん", "nn"],
|
||||
"viseme_pp": ["ぱ", "pp"],
|
||||
"viseme_rr": ["ら", "rr"],
|
||||
"viseme_ss": ["さ", "ss"],
|
||||
"viseme_th": ["た", "th"],
|
||||
|
||||
"basis": ["基本", "きほん", "ベース", "base", "basis", "default"],
|
||||
"reset": ["リセット", "初期化", "しょきか", "reset", "clear"],
|
||||
}
|
||||
|
||||
# Material name translations (Japanese to English)
|
||||
material_names: Dict[str, List[str]] = {
|
||||
# Basic materials
|
||||
"skin": ["肌", "はだ", "皮膚", "ひふ", "スキン", "skin", "flesh"],
|
||||
"hair": ["髪", "かみ", "毛髪", "もうはつ", "ヘア", "hair"],
|
||||
"eyes": ["目", "め", "眼", "がん", "アイ", "eye", "iris"],
|
||||
"eyebrow": ["眉", "まゆ", "眉毛", "まゆげ", "eyebrow", "brow"],
|
||||
"eyelash": ["まつ毛", "まつげ", "睫毛", "eyelash", "lash"],
|
||||
"teeth": ["歯", "は", "歯列", "しれつ", "tooth", "teeth"],
|
||||
"tongue": ["舌", "した", "tongue"],
|
||||
"nails": ["爪", "つめ", "nail", "nails"],
|
||||
"shirt": ["シャツ", "上着", "うわぎ", "shirt", "top"],
|
||||
"pants": ["パンツ", "ズボン", "下着", "したぎ", "pants", "trousers"],
|
||||
"skirt": ["スカート", "skirt"],
|
||||
"dress": ["ドレス", "ワンピース", "dress"],
|
||||
"shoes": ["靴", "くつ", "シューズ", "shoe", "shoes"],
|
||||
"socks": ["靴下", "くつした", "ソックス", "sock", "socks"],
|
||||
"gloves": ["手袋", "てぶくろ", "グローブ", "glove", "gloves"],
|
||||
"hat": ["帽子", "ぼうし", "ハット", "hat", "cap"],
|
||||
"jacket": ["ジャケット", "上着", "うわぎ", "jacket", "coat"],
|
||||
"underwear": ["下着", "したぎ", "パンティー", "underwear", "panties"],
|
||||
"bra": ["ブラ", "ブラジャー", "胸当て", "bra", "brassiere"],
|
||||
"glasses": ["眼鏡", "めがね", "メガネ", "glasses", "spectacles"],
|
||||
"earring": ["イヤリング", "耳飾り", "みみかざり", "earring"],
|
||||
"necklace": ["ネックレス", "首飾り", "くびかざり", "necklace"],
|
||||
"bracelet": ["ブレスレット", "腕輪", "うでわ", "bracelet"],
|
||||
"ring": ["指輪", "ゆびわ", "リング", "ring"],
|
||||
"watch": ["時計", "とけい", "ウォッチ", "watch"],
|
||||
"bag": ["鞄", "かばん", "バッグ", "bag", "purse"],
|
||||
"belt": ["ベルト", "帯", "おび", "belt"],
|
||||
"transparent": ["透明", "とうめい", "クリア", "transparent", "clear"],
|
||||
"metal": ["金属", "きんぞく", "メタル", "metal"],
|
||||
"fabric": ["布", "ぬの", "生地", "きじ", "fabric", "cloth"],
|
||||
"leather": ["革", "かわ", "皮", "ひ", "レザー", "leather"],
|
||||
"plastic": ["プラスチック", "プラ", "plastic"],
|
||||
"glass": ["ガラス", "硝子", "glass"],
|
||||
"rubber": ["ゴム", "ラバー", "rubber"],
|
||||
"wood": ["木", "き", "木材", "もくざい", "wood", "wooden"],
|
||||
"diffuse": ["ディフューズ", "基本色", "きほんしょく", "diffuse", "albedo"],
|
||||
"normal": ["ノーマル", "法線", "ほうせん", "normal", "bump"],
|
||||
"specular": ["スペキュラー", "反射", "はんしゃ", "specular", "reflection"],
|
||||
"emission": ["発光", "はっこう", "エミッション", "emission", "glow"],
|
||||
"roughness": ["粗さ", "あらさ", "ラフネス", "roughness"],
|
||||
"metallic": ["メタリック", "金属性", "きんぞくせい", "metallic"],
|
||||
"subsurface": ["表面下散乱", "サブサーフェス", "subsurface", "sss"],
|
||||
|
||||
# Common naming patterns
|
||||
"main": ["メイン", "主要", "しゅよう", "main", "primary"],
|
||||
"sub": ["サブ", "副", "ふく", "sub", "secondary"],
|
||||
"detail": ["詳細", "しょうさい", "ディテール", "detail"],
|
||||
"shadow": ["影", "かげ", "シャドウ", "shadow"],
|
||||
"highlight": ["ハイライト", "強調", "きょうちょう", "highlight"],
|
||||
}
|
||||
|
||||
# Object name translations (Japanese to English)
|
||||
object_names: Dict[str, List[str]] = {
|
||||
|
||||
"body": ["体", "からだ", "身体", "しんたい", "ボディ", "body", "torso"],
|
||||
"head": ["頭", "あたま", "ヘッド", "head"],
|
||||
"face": ["顔", "かお", "フェイス", "face"],
|
||||
"neck": ["首", "くび", "ネック", "neck"],
|
||||
"chest": ["胸", "むね", "チェスト", "chest", "breast"],
|
||||
"back": ["背中", "せなか", "バック", "back"],
|
||||
"waist": ["腰", "こし", "ウエスト", "waist"],
|
||||
"hip": ["腰", "こし", "ヒップ", "hip"],
|
||||
"arm": ["腕", "うで", "アーム", "arm"],
|
||||
"hand": ["手", "て", "ハンド", "hand"],
|
||||
"finger": ["指", "ゆび", "フィンガー", "finger"],
|
||||
"leg": ["足", "あし", "脚", "レッグ", "leg"],
|
||||
"foot": ["足", "あし", "フット", "foot"],
|
||||
"toe": ["つま先", "つまさき", "トゥ", "toe"],
|
||||
"clothing": ["服", "ふく", "衣服", "いふく", "クロージング", "clothing", "clothes"],
|
||||
"outfit": ["服装", "ふくそう", "アウトフィット", "outfit"],
|
||||
"accessory": ["アクセサリー", "装身具", "そうしんぐ", "accessory"],
|
||||
"decoration": ["装飾", "そうしょく", "デコレーション", "decoration"],
|
||||
"hair_front": ["前髪", "まえがみ", "フロント髪", "hairfront"],
|
||||
"hair_back": ["後ろ髪", "うしろがみ", "バック髪", "hairback"],
|
||||
"hair_side": ["横髪", "よこがみ", "サイド髪", "hairside"],
|
||||
"ponytail": ["ポニーテール", "一つ結び", "ひとつむすび", "ponytail"],
|
||||
"twintail": ["ツインテール", "二つ結び", "ふたつむすび", "twintail"],
|
||||
"ahoge": ["あほ毛", "アホ毛", "はね毛", "ahoge", "antenna"],
|
||||
"eyeball": ["眼球", "がんきゅう", "目玉", "めだま", "eyeball"],
|
||||
"pupil": ["瞳", "ひとみ", "瞳孔", "どうこう", "pupil"],
|
||||
"iris": ["虹彩", "こうさい", "アイリス", "iris"],
|
||||
"eyelid": ["まぶた", "眼瞼", "がんけん", "eyelid"],
|
||||
"nose": ["鼻", "はな", "ノーズ", "nose"],
|
||||
"mouth": ["口", "くち", "マウス", "mouth"],
|
||||
"lip": ["唇", "くちびる", "リップ", "lip"],
|
||||
"ear": ["耳", "みみ", "イヤー", "ear"],
|
||||
|
||||
# Common object suffixes
|
||||
"left": ["左", "ひだり", "レフト", "left", "l"],
|
||||
"right": ["右", "みぎ", "ライト", "right", "r"],
|
||||
"upper": ["上", "うえ", "アッパー", "upper", "top"],
|
||||
"lower": ["下", "した", "ロワー", "lower", "bottom"],
|
||||
"inner": ["内", "うち", "インナー", "inner", "inside"],
|
||||
"outer": ["外", "そと", "アウター", "outer", "outside"],
|
||||
"front": ["前", "まえ", "フロント", "front"],
|
||||
"back": ["後ろ", "うしろ", "バック", "back", "rear"],
|
||||
}
|
||||
|
||||
# Physics object names (for MMD rigid bodies and joints)
|
||||
physics_names: Dict[str, List[str]] = {
|
||||
# Rigid body types
|
||||
"rigidbody": ["剛体", "ごうたい", "リジッドボディ", "rigidbody", "rigid"],
|
||||
"joint": ["ジョイント", "関節", "かんせつ", "joint", "constraint"],
|
||||
"collision": ["当たり判定", "あたりはんてい", "コリジョン", "collision"],
|
||||
"hair_physics": ["髪物理", "かみぶつり", "ヘアフィジックス", "hairphys"],
|
||||
"hair_root": ["髪根元", "かみねもと", "ヘアルート", "hairroot"],
|
||||
"hair_tip": ["髪先", "かみさき", "ヘアティップ", "hairtip"],
|
||||
"cloth_physics": ["布物理", "ぬのぶつり", "クロスフィジックス", "clothphys"],
|
||||
"skirt_physics": ["スカート物理", "スカートフィジックス", "skirtphys"],
|
||||
"breast_physics": ["胸物理", "むねぶつり", "ブレストフィジックス", "breastphys"],
|
||||
"breast_root": ["胸根元", "むねねもと", "ブレストルート", "breastroot"],
|
||||
"breast_tip": ["胸先", "むねさき", "ブレストティップ", "breasttip"],
|
||||
}
|
||||
|
||||
# MMD bone name patterns (for detection)
|
||||
mmd_bone_patterns: List[str] = [
|
||||
# Japanese bone names
|
||||
'全ての親', 'センター', '上半身', '下半身', '首', '頭',
|
||||
'右腕', '左腕', '右ひじ', '左ひじ', '右手首', '左手首',
|
||||
'右足', '左足', '右ひざ', '左ひざ', '右足首', '左足首',
|
||||
'両目', '左目', '右目', '右肩', '左肩',
|
||||
# English bone names (common in MMD exports)
|
||||
'center', 'groove', 'waist', 'upperbody', 'upperbody2', 'lowerbody',
|
||||
'neck', 'head',
|
||||
'shoulder_r', 'shoulder_l', 'arm_r', 'arm_l',
|
||||
'elbow_r', 'elbow_l', 'wrist_r', 'wrist_l',
|
||||
'leg_r', 'leg_l', 'knee_r', 'knee_l',
|
||||
'ankle_r', 'ankle_l', 'toe_r', 'toe_l',
|
||||
# Mixed/Romanized patterns
|
||||
'센터', 'グルーブ', 'ウエスト',
|
||||
# Common MMD suffixes
|
||||
'_r', '_l', '.r', '.l'
|
||||
]
|
||||
|
||||
# MMD to Unity bone mapping
|
||||
# Maps MMD bone names (after English translation) to Unity humanoid bone names
|
||||
mmd_to_unity_bone_map: Dict[str, Optional[str]] = {
|
||||
# Root and core
|
||||
"ParentNode": None, # Remove this
|
||||
"Center": "Hips",
|
||||
"センター": "Hips",
|
||||
"Groove": None, # Remove this
|
||||
"グルーブ": None,
|
||||
"Waist": None, # Will be merged into Hips
|
||||
|
||||
# Spine chain
|
||||
"LowerBody": "Hips",
|
||||
"下半身": "Hips",
|
||||
"UpperBody": "Spine",
|
||||
"上半身": "Spine",
|
||||
"UpperBody2": "Chest",
|
||||
"上半身2": "Chest",
|
||||
"Neck": "Neck",
|
||||
"首": "Neck",
|
||||
"Head": "Head",
|
||||
"頭": "Head",
|
||||
|
||||
# Right leg
|
||||
"RightLeg": "Right leg",
|
||||
"右足": "Right leg",
|
||||
"RightLegD": None, # Remove D variant
|
||||
"RightKnee": "Right knee",
|
||||
"右ひざ": "Right knee",
|
||||
"RightAnkle": "Right ankle",
|
||||
"右足首": "Right ankle",
|
||||
"RightToe": "Right toe",
|
||||
"右つま先": "Right toe",
|
||||
|
||||
# Left leg
|
||||
"LeftLeg": "Left leg",
|
||||
"左足": "Left leg",
|
||||
"LeftLegD": None, # Remove D variant
|
||||
"LeftKnee": "Left knee",
|
||||
"左ひざ": "Left knee",
|
||||
"LeftAnkle": "Left ankle",
|
||||
"左足首": "Left ankle",
|
||||
"LeftToe": "Left toe",
|
||||
"左つま先": "Left toe",
|
||||
|
||||
# Right arm
|
||||
"RightShoulder": "Right shoulder",
|
||||
"右肩": "Right shoulder",
|
||||
"RightArm": "Right arm",
|
||||
"右腕": "Right arm",
|
||||
"RightElbow": "Right elbow",
|
||||
"右ひじ": "Right elbow",
|
||||
"RightWrist": "Right wrist",
|
||||
"右手首": "Right wrist",
|
||||
|
||||
# Left arm
|
||||
"LeftShoulder": "Left shoulder",
|
||||
"左肩": "Left shoulder",
|
||||
"LeftArm": "Left arm",
|
||||
"左腕": "Left arm",
|
||||
"LeftElbow": "Left elbow",
|
||||
"左ひじ": "Left elbow",
|
||||
"LeftWrist": "Left wrist",
|
||||
"左手首": "Left wrist",
|
||||
|
||||
# Cancel/Helper bones (remove these)
|
||||
"WaistCancelRight": None,
|
||||
"WaistCancelLeft": None,
|
||||
"LegIKParentRight": None,
|
||||
"LegIKParentLeft": None,
|
||||
}
|
||||
|
||||
# Unity humanoid bone hierarchy
|
||||
# Defines parent-child relationships for Unity standard
|
||||
unity_bone_hierarchy: Dict[str, Optional[str]] = {
|
||||
"Hips": None, # Root bone
|
||||
"Spine": "Hips",
|
||||
"Chest": "Spine",
|
||||
"Neck": "Chest",
|
||||
"Head": "Neck",
|
||||
|
||||
# Arms
|
||||
"Left shoulder": "Chest",
|
||||
"Left arm": "Left shoulder",
|
||||
"Left elbow": "Left arm",
|
||||
"Left wrist": "Left elbow",
|
||||
|
||||
"Right shoulder": "Chest",
|
||||
"Right arm": "Right shoulder",
|
||||
"Right elbow": "Right arm",
|
||||
"Right wrist": "Right elbow",
|
||||
|
||||
# Legs
|
||||
"Left leg": "Hips",
|
||||
"Left knee": "Left leg",
|
||||
"Left ankle": "Left knee",
|
||||
"Left toe": "Left ankle",
|
||||
|
||||
"Right leg": "Hips",
|
||||
"Right knee": "Right leg",
|
||||
"Right ankle": "Right knee",
|
||||
"Right toe": "Right ankle",
|
||||
}
|
||||
|
||||
# Create reverse lookup dictionaries
|
||||
reverse_shapekey_lookup: Dict[str, str] = {}
|
||||
reverse_material_lookup: Dict[str, str] = {}
|
||||
reverse_object_lookup: Dict[str, str] = {}
|
||||
reverse_physics_lookup: Dict[str, str] = {}
|
||||
|
||||
def _build_reverse_lookups():
|
||||
"""Build reverse lookup dictionaries for fast translation"""
|
||||
global reverse_shapekey_lookup, reverse_material_lookup, reverse_object_lookup, reverse_physics_lookup
|
||||
|
||||
for standard_name, variations in shapekey_names.items():
|
||||
for variation in variations:
|
||||
simplified = simplify_bonename(variation)
|
||||
reverse_shapekey_lookup[simplified] = standard_name
|
||||
|
||||
for standard_name, variations in material_names.items():
|
||||
for variation in variations:
|
||||
simplified = simplify_bonename(variation)
|
||||
reverse_material_lookup[simplified] = standard_name
|
||||
|
||||
for standard_name, variations in object_names.items():
|
||||
for variation in variations:
|
||||
simplified = simplify_bonename(variation)
|
||||
reverse_object_lookup[simplified] = standard_name
|
||||
|
||||
for standard_name, variations in physics_names.items():
|
||||
for variation in variations:
|
||||
simplified = simplify_bonename(variation)
|
||||
reverse_physics_lookup[simplified] = standard_name
|
||||
|
||||
_build_reverse_lookups()
|
||||
|
||||
|
||||
class EnhancedDictionaryTranslator:
|
||||
"""Enhanced dictionary translator with support for bones, shapekeys, materials, and objects"""
|
||||
|
||||
def __init__(self):
|
||||
self.translation_stats = {
|
||||
'bones': 0,
|
||||
'shapekeys': 0,
|
||||
'materials': 0,
|
||||
'objects': 0,
|
||||
'physics': 0,
|
||||
'total': 0
|
||||
}
|
||||
|
||||
def translate_bone_name(self, name: str) -> Optional[str]:
|
||||
"""Translate bone name using existing bone dictionary"""
|
||||
simplified = simplify_bonename(name)
|
||||
if simplified in reverse_bone_lookup:
|
||||
self.translation_stats['bones'] += 1
|
||||
self.translation_stats['total'] += 1
|
||||
return reverse_bone_lookup[simplified]
|
||||
return None
|
||||
|
||||
def translate_shapekey_name(self, name: str) -> Optional[str]:
|
||||
"""Translate shapekey/morph name using shapekey dictionary"""
|
||||
simplified = simplify_bonename(name)
|
||||
if simplified in reverse_shapekey_lookup:
|
||||
self.translation_stats['shapekeys'] += 1
|
||||
self.translation_stats['total'] += 1
|
||||
return reverse_shapekey_lookup[simplified]
|
||||
return None
|
||||
|
||||
def translate_material_name(self, name: str) -> Optional[str]:
|
||||
"""Translate material name using material dictionary"""
|
||||
simplified = simplify_bonename(name)
|
||||
if simplified in reverse_material_lookup:
|
||||
self.translation_stats['materials'] += 1
|
||||
self.translation_stats['total'] += 1
|
||||
return reverse_material_lookup[simplified]
|
||||
return None
|
||||
|
||||
def translate_object_name(self, name: str) -> Optional[str]:
|
||||
"""Translate object name using object dictionary"""
|
||||
simplified = simplify_bonename(name)
|
||||
if simplified in reverse_object_lookup:
|
||||
self.translation_stats['objects'] += 1
|
||||
self.translation_stats['total'] += 1
|
||||
return reverse_object_lookup[simplified]
|
||||
return None
|
||||
|
||||
def translate_physics_name(self, name: str) -> Optional[str]:
|
||||
"""Translate physics object name using physics dictionary"""
|
||||
simplified = simplify_bonename(name)
|
||||
if simplified in reverse_physics_lookup:
|
||||
self.translation_stats['physics'] += 1
|
||||
self.translation_stats['total'] += 1
|
||||
return reverse_physics_lookup[simplified]
|
||||
return None
|
||||
|
||||
def translate_name(self, name: str, category: str = "auto") -> Tuple[Optional[str], str]:
|
||||
"""
|
||||
Translate name with automatic category detection or specified category
|
||||
Returns (translated_name, detected_category)
|
||||
"""
|
||||
if not name or not name.strip():
|
||||
return None, "none"
|
||||
|
||||
if category == "bones":
|
||||
result = self.translate_bone_name(name)
|
||||
return (result, "bones") if result else (None, "unknown")
|
||||
elif category == "shapekeys":
|
||||
result = self.translate_shapekey_name(name)
|
||||
return (result, "shapekeys") if result else (None, "unknown")
|
||||
elif category == "materials":
|
||||
result = self.translate_material_name(name)
|
||||
return (result, "materials") if result else (None, "unknown")
|
||||
elif category == "objects":
|
||||
result = self.translate_object_name(name)
|
||||
return (result, "objects") if result else (None, "unknown")
|
||||
elif category == "physics":
|
||||
result = self.translate_physics_name(name)
|
||||
return (result, "physics") if result else (None, "unknown")
|
||||
elif category == "auto":
|
||||
# Try all categories in order of likelihood
|
||||
for cat_name, translate_func in [
|
||||
("bones", self.translate_bone_name),
|
||||
("shapekeys", self.translate_shapekey_name),
|
||||
("materials", self.translate_material_name),
|
||||
("objects", self.translate_object_name),
|
||||
("physics", self.translate_physics_name)
|
||||
]:
|
||||
result = translate_func(name)
|
||||
if result:
|
||||
return result, cat_name
|
||||
return None, "unknown"
|
||||
else:
|
||||
return None, "invalid_category"
|
||||
|
||||
def get_statistics(self) -> Dict[str, int]:
|
||||
"""Get translation statistics"""
|
||||
return self.translation_stats.copy()
|
||||
|
||||
def reset_statistics(self) -> None:
|
||||
"""Reset translation statistics"""
|
||||
for key in self.translation_stats:
|
||||
self.translation_stats[key] = 0
|
||||
|
||||
|
||||
# Global enhanced dictionary translator instance
|
||||
_enhanced_translator: Optional[EnhancedDictionaryTranslator] = None
|
||||
|
||||
|
||||
def get_enhanced_translator() -> EnhancedDictionaryTranslator:
|
||||
"""Get the global enhanced dictionary translator"""
|
||||
global _enhanced_translator
|
||||
if _enhanced_translator is None:
|
||||
_enhanced_translator = EnhancedDictionaryTranslator()
|
||||
return _enhanced_translator
|
||||
|
||||
|
||||
def get_all_dictionary_names() -> Dict[str, Dict[str, List[str]]]:
|
||||
"""Get all dictionary names for reference"""
|
||||
return {
|
||||
"bones": bone_names,
|
||||
"shapekeys": shapekey_names,
|
||||
"materials": material_names,
|
||||
"objects": object_names,
|
||||
"physics": physics_names
|
||||
}
|
||||
|
||||
|
||||
def add_custom_translation(category: str, standard_name: str, variations: List[str]) -> bool:
|
||||
"""Add custom translation to the dictionaries"""
|
||||
try:
|
||||
if category == "bones":
|
||||
if standard_name not in bone_names:
|
||||
bone_names[standard_name] = []
|
||||
bone_names[standard_name].extend(variations)
|
||||
elif category == "shapekeys":
|
||||
if standard_name not in shapekey_names:
|
||||
shapekey_names[standard_name] = []
|
||||
shapekey_names[standard_name].extend(variations)
|
||||
elif category == "materials":
|
||||
if standard_name not in material_names:
|
||||
material_names[standard_name] = []
|
||||
material_names[standard_name].extend(variations)
|
||||
elif category == "objects":
|
||||
if standard_name not in object_names:
|
||||
object_names[standard_name] = []
|
||||
object_names[standard_name].extend(variations)
|
||||
elif category == "physics":
|
||||
if standard_name not in physics_names:
|
||||
physics_names[standard_name] = []
|
||||
physics_names[standard_name].extend(variations)
|
||||
else:
|
||||
return False
|
||||
|
||||
_build_reverse_lookups()
|
||||
logger.info(f"Added custom translation for {category}: {standard_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add custom translation: {e}")
|
||||
return False
|
||||
@@ -1,271 +0,0 @@
|
||||
import bpy
|
||||
import struct
|
||||
import mathutils
|
||||
import traceback
|
||||
import os
|
||||
|
||||
from bpy.types import Material, Operator, Context, Object, Image, Mesh, MeshUVLoopLayer, Float2AttributeValue, ShaderNodeTexImage, ShaderNodeBsdfPrincipled, ShaderNodeOutputMaterial
|
||||
|
||||
def read_pmd_header(file):
|
||||
# Read PMD header information
|
||||
magic = file.read(3)
|
||||
if magic != b'Pmd':
|
||||
raise ValueError("Invalid PMD file")
|
||||
|
||||
version = struct.unpack('<f', file.read(4))[0]
|
||||
|
||||
# Read additional header fields
|
||||
model_name = file.read(20).decode('shift-jis').rstrip('\0')
|
||||
comment = file.read(256).decode('shift-jis').rstrip('\0')
|
||||
|
||||
return version, model_name, comment
|
||||
|
||||
def read_pmd_vertex(file):
|
||||
# Read PMD vertex information
|
||||
position = struct.unpack('<3f', file.read(12))
|
||||
normal = struct.unpack('<3f', file.read(12))
|
||||
uv = struct.unpack('<2f', file.read(8))
|
||||
bone_indices = list(struct.unpack('<2H', file.read(4)))
|
||||
bone_weights = struct.unpack('<b', file.read(1))[0] / 100
|
||||
edge_flag = struct.unpack('<b', file.read(1))[0]
|
||||
|
||||
return position, normal, uv, bone_indices, bone_weights, edge_flag
|
||||
|
||||
def read_pmd_material(file):
|
||||
# Read PMD material information
|
||||
diffuse_color = struct.unpack('<4f', file.read(16))
|
||||
specular_color = struct.unpack('<3f', file.read(12))
|
||||
specular_intensity = struct.unpack('<f', file.read(4))[0]
|
||||
ambient_color = struct.unpack('<3f', file.read(12))
|
||||
toon_index = struct.unpack('<b', file.read(1))[0]
|
||||
edge_flag = struct.unpack('<b', file.read(1))[0]
|
||||
vertex_count = struct.unpack('<i', file.read(4))[0]
|
||||
texture_file_name = file.read(20).decode('shift-jis').rstrip('\0')
|
||||
|
||||
return diffuse_color, specular_color, specular_intensity, ambient_color, toon_index, edge_flag, vertex_count, texture_file_name
|
||||
|
||||
def read_pmd_bone(file):
|
||||
# Read PMD bone information
|
||||
bone_name = file.read(20).decode('shift-jis').rstrip('\0')
|
||||
parent_bone_index = struct.unpack('<h', file.read(2))[0]
|
||||
tail_pos_bone_index = struct.unpack('<h', file.read(2))[0]
|
||||
bone_type = struct.unpack('<b', file.read(1))[0]
|
||||
ik_parent_bone_index = struct.unpack('<h', file.read(2))[0]
|
||||
bone_head_pos = struct.unpack('<3f', file.read(12))
|
||||
|
||||
return bone_name, parent_bone_index, tail_pos_bone_index, bone_type, ik_parent_bone_index, bone_head_pos
|
||||
|
||||
def read_pmd_ik(file):
|
||||
# Read PMD IK information
|
||||
ik_bone_index = struct.unpack('<h', file.read(2))[0]
|
||||
ik_target_bone_index = struct.unpack('<h', file.read(2))[0]
|
||||
ik_chain_length = struct.unpack('<b', file.read(1))[0]
|
||||
iterations = struct.unpack('<h', file.read(2))[0]
|
||||
limit_angle = struct.unpack('<f', file.read(4))[0]
|
||||
|
||||
ik_child_bone_indices = []
|
||||
for _ in range(ik_chain_length):
|
||||
ik_child_bone_index = struct.unpack('<h', file.read(2))[0]
|
||||
ik_child_bone_indices.append(ik_child_bone_index)
|
||||
|
||||
return ik_bone_index, ik_target_bone_index, ik_chain_length, iterations, limit_angle, ik_child_bone_indices
|
||||
|
||||
def read_pmd_morph(file):
|
||||
# Read PMD morph information
|
||||
morph_name = file.read(20).decode('shift-jis').rstrip('\0')
|
||||
morph_vertex_count = struct.unpack('<i', file.read(4))[0]
|
||||
morph_type = struct.unpack('<b', file.read(1))[0]
|
||||
|
||||
morph_vertices = []
|
||||
for _ in range(morph_vertex_count):
|
||||
morph_vertex_index = struct.unpack('<i', file.read(4))[0]
|
||||
morph_vertex_pos = struct.unpack('<3f', file.read(12))
|
||||
morph_vertices.append((morph_vertex_index, morph_vertex_pos))
|
||||
|
||||
return morph_name, morph_vertex_count, morph_type, morph_vertices
|
||||
|
||||
def import_pmd(filepath):
|
||||
try:
|
||||
with open(filepath, 'rb') as file:
|
||||
version, model_name, comment = read_pmd_header(file)
|
||||
|
||||
# Read vertices
|
||||
vertex_count = struct.unpack('<i', file.read(4))[0]
|
||||
vertices = []
|
||||
for _ in range(vertex_count):
|
||||
position, normal, uv, bone_indices, bone_weights, edge_flag = read_pmd_vertex(file)
|
||||
vertices.append((position, normal, uv, bone_indices, bone_weights, edge_flag))
|
||||
|
||||
# Read faces
|
||||
face_count = struct.unpack('<i', file.read(4))[0]
|
||||
faces = []
|
||||
for _ in range(face_count // 3):
|
||||
face_indices = struct.unpack('<3i', file.read(12))
|
||||
faces.append(face_indices)
|
||||
|
||||
# Read materials
|
||||
material_count = struct.unpack('<i', file.read(4))[0]
|
||||
materials = []
|
||||
for _ in range(material_count):
|
||||
diffuse_color, specular_color, specular_intensity, ambient_color, toon_index, edge_flag, vertex_count, texture_file_name = read_pmd_material(file)
|
||||
materials.append((diffuse_color, specular_color, specular_intensity, ambient_color, toon_index, edge_flag, vertex_count, texture_file_name))
|
||||
|
||||
# Read bones
|
||||
bone_count = struct.unpack('<h', file.read(2))[0]
|
||||
bones = []
|
||||
for _ in range(bone_count):
|
||||
bone_name, parent_bone_index, tail_pos_bone_index, bone_type, ik_parent_bone_index, bone_head_pos = read_pmd_bone(file)
|
||||
bones.append((bone_name, parent_bone_index, tail_pos_bone_index, bone_type, ik_parent_bone_index, bone_head_pos))
|
||||
|
||||
# Read IKs
|
||||
ik_count = struct.unpack('<h', file.read(2))[0]
|
||||
iks = []
|
||||
for _ in range(ik_count):
|
||||
ik_bone_index, ik_target_bone_index, ik_chain_length, iterations, limit_angle, ik_child_bone_indices = read_pmd_ik(file)
|
||||
iks.append((ik_bone_index, ik_target_bone_index, ik_chain_length, iterations, limit_angle, ik_child_bone_indices))
|
||||
|
||||
# Read morphs
|
||||
morph_count = struct.unpack('<h', file.read(2))[0]
|
||||
morphs = []
|
||||
for _ in range(morph_count):
|
||||
morph_name, morph_vertex_count, morph_type, morph_vertices = read_pmd_morph(file)
|
||||
morphs.append((morph_name, morph_vertex_count, morph_type, morph_vertices))
|
||||
|
||||
# Create Blender objects and assign PMD data
|
||||
mesh = bpy.data.meshes.new(model_name)
|
||||
mesh.from_pydata([v[0] for v in vertices], [], faces)
|
||||
mesh.update()
|
||||
|
||||
obj = bpy.data.objects.new(model_name, mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
|
||||
# Assign vertex normals
|
||||
for i, vertex in enumerate(vertices):
|
||||
mesh.vertices[i].normal = vertex[1]
|
||||
|
||||
# Assign UV coordinates
|
||||
uv_layer = mesh.uv_layers.new()
|
||||
for i, vertex in enumerate(vertices):
|
||||
uv_layer.data[i].uv = vertex[2]
|
||||
|
||||
# Assign materials
|
||||
for material_data in materials:
|
||||
material: bpy.types.Material
|
||||
if f"Material_{len(mesh.materials)}" in bpy.data.materials:
|
||||
material = bpy.data.materials[f"Material_{len(mesh.materials)}"]
|
||||
else:
|
||||
material = bpy.data.materials.new(f"Material_{len(mesh.materials)}")
|
||||
|
||||
material.use_nodes = True
|
||||
for node in [node for node in material.node_tree.nodes]:
|
||||
material.node_tree.nodes.remove(node)
|
||||
|
||||
principled_node: ShaderNodeBsdfPrincipled = material.node_tree.nodes.new(type="ShaderNodeBsdfPrincipled")
|
||||
principled_node.location.x = 7.29706335067749
|
||||
principled_node.location.y = 298.918212890625
|
||||
principled_node.inputs["Base Color"].default_value = material_data[0]
|
||||
principled_node.inputs["Specular Tint"].default_value = [material_data[1][0],material_data[1][1],material_data[1][2],1.0]
|
||||
principled_node.inputs["Specular IOR Level"].default_value = material_data[2]
|
||||
|
||||
output_node: ShaderNodeOutputMaterial = material.node_tree.nodes.new(type="ShaderNodeOutputMaterial")
|
||||
output_node.location.x = 297.29705810546875
|
||||
output_node.location.y = 298.918212890625
|
||||
|
||||
albedo_node: ShaderNodeTexImage = material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
||||
albedo_node.location.x = -588.6177978515625
|
||||
albedo_node.location.y = 414.1948547363281
|
||||
|
||||
if texture_file_name in bpy.data.images:
|
||||
albedo_node.image = bpy.data.images[texture_file_name]
|
||||
else:
|
||||
albedo_node.image = bpy.data.images.new(name=texture_file_name,width=32,height=32)
|
||||
albedo_node.image.filepath = os.path.join(os.path.dirname(filepath),texture_file_name)
|
||||
albedo_node.image.source = 'FILE'
|
||||
albedo_node.image.reload()
|
||||
|
||||
|
||||
|
||||
material.node_tree.links.new(principled_node.inputs["Base Color"], albedo_node.outputs["Color"])
|
||||
material.node_tree.links.new(principled_node.inputs["Alpha"], albedo_node.outputs["Alpha"])
|
||||
material.node_tree.links.new(output_node.inputs["Surface"], principled_node.outputs["BSDF"])
|
||||
|
||||
#material.ambient = material_data[5] #TODO: this doesn't exist
|
||||
# Set other material properties based on the PMX data
|
||||
if not (material.name in mesh.materials):
|
||||
mesh.materials.append(material)
|
||||
|
||||
#surprised this works - @989onan
|
||||
end: int = cur_polygon_index+material_data[15]-1
|
||||
for face in mesh.polygons.items()[cur_polygon_index:end]:
|
||||
face[1].material_index = mesh.materials.find(material.name)
|
||||
|
||||
cur_polygon_index = cur_polygon_index+material_data[15]
|
||||
# Set other material properties based on the PMD data
|
||||
|
||||
# Create armature and assign bones
|
||||
armature = bpy.data.armatures.new(model_name + "_Armature")
|
||||
armature_obj = bpy.data.objects.new(model_name + "_Armature", armature)
|
||||
bpy.context.collection.objects.link(armature_obj)
|
||||
|
||||
bpy.context.view_layer.objects.active = armature_obj
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
for bone_data in bones:
|
||||
bone = armature.edit_bones.new(bone_data[0])
|
||||
bone.head = bone_data[5]
|
||||
|
||||
if bone_data[1] != -1:
|
||||
parent_bone = armature.edit_bones[bone_data[1]]
|
||||
bone.parent = parent_bone
|
||||
bone.tail = parent_bone.head
|
||||
else:
|
||||
bone.tail = bone.head + mathutils.Vector((0, 0.1, 0))
|
||||
|
||||
# Set other bone properties based on the PMD data
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Assign bone weights to the mesh
|
||||
for i, vertex in enumerate(vertices):
|
||||
for j in range(2):
|
||||
if vertex[3][j] != 65535:
|
||||
bone_name = bones[vertex[3][j]][0]
|
||||
weight = vertex[4] if j == 0 else 1 - vertex[4]
|
||||
|
||||
vertex_group = obj.vertex_groups.get(bone_name)
|
||||
if not vertex_group:
|
||||
vertex_group = obj.vertex_groups.new(name=bone_name)
|
||||
|
||||
vertex_group.add([i], weight, 'REPLACE')
|
||||
|
||||
# Assign IK constraints to bones
|
||||
for ik_data in iks:
|
||||
ik_bone = armature.bones[bones[ik_data[0]][0]]
|
||||
ik_target_bone = armature.bones[bones[ik_data[1]][0]]
|
||||
|
||||
ik_constraint = ik_bone.constraints.new('IK')
|
||||
ik_constraint.target = armature_obj
|
||||
ik_constraint.subtarget = ik_target_bone.name
|
||||
ik_constraint.chain_count = ik_data[2]
|
||||
ik_constraint.iterations = ik_data[3]
|
||||
ik_constraint.limit_mode = 'LIMITDIST_INSIDE'
|
||||
ik_constraint.limit_mode_max_x = ik_data[4]
|
||||
|
||||
# Assign morphs to the mesh
|
||||
for morph_data in morphs:
|
||||
morph_name = morph_data[0]
|
||||
morph_type = morph_data[2]
|
||||
|
||||
if morph_type == 0: # Vertex morph
|
||||
shape_key = obj.shape_key_add(name=morph_name)
|
||||
for vertex_data in morph_data[3]:
|
||||
vertex_index = vertex_data[0]
|
||||
vertex_offset = vertex_data[1]
|
||||
shape_key.data[vertex_index].co += mathutils.Vector(vertex_offset)
|
||||
|
||||
print(f"Successfully imported PMD file: {filepath}")
|
||||
print(f"Model Name: {model_name}")
|
||||
print(f"Comment: {comment}")
|
||||
except Exception:
|
||||
print(f"Error importing PMD file: {filepath}")
|
||||
print(f"Error details: {traceback.format_exc()}")
|
||||
@@ -1,861 +0,0 @@
|
||||
from io import BufferedReader
|
||||
import os
|
||||
import bpy
|
||||
import struct
|
||||
import traceback
|
||||
import mathutils
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
class PMXVertex:
|
||||
def __init__(self, position, normal, uv, bone_indices, bone_weights, edge_scale, additional_uvs):
|
||||
self.position = position
|
||||
self.normal = normal
|
||||
self.uv = uv
|
||||
self.bone_indices = bone_indices
|
||||
self.bone_weights = bone_weights
|
||||
self.edge_scale = edge_scale
|
||||
self.additional_uvs = additional_uvs
|
||||
|
||||
class PMXBone:
|
||||
def __init__(self, name, english_name, position, parent_index, layer, flag,
|
||||
tail_position, inherit_parent_index, inherit_influence,
|
||||
fixed_axis, local_x, local_z, external_key,
|
||||
ik_target_index, ik_loop_count, ik_limit_rad, ik_links):
|
||||
self.name = name
|
||||
self.english_name = english_name
|
||||
self.position = position
|
||||
self.parent_index = parent_index
|
||||
self.layer = layer
|
||||
self.flag = flag
|
||||
self.tail_position = tail_position
|
||||
self.inherit_parent_index = inherit_parent_index
|
||||
self.inherit_influence = inherit_influence
|
||||
self.fixed_axis = fixed_axis
|
||||
self.local_x = local_x
|
||||
self.local_z = local_z
|
||||
self.external_key = external_key
|
||||
self.ik_target_index = ik_target_index
|
||||
self.ik_loop_count = ik_loop_count
|
||||
self.ik_limit_rad = ik_limit_rad
|
||||
self.ik_links = ik_links
|
||||
|
||||
class PMXMaterial:
|
||||
def __init__(self, name, english_name, diffuse, specular, specular_strength,
|
||||
ambient, flag, edge_color, edge_size, texture_index,
|
||||
sphere_texture_index, sphere_mode, toon_sharing_flag,
|
||||
toon_texture_index, comment, surface_count):
|
||||
self.name = name
|
||||
self.english_name = english_name
|
||||
self.diffuse = diffuse
|
||||
self.specular = specular
|
||||
self.specular_strength = specular_strength
|
||||
self.ambient = ambient
|
||||
self.flag = flag
|
||||
self.edge_color = edge_color
|
||||
self.edge_size = edge_size
|
||||
self.texture_index = texture_index
|
||||
self.sphere_texture_index = sphere_texture_index
|
||||
self.sphere_mode = sphere_mode
|
||||
self.toon_sharing_flag = toon_sharing_flag
|
||||
self.toon_texture_index = toon_texture_index
|
||||
self.comment = comment
|
||||
self.surface_count = surface_count
|
||||
|
||||
class PMXMorph:
|
||||
def __init__(self, name, english_name, panel, morph_type, offsets):
|
||||
self.name = name
|
||||
self.english_name = english_name
|
||||
self.panel = panel
|
||||
self.morph_type = morph_type
|
||||
self.offsets = offsets
|
||||
|
||||
class PMXRigidBody:
|
||||
def __init__(self, name, bone_index, group, shape_type, size, position, rotation, mass, linear_damping, angular_damping, restitution, friction, mode):
|
||||
self.name = name
|
||||
self.bone_index = bone_index
|
||||
self.group = group
|
||||
self.shape_type = shape_type
|
||||
self.size = size
|
||||
self.position = position
|
||||
self.rotation = rotation
|
||||
self.mass = mass
|
||||
self.linear_damping = linear_damping
|
||||
self.angular_damping = angular_damping
|
||||
self.restitution = restitution
|
||||
self.friction = friction
|
||||
self.mode = mode
|
||||
|
||||
class PMXJoint:
|
||||
def __init__(self, name, joint_type, rigid_body_a, rigid_body_b, position, rotation, linear_limit_min, linear_limit_max, angular_limit_min, angular_limit_max, spring_constant_translation, spring_constant_rotation):
|
||||
self.name = name
|
||||
self.joint_type = joint_type
|
||||
self.rigid_body_a = rigid_body_a
|
||||
self.rigid_body_b = rigid_body_b
|
||||
self.position = position
|
||||
self.rotation = rotation
|
||||
self.linear_limit_min = linear_limit_min
|
||||
self.linear_limit_max = linear_limit_max
|
||||
self.angular_limit_min = angular_limit_min
|
||||
self.angular_limit_max = angular_limit_max
|
||||
self.spring_constant_translation = spring_constant_translation
|
||||
self.spring_constant_rotation = spring_constant_rotation
|
||||
|
||||
def read_pmx_header(file: BufferedReader):
|
||||
magic = file.read(4)
|
||||
if magic != b'PMX ':
|
||||
raise ValueError("Invalid PMX file")
|
||||
|
||||
version = struct.unpack('<f', file.read(4))[0]
|
||||
data_size = struct.unpack('<b', file.read(1))[0]
|
||||
encoding = struct.unpack('<b', file.read(1))[0]
|
||||
additional_uvs = struct.unpack('<b', file.read(1))[0]
|
||||
vertex_index_size = struct.unpack('<b', file.read(1))[0]
|
||||
texture_index_size = struct.unpack('<b', file.read(1))[0]
|
||||
material_index_size = struct.unpack('<b', file.read(1))[0]
|
||||
bone_index_size = struct.unpack('<b', file.read(1))[0]
|
||||
morph_index_size = struct.unpack('<b', file.read(1))[0]
|
||||
rigid_body_index_size = struct.unpack('<b', file.read(1))[0]
|
||||
|
||||
model_name = str(file.read(struct.unpack('<i', file.read(4))[0]), 'utf-16-le', errors='replace')
|
||||
model_english_name = str(file.read(struct.unpack('<i', file.read(4))[0]), 'utf-16-le', errors='replace')
|
||||
model_comment = str(file.read(struct.unpack('<i', file.read(4))[0]), 'utf-16-le', errors='replace')
|
||||
model_english_comment = str(file.read(struct.unpack('<i', file.read(4))[0]), 'utf-16-le', errors='replace')
|
||||
|
||||
return (version, encoding, additional_uvs, vertex_index_size, texture_index_size,
|
||||
material_index_size, bone_index_size, morph_index_size, rigid_body_index_size,
|
||||
model_name, model_english_name, model_comment, model_english_comment)
|
||||
|
||||
def read_index_size(index, types):
|
||||
struct_format = "<??"
|
||||
byte_size = 0
|
||||
if index == 1:
|
||||
struct_format = replace_char(struct_format, 2, types[0])
|
||||
byte_size = 1
|
||||
elif index == 2:
|
||||
struct_format = replace_char(struct_format, 2, types[1])
|
||||
byte_size = 2
|
||||
else:
|
||||
struct_format = replace_char(struct_format, 2, types[2])
|
||||
byte_size = 4
|
||||
|
||||
return struct_format, byte_size
|
||||
|
||||
def replace_char(string, index, character):
|
||||
temp = list(string)
|
||||
temp[index] = character
|
||||
return "".join(temp)
|
||||
|
||||
def read_morph(file: BufferedReader, vertex_struct, vertex_size):
|
||||
try:
|
||||
name_length = struct.unpack('<i', file.read(4))[0]
|
||||
name = str(file.read(name_length), 'utf-16-le', errors='replace')
|
||||
|
||||
english_name_length = struct.unpack('<i', file.read(4))[0]
|
||||
english_name = str(file.read(english_name_length), 'utf-16-le', errors='replace')
|
||||
|
||||
panel = int.from_bytes(file.read(1), byteorder='little', signed=True)
|
||||
morph_type = int.from_bytes(file.read(1), byteorder='little', signed=True)
|
||||
|
||||
# Read offset count with error checking
|
||||
offset_count_bytes = file.read(4)
|
||||
if len(offset_count_bytes) != 4:
|
||||
return PMXMorph(name, english_name, panel, morph_type, [])
|
||||
|
||||
offset_count = struct.unpack('<i', offset_count_bytes)[0]
|
||||
|
||||
offsets = []
|
||||
if morph_type == 1: # Vertex morph
|
||||
for _ in range(offset_count):
|
||||
vertex_index = struct.unpack(replace_char(vertex_struct, 1, '1'), file.read(vertex_size))[0]
|
||||
offset = struct.unpack('<3f', file.read(12))
|
||||
offsets.append((vertex_index, offset))
|
||||
|
||||
return PMXMorph(name, english_name, panel, morph_type, offsets)
|
||||
except:
|
||||
return PMXMorph("", "", 0, 0, [])
|
||||
|
||||
def validate_pmx_data(header_data, vertices, faces, materials, bones):
|
||||
"""Validate PMX data integrity"""
|
||||
if not vertices:
|
||||
raise ValueError("No vertices found in PMX file")
|
||||
if not faces:
|
||||
raise ValueError("No faces found in PMX file")
|
||||
if not materials:
|
||||
raise ValueError("No materials found in PMX file")
|
||||
if not bones:
|
||||
raise ValueError("No bones found in PMX file")
|
||||
return True
|
||||
|
||||
def handle_import_error(context, error_msg):
|
||||
"""Handle import errors with user feedback"""
|
||||
context.window_manager.progress_end()
|
||||
bpy.ops.ui.popup_menu(message=error_msg)
|
||||
return {'CANCELLED'}
|
||||
|
||||
def read_vertex(file: BufferedReader, string_build, byte_size, additional_uvs):
|
||||
position = struct.unpack('<3f', file.read(12))
|
||||
normal = struct.unpack('<3f', file.read(12))
|
||||
uv = struct.unpack('<2f', file.read(8))
|
||||
uv = [uv[0], (1.0-uv[1])-1.0]
|
||||
|
||||
additional_uv_read = []
|
||||
for _ in range(additional_uvs):
|
||||
additional_uv_read.append(struct.unpack('<4f', file.read(16)))
|
||||
|
||||
weight_deform_type = struct.unpack('<B', file.read(1))[0]
|
||||
|
||||
bone_indices = []
|
||||
bone_weights = []
|
||||
|
||||
if weight_deform_type == 0: # BDEF1
|
||||
string_build = replace_char(string_build, 1, '1')
|
||||
bone_indices = list(struct.unpack(string_build, file.read(byte_size*1)))
|
||||
bone_weights = [1.0]
|
||||
elif weight_deform_type == 1: # BDEF2
|
||||
string_build = replace_char(string_build, 1, '2')
|
||||
bone_indices = list(struct.unpack(string_build, file.read(byte_size*2)))
|
||||
weight = struct.unpack('<f', file.read(4))[0]
|
||||
bone_weights = [weight, 1.0-weight]
|
||||
elif weight_deform_type == 2: # BDEF4
|
||||
string_build = replace_char(string_build, 1, '4')
|
||||
bone_indices = list(struct.unpack(string_build, file.read(byte_size*4)))
|
||||
bone_weights = list(struct.unpack('<4f', file.read(16)))
|
||||
elif weight_deform_type == 3: # SDEF
|
||||
string_build = replace_char(string_build, 1, '2')
|
||||
bone_indices = list(struct.unpack(string_build, file.read(byte_size*2)))
|
||||
weight = struct.unpack('<f', file.read(4))[0]
|
||||
bone_weights = [weight, 1.0-weight]
|
||||
# Skip SDEF data as we don't use it
|
||||
file.read(36) # 3 vectors of 3 floats each (C, R0, R1)
|
||||
elif weight_deform_type == 4: # QDEF
|
||||
string_build = replace_char(string_build, 1, '4')
|
||||
bone_indices = list(struct.unpack(string_build, file.read(byte_size*4)))
|
||||
bone_weights = list(struct.unpack('<4f', file.read(16)))
|
||||
|
||||
edge_scale = struct.unpack('<f', file.read(4))[0]
|
||||
|
||||
return PMXVertex(position, normal, uv, bone_indices, bone_weights, edge_scale, additional_uv_read)
|
||||
|
||||
def read_material(file: BufferedReader, string_build, byte_size):
|
||||
material_name = str(file.read(struct.unpack('<i', file.read(4))[0]), 'utf-16-le', errors='replace')
|
||||
material_english_name = str(file.read(struct.unpack('<i', file.read(4))[0]), 'utf-16-le', errors='replace')
|
||||
|
||||
diffuse_color = struct.unpack('<4f', file.read(16))
|
||||
specular_color = struct.unpack('<3f', file.read(12))
|
||||
specular_strength = struct.unpack('<f', file.read(4))[0]
|
||||
ambient_color = struct.unpack('<3f', file.read(12))
|
||||
|
||||
flag = struct.unpack('<b', file.read(1))[0]
|
||||
edge_color = struct.unpack('<4f', file.read(16))
|
||||
edge_size = struct.unpack('<f', file.read(4))[0]
|
||||
|
||||
texture_index = struct.unpack(replace_char(string_build, 1, '1'), file.read(byte_size))[0]
|
||||
sphere_texture_index = struct.unpack(replace_char(string_build, 1, '1'), file.read(byte_size))[0]
|
||||
sphere_mode = struct.unpack('<b', file.read(1))[0]
|
||||
toon_sharing_flag = struct.unpack('<b', file.read(1))[0]
|
||||
|
||||
if toon_sharing_flag == 0:
|
||||
toon_texture_index = struct.unpack(replace_char(string_build, 1, '1'), file.read(byte_size))[0]
|
||||
else:
|
||||
toon_texture_index = struct.unpack('<b', file.read(1))[0]
|
||||
|
||||
comment = str(file.read(struct.unpack('<i', file.read(4))[0]), 'utf-16-le', errors='replace')
|
||||
surface_count = int(struct.unpack('<i', file.read(4))[0]/3)
|
||||
|
||||
return PMXMaterial(material_name, material_english_name, diffuse_color, specular_color,
|
||||
specular_strength, ambient_color, flag, edge_color, edge_size,
|
||||
texture_index, sphere_texture_index, sphere_mode,
|
||||
toon_sharing_flag, toon_texture_index, comment, surface_count)
|
||||
|
||||
def create_material_nodes(material: bpy.types.Material, texture_path: str, diffuse_color, specular_color, specular_strength, toon_texture_path=None):
|
||||
material.use_nodes = True
|
||||
nodes = material.node_tree.nodes
|
||||
links = material.node_tree.links
|
||||
|
||||
nodes.clear()
|
||||
|
||||
principled = nodes.new("ShaderNodeBsdfPrincipled")
|
||||
principled.location = (0, 0)
|
||||
principled.inputs["Base Color"].default_value = diffuse_color
|
||||
principled.inputs["Specular IOR Level"].default_value = specular_strength
|
||||
principled.inputs["Specular Tint"].default_value = (*specular_color, 1.0)
|
||||
|
||||
# Handle transparency
|
||||
if diffuse_color[3] < 1.0:
|
||||
material.blend_method = 'HASHED'
|
||||
principled.inputs["Alpha"].default_value = diffuse_color[3]
|
||||
|
||||
output = nodes.new("ShaderNodeOutputMaterial")
|
||||
output.location = (300, 0)
|
||||
|
||||
# Main texture
|
||||
if texture_path and os.path.exists(texture_path):
|
||||
texture = nodes.new("ShaderNodeTexImage")
|
||||
texture.location = (-300, 0)
|
||||
texture.image = bpy.data.images.load(texture_path)
|
||||
links.new(texture.outputs["Color"], principled.inputs["Base Color"])
|
||||
links.new(texture.outputs["Alpha"], principled.inputs["Alpha"])
|
||||
|
||||
# Toon texture
|
||||
if toon_texture_path and os.path.exists(toon_texture_path):
|
||||
toon = nodes.new("ShaderNodeTexImage")
|
||||
toon.location = (-300, -300)
|
||||
toon.image = bpy.data.images.load(toon_texture_path)
|
||||
mix = nodes.new("ShaderNodeMixRGB")
|
||||
mix.location = (-50, -150)
|
||||
mix.blend_type = 'MULTIPLY'
|
||||
links.new(toon.outputs["Color"], mix.inputs[2])
|
||||
links.new(mix.outputs["Color"], principled.inputs["Base Color"])
|
||||
|
||||
links.new(principled.outputs["BSDF"], output.inputs["Surface"])
|
||||
|
||||
def read_bone(file: BufferedReader, string_build, byte_size):
|
||||
bone_name = str(file.read(struct.unpack('<i', file.read(4))[0]), 'utf-16-le', errors='replace')
|
||||
bone_english_name = str(file.read(struct.unpack('<i', file.read(4))[0]), 'utf-16-le', errors='replace')
|
||||
|
||||
position = struct.unpack('<3f', file.read(12))
|
||||
parent_bone_index = struct.unpack(replace_char(string_build, 1, '1'), file.read(byte_size))[0]
|
||||
layer = struct.unpack('<i', file.read(4))[0]
|
||||
flag = struct.unpack('<H', file.read(2))[0]
|
||||
|
||||
tail_position = [None, None, None]
|
||||
inherit_bone_parent_index = 0
|
||||
inherit_bone_parent_influence = 0.0
|
||||
fixed_axis = [0.0, 0.0, 0.0]
|
||||
local_x_vector = [0.0, 0.0, 0.0]
|
||||
local_z_vector = [0.0, 0.0, 0.0]
|
||||
external_key = 0
|
||||
ik_target_bone_index = 0
|
||||
ik_loop_count = -1
|
||||
ik_limit_radian = 0.0
|
||||
ik_links = []
|
||||
|
||||
if not (flag & 0x0001):
|
||||
tail_position = struct.unpack('<3f', file.read(12))
|
||||
else:
|
||||
tail_index = struct.unpack(replace_char(string_build, 1, '1'), file.read(byte_size))[0]
|
||||
|
||||
if flag & 0x0100 or flag & 0x0200:
|
||||
inherit_bone_parent_index = struct.unpack(replace_char(string_build, 1, '1'), file.read(byte_size))[0]
|
||||
inherit_bone_parent_influence = struct.unpack('<f', file.read(4))[0]
|
||||
|
||||
if flag & 0x0400:
|
||||
fixed_axis = struct.unpack('<3f', file.read(12))
|
||||
|
||||
if flag & 0x0800:
|
||||
local_x_vector = struct.unpack('<3f', file.read(12))
|
||||
local_z_vector = struct.unpack('<3f', file.read(12))
|
||||
|
||||
if flag & 0x2000:
|
||||
external_key = struct.unpack('<i', file.read(4))[0]
|
||||
|
||||
if flag & 0x0020:
|
||||
ik_target_bone_index = struct.unpack(replace_char(string_build, 1, '1'), file.read(byte_size))[0]
|
||||
ik_loop_count = struct.unpack('<i', file.read(4))[0]
|
||||
ik_limit_radian = struct.unpack('<f', file.read(4))[0]
|
||||
ik_link_count = struct.unpack('<i', file.read(4))[0]
|
||||
|
||||
for _ in range(ik_link_count):
|
||||
ik_link_bone_index = struct.unpack(replace_char(string_build, 1, '1'), file.read(byte_size))[0]
|
||||
ik_link_limit = struct.unpack('<b', file.read(1))[0]
|
||||
if ik_link_limit == 1:
|
||||
angle_limit = (struct.unpack('<3f', file.read(12)), struct.unpack('<3f', file.read(12)))
|
||||
ik_links.append((ik_link_bone_index, True, angle_limit))
|
||||
else:
|
||||
ik_links.append((ik_link_bone_index, False, None))
|
||||
|
||||
return PMXBone(bone_name, bone_english_name, position, parent_bone_index, layer,
|
||||
flag, tail_position, inherit_bone_parent_index, inherit_bone_parent_influence,
|
||||
fixed_axis, local_x_vector, local_z_vector, external_key,
|
||||
ik_target_bone_index, ik_loop_count, ik_limit_radian, ik_links)
|
||||
|
||||
def create_bone_constraints(armature_obj: bpy.types.Object, bones: list[PMXBone]):
|
||||
bpy.context.view_layer.objects.active = armature_obj
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
|
||||
# Clear existing constraints
|
||||
for pose_bone in armature_obj.pose.bones:
|
||||
while pose_bone.constraints:
|
||||
pose_bone.constraints.remove(pose_bone.constraints[0])
|
||||
|
||||
# Handle rotation inheritance first
|
||||
for bone_data in bones:
|
||||
pose_bone = armature_obj.pose.bones.get(bone_data.name)
|
||||
if not pose_bone or bone_data.parent_index < 0:
|
||||
continue
|
||||
|
||||
# Check if bone has vertex groups
|
||||
if not pose_bone.bone.use_deform:
|
||||
continue
|
||||
|
||||
if bone_data.flag & 0x0100: # Rotation inheritance
|
||||
if bone_data.inherit_parent_index >= 0:
|
||||
constraint = pose_bone.constraints.new('COPY_ROTATION')
|
||||
constraint.name = "MMD Rotation"
|
||||
constraint.target = armature_obj
|
||||
constraint.subtarget = bones[bone_data.inherit_parent_index].name
|
||||
constraint.influence = bone_data.inherit_influence
|
||||
constraint.target_space = 'LOCAL'
|
||||
constraint.owner_space = 'LOCAL'
|
||||
|
||||
# Then handle IK constraints
|
||||
for bone_data in bones:
|
||||
pose_bone = armature_obj.pose.bones.get(bone_data.name)
|
||||
if not pose_bone:
|
||||
continue
|
||||
|
||||
# Skip non-deforming bones
|
||||
if not pose_bone.bone.use_deform:
|
||||
continue
|
||||
|
||||
if bone_data.flag & 0x0020: # IK
|
||||
if bone_data.ik_target_index >= 0:
|
||||
constraint = pose_bone.constraints.new('IK')
|
||||
constraint.name = "MMD IK"
|
||||
constraint.target = armature_obj
|
||||
constraint.subtarget = bones[bone_data.ik_target_index].name
|
||||
constraint.chain_count = min(len(bone_data.ik_links), 3)
|
||||
constraint.iterations = min(bone_data.ik_loop_count, 8)
|
||||
constraint.use_tail = False
|
||||
constraint.use_stretch = False
|
||||
|
||||
# Configure IK chain
|
||||
for link_bone_index, has_limits, angle_limits in bone_data.ik_links:
|
||||
link_pose_bone = armature_obj.pose.bones.get(bones[link_bone_index].name)
|
||||
if link_pose_bone and link_pose_bone.bone.use_deform:
|
||||
link_pose_bone.rotation_mode = 'XYZ'
|
||||
link_pose_bone.use_ik_limit_x = True
|
||||
link_pose_bone.use_ik_limit_y = True
|
||||
link_pose_bone.use_ik_limit_z = True
|
||||
|
||||
if has_limits and angle_limits:
|
||||
min_angles, max_angles = angle_limits
|
||||
link_pose_bone.ik_min_x = max(-1.4, min_angles[0])
|
||||
link_pose_bone.ik_max_x = min(1.4, max_angles[0])
|
||||
link_pose_bone.ik_min_y = max(-1.4, min_angles[1])
|
||||
link_pose_bone.ik_max_y = min(1.4, max_angles[1])
|
||||
link_pose_bone.ik_min_z = max(-1.4, min_angles[2])
|
||||
link_pose_bone.ik_max_z = min(1.4, max_angles[2])
|
||||
|
||||
# Reset pose to default state
|
||||
bpy.ops.pose.select_all(action='SELECT')
|
||||
bpy.ops.pose.transforms_clear()
|
||||
bpy.ops.pose.select_all(action='DESELECT')
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
def setup_physics(obj: bpy.types.Object, armature_obj: bpy.types.Object, rigid_bodies: list[PMXRigidBody], joints: list[PMXJoint]):
|
||||
"""Set up physics for PMX model"""
|
||||
# Create rigid body collection if it doesn't exist
|
||||
if 'RigidBodies' not in bpy.data.collections:
|
||||
rigid_body_collection = bpy.data.collections.new('RigidBodies')
|
||||
bpy.context.scene.collection.children.link(rigid_body_collection)
|
||||
else:
|
||||
rigid_body_collection = bpy.data.collections['RigidBodies']
|
||||
|
||||
# Create rigid bodies
|
||||
for rb in rigid_bodies:
|
||||
# Create mesh based on shape type
|
||||
if rb.shape_type == 0: # Sphere
|
||||
bpy.ops.mesh.primitive_uv_sphere_add(radius=rb.size[0])
|
||||
elif rb.shape_type == 1: # Box
|
||||
bpy.ops.mesh.primitive_cube_add()
|
||||
bpy.context.active_object.scale = rb.size
|
||||
elif rb.shape_type == 2: # Capsule
|
||||
bpy.ops.mesh.primitive_cylinder_add(radius=rb.size[0], depth=rb.size[1])
|
||||
|
||||
rb_obj = bpy.context.active_object
|
||||
rb_obj.name = f"RB_{rb.name}"
|
||||
rb_obj.location = rb.position
|
||||
rb_obj.rotation_euler = rb.rotation
|
||||
|
||||
# Set up rigid body physics
|
||||
rb_obj.rigid_body.type = 'ACTIVE' if rb.mode == 0 else 'PASSIVE'
|
||||
rb_obj.rigid_body.mass = rb.mass
|
||||
rb_obj.rigid_body.linear_damping = rb.linear_damping
|
||||
rb_obj.rigid_body.angular_damping = rb.angular_damping
|
||||
rb_obj.rigid_body.restitution = rb.restitution
|
||||
rb_obj.rigid_body.friction = rb.friction
|
||||
|
||||
# Parent to bone if specified
|
||||
if rb.bone_index >= 0:
|
||||
rb_obj.parent = armature_obj
|
||||
rb_obj.parent_type = 'BONE'
|
||||
rb_obj.parent_bone = bones[rb.bone_index].name
|
||||
|
||||
# Move to rigid body collection
|
||||
rigid_body_collection.objects.link(rb_obj)
|
||||
bpy.context.scene.collection.objects.unlink(rb_obj)
|
||||
|
||||
# Create joints
|
||||
for joint in joints:
|
||||
empty = bpy.data.objects.new(f"Joint_{joint.name}", None)
|
||||
empty.empty_display_type = 'ARROWS'
|
||||
empty.location = joint.position
|
||||
empty.rotation_euler = joint.rotation
|
||||
bpy.context.scene.collection.objects.link(empty)
|
||||
|
||||
# Set up constraint
|
||||
constraint = empty.constraints.new('RIGID_BODY_JOINT')
|
||||
constraint.target = rigid_bodies[joint.rigid_body_a]
|
||||
constraint.child = rigid_bodies[joint.rigid_body_b]
|
||||
constraint.use_limit_lin_x = True
|
||||
constraint.use_limit_lin_y = True
|
||||
constraint.use_limit_lin_z = True
|
||||
constraint.use_limit_ang_x = True
|
||||
constraint.use_limit_ang_y = True
|
||||
constraint.use_limit_ang_z = True
|
||||
|
||||
# Set limits
|
||||
constraint.limit_lin_x_lower = joint.linear_limit_min[0]
|
||||
constraint.limit_lin_x_upper = joint.linear_limit_max[0]
|
||||
constraint.limit_lin_y_lower = joint.linear_limit_min[1]
|
||||
constraint.limit_lin_y_upper = joint.linear_limit_max[1]
|
||||
constraint.limit_lin_z_lower = joint.linear_limit_min[2]
|
||||
constraint.limit_lin_z_upper = joint.linear_limit_max[2]
|
||||
constraint.limit_ang_x_lower = joint.angular_limit_min[0]
|
||||
constraint.limit_ang_x_upper = joint.angular_limit_max[0]
|
||||
constraint.limit_ang_y_lower = joint.angular_limit_min[1]
|
||||
constraint.limit_ang_y_upper = joint.angular_limit_max[1]
|
||||
constraint.limit_ang_z_lower = joint.angular_limit_min[2]
|
||||
constraint.limit_ang_z_upper = joint.angular_limit_max[2]
|
||||
|
||||
def create_armature(model_name: str, bones: list[PMXBone]) -> bpy.types.Object:
|
||||
# Handle CJK characters in model name
|
||||
if isinstance(model_name, bytes):
|
||||
try:
|
||||
model_name = model_name.decode('gbk') # Try Chinese encoding first
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
model_name = model_name.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
model_name = model_name.decode('shift-jis')
|
||||
except UnicodeDecodeError:
|
||||
model_name = model_name.decode('latin1')
|
||||
|
||||
armature = bpy.data.armatures.new(f"{model_name}_Armature")
|
||||
armature_obj = bpy.data.objects.new(f"{model_name}_Armature", armature)
|
||||
bpy.context.collection.objects.link(armature_obj)
|
||||
|
||||
bpy.context.view_layer.objects.active = armature_obj
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
# First pass: Create bones with proper names and types
|
||||
edit_bones = []
|
||||
for i, bone_data in enumerate(bones):
|
||||
bone_name = bone_data.name if bone_data.name else bone_data.english_name
|
||||
if not bone_name:
|
||||
bone_name = f"bone_{i}"
|
||||
|
||||
edit_bone = armature.edit_bones.new(bone_name)
|
||||
edit_bone.head = Vector(bone_data.position)
|
||||
|
||||
# Handle different bone types based on flags and names
|
||||
is_expression = bool(bone_data.flag & 0x0004)
|
||||
is_rotation_influenced = bool(bone_data.flag & 0x0100)
|
||||
is_ik = bool(bone_data.flag & 0x0020)
|
||||
is_twist = "twist" in bone_name.lower()
|
||||
|
||||
if is_twist:
|
||||
# Twist bones need specific handling
|
||||
parent_pos = bones[bone_data.parent_index].position if bone_data.parent_index >= 0 else None
|
||||
if parent_pos:
|
||||
direction = Vector(bone_data.position) - Vector(parent_pos)
|
||||
if direction.length > 0.001:
|
||||
edit_bone.tail = edit_bone.head + direction.normalized() * 0.1
|
||||
else:
|
||||
edit_bone.tail = edit_bone.head + Vector((0, 0.05, 0))
|
||||
else:
|
||||
edit_bone.tail = edit_bone.head + Vector((0, 0.05, 0))
|
||||
|
||||
elif is_expression:
|
||||
edit_bone.tail = edit_bone.head + Vector((0, 0.02, 0))
|
||||
edit_bone.use_deform = False
|
||||
|
||||
elif is_ik:
|
||||
if bone_data.ik_links:
|
||||
target_pos = bones[bone_data.ik_links[0][0]].position
|
||||
direction = Vector(target_pos) - Vector(edit_bone.head)
|
||||
if direction.length > 0.001:
|
||||
edit_bone.tail = edit_bone.head + direction.normalized() * 0.1
|
||||
else:
|
||||
edit_bone.tail = edit_bone.head + Vector((0, 0.1, 0))
|
||||
else:
|
||||
edit_bone.tail = edit_bone.head + Vector((0, 0.1, 0))
|
||||
|
||||
elif is_rotation_influenced:
|
||||
# Handle rotation influenced bones
|
||||
if bone_data.inherit_parent_index >= 0:
|
||||
target_pos = bones[bone_data.inherit_parent_index].position
|
||||
direction = Vector(target_pos) - Vector(edit_bone.head)
|
||||
if direction.length > 0.001:
|
||||
edit_bone.tail = edit_bone.head + direction.normalized() * 0.08
|
||||
else:
|
||||
edit_bone.tail = edit_bone.head + Vector((0, 0.08, 0))
|
||||
else:
|
||||
edit_bone.tail = edit_bone.head + Vector((0, 0.08, 0))
|
||||
|
||||
else:
|
||||
# Standard bones
|
||||
if bone_data.tail_position[0] is not None:
|
||||
edit_bone.tail = Vector(bone_data.tail_position)
|
||||
else:
|
||||
child_positions = [bones[j].position for j in range(len(bones))
|
||||
if bones[j].parent_index == i]
|
||||
if child_positions:
|
||||
avg_child_pos = Vector((0, 0, 0))
|
||||
for pos in child_positions:
|
||||
avg_child_pos += Vector(pos)
|
||||
avg_child_pos /= len(child_positions)
|
||||
edit_bone.tail = avg_child_pos
|
||||
else:
|
||||
bone_length = 0.1 if bone_data.layer == 0 else 0.05
|
||||
edit_bone.tail = edit_bone.head + Vector((0, bone_length, 0))
|
||||
|
||||
edit_bones.append(edit_bone)
|
||||
|
||||
# Second pass: Set up hierarchy and orientations
|
||||
for i, bone_data in enumerate(bones):
|
||||
edit_bone = edit_bones[i]
|
||||
|
||||
# Parent bones
|
||||
if bone_data.parent_index >= 0:
|
||||
parent_bone = edit_bones[bone_data.parent_index]
|
||||
edit_bone.parent = parent_bone
|
||||
|
||||
# Connect bones only if they should be connected
|
||||
if (Vector(bone_data.position) - Vector(parent_bone.tail)).length < 0.01:
|
||||
edit_bone.use_connect = True
|
||||
|
||||
# Handle bone orientation
|
||||
if bone_data.fixed_axis != [0.0, 0.0, 0.0]:
|
||||
edit_bone.align_roll(Vector(bone_data.fixed_axis))
|
||||
elif bone_data.local_x != [0.0, 0.0, 0.0]:
|
||||
x_axis = Vector(bone_data.local_x).normalized()
|
||||
z_axis = Vector(bone_data.local_z).normalized()
|
||||
y_axis = z_axis.cross(x_axis)
|
||||
|
||||
# Create and apply orientation matrix
|
||||
matrix_3x3 = Matrix((x_axis, y_axis, z_axis)).to_3x3()
|
||||
matrix_4x4 = matrix_3x3.to_4x4()
|
||||
edit_bone.matrix = matrix_4x4
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
return armature_obj
|
||||
|
||||
|
||||
def assign_vertex_weights(obj: bpy.types.Object, vertices: list[PMXVertex], bones: list[PMXBone]):
|
||||
# Pre-create vertex groups
|
||||
vertex_groups = {}
|
||||
for bone in bones:
|
||||
vertex_groups[bone.name] = obj.vertex_groups.new(name=bone.name)
|
||||
|
||||
# Batch assign weights
|
||||
for vertex_index, vertex in enumerate(vertices):
|
||||
for bone_idx, weight in zip(vertex.bone_indices, vertex.bone_weights):
|
||||
if bone_idx != -1 and weight > 0:
|
||||
vertex_groups[bones[bone_idx].name].add([vertex_index], weight, 'REPLACE')
|
||||
|
||||
def assign_materials(obj: bpy.types.Object, materials: list[PMXMaterial], textures: list[str], base_path: str):
|
||||
current_face_index = 0
|
||||
|
||||
for material in materials:
|
||||
# Create or get material
|
||||
mat_name = material.name or f"Material_{len(obj.data.materials)}"
|
||||
if mat_name in bpy.data.materials:
|
||||
mat = bpy.data.materials[mat_name]
|
||||
else:
|
||||
mat = bpy.data.materials.new(name=mat_name)
|
||||
|
||||
# Set up material nodes
|
||||
texture_path = None
|
||||
if material.texture_index >= 0 and material.texture_index < len(textures):
|
||||
texture_path = os.path.join(base_path, textures[material.texture_index])
|
||||
|
||||
create_material_nodes(mat, texture_path, material.diffuse, material.specular,
|
||||
material.specular_strength)
|
||||
|
||||
# Assign material to mesh
|
||||
if mat.name not in obj.data.materials:
|
||||
obj.data.materials.append(mat)
|
||||
|
||||
# Assign faces to material
|
||||
mat_index = obj.data.materials.find(mat.name)
|
||||
for face in obj.data.polygons[current_face_index:current_face_index + material.surface_count]:
|
||||
face.material_index = mat_index
|
||||
|
||||
current_face_index += material.surface_count
|
||||
|
||||
def import_pmx(filepath: str):
|
||||
wm = bpy.context.window_manager
|
||||
wm.progress_begin(0, 100)
|
||||
|
||||
try:
|
||||
with open(filepath, 'rb') as file:
|
||||
# Read header (5%)
|
||||
wm.progress_update(5)
|
||||
header_data = read_pmx_header(file)
|
||||
version, encoding, additional_uvs, vertex_index_size, texture_index_size, \
|
||||
material_index_size, bone_index_size, morph_index_size, rigid_body_index_size, \
|
||||
model_name, model_english_name, model_comment, model_english_comment = header_data
|
||||
|
||||
# Set up index size formats (10%)
|
||||
wm.progress_update(10)
|
||||
vertex_struct, vertex_size = read_index_size(vertex_index_size, 'BHi')
|
||||
bone_struct, bone_size = read_index_size(bone_index_size, 'bhi')
|
||||
texture_struct, texture_size = read_index_size(texture_index_size, 'bhi')
|
||||
|
||||
# Read vertices (25%)
|
||||
vertex_count = struct.unpack('<i', file.read(4))[0]
|
||||
vertices = []
|
||||
for i in range(vertex_count):
|
||||
vertices.append(read_vertex(file, bone_struct, bone_size, additional_uvs))
|
||||
if i % 1000 == 0:
|
||||
wm.progress_update(10 + (i/vertex_count * 15))
|
||||
|
||||
# Read faces (35%)
|
||||
wm.progress_update(35)
|
||||
face_count = struct.unpack('<i', file.read(4))[0] // 3
|
||||
faces = []
|
||||
for _ in range(face_count):
|
||||
if vertex_index_size == 1:
|
||||
faces.append(struct.unpack('<3B', file.read(3)))
|
||||
elif vertex_index_size == 2:
|
||||
faces.append(struct.unpack('<3H', file.read(6)))
|
||||
else:
|
||||
faces.append(struct.unpack('<3i', file.read(12)))
|
||||
|
||||
# Read textures (45%)
|
||||
wm.progress_update(45)
|
||||
texture_count = struct.unpack('<i', file.read(4))[0]
|
||||
textures = []
|
||||
for _ in range(texture_count):
|
||||
texture_path = str(file.read(struct.unpack('<i', file.read(4))[0]), 'utf-16-le', errors='replace')
|
||||
textures.append(texture_path)
|
||||
|
||||
# Read materials (55%)
|
||||
wm.progress_update(55)
|
||||
material_count = struct.unpack('<i', file.read(4))[0]
|
||||
materials = []
|
||||
for _ in range(material_count):
|
||||
materials.append(read_material(file, texture_struct, texture_size))
|
||||
|
||||
# Read bones (65%)
|
||||
wm.progress_update(65)
|
||||
bone_count = struct.unpack('<i', file.read(4))[0]
|
||||
bones = []
|
||||
for _ in range(bone_count):
|
||||
bones.append(read_bone(file, bone_struct, bone_size))
|
||||
|
||||
# Read morphs (75%)
|
||||
wm.progress_update(75)
|
||||
morph_count = struct.unpack('<i', file.read(4))[0]
|
||||
morphs = []
|
||||
for _ in range(morph_count):
|
||||
morphs.append(read_morph(file, vertex_struct, vertex_size))
|
||||
|
||||
# Read rigid bodies (85%)
|
||||
wm.progress_update(85)
|
||||
try:
|
||||
rigid_body_count_bytes = file.read(4)
|
||||
if len(rigid_body_count_bytes) == 4:
|
||||
rigid_body_count = struct.unpack('<i', rigid_body_count_bytes)[0]
|
||||
rigid_bodies = []
|
||||
for _ in range(rigid_body_count):
|
||||
rigid_bodies.append(read_rigid_body(file, bone_struct, bone_size))
|
||||
else:
|
||||
rigid_bodies = []
|
||||
except:
|
||||
rigid_bodies = []
|
||||
|
||||
# Read joints (90%)
|
||||
wm.progress_update(90)
|
||||
try:
|
||||
joint_count_bytes = file.read(4)
|
||||
if len(joint_count_bytes) == 4:
|
||||
joint_count = struct.unpack('<i', joint_count_bytes)[0]
|
||||
joints = []
|
||||
for _ in range(joint_count):
|
||||
joints.append(read_joint(file, rigid_body_struct, rigid_body_size))
|
||||
else:
|
||||
joints = []
|
||||
except:
|
||||
joints = []
|
||||
|
||||
# Validate data (92%)
|
||||
wm.progress_update(92)
|
||||
validate_pmx_data(header_data, vertices, faces, materials, bones)
|
||||
|
||||
# Create mesh and object (94%)
|
||||
wm.progress_update(94)
|
||||
mesh = bpy.data.meshes.new(model_name)
|
||||
mesh.from_pydata([v.position for v in vertices], [], faces)
|
||||
mesh.update()
|
||||
|
||||
obj = bpy.data.objects.new(model_name, mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
|
||||
# Create and set up armature (96%)
|
||||
wm.progress_update(96)
|
||||
armature_obj = create_armature(model_name, bones)
|
||||
obj.parent = armature_obj
|
||||
|
||||
# Create shape keys (97%)
|
||||
wm.progress_update(97)
|
||||
for morph in morphs:
|
||||
if morph.morph_type == 1:
|
||||
if not obj.data.shape_keys:
|
||||
obj.shape_key_add(name='Basis')
|
||||
shape_key = obj.shape_key_add(name=morph.name)
|
||||
for vertex_index, offset in morph.offsets:
|
||||
shape_key.data[vertex_index].co = (
|
||||
vertices[vertex_index].position[0] + offset[0],
|
||||
vertices[vertex_index].position[1] + offset[1],
|
||||
vertices[vertex_index].position[2] + offset[2]
|
||||
)
|
||||
|
||||
# Set up physics (98%)
|
||||
wm.progress_update(98)
|
||||
setup_physics(obj, armature_obj, rigid_bodies, joints)
|
||||
|
||||
# Final setup (99%)
|
||||
wm.progress_update(99)
|
||||
base_path = os.path.dirname(filepath)
|
||||
assign_materials(obj, materials, textures, base_path)
|
||||
assign_vertex_weights(obj, vertices, bones)
|
||||
|
||||
# Add armature modifier
|
||||
mod = obj.modifiers.new(name="Armature", type='ARMATURE')
|
||||
mod.object = armature_obj
|
||||
|
||||
# Set proper scale and orientation
|
||||
armature_obj.scale = (0.08, 0.08, 0.08)
|
||||
armature_obj.rotation_euler = (1.5708, 0, 0)
|
||||
|
||||
# Select objects and set active
|
||||
armature_obj.select_set(True)
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = armature_obj
|
||||
|
||||
# Disable automatic mirroring
|
||||
armature_obj.data.use_mirror_x = False
|
||||
|
||||
# Add constraints
|
||||
create_bone_constraints(armature_obj, bones)
|
||||
|
||||
# Apply transforms
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
|
||||
# Ensure object mode
|
||||
bpy.context.view_layer.objects.active = armature_obj
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
wm.progress_end()
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
wm.progress_end()
|
||||
error_msg = f"PMX Import Error: {str(e)}\n{traceback.format_exc()}"
|
||||
print(error_msg) # Console output for debugging
|
||||
return {'CANCELLED'}
|
||||
+40
-12
@@ -7,9 +7,8 @@ from bpy.types import Operator, Context
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
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
|
||||
from ..translations import t
|
||||
import traceback
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -18,7 +17,7 @@ logger: logging.Logger = logging.getLogger(__name__)
|
||||
import importlib.util
|
||||
|
||||
if importlib.util.find_spec("io_scene_valvesource") is not None:
|
||||
from io_scene_valvesource.import_smd import SmdImporter
|
||||
from io_scene_valvesource.import_smd import SmdImporter # type: ignore
|
||||
|
||||
class ImportProgress:
|
||||
"""Tracks and logs the progress of multi-file imports"""
|
||||
@@ -85,8 +84,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]
|
||||
@@ -96,6 +95,12 @@ import_types: Dict[str, ImportMethod] = {
|
||||
files=files, directory=directory, filepath=filepath,
|
||||
automatic_bone_orientation=False, use_prepost_rot=False, use_anim=False
|
||||
),
|
||||
"pmx": lambda directory, files, filepath: import_multi_files(
|
||||
directory=directory,
|
||||
files=files,
|
||||
filepath=filepath,
|
||||
method=lambda directory, filepath: import_pmx_file(filepath)
|
||||
),
|
||||
"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),
|
||||
@@ -122,13 +127,6 @@ import_types: Dict[str, ImportMethod] = {
|
||||
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(bpy.context, filepath,
|
||||
scale=1.0,
|
||||
use_mipmap=True,
|
||||
sph_blend_factor=1.0,
|
||||
spa_blend_factor=1.0
|
||||
),
|
||||
"pmd": lambda directory, files, filepath: import_pmd(filepath),
|
||||
"animx": (lambda directory, files, filepath : bpy.ops.avatar_toolkit.animx_importer(directory=directory,files=files,filepath=filepath)),
|
||||
}
|
||||
|
||||
@@ -202,3 +200,33 @@ class AvatarToolKit_OT_Import(Operator, ImportHelper):
|
||||
self.report({'INFO'}, t('Quick_Access.import_success'))
|
||||
return {'FINISHED'}
|
||||
|
||||
def import_pmx_file(filepath: str) -> None:
|
||||
"""
|
||||
Import a PMX file using the MMD Tools import operator
|
||||
|
||||
Args:
|
||||
filepath: Path to the PMX file
|
||||
"""
|
||||
|
||||
# Use the MMD Tools operator to import PMX files (CATS-compatible)
|
||||
# Must pass files + directory like CATS does, not just filepath
|
||||
try:
|
||||
directory = os.path.dirname(filepath)
|
||||
filename = os.path.basename(filepath)
|
||||
|
||||
bpy.ops.mmd_tools.import_model('EXEC_DEFAULT',
|
||||
files=[{'name': filename}],
|
||||
directory=directory,
|
||||
scale=0.08,
|
||||
types={'MESH', 'ARMATURE', 'MORPHS', 'DISPLAY'},
|
||||
clean_model=False, # Disable cleaning to preserve morph indices
|
||||
remove_doubles=False,
|
||||
fix_ik_links=False,
|
||||
ik_loop_factor=5,
|
||||
apply_bone_fixed_axis=False,
|
||||
rename_bones=False,
|
||||
use_underscore=False)
|
||||
logger.info(f"Successfully imported PMX file: {filepath}")
|
||||
except (AttributeError, TypeError, ValueError) as e:
|
||||
logger.error(f"Failed to import PMX file: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
+30
-6
@@ -1,27 +1,51 @@
|
||||
import logging
|
||||
import traceback
|
||||
from typing import Optional, Any
|
||||
from bpy.types import Context
|
||||
|
||||
logger = logging.getLogger('avatar_toolkit')
|
||||
_original_error = logger.error
|
||||
|
||||
def configure_logging(enabled: bool = False) -> None:
|
||||
"""Configure logging for Avatar Toolkit"""
|
||||
logger.setLevel(logging.DEBUG if enabled else logging.WARNING)
|
||||
def configure_logging(enabled: bool = False, level: str = "WARNING") -> None:
|
||||
"""Configure logging for Avatar Toolkit """
|
||||
level_map = {
|
||||
"DEBUG": logging.DEBUG,
|
||||
"INFO": logging.INFO,
|
||||
"WARNING": logging.WARNING,
|
||||
"ERROR": logging.ERROR
|
||||
}
|
||||
|
||||
log_level = level_map.get(level, logging.WARNING)
|
||||
|
||||
if enabled:
|
||||
logger.setLevel(log_level)
|
||||
else:
|
||||
logger.setLevel(logging.ERROR) # We should still log errors when logging is disabled so we don't have silent failures
|
||||
|
||||
# Remove existing handlers
|
||||
for handler in logger.handlers[:]:
|
||||
logger.removeHandler(handler)
|
||||
|
||||
if enabled:
|
||||
handler = logging.StreamHandler()
|
||||
handler.setLevel(logging.DEBUG)
|
||||
handler.setLevel(log_level)
|
||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
def error_with_traceback(msg, *args, **kwargs):
|
||||
# If exc_info is True, include traceback in the message
|
||||
if kwargs.get('exc_info', False):
|
||||
full_msg = f"{msg}\n{traceback.format_exc()}"
|
||||
_original_error(full_msg, *args, **{k: v for k, v in kwargs.items() if k != 'exc_info'})
|
||||
else:
|
||||
_original_error(msg, *args, **kwargs)
|
||||
|
||||
logger.error = error_with_traceback
|
||||
|
||||
def update_logging_state(self: Any, context: Context) -> None:
|
||||
"""Update logging state based on user preference"""
|
||||
from .addon_preferences import save_preference
|
||||
enabled = self.enable_logging
|
||||
level = self.log_level if hasattr(self, "log_level") else "WARNING"
|
||||
save_preference("enable_logging", enabled)
|
||||
configure_logging(enabled)
|
||||
configure_logging(enabled, level)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2014 MMD Tools authors
|
||||
# This file was originally part of the MMD Tools add-on for Blender
|
||||
# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools
|
||||
# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements.
|
||||
# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit.
|
||||
|
||||
import os
|
||||
import tomllib
|
||||
|
||||
# This is a temporary workaround i be changing how MMD Tools works later when it comes to getting version number.
|
||||
|
||||
try:
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
root_dir = os.path.dirname(os.path.dirname(current_dir))
|
||||
manifest_path = os.path.join(root_dir, 'blender_manifest.toml')
|
||||
|
||||
if os.path.exists(manifest_path):
|
||||
with open(manifest_path, 'rb') as f:
|
||||
manifest = tomllib.load(f)
|
||||
AVATAR_TOOLKIT_VERSION = manifest.get('version', '0.2.1')
|
||||
else:
|
||||
AVATAR_TOOLKIT_VERSION = '0.2.1'
|
||||
except Exception:
|
||||
AVATAR_TOOLKIT_VERSION = '0.2.1'
|
||||
@@ -0,0 +1,532 @@
|
||||
# Copyright 2013 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
import contextlib
|
||||
import math
|
||||
from typing import Generator, List, Optional, TypeVar
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
from mathutils import Matrix
|
||||
|
||||
|
||||
class Props: # For API changes of only name changed properties
|
||||
show_in_front = "show_in_front"
|
||||
display_type = "display_type"
|
||||
display_size = "display_size"
|
||||
empty_display_type = "empty_display_type"
|
||||
empty_display_size = "empty_display_size"
|
||||
|
||||
|
||||
class __EditMode:
|
||||
def __init__(self, obj):
|
||||
if not isinstance(obj, bpy.types.Object):
|
||||
raise ValueError
|
||||
self.__prevMode = obj.mode
|
||||
self.__obj = obj
|
||||
self.__obj_select = obj.select_get()
|
||||
with select_object(obj):
|
||||
if obj.mode != "EDIT":
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
|
||||
def __enter__(self):
|
||||
return self.__obj.data
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
if self.__prevMode == "EDIT":
|
||||
bpy.ops.object.mode_set(mode="OBJECT") # update edited data
|
||||
bpy.ops.object.mode_set(mode=self.__prevMode)
|
||||
self.__obj.select_set(self.__obj_select)
|
||||
|
||||
|
||||
class __SelectObjects:
|
||||
def __init__(self, active_object: bpy.types.Object, selected_objects: Optional[List[bpy.types.Object]] = None):
|
||||
if not isinstance(active_object, bpy.types.Object):
|
||||
raise ValueError
|
||||
try:
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
contenxt = FnContext.ensure_context()
|
||||
|
||||
for i in contenxt.selected_objects:
|
||||
i.select_set(False)
|
||||
|
||||
self.__active_object = active_object
|
||||
self.__selected_objects = tuple(set(selected_objects) | {active_object}) if selected_objects else (active_object,)
|
||||
|
||||
self.__hides: List[bool] = []
|
||||
for i in self.__selected_objects:
|
||||
self.__hides.append(i.hide_get())
|
||||
FnContext.select_object(contenxt, i)
|
||||
FnContext.set_active_object(contenxt, active_object)
|
||||
|
||||
def __enter__(self) -> bpy.types.Object:
|
||||
return self.__active_object
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
for i, j in zip(self.__selected_objects, self.__hides, strict=False):
|
||||
try:
|
||||
i.hide_set(j)
|
||||
except ReferenceError:
|
||||
# Object may no longer exist, so skip restoring hidden state.
|
||||
pass
|
||||
|
||||
|
||||
def setParent(obj, parent):
|
||||
with select_object(parent, objects=[parent, obj]):
|
||||
bpy.ops.object.parent_set(type="OBJECT", xmirror=False, keep_transform=False)
|
||||
|
||||
|
||||
def setParentToBone(obj, parent, bone_name):
|
||||
with select_object(parent, objects=[parent, obj]):
|
||||
bpy.ops.object.mode_set(mode="POSE")
|
||||
parent.data.bones.active = parent.data.bones[bone_name]
|
||||
bpy.ops.object.parent_set(type="BONE", xmirror=False, keep_transform=False)
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
|
||||
|
||||
def edit_object(obj):
|
||||
"""Set the object interaction mode to 'EDIT'
|
||||
|
||||
It is recommended to use 'edit_object' with 'with' statement like the following code.
|
||||
|
||||
with edit_object:
|
||||
some functions...
|
||||
"""
|
||||
return __EditMode(obj)
|
||||
|
||||
|
||||
def select_object(obj: bpy.types.Object, objects: Optional[List[bpy.types.Object]] = None):
|
||||
"""Select objects.
|
||||
|
||||
It is recommended to use 'select_object' with 'with' statement like the following code.
|
||||
This function can select "hidden" objects safely.
|
||||
|
||||
with select_object(obj):
|
||||
some functions...
|
||||
"""
|
||||
# TODO: Consider reimplementing with bpy.context.temp_override,
|
||||
# but note that Blender's new API has stability issues.
|
||||
# temp_override is prone to crashes, making the current approach safer.
|
||||
# If it ain't broke, don't fix it.
|
||||
return __SelectObjects(obj, objects)
|
||||
|
||||
|
||||
def duplicateObject(obj, total_len):
|
||||
return FnContext.duplicate_object(FnContext.ensure_context(), obj, total_len)
|
||||
|
||||
|
||||
def createObject(name="Object", object_data=None, target_scene=None):
|
||||
context = FnContext.ensure_context(target_scene)
|
||||
return FnContext.set_active_object(context, FnContext.new_and_link_object(context, name, object_data))
|
||||
|
||||
|
||||
def makeSphere(segment=8, ring_count=5, radius=1.0, target_object=None):
|
||||
if target_object is None:
|
||||
mesh_data = bpy.data.meshes.new("Sphere")
|
||||
target_object = createObject(name="Sphere", object_data=mesh_data)
|
||||
|
||||
mesh = target_object.data
|
||||
bm = bmesh.new()
|
||||
bmesh.ops.create_uvsphere(
|
||||
bm,
|
||||
u_segments=segment,
|
||||
v_segments=ring_count,
|
||||
radius=radius,
|
||||
)
|
||||
for f in bm.faces:
|
||||
f.smooth = True
|
||||
bm.to_mesh(mesh)
|
||||
bm.free()
|
||||
return target_object
|
||||
|
||||
|
||||
def makeBox(size=(1, 1, 1), target_object=None):
|
||||
if target_object is None:
|
||||
mesh_data = bpy.data.meshes.new("Box")
|
||||
target_object = createObject(name="Box", object_data=mesh_data)
|
||||
|
||||
mesh = target_object.data
|
||||
bm = bmesh.new()
|
||||
bmesh.ops.create_cube(
|
||||
bm,
|
||||
size=2,
|
||||
matrix=Matrix([[size[0], 0, 0, 0], [0, size[1], 0, 0], [0, 0, size[2], 0], [0, 0, 0, 1]]),
|
||||
)
|
||||
for f in bm.faces:
|
||||
f.smooth = True
|
||||
bm.to_mesh(mesh)
|
||||
bm.free()
|
||||
return target_object
|
||||
|
||||
|
||||
def makeCapsule(segment=8, ring_count=2, radius=1.0, height=1.0, target_object=None):
|
||||
if target_object is None:
|
||||
mesh_data = bpy.data.meshes.new("Capsule")
|
||||
target_object = createObject(name="Capsule", object_data=mesh_data)
|
||||
height = max(height, 1e-3)
|
||||
|
||||
mesh = target_object.data
|
||||
bm = bmesh.new()
|
||||
verts = bm.verts
|
||||
top = (0, 0, height / 2 + radius)
|
||||
verts.new(top)
|
||||
|
||||
# def f(i):
|
||||
# return radius * i / ring_count
|
||||
def f(i):
|
||||
return radius * math.sin(0.5 * math.pi * i / ring_count)
|
||||
|
||||
for i in range(ring_count, 0, -1):
|
||||
z = f(i - 1)
|
||||
t = math.sqrt(radius**2 - z**2)
|
||||
for j in range(segment):
|
||||
theta = 2 * math.pi / segment * j
|
||||
x = t * math.sin(-theta)
|
||||
y = t * math.cos(-theta)
|
||||
verts.new((x, y, z + height / 2))
|
||||
|
||||
for i in range(ring_count):
|
||||
z = -f(i)
|
||||
t = math.sqrt(radius**2 - z**2)
|
||||
for j in range(segment):
|
||||
theta = 2 * math.pi / segment * j
|
||||
x = t * math.sin(-theta)
|
||||
y = t * math.cos(-theta)
|
||||
verts.new((x, y, z - height / 2))
|
||||
|
||||
bottom = (0, 0, -(height / 2 + radius))
|
||||
verts.new(bottom)
|
||||
if hasattr(verts, "ensure_lookup_table"):
|
||||
verts.ensure_lookup_table()
|
||||
|
||||
faces = bm.faces
|
||||
for i in range(1, segment):
|
||||
faces.new([verts[x] for x in (0, i, i + 1)])
|
||||
faces.new([verts[x] for x in (0, segment, 1)])
|
||||
offset = segment + 1
|
||||
for i in range(ring_count * 2 - 1):
|
||||
for j in range(segment - 1):
|
||||
t = offset + j
|
||||
faces.new([verts[x] for x in (t - segment, t, t + 1, t - segment + 1)])
|
||||
faces.new([verts[x] for x in (offset - 1, offset + segment - 1, offset, offset - segment)])
|
||||
offset += segment
|
||||
for i in range(segment - 1):
|
||||
t = offset + i
|
||||
faces.new([verts[x] for x in (t - segment, offset, t - segment + 1)])
|
||||
faces.new([verts[x] for x in (offset - 1, offset, offset - segment)])
|
||||
|
||||
for f in bm.faces:
|
||||
f.smooth = True
|
||||
bm.normal_update()
|
||||
bm.to_mesh(mesh)
|
||||
bm.free()
|
||||
return target_object
|
||||
|
||||
|
||||
class TransformConstraintOp:
|
||||
__MIN_MAX_MAP = {"ROTATION": "_rot", "SCALE": "_scale"}
|
||||
|
||||
@staticmethod
|
||||
def create(constraints, name, map_type):
|
||||
c = constraints.get(name, None)
|
||||
if c and c.type != "TRANSFORM":
|
||||
constraints.remove(c)
|
||||
c = None
|
||||
if c is None:
|
||||
c = constraints.new("TRANSFORM")
|
||||
c.name = name
|
||||
c.use_motion_extrapolate = True
|
||||
c.target_space = c.owner_space = "LOCAL"
|
||||
c.map_from = c.map_to = map_type
|
||||
c.map_to_x_from = "X"
|
||||
c.map_to_y_from = "Y"
|
||||
c.map_to_z_from = "Z"
|
||||
c.influence = 1
|
||||
return c
|
||||
|
||||
@classmethod
|
||||
def min_max_attributes(cls, map_type, name_id=""):
|
||||
key = (map_type, name_id)
|
||||
ret = cls.__MIN_MAX_MAP.get(key, None)
|
||||
if ret is None:
|
||||
defaults = (i + j + k for i in ("from_", "to_") for j in ("min_", "max_") for k in "xyz")
|
||||
extension = cls.__MIN_MAX_MAP.get(map_type, "")
|
||||
ret = cls.__MIN_MAX_MAP[key] = tuple(n + extension for n in defaults if name_id in n)
|
||||
return ret
|
||||
|
||||
@classmethod
|
||||
def update_min_max(cls, constraint, value, influence=1):
|
||||
c = constraint
|
||||
if not c or c.type != "TRANSFORM":
|
||||
return
|
||||
|
||||
for attr in cls.min_max_attributes(c.map_from, "from_min"):
|
||||
setattr(c, attr, -value)
|
||||
for attr in cls.min_max_attributes(c.map_from, "from_max"):
|
||||
setattr(c, attr, value)
|
||||
|
||||
if influence is None:
|
||||
return
|
||||
|
||||
for attr in cls.min_max_attributes(c.map_to, "to_min"):
|
||||
setattr(c, attr, -value * influence)
|
||||
for attr in cls.min_max_attributes(c.map_to, "to_max"):
|
||||
setattr(c, attr, value * influence)
|
||||
|
||||
|
||||
class FnObject:
|
||||
def __init__(self):
|
||||
raise NotImplementedError("This class is not expected to be instantiated.")
|
||||
|
||||
@staticmethod
|
||||
def mesh_remove_shape_key(mesh_object: bpy.types.Object, shape_key: bpy.types.ShapeKey):
|
||||
assert isinstance(mesh_object.data, bpy.types.Mesh)
|
||||
|
||||
key: bpy.types.Key = shape_key.id_data
|
||||
assert key == mesh_object.data.shape_keys
|
||||
|
||||
if mesh_object.animation_data is not None:
|
||||
fc_curve: bpy.types.FCurve
|
||||
for fc_curve in mesh_object.animation_data.drivers:
|
||||
if not fc_curve.data_path.startswith(shape_key.path_from_id()):
|
||||
continue
|
||||
mesh_object.driver_remove(fc_curve.data_path)
|
||||
|
||||
key_blocks = key.key_blocks
|
||||
|
||||
last_index = mesh_object.active_shape_key_index or 0
|
||||
if last_index >= key_blocks.find(shape_key.name):
|
||||
last_index = max(0, last_index - 1)
|
||||
|
||||
mesh_object.shape_key_remove(shape_key)
|
||||
mesh_object.active_shape_key_index = min(last_index, len(key_blocks) - 1)
|
||||
|
||||
|
||||
ADDON_PREFERENCE_ATTRIBUTE_VALUE_TYPE = TypeVar("ADDON_PREFERENCE_ATTRIBUTE_VALUE_TYPE")
|
||||
|
||||
|
||||
class FnContext:
|
||||
def __init__(self):
|
||||
raise NotImplementedError("This class is not expected to be instantiated.")
|
||||
|
||||
@staticmethod
|
||||
def ensure_context(context: Optional[bpy.types.Context] = None) -> bpy.types.Context:
|
||||
return context or bpy.context
|
||||
|
||||
@staticmethod
|
||||
def get_active_object(context: bpy.types.Context) -> Optional[bpy.types.Object]:
|
||||
# Added defensive programming for get methods
|
||||
# Related to: https://github.com/MMD-Blender/blender_mmd_tools_local/issues/176
|
||||
if context is None or not hasattr(context, "active_object"):
|
||||
return None
|
||||
return context.active_object
|
||||
|
||||
@staticmethod
|
||||
def set_active_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object:
|
||||
context.view_layer.objects.active = obj
|
||||
return obj
|
||||
|
||||
@staticmethod
|
||||
def set_active_and_select_single_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object:
|
||||
return FnContext.set_active_object(context, FnContext.select_single_object(context, obj))
|
||||
|
||||
@staticmethod
|
||||
def get_scene_objects(context: bpy.types.Context) -> bpy.types.SceneObjects:
|
||||
# Added defensive programming for get methods
|
||||
# Added for consistency with get_active_object
|
||||
if context is None or not hasattr(context, "scene") or not hasattr(context.scene, "objects"):
|
||||
return []
|
||||
return context.scene.objects
|
||||
|
||||
@staticmethod
|
||||
def ensure_selectable(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object:
|
||||
obj.hide_viewport = False
|
||||
obj.hide_select = False
|
||||
obj.hide_set(False)
|
||||
|
||||
if obj not in context.selectable_objects:
|
||||
|
||||
def __layer_check(layer_collection: bpy.types.LayerCollection) -> bool:
|
||||
for lc in layer_collection.children:
|
||||
if __layer_check(lc):
|
||||
lc.hide_viewport = False
|
||||
lc.collection.hide_viewport = False
|
||||
lc.collection.hide_select = False
|
||||
return True
|
||||
if obj in layer_collection.collection.objects.values():
|
||||
if layer_collection.exclude:
|
||||
layer_collection.exclude = False
|
||||
return True
|
||||
return False
|
||||
|
||||
selected_objects = set(context.selected_objects)
|
||||
__layer_check(context.view_layer.layer_collection)
|
||||
if len(context.selected_objects) != len(selected_objects):
|
||||
for i in context.selected_objects:
|
||||
if i not in selected_objects:
|
||||
i.select_set(False)
|
||||
return obj
|
||||
|
||||
@staticmethod
|
||||
def select_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object:
|
||||
FnContext.ensure_selectable(context, obj).select_set(True)
|
||||
return obj
|
||||
|
||||
@staticmethod
|
||||
def select_objects(context: bpy.types.Context, *objects: bpy.types.Object) -> List[bpy.types.Object]:
|
||||
return [FnContext.select_object(context, obj) for obj in objects]
|
||||
|
||||
@staticmethod
|
||||
def select_single_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object:
|
||||
for i in context.selected_objects:
|
||||
if i != obj:
|
||||
i.select_set(False)
|
||||
return FnContext.select_object(context, obj)
|
||||
|
||||
@staticmethod
|
||||
def link_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object:
|
||||
context.collection.objects.link(obj)
|
||||
return obj
|
||||
|
||||
@staticmethod
|
||||
def new_and_link_object(context: bpy.types.Context, name: str, object_data: Optional[bpy.types.ID]) -> bpy.types.Object:
|
||||
return FnContext.link_object(context, bpy.data.objects.new(name=name, object_data=object_data))
|
||||
|
||||
@staticmethod
|
||||
def duplicate_object(context: bpy.types.Context, object_to_duplicate: bpy.types.Object, target_count: int) -> List[bpy.types.Object]:
|
||||
"""
|
||||
Duplicate object.
|
||||
|
||||
This function duplicates the given object and returns a list of duplicated objects.
|
||||
|
||||
Args:
|
||||
context (bpy.types.Context): The context in which the duplication is performed.
|
||||
object_to_duplicate (bpy.types.Object): The object to be duplicated.
|
||||
target_count (int): The desired count of duplicated objects.
|
||||
|
||||
Returns:
|
||||
List[bpy.types.Object]: A list of duplicated objects.
|
||||
|
||||
Raises:
|
||||
AssertionError: If the number of selected objects in the context is not equal to 1 or if the selected object is not the same as the object to be duplicated.
|
||||
"""
|
||||
for o in context.selected_objects:
|
||||
o.select_set(False)
|
||||
object_to_duplicate.select_set(True)
|
||||
assert len(context.selected_objects) == 1
|
||||
assert context.selected_objects[0] == object_to_duplicate
|
||||
last_selected_objects = result_objects = [object_to_duplicate]
|
||||
while len(result_objects) < target_count:
|
||||
bpy.ops.object.duplicate()
|
||||
result_objects.extend(context.selected_objects)
|
||||
remain = target_count - len(result_objects) - len(context.selected_objects)
|
||||
if remain < 0:
|
||||
last_selected_objects = context.selected_objects
|
||||
for i in range(-remain):
|
||||
last_selected_objects[i].select_set(False)
|
||||
else:
|
||||
for i in range(min(remain, len(last_selected_objects))):
|
||||
last_selected_objects[i].select_set(True)
|
||||
last_selected_objects = context.selected_objects
|
||||
assert len(result_objects) == target_count
|
||||
return result_objects
|
||||
|
||||
@staticmethod
|
||||
def find_user_layer_collection_by_object(context: bpy.types.Context, target_object: bpy.types.Object) -> Optional[bpy.types.LayerCollection]:
|
||||
"""
|
||||
Find the layer collection that contains the given target_object in the user's collections.
|
||||
|
||||
Args:
|
||||
context (bpy.types.Context): The Blender context.
|
||||
target_object (bpy.types.Object): The target object to find the layer collection for.
|
||||
|
||||
Returns:
|
||||
Optional[bpy.types.LayerCollection]: The layer collection that contains the target_object, or None if not found.
|
||||
"""
|
||||
scene_layer_collection: bpy.types.LayerCollection = context.view_layer.layer_collection
|
||||
|
||||
def find_layer_collection_by_name(layer_collection: bpy.types.LayerCollection, name: str) -> Optional[bpy.types.LayerCollection]:
|
||||
if layer_collection.name == name:
|
||||
return layer_collection
|
||||
|
||||
child_layer_collection: bpy.types.LayerCollection
|
||||
for child_layer_collection in layer_collection.children:
|
||||
found = find_layer_collection_by_name(child_layer_collection, name)
|
||||
if found is not None:
|
||||
return found
|
||||
|
||||
return None
|
||||
|
||||
user_collection: bpy.types.Collection
|
||||
for user_collection in target_object.users_collection:
|
||||
found = find_layer_collection_by_name(scene_layer_collection, user_collection.name)
|
||||
if found is not None:
|
||||
return found
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@contextlib.contextmanager
|
||||
def temp_override_active_layer_collection(context: bpy.types.Context, target_object: bpy.types.Object) -> Generator[bpy.types.Context, None, None]:
|
||||
"""
|
||||
Context manager to temporarily override the active_layer_collection that contains the target object.
|
||||
|
||||
This context manager allows you to temporarily change the active_layer_collection in the given context to the one that contains the target object.
|
||||
It ensures that the original active_layer_collection is restored after the context is exited.
|
||||
|
||||
Args:
|
||||
context (bpy.types.Context): The context in which the active_layer_collection will be overridden.
|
||||
target_object (bpy.types.Object): The target object whose layer collection will be set as the active_layer_collection.
|
||||
|
||||
Yields:
|
||||
bpy.types.Context: The modified context with the active_layer_collection overridden.
|
||||
|
||||
Example:
|
||||
with FnContext.temp_override_active_layer_collection(context, target_object):
|
||||
# Perform operations with the modified context
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
target_object.select_set(True)
|
||||
bpy.ops.object.delete()
|
||||
|
||||
"""
|
||||
original_layer_collection = context.view_layer.active_layer_collection
|
||||
target_layer_collection = FnContext.find_user_layer_collection_by_object(context, target_object)
|
||||
if target_layer_collection is not None:
|
||||
context.view_layer.active_layer_collection = target_layer_collection
|
||||
try:
|
||||
yield context
|
||||
finally:
|
||||
if context.view_layer.active_layer_collection.name != original_layer_collection.name:
|
||||
context.view_layer.active_layer_collection = original_layer_collection
|
||||
|
||||
@staticmethod
|
||||
def __get_addon_preferences(context: bpy.types.Context) -> Optional[bpy.types.AddonPreferences]:
|
||||
addon: bpy.types.Addon = context.preferences.addons.get(__package__, None)
|
||||
return addon.preferences if addon else None
|
||||
|
||||
@staticmethod
|
||||
def get_addon_preferences_attribute(context: bpy.types.Context, attribute_name: str, default_value: ADDON_PREFERENCE_ATTRIBUTE_VALUE_TYPE = None) -> ADDON_PREFERENCE_ATTRIBUTE_VALUE_TYPE:
|
||||
return getattr(FnContext.__get_addon_preferences(context), attribute_name, default_value)
|
||||
|
||||
@staticmethod
|
||||
def temp_override_objects(
|
||||
context: bpy.types.Context,
|
||||
window: Optional[bpy.types.Window] = None,
|
||||
area: Optional[bpy.types.Area] = None,
|
||||
region: Optional[bpy.types.Region] = None,
|
||||
active_object: Optional[bpy.types.Object] = None,
|
||||
selected_objects: Optional[List[bpy.types.Object]] = None,
|
||||
**keywords,
|
||||
) -> Generator[bpy.types.Context, None, None]:
|
||||
if active_object is not None:
|
||||
keywords["active_object"] = active_object
|
||||
keywords["object"] = active_object
|
||||
|
||||
if selected_objects is not None:
|
||||
keywords["selected_objects"] = selected_objects
|
||||
keywords["selected_editable_objects"] = selected_objects
|
||||
|
||||
return context.temp_override(window=window, area=area, region=region, **keywords)
|
||||
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2014 MMD Tools authors
|
||||
# This file was originally part of the MMD Tools add-on for Blender
|
||||
# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools
|
||||
# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements.
|
||||
# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit.
|
||||
@@ -0,0 +1,565 @@
|
||||
# Copyright 2015 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Iterable, Optional, Set
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
from .. import bpyutils
|
||||
from ..bpyutils import TransformConstraintOp
|
||||
from ..utils import ItemOp
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..properties.pose_bone import MMDBone
|
||||
from ..properties.root import MMDDisplayItemFrame, MMDRoot
|
||||
|
||||
|
||||
def remove_constraint(constraints, name):
|
||||
c = constraints.get(name, None)
|
||||
if c:
|
||||
constraints.remove(c)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def remove_edit_bones(edit_bones, bone_names):
|
||||
for name in bone_names:
|
||||
b = edit_bones.get(name, None)
|
||||
if b:
|
||||
edit_bones.remove(b)
|
||||
|
||||
|
||||
BONE_COLLECTION_CUSTOM_PROPERTY_NAME = "mmd_tools_local"
|
||||
BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_SPECIAL = "special collection"
|
||||
BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_NORMAL = "normal collection"
|
||||
BONE_COLLECTION_NAME_SHADOW = "mmd_shadow"
|
||||
BONE_COLLECTION_NAME_DUMMY = "mmd_dummy"
|
||||
|
||||
SPECIAL_BONE_COLLECTION_NAMES = [BONE_COLLECTION_NAME_SHADOW, BONE_COLLECTION_NAME_DUMMY]
|
||||
|
||||
|
||||
class FnBone:
|
||||
AUTO_LOCAL_AXIS_ARMS = ("左肩", "左腕", "左ひじ", "左手首", "右腕", "右肩", "右ひじ", "右手首")
|
||||
AUTO_LOCAL_AXIS_FINGERS = ("親指", "人指", "中指", "薬指", "小指")
|
||||
AUTO_LOCAL_AXIS_SEMI_STANDARD_ARMS = ("左腕捩", "左手捩", "左肩P", "左ダミー", "右腕捩", "右手捩", "右肩P", "右ダミー")
|
||||
|
||||
def __init__(self):
|
||||
raise NotImplementedError("This class cannot be instantiated.")
|
||||
|
||||
@staticmethod
|
||||
def find_pose_bone_by_bone_id(armature_object: bpy.types.Object, bone_id: int) -> Optional[bpy.types.PoseBone]:
|
||||
for bone in armature_object.pose.bones:
|
||||
if bone.mmd_bone.bone_id != bone_id:
|
||||
continue
|
||||
return bone
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def __new_bone_id(armature_object: bpy.types.Object) -> int:
|
||||
return max(b.mmd_bone.bone_id for b in armature_object.pose.bones) + 1
|
||||
|
||||
@staticmethod
|
||||
def get_or_assign_bone_id(pose_bone: bpy.types.PoseBone) -> int:
|
||||
if pose_bone.mmd_bone.bone_id < 0:
|
||||
pose_bone.mmd_bone.bone_id = FnBone.__new_bone_id(pose_bone.id_data)
|
||||
return pose_bone.mmd_bone.bone_id
|
||||
|
||||
@staticmethod
|
||||
def __get_selected_pose_bones(armature_object: bpy.types.Object) -> Iterable[bpy.types.PoseBone]:
|
||||
if armature_object.mode == "EDIT":
|
||||
bpy.ops.object.mode_set(mode="OBJECT") # update selected bones
|
||||
bpy.ops.object.mode_set(mode="EDIT") # back to edit mode
|
||||
context_selected_bones = bpy.context.selected_pose_bones or bpy.context.selected_bones or []
|
||||
bones = armature_object.pose.bones
|
||||
return (bones[b.name] for b in context_selected_bones if not bones[b.name].is_mmd_shadow_bone)
|
||||
|
||||
@staticmethod
|
||||
def load_bone_fixed_axis(armature_object: bpy.types.Object, enable=True):
|
||||
for b in FnBone.__get_selected_pose_bones(armature_object):
|
||||
mmd_bone: MMDBone = b.mmd_bone
|
||||
mmd_bone.enabled_fixed_axis = enable
|
||||
lock_rotation = b.lock_rotation[:]
|
||||
if enable:
|
||||
axes = b.bone.matrix_local.to_3x3().transposed()
|
||||
if lock_rotation.count(False) == 1:
|
||||
mmd_bone.fixed_axis = axes[lock_rotation.index(False)].xzy
|
||||
else:
|
||||
mmd_bone.fixed_axis = axes[1].xzy # Y-axis
|
||||
elif all(b.lock_location) and lock_rotation.count(True) > 1 and lock_rotation == (b.lock_ik_x, b.lock_ik_y, b.lock_ik_z):
|
||||
# unlock transform locks if fixed axis was applied
|
||||
b.lock_ik_x, b.lock_ik_y, b.lock_ik_z = b.lock_rotation = (False, False, False)
|
||||
b.lock_location = b.lock_scale = (False, False, False)
|
||||
|
||||
@staticmethod
|
||||
def setup_special_bone_collections(armature_object: bpy.types.Object) -> bpy.types.Object:
|
||||
armature: bpy.types.Armature = armature_object.data
|
||||
bone_collections = armature.collections
|
||||
for bone_collection_name in SPECIAL_BONE_COLLECTION_NAMES:
|
||||
if bone_collection_name in bone_collections:
|
||||
continue
|
||||
bone_collection = bone_collections.new(bone_collection_name)
|
||||
FnBone.__set_bone_collection_to_special(bone_collection, is_visible=False)
|
||||
return armature_object
|
||||
|
||||
@staticmethod
|
||||
def __is_mmd_tools_local_bone_collection(bone_collection: bpy.types.BoneCollection) -> bool:
|
||||
return BONE_COLLECTION_CUSTOM_PROPERTY_NAME in bone_collection
|
||||
|
||||
@staticmethod
|
||||
def __is_special_bone_collection(bone_collection: bpy.types.BoneCollection) -> bool:
|
||||
return bone_collection.get(BONE_COLLECTION_CUSTOM_PROPERTY_NAME) == BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_SPECIAL
|
||||
|
||||
@staticmethod
|
||||
def __set_bone_collection_to_special(bone_collection: bpy.types.BoneCollection, is_visible: bool):
|
||||
bone_collection[BONE_COLLECTION_CUSTOM_PROPERTY_NAME] = BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_SPECIAL
|
||||
bone_collection.is_visible = is_visible
|
||||
|
||||
@staticmethod
|
||||
def __is_normal_bone_collection(bone_collection: bpy.types.BoneCollection) -> bool:
|
||||
return bone_collection.get(BONE_COLLECTION_CUSTOM_PROPERTY_NAME) == BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_NORMAL
|
||||
|
||||
@staticmethod
|
||||
def __set_bone_collection_to_normal(bone_collection: bpy.types.BoneCollection):
|
||||
bone_collection[BONE_COLLECTION_CUSTOM_PROPERTY_NAME] = BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_NORMAL
|
||||
|
||||
@staticmethod
|
||||
def __set_edit_bone_to_special(edit_bone: bpy.types.EditBone, bone_collection_name: str) -> bpy.types.EditBone:
|
||||
edit_bone.id_data.collections[bone_collection_name].assign(edit_bone)
|
||||
edit_bone.use_deform = False
|
||||
return edit_bone
|
||||
|
||||
@staticmethod
|
||||
def set_edit_bone_to_dummy(edit_bone: bpy.types.EditBone) -> bpy.types.EditBone:
|
||||
return FnBone.__set_edit_bone_to_special(edit_bone, BONE_COLLECTION_NAME_DUMMY)
|
||||
|
||||
@staticmethod
|
||||
def set_edit_bone_to_shadow(edit_bone: bpy.types.EditBone) -> bpy.types.EditBone:
|
||||
return FnBone.__set_edit_bone_to_special(edit_bone, BONE_COLLECTION_NAME_SHADOW)
|
||||
|
||||
@staticmethod
|
||||
def __unassign_mmd_tools_local_bone_collections(edit_bone: bpy.types.EditBone) -> bpy.types.EditBone:
|
||||
for bone_collection in edit_bone.collections:
|
||||
if not FnBone.__is_mmd_tools_local_bone_collection(bone_collection):
|
||||
continue
|
||||
bone_collection.unassign(edit_bone)
|
||||
return edit_bone
|
||||
|
||||
@staticmethod
|
||||
def sync_bone_collections_from_display_item_frames(armature_object: bpy.types.Object):
|
||||
armature: bpy.types.Armature = armature_object.data
|
||||
bone_collections = armature.collections
|
||||
|
||||
from .model import FnModel
|
||||
|
||||
root_object: bpy.types.Object = FnModel.find_root_object(armature_object)
|
||||
mmd_root: MMDRoot = root_object.mmd_root
|
||||
|
||||
bones = armature.bones
|
||||
used_groups = set()
|
||||
unassigned_bone_names = {b.name for b in bones}
|
||||
|
||||
for frame in mmd_root.display_item_frames:
|
||||
for item in frame.data:
|
||||
if item.type == "BONE" and item.name in unassigned_bone_names:
|
||||
unassigned_bone_names.remove(item.name)
|
||||
group_name = frame.name
|
||||
used_groups.add(group_name)
|
||||
bone_collection = bone_collections.get(group_name)
|
||||
if bone_collection is None:
|
||||
bone_collection = bone_collections.new(name=group_name)
|
||||
FnBone.__set_bone_collection_to_normal(bone_collection)
|
||||
bone_collection.assign(bones[item.name])
|
||||
|
||||
for name in unassigned_bone_names:
|
||||
for bc in bones[name].collections:
|
||||
if not FnBone.__is_mmd_tools_local_bone_collection(bc):
|
||||
continue
|
||||
if not FnBone.__is_normal_bone_collection(bc):
|
||||
continue
|
||||
bc.unassign(bones[name])
|
||||
|
||||
# remove unused bone groups
|
||||
for bone_collection in bone_collections.values():
|
||||
if bone_collection.name in used_groups:
|
||||
continue
|
||||
if not FnBone.__is_mmd_tools_local_bone_collection(bone_collection):
|
||||
continue
|
||||
if not FnBone.__is_normal_bone_collection(bone_collection):
|
||||
continue
|
||||
bone_collections.remove(bone_collection)
|
||||
|
||||
@staticmethod
|
||||
def sync_display_item_frames_from_bone_collections(armature_object: bpy.types.Object):
|
||||
armature: bpy.types.Armature = armature_object.data
|
||||
bone_collections: bpy.types.BoneCollections = armature.collections
|
||||
|
||||
from .model import FnModel
|
||||
|
||||
root_object: bpy.types.Object = FnModel.find_root_object(armature_object)
|
||||
mmd_root: MMDRoot = root_object.mmd_root
|
||||
display_item_frames = mmd_root.display_item_frames
|
||||
|
||||
used_frame_index: Set[int] = set()
|
||||
|
||||
bone_collection: bpy.types.BoneCollection
|
||||
for bone_collection in bone_collections:
|
||||
if len(bone_collection.bones) == 0 or FnBone.__is_special_bone_collection(bone_collection):
|
||||
continue
|
||||
|
||||
bone_collection_name = bone_collection.name
|
||||
display_item_frame: Optional[MMDDisplayItemFrame] = display_item_frames.get(bone_collection_name)
|
||||
if display_item_frame is None:
|
||||
display_item_frame = display_item_frames.add()
|
||||
display_item_frame.name = bone_collection_name
|
||||
display_item_frame.name_e = bone_collection_name
|
||||
used_frame_index.add(display_item_frames.find(bone_collection_name))
|
||||
|
||||
ItemOp.resize(display_item_frame.data, len(bone_collection.bones))
|
||||
for display_item, bone in zip(display_item_frame.data, bone_collection.bones, strict=False):
|
||||
display_item.type = "BONE"
|
||||
display_item.name = bone.name
|
||||
|
||||
for i in reversed(range(len(display_item_frames))):
|
||||
if i in used_frame_index:
|
||||
continue
|
||||
display_item_frame = display_item_frames[i]
|
||||
if display_item_frame.is_special:
|
||||
if display_item_frame.name != "表情":
|
||||
display_item_frame.data.clear()
|
||||
else:
|
||||
display_item_frames.remove(i)
|
||||
mmd_root.active_display_item_frame = 0
|
||||
|
||||
@staticmethod
|
||||
def apply_bone_fixed_axis(armature_object: bpy.types.Object):
|
||||
bone_map = {}
|
||||
for b in armature_object.pose.bones:
|
||||
if b.is_mmd_shadow_bone or not b.mmd_bone.enabled_fixed_axis:
|
||||
continue
|
||||
mmd_bone: MMDBone = b.mmd_bone
|
||||
parent_tip = b.parent and not b.parent.is_mmd_shadow_bone and b.parent.mmd_bone.is_tip
|
||||
bone_map[b.name] = (mmd_bone.fixed_axis.normalized(), mmd_bone.is_tip, parent_tip)
|
||||
|
||||
force_align = True
|
||||
with bpyutils.edit_object(armature_object) as data:
|
||||
bone: bpy.types.EditBone
|
||||
for bone in data.edit_bones:
|
||||
if bone.name not in bone_map:
|
||||
bone.select = False
|
||||
continue
|
||||
fixed_axis, is_tip, parent_tip = bone_map[bone.name]
|
||||
if fixed_axis.length:
|
||||
axes = [bone.x_axis, bone.y_axis, bone.z_axis]
|
||||
direction = fixed_axis.normalized().xzy
|
||||
idx, val = max([(i, direction.dot(v)) for i, v in enumerate(axes)], key=lambda x: abs(x[1]))
|
||||
idx_1, idx_2 = (idx + 1) % 3, (idx + 2) % 3
|
||||
axes[idx] = -direction if val < 0 else direction
|
||||
axes[idx_2] = axes[idx].cross(axes[idx_1])
|
||||
axes[idx_1] = axes[idx_2].cross(axes[idx])
|
||||
if parent_tip and bone.use_connect:
|
||||
bone.use_connect = False
|
||||
bone.head = bone.parent.head
|
||||
if force_align:
|
||||
tail = bone.head + axes[1].normalized() * bone.length
|
||||
if is_tip or (tail - bone.tail).length > 1e-4:
|
||||
for c in bone.children:
|
||||
if c.use_connect:
|
||||
c.use_connect = False
|
||||
if is_tip:
|
||||
c.head = bone.head
|
||||
bone.tail = tail
|
||||
bone.align_roll(axes[2])
|
||||
bone_map[bone.name] = tuple(i != idx for i in range(3))
|
||||
else:
|
||||
bone_map[bone.name] = (True, True, True)
|
||||
bone.select = True
|
||||
|
||||
for bone_name, locks in bone_map.items():
|
||||
b = armature_object.pose.bones[bone_name]
|
||||
b.lock_location = (True, True, True)
|
||||
b.lock_ik_x, b.lock_ik_y, b.lock_ik_z = b.lock_rotation = locks
|
||||
|
||||
@staticmethod
|
||||
def load_bone_local_axes(armature_object: bpy.types.Object, enable=True):
|
||||
for b in FnBone.__get_selected_pose_bones(armature_object):
|
||||
mmd_bone: MMDBone = b.mmd_bone
|
||||
mmd_bone.enabled_local_axes = enable
|
||||
if enable:
|
||||
axes = b.bone.matrix_local.to_3x3().transposed()
|
||||
mmd_bone.local_axis_x = axes[0].xzy
|
||||
mmd_bone.local_axis_z = axes[2].xzy
|
||||
|
||||
@staticmethod
|
||||
def apply_bone_local_axes(armature_object: bpy.types.Object):
|
||||
bone_map = {}
|
||||
for b in armature_object.pose.bones:
|
||||
if b.is_mmd_shadow_bone or not b.mmd_bone.enabled_local_axes:
|
||||
continue
|
||||
mmd_bone: MMDBone = b.mmd_bone
|
||||
bone_map[b.name] = (mmd_bone.local_axis_x, mmd_bone.local_axis_z)
|
||||
|
||||
with bpyutils.edit_object(armature_object) as data:
|
||||
bone: bpy.types.EditBone
|
||||
for bone in data.edit_bones:
|
||||
if bone.name not in bone_map:
|
||||
bone.select = False
|
||||
continue
|
||||
local_axis_x, local_axis_z = bone_map[bone.name]
|
||||
FnBone.update_bone_roll(bone, local_axis_x, local_axis_z)
|
||||
bone.select = True
|
||||
|
||||
@staticmethod
|
||||
def update_bone_roll(edit_bone: bpy.types.EditBone, mmd_local_axis_x, mmd_local_axis_z):
|
||||
axes = FnBone.get_axes(mmd_local_axis_x, mmd_local_axis_z)
|
||||
idx, val = max([(i, edit_bone.vector.dot(v)) for i, v in enumerate(axes)], key=lambda x: abs(x[1]))
|
||||
edit_bone.align_roll(axes[(idx - 1) % 3 if val < 0 else (idx + 1) % 3])
|
||||
|
||||
@staticmethod
|
||||
def get_axes(mmd_local_axis_x, mmd_local_axis_z):
|
||||
x_axis = Vector(mmd_local_axis_x).normalized().xzy
|
||||
z_axis = Vector(mmd_local_axis_z).normalized().xzy
|
||||
y_axis = z_axis.cross(x_axis).normalized()
|
||||
z_axis = x_axis.cross(y_axis).normalized() # correction
|
||||
return (x_axis, y_axis, z_axis)
|
||||
|
||||
@staticmethod
|
||||
def apply_auto_bone_roll(armature):
|
||||
bone_names = [b.name for b in armature.pose.bones if not b.is_mmd_shadow_bone and not b.mmd_bone.enabled_local_axes and FnBone.has_auto_local_axis(b.mmd_bone.name_j)]
|
||||
with bpyutils.edit_object(armature) as data:
|
||||
bone: bpy.types.EditBone
|
||||
for bone in data.edit_bones:
|
||||
if bone.name not in bone_names:
|
||||
continue
|
||||
FnBone.update_auto_bone_roll(bone)
|
||||
bone.select = True
|
||||
|
||||
@staticmethod
|
||||
def update_auto_bone_roll(edit_bone):
|
||||
# make a triangle face (p1,p2,p3)
|
||||
p1 = edit_bone.head.copy()
|
||||
p2 = edit_bone.tail.copy()
|
||||
p3 = p2.copy()
|
||||
# translate p3 in xz plane
|
||||
# the normal vector of the face tracks -Y direction
|
||||
xz = Vector((p2.x - p1.x, p2.z - p1.z))
|
||||
xz.normalize()
|
||||
theta = math.atan2(xz.y, xz.x)
|
||||
norm = edit_bone.vector.length
|
||||
p3.z += norm * math.cos(theta)
|
||||
p3.x -= norm * math.sin(theta)
|
||||
# calculate the normal vector of the face
|
||||
y = (p2 - p1).normalized()
|
||||
z_tmp = (p3 - p1).normalized()
|
||||
x = y.cross(z_tmp) # normal vector
|
||||
# z = x.cross(y)
|
||||
FnBone.update_bone_roll(edit_bone, y.xzy, x.xzy)
|
||||
|
||||
@staticmethod
|
||||
def has_auto_local_axis(name_j):
|
||||
if name_j:
|
||||
if name_j in FnBone.AUTO_LOCAL_AXIS_ARMS or name_j in FnBone.AUTO_LOCAL_AXIS_SEMI_STANDARD_ARMS:
|
||||
return True
|
||||
for finger_name in FnBone.AUTO_LOCAL_AXIS_FINGERS:
|
||||
if finger_name in name_j:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def clean_additional_transformation(armature_object: bpy.types.Object):
|
||||
if armature_object.type != "ARMATURE" or armature_object.pose is None:
|
||||
return
|
||||
|
||||
# clean constraints
|
||||
p_bone: bpy.types.PoseBone
|
||||
for p_bone in armature_object.pose.bones:
|
||||
p_bone.mmd_bone.is_additional_transform_dirty = True
|
||||
constraints = p_bone.constraints
|
||||
remove_constraint(constraints, "mmd_additional_rotation")
|
||||
remove_constraint(constraints, "mmd_additional_location")
|
||||
if remove_constraint(constraints, "mmd_additional_parent"):
|
||||
p_bone.bone.use_inherit_rotation = True
|
||||
# clean shadow bones
|
||||
shadow_bone_types = {
|
||||
"DUMMY",
|
||||
"SHADOW",
|
||||
"ADDITIONAL_TRANSFORM",
|
||||
"ADDITIONAL_TRANSFORM_INVERT",
|
||||
}
|
||||
|
||||
def __is_at_shadow_bone(b):
|
||||
return b.is_mmd_shadow_bone and b.mmd_shadow_bone_type in shadow_bone_types
|
||||
|
||||
shadow_bone_names = [b.name for b in armature_object.pose.bones if __is_at_shadow_bone(b)]
|
||||
if len(shadow_bone_names) > 0:
|
||||
with bpyutils.edit_object(armature_object) as data:
|
||||
remove_edit_bones(data.edit_bones, shadow_bone_names)
|
||||
|
||||
@staticmethod
|
||||
def apply_additional_transformation(armature_object: bpy.types.Object):
|
||||
def __is_dirty_bone(b):
|
||||
if b.is_mmd_shadow_bone:
|
||||
return False
|
||||
mmd_bone = b.mmd_bone
|
||||
if mmd_bone.has_additional_rotation or mmd_bone.has_additional_location:
|
||||
return True
|
||||
return mmd_bone.is_additional_transform_dirty
|
||||
|
||||
dirty_bones = [b for b in armature_object.pose.bones if __is_dirty_bone(b)]
|
||||
|
||||
# setup constraints
|
||||
shadow_bone_pool = []
|
||||
for p_bone in dirty_bones:
|
||||
sb = FnBone.__setup_constraints(p_bone)
|
||||
if sb:
|
||||
shadow_bone_pool.append(sb)
|
||||
|
||||
# setup shadow bones
|
||||
with bpyutils.edit_object(armature_object) as data:
|
||||
edit_bones = data.edit_bones
|
||||
for sb in shadow_bone_pool:
|
||||
sb.update_edit_bones(edit_bones)
|
||||
|
||||
pose_bones = armature_object.pose.bones
|
||||
for sb in shadow_bone_pool:
|
||||
sb.update_pose_bones(pose_bones)
|
||||
|
||||
# finish
|
||||
for p_bone in dirty_bones:
|
||||
p_bone.mmd_bone.is_additional_transform_dirty = False
|
||||
|
||||
@staticmethod
|
||||
def __setup_constraints(p_bone):
|
||||
bone_name = p_bone.name
|
||||
mmd_bone = p_bone.mmd_bone
|
||||
influence = mmd_bone.additional_transform_influence
|
||||
target_bone = mmd_bone.additional_transform_bone
|
||||
mute_rotation = not mmd_bone.has_additional_rotation # or p_bone.is_in_ik_chain
|
||||
mute_location = not mmd_bone.has_additional_location
|
||||
|
||||
constraints = p_bone.constraints
|
||||
if not target_bone or (mute_rotation and mute_location) or influence == 0:
|
||||
rot = remove_constraint(constraints, "mmd_additional_rotation")
|
||||
loc = remove_constraint(constraints, "mmd_additional_location")
|
||||
if rot or loc:
|
||||
return _AT_ShadowBoneRemove(bone_name)
|
||||
return None
|
||||
|
||||
shadow_bone = _AT_ShadowBoneCreate(bone_name, target_bone)
|
||||
|
||||
def __config(name, mute, map_type, value):
|
||||
if mute:
|
||||
remove_constraint(constraints, name)
|
||||
return
|
||||
c = TransformConstraintOp.create(constraints, name, map_type)
|
||||
# FIXME: Some bones require specific rotation modes to match MMD behavior.
|
||||
# Currently using hardcoded bone names as a temporary solution.
|
||||
# See https://github.com/MMD-Blender/blender_mmd_tools_local/issues/242
|
||||
if bone_name in {"左肩C", "右肩C", "肩C.L", "肩C.R", "肩C_L", "肩C_R"}:
|
||||
c.from_rotation_mode = "ZYX" # Best matches MMD behavior for shoulder bones
|
||||
c.target = p_bone.id_data
|
||||
shadow_bone.add_constraint(c)
|
||||
TransformConstraintOp.update_min_max(c, value, influence)
|
||||
|
||||
__config("mmd_additional_rotation", mute_rotation, "ROTATION", math.pi)
|
||||
__config("mmd_additional_location", mute_location, "LOCATION", 100)
|
||||
|
||||
return shadow_bone
|
||||
|
||||
@staticmethod
|
||||
def update_additional_transform_influence(pose_bone: bpy.types.PoseBone):
|
||||
influence = pose_bone.mmd_bone.additional_transform_influence
|
||||
constraints = pose_bone.constraints
|
||||
c = constraints.get("mmd_additional_rotation", None)
|
||||
TransformConstraintOp.update_min_max(c, math.pi, influence)
|
||||
c = constraints.get("mmd_additional_location", None)
|
||||
TransformConstraintOp.update_min_max(c, 100, influence)
|
||||
|
||||
|
||||
class MigrationFnBone:
|
||||
"""Migration Functions for old MMD models broken by bugs or issues"""
|
||||
|
||||
@staticmethod
|
||||
def fix_mmd_ik_limit_override(armature_object: bpy.types.Object):
|
||||
pose_bone: bpy.types.PoseBone
|
||||
for pose_bone in armature_object.pose.bones:
|
||||
constraint: bpy.types.Constraint
|
||||
for constraint in pose_bone.constraints:
|
||||
if constraint.type == "LIMIT_ROTATION" and "mmd_ik_limit_override" in constraint.name:
|
||||
constraint.owner_space = "LOCAL"
|
||||
|
||||
|
||||
class _AT_ShadowBoneRemove:
|
||||
def __init__(self, bone_name):
|
||||
self.__shadow_bone_names = ("_dummy_" + bone_name, "_shadow_" + bone_name)
|
||||
|
||||
def update_edit_bones(self, edit_bones):
|
||||
remove_edit_bones(edit_bones, self.__shadow_bone_names)
|
||||
|
||||
def update_pose_bones(self, pose_bones):
|
||||
pass
|
||||
|
||||
|
||||
class _AT_ShadowBoneCreate:
|
||||
def __init__(self, bone_name, target_bone_name):
|
||||
self.__dummy_bone_name = "_dummy_" + bone_name
|
||||
self.__shadow_bone_name = "_shadow_" + bone_name
|
||||
self.__bone_name = bone_name
|
||||
self.__target_bone_name = target_bone_name
|
||||
self.__constraint_pool = []
|
||||
|
||||
def __is_well_aligned(self, bone0, bone1):
|
||||
return bone0.x_axis.dot(bone1.x_axis) > 0.99 and bone0.y_axis.dot(bone1.y_axis) > 0.99
|
||||
|
||||
def __update_constraints(self, use_shadow=True):
|
||||
subtarget = self.__shadow_bone_name if use_shadow else self.__target_bone_name
|
||||
for c in self.__constraint_pool:
|
||||
c.subtarget = subtarget
|
||||
|
||||
def add_constraint(self, constraint):
|
||||
self.__constraint_pool.append(constraint)
|
||||
|
||||
def update_edit_bones(self, edit_bones):
|
||||
bone = edit_bones[self.__bone_name]
|
||||
target_bone = edit_bones[self.__target_bone_name]
|
||||
if bone != target_bone and self.__is_well_aligned(bone, target_bone):
|
||||
_AT_ShadowBoneRemove(self.__bone_name).update_edit_bones(edit_bones)
|
||||
return
|
||||
|
||||
dummy_bone_name = self.__dummy_bone_name
|
||||
dummy = edit_bones.get(dummy_bone_name, None) or FnBone.set_edit_bone_to_dummy(edit_bones.new(name=dummy_bone_name))
|
||||
dummy.parent = target_bone
|
||||
dummy.head = target_bone.head
|
||||
dummy.tail = dummy.head + bone.tail - bone.head
|
||||
dummy.roll = bone.roll
|
||||
|
||||
shadow_bone_name = self.__shadow_bone_name
|
||||
shadow = edit_bones.get(shadow_bone_name, None) or FnBone.set_edit_bone_to_shadow(edit_bones.new(name=shadow_bone_name))
|
||||
shadow.parent = target_bone.parent
|
||||
shadow.head = dummy.head
|
||||
shadow.tail = dummy.tail
|
||||
shadow.roll = bone.roll
|
||||
|
||||
def update_pose_bones(self, pose_bones):
|
||||
if self.__shadow_bone_name not in pose_bones:
|
||||
self.__update_constraints(use_shadow=False)
|
||||
return
|
||||
|
||||
dummy_p_bone = pose_bones[self.__dummy_bone_name]
|
||||
dummy_p_bone.is_mmd_shadow_bone = True
|
||||
dummy_p_bone.mmd_shadow_bone_type = "DUMMY"
|
||||
|
||||
shadow_p_bone = pose_bones[self.__shadow_bone_name]
|
||||
shadow_p_bone.is_mmd_shadow_bone = True
|
||||
shadow_p_bone.mmd_shadow_bone_type = "SHADOW"
|
||||
|
||||
if "mmd_tools_at_dummy" not in shadow_p_bone.constraints:
|
||||
c = shadow_p_bone.constraints.new("COPY_TRANSFORMS")
|
||||
c.name = "mmd_tools_at_dummy"
|
||||
c.target = dummy_p_bone.id_data
|
||||
c.subtarget = dummy_p_bone.name
|
||||
c.target_space = "POSE"
|
||||
c.owner_space = "POSE"
|
||||
|
||||
self.__update_constraints()
|
||||
@@ -0,0 +1,248 @@
|
||||
# Copyright 2014 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
import bpy
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
from ..bpyutils import FnContext, Props
|
||||
|
||||
|
||||
class FnCamera:
|
||||
@staticmethod
|
||||
def find_root(obj: bpy.types.Object) -> Optional[bpy.types.Object]:
|
||||
if obj is None:
|
||||
return None
|
||||
if FnCamera.is_mmd_camera_root(obj):
|
||||
return obj
|
||||
if obj.parent is not None and FnCamera.is_mmd_camera_root(obj.parent):
|
||||
return obj.parent
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def is_mmd_camera(obj: bpy.types.Object) -> bool:
|
||||
return obj.type == "CAMERA" and FnCamera.find_root(obj.parent) is not None
|
||||
|
||||
@staticmethod
|
||||
def is_mmd_camera_root(obj: bpy.types.Object) -> bool:
|
||||
return obj.type == "EMPTY" and obj.mmd_type == "CAMERA"
|
||||
|
||||
@staticmethod
|
||||
def add_drivers(camera_object: bpy.types.Object):
|
||||
def __add_driver(id_data: bpy.types.ID, data_path: str, expression: str, index: int = -1):
|
||||
d = id_data.driver_add(data_path, index).driver
|
||||
d.type = "SCRIPTED"
|
||||
if "$empty_distance" in expression:
|
||||
v = d.variables.new()
|
||||
v.name = "empty_distance"
|
||||
v.type = "TRANSFORMS"
|
||||
v.targets[0].id = camera_object
|
||||
v.targets[0].transform_type = "LOC_Y"
|
||||
v.targets[0].transform_space = "LOCAL_SPACE"
|
||||
expression = expression.replace("$empty_distance", v.name)
|
||||
if "$is_perspective" in expression:
|
||||
v = d.variables.new()
|
||||
v.name = "is_perspective"
|
||||
v.type = "SINGLE_PROP"
|
||||
v.targets[0].id_type = "OBJECT"
|
||||
v.targets[0].id = camera_object.parent
|
||||
v.targets[0].data_path = "mmd_camera.is_perspective"
|
||||
expression = expression.replace("$is_perspective", v.name)
|
||||
if "$angle" in expression:
|
||||
v = d.variables.new()
|
||||
v.name = "angle"
|
||||
v.type = "SINGLE_PROP"
|
||||
v.targets[0].id_type = "OBJECT"
|
||||
v.targets[0].id = camera_object.parent
|
||||
v.targets[0].data_path = "mmd_camera.angle"
|
||||
expression = expression.replace("$angle", v.name)
|
||||
if "$sensor_height" in expression:
|
||||
# Use fixed sensor_height instead of dynamic reference.
|
||||
# When controlled by MMD angle, sensor_height shouldn't change.
|
||||
# This avoids unnecessary dependency cycles.
|
||||
# Reference: https://github.com/MMD-Blender/blender_mmd_tools_local/issues/227
|
||||
current_sensor_height = camera_object.data.sensor_height
|
||||
expression = expression.replace("$sensor_height", str(current_sensor_height))
|
||||
|
||||
d.expression = expression
|
||||
|
||||
__add_driver(camera_object.data, "ortho_scale", "25*abs($empty_distance)/45")
|
||||
__add_driver(camera_object, "rotation_euler", "pi if $is_perspective == False and $empty_distance > 1e-5 else 0", index=1)
|
||||
__add_driver(camera_object.data, "type", "not $is_perspective")
|
||||
__add_driver(camera_object.data, "lens", "$sensor_height/tan($angle/2)/2")
|
||||
|
||||
@staticmethod
|
||||
def remove_drivers(camera_object: bpy.types.Object):
|
||||
camera_object.data.driver_remove("ortho_scale")
|
||||
camera_object.driver_remove("rotation_euler")
|
||||
camera_object.data.driver_remove("type")
|
||||
camera_object.data.driver_remove("lens")
|
||||
|
||||
|
||||
class MigrationFnCamera:
|
||||
@staticmethod
|
||||
def update_mmd_camera():
|
||||
for camera_object in bpy.data.objects:
|
||||
if camera_object.type != "CAMERA":
|
||||
continue
|
||||
|
||||
root_object = FnCamera.find_root(camera_object)
|
||||
if root_object is None:
|
||||
# It's not a MMD Camera
|
||||
continue
|
||||
|
||||
FnCamera.remove_drivers(camera_object)
|
||||
FnCamera.add_drivers(camera_object)
|
||||
|
||||
|
||||
class MMDCamera:
|
||||
def __init__(self, obj):
|
||||
root_object = FnCamera.find_root(obj)
|
||||
if root_object is None:
|
||||
raise ValueError(f"{str(obj)} is not MMDCamera")
|
||||
|
||||
self.__emptyObj = getattr(root_object, "original", obj)
|
||||
|
||||
@staticmethod
|
||||
def isMMDCamera(obj: bpy.types.Object) -> bool:
|
||||
return FnCamera.find_root(obj) is not None
|
||||
|
||||
@staticmethod
|
||||
def addDrivers(cameraObj: bpy.types.Object):
|
||||
FnCamera.add_drivers(cameraObj)
|
||||
|
||||
@staticmethod
|
||||
def removeDrivers(cameraObj: bpy.types.Object):
|
||||
if cameraObj.type != "CAMERA":
|
||||
return
|
||||
FnCamera.remove_drivers(cameraObj)
|
||||
|
||||
@staticmethod
|
||||
def convertToMMDCamera(cameraObj: bpy.types.Object, scale=1.0):
|
||||
if FnCamera.is_mmd_camera(cameraObj):
|
||||
return MMDCamera(cameraObj)
|
||||
|
||||
empty = bpy.data.objects.new(name="MMD_Camera", object_data=None)
|
||||
FnContext.link_object(FnContext.ensure_context(), empty)
|
||||
|
||||
cameraObj.parent = empty
|
||||
cameraObj.data.sensor_fit = "VERTICAL"
|
||||
cameraObj.data.lens_unit = "MILLIMETERS" # MILLIMETERS, FOV
|
||||
cameraObj.data.ortho_scale = 25 * scale
|
||||
cameraObj.data.clip_end = 500 * scale
|
||||
setattr(cameraObj.data, Props.display_size, 5 * scale)
|
||||
cameraObj.location = (0, -45 * scale, 0)
|
||||
cameraObj.rotation_mode = "XYZ"
|
||||
cameraObj.rotation_euler = (math.radians(90), 0, 0)
|
||||
cameraObj.lock_location = (True, False, True)
|
||||
cameraObj.lock_rotation = (True, True, True)
|
||||
cameraObj.lock_scale = (True, True, True)
|
||||
cameraObj.data.dof.focus_object = empty
|
||||
FnCamera.add_drivers(cameraObj)
|
||||
|
||||
empty.location = (0, 0, 10 * scale)
|
||||
empty.rotation_mode = "YXZ"
|
||||
setattr(empty, Props.empty_display_size, 5 * scale)
|
||||
empty.lock_scale = (True, True, True)
|
||||
empty.mmd_type = "CAMERA"
|
||||
empty.mmd_camera.angle = math.radians(30)
|
||||
empty.mmd_camera.persp = True
|
||||
return MMDCamera(empty)
|
||||
|
||||
@staticmethod
|
||||
def newMMDCameraAnimation(cameraObj, cameraTarget=None, scale=1.0, min_distance=0.1):
|
||||
scene = bpy.context.scene
|
||||
mmd_cam = bpy.data.objects.new(name="Camera", object_data=bpy.data.cameras.new("Camera"))
|
||||
FnContext.link_object(FnContext.ensure_context(), mmd_cam)
|
||||
MMDCamera.convertToMMDCamera(mmd_cam, scale=scale)
|
||||
mmd_cam_root = mmd_cam.parent
|
||||
|
||||
_camera_override_func = None
|
||||
if cameraObj is None:
|
||||
if scene.camera is None:
|
||||
scene.camera = mmd_cam
|
||||
return MMDCamera(mmd_cam_root)
|
||||
def _camera_override_func():
|
||||
return scene.camera
|
||||
|
||||
_target_override_func = None
|
||||
if cameraTarget is None:
|
||||
def _target_override_func(camObj):
|
||||
return camObj.data.dof.focus_object or camObj
|
||||
|
||||
action_name = mmd_cam_root.name
|
||||
parent_action = bpy.data.actions.new(name=action_name)
|
||||
distance_action = bpy.data.actions.new(name=action_name + "_dis")
|
||||
FnCamera.remove_drivers(mmd_cam)
|
||||
|
||||
render = scene.render
|
||||
factor = (render.resolution_y * render.pixel_aspect_y) / (render.resolution_x * render.pixel_aspect_x)
|
||||
matrix_rotation = Matrix(([1, 0, 0, 0], [0, 0, 1, 0], [0, -1, 0, 0], [0, 0, 0, 1]))
|
||||
neg_z_vector = Vector((0, 0, -1))
|
||||
frame_start, frame_end, frame_current = scene.frame_start, scene.frame_end + 1, scene.frame_current
|
||||
frame_count = frame_end - frame_start
|
||||
frames = range(frame_start, frame_end)
|
||||
|
||||
fcurves = [parent_action.fcurves.new(data_path="location", index=i) for i in range(3)] # x, y, z
|
||||
fcurves.extend(parent_action.fcurves.new(data_path="rotation_euler", index=i) for i in range(3)) # rx, ry, rz
|
||||
fcurves.append(parent_action.fcurves.new(data_path="mmd_camera.angle")) # fov
|
||||
fcurves.append(parent_action.fcurves.new(data_path="mmd_camera.is_perspective")) # persp
|
||||
fcurves.append(distance_action.fcurves.new(data_path="location", index=1)) # dis
|
||||
for c in fcurves:
|
||||
c.keyframe_points.add(frame_count)
|
||||
|
||||
for f, x, y, z, rx, ry, rz, fov, persp, dis in zip(frames, *(c.keyframe_points for c in fcurves), strict=False):
|
||||
scene.frame_set(f)
|
||||
if _camera_override_func:
|
||||
cameraObj = _camera_override_func()
|
||||
if _target_override_func:
|
||||
cameraTarget = _target_override_func(cameraObj)
|
||||
cam_matrix_world = cameraObj.matrix_world
|
||||
cam_target_loc = cameraTarget.matrix_world.translation
|
||||
cam_rotation = (cam_matrix_world @ matrix_rotation).to_euler(mmd_cam_root.rotation_mode)
|
||||
cam_vec = cam_matrix_world.to_3x3() @ neg_z_vector
|
||||
if cameraObj.data.type == "ORTHO":
|
||||
cam_dis = -(9 / 5) * cameraObj.data.ortho_scale
|
||||
if cameraObj.data.sensor_fit != "VERTICAL":
|
||||
if cameraObj.data.sensor_fit == "HORIZONTAL":
|
||||
cam_dis *= factor
|
||||
else:
|
||||
cam_dis *= min(1, factor)
|
||||
else:
|
||||
target_vec = cam_target_loc - cam_matrix_world.translation
|
||||
cam_dis = -max(target_vec.length * cam_vec.dot(target_vec.normalized()), min_distance)
|
||||
cam_target_loc = cam_matrix_world.translation - cam_vec * cam_dis
|
||||
|
||||
tan_val = cameraObj.data.sensor_height / cameraObj.data.lens / 2
|
||||
if cameraObj.data.sensor_fit != "VERTICAL":
|
||||
ratio = cameraObj.data.sensor_width / cameraObj.data.sensor_height
|
||||
if cameraObj.data.sensor_fit == "HORIZONTAL":
|
||||
tan_val *= factor * ratio
|
||||
else: # cameraObj.data.sensor_fit == 'AUTO'
|
||||
tan_val *= min(ratio, factor * ratio)
|
||||
|
||||
x.co, y.co, z.co = ((f, i) for i in cam_target_loc)
|
||||
rx.co, ry.co, rz.co = ((f, i) for i in cam_rotation)
|
||||
dis.co = (f, cam_dis)
|
||||
fov.co = (f, 2 * math.atan(tan_val))
|
||||
persp.co = (f, cameraObj.data.type != "ORTHO")
|
||||
persp.interpolation = "CONSTANT"
|
||||
for kp in (x, y, z, rx, ry, rz, fov, dis):
|
||||
kp.interpolation = "LINEAR"
|
||||
|
||||
FnCamera.add_drivers(mmd_cam)
|
||||
mmd_cam_root.animation_data_create().action = parent_action
|
||||
mmd_cam.animation_data_create().action = distance_action
|
||||
scene.frame_set(frame_current)
|
||||
return MMDCamera(mmd_cam_root)
|
||||
|
||||
def object(self):
|
||||
return self.__emptyObj
|
||||
|
||||
def camera(self):
|
||||
for i in self.__emptyObj.children:
|
||||
if i.type == "CAMERA":
|
||||
return i
|
||||
raise KeyError
|
||||
@@ -0,0 +1,12 @@
|
||||
# Copyright 2016 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
# Module for custom exceptions
|
||||
|
||||
|
||||
class MaterialNotFoundError(KeyError):
|
||||
"""Exception raised when a material is not found in the scene"""
|
||||
|
||||
def __init__(self, *args: object) -> None:
|
||||
"""Initialize MaterialNotFoundError"""
|
||||
super().__init__(*args)
|
||||
@@ -0,0 +1,65 @@
|
||||
# Copyright 2014 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
import bpy
|
||||
|
||||
from ..bpyutils import FnContext, Props
|
||||
|
||||
|
||||
class MMDLamp:
|
||||
def __init__(self, obj):
|
||||
if MMDLamp.isLamp(obj):
|
||||
obj = obj.parent
|
||||
if obj and obj.type == "EMPTY" and obj.mmd_type == "LIGHT":
|
||||
self.__emptyObj = obj
|
||||
else:
|
||||
raise ValueError(f"{str(obj)} is not MMDLamp")
|
||||
|
||||
@staticmethod
|
||||
def isLamp(obj):
|
||||
return obj and obj.type in {"LIGHT", "LAMP"}
|
||||
|
||||
@staticmethod
|
||||
def isMMDLamp(obj):
|
||||
if MMDLamp.isLamp(obj):
|
||||
obj = obj.parent
|
||||
return obj and obj.type == "EMPTY" and obj.mmd_type == "LIGHT"
|
||||
|
||||
@staticmethod
|
||||
def convertToMMDLamp(lampObj, scale=1.0):
|
||||
if MMDLamp.isMMDLamp(lampObj):
|
||||
return MMDLamp(lampObj)
|
||||
|
||||
empty = bpy.data.objects.new(name="MMD_Light", object_data=None)
|
||||
FnContext.link_object(FnContext.ensure_context(), empty)
|
||||
|
||||
empty.rotation_mode = "XYZ"
|
||||
empty.lock_rotation = (True, True, True)
|
||||
setattr(empty, Props.empty_display_size, 0.4)
|
||||
empty.scale = [10 * scale] * 3
|
||||
empty.mmd_type = "LIGHT"
|
||||
empty.location = (0, 0, 11 * scale)
|
||||
|
||||
lampObj.parent = empty
|
||||
lampObj.data.color = (0.602, 0.602, 0.602)
|
||||
lampObj.location = (0.5, -0.5, 1.0)
|
||||
lampObj.rotation_mode = "XYZ"
|
||||
lampObj.rotation_euler = (0, 0, 0)
|
||||
lampObj.lock_rotation = (True, True, True)
|
||||
|
||||
constraint = lampObj.constraints.new(type="TRACK_TO")
|
||||
constraint.name = "mmd_lamp_track"
|
||||
constraint.target = empty
|
||||
constraint.track_axis = "TRACK_NEGATIVE_Z"
|
||||
constraint.up_axis = "UP_Y"
|
||||
|
||||
return MMDLamp(empty)
|
||||
|
||||
def object(self):
|
||||
return self.__emptyObj
|
||||
|
||||
def lamp(self):
|
||||
for i in self.__emptyObj.children:
|
||||
if MMDLamp.isLamp(i):
|
||||
return i
|
||||
raise KeyError
|
||||
@@ -0,0 +1,713 @@
|
||||
# Copyright 2014 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
from ....core.logging_setup import logger
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Callable, Iterable, Optional, Tuple, cast
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
from ..bpyutils import FnContext
|
||||
from .exceptions import MaterialNotFoundError
|
||||
from .shader import _NodeGroupUtils
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..properties.material import MMDMaterial
|
||||
|
||||
# TODO: use enum instead of constants
|
||||
SPHERE_MODE_OFF = 0
|
||||
SPHERE_MODE_MULT = 1
|
||||
SPHERE_MODE_ADD = 2
|
||||
SPHERE_MODE_SUBTEX = 3
|
||||
|
||||
|
||||
class _DummyTexture:
|
||||
def __init__(self, image):
|
||||
self.type = "IMAGE"
|
||||
self.image = image
|
||||
self.use_mipmap = True
|
||||
|
||||
|
||||
class _DummyTextureSlot:
|
||||
def __init__(self, image):
|
||||
self.diffuse_color_factor = 1
|
||||
self.uv_layer = ""
|
||||
self.texture = _DummyTexture(image)
|
||||
|
||||
|
||||
class FnMaterial:
|
||||
__NODES_ARE_READONLY: bool = False
|
||||
|
||||
def __init__(self, material: bpy.types.Material):
|
||||
self.__material = material
|
||||
self._nodes_are_readonly = FnMaterial.__NODES_ARE_READONLY
|
||||
|
||||
@staticmethod
|
||||
def set_nodes_are_readonly(nodes_are_readonly: bool):
|
||||
FnMaterial.__NODES_ARE_READONLY = nodes_are_readonly
|
||||
|
||||
@classmethod
|
||||
def from_material_id(cls, material_id: int):
|
||||
for material in bpy.data.materials:
|
||||
if material.mmd_material.material_id == material_id:
|
||||
return cls(material)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def clean_materials(obj, can_remove: Callable[[bpy.types.Material], bool]):
|
||||
materials = obj.data.materials
|
||||
materials_pop = materials.pop
|
||||
for i in sorted((x for x, m in enumerate(materials) if can_remove(m)), reverse=True):
|
||||
m = materials_pop(index=i)
|
||||
if m.users < 1:
|
||||
bpy.data.materials.remove(m)
|
||||
|
||||
@staticmethod
|
||||
def swap_materials(mesh_object: bpy.types.Object, mat1_ref: str | int, mat2_ref: str | int, reverse=False, swap_slots=False) -> Tuple[bpy.types.Material, bpy.types.Material]:
|
||||
"""
|
||||
Assign the polygons of mat1 to mat2.
|
||||
|
||||
If reverse is True it will also swap the polygons assigned to mat2 to mat1.
|
||||
The reference to materials can be indexes or names
|
||||
Finally it will also swap the material slots if the option is given.
|
||||
|
||||
Args:
|
||||
mesh_object (bpy.types.Object): The mesh object
|
||||
mat1_ref (str | int): The reference to the first material
|
||||
mat2_ref (str | int): The reference to the second material
|
||||
reverse (bool, optional): If true it will also swap the polygons assigned to mat2 to mat1. Defaults to False.
|
||||
swap_slots (bool, optional): If true it will also swap the material slots. Defaults to False.
|
||||
|
||||
Retruns:
|
||||
Tuple[bpy.types.Material, bpy.types.Material]: The swapped materials
|
||||
|
||||
Raises:
|
||||
MaterialNotFoundError: If one of the materials is not found
|
||||
"""
|
||||
mesh = cast("bpy.types.Mesh", mesh_object.data)
|
||||
try:
|
||||
# Try to find the materials
|
||||
mat1 = mesh.materials[mat1_ref]
|
||||
mat2 = mesh.materials[mat2_ref]
|
||||
if None in {mat1, mat2}:
|
||||
raise MaterialNotFoundError
|
||||
except (KeyError, IndexError) as exc:
|
||||
# Wrap exceptions within our custom ones
|
||||
raise MaterialNotFoundError from exc
|
||||
mat1_idx = mesh.materials.find(mat1.name)
|
||||
mat2_idx = mesh.materials.find(mat2.name)
|
||||
# Swap polygons
|
||||
for poly in mesh.polygons:
|
||||
if poly.material_index == mat1_idx:
|
||||
poly.material_index = mat2_idx
|
||||
elif reverse and poly.material_index == mat2_idx:
|
||||
poly.material_index = mat1_idx
|
||||
# Swap slots if specified
|
||||
if swap_slots:
|
||||
mesh_object.material_slots[mat1_idx].material = mat2
|
||||
mesh_object.material_slots[mat2_idx].material = mat1
|
||||
return mat1, mat2
|
||||
|
||||
@staticmethod
|
||||
def fixMaterialOrder(meshObj: bpy.types.Object, material_names: Iterable[str]):
|
||||
"""Fix the material order which is lost after joining meshes."""
|
||||
materials = cast("bpy.types.Mesh", meshObj.data).materials
|
||||
for new_idx, mat in enumerate(material_names):
|
||||
# Get the material that is currently on this index
|
||||
other_mat = materials[new_idx]
|
||||
if other_mat.name == mat:
|
||||
continue # This is already in place
|
||||
FnMaterial.swap_materials(meshObj, mat, new_idx, reverse=True, swap_slots=True)
|
||||
|
||||
@property
|
||||
def material_id(self):
|
||||
mmd_mat: MMDMaterial = self.__material.mmd_material
|
||||
if mmd_mat.material_id < 0:
|
||||
max_id = -1
|
||||
for mat in bpy.data.materials:
|
||||
max_id = max(max_id, mat.mmd_material.material_id)
|
||||
mmd_mat.material_id = max_id + 1
|
||||
return mmd_mat.material_id
|
||||
|
||||
@property
|
||||
def material(self):
|
||||
return self.__material
|
||||
|
||||
def __same_image_file(self, image, filepath):
|
||||
if image and image.source == "FILE":
|
||||
# pylint: disable=assignment-from-no-return
|
||||
img_filepath = bpy.path.abspath(image.filepath) # image.filepath_from_user()
|
||||
if img_filepath == filepath:
|
||||
return True
|
||||
# pylint: disable=bare-except
|
||||
try:
|
||||
return os.path.samefile(img_filepath, filepath)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compare files '{img_filepath}' and '{filepath}': {e}")
|
||||
return False
|
||||
|
||||
def _load_image(self, filepath):
|
||||
img = next((i for i in bpy.data.images if self.__same_image_file(i, filepath)), None)
|
||||
if img is None:
|
||||
# pylint: disable=bare-except
|
||||
try:
|
||||
img = bpy.data.images.load(filepath)
|
||||
except Exception:
|
||||
logger.warning("Cannot create a texture for %s. No such file.", filepath)
|
||||
img = bpy.data.images.new(os.path.basename(filepath), 1, 1)
|
||||
img.source = "FILE"
|
||||
img.filepath = filepath
|
||||
use_alpha = img.depth == 32 and img.file_format != "BMP"
|
||||
if hasattr(img, "use_alpha"):
|
||||
img.use_alpha = use_alpha
|
||||
elif not use_alpha:
|
||||
img.alpha_mode = "NONE"
|
||||
return img
|
||||
|
||||
def update_toon_texture(self):
|
||||
if self._nodes_are_readonly:
|
||||
return
|
||||
mmd_mat: MMDMaterial = self.__material.mmd_material
|
||||
if mmd_mat.is_shared_toon_texture:
|
||||
shared_toon_folder = FnContext.get_addon_preferences_attribute(FnContext.ensure_context(), "shared_toon_folder", "")
|
||||
toon_path = os.path.join(shared_toon_folder, "toon%02d.bmp" % (mmd_mat.shared_toon_texture + 1))
|
||||
self.create_toon_texture(str(Path(toon_path).resolve()))
|
||||
elif mmd_mat.toon_texture != "":
|
||||
self.create_toon_texture(mmd_mat.toon_texture)
|
||||
else:
|
||||
self.remove_toon_texture()
|
||||
|
||||
def _mix_diffuse_and_ambient(self, mmd_mat):
|
||||
r, g, b = mmd_mat.diffuse_color
|
||||
ar, ag, ab = mmd_mat.ambient_color
|
||||
return [min(1.0, 0.5 * r + ar), min(1.0, 0.5 * g + ag), min(1.0, 0.5 * b + ab)]
|
||||
|
||||
def update_drop_shadow(self):
|
||||
pass
|
||||
|
||||
def update_enabled_toon_edge(self):
|
||||
if self._nodes_are_readonly:
|
||||
return
|
||||
self.update_edge_color()
|
||||
|
||||
def update_edge_color(self):
|
||||
if self._nodes_are_readonly:
|
||||
return
|
||||
mat = self.__material
|
||||
mmd_mat: MMDMaterial = mat.mmd_material
|
||||
color, alpha = mmd_mat.edge_color[:3], mmd_mat.edge_color[3]
|
||||
line_color = color + (min(alpha, int(mmd_mat.enabled_toon_edge)),)
|
||||
if hasattr(mat, "line_color"): # freestyle line color
|
||||
mat.line_color = line_color
|
||||
|
||||
mat_edge: bpy.types.Material = bpy.data.materials.get("mmd_edge." + mat.name, None)
|
||||
if mat_edge:
|
||||
mat_edge.mmd_material.edge_color = line_color
|
||||
|
||||
if mat.name.startswith("mmd_edge.") and mat.node_tree:
|
||||
mmd_mat.ambient_color, mmd_mat.alpha = color, alpha
|
||||
node_shader = mat.node_tree.nodes.get("mmd_edge_preview", None)
|
||||
if node_shader and "Color" in node_shader.inputs:
|
||||
node_shader.inputs["Color"].default_value = mmd_mat.edge_color
|
||||
if node_shader and "Alpha" in node_shader.inputs:
|
||||
node_shader.inputs["Alpha"].default_value = alpha
|
||||
|
||||
def update_edge_weight(self):
|
||||
pass
|
||||
|
||||
def get_texture(self):
|
||||
return self.__get_texture_node("mmd_base_tex", use_dummy=True)
|
||||
|
||||
def create_texture(self, filepath):
|
||||
texture = self.__create_texture_node("mmd_base_tex", filepath, (-4, -1))
|
||||
return _DummyTextureSlot(texture.image)
|
||||
|
||||
def remove_texture(self):
|
||||
if self._nodes_are_readonly:
|
||||
return
|
||||
self.__remove_texture_node("mmd_base_tex")
|
||||
|
||||
def get_sphere_texture(self):
|
||||
return self.__get_texture_node("mmd_sphere_tex", use_dummy=True)
|
||||
|
||||
def use_sphere_texture(self, use_sphere, obj=None):
|
||||
if self._nodes_are_readonly:
|
||||
return
|
||||
if use_sphere:
|
||||
self.update_sphere_texture_type(obj)
|
||||
else:
|
||||
self.__update_shader_input("Sphere Tex Fac", 0)
|
||||
|
||||
def create_sphere_texture(self, filepath, obj=None):
|
||||
texture = self.__create_texture_node("mmd_sphere_tex", filepath, (-2, -2))
|
||||
self.update_sphere_texture_type(obj)
|
||||
return _DummyTextureSlot(texture.image)
|
||||
|
||||
def update_sphere_texture_type(self, obj=None):
|
||||
if self._nodes_are_readonly:
|
||||
return
|
||||
sphere_texture_type = int(self.material.mmd_material.sphere_texture_type)
|
||||
is_sph_add = sphere_texture_type == 2
|
||||
|
||||
if sphere_texture_type not in {1, 2, 3}:
|
||||
self.__update_shader_input("Sphere Tex Fac", 0)
|
||||
else:
|
||||
self.__update_shader_input("Sphere Tex Fac", 1)
|
||||
self.__update_shader_input("Sphere Mul/Add", is_sph_add)
|
||||
self.__update_shader_input("Sphere Tex", (0, 0, 0, 1) if is_sph_add else (1, 1, 1, 1))
|
||||
|
||||
texture = self.__get_texture_node("mmd_sphere_tex")
|
||||
if texture and (not texture.inputs["Vector"].is_linked or texture.inputs["Vector"].links[0].from_node.name == "mmd_tex_uv"):
|
||||
if hasattr(texture, "color_space"):
|
||||
texture.color_space = "NONE" if is_sph_add else "COLOR"
|
||||
elif hasattr(texture.image, "colorspace_settings"):
|
||||
texture.image.colorspace_settings.name = "Linear Rec.709" if is_sph_add else "sRGB"
|
||||
|
||||
mat = self.material
|
||||
nodes, links = mat.node_tree.nodes, mat.node_tree.links
|
||||
if sphere_texture_type == 3:
|
||||
if obj and obj.type == "MESH" and mat in tuple(obj.data.materials):
|
||||
uv_layers = (layer for layer in obj.data.uv_layers if not layer.name.startswith("_"))
|
||||
next(uv_layers, None) # skip base UV
|
||||
subtex_uv = getattr(next(uv_layers, None), "name", "")
|
||||
if subtex_uv != "UV1":
|
||||
logger.info(' * material(%s): object "%s" use UV "%s" for SubTex', mat.name, obj.name, subtex_uv)
|
||||
links.new(nodes["mmd_tex_uv"].outputs["SubTex UV"], texture.inputs["Vector"])
|
||||
else:
|
||||
links.new(nodes["mmd_tex_uv"].outputs["Sphere UV"], texture.inputs["Vector"])
|
||||
|
||||
def remove_sphere_texture(self):
|
||||
if self._nodes_are_readonly:
|
||||
return
|
||||
self.__remove_texture_node("mmd_sphere_tex")
|
||||
|
||||
def get_toon_texture(self):
|
||||
return self.__get_texture_node("mmd_toon_tex", use_dummy=True)
|
||||
|
||||
def use_toon_texture(self, use_toon):
|
||||
if self._nodes_are_readonly:
|
||||
return
|
||||
self.__update_shader_input("Toon Tex Fac", use_toon)
|
||||
|
||||
def create_toon_texture(self, filepath):
|
||||
texture = self.__create_texture_node("mmd_toon_tex", filepath, (-3, -1.5))
|
||||
return _DummyTextureSlot(texture.image)
|
||||
|
||||
def remove_toon_texture(self):
|
||||
if self._nodes_are_readonly:
|
||||
return
|
||||
self.__remove_texture_node("mmd_toon_tex")
|
||||
|
||||
def __get_texture_node(self, node_name, use_dummy=False):
|
||||
mat = self.material
|
||||
texture = getattr(mat.node_tree, "nodes", {}).get(node_name, None)
|
||||
if isinstance(texture, bpy.types.ShaderNodeTexImage):
|
||||
return _DummyTexture(texture.image) if use_dummy else texture
|
||||
return None
|
||||
|
||||
def __remove_texture_node(self, node_name):
|
||||
mat = self.material
|
||||
texture = getattr(mat.node_tree, "nodes", {}).get(node_name, None)
|
||||
if isinstance(texture, bpy.types.ShaderNodeTexImage):
|
||||
mat.node_tree.nodes.remove(texture)
|
||||
mat.update_tag()
|
||||
|
||||
def __create_texture_node(self, node_name, filepath, pos):
|
||||
texture = self.__get_texture_node(node_name)
|
||||
if texture is None:
|
||||
self.__update_shader_nodes()
|
||||
nodes = self.material.node_tree.nodes
|
||||
texture = nodes.new("ShaderNodeTexImage")
|
||||
# pylint: disable=assignment-from-no-return
|
||||
texture.label = bpy.path.display_name(node_name)
|
||||
texture.name = node_name
|
||||
texture.location = nodes["mmd_shader"].location + Vector((pos[0] * 210, pos[1] * 220))
|
||||
texture.image = self._load_image(filepath)
|
||||
self.__update_shader_nodes()
|
||||
return texture
|
||||
|
||||
def update_ambient_color(self):
|
||||
if self._nodes_are_readonly:
|
||||
return
|
||||
mat = self.material
|
||||
mmd_mat = mat.mmd_material
|
||||
mat.diffuse_color[:3] = self._mix_diffuse_and_ambient(mmd_mat)
|
||||
self.__update_shader_input("Ambient Color", mmd_mat.ambient_color[:] + (1,))
|
||||
|
||||
def update_diffuse_color(self):
|
||||
if self._nodes_are_readonly:
|
||||
return
|
||||
mat = self.material
|
||||
mmd_mat = mat.mmd_material
|
||||
mat.diffuse_color[:3] = self._mix_diffuse_and_ambient(mmd_mat)
|
||||
self.__update_shader_input("Diffuse Color", mmd_mat.diffuse_color[:] + (1,))
|
||||
|
||||
def update_alpha(self):
|
||||
if self._nodes_are_readonly:
|
||||
return
|
||||
mat = self.material
|
||||
mmd_mat = mat.mmd_material
|
||||
if hasattr(mat, "blend_method"):
|
||||
mat.blend_method = "HASHED" # 'BLEND'
|
||||
# mat.show_transparent_back = False
|
||||
elif hasattr(mat, "transparency_method"):
|
||||
mat.use_transparency = True
|
||||
mat.transparency_method = "Z_TRANSPARENCY"
|
||||
mat.game_settings.alpha_blend = "ALPHA"
|
||||
if hasattr(mat, "alpha"):
|
||||
mat.alpha = mmd_mat.alpha
|
||||
elif len(mat.diffuse_color) > 3:
|
||||
mat.diffuse_color[3] = mmd_mat.alpha
|
||||
self.__update_shader_input("Alpha", mmd_mat.alpha)
|
||||
self.update_self_shadow_map()
|
||||
|
||||
def update_specular_color(self):
|
||||
if self._nodes_are_readonly:
|
||||
return
|
||||
mat = self.material
|
||||
mmd_mat = mat.mmd_material
|
||||
mat.specular_color = mmd_mat.specular_color
|
||||
self.__update_shader_input("Specular Color", mmd_mat.specular_color[:] + (1,))
|
||||
|
||||
def update_shininess(self):
|
||||
if self._nodes_are_readonly:
|
||||
return
|
||||
mat = self.material
|
||||
mmd_mat = mat.mmd_material
|
||||
mat.roughness = 1 / pow(max(mmd_mat.shininess, 1), 0.37)
|
||||
if hasattr(mat, "metallic"):
|
||||
mat.metallic = 0.0
|
||||
if hasattr(mat, "specular_hardness"):
|
||||
mat.specular_hardness = mmd_mat.shininess
|
||||
self.__update_shader_input("Reflect", mmd_mat.shininess)
|
||||
|
||||
def update_is_double_sided(self):
|
||||
if self._nodes_are_readonly:
|
||||
return
|
||||
mat = self.material
|
||||
mmd_mat = mat.mmd_material
|
||||
if hasattr(mat, "game_settings"):
|
||||
mat.game_settings.use_backface_culling = not mmd_mat.is_double_sided
|
||||
elif hasattr(mat, "use_backface_culling"):
|
||||
mat.use_backface_culling = not mmd_mat.is_double_sided
|
||||
self.__update_shader_input("Double Sided", mmd_mat.is_double_sided)
|
||||
|
||||
def update_self_shadow_map(self):
|
||||
if self._nodes_are_readonly:
|
||||
return
|
||||
mat = self.material
|
||||
mmd_mat = mat.mmd_material
|
||||
cast_shadows = mmd_mat.enabled_self_shadow_map if mmd_mat.alpha > 1e-3 else False
|
||||
if hasattr(mat, "shadow_method"):
|
||||
mat.shadow_method = "HASHED" if cast_shadows else "NONE"
|
||||
|
||||
def update_self_shadow(self):
|
||||
if self._nodes_are_readonly:
|
||||
return
|
||||
mat = self.material
|
||||
mmd_mat = mat.mmd_material
|
||||
self.__update_shader_input("Self Shadow", mmd_mat.enabled_self_shadow)
|
||||
|
||||
@staticmethod
|
||||
def convert_to_mmd_material(material, context=bpy.context):
|
||||
m, mmd_material = material, material.mmd_material
|
||||
|
||||
if m.use_nodes and next((n for n in m.node_tree.nodes if n.name.startswith("mmd_")), None) is None:
|
||||
|
||||
def search_tex_image_node(node: bpy.types.ShaderNode):
|
||||
if node.type == "TEX_IMAGE":
|
||||
return node
|
||||
for node_input in node.inputs:
|
||||
if not node_input.is_linked:
|
||||
continue
|
||||
child = search_tex_image_node(node_input.links[0].from_node)
|
||||
if child is not None:
|
||||
return child
|
||||
return None
|
||||
|
||||
if hasattr(context, "engine"):
|
||||
active_render_engine = context.engine
|
||||
else:
|
||||
# use ALL anyway
|
||||
active_render_engine = "ALL"
|
||||
|
||||
preferred_output_node_target = {
|
||||
"CYCLES": "CYCLES",
|
||||
"BLENDER_EEVEE": "EEVEE",
|
||||
"BLENDER_EEVEE_NEXT": "EEVEE", # Keep for backwards compatibility with 4.x
|
||||
}.get(active_render_engine, "ALL")
|
||||
|
||||
tex_node = None
|
||||
for target in [preferred_output_node_target, "ALL"]:
|
||||
output_node = m.node_tree.get_output_node(target)
|
||||
if output_node is None:
|
||||
continue
|
||||
|
||||
if not output_node.inputs[0].is_linked:
|
||||
continue
|
||||
|
||||
tex_node = search_tex_image_node(output_node.inputs[0].links[0].from_node)
|
||||
break
|
||||
|
||||
if tex_node is None:
|
||||
tex_node = next((n for n in m.node_tree.nodes if n.bl_idname == "ShaderNodeTexImage"), None)
|
||||
if tex_node:
|
||||
tex_node.name = "mmd_base_tex"
|
||||
else:
|
||||
# Take the Base Color from BSDF if there's no texture
|
||||
bsdf_node = next((n for n in m.node_tree.nodes if n.type.startswith("BSDF_")), None)
|
||||
if bsdf_node:
|
||||
base_color_input = bsdf_node.inputs.get("Base Color") or bsdf_node.inputs.get("Color")
|
||||
if base_color_input:
|
||||
mmd_material.diffuse_color = base_color_input.default_value[:3]
|
||||
# ambient should be half the diffuse
|
||||
mmd_material.ambient_color = [x * 0.5 for x in mmd_material.diffuse_color]
|
||||
|
||||
shadow_method = getattr(m, "shadow_method", None)
|
||||
|
||||
if mmd_material.diffuse_color is None:
|
||||
mmd_material.diffuse_color = m.diffuse_color[:3]
|
||||
if hasattr(m, "alpha"):
|
||||
mmd_material.alpha = m.alpha
|
||||
elif len(m.diffuse_color) > 3:
|
||||
mmd_material.alpha = m.diffuse_color[3]
|
||||
|
||||
mmd_material.specular_color = m.specular_color
|
||||
if hasattr(m, "specular_hardness"):
|
||||
mmd_material.shininess = m.specular_hardness
|
||||
else:
|
||||
mmd_material.shininess = pow(1 / max(m.roughness, 0.099), 1 / 0.37)
|
||||
|
||||
if hasattr(m, "game_settings"):
|
||||
mmd_material.is_double_sided = not m.game_settings.use_backface_culling
|
||||
elif hasattr(m, "use_backface_culling"):
|
||||
mmd_material.is_double_sided = not m.use_backface_culling
|
||||
|
||||
if shadow_method:
|
||||
mmd_material.enabled_self_shadow_map = (shadow_method != "NONE") and mmd_material.alpha > 1e-3
|
||||
mmd_material.enabled_self_shadow = shadow_method != "NONE"
|
||||
|
||||
# delete bsdf node if it's there
|
||||
if m.use_nodes:
|
||||
nodes_to_remove = [n for n in m.node_tree.nodes if n.type == "BSDF_PRINCIPLED" or n.type.startswith("BSDF_")]
|
||||
for n in nodes_to_remove:
|
||||
m.node_tree.nodes.remove(n)
|
||||
|
||||
def __update_shader_input(self, name, val):
|
||||
mat = self.material
|
||||
if mat.name.startswith("mmd_"): # skip mmd_edge.*
|
||||
return
|
||||
self.__update_shader_nodes()
|
||||
shader = mat.node_tree.nodes.get("mmd_shader", None)
|
||||
if shader and name in shader.inputs:
|
||||
interface_socket = shader.node_tree.interface.items_tree[name]
|
||||
if hasattr(interface_socket, "min_value"):
|
||||
val = min(max(val, interface_socket.min_value), interface_socket.max_value)
|
||||
shader.inputs[name].default_value = val
|
||||
|
||||
def __update_shader_nodes(self):
|
||||
mat = self.material
|
||||
if mat.node_tree is None:
|
||||
mat.use_nodes = True
|
||||
mat.node_tree.nodes.clear()
|
||||
|
||||
nodes, links = mat.node_tree.nodes, mat.node_tree.links
|
||||
|
||||
class _Dummy:
|
||||
default_value, is_linked = None, True
|
||||
|
||||
node_shader = nodes.get("mmd_shader", None)
|
||||
if node_shader is None:
|
||||
node_shader: bpy.types.ShaderNodeGroup = nodes.new("ShaderNodeGroup")
|
||||
node_shader.name = "mmd_shader"
|
||||
node_shader.location = (0, 300)
|
||||
node_shader.width = 200
|
||||
node_shader.node_tree = self.__get_shader()
|
||||
|
||||
mmd_mat: MMDMaterial = mat.mmd_material
|
||||
node_shader.inputs.get("Ambient Color", _Dummy).default_value = mmd_mat.ambient_color[:] + (1,)
|
||||
node_shader.inputs.get("Diffuse Color", _Dummy).default_value = mmd_mat.diffuse_color[:] + (1,)
|
||||
node_shader.inputs.get("Specular Color", _Dummy).default_value = mmd_mat.specular_color[:] + (1,)
|
||||
node_shader.inputs.get("Reflect", _Dummy).default_value = mmd_mat.shininess
|
||||
node_shader.inputs.get("Alpha", _Dummy).default_value = mmd_mat.alpha
|
||||
node_shader.inputs.get("Double Sided", _Dummy).default_value = mmd_mat.is_double_sided
|
||||
node_shader.inputs.get("Self Shadow", _Dummy).default_value = mmd_mat.enabled_self_shadow
|
||||
self.update_sphere_texture_type()
|
||||
|
||||
node_uv = nodes.get("mmd_tex_uv", None)
|
||||
if node_uv is None:
|
||||
node_uv: bpy.types.ShaderNodeGroup = nodes.new("ShaderNodeGroup")
|
||||
node_uv.name = "mmd_tex_uv"
|
||||
node_uv.location = node_shader.location + Vector((-5 * 210, -2.5 * 220))
|
||||
node_uv.node_tree = self.__get_shader_uv()
|
||||
|
||||
if not (node_shader.outputs["Shader"].is_linked or node_shader.outputs["Color"].is_linked or node_shader.outputs["Alpha"].is_linked):
|
||||
node_output = next((n for n in nodes if isinstance(n, bpy.types.ShaderNodeOutputMaterial) and n.is_active_output), None)
|
||||
if node_output is None:
|
||||
node_output: bpy.types.ShaderNodeOutputMaterial = nodes.new("ShaderNodeOutputMaterial")
|
||||
node_output.is_active_output = True
|
||||
node_output.location = node_shader.location + Vector((400, 0))
|
||||
links.new(node_shader.outputs["Shader"], node_output.inputs["Surface"])
|
||||
|
||||
for name_id in ("Base", "Toon", "Sphere"):
|
||||
texture = self.__get_texture_node(f"mmd_{name_id.lower()}_tex")
|
||||
if texture:
|
||||
name_tex_in, name_alpha_in, name_uv_out = (name_id + x for x in (" Tex", " Alpha", " UV"))
|
||||
if not node_shader.inputs.get(name_tex_in, _Dummy).is_linked:
|
||||
links.new(texture.outputs["Color"], node_shader.inputs[name_tex_in])
|
||||
if not node_shader.inputs.get(name_alpha_in, _Dummy).is_linked:
|
||||
links.new(texture.outputs["Alpha"], node_shader.inputs[name_alpha_in])
|
||||
if not texture.inputs["Vector"].is_linked:
|
||||
links.new(node_uv.outputs[name_uv_out], texture.inputs["Vector"])
|
||||
|
||||
def __get_shader_uv(self):
|
||||
group_name = "MMDTexUV"
|
||||
shader: bpy.types.ShaderNodeTree = bpy.data.node_groups.get(group_name, None) or bpy.data.node_groups.new(name=group_name, type="ShaderNodeTree")
|
||||
if len(shader.nodes):
|
||||
return shader
|
||||
|
||||
ng = _NodeGroupUtils(shader)
|
||||
|
||||
############################################################################
|
||||
_node_output: bpy.types.NodeGroupOutput = ng.new_node("NodeGroupOutput", (6, 0))
|
||||
|
||||
tex_coord: bpy.types.ShaderNodeTexCoord = ng.new_node("ShaderNodeTexCoord", (0, 0))
|
||||
|
||||
tex_coord1: bpy.types.ShaderNodeUVMap = ng.new_node("ShaderNodeUVMap", (4, -2))
|
||||
tex_coord1.uv_map = "UV1"
|
||||
|
||||
vec_trans: bpy.types.ShaderNodeVectorTransform = ng.new_node("ShaderNodeVectorTransform", (1, -1))
|
||||
vec_trans.vector_type = "NORMAL"
|
||||
vec_trans.convert_from = "OBJECT"
|
||||
vec_trans.convert_to = "CAMERA"
|
||||
|
||||
node_vector: bpy.types.ShaderNodeMapping = ng.new_node("ShaderNodeMapping", (2, -1))
|
||||
node_vector.vector_type = "POINT"
|
||||
node_vector.inputs["Location"].default_value = (0.5, 0.5, 0.0)
|
||||
node_vector.inputs["Scale"].default_value = (0.5, 0.5, 1.0)
|
||||
|
||||
links = ng.links
|
||||
links.new(tex_coord.outputs["Normal"], vec_trans.inputs["Vector"])
|
||||
links.new(vec_trans.outputs["Vector"], node_vector.inputs["Vector"])
|
||||
|
||||
ng.new_output_socket("Base UV", tex_coord.outputs["UV"])
|
||||
ng.new_output_socket("Toon UV", node_vector.outputs["Vector"])
|
||||
ng.new_output_socket("Sphere UV", node_vector.outputs["Vector"])
|
||||
ng.new_output_socket("SubTex UV", tex_coord1.outputs["UV"])
|
||||
|
||||
return shader
|
||||
|
||||
def __get_shader(self):
|
||||
group_name = "MMDShaderDev"
|
||||
shader: bpy.types.ShaderNodeTree = bpy.data.node_groups.get(group_name, None) or bpy.data.node_groups.new(name=group_name, type="ShaderNodeTree")
|
||||
if len(shader.nodes):
|
||||
return shader
|
||||
|
||||
ng = _NodeGroupUtils(shader)
|
||||
|
||||
############################################################################
|
||||
node_input: bpy.types.NodeGroupInput = ng.new_node("NodeGroupInput", (-5, -1))
|
||||
_node_output: bpy.types.NodeGroupOutput = ng.new_node("NodeGroupOutput", (11, 1))
|
||||
|
||||
node_diffuse: bpy.types.ShaderNodeMath = ng.new_mix_node("ADD", (-3, 4), fac=0.6)
|
||||
node_diffuse.use_clamp = True
|
||||
|
||||
node_tex: bpy.types.ShaderNodeMath = ng.new_mix_node("MULTIPLY", (-2, 3.5))
|
||||
node_toon: bpy.types.ShaderNodeMath = ng.new_mix_node("MULTIPLY", (-1, 3))
|
||||
node_sph: bpy.types.ShaderNodeMath = ng.new_mix_node("MULTIPLY", (0, 2.5))
|
||||
node_spa: bpy.types.ShaderNodeMath = ng.new_mix_node("ADD", (0, 1.5))
|
||||
node_sphere: bpy.types.ShaderNodeMath = ng.new_mix_node("MIX", (1, 1))
|
||||
|
||||
node_geo: bpy.types.ShaderNodeNewGeometry = ng.new_node("ShaderNodeNewGeometry", (6, 3.5))
|
||||
node_invert: bpy.types.ShaderNodeMath = ng.new_math_node("LESS_THAN", (7, 3))
|
||||
node_cull: bpy.types.ShaderNodeMath = ng.new_math_node("MAXIMUM", (8, 2.5))
|
||||
node_alpha: bpy.types.ShaderNodeMath = ng.new_math_node("MINIMUM", (9, 2))
|
||||
node_alpha.use_clamp = True
|
||||
node_alpha_tex: bpy.types.ShaderNodeMath = ng.new_math_node("MULTIPLY", (-1, -2))
|
||||
node_alpha_toon: bpy.types.ShaderNodeMath = ng.new_math_node("MULTIPLY", (0, -2.5))
|
||||
node_alpha_sph: bpy.types.ShaderNodeMath = ng.new_math_node("MULTIPLY", (1, -3))
|
||||
|
||||
node_reflect: bpy.types.ShaderNodeMath = ng.new_math_node("DIVIDE", (7, -1.5), value1=1)
|
||||
node_reflect.use_clamp = True
|
||||
|
||||
shader_diffuse: bpy.types.ShaderNodeBsdfDiffuse = ng.new_node("ShaderNodeBsdfDiffuse", (8, 0))
|
||||
shader_glossy: bpy.types.ShaderNodeBsdfAnisotropic = ng.new_node("ShaderNodeBsdfAnisotropic", (8, -1))
|
||||
shader_base_mix: bpy.types.ShaderNodeMixShader = ng.new_node("ShaderNodeMixShader", (9, 0))
|
||||
shader_base_mix.inputs["Fac"].default_value = 0.02
|
||||
shader_trans: bpy.types.ShaderNodeBsdfTransparent = ng.new_node("ShaderNodeBsdfTransparent", (9, 1))
|
||||
shader_alpha_mix: bpy.types.ShaderNodeMixShader = ng.new_node("ShaderNodeMixShader", (10, 1))
|
||||
|
||||
links = ng.links
|
||||
links.new(node_reflect.outputs["Value"], shader_glossy.inputs["Roughness"])
|
||||
links.new(shader_diffuse.outputs["BSDF"], shader_base_mix.inputs[1])
|
||||
links.new(shader_glossy.outputs["BSDF"], shader_base_mix.inputs[2])
|
||||
|
||||
links.new(node_diffuse.outputs["Color"], node_tex.inputs["Color1"])
|
||||
links.new(node_tex.outputs["Color"], node_toon.inputs["Color1"])
|
||||
links.new(node_toon.outputs["Color"], node_sph.inputs["Color1"])
|
||||
links.new(node_toon.outputs["Color"], node_spa.inputs["Color1"])
|
||||
links.new(node_sph.outputs["Color"], node_sphere.inputs["Color1"])
|
||||
links.new(node_spa.outputs["Color"], node_sphere.inputs["Color2"])
|
||||
links.new(node_sphere.outputs["Color"], shader_diffuse.inputs["Color"])
|
||||
|
||||
links.new(node_geo.outputs["Backfacing"], node_invert.inputs[0])
|
||||
links.new(node_invert.outputs["Value"], node_cull.inputs[0])
|
||||
links.new(node_cull.outputs["Value"], node_alpha.inputs[0])
|
||||
links.new(node_alpha_tex.outputs["Value"], node_alpha_toon.inputs[0])
|
||||
links.new(node_alpha_toon.outputs["Value"], node_alpha_sph.inputs[0])
|
||||
links.new(node_alpha_sph.outputs["Value"], node_alpha.inputs[1])
|
||||
|
||||
links.new(node_alpha.outputs["Value"], shader_alpha_mix.inputs["Fac"])
|
||||
links.new(shader_trans.outputs["BSDF"], shader_alpha_mix.inputs[1])
|
||||
links.new(shader_base_mix.outputs["Shader"], shader_alpha_mix.inputs[2])
|
||||
|
||||
############################################################################
|
||||
ng.new_input_socket("Ambient Color", node_diffuse.inputs["Color1"], (0.4, 0.4, 0.4, 1))
|
||||
ng.new_input_socket("Diffuse Color", node_diffuse.inputs["Color2"], (0.8, 0.8, 0.8, 1))
|
||||
# ↓ specular should be disabled by default
|
||||
ng.new_input_socket("Specular Color", shader_glossy.inputs["Color"], (0.0, 0.0, 0.0, 1))
|
||||
ng.new_input_socket("Reflect", node_reflect.inputs[1], 50, min_max=(1, 512))
|
||||
ng.new_input_socket("Base Tex Fac", node_tex.inputs["Fac"], 1)
|
||||
ng.new_input_socket("Base Tex", node_tex.inputs["Color2"], (1, 1, 1, 1))
|
||||
ng.new_input_socket("Toon Tex Fac", node_toon.inputs["Fac"], 1)
|
||||
ng.new_input_socket("Toon Tex", node_toon.inputs["Color2"], (1, 1, 1, 1))
|
||||
ng.new_input_socket("Sphere Tex Fac", node_sph.inputs["Fac"], 1)
|
||||
ng.new_input_socket("Sphere Tex", node_sph.inputs["Color2"], (1, 1, 1, 1))
|
||||
ng.new_input_socket("Sphere Mul/Add", node_sphere.inputs["Fac"], 0)
|
||||
ng.new_input_socket("Double Sided", node_cull.inputs[1], 0, min_max=(0, 1))
|
||||
ng.new_input_socket("Alpha", node_alpha_tex.inputs[0], 1, min_max=(0, 1))
|
||||
ng.new_input_socket("Base Alpha", node_alpha_tex.inputs[1], 1, min_max=(0, 1))
|
||||
ng.new_input_socket("Toon Alpha", node_alpha_toon.inputs[1], 1, min_max=(0, 1))
|
||||
ng.new_input_socket("Sphere Alpha", node_alpha_sph.inputs[1], 1, min_max=(0, 1))
|
||||
|
||||
links.new(node_input.outputs["Sphere Tex Fac"], node_spa.inputs["Fac"])
|
||||
links.new(node_input.outputs["Sphere Tex"], node_spa.inputs["Color2"])
|
||||
|
||||
ng.new_output_socket("Shader", shader_alpha_mix.outputs["Shader"])
|
||||
ng.new_output_socket("Color", node_sphere.outputs["Color"])
|
||||
ng.new_output_socket("Alpha", node_alpha.outputs["Value"])
|
||||
|
||||
return shader
|
||||
|
||||
|
||||
class MigrationFnMaterial:
|
||||
@staticmethod
|
||||
def update_mmd_shader():
|
||||
mmd_shader_node_tree: Optional[bpy.types.NodeTree] = bpy.data.node_groups.get("MMDShaderDev")
|
||||
if mmd_shader_node_tree is None:
|
||||
return
|
||||
|
||||
ng = _NodeGroupUtils(mmd_shader_node_tree)
|
||||
if "Color" in ng.node_output.inputs:
|
||||
return
|
||||
|
||||
shader_diffuse: bpy.types.ShaderNodeBsdfDiffuse = [n for n in mmd_shader_node_tree.nodes if n.type == "BSDF_DIFFUSE"][0]
|
||||
node_sphere: bpy.types.ShaderNodeMixRGB = shader_diffuse.inputs["Color"].links[0].from_node
|
||||
node_output: bpy.types.NodeGroupOutput = ng.node_output
|
||||
shader_alpha_mix: bpy.types.ShaderNodeMixShader = node_output.inputs["Shader"].links[0].from_node
|
||||
node_alpha: bpy.types.ShaderNodeMath = shader_alpha_mix.inputs["Fac"].links[0].from_node
|
||||
|
||||
ng.new_output_socket("Color", node_sphere.outputs["Color"])
|
||||
ng.new_output_socket("Alpha", node_alpha.outputs["Value"])
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,790 @@
|
||||
# Copyright 2016 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
from ....core.logging_setup import logger
|
||||
import math
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Tuple, cast
|
||||
|
||||
import bpy
|
||||
|
||||
from .. import bpyutils, utils
|
||||
from ..bpyutils import FnContext, FnObject, TransformConstraintOp
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .model import Model
|
||||
|
||||
|
||||
class FnMorph:
|
||||
def __init__(self, morph, model: "Model"):
|
||||
self.__morph = morph
|
||||
self.__rig = model
|
||||
|
||||
@classmethod
|
||||
def storeShapeKeyOrder(cls, obj, shape_key_names):
|
||||
if len(shape_key_names) < 1:
|
||||
return
|
||||
assert FnContext.get_active_object(FnContext.ensure_context()) == obj
|
||||
if obj.data.shape_keys is None:
|
||||
bpy.ops.object.shape_key_add()
|
||||
|
||||
def __move_to_bottom(key_blocks, name):
|
||||
obj.active_shape_key_index = key_blocks.find(name)
|
||||
bpy.ops.object.shape_key_move(type="BOTTOM")
|
||||
|
||||
key_blocks = obj.data.shape_keys.key_blocks
|
||||
for name in shape_key_names:
|
||||
if name not in key_blocks:
|
||||
obj.shape_key_add(name=name, from_mix=False)
|
||||
elif len(key_blocks) > 1:
|
||||
__move_to_bottom(key_blocks, name)
|
||||
|
||||
@classmethod
|
||||
def fixShapeKeyOrder(cls, obj, shape_key_names):
|
||||
if len(shape_key_names) < 1:
|
||||
return
|
||||
assert FnContext.get_active_object(FnContext.ensure_context()) == obj
|
||||
key_blocks = getattr(obj.data.shape_keys, "key_blocks", None)
|
||||
if key_blocks is None:
|
||||
return
|
||||
for name in shape_key_names:
|
||||
idx = key_blocks.find(name)
|
||||
if idx < 0:
|
||||
continue
|
||||
obj.active_shape_key_index = idx
|
||||
bpy.ops.object.shape_key_move(type="BOTTOM")
|
||||
|
||||
@staticmethod
|
||||
def get_morph_slider(rig):
|
||||
return _MorphSlider(rig)
|
||||
|
||||
@staticmethod
|
||||
def category_guess(morph):
|
||||
name_lower = morph.name.lower()
|
||||
if "mouth" in name_lower:
|
||||
morph.category = "MOUTH"
|
||||
elif "eye" in name_lower:
|
||||
if "brow" in name_lower:
|
||||
morph.category = "EYEBROW"
|
||||
else:
|
||||
morph.category = "EYE"
|
||||
|
||||
@classmethod
|
||||
def load_morphs(cls, rig):
|
||||
mmd_root = rig.rootObject().mmd_root
|
||||
vertex_morphs = mmd_root.vertex_morphs
|
||||
uv_morphs = mmd_root.uv_morphs
|
||||
for obj in rig.meshes():
|
||||
for kb in getattr(obj.data.shape_keys, "key_blocks", ())[1:]:
|
||||
if not kb.name.startswith("mmd_") and kb.name not in vertex_morphs:
|
||||
item = vertex_morphs.add()
|
||||
item.name = kb.name
|
||||
item.name_e = kb.name
|
||||
cls.category_guess(item)
|
||||
for g, name, x in FnMorph.get_uv_morph_vertex_groups(obj):
|
||||
if name not in uv_morphs:
|
||||
item = uv_morphs.add()
|
||||
item.name = item.name_e = name
|
||||
item.data_type = "VERTEX_GROUP"
|
||||
cls.category_guess(item)
|
||||
|
||||
@staticmethod
|
||||
def remove_shape_key(mesh_object: bpy.types.Object, shape_key_name: str):
|
||||
assert isinstance(mesh_object.data, bpy.types.Mesh)
|
||||
|
||||
shape_keys = mesh_object.data.shape_keys
|
||||
if shape_keys is None:
|
||||
return
|
||||
|
||||
key_blocks = shape_keys.key_blocks
|
||||
if key_blocks and shape_key_name in key_blocks:
|
||||
FnObject.mesh_remove_shape_key(mesh_object, key_blocks[shape_key_name])
|
||||
|
||||
@staticmethod
|
||||
def copy_shape_key(mesh_object: bpy.types.Object, src_name: str, dest_name: str):
|
||||
assert isinstance(mesh_object.data, bpy.types.Mesh)
|
||||
|
||||
shape_keys = mesh_object.data.shape_keys
|
||||
if shape_keys is None:
|
||||
return
|
||||
|
||||
key_blocks = shape_keys.key_blocks
|
||||
|
||||
if src_name not in key_blocks:
|
||||
return
|
||||
|
||||
if dest_name in key_blocks:
|
||||
FnObject.mesh_remove_shape_key(mesh_object, key_blocks[dest_name])
|
||||
|
||||
mesh_object.active_shape_key_index = key_blocks.find(src_name)
|
||||
mesh_object.show_only_shape_key, last = True, mesh_object.show_only_shape_key
|
||||
mesh_object.shape_key_add(name=dest_name, from_mix=True)
|
||||
mesh_object.show_only_shape_key = last
|
||||
mesh_object.active_shape_key_index = key_blocks.find(dest_name)
|
||||
|
||||
@staticmethod
|
||||
def get_uv_morph_vertex_groups(obj, morph_name=None, offset_axes="XYZW"):
|
||||
pattern = "UV_%s[+-][%s]$" % (morph_name or ".{1,}", offset_axes or "XYZW")
|
||||
# yield (vertex_group, morph_name, axis),...
|
||||
return ((g, g.name[3:-2], g.name[-2:]) for g in obj.vertex_groups if re.match(pattern, g.name))
|
||||
|
||||
@staticmethod
|
||||
def copy_uv_morph_vertex_groups(obj, src_name, dest_name):
|
||||
for vg, n, x in FnMorph.get_uv_morph_vertex_groups(obj, dest_name):
|
||||
obj.vertex_groups.remove(vg)
|
||||
|
||||
for vg_name in tuple(i[0].name for i in FnMorph.get_uv_morph_vertex_groups(obj, src_name)):
|
||||
obj.vertex_groups.active = obj.vertex_groups[vg_name]
|
||||
with bpy.context.temp_override(object=obj, window=bpy.context.window, region=bpy.context.region):
|
||||
bpy.ops.object.vertex_group_copy()
|
||||
obj.vertex_groups.active.name = vg_name.replace(src_name, dest_name)
|
||||
|
||||
@staticmethod
|
||||
def overwrite_bone_morphs_from_action_pose(armature_object):
|
||||
armature = armature_object.id_data
|
||||
|
||||
# Use animation_data and action instead of action_pose
|
||||
if armature.animation_data is None or armature.animation_data.action is None:
|
||||
logger.warning('[WARNING] armature "%s" has no animation data or action', armature_object.name)
|
||||
return
|
||||
|
||||
action = armature.animation_data.action
|
||||
pose_markers = action.pose_markers
|
||||
|
||||
if not pose_markers:
|
||||
return
|
||||
|
||||
root = armature_object.parent
|
||||
mmd_root = root.mmd_root
|
||||
bone_morphs = mmd_root.bone_morphs
|
||||
|
||||
utils.selectAObject(armature_object)
|
||||
original_mode = bpy.context.active_object.mode
|
||||
bpy.ops.object.mode_set(mode="POSE")
|
||||
try:
|
||||
for index, pose_marker in enumerate(pose_markers):
|
||||
bone_morph = next(iter([m for m in bone_morphs if m.name == pose_marker.name]), None)
|
||||
if bone_morph is None:
|
||||
bone_morph = bone_morphs.add()
|
||||
bone_morph.name = pose_marker.name
|
||||
|
||||
bpy.ops.pose.select_all(action="SELECT")
|
||||
bpy.ops.pose.transforms_clear()
|
||||
|
||||
frame = pose_marker.frame
|
||||
bpy.context.scene.frame_set(int(frame))
|
||||
|
||||
mmd_root.active_morph = bone_morphs.find(bone_morph.name)
|
||||
bpy.ops.mmd_tools.apply_bone_morph()
|
||||
|
||||
bpy.ops.pose.transforms_clear()
|
||||
|
||||
finally:
|
||||
bpy.ops.object.mode_set(mode=original_mode)
|
||||
utils.selectAObject(root)
|
||||
|
||||
@staticmethod
|
||||
def clean_uv_morph_vertex_groups(obj):
|
||||
# remove empty vertex groups of uv morphs
|
||||
vg_indices = {g.index for g, n, x in FnMorph.get_uv_morph_vertex_groups(obj)}
|
||||
vertex_groups = obj.vertex_groups
|
||||
for v in obj.data.vertices:
|
||||
for x in v.groups:
|
||||
if x.group in vg_indices and x.weight > 0:
|
||||
vg_indices.remove(x.group)
|
||||
for i in sorted(vg_indices, reverse=True):
|
||||
vg = vertex_groups[i]
|
||||
m = obj.modifiers.get("mmd_bind%s" % hash(vg.name), None)
|
||||
if m:
|
||||
obj.modifiers.remove(m)
|
||||
vertex_groups.remove(vg)
|
||||
|
||||
@staticmethod
|
||||
def get_uv_morph_offset_map(obj, morph):
|
||||
offset_map = {} # offset_map[vertex_index] = offset_xyzw
|
||||
if morph.data_type == "VERTEX_GROUP":
|
||||
scale = morph.vertex_group_scale
|
||||
axis_map = {g.index: x for g, n, x in FnMorph.get_uv_morph_vertex_groups(obj, morph.name)}
|
||||
for v in obj.data.vertices:
|
||||
i = v.index
|
||||
for x in v.groups:
|
||||
if x.group in axis_map and x.weight > 0:
|
||||
axis, weight = axis_map[x.group], x.weight
|
||||
d = offset_map.setdefault(i, [0, 0, 0, 0])
|
||||
d["XYZW".index(axis[1])] += -weight * scale if axis[0] == "-" else weight * scale
|
||||
else:
|
||||
for val in morph.data:
|
||||
i = val.index
|
||||
if i in offset_map:
|
||||
offset_map[i] = [a + b for a, b in zip(offset_map[i], val.offset, strict=False)]
|
||||
else:
|
||||
offset_map[i] = val.offset
|
||||
return offset_map
|
||||
|
||||
@staticmethod
|
||||
def store_uv_morph_data(obj, morph, offsets=None, offset_axes="XYZW"):
|
||||
vertex_groups = obj.vertex_groups
|
||||
morph_name = getattr(morph, "name", None)
|
||||
if offset_axes:
|
||||
for vg, n, x in FnMorph.get_uv_morph_vertex_groups(obj, morph_name, offset_axes):
|
||||
vertex_groups.remove(vg)
|
||||
if not morph_name or not offsets:
|
||||
return
|
||||
|
||||
axis_indices = tuple("XYZW".index(x) for x in offset_axes) or tuple(range(4))
|
||||
offset_map = FnMorph.get_uv_morph_offset_map(obj, morph) if offset_axes else {}
|
||||
for data in offsets:
|
||||
idx, offset = data.index, data.offset
|
||||
for i in axis_indices:
|
||||
offset_map.setdefault(idx, [0, 0, 0, 0])[i] += round(offset[i], 5)
|
||||
|
||||
max_value = max(max(abs(x) for x in v) for v in offset_map.values() or ([0],))
|
||||
scale = morph.vertex_group_scale = max(abs(morph.vertex_group_scale), max_value)
|
||||
for idx, offset in offset_map.items():
|
||||
for val, axis in zip(offset, "XYZW", strict=False):
|
||||
if abs(val) > 1e-4:
|
||||
vg_name = f"UV_{morph_name}{'-' if val < 0 else '+'}{axis}"
|
||||
vg = vertex_groups.get(vg_name, None) or vertex_groups.new(name=vg_name)
|
||||
vg.add(index=[idx], weight=abs(val) / scale, type="REPLACE")
|
||||
|
||||
def update_mat_related_mesh(self, new_mesh=None):
|
||||
for offset in self.__morph.data:
|
||||
# Use the new_mesh if provided
|
||||
meshObj = new_mesh
|
||||
if new_mesh is None:
|
||||
# Try to find the mesh by material name
|
||||
meshObj = self.__rig.findMesh(offset.material)
|
||||
|
||||
if meshObj is None:
|
||||
# Given this point we need to loop through all the meshes
|
||||
for mesh in self.__rig.meshes():
|
||||
if mesh.data.materials.find(offset.material) >= 0:
|
||||
meshObj = mesh
|
||||
break
|
||||
|
||||
# Finally update the reference
|
||||
if meshObj is not None:
|
||||
offset.related_mesh = meshObj.data.name
|
||||
|
||||
@staticmethod
|
||||
def clean_duplicated_material_morphs(mmd_root_object: bpy.types.Object):
|
||||
"""Clean duplicated material_morphs and data from mmd_root_object.mmd_root.material_morphs[].data[]"""
|
||||
mmd_root = mmd_root_object.mmd_root
|
||||
|
||||
def morph_data_equals(left, right) -> bool:
|
||||
return (
|
||||
left.related_mesh_data == right.related_mesh_data
|
||||
and left.offset_type == right.offset_type
|
||||
and left.material == right.material
|
||||
and all(a == b for a, b in zip(left.diffuse_color, right.diffuse_color, strict=False))
|
||||
and all(a == b for a, b in zip(left.specular_color, right.specular_color, strict=False))
|
||||
and left.shininess == right.shininess
|
||||
and all(a == b for a, b in zip(left.ambient_color, right.ambient_color, strict=False))
|
||||
and all(a == b for a, b in zip(left.edge_color, right.edge_color, strict=False))
|
||||
and left.edge_weight == right.edge_weight
|
||||
and all(a == b for a, b in zip(left.texture_factor, right.texture_factor, strict=False))
|
||||
and all(a == b for a, b in zip(left.sphere_texture_factor, right.sphere_texture_factor, strict=False))
|
||||
and all(a == b for a, b in zip(left.toon_texture_factor, right.toon_texture_factor, strict=False))
|
||||
)
|
||||
|
||||
def morph_equals(left, right) -> bool:
|
||||
return len(left.data) == len(right.data) and all(morph_data_equals(a, b) for a, b in zip(left.data, right.data, strict=False))
|
||||
|
||||
# Remove duplicated mmd_root.material_morphs.data[]
|
||||
for material_morph in mmd_root.material_morphs:
|
||||
save_materil_morph_datas = []
|
||||
remove_material_morph_data_indices = []
|
||||
for index, material_morph_data in enumerate(material_morph.data):
|
||||
if any(morph_data_equals(material_morph_data, saved_material_morph_data) for saved_material_morph_data in save_materil_morph_datas):
|
||||
remove_material_morph_data_indices.append(index)
|
||||
continue
|
||||
save_materil_morph_datas.append(material_morph_data)
|
||||
|
||||
for index in reversed(remove_material_morph_data_indices):
|
||||
material_morph.data.remove(index)
|
||||
|
||||
# Mark duplicated mmd_root.material_morphs[]
|
||||
save_material_morphs = []
|
||||
remove_material_morph_names = []
|
||||
for material_morph in sorted(mmd_root.material_morphs, key=lambda m: m.name):
|
||||
if any(morph_equals(material_morph, saved_material_morph) for saved_material_morph in save_material_morphs):
|
||||
remove_material_morph_names.append(material_morph.name)
|
||||
continue
|
||||
|
||||
save_material_morphs.append(material_morph)
|
||||
|
||||
# Remove marked mmd_root.material_morphs[]
|
||||
for material_morph_name in remove_material_morph_names:
|
||||
mmd_root.material_morphs.remove(mmd_root.material_morphs.find(material_morph_name))
|
||||
|
||||
|
||||
class _MorphSlider:
|
||||
def __init__(self, model: "Model"):
|
||||
self.__rig = model
|
||||
|
||||
def placeholder(self, create=False, binded=False):
|
||||
rig = self.__rig
|
||||
root = rig.rootObject()
|
||||
obj = next((x for x in root.children if x.mmd_type == "PLACEHOLDER" and x.type == "MESH"), None)
|
||||
if create and obj is None:
|
||||
obj = bpy.data.objects.new(name=".placeholder", object_data=bpy.data.meshes.new(".placeholder"))
|
||||
obj.mmd_type = "PLACEHOLDER"
|
||||
obj.parent = root
|
||||
FnContext.link_object(FnContext.ensure_context(), obj)
|
||||
if obj and obj.data.shape_keys is None:
|
||||
key = obj.shape_key_add(name="--- morph sliders ---")
|
||||
key.mute = True
|
||||
obj.active_shape_key_index = 0
|
||||
if binded and obj and obj.data.shape_keys.key_blocks[0].mute:
|
||||
return None
|
||||
return obj
|
||||
|
||||
@property
|
||||
def dummy_armature(self):
|
||||
obj = self.placeholder()
|
||||
return self.__dummy_armature(obj) if obj else None
|
||||
|
||||
def __dummy_armature(self, obj, create=False):
|
||||
arm = next((x for x in obj.children if x.mmd_type == "PLACEHOLDER" and x.type == "ARMATURE"), None)
|
||||
if create and arm is None:
|
||||
arm = bpy.data.objects.new(name=".dummy_armature", object_data=bpy.data.armatures.new(name=".dummy_armature"))
|
||||
arm.mmd_type = "PLACEHOLDER"
|
||||
arm.parent = obj
|
||||
FnContext.link_object(FnContext.ensure_context(), arm)
|
||||
|
||||
from .bone import FnBone
|
||||
|
||||
FnBone.setup_special_bone_collections(arm)
|
||||
return arm
|
||||
|
||||
def get(self, morph_name):
|
||||
obj = self.placeholder()
|
||||
if obj is None:
|
||||
return None
|
||||
key_blocks = obj.data.shape_keys.key_blocks
|
||||
if key_blocks[0].mute:
|
||||
return None
|
||||
return key_blocks.get(morph_name, None)
|
||||
|
||||
def create(self):
|
||||
self.__rig.loadMorphs()
|
||||
obj = self.placeholder(create=True)
|
||||
self.__load(obj, self.__rig.rootObject().mmd_root)
|
||||
return obj
|
||||
|
||||
def __load(self, obj, mmd_root):
|
||||
attr_list = ("group", "vertex", "bone", "uv", "material")
|
||||
morph_sliders = obj.data.shape_keys.key_blocks
|
||||
for m in (x for attr in attr_list for x in getattr(mmd_root, attr + "_morphs", ())):
|
||||
name = m.name
|
||||
# if name[-1] == '\\': # fix driver's bug???
|
||||
# m.name = name = name + ' '
|
||||
if name and name not in morph_sliders:
|
||||
obj.shape_key_add(name=name, from_mix=False)
|
||||
|
||||
@staticmethod
|
||||
def __driver_variables(id_data, path, index=-1):
|
||||
d = id_data.driver_add(path, index)
|
||||
variables = d.driver.variables
|
||||
for x in reversed(variables):
|
||||
variables.remove(x)
|
||||
return d.driver, variables
|
||||
|
||||
@staticmethod
|
||||
def __add_single_prop(variables, id_obj, data_path, prefix):
|
||||
var = variables.new()
|
||||
var.name = f"{prefix}{len(variables)}"
|
||||
var.type = "SINGLE_PROP"
|
||||
target = var.targets[0]
|
||||
target.id_type = "OBJECT"
|
||||
target.id = id_obj
|
||||
target.data_path = data_path
|
||||
return var
|
||||
|
||||
@staticmethod
|
||||
def __shape_key_driver_check(key_block, resolve_path=False):
|
||||
if resolve_path:
|
||||
try:
|
||||
key_block.id_data.path_resolve(key_block.path_from_id())
|
||||
except ValueError:
|
||||
return False
|
||||
if not key_block.id_data.animation_data:
|
||||
return True
|
||||
d = key_block.id_data.animation_data.drivers.find(key_block.path_from_id("value"))
|
||||
if isinstance(d, int): # for Blender 2.76 or older
|
||||
data_path = key_block.path_from_id("value")
|
||||
d = next((i for i in key_block.id_data.animation_data.drivers if i.data_path == data_path), None)
|
||||
return not d or d.driver.expression == "".join(("*w", "+g", "v")[-1 if i < 1 else i % 2] + str(i + 1) for i in range(len(d.driver.variables)))
|
||||
|
||||
def __cleanup(self, names_in_use=None):
|
||||
names_in_use = names_in_use or {}
|
||||
rig = self.__rig
|
||||
morph_sliders = self.placeholder()
|
||||
morph_sliders = morph_sliders.data.shape_keys.key_blocks if morph_sliders else {}
|
||||
for mesh_object in rig.meshes():
|
||||
for kb in getattr(mesh_object.data.shape_keys, "key_blocks", cast("Tuple[bpy.types.ShapeKey]", ())):
|
||||
if kb.name in names_in_use:
|
||||
continue
|
||||
|
||||
if kb.name.startswith("mmd_bind"):
|
||||
kb.driver_remove("value")
|
||||
ms = morph_sliders[kb.relative_key.name]
|
||||
kb.relative_key.slider_min, kb.relative_key.slider_max = min(ms.slider_min, math.floor(ms.value)), max(ms.slider_max, math.ceil(ms.value))
|
||||
kb.relative_key.value = ms.value
|
||||
kb.relative_key.mute = False
|
||||
FnObject.mesh_remove_shape_key(mesh_object, kb)
|
||||
|
||||
elif kb.name in morph_sliders and self.__shape_key_driver_check(kb):
|
||||
ms = morph_sliders[kb.name]
|
||||
kb.driver_remove("value")
|
||||
kb.slider_min, kb.slider_max = min(ms.slider_min, math.floor(kb.value)), max(ms.slider_max, math.ceil(kb.value))
|
||||
|
||||
for m in reversed(mesh_object.modifiers): # uv morph
|
||||
if m.name.startswith("mmd_bind") and m.name not in names_in_use:
|
||||
mesh_object.modifiers.remove(m)
|
||||
|
||||
from .shader import _MaterialMorph
|
||||
|
||||
for m in rig.materials():
|
||||
if m and m.node_tree:
|
||||
for n in sorted((x for x in m.node_tree.nodes if x.name.startswith("mmd_bind")), key=lambda x: -x.location[0]):
|
||||
_MaterialMorph.reset_morph_links(n)
|
||||
m.node_tree.nodes.remove(n)
|
||||
|
||||
attributes = set(TransformConstraintOp.min_max_attributes("LOCATION", "to"))
|
||||
attributes |= set(TransformConstraintOp.min_max_attributes("ROTATION", "to"))
|
||||
for b in rig.armature().pose.bones:
|
||||
for c in reversed(b.constraints):
|
||||
if c.name.startswith("mmd_bind") and c.name[:-4] not in names_in_use:
|
||||
for attr in attributes:
|
||||
c.driver_remove(attr)
|
||||
b.constraints.remove(c)
|
||||
|
||||
def unbind(self):
|
||||
mmd_root = self.__rig.rootObject().mmd_root
|
||||
|
||||
# after unbind, the weird lag problem will disappear.
|
||||
mmd_root.morph_panel_show_settings = True
|
||||
|
||||
for m in mmd_root.bone_morphs:
|
||||
for d in m.data:
|
||||
d.name = ""
|
||||
for m in mmd_root.material_morphs:
|
||||
for d in m.data:
|
||||
d.name = ""
|
||||
obj = self.placeholder()
|
||||
if obj:
|
||||
obj.data.shape_keys.key_blocks[0].mute = True
|
||||
arm = self.__dummy_armature(obj)
|
||||
if arm:
|
||||
for b in arm.pose.bones:
|
||||
if b.name.startswith("mmd_bind"):
|
||||
b.driver_remove("location")
|
||||
b.driver_remove("rotation_quaternion")
|
||||
self.__cleanup()
|
||||
|
||||
def bind(self):
|
||||
rig = self.__rig
|
||||
root = rig.rootObject()
|
||||
armObj = rig.armature()
|
||||
mmd_root = root.mmd_root
|
||||
|
||||
# hide detail to avoid weird lag problem
|
||||
mmd_root.morph_panel_show_settings = False
|
||||
|
||||
obj = self.create()
|
||||
arm = self.__dummy_armature(obj, create=True)
|
||||
morph_sliders = obj.data.shape_keys.key_blocks
|
||||
|
||||
# data gathering
|
||||
group_map = {}
|
||||
|
||||
shape_key_map = {}
|
||||
uv_morph_map = {}
|
||||
for mesh_object in rig.meshes():
|
||||
mesh_object.show_only_shape_key = False
|
||||
key_blocks = getattr(mesh_object.data.shape_keys, "key_blocks", ())
|
||||
for kb in key_blocks:
|
||||
kb_name = kb.name
|
||||
if kb_name not in morph_sliders:
|
||||
continue
|
||||
|
||||
if self.__shape_key_driver_check(kb, resolve_path=True):
|
||||
name_bind, kb_bind = kb_name, kb
|
||||
else:
|
||||
name_bind = "mmd_bind%s" % hash(morph_sliders[kb_name])
|
||||
if name_bind not in key_blocks:
|
||||
mesh_object.shape_key_add(name=name_bind, from_mix=False)
|
||||
kb_bind = key_blocks[name_bind]
|
||||
kb_bind.relative_key = kb
|
||||
kb_bind.slider_min = -10
|
||||
kb_bind.slider_max = 10
|
||||
|
||||
data_path = 'data.shape_keys.key_blocks["%s"].value' % kb_name.replace('"', '\\"')
|
||||
groups = []
|
||||
shape_key_map.setdefault(name_bind, []).append((kb_bind, data_path, groups))
|
||||
group_map.setdefault(("vertex_morphs", kb_name), []).append(groups)
|
||||
|
||||
uv_layers = [layer.name for layer in mesh_object.data.uv_layers if not layer.name.startswith("_")]
|
||||
uv_layers += [""] * (5 - len(uv_layers))
|
||||
for vg, morph_name, axis in FnMorph.get_uv_morph_vertex_groups(mesh_object):
|
||||
morph = mmd_root.uv_morphs.get(morph_name, None)
|
||||
if morph is None or morph.data_type != "VERTEX_GROUP":
|
||||
continue
|
||||
|
||||
uv_layer = "_" + uv_layers[morph.uv_index] if axis[1] in "ZW" else uv_layers[morph.uv_index]
|
||||
if uv_layer not in mesh_object.data.uv_layers:
|
||||
continue
|
||||
|
||||
name_bind = "mmd_bind%s" % hash(vg.name)
|
||||
uv_morph_map.setdefault(name_bind, ())
|
||||
mod = mesh_object.modifiers.get(name_bind, None) or mesh_object.modifiers.new(name=name_bind, type="UV_WARP")
|
||||
mod.show_expanded = False
|
||||
mod.vertex_group = vg.name
|
||||
mod.axis_u, mod.axis_v = ("Y", "X") if axis[1] in "YW" else ("X", "Y")
|
||||
mod.uv_layer = uv_layer
|
||||
name_bind = "mmd_bind%s" % hash(morph_name)
|
||||
mod.object_from = mod.object_to = arm
|
||||
if axis[0] == "-":
|
||||
mod.bone_from, mod.bone_to = "mmd_bind_ctrl_base", name_bind
|
||||
else:
|
||||
mod.bone_from, mod.bone_to = name_bind, "mmd_bind_ctrl_base"
|
||||
|
||||
bone_offset_map = {}
|
||||
with bpyutils.edit_object(arm) as data:
|
||||
from .bone import FnBone
|
||||
|
||||
edit_bones = data.edit_bones
|
||||
|
||||
def __get_bone(name, parent):
|
||||
b = edit_bones.get(name, None) or edit_bones.new(name=name)
|
||||
b.head = (0, 0, 0)
|
||||
b.tail = (0, 0, 1)
|
||||
b.use_deform = False
|
||||
b.parent = parent
|
||||
return b
|
||||
|
||||
for m in mmd_root.bone_morphs:
|
||||
morph_name = m.name.replace('"', '\\"')
|
||||
data_path = f'data.shape_keys.key_blocks["{morph_name}"].value'
|
||||
for d in m.data:
|
||||
if not d.bone:
|
||||
d.name = ""
|
||||
continue
|
||||
d.name = name_bind = f"mmd_bind{hash(d)}"
|
||||
b = FnBone.set_edit_bone_to_shadow(__get_bone(name_bind, None))
|
||||
groups = []
|
||||
bone_offset_map[name_bind] = (m.name, d, b.name, data_path, groups)
|
||||
group_map.setdefault(("bone_morphs", m.name), []).append(groups)
|
||||
|
||||
ctrl_base = FnBone.set_edit_bone_to_dummy(__get_bone("mmd_bind_ctrl_base", None))
|
||||
for m in mmd_root.uv_morphs:
|
||||
morph_name = m.name.replace('"', '\\"')
|
||||
data_path = f'data.shape_keys.key_blocks["{morph_name}"].value'
|
||||
scale_path = f'mmd_root.uv_morphs["{morph_name}"].vertex_group_scale'
|
||||
name_bind = f"mmd_bind{hash(m.name)}"
|
||||
b = FnBone.set_edit_bone_to_dummy(__get_bone(name_bind, ctrl_base))
|
||||
groups = []
|
||||
uv_morph_map.setdefault(name_bind, []).append((b.name, data_path, scale_path, groups))
|
||||
group_map.setdefault(("uv_morphs", m.name), []).append(groups)
|
||||
|
||||
used_bone_names = bone_offset_map.keys() | uv_morph_map.keys()
|
||||
used_bone_names.add(ctrl_base.name)
|
||||
for b in reversed(edit_bones): # cleanup
|
||||
if b.name.startswith("mmd_bind") and b.name not in used_bone_names:
|
||||
edit_bones.remove(b)
|
||||
|
||||
material_offset_map = {}
|
||||
for m in mmd_root.material_morphs:
|
||||
morph_name = m.name.replace('"', '\\"')
|
||||
data_path = f'data.shape_keys.key_blocks["{morph_name}"].value'
|
||||
groups = []
|
||||
group_map.setdefault(("material_morphs", m.name), []).append(groups)
|
||||
material_offset_map.setdefault("group_dict", {})[m.name] = (data_path, groups)
|
||||
for d in m.data:
|
||||
d.name = name_bind = f"mmd_bind{hash(d)}"
|
||||
# add '#' before material name to avoid conflict with group_dict
|
||||
table = material_offset_map.setdefault("#" + d.material, ([], []))
|
||||
table[1 if d.offset_type == "ADD" else 0].append((m.name, d, name_bind))
|
||||
|
||||
for m in mmd_root.group_morphs:
|
||||
if len(m.data) != len(set(m.data.keys())):
|
||||
logger.warning(' * Found duplicated morph data in Group Morph "%s"', m.name)
|
||||
morph_name = m.name.replace('"', '\\"')
|
||||
morph_path = f'data.shape_keys.key_blocks["{morph_name}"].value'
|
||||
for d in m.data:
|
||||
data_name = d.name.replace('"', '\\"')
|
||||
factor_path = f'mmd_root.group_morphs["{morph_name}"].data["{data_name}"].factor'
|
||||
for groups in group_map.get((d.morph_type, d.name), ()):
|
||||
groups.append((m.name, morph_path, factor_path))
|
||||
|
||||
self.__cleanup(shape_key_map.keys() | bone_offset_map.keys() | uv_morph_map.keys())
|
||||
|
||||
def __config_groups(variables, expression, groups):
|
||||
for g_name, morph_path, factor_path in groups:
|
||||
var = self.__add_single_prop(variables, obj, morph_path, "g")
|
||||
fvar = self.__add_single_prop(variables, root, factor_path, "w")
|
||||
expression = f"{expression}+{var.name}*{fvar.name}"
|
||||
return expression
|
||||
|
||||
# vertex morphs
|
||||
for kb_bind, morph_data_path, groups in (i for value_list in shape_key_map.values() for i in value_list):
|
||||
driver, variables = self.__driver_variables(kb_bind, "value")
|
||||
var = self.__add_single_prop(variables, obj, morph_data_path, "v")
|
||||
if kb_bind.name.startswith("mmd_bind"):
|
||||
driver.expression = f"-({__config_groups(variables, var.name, groups)})"
|
||||
kb_bind.relative_key.mute = True
|
||||
else:
|
||||
driver.expression = __config_groups(variables, var.name, groups)
|
||||
kb_bind.mute = False
|
||||
|
||||
# bone morphs
|
||||
def __config_bone_morph(constraints, map_type, attributes, val, val_str):
|
||||
c_name = f"mmd_bind{hash(data)}.{map_type[:3]}"
|
||||
c = TransformConstraintOp.create(constraints, c_name, map_type)
|
||||
TransformConstraintOp.update_min_max(c, val, None)
|
||||
c.show_expanded = False
|
||||
c.target = arm
|
||||
c.subtarget = bname
|
||||
for attr in attributes:
|
||||
driver, variables = self.__driver_variables(armObj, c.path_from_id(attr))
|
||||
var = self.__add_single_prop(variables, obj, morph_data_path, "b")
|
||||
expression = __config_groups(variables, var.name, groups)
|
||||
sign = "-" if attr.startswith("to_min") else ""
|
||||
driver.expression = f"{sign}{val_str}*({expression})"
|
||||
|
||||
attributes_rot = TransformConstraintOp.min_max_attributes("ROTATION", "to")
|
||||
attributes_loc = TransformConstraintOp.min_max_attributes("LOCATION", "to")
|
||||
for morph_name, data, bname, morph_data_path, groups in bone_offset_map.values():
|
||||
b = arm.pose.bones[bname]
|
||||
b.location = data.location
|
||||
b.rotation_quaternion = data.rotation.__class__(*data.rotation.to_axis_angle()) # Fix for consistency
|
||||
b.is_mmd_shadow_bone = True
|
||||
b.mmd_shadow_bone_type = "BIND"
|
||||
pb = armObj.pose.bones[data.bone]
|
||||
__config_bone_morph(pb.constraints, "ROTATION", attributes_rot, math.pi, "pi")
|
||||
__config_bone_morph(pb.constraints, "LOCATION", attributes_loc, 100, "100")
|
||||
|
||||
# uv morphs
|
||||
# HACK: workaround for Blender 2.80+, data_path can't be properly detected (Save & Reopen file also works)
|
||||
root.parent, root.parent, root.matrix_parent_inverse = arm, root.parent, root.matrix_parent_inverse.copy()
|
||||
b = arm.pose.bones["mmd_bind_ctrl_base"]
|
||||
b.is_mmd_shadow_bone = True
|
||||
b.mmd_shadow_bone_type = "BIND"
|
||||
for bname, data_path, scale_path, groups in (i for value_list in uv_morph_map.values() for i in value_list):
|
||||
b = arm.pose.bones[bname]
|
||||
b.is_mmd_shadow_bone = True
|
||||
b.mmd_shadow_bone_type = "BIND"
|
||||
driver, variables = self.__driver_variables(b, "location", index=0)
|
||||
var = self.__add_single_prop(variables, obj, data_path, "u")
|
||||
fvar = self.__add_single_prop(variables, root, scale_path, "s")
|
||||
driver.expression = f"({__config_groups(variables, var.name, groups)})*{fvar.name}"
|
||||
|
||||
# material morphs
|
||||
from .shader import _MaterialMorph
|
||||
|
||||
group_dict = material_offset_map.get("group_dict", {})
|
||||
|
||||
def __config_material_morph(mat, morph_list):
|
||||
nodes = _MaterialMorph.setup_morph_nodes(mat, tuple(x[1] for x in morph_list))
|
||||
for (morph_name, data, name_bind), node in zip(morph_list, nodes, strict=False):
|
||||
node.label, node.name = morph_name, name_bind
|
||||
data_path, groups = group_dict[morph_name]
|
||||
driver, variables = self.__driver_variables(mat.node_tree, node.inputs[0].path_from_id("default_value"))
|
||||
var = self.__add_single_prop(variables, obj, data_path, "m")
|
||||
driver.expression = "%s" % __config_groups(variables, var.name, groups)
|
||||
|
||||
for mat in (m for m in rig.materials() if m and m.use_nodes and not m.name.startswith("mmd_")):
|
||||
mul_all, add_all = material_offset_map.get("#", ([], []))
|
||||
if mat.name == "":
|
||||
logger.warning("Oh no. The material name should never empty.")
|
||||
mul_list, add_list = [], []
|
||||
else:
|
||||
mat_name = "#" + mat.name
|
||||
mul_list, add_list = material_offset_map.get(mat_name, ([], []))
|
||||
morph_list = tuple(mul_all + mul_list + add_all + add_list)
|
||||
__config_material_morph(mat, morph_list)
|
||||
mat_edge = bpy.data.materials.get("mmd_edge." + mat.name, None)
|
||||
if mat_edge:
|
||||
__config_material_morph(mat_edge, morph_list)
|
||||
|
||||
morph_sliders[0].mute = False
|
||||
|
||||
|
||||
class MigrationFnMorph:
|
||||
@staticmethod
|
||||
def update_mmd_morph():
|
||||
from .material import FnMaterial
|
||||
|
||||
for root in bpy.data.objects:
|
||||
if root.mmd_type != "ROOT":
|
||||
continue
|
||||
|
||||
for mat_morph in root.mmd_root.material_morphs:
|
||||
for morph_data in mat_morph.data:
|
||||
if morph_data.material_data is not None:
|
||||
# SUPPORT_UNTIL: 5 LTS
|
||||
# The material_id is also no longer used, but for compatibility with older version mmd_tools_local, keep it.
|
||||
if "material_id" not in morph_data.material_data.mmd_material or "material_id" not in morph_data or morph_data.material_data.mmd_material["material_id"] == morph_data["material_id"]:
|
||||
# In the new version, the related_mesh property is no longer used.
|
||||
# Explicitly remove this property to avoid misuse.
|
||||
if "related_mesh" in morph_data:
|
||||
del morph_data["related_mesh"]
|
||||
continue
|
||||
|
||||
# Compat case. The new version mmd_tools_local saved. And old version mmd_tools_local edit. Then new version mmd_tools_local load again.
|
||||
# Go update path.
|
||||
pass
|
||||
|
||||
morph_data.material_data = None
|
||||
if "material_id" in morph_data:
|
||||
mat_id = morph_data["material_id"]
|
||||
if mat_id >= 0:
|
||||
fnMat = FnMaterial.from_material_id(mat_id)
|
||||
if fnMat:
|
||||
morph_data.material_data = fnMat.material
|
||||
else:
|
||||
morph_data["material_id"] = -1
|
||||
|
||||
morph_data.related_mesh_data = None
|
||||
if "related_mesh" in morph_data:
|
||||
related_mesh = morph_data["related_mesh"]
|
||||
del morph_data["related_mesh"]
|
||||
if related_mesh != "" and related_mesh in bpy.data.meshes:
|
||||
morph_data.related_mesh_data = bpy.data.meshes[related_mesh]
|
||||
|
||||
@staticmethod
|
||||
def ensure_material_id_not_conflict():
|
||||
mat_ids_set = set()
|
||||
|
||||
# The reference library properties cannot be modified and bypassed in advance.
|
||||
need_update_mat = []
|
||||
for mat in bpy.data.materials:
|
||||
if mat.mmd_material.material_id < 0:
|
||||
continue
|
||||
if mat.library is not None:
|
||||
mat_ids_set.add(mat.mmd_material.material_id)
|
||||
else:
|
||||
need_update_mat.append(mat)
|
||||
|
||||
for mat in need_update_mat:
|
||||
if mat.mmd_material.material_id in mat_ids_set:
|
||||
mat.mmd_material.material_id = max(mat_ids_set) + 1
|
||||
mat_ids_set.add(mat.mmd_material.material_id)
|
||||
|
||||
@staticmethod
|
||||
def compatible_with_old_version_mmd_tools_local():
|
||||
MigrationFnMorph.ensure_material_id_not_conflict()
|
||||
|
||||
for root in bpy.data.objects:
|
||||
if root.mmd_type != "ROOT":
|
||||
continue
|
||||
|
||||
for mat_morph in root.mmd_root.material_morphs:
|
||||
for morph_data in mat_morph.data:
|
||||
morph_data["related_mesh"] = morph_data.related_mesh
|
||||
|
||||
if morph_data.material_data is None:
|
||||
morph_data.material_id = -1
|
||||
else:
|
||||
morph_data.material_id = morph_data.material_data.mmd_material.material_id
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,288 @@
|
||||
# Copyright 2014 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
from ....core.logging_setup import logger
|
||||
from typing import List, Optional
|
||||
|
||||
import bpy
|
||||
from mathutils import Euler, Vector
|
||||
|
||||
from ..bpyutils import FnContext, Props
|
||||
|
||||
SHAPE_SPHERE = 0
|
||||
SHAPE_BOX = 1
|
||||
SHAPE_CAPSULE = 2
|
||||
|
||||
MODE_STATIC = 0
|
||||
MODE_DYNAMIC = 1
|
||||
MODE_DYNAMIC_BONE = 2
|
||||
|
||||
|
||||
def shapeType(collision_shape):
|
||||
return ("SPHERE", "BOX", "CAPSULE").index(collision_shape)
|
||||
|
||||
|
||||
def collisionShape(shape_type):
|
||||
return ("SPHERE", "BOX", "CAPSULE")[shape_type]
|
||||
|
||||
|
||||
def setRigidBodyWorldEnabled(enable):
|
||||
if bpy.ops.rigidbody.world_add.poll():
|
||||
bpy.ops.rigidbody.world_add()
|
||||
rigidbody_world = bpy.context.scene.rigidbody_world
|
||||
enabled = rigidbody_world.enabled
|
||||
rigidbody_world.enabled = enable
|
||||
return enabled
|
||||
|
||||
|
||||
class RigidBodyMaterial:
|
||||
COLORS = [
|
||||
0x7FDDD4,
|
||||
0xF0E68C,
|
||||
0xEE82EE,
|
||||
0xFFE4E1,
|
||||
0x8FEEEE,
|
||||
0xADFF2F,
|
||||
0xFA8072,
|
||||
0x9370DB,
|
||||
0x40E0D0,
|
||||
0x96514D,
|
||||
0x5A964E,
|
||||
0xE6BFAB,
|
||||
0xD3381C,
|
||||
0x165E83,
|
||||
0x701682,
|
||||
0x828216,
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def getMaterial(cls, number):
|
||||
number = int(number)
|
||||
material_name = "mmd_tools_rigid_%d" % (number)
|
||||
if material_name not in bpy.data.materials:
|
||||
mat = bpy.data.materials.new(material_name)
|
||||
color = cls.COLORS[number]
|
||||
mat.diffuse_color[:3] = [((0xFF0000 & color) >> 16) / float(255), ((0x00FF00 & color) >> 8) / float(255), (0x0000FF & color) / float(255)]
|
||||
mat.specular_intensity = 0
|
||||
if len(mat.diffuse_color) > 3:
|
||||
mat.diffuse_color[3] = 0.5
|
||||
mat.blend_method = "BLEND"
|
||||
if hasattr(mat, "shadow_method"):
|
||||
mat.shadow_method = "NONE"
|
||||
mat.use_backface_culling = True
|
||||
mat.show_transparent_back = False
|
||||
mat.use_nodes = True
|
||||
nodes, links = mat.node_tree.nodes, mat.node_tree.links
|
||||
nodes.clear()
|
||||
node_color = nodes.new("ShaderNodeBackground")
|
||||
node_color.inputs["Color"].default_value = mat.diffuse_color
|
||||
node_output = nodes.new("ShaderNodeOutputMaterial")
|
||||
links.new(node_color.outputs[0], node_output.inputs["Surface"])
|
||||
else:
|
||||
mat = bpy.data.materials[material_name]
|
||||
return mat
|
||||
|
||||
|
||||
class FnRigidBody:
|
||||
@staticmethod
|
||||
def new_rigid_body_objects(context: bpy.types.Context, parent_object: bpy.types.Object, count: int) -> List[bpy.types.Object]:
|
||||
if count < 1:
|
||||
return []
|
||||
|
||||
obj = FnRigidBody.new_rigid_body_object(context, parent_object)
|
||||
|
||||
if count == 1:
|
||||
return [obj]
|
||||
|
||||
return FnContext.duplicate_object(context, obj, count)
|
||||
|
||||
@staticmethod
|
||||
def new_rigid_body_object(context: bpy.types.Context, parent_object: bpy.types.Object) -> bpy.types.Object:
|
||||
obj = FnContext.new_and_link_object(context, name="Rigidbody", object_data=bpy.data.meshes.new(name="Rigidbody"))
|
||||
obj.parent = parent_object
|
||||
obj.mmd_type = "RIGID_BODY"
|
||||
obj.rotation_mode = "YXZ"
|
||||
setattr(obj, Props.display_type, "SOLID")
|
||||
obj.show_transparent = True
|
||||
obj.hide_render = True
|
||||
obj.display.show_shadows = False
|
||||
|
||||
with context.temp_override(object=obj):
|
||||
bpy.ops.rigidbody.object_add(type="ACTIVE")
|
||||
|
||||
return obj
|
||||
|
||||
@staticmethod
|
||||
def setup_rigid_body_object(
|
||||
obj: bpy.types.Object,
|
||||
shape_type: str,
|
||||
location: Vector,
|
||||
rotation: Euler,
|
||||
size: Vector,
|
||||
dynamics_type: str,
|
||||
collision_group_number: Optional[int] = None,
|
||||
collision_group_mask: Optional[List[bool]] = None,
|
||||
name: Optional[str] = None,
|
||||
name_e: Optional[str] = None,
|
||||
bone: Optional[str] = None,
|
||||
friction: Optional[float] = None,
|
||||
mass: Optional[float] = None,
|
||||
angular_damping: Optional[float] = None,
|
||||
linear_damping: Optional[float] = None,
|
||||
bounce: Optional[float] = None,
|
||||
) -> bpy.types.Object:
|
||||
obj.location = location
|
||||
obj.rotation_euler = rotation
|
||||
|
||||
obj.mmd_rigid.shape = collisionShape(shape_type)
|
||||
obj.mmd_rigid.size = size
|
||||
obj.mmd_rigid.type = str(dynamics_type) if dynamics_type in range(3) else "1"
|
||||
|
||||
if collision_group_number is not None:
|
||||
obj.mmd_rigid.collision_group_number = collision_group_number
|
||||
|
||||
if collision_group_mask is not None:
|
||||
obj.mmd_rigid.collision_group_mask = collision_group_mask
|
||||
|
||||
if name is not None:
|
||||
obj.name = name
|
||||
obj.mmd_rigid.name_j = name
|
||||
obj.data.name = name
|
||||
|
||||
if name_e is not None:
|
||||
obj.mmd_rigid.name_e = name_e
|
||||
|
||||
if bone is not None:
|
||||
obj.mmd_rigid.bone = bone
|
||||
else:
|
||||
obj.mmd_rigid.bone = ""
|
||||
|
||||
rb = obj.rigid_body
|
||||
if friction is not None:
|
||||
rb.friction = friction
|
||||
if mass is not None:
|
||||
rb.mass = mass
|
||||
if angular_damping is not None:
|
||||
rb.angular_damping = angular_damping
|
||||
if linear_damping is not None:
|
||||
rb.linear_damping = linear_damping
|
||||
if bounce is not None:
|
||||
rb.restitution = bounce
|
||||
|
||||
return obj
|
||||
|
||||
@staticmethod
|
||||
def get_rigid_body_size(obj: bpy.types.Object):
|
||||
assert obj.mmd_type == "RIGID_BODY"
|
||||
|
||||
x0, y0, z0 = obj.bound_box[0]
|
||||
x1, y1, z1 = obj.bound_box[6]
|
||||
if not (x1 >= x0 and y1 >= y0 and z1 >= z0):
|
||||
logger.warning(f"Rigid body '{obj.name}' has invalid bounding box coordinates, using default size")
|
||||
return (1.0, 1.0, 1.0)
|
||||
|
||||
shape = obj.mmd_rigid.shape
|
||||
if shape == "SPHERE":
|
||||
radius = (z1 - z0) / 2
|
||||
return (radius, 0.0, 0.0)
|
||||
if shape == "BOX":
|
||||
x, y, z = (x1 - x0) / 2, (y1 - y0) / 2, (z1 - z0) / 2
|
||||
return (x, y, z)
|
||||
if shape == "CAPSULE":
|
||||
diameter = x1 - x0
|
||||
radius = diameter / 2
|
||||
height = abs((z1 - z0) - diameter)
|
||||
return (radius, height, 0.0)
|
||||
raise ValueError(f"Invalid shape type: {shape}")
|
||||
|
||||
@staticmethod
|
||||
def new_joint_object(context: bpy.types.Context, parent_object: bpy.types.Object, empty_display_size: float) -> bpy.types.Object:
|
||||
obj = FnContext.new_and_link_object(context, name="Joint", object_data=None)
|
||||
obj.parent = parent_object
|
||||
obj.mmd_type = "JOINT"
|
||||
obj.rotation_mode = "YXZ"
|
||||
setattr(obj, Props.empty_display_type, "ARROWS")
|
||||
setattr(obj, Props.empty_display_size, 0.1 * empty_display_size)
|
||||
obj.hide_render = True
|
||||
|
||||
with context.temp_override():
|
||||
context.view_layer.objects.active = obj
|
||||
bpy.ops.rigidbody.constraint_add(type="GENERIC_SPRING")
|
||||
|
||||
rigid_body_constraint = obj.rigid_body_constraint
|
||||
rigid_body_constraint.disable_collisions = False
|
||||
rigid_body_constraint.use_limit_ang_x = True
|
||||
rigid_body_constraint.use_limit_ang_y = True
|
||||
rigid_body_constraint.use_limit_ang_z = True
|
||||
rigid_body_constraint.use_limit_lin_x = True
|
||||
rigid_body_constraint.use_limit_lin_y = True
|
||||
rigid_body_constraint.use_limit_lin_z = True
|
||||
rigid_body_constraint.use_spring_x = True
|
||||
rigid_body_constraint.use_spring_y = True
|
||||
rigid_body_constraint.use_spring_z = True
|
||||
rigid_body_constraint.use_spring_ang_x = True
|
||||
rigid_body_constraint.use_spring_ang_y = True
|
||||
rigid_body_constraint.use_spring_ang_z = True
|
||||
|
||||
return obj
|
||||
|
||||
@staticmethod
|
||||
def new_joint_objects(context: bpy.types.Context, parent_object: bpy.types.Object, count: int, empty_display_size: float) -> List[bpy.types.Object]:
|
||||
if count < 1:
|
||||
return []
|
||||
|
||||
obj = FnRigidBody.new_joint_object(context, parent_object, empty_display_size)
|
||||
|
||||
if count == 1:
|
||||
return [obj]
|
||||
|
||||
return FnContext.duplicate_object(context, obj, count)
|
||||
|
||||
@staticmethod
|
||||
def setup_joint_object(
|
||||
obj: bpy.types.Object,
|
||||
location: Vector,
|
||||
rotation: Euler,
|
||||
rigid_a: bpy.types.Object,
|
||||
rigid_b: bpy.types.Object,
|
||||
maximum_location: Vector,
|
||||
minimum_location: Vector,
|
||||
maximum_rotation: Euler,
|
||||
minimum_rotation: Euler,
|
||||
spring_angular: Vector,
|
||||
spring_linear: Vector,
|
||||
name: str,
|
||||
name_e: Optional[str] = None,
|
||||
) -> bpy.types.Object:
|
||||
obj.name = f"J.{name}"
|
||||
|
||||
obj.location = location
|
||||
obj.rotation_euler = rotation
|
||||
|
||||
rigid_body_constraint = obj.rigid_body_constraint
|
||||
rigid_body_constraint.object1 = rigid_a
|
||||
rigid_body_constraint.object2 = rigid_b
|
||||
rigid_body_constraint.limit_lin_x_upper = maximum_location.x
|
||||
rigid_body_constraint.limit_lin_y_upper = maximum_location.y
|
||||
rigid_body_constraint.limit_lin_z_upper = maximum_location.z
|
||||
|
||||
rigid_body_constraint.limit_lin_x_lower = minimum_location.x
|
||||
rigid_body_constraint.limit_lin_y_lower = minimum_location.y
|
||||
rigid_body_constraint.limit_lin_z_lower = minimum_location.z
|
||||
|
||||
rigid_body_constraint.limit_ang_x_upper = maximum_rotation.x
|
||||
rigid_body_constraint.limit_ang_y_upper = maximum_rotation.y
|
||||
rigid_body_constraint.limit_ang_z_upper = maximum_rotation.z
|
||||
|
||||
rigid_body_constraint.limit_ang_x_lower = minimum_rotation.x
|
||||
rigid_body_constraint.limit_ang_y_lower = minimum_rotation.y
|
||||
rigid_body_constraint.limit_ang_z_lower = minimum_rotation.z
|
||||
|
||||
obj.mmd_joint.name_j = name
|
||||
if name_e is not None:
|
||||
obj.mmd_joint.name_e = name_e
|
||||
|
||||
obj.mmd_joint.spring_linear = spring_linear
|
||||
obj.mmd_joint.spring_angular = spring_angular
|
||||
|
||||
return obj
|
||||
@@ -0,0 +1,335 @@
|
||||
# Copyright 2018 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
from ....core.logging_setup import logger
|
||||
import time
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
from ..bpyutils import FnObject
|
||||
|
||||
|
||||
def _hash(v):
|
||||
if isinstance(v, (bpy.types.Object, bpy.types.PoseBone)):
|
||||
return hash(type(v).__name__ + v.name)
|
||||
if isinstance(v, bpy.types.Pose):
|
||||
return hash(type(v).__name__ + v.id_data.name)
|
||||
raise NotImplementedError("hash")
|
||||
|
||||
|
||||
class FnSDEF:
|
||||
g_verts = {} # global cache
|
||||
g_shapekey_data = {}
|
||||
g_bone_check = {}
|
||||
__g_armature_check = {}
|
||||
SHAPEKEY_NAME = "mmd_sdef_skinning"
|
||||
MASK_NAME = "mmd_sdef_mask"
|
||||
|
||||
def __init__(self):
|
||||
raise NotImplementedError("not allowed")
|
||||
|
||||
@classmethod
|
||||
def __init_cache(cls, obj, shapekey):
|
||||
key = _hash(obj)
|
||||
obj = getattr(obj, "original", obj)
|
||||
mod = obj.modifiers.get("mmd_armature")
|
||||
key_armature = _hash(mod.object.pose) if mod and mod.type == "ARMATURE" and mod.object else None
|
||||
if key not in cls.g_verts or cls.__g_armature_check.get(key) != key_armature:
|
||||
cls.g_verts[key] = cls.__find_vertices(obj)
|
||||
cls.g_bone_check[key] = {}
|
||||
cls.__g_armature_check[key] = key_armature
|
||||
cls.g_shapekey_data[key] = None
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def __check_bone_update(cls, obj, bone0, bone1):
|
||||
check = cls.g_bone_check[_hash(obj)]
|
||||
key = (_hash(bone0), _hash(bone1))
|
||||
if key not in check or (bone0.matrix, bone1.matrix) != check[key]:
|
||||
check[key] = (bone0.matrix.copy(), bone1.matrix.copy())
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def mute_sdef_set(cls, obj, mute):
|
||||
key_blocks = getattr(obj.data.shape_keys, "key_blocks", ())
|
||||
if cls.SHAPEKEY_NAME in key_blocks:
|
||||
shapekey = key_blocks[cls.SHAPEKEY_NAME]
|
||||
shapekey.mute = mute
|
||||
if cls.has_sdef_data(obj):
|
||||
cls.__init_cache(obj, shapekey)
|
||||
cls.__sdef_muted(obj, shapekey)
|
||||
|
||||
@classmethod
|
||||
def __sdef_muted(cls, obj, shapekey):
|
||||
mute = shapekey.mute
|
||||
if mute != cls.g_bone_check[_hash(obj)].get("sdef_mute"):
|
||||
mod = obj.modifiers.get("mmd_armature")
|
||||
if mod and mod.type == "ARMATURE":
|
||||
if not mute and cls.MASK_NAME not in obj.vertex_groups and obj.mode != "EDIT":
|
||||
mask = tuple(i for v in cls.g_verts[_hash(obj)].values() for i in v[3])
|
||||
obj.vertex_groups.new(name=cls.MASK_NAME).add(mask, 1, "REPLACE")
|
||||
mod.vertex_group = "" if mute else cls.MASK_NAME
|
||||
mod.invert_vertex_group = True
|
||||
shapekey.vertex_group = cls.MASK_NAME
|
||||
cls.g_bone_check[_hash(obj)]["sdef_mute"] = mute
|
||||
return mute
|
||||
|
||||
@staticmethod
|
||||
def has_sdef_data(obj):
|
||||
if obj is None or not hasattr(obj, "modifiers") or not hasattr(obj, "data") or obj.data is None:
|
||||
return False
|
||||
mod = obj.modifiers.get("mmd_armature")
|
||||
if mod and mod.type == "ARMATURE" and mod.object:
|
||||
kb = getattr(obj.data.shape_keys, "key_blocks", None)
|
||||
return kb and "mmd_sdef_c" in kb and "mmd_sdef_r0" in kb and "mmd_sdef_r1" in kb
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def __find_vertices(cls, obj):
|
||||
if not cls.has_sdef_data(obj):
|
||||
logger.debug(f"SDEF vertex search skipped for '{obj.name}': No SDEF data found")
|
||||
return {}
|
||||
|
||||
vertices = {}
|
||||
pose_bones = obj.modifiers.get("mmd_armature").object.pose.bones
|
||||
bone_map = {g.index: pose_bones[g.name] for g in obj.vertex_groups if g.name in pose_bones}
|
||||
sdef_c = obj.data.shape_keys.key_blocks["mmd_sdef_c"].data
|
||||
sdef_r0 = obj.data.shape_keys.key_blocks["mmd_sdef_r0"].data
|
||||
sdef_r1 = obj.data.shape_keys.key_blocks["mmd_sdef_r1"].data
|
||||
vd = obj.data.vertices
|
||||
|
||||
for i in range(len(sdef_c)):
|
||||
if vd[i].co != sdef_c[i].co:
|
||||
bgs = [g for g in vd[i].groups if g.group in bone_map and g.weight] # bone groups
|
||||
if len(bgs) >= 2:
|
||||
bgs.sort(key=lambda x: x.group)
|
||||
# preprocessing
|
||||
w0, w1 = bgs[0].weight, bgs[1].weight
|
||||
# w0 + w1 == 1
|
||||
w0 /= (w0 + w1)
|
||||
w1 = 1 - w0
|
||||
|
||||
c, r0, r1 = sdef_c[i].co, sdef_r0[i].co, sdef_r1[i].co
|
||||
rw = r0 * w0 + r1 * w1
|
||||
r0 = c + r0 - rw
|
||||
r1 = c + r1 - rw
|
||||
|
||||
key = (bgs[0].group, bgs[1].group)
|
||||
if key not in vertices:
|
||||
# TODO basically we can not cache any bone reference
|
||||
vertices[key] = (bone_map[bgs[0].group], bone_map[bgs[1].group], [], [])
|
||||
vertices[key][2].append((i, w0, w1, vd[i].co - c, (c + r0) / 2, (c + r1) / 2))
|
||||
vertices[key][3].append(i)
|
||||
return vertices
|
||||
|
||||
@classmethod
|
||||
def driver_function_wrap(cls, obj_name, bulk_update, use_skip, use_scale):
|
||||
if obj_name not in bpy.data.objects:
|
||||
logger.warning(f"SDEF driver wrap: Object '{obj_name}' not found")
|
||||
return 0.0
|
||||
obj = bpy.data.objects[obj_name]
|
||||
shapekey = obj.data.shape_keys.key_blocks[cls.SHAPEKEY_NAME]
|
||||
return cls.driver_function(shapekey, obj_name, bulk_update, use_skip, use_scale)
|
||||
|
||||
@classmethod
|
||||
def driver_function(cls, shapekey, obj_name, bulk_update, use_skip, use_scale):
|
||||
if obj_name not in bpy.data.objects:
|
||||
logger.warning(f"SDEF driver: Object '{obj_name}' not found, driver will be inactive")
|
||||
return 0.0
|
||||
obj = bpy.data.objects[obj_name]
|
||||
if getattr(shapekey.id_data, "is_evaluated", False):
|
||||
# For Blender 2.8x, we should use evaluated object, and the only reference is the "obj" variable of SDEF driver
|
||||
# cls.driver_function(shapekey.id_data.original.key_blocks[shapekey.name], obj_name, bulk_update, use_skip, use_scale) # update original data
|
||||
data_path = shapekey.path_from_id("value")
|
||||
obj = next(i for i in shapekey.id_data.animation_data.drivers if i.data_path == data_path).driver.variables["obj"].targets[0].id
|
||||
cls.__init_cache(obj, shapekey)
|
||||
if cls.__sdef_muted(obj, shapekey):
|
||||
return 0.0
|
||||
|
||||
pose_bones = obj.modifiers.get("mmd_armature").object.pose.bones
|
||||
if not bulk_update:
|
||||
shapekey_data = shapekey.data
|
||||
if use_scale:
|
||||
# with scale
|
||||
key_blocks = tuple(k for k in shapekey.id_data.key_blocks[1:] if not k.mute and k.value and k.name != cls.SHAPEKEY_NAME)
|
||||
for bone0, bone1, sdef_data, vids in cls.g_verts[_hash(obj)].values():
|
||||
bone0, bone1 = pose_bones[bone0.name], pose_bones[bone1.name]
|
||||
# if use_skip and not cls.__check_bone_update(obj, bone0, bone1):
|
||||
# continue
|
||||
mat0 = bone0.matrix @ bone0.bone.matrix_local.inverted()
|
||||
mat1 = bone1.matrix @ bone1.bone.matrix_local.inverted()
|
||||
rot0 = mat0.to_euler("YXZ").to_quaternion()
|
||||
rot1 = mat1.to_euler("YXZ").to_quaternion()
|
||||
if rot1.dot(rot0) < 0:
|
||||
rot1 = -rot1
|
||||
s0, s1 = mat0.to_scale(), mat1.to_scale()
|
||||
for vid, w0, w1, pos_c, cr0, cr1 in sdef_data:
|
||||
s = s0 * w0 + s1 * w1
|
||||
mat_rot = (rot0 * w0 + rot1 * w1).normalized().to_matrix() @ Matrix([(s[0], 0, 0), (0, s[1], 0), (0, 0, s[2])])
|
||||
delta = sum(((key.data[vid].co - key.relative_key.data[vid].co) * key.value for key in key_blocks), Vector()) # assuming key.vertex_group = ''
|
||||
shapekey_data[vid].co = (mat_rot @ (pos_c + delta)) - delta + (mat0 @ cr0) * w0 + (mat1 @ cr1) * w1
|
||||
else:
|
||||
# default
|
||||
for bone0, bone1, sdef_data, vids in cls.g_verts[_hash(obj)].values():
|
||||
bone0, bone1 = pose_bones[bone0.name], pose_bones[bone1.name]
|
||||
if use_skip and not cls.__check_bone_update(obj, bone0, bone1):
|
||||
continue
|
||||
mat0 = bone0.matrix @ bone0.bone.matrix_local.inverted()
|
||||
mat1 = bone1.matrix @ bone1.bone.matrix_local.inverted()
|
||||
# workaround some weird result of matrix.to_quaternion() using to_euler(), but still minor issues
|
||||
rot0 = mat0.to_euler("YXZ").to_quaternion()
|
||||
rot1 = mat1.to_euler("YXZ").to_quaternion()
|
||||
if rot1.dot(rot0) < 0:
|
||||
rot1 = -rot1
|
||||
for vid, w0, w1, pos_c, cr0, cr1 in sdef_data:
|
||||
mat_rot = (rot0 * w0 + rot1 * w1).normalized().to_matrix()
|
||||
shapekey_data[vid].co = (mat_rot @ pos_c) + (mat0 @ cr0) * w0 + (mat1 @ cr1) * w1
|
||||
else: # bulk update
|
||||
shapekey_data = cls.g_shapekey_data[_hash(obj)]
|
||||
if shapekey_data is None:
|
||||
shapekey_data = np.zeros(len(shapekey.data) * 3, dtype=np.float32)
|
||||
shapekey.data.foreach_get("co", shapekey_data)
|
||||
shapekey_data = cls.g_shapekey_data[_hash(obj)] = shapekey_data.reshape(len(shapekey.data), 3)
|
||||
if use_scale:
|
||||
# scale & bulk update
|
||||
key_blocks = tuple(k for k in shapekey.id_data.key_blocks[1:] if not k.mute and k.value and k.name != cls.SHAPEKEY_NAME)
|
||||
for bone0, bone1, sdef_data, vids in cls.g_verts[_hash(obj)].values():
|
||||
bone0, bone1 = pose_bones[bone0.name], pose_bones[bone1.name]
|
||||
# if use_skip and not cls.__check_bone_update(obj, bone0, bone1):
|
||||
# continue
|
||||
mat0 = bone0.matrix @ bone0.bone.matrix_local.inverted()
|
||||
mat1 = bone1.matrix @ bone1.bone.matrix_local.inverted()
|
||||
rot0 = mat0.to_euler("YXZ").to_quaternion()
|
||||
rot1 = mat1.to_euler("YXZ").to_quaternion()
|
||||
if rot1.dot(rot0) < 0:
|
||||
rot1 = -rot1
|
||||
s0, s1 = mat0.to_scale(), mat1.to_scale()
|
||||
|
||||
def scale(mat_rot, w0, w1, s0, s1):
|
||||
s = s0 * w0 + s1 * w1
|
||||
return mat_rot @ Matrix([(s[0], 0, 0), (0, s[1], 0), (0, 0, s[2])])
|
||||
|
||||
def offset(mat_rot, pos_c, vid):
|
||||
delta = sum(((key.data[vid].co - key.relative_key.data[vid].co) * key.value for key in key_blocks), Vector()) # assuming key.vertex_group = ''
|
||||
return (mat_rot @ (pos_c + delta)) - delta
|
||||
|
||||
shapekey_data[vids] = [offset(scale((rot0 * w0 + rot1 * w1).normalized().to_matrix(), w0, w1, s0, s1), pos_c, vid) + (mat0 @ cr0) * w0 + (mat1 @ cr1) * w1 for vid, w0, w1, pos_c, cr0, cr1 in sdef_data]
|
||||
else:
|
||||
# bulk update
|
||||
for bone0, bone1, sdef_data, vids in cls.g_verts[_hash(obj)].values():
|
||||
bone0, bone1 = pose_bones[bone0.name], pose_bones[bone1.name]
|
||||
if use_skip and not cls.__check_bone_update(obj, bone0, bone1):
|
||||
continue
|
||||
mat0 = bone0.matrix @ bone0.bone.matrix_local.inverted()
|
||||
mat1 = bone1.matrix @ bone1.bone.matrix_local.inverted()
|
||||
rot0 = mat0.to_euler("YXZ").to_quaternion()
|
||||
rot1 = mat1.to_euler("YXZ").to_quaternion()
|
||||
if rot1.dot(rot0) < 0:
|
||||
rot1 = -rot1
|
||||
shapekey_data[vids] = [((rot0 * w0 + rot1 * w1).normalized().to_matrix() @ pos_c) + (mat0 @ cr0) * w0 + (mat1 @ cr1) * w1 for vid, w0, w1, pos_c, cr0, cr1 in sdef_data]
|
||||
shapekey.data.foreach_set("co", shapekey_data.reshape(3 * len(shapekey.data)))
|
||||
|
||||
return 1.0 # shapkey value
|
||||
|
||||
@classmethod
|
||||
def register_driver_function(cls):
|
||||
if "mmd_sdef_driver" not in bpy.app.driver_namespace:
|
||||
bpy.app.driver_namespace["mmd_sdef_driver"] = cls.driver_function
|
||||
if "mmd_sdef_driver_wrap" not in bpy.app.driver_namespace:
|
||||
bpy.app.driver_namespace["mmd_sdef_driver_wrap"] = cls.driver_function_wrap
|
||||
|
||||
BENCH_LOOP = 10
|
||||
|
||||
@classmethod
|
||||
def __get_benchmark_result(cls, obj, shapkey, use_scale, use_skip):
|
||||
# warmed up
|
||||
cls.driver_function(shapkey, obj.name, bulk_update=True, use_skip=False, use_scale=use_scale)
|
||||
cls.driver_function(shapkey, obj.name, bulk_update=False, use_skip=False, use_scale=use_scale)
|
||||
# benchmark
|
||||
t = time.time()
|
||||
for i in range(cls.BENCH_LOOP):
|
||||
cls.driver_function(shapkey, obj.name, bulk_update=False, use_skip=False, use_scale=use_scale)
|
||||
default_time = time.time() - t
|
||||
t = time.time()
|
||||
for i in range(cls.BENCH_LOOP):
|
||||
cls.driver_function(shapkey, obj.name, bulk_update=True, use_skip=False, use_scale=use_scale)
|
||||
bulk_time = time.time() - t
|
||||
result = default_time > bulk_time
|
||||
logger.info("FnSDEF:benchmark: default %.4f vs bulk_update %.4f => bulk_update=%s", default_time, bulk_time, result)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def bind(cls, obj, bulk_update=None, use_skip=True, use_scale=False):
|
||||
# Unbind first
|
||||
cls.unbind(obj)
|
||||
if not cls.has_sdef_data(obj):
|
||||
logger.debug(f"SDEF bind skipped for '{obj.name}': No SDEF data found")
|
||||
return False
|
||||
# Create the shapekey for the driver
|
||||
shapekey = obj.shape_key_add(name=cls.SHAPEKEY_NAME, from_mix=False)
|
||||
cls.__init_cache(obj, shapekey)
|
||||
cls.__sdef_muted(obj, shapekey)
|
||||
cls.register_driver_function()
|
||||
if bulk_update is None:
|
||||
bulk_update = cls.__get_benchmark_result(obj, shapekey, use_scale, use_skip)
|
||||
# Add the driver to the shapekey
|
||||
f = obj.data.shape_keys.driver_add('key_blocks["' + cls.SHAPEKEY_NAME + '"].value', -1)
|
||||
if hasattr(f.driver, "show_debug_info"):
|
||||
f.driver.show_debug_info = False
|
||||
f.driver.type = "SCRIPTED"
|
||||
ov = f.driver.variables.new()
|
||||
ov.name = "obj"
|
||||
ov.type = "SINGLE_PROP"
|
||||
ov.targets[0].id = obj
|
||||
ov.targets[0].data_path = "name"
|
||||
mod = obj.modifiers.get("mmd_armature")
|
||||
variables = f.driver.variables
|
||||
for name in {data[i].name for data in cls.g_verts[_hash(obj)].values() for i in range(2)}: # add required bones for dependency graph
|
||||
var = variables.new()
|
||||
var.type = "TRANSFORMS"
|
||||
var.targets[0].id = mod.object
|
||||
var.targets[0].bone_target = name
|
||||
f.driver.use_self = True
|
||||
f.driver.expression = f"mmd_sdef_driver(self, obj, bulk_update={bulk_update}, use_skip={use_skip}, use_scale={use_scale})"
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def unbind(cls, obj):
|
||||
if obj.data.shape_keys:
|
||||
if cls.SHAPEKEY_NAME in obj.data.shape_keys.key_blocks:
|
||||
FnObject.mesh_remove_shape_key(obj, obj.data.shape_keys.key_blocks[cls.SHAPEKEY_NAME])
|
||||
for mod in obj.modifiers:
|
||||
if mod.type == "ARMATURE" and mod.vertex_group == cls.MASK_NAME:
|
||||
mod.vertex_group = ""
|
||||
mod.invert_vertex_group = False
|
||||
break
|
||||
if cls.MASK_NAME in obj.vertex_groups:
|
||||
obj.vertex_groups.remove(obj.vertex_groups[cls.MASK_NAME])
|
||||
cls.clear_cache(obj)
|
||||
|
||||
@classmethod
|
||||
def clear_cache(cls, obj=None, unused_only=False):
|
||||
if unused_only:
|
||||
valid_keys = {_hash(i) for i in bpy.data.objects if i.type == "MESH" and i != obj}
|
||||
for key in cls.g_verts.keys() - valid_keys:
|
||||
del cls.g_verts[key]
|
||||
for key in cls.g_shapekey_data.keys() - cls.g_verts.keys():
|
||||
del cls.g_shapekey_data[key]
|
||||
for key in cls.g_bone_check.keys() - cls.g_verts.keys():
|
||||
del cls.g_bone_check[key]
|
||||
elif obj:
|
||||
key = _hash(obj)
|
||||
if key in cls.g_verts:
|
||||
del cls.g_verts[key]
|
||||
if key in cls.g_shapekey_data:
|
||||
del cls.g_shapekey_data[key]
|
||||
if key in cls.g_bone_check:
|
||||
del cls.g_bone_check[key]
|
||||
else:
|
||||
cls.g_verts = {}
|
||||
cls.g_bone_check = {}
|
||||
cls.g_shapekey_data = {}
|
||||
@@ -0,0 +1,343 @@
|
||||
# Copyright 2019 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
from typing import Optional, Tuple, cast
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
class _NodeTreeUtils:
|
||||
def __init__(self, shader: bpy.types.ShaderNodeTree):
|
||||
self.shader = shader
|
||||
self.nodes: bpy.types.bpy_prop_collection[bpy.types.ShaderNode] = shader.nodes # type: ignore[assignment]
|
||||
self.links = shader.links
|
||||
|
||||
def _find_node(self, node_type: str) -> Optional[bpy.types.ShaderNode]:
|
||||
return next((n for n in self.nodes if n.bl_idname == node_type), None)
|
||||
|
||||
def new_node(self, idname: str, pos: Tuple[int, int]) -> bpy.types.ShaderNode:
|
||||
node: bpy.types.ShaderNode = self.nodes.new(idname)
|
||||
node.location = (pos[0] * 210, pos[1] * 220)
|
||||
return node
|
||||
|
||||
def new_math_node(self, operation, pos, value1=None, value2=None):
|
||||
node = self.new_node("ShaderNodeMath", pos)
|
||||
node.operation = operation
|
||||
if value1 is not None:
|
||||
node.inputs[0].default_value = value1
|
||||
if value2 is not None:
|
||||
node.inputs[1].default_value = value2
|
||||
return node
|
||||
|
||||
def new_vector_math_node(self, operation, pos, vector1=None, vector2=None):
|
||||
node = self.new_node("ShaderNodeVectorMath", pos)
|
||||
node.operation = operation
|
||||
if vector1 is not None:
|
||||
node.inputs[0].default_value = vector1
|
||||
if vector2 is not None:
|
||||
node.inputs[1].default_value = vector2
|
||||
return node
|
||||
|
||||
def new_mix_node(self, blend_type, pos, fac=None, color1=None, color2=None):
|
||||
node = self.new_node("ShaderNodeMixRGB", pos)
|
||||
node.blend_type = blend_type
|
||||
if fac is not None:
|
||||
node.inputs["Fac"].default_value = fac
|
||||
if color1 is not None:
|
||||
node.inputs["Color1"].default_value = color1
|
||||
if color2 is not None:
|
||||
node.inputs["Color2"].default_value = color2
|
||||
return node
|
||||
|
||||
|
||||
SOCKET_TYPE_MAPPING = {"NodeSocketFloatFactor": "NodeSocketFloat"}
|
||||
|
||||
SOCKET_SUBTYPE_MAPPING = {"NodeSocketFloatFactor": "FACTOR"}
|
||||
|
||||
|
||||
class _NodeGroupUtils(_NodeTreeUtils):
|
||||
def __init__(self, shader: bpy.types.ShaderNodeTree):
|
||||
super().__init__(shader)
|
||||
self.__node_input: Optional[bpy.types.NodeGroupInput] = None
|
||||
self.__node_output: Optional[bpy.types.NodeGroupOutput] = None
|
||||
|
||||
@property
|
||||
def node_input(self) -> bpy.types.NodeGroupInput:
|
||||
if not self.__node_input:
|
||||
self.__node_input = cast("bpy.types.NodeGroupInput", self._find_node("NodeGroupInput") or self.new_node("NodeGroupInput", (-2, 0)))
|
||||
return self.__node_input
|
||||
|
||||
@property
|
||||
def node_output(self) -> bpy.types.NodeGroupOutput:
|
||||
if not self.__node_output:
|
||||
self.__node_output = cast("bpy.types.NodeGroupOutput", self._find_node("NodeGroupOutput") or self.new_node("NodeGroupOutput", (2, 0)))
|
||||
return self.__node_output
|
||||
|
||||
def hide_nodes(self, hide_sockets=True):
|
||||
skip_nodes = {self.__node_input, self.__node_output}
|
||||
for n in (x for x in self.nodes if x not in skip_nodes):
|
||||
n.hide = True
|
||||
if not hide_sockets:
|
||||
continue
|
||||
for s in n.inputs:
|
||||
s.hide = not s.is_linked
|
||||
for s in n.outputs:
|
||||
s.hide = not s.is_linked
|
||||
|
||||
def new_input_socket(self, io_name, socket, default_val=None, min_max=None, socket_type=None):
|
||||
self.__new_io("INPUT", self.node_input.outputs, io_name, socket, default_val, min_max, socket_type)
|
||||
|
||||
def new_output_socket(self, io_name, socket, default_val=None, min_max=None, socket_type=None):
|
||||
self.__new_io("OUTPUT", self.node_output.inputs, io_name, socket, default_val, min_max, socket_type)
|
||||
|
||||
def __new_io(self, in_out, io_sockets, io_name, socket, default_val=None, min_max=None, socket_type=None):
|
||||
if io_name not in io_sockets:
|
||||
idname = socket_type or socket.bl_idname
|
||||
interface_socket = self.shader.interface.new_socket(name=io_name, in_out=in_out, socket_type=SOCKET_TYPE_MAPPING.get(idname, idname))
|
||||
if idname in SOCKET_SUBTYPE_MAPPING:
|
||||
interface_socket.subtype = SOCKET_SUBTYPE_MAPPING.get(idname, "")
|
||||
if not min_max:
|
||||
if idname.endswith("Factor") or io_name.endswith("Alpha"):
|
||||
interface_socket.min_value, interface_socket.max_value = 0, 1
|
||||
elif idname.endswith(("Float", "Vector")):
|
||||
interface_socket.min_value, interface_socket.max_value = -10, 10
|
||||
if socket is not None:
|
||||
self.links.new(io_sockets[io_name], socket)
|
||||
if default_val is not None:
|
||||
interface_socket.default_value = default_val
|
||||
if min_max is not None:
|
||||
interface_socket.min_value, interface_socket.max_value = min_max
|
||||
|
||||
|
||||
class _MaterialMorph:
|
||||
@classmethod
|
||||
def update_morph_inputs(cls, material, morph):
|
||||
if material and material.node_tree and morph.name in material.node_tree.nodes:
|
||||
cls.__update_node_inputs(material.node_tree.nodes[morph.name], morph)
|
||||
cls.update_morph_inputs(bpy.data.materials.get("mmd_edge." + material.name, None), morph)
|
||||
|
||||
@classmethod
|
||||
def setup_morph_nodes(cls, material, morphs):
|
||||
node, nodes = None, []
|
||||
for m in morphs:
|
||||
node = cls.__morph_node_add(material, m, node)
|
||||
nodes.append(node)
|
||||
if node:
|
||||
node = cls.__morph_node_add(material, None, node) or node
|
||||
for n in reversed(nodes):
|
||||
n.location += node.location
|
||||
if n.node_tree.name != node.node_tree.name:
|
||||
n.location.x -= 100
|
||||
if node.name.startswith("mmd_"):
|
||||
n.location.y += 1500
|
||||
node = n
|
||||
return nodes
|
||||
|
||||
@classmethod
|
||||
def reset_morph_links(cls, node):
|
||||
cls.__update_morph_links(node, reset=True)
|
||||
|
||||
@classmethod
|
||||
def __update_morph_links(cls, node, reset=False):
|
||||
nodes, links = node.id_data.nodes, node.id_data.links
|
||||
if reset:
|
||||
if any(link.from_node.name.startswith("mmd_bind") for i in node.inputs for link in i.links):
|
||||
return
|
||||
|
||||
def __init_link(socket_morph, socket_shader):
|
||||
if socket_shader and socket_morph.is_linked:
|
||||
links.new(socket_morph.links[0].from_socket, socket_shader)
|
||||
|
||||
else:
|
||||
|
||||
def __init_link(socket_morph, socket_shader):
|
||||
if socket_shader:
|
||||
if socket_shader.is_linked:
|
||||
links.new(socket_shader.links[0].from_socket, socket_morph)
|
||||
if socket_morph.type == "VALUE":
|
||||
socket_morph.default_value = socket_shader.default_value
|
||||
else:
|
||||
socket_morph.default_value[:3] = socket_shader.default_value[:3]
|
||||
|
||||
shader = nodes.get("mmd_shader", None)
|
||||
if shader:
|
||||
__init_link(node.inputs["Ambient1"], shader.inputs.get("Ambient Color"))
|
||||
__init_link(node.inputs["Diffuse1"], shader.inputs.get("Diffuse Color"))
|
||||
__init_link(node.inputs["Specular1"], shader.inputs.get("Specular Color"))
|
||||
__init_link(node.inputs["Reflect1"], shader.inputs.get("Reflect"))
|
||||
__init_link(node.inputs["Alpha1"], shader.inputs.get("Alpha"))
|
||||
__init_link(node.inputs["Base1 RGB"], shader.inputs.get("Base Tex"))
|
||||
__init_link(node.inputs["Toon1 RGB"], shader.inputs.get("Toon Tex")) # FIXME toon only affect shadow color
|
||||
__init_link(node.inputs["Sphere1 RGB"], shader.inputs.get("Sphere Tex"))
|
||||
elif "mmd_edge_preview" in nodes:
|
||||
shader = nodes["mmd_edge_preview"]
|
||||
__init_link(node.inputs["Edge1 RGB"], shader.inputs["Color"])
|
||||
__init_link(node.inputs["Edge1 A"], shader.inputs["Alpha"])
|
||||
|
||||
@classmethod
|
||||
def __update_node_inputs(cls, node, morph):
|
||||
node.inputs["Ambient2"].default_value[:3] = morph.ambient_color[:3]
|
||||
node.inputs["Diffuse2"].default_value[:3] = morph.diffuse_color[:3]
|
||||
node.inputs["Specular2"].default_value[:3] = morph.specular_color[:3]
|
||||
node.inputs["Reflect2"].default_value = morph.shininess
|
||||
node.inputs["Alpha2"].default_value = morph.diffuse_color[3]
|
||||
|
||||
node.inputs["Edge2 RGB"].default_value[:3] = morph.edge_color[:3]
|
||||
node.inputs["Edge2 A"].default_value = morph.edge_color[3]
|
||||
|
||||
node.inputs["Base2 RGB"].default_value[:3] = morph.texture_factor[:3]
|
||||
node.inputs["Base2 A"].default_value = morph.texture_factor[3]
|
||||
node.inputs["Toon2 RGB"].default_value[:3] = morph.toon_texture_factor[:3]
|
||||
node.inputs["Toon2 A"].default_value = morph.toon_texture_factor[3]
|
||||
node.inputs["Sphere2 RGB"].default_value[:3] = morph.sphere_texture_factor[:3]
|
||||
node.inputs["Sphere2 A"].default_value = morph.sphere_texture_factor[3]
|
||||
|
||||
@classmethod
|
||||
def __morph_node_add(cls, material, morph, prev_node):
|
||||
nodes, links = material.node_tree.nodes, material.node_tree.links
|
||||
|
||||
shader = nodes.get("mmd_shader", None)
|
||||
if morph:
|
||||
node = nodes.new("ShaderNodeGroup")
|
||||
node.parent = getattr(shader, "parent", None)
|
||||
node.location = (-250, 0)
|
||||
node.node_tree = cls.__get_shader("Add" if morph.offset_type == "ADD" else "Mul")
|
||||
cls.__update_node_inputs(node, morph)
|
||||
if prev_node:
|
||||
for id_name in ("Ambient", "Diffuse", "Specular", "Reflect", "Alpha"):
|
||||
links.new(prev_node.outputs[id_name], node.inputs[id_name + "1"])
|
||||
for id_name in ("Edge", "Base", "Toon", "Sphere"):
|
||||
links.new(prev_node.outputs[id_name + " RGB"], node.inputs[id_name + "1 RGB"])
|
||||
links.new(prev_node.outputs[id_name + " A"], node.inputs[id_name + "1 A"])
|
||||
else: # initial first node
|
||||
if node.node_tree.name.endswith("Add"):
|
||||
node.inputs["Base1 A"].default_value = 1
|
||||
node.inputs["Toon1 A"].default_value = 1
|
||||
node.inputs["Sphere1 A"].default_value = 1
|
||||
cls.__update_morph_links(node)
|
||||
return node
|
||||
# connect last node to shader
|
||||
if shader:
|
||||
|
||||
def __soft_link(socket_out, socket_in):
|
||||
if socket_out and socket_in:
|
||||
links.new(socket_out, socket_in)
|
||||
|
||||
__soft_link(prev_node.outputs["Ambient"], shader.inputs.get("Ambient Color"))
|
||||
__soft_link(prev_node.outputs["Diffuse"], shader.inputs.get("Diffuse Color"))
|
||||
__soft_link(prev_node.outputs["Specular"], shader.inputs.get("Specular Color"))
|
||||
__soft_link(prev_node.outputs["Reflect"], shader.inputs.get("Reflect"))
|
||||
__soft_link(prev_node.outputs["Alpha"], shader.inputs.get("Alpha"))
|
||||
__soft_link(prev_node.outputs["Base Tex"], shader.inputs.get("Base Tex"))
|
||||
__soft_link(prev_node.outputs["Toon Tex"], shader.inputs.get("Toon Tex"))
|
||||
if int(material.mmd_material.sphere_texture_type) != 2: # shader.inputs['Sphere Mul/Add'].default_value < 0.5
|
||||
__soft_link(prev_node.outputs["Sphere Tex"], shader.inputs.get("Sphere Tex"))
|
||||
else:
|
||||
__soft_link(prev_node.outputs["Sphere Tex Add"], shader.inputs.get("Sphere Tex"))
|
||||
elif "mmd_edge_preview" in nodes:
|
||||
shader = nodes["mmd_edge_preview"]
|
||||
links.new(prev_node.outputs["Edge RGB"], shader.inputs["Color"])
|
||||
links.new(prev_node.outputs["Edge A"], shader.inputs["Alpha"])
|
||||
return shader
|
||||
|
||||
@classmethod
|
||||
def __get_shader(cls, morph_type):
|
||||
group_name = "MMDMorph" + morph_type
|
||||
shader = bpy.data.node_groups.get(group_name, None) or bpy.data.node_groups.new(name=group_name, type="ShaderNodeTree")
|
||||
if len(shader.nodes):
|
||||
return shader
|
||||
|
||||
ng = _NodeGroupUtils(shader)
|
||||
links = ng.links
|
||||
|
||||
use_mul = morph_type == "Mul"
|
||||
|
||||
############################################################################
|
||||
node_input = ng.new_node("NodeGroupInput", (-3, 0))
|
||||
ng.new_input_socket("Fac", None, 0, socket_type="NodeSocketFloat")
|
||||
ng.new_node("NodeGroupOutput", (3, 0))
|
||||
|
||||
def __blend_color_add(id_name, pos, tag=""):
|
||||
# MA_RAMP_MULT: ColorMul = Color1 * (Fac * Color2 + (1 - Fac))
|
||||
# MA_RAMP_ADD: ColorAdd = Color1 + Fac * Color2
|
||||
# https://github.com/blender/blender/blob/594f47ecd2d5367ca936cf6fc6ec8168c2b360d0/source/blender/blenkernel/intern/material.c#L1400
|
||||
node_mix = ng.new_mix_node("MULTIPLY" if use_mul else "ADD", (pos[0] + 1, pos[1]))
|
||||
links.new(node_input.outputs["Fac"], node_mix.inputs["Fac"])
|
||||
ng.new_input_socket(f"{id_name}1" + tag, node_mix.inputs["Color1"])
|
||||
ng.new_input_socket(f"{id_name}2" + tag, node_mix.inputs["Color2"], socket_type="NodeSocketVector")
|
||||
ng.new_output_socket(id_name + tag, node_mix.outputs["Color"])
|
||||
return node_mix
|
||||
|
||||
def __blend_tex_color(id_name, pos, node_tex_rgb, node_tex_a_output):
|
||||
# Tex Color = tex_rgb * tex_a + (1 - tex_a)
|
||||
# : tex_rgb = TexRGB * ColorMul + ColorAdd
|
||||
# : tex_a = TexA * ValueMul + ValueAdd
|
||||
if id_name != "Sphere":
|
||||
node_mix = ng.new_mix_node("MULTIPLY", pos, color1=(1, 1, 1, 1))
|
||||
links.new(node_tex_a_output, node_mix.inputs[0])
|
||||
links.new(node_tex_rgb.outputs["Color"], node_mix.inputs[2])
|
||||
ng.new_output_socket(id_name + " Tex", node_mix.outputs[0])
|
||||
else:
|
||||
node_inv = ng.new_math_node("SUBTRACT", (pos[0], pos[1] - 0.25), value1=1.0)
|
||||
node_scale = ng.new_vector_math_node("SCALE", (pos[0], pos[1]))
|
||||
node_add = ng.new_vector_math_node("ADD", (pos[0] + 1, pos[1]))
|
||||
|
||||
links.new(node_tex_a_output, node_inv.inputs[1])
|
||||
links.new(node_tex_rgb.outputs["Color"], node_scale.inputs[0])
|
||||
links.new(node_tex_a_output, node_scale.inputs["Scale"])
|
||||
links.new(node_scale.outputs[0], node_add.inputs[0])
|
||||
links.new(node_inv.outputs[0], node_add.inputs[1])
|
||||
|
||||
ng.new_output_socket(id_name + " Tex", node_add.outputs[0], socket_type="NodeSocketColor")
|
||||
ng.new_output_socket(id_name + " Tex Add", node_scale.outputs[0], socket_type="NodeSocketColor")
|
||||
|
||||
def __add_sockets(id_name, input1, input2, output, tag=""):
|
||||
ng.new_input_socket(f"{id_name}1{tag}", input1, use_mul)
|
||||
ng.new_input_socket(f"{id_name}2{tag}", input2, use_mul)
|
||||
ng.new_output_socket(f"{id_name}{tag}", output)
|
||||
|
||||
pos_x = -2
|
||||
__blend_color_add("Ambient", (pos_x, +0.5))
|
||||
__blend_color_add("Diffuse", (pos_x, +0.0))
|
||||
__blend_color_add("Specular", (pos_x, -0.5))
|
||||
|
||||
combine_reflect1_alpha1_edge1 = ng.new_node("ShaderNodeCombineRGB", (-2, -1.5))
|
||||
combine_reflect2_alpha2_edge2 = ng.new_node("ShaderNodeCombineRGB", (-2, -1.75))
|
||||
separate_reflect_alpha_edge = ng.new_node("ShaderNodeSeparateRGB", (pos_x + 2, -1.5))
|
||||
|
||||
__add_sockets("Reflect", combine_reflect1_alpha1_edge1.inputs[0], combine_reflect2_alpha2_edge2.inputs[0], separate_reflect_alpha_edge.outputs[0])
|
||||
__add_sockets("Alpha", combine_reflect1_alpha1_edge1.inputs[1], combine_reflect2_alpha2_edge2.inputs[1], separate_reflect_alpha_edge.outputs[1])
|
||||
|
||||
__blend_color_add("Edge", (pos_x, -1.0), " RGB")
|
||||
__add_sockets("Edge", combine_reflect1_alpha1_edge1.inputs[2], combine_reflect2_alpha2_edge2.inputs[2], separate_reflect_alpha_edge.outputs[2], tag=" A")
|
||||
|
||||
node_mix = ng.new_mix_node("MULTIPLY" if use_mul else "ADD", (pos_x + 1, -1.5))
|
||||
links.new(node_input.outputs["Fac"], node_mix.inputs[0])
|
||||
links.new(combine_reflect1_alpha1_edge1.outputs[0], node_mix.inputs[1])
|
||||
links.new(combine_reflect2_alpha2_edge2.outputs[0], node_mix.inputs[2])
|
||||
links.new(node_mix.outputs[0], separate_reflect_alpha_edge.inputs[0])
|
||||
|
||||
combine_base1a_toon1a_sphere1a = ng.new_node("ShaderNodeCombineRGB", (-2, -2.0))
|
||||
combine_base2a_toon2a_sphere2a = ng.new_node("ShaderNodeCombineRGB", (-2, -2.25))
|
||||
separate_basea_toona_spherea = ng.new_node("ShaderNodeSeparateRGB", (pos_x + 2, -2.0))
|
||||
|
||||
node_mix = ng.new_mix_node("MULTIPLY" if use_mul else "ADD", (pos_x + 1, -2.0))
|
||||
links.new(node_input.outputs["Fac"], node_mix.inputs[0])
|
||||
links.new(combine_base1a_toon1a_sphere1a.outputs[0], node_mix.inputs[1])
|
||||
links.new(combine_base2a_toon2a_sphere2a.outputs[0], node_mix.inputs[2])
|
||||
links.new(node_mix.outputs[0], separate_basea_toona_spherea.inputs[0])
|
||||
|
||||
base_rgb = __blend_color_add("Base", (pos_x, -2.5), " RGB")
|
||||
__add_sockets("Base", combine_base1a_toon1a_sphere1a.inputs[0], combine_base2a_toon2a_sphere2a.inputs[0], separate_basea_toona_spherea.outputs[0], tag=" A")
|
||||
__blend_tex_color("Base", (pos_x + 3, -2.5), base_rgb, separate_basea_toona_spherea.outputs[0])
|
||||
|
||||
toon_rgb = __blend_color_add("Toon", (pos_x, -3.0), " RGB")
|
||||
__add_sockets("Toon", combine_base1a_toon1a_sphere1a.inputs[1], combine_base2a_toon2a_sphere2a.inputs[1], separate_basea_toona_spherea.outputs[1], tag=" A")
|
||||
__blend_tex_color("Toon", (pos_x + 3, -3.0), toon_rgb, separate_basea_toona_spherea.outputs[1])
|
||||
|
||||
sphere_rgb = __blend_color_add("Sphere", (pos_x, -3.5), " RGB")
|
||||
__add_sockets("Sphere", combine_base1a_toon1a_sphere1a.inputs[2], combine_base2a_toon2a_sphere2a.inputs[2], separate_basea_toona_spherea.outputs[2], tag=" A")
|
||||
__blend_tex_color("Sphere", (pos_x + 3, -3.5), sphere_rgb, separate_basea_toona_spherea.outputs[2])
|
||||
|
||||
ng.hide_nodes()
|
||||
return ng.shader
|
||||
@@ -0,0 +1,713 @@
|
||||
# Copyright 2021 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
import itertools
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Callable, Dict, Optional, Set, Tuple
|
||||
|
||||
import bpy
|
||||
|
||||
from ..translations import DictionaryEnum
|
||||
from ..utils import convertLRToName, convertNameToLR
|
||||
from .model import FnModel, Model
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..properties.morph import _MorphBase
|
||||
from ..properties.root import MMDRoot
|
||||
from ..properties.translations import MMDTranslation, MMDTranslationElement, MMDTranslationElementIndex
|
||||
|
||||
|
||||
class MMDTranslationElementType(Enum):
|
||||
BONE = "Bones"
|
||||
MORPH = "Morphs"
|
||||
MATERIAL = "Materials"
|
||||
DISPLAY = "Display"
|
||||
PHYSICS = "Physics"
|
||||
INFO = "Information"
|
||||
|
||||
|
||||
class MMDDataHandlerABC(ABC):
|
||||
type_name: str
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def collect_data(cls, mmd_translation: "MMDTranslation"):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def update_index(cls, mmd_translation_element: "MMDTranslationElement"):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]:
|
||||
"""Return (name, name_j, name_e)"""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def is_restorable(cls, mmd_translation_element: "MMDTranslationElement") -> bool:
|
||||
return (mmd_translation_element.name, mmd_translation_element.name_j, mmd_translation_element.name_e) != cls.get_names(mmd_translation_element)
|
||||
|
||||
@classmethod
|
||||
def check_data_visible(cls, filter_selected: bool, filter_visible: bool, select: bool, hide: bool) -> bool:
|
||||
return (filter_selected and not select) or (filter_visible and hide)
|
||||
|
||||
@classmethod
|
||||
def prop_restorable(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", prop_name: str, original_value: str, index: int):
|
||||
row = layout.row(align=True)
|
||||
row.prop(mmd_translation_element, prop_name, text="")
|
||||
|
||||
if getattr(mmd_translation_element, prop_name) == original_value:
|
||||
row.label(text="", icon="BLANK1")
|
||||
return
|
||||
|
||||
op = row.operator("mmd_tools_local.restore_mmd_translation_element_name", text="", icon="FILE_REFRESH")
|
||||
op.index = index
|
||||
op.prop_name = prop_name
|
||||
op.restore_value = original_value
|
||||
|
||||
@classmethod
|
||||
def prop_disabled(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", prop_name: str):
|
||||
row = layout.row(align=True)
|
||||
row.enabled = False
|
||||
row.prop(mmd_translation_element, prop_name, text="")
|
||||
row.label(text="", icon="BLANK1")
|
||||
|
||||
|
||||
class MMDBoneHandler(MMDDataHandlerABC):
|
||||
type_name = MMDTranslationElementType.BONE.name
|
||||
|
||||
@classmethod
|
||||
def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int):
|
||||
pose_bone: bpy.types.PoseBone = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path)
|
||||
row = layout.row(align=True)
|
||||
row.label(text="", icon="BONE_DATA")
|
||||
prop_row = row.row()
|
||||
cls.prop_restorable(prop_row, mmd_translation_element, "name", pose_bone.name, index)
|
||||
cls.prop_restorable(prop_row, mmd_translation_element, "name_j", pose_bone.mmd_bone.name_j, index)
|
||||
cls.prop_restorable(prop_row, mmd_translation_element, "name_e", pose_bone.mmd_bone.name_e, index)
|
||||
row.prop(pose_bone.bone, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if pose_bone.select else "RESTRICT_SELECT_ON")
|
||||
row.prop(pose_bone.bone, "hide", text="", emboss=False, icon_only=True)
|
||||
|
||||
@classmethod
|
||||
def collect_data(cls, mmd_translation: "MMDTranslation"):
|
||||
armature_object: bpy.types.Object = FnModel.find_armature_object(mmd_translation.id_data)
|
||||
pose_bone: bpy.types.PoseBone
|
||||
for index, pose_bone in enumerate(armature_object.pose.bones):
|
||||
if pose_bone.bone.hide or (pose_bone.bone.collections and not any(c.is_visible for c in pose_bone.bone.collections)):
|
||||
continue
|
||||
|
||||
mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements.add()
|
||||
mmd_translation_element.type = MMDTranslationElementType.BONE.name
|
||||
mmd_translation_element.object = armature_object
|
||||
mmd_translation_element.data_path = f"pose.bones[{index}]"
|
||||
mmd_translation_element.name = pose_bone.name
|
||||
mmd_translation_element.name_j = pose_bone.mmd_bone.name_j
|
||||
mmd_translation_element.name_e = pose_bone.mmd_bone.name_e
|
||||
|
||||
@classmethod
|
||||
def update_index(cls, mmd_translation_element: "MMDTranslationElement"):
|
||||
bpy.context.view_layer.objects.active = mmd_translation_element.object
|
||||
mmd_translation_element.object.id_data.data.bones.active = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path).bone
|
||||
|
||||
@classmethod
|
||||
def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]):
|
||||
mmd_translation_element: MMDTranslationElement
|
||||
for index, mmd_translation_element in enumerate(mmd_translation.translation_elements):
|
||||
if mmd_translation_element.type != MMDTranslationElementType.BONE.name:
|
||||
continue
|
||||
|
||||
pose_bone: bpy.types.PoseBone = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path)
|
||||
|
||||
if cls.check_data_visible(filter_selected, filter_visible, pose_bone.select, pose_bone.bone.hide):
|
||||
continue
|
||||
|
||||
if check_blank_name(mmd_translation_element.name_j, mmd_translation_element.name_e):
|
||||
continue
|
||||
|
||||
if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element):
|
||||
continue
|
||||
|
||||
mmd_translation_element_index: MMDTranslationElementIndex = mmd_translation.filtered_translation_element_indices.add()
|
||||
mmd_translation_element_index.value = index
|
||||
|
||||
@classmethod
|
||||
def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]):
|
||||
pose_bone: bpy.types.PoseBone = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path)
|
||||
if name is not None:
|
||||
pose_bone.name = name
|
||||
if name_j is not None:
|
||||
pose_bone.mmd_bone.name_j = name_j
|
||||
if name_e is not None:
|
||||
pose_bone.mmd_bone.name_e = name_e
|
||||
|
||||
@classmethod
|
||||
def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]:
|
||||
pose_bone: bpy.types.PoseBone = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path)
|
||||
return (pose_bone.name, pose_bone.mmd_bone.name_j, pose_bone.mmd_bone.name_e)
|
||||
|
||||
|
||||
class MMDMorphHandler(MMDDataHandlerABC):
|
||||
type_name = MMDTranslationElementType.MORPH.name
|
||||
|
||||
@classmethod
|
||||
def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int):
|
||||
morph: _MorphBase = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path)
|
||||
row = layout.row(align=True)
|
||||
row.label(text="", icon="SHAPEKEY_DATA")
|
||||
prop_row = row.row()
|
||||
cls.prop_disabled(prop_row, mmd_translation_element, "name")
|
||||
cls.prop_restorable(prop_row, mmd_translation_element, "name", morph.name, index)
|
||||
cls.prop_restorable(prop_row, mmd_translation_element, "name_e", morph.name_e, index)
|
||||
row.label(text="", icon="BLANK1")
|
||||
row.label(text="", icon="BLANK1")
|
||||
|
||||
MORPH_DATA_PATH_EXTRACT = re.compile(r"mmd_root\.(?P<morphs_name>[^\[]*)\[(?P<index>\d*)\]")
|
||||
|
||||
@classmethod
|
||||
def collect_data(cls, mmd_translation: "MMDTranslation"):
|
||||
root_object: bpy.types.Object = mmd_translation.id_data
|
||||
mmd_root: MMDRoot = root_object.mmd_root
|
||||
|
||||
for morphs_name, morphs in {
|
||||
"material_morphs": mmd_root.material_morphs,
|
||||
"uv_morphs": mmd_root.uv_morphs,
|
||||
"bone_morphs": mmd_root.bone_morphs,
|
||||
"vertex_morphs": mmd_root.vertex_morphs,
|
||||
"group_morphs": mmd_root.group_morphs,
|
||||
}.items():
|
||||
morph: _MorphBase
|
||||
for index, morph in enumerate(morphs):
|
||||
mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements.add()
|
||||
mmd_translation_element.type = MMDTranslationElementType.MORPH.name
|
||||
mmd_translation_element.object = root_object
|
||||
mmd_translation_element.data_path = f"mmd_root.{morphs_name}[{index}]"
|
||||
mmd_translation_element.name = morph.name
|
||||
# mmd_translation_element.name_j = None
|
||||
mmd_translation_element.name_e = morph.name_e
|
||||
|
||||
@classmethod
|
||||
def update_index(cls, mmd_translation_element: "MMDTranslationElement"):
|
||||
match = cls.MORPH_DATA_PATH_EXTRACT.match(mmd_translation_element.data_path)
|
||||
if not match:
|
||||
return
|
||||
|
||||
mmd_translation_element.object.mmd_root.active_morph_type = match["morphs_name"]
|
||||
mmd_translation_element.object.mmd_root.active_morph = int(match["index"])
|
||||
|
||||
@classmethod
|
||||
def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]):
|
||||
mmd_translation_element: MMDTranslationElement
|
||||
for index, mmd_translation_element in enumerate(mmd_translation.translation_elements):
|
||||
if mmd_translation_element.type != MMDTranslationElementType.MORPH.name:
|
||||
continue
|
||||
|
||||
morph: _MorphBase = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path)
|
||||
if check_blank_name(morph.name, morph.name_e):
|
||||
continue
|
||||
|
||||
if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element):
|
||||
continue
|
||||
|
||||
mmd_translation_element_index: MMDTranslationElementIndex = mmd_translation.filtered_translation_element_indices.add()
|
||||
mmd_translation_element_index.value = index
|
||||
|
||||
@classmethod
|
||||
def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]):
|
||||
morph: _MorphBase = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path)
|
||||
if name is not None:
|
||||
morph.name = name
|
||||
if name_e is not None:
|
||||
morph.name_e = name_e
|
||||
|
||||
@classmethod
|
||||
def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]:
|
||||
morph: _MorphBase = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path)
|
||||
return (morph.name, "", morph.name_e)
|
||||
|
||||
|
||||
class MMDMaterialHandler(MMDDataHandlerABC):
|
||||
type_name = MMDTranslationElementType.MATERIAL.name
|
||||
|
||||
@classmethod
|
||||
def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int):
|
||||
mesh_object: bpy.types.Object = mmd_translation_element.object
|
||||
material: bpy.types.Material = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path)
|
||||
row = layout.row(align=True)
|
||||
row.label(text="", icon="MATERIAL_DATA")
|
||||
prop_row = row.row()
|
||||
cls.prop_restorable(prop_row, mmd_translation_element, "name", material.name, index)
|
||||
cls.prop_restorable(prop_row, mmd_translation_element, "name_j", material.mmd_material.name_j, index)
|
||||
cls.prop_restorable(prop_row, mmd_translation_element, "name_e", material.mmd_material.name_e, index)
|
||||
row.prop(mesh_object, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if mesh_object.select_get() else "RESTRICT_SELECT_ON")
|
||||
row.prop(mesh_object, "hide", text="", emboss=False, icon_only=True)
|
||||
|
||||
MATERIAL_DATA_PATH_EXTRACT = re.compile(r"data\.materials\[(?P<index>\d*)\]")
|
||||
|
||||
@classmethod
|
||||
def collect_data(cls, mmd_translation: "MMDTranslation"):
|
||||
checked_materials: Set[bpy.types.Material] = set()
|
||||
mesh_object: bpy.types.Object
|
||||
for mesh_object in FnModel.iterate_mesh_objects(mmd_translation.id_data):
|
||||
material: bpy.types.Material
|
||||
for index, material in enumerate(mesh_object.data.materials):
|
||||
if material in checked_materials:
|
||||
continue
|
||||
|
||||
checked_materials.add(material)
|
||||
|
||||
if not hasattr(material, "mmd_material"):
|
||||
continue
|
||||
|
||||
mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements.add()
|
||||
mmd_translation_element.type = MMDTranslationElementType.MATERIAL.name
|
||||
mmd_translation_element.object = mesh_object
|
||||
mmd_translation_element.data_path = f"data.materials[{index}]"
|
||||
mmd_translation_element.name = material.name
|
||||
mmd_translation_element.name_j = material.mmd_material.name_j
|
||||
mmd_translation_element.name_e = material.mmd_material.name_e
|
||||
|
||||
@classmethod
|
||||
def update_index(cls, mmd_translation_element: "MMDTranslationElement"):
|
||||
id_data: bpy.types.Object = mmd_translation_element.object
|
||||
bpy.context.view_layer.objects.active = id_data
|
||||
|
||||
match = cls.MATERIAL_DATA_PATH_EXTRACT.match(mmd_translation_element.data_path)
|
||||
if not match:
|
||||
return
|
||||
|
||||
id_data.active_material_index = int(match["index"])
|
||||
|
||||
@classmethod
|
||||
def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]):
|
||||
mmd_translation_element: MMDTranslationElement
|
||||
for index, mmd_translation_element in enumerate(mmd_translation.translation_elements):
|
||||
if mmd_translation_element.type != MMDTranslationElementType.MATERIAL.name:
|
||||
continue
|
||||
|
||||
mesh_object: bpy.types.Object = mmd_translation_element.object
|
||||
if cls.check_data_visible(filter_selected, filter_visible, mesh_object.select_get(), mesh_object.hide_get()):
|
||||
continue
|
||||
|
||||
material: bpy.types.Material = mesh_object.path_resolve(mmd_translation_element.data_path)
|
||||
if check_blank_name(material.mmd_material.name_j, material.mmd_material.name_e):
|
||||
continue
|
||||
|
||||
if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element):
|
||||
continue
|
||||
|
||||
mmd_translation_element_index: MMDTranslationElementIndex = mmd_translation.filtered_translation_element_indices.add()
|
||||
mmd_translation_element_index.value = index
|
||||
|
||||
@classmethod
|
||||
def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]):
|
||||
material: bpy.types.Material = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path)
|
||||
if name is not None:
|
||||
material.name = name
|
||||
if name_j is not None:
|
||||
material.mmd_material.name_j = name_j
|
||||
if name_e is not None:
|
||||
material.mmd_material.name_e = name_e
|
||||
|
||||
@classmethod
|
||||
def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]:
|
||||
material: bpy.types.Material = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path)
|
||||
return (material.name, material.mmd_material.name_j, material.mmd_material.name_e)
|
||||
|
||||
|
||||
class MMDDisplayHandler(MMDDataHandlerABC):
|
||||
type_name = MMDTranslationElementType.DISPLAY.name
|
||||
|
||||
@classmethod
|
||||
def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int):
|
||||
bone_collection: bpy.types.BoneCollection = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path)
|
||||
row = layout.row(align=True)
|
||||
row.label(text="", icon="GROUP_BONE")
|
||||
|
||||
prop_row = row.row()
|
||||
cls.prop_restorable(prop_row, mmd_translation_element, "name", bone_collection.name, index)
|
||||
cls.prop_disabled(prop_row, mmd_translation_element, "name")
|
||||
cls.prop_disabled(prop_row, mmd_translation_element, "name_e")
|
||||
row.prop(mmd_translation_element.object, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if mmd_translation_element.object.select_get() else "RESTRICT_SELECT_ON")
|
||||
row.prop(mmd_translation_element.object, "hide", text="", emboss=False, icon_only=True)
|
||||
|
||||
DISPLAY_DATA_PATH_EXTRACT = re.compile(r"data\.collections\[(?P<index>\d*)\]")
|
||||
|
||||
@classmethod
|
||||
def collect_data(cls, mmd_translation: "MMDTranslation"):
|
||||
armature_object: bpy.types.Object = FnModel.find_armature_object(mmd_translation.id_data)
|
||||
bone_collection: bpy.types.BoneCollection
|
||||
for index, bone_collection in enumerate(armature_object.data.collections):
|
||||
mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements.add()
|
||||
mmd_translation_element.type = MMDTranslationElementType.DISPLAY.name
|
||||
mmd_translation_element.object = armature_object
|
||||
mmd_translation_element.data_path = f"data.collections[{index}]"
|
||||
mmd_translation_element.name = bone_collection.name
|
||||
# mmd_translation_element.name_j = None
|
||||
# mmd_translation_element.name_e = None
|
||||
|
||||
@classmethod
|
||||
def update_index(cls, mmd_translation_element: "MMDTranslationElement"):
|
||||
id_data: bpy.types.Object = mmd_translation_element.object
|
||||
bpy.context.view_layer.objects.active = id_data
|
||||
|
||||
match = cls.DISPLAY_DATA_PATH_EXTRACT.match(mmd_translation_element.data_path)
|
||||
if not match:
|
||||
return
|
||||
|
||||
id_data.data.collections.active_index = int(match["index"])
|
||||
|
||||
@classmethod
|
||||
def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]):
|
||||
mmd_translation_element: MMDTranslationElement
|
||||
for index, mmd_translation_element in enumerate(mmd_translation.translation_elements):
|
||||
if mmd_translation_element.type != MMDTranslationElementType.DISPLAY.name:
|
||||
continue
|
||||
|
||||
obj: bpy.types.Object = mmd_translation_element.object
|
||||
if cls.check_data_visible(filter_selected, filter_visible, obj.select_get(), obj.hide_get()):
|
||||
continue
|
||||
|
||||
bone_collection: bpy.types.BoneCollection = obj.path_resolve(mmd_translation_element.data_path)
|
||||
if check_blank_name(bone_collection.name, ""):
|
||||
continue
|
||||
|
||||
if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element):
|
||||
continue
|
||||
|
||||
mmd_translation_element_index: MMDTranslationElementIndex = mmd_translation.filtered_translation_element_indices.add()
|
||||
mmd_translation_element_index.value = index
|
||||
|
||||
@classmethod
|
||||
def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]):
|
||||
bone_collection: bpy.types.BoneCollection = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path)
|
||||
if name is not None:
|
||||
bone_collection.name = name
|
||||
|
||||
@classmethod
|
||||
def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]:
|
||||
bone_collection: bpy.types.BoneCollection = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path)
|
||||
return (bone_collection.name, "", "")
|
||||
|
||||
|
||||
class MMDPhysicsHandler(MMDDataHandlerABC):
|
||||
type_name = MMDTranslationElementType.PHYSICS.name
|
||||
|
||||
@classmethod
|
||||
def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int):
|
||||
obj: bpy.types.Object = mmd_translation_element.object
|
||||
|
||||
if FnModel.is_rigid_body_object(obj):
|
||||
icon = "MESH_ICOSPHERE"
|
||||
mmd_object = obj.mmd_rigid
|
||||
elif FnModel.is_joint_object(obj):
|
||||
icon = "CONSTRAINT"
|
||||
mmd_object = obj.mmd_joint
|
||||
|
||||
row = layout.row(align=True)
|
||||
row.label(text="", icon=icon)
|
||||
prop_row = row.row()
|
||||
cls.prop_restorable(prop_row, mmd_translation_element, "name", obj.name, index)
|
||||
cls.prop_restorable(prop_row, mmd_translation_element, "name_j", mmd_object.name_j, index)
|
||||
cls.prop_restorable(prop_row, mmd_translation_element, "name_e", mmd_object.name_e, index)
|
||||
row.prop(obj, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if obj.select_get() else "RESTRICT_SELECT_ON")
|
||||
row.prop(obj, "hide", text="", emboss=False, icon_only=True)
|
||||
|
||||
@classmethod
|
||||
def collect_data(cls, mmd_translation: "MMDTranslation"):
|
||||
root_object: bpy.types.Object = mmd_translation.id_data
|
||||
model = Model(root_object)
|
||||
|
||||
obj: bpy.types.Object
|
||||
for obj in model.rigidBodies():
|
||||
mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements.add()
|
||||
mmd_translation_element.type = MMDTranslationElementType.PHYSICS.name
|
||||
mmd_translation_element.object = obj
|
||||
mmd_translation_element.data_path = "mmd_rigid"
|
||||
mmd_translation_element.name = obj.name
|
||||
mmd_translation_element.name_j = obj.mmd_rigid.name_j
|
||||
mmd_translation_element.name_e = obj.mmd_rigid.name_e
|
||||
|
||||
obj: bpy.types.Object
|
||||
for obj in model.joints():
|
||||
mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements.add()
|
||||
mmd_translation_element.type = MMDTranslationElementType.PHYSICS.name
|
||||
mmd_translation_element.object = obj
|
||||
mmd_translation_element.data_path = "mmd_joint"
|
||||
mmd_translation_element.name = obj.name
|
||||
mmd_translation_element.name_j = obj.mmd_joint.name_j
|
||||
mmd_translation_element.name_e = obj.mmd_joint.name_e
|
||||
|
||||
@classmethod
|
||||
def update_index(cls, mmd_translation_element: "MMDTranslationElement"):
|
||||
bpy.context.view_layer.objects.active = mmd_translation_element.object
|
||||
|
||||
@classmethod
|
||||
def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]):
|
||||
mmd_translation_element: MMDTranslationElement
|
||||
for index, mmd_translation_element in enumerate(mmd_translation.translation_elements):
|
||||
if mmd_translation_element.type != MMDTranslationElementType.PHYSICS.name:
|
||||
continue
|
||||
|
||||
obj: bpy.types.Object = mmd_translation_element.object
|
||||
if cls.check_data_visible(filter_selected, filter_visible, obj.select_get(), obj.hide_get()):
|
||||
continue
|
||||
|
||||
if FnModel.is_rigid_body_object(obj):
|
||||
mmd_object = obj.mmd_rigid
|
||||
elif FnModel.is_joint_object(obj):
|
||||
mmd_object = obj.mmd_joint
|
||||
|
||||
if check_blank_name(mmd_object.name_j, mmd_object.name_e):
|
||||
continue
|
||||
|
||||
if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element):
|
||||
continue
|
||||
|
||||
mmd_translation_element_index: MMDTranslationElementIndex = mmd_translation.filtered_translation_element_indices.add()
|
||||
mmd_translation_element_index.value = index
|
||||
|
||||
@classmethod
|
||||
def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]):
|
||||
obj: bpy.types.Object = mmd_translation_element.object
|
||||
|
||||
if FnModel.is_rigid_body_object(obj):
|
||||
mmd_object = obj.mmd_rigid
|
||||
elif FnModel.is_joint_object(obj):
|
||||
mmd_object = obj.mmd_joint
|
||||
|
||||
if name is not None:
|
||||
obj.name = name
|
||||
if name_j is not None:
|
||||
mmd_object.name_j = name_j
|
||||
if name_e is not None:
|
||||
mmd_object.name_e = name_e
|
||||
|
||||
@classmethod
|
||||
def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]:
|
||||
obj: bpy.types.Object = mmd_translation_element.object
|
||||
|
||||
if FnModel.is_rigid_body_object(obj):
|
||||
mmd_object = obj.mmd_rigid
|
||||
elif FnModel.is_joint_object(obj):
|
||||
mmd_object = obj.mmd_joint
|
||||
|
||||
return (obj.name, mmd_object.name_j, mmd_object.name_e)
|
||||
|
||||
|
||||
class MMDInfoHandler(MMDDataHandlerABC):
|
||||
type_name = MMDTranslationElementType.INFO.name
|
||||
|
||||
TYPE_TO_ICONS = {
|
||||
"EMPTY": "EMPTY_DATA",
|
||||
"ARMATURE": "ARMATURE_DATA",
|
||||
"MESH": "MESH_DATA",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int):
|
||||
info_object: bpy.types.Object = mmd_translation_element.object
|
||||
row = layout.row(align=True)
|
||||
row.label(text="", icon=MMDInfoHandler.TYPE_TO_ICONS.get(info_object.type, "OBJECT_DATA"))
|
||||
prop_row = row.row()
|
||||
cls.prop_restorable(prop_row, mmd_translation_element, "name", info_object.name, index)
|
||||
cls.prop_disabled(prop_row, mmd_translation_element, "name")
|
||||
cls.prop_disabled(prop_row, mmd_translation_element, "name_e")
|
||||
row.prop(info_object, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if info_object.select_get() else "RESTRICT_SELECT_ON")
|
||||
row.prop(info_object, "hide", text="", emboss=False, icon_only=True)
|
||||
|
||||
@classmethod
|
||||
def collect_data(cls, mmd_translation: "MMDTranslation"):
|
||||
root_object: bpy.types.Object = mmd_translation.id_data
|
||||
info_objects = [root_object]
|
||||
armature_object = FnModel.find_armature_object(root_object)
|
||||
if armature_object is not None:
|
||||
info_objects.append(armature_object)
|
||||
|
||||
for info_object in itertools.chain(info_objects, FnModel.iterate_mesh_objects(root_object)):
|
||||
mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements.add()
|
||||
mmd_translation_element.type = MMDTranslationElementType.INFO.name
|
||||
mmd_translation_element.object = info_object
|
||||
mmd_translation_element.data_path = ""
|
||||
mmd_translation_element.name = info_object.name
|
||||
# mmd_translation_element.name_j = None
|
||||
# mmd_translation_element.name_e = None
|
||||
|
||||
@classmethod
|
||||
def update_index(cls, mmd_translation_element: "MMDTranslationElement"):
|
||||
bpy.context.view_layer.objects.active = mmd_translation_element.object
|
||||
|
||||
@classmethod
|
||||
def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]):
|
||||
mmd_translation_element: MMDTranslationElement
|
||||
for index, mmd_translation_element in enumerate(mmd_translation.translation_elements):
|
||||
if mmd_translation_element.type != MMDTranslationElementType.INFO.name:
|
||||
continue
|
||||
|
||||
info_object: bpy.types.Object = mmd_translation_element.object
|
||||
if cls.check_data_visible(filter_selected, filter_visible, info_object.select_get(), info_object.hide_get()):
|
||||
continue
|
||||
|
||||
if check_blank_name(info_object.name, ""):
|
||||
continue
|
||||
|
||||
if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element):
|
||||
continue
|
||||
|
||||
mmd_translation_element_index: MMDTranslationElementIndex = mmd_translation.filtered_translation_element_indices.add()
|
||||
mmd_translation_element_index.value = index
|
||||
|
||||
@classmethod
|
||||
def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]):
|
||||
info_object: bpy.types.Object = mmd_translation_element.object
|
||||
if name is not None:
|
||||
info_object.name = name
|
||||
|
||||
@classmethod
|
||||
def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]:
|
||||
info_object: bpy.types.Object = mmd_translation_element.object
|
||||
return (info_object.name, "", "")
|
||||
|
||||
|
||||
MMD_DATA_HANDLERS: Set[MMDDataHandlerABC] = {
|
||||
MMDBoneHandler,
|
||||
MMDMorphHandler,
|
||||
MMDMaterialHandler,
|
||||
MMDDisplayHandler,
|
||||
MMDPhysicsHandler,
|
||||
MMDInfoHandler,
|
||||
}
|
||||
|
||||
MMD_DATA_TYPE_TO_HANDLERS: Dict[str, MMDDataHandlerABC] = {h.type_name: h for h in MMD_DATA_HANDLERS}
|
||||
|
||||
|
||||
class FnTranslations:
|
||||
@staticmethod
|
||||
def apply_translations(root_object: bpy.types.Object):
|
||||
mmd_translation: MMDTranslation = root_object.mmd_root.translation
|
||||
mmd_translation_element_index: MMDTranslationElementIndex
|
||||
for mmd_translation_element_index in mmd_translation.filtered_translation_element_indices:
|
||||
mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements[mmd_translation_element_index.value]
|
||||
handler: MMDDataHandlerABC = MMD_DATA_TYPE_TO_HANDLERS[mmd_translation_element.type]
|
||||
name, name_j, name_e = handler.get_names(mmd_translation_element)
|
||||
handler.set_names(
|
||||
mmd_translation_element,
|
||||
mmd_translation_element.name if mmd_translation_element.name != name else None,
|
||||
mmd_translation_element.name_j if mmd_translation_element.name_j != name_j else None,
|
||||
mmd_translation_element.name_e if mmd_translation_element.name_e != name_e else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def execute_translation_batch(root_object: bpy.types.Object) -> Tuple[Dict[str, str], Optional[bpy.types.Text]]:
|
||||
mmd_translation: MMDTranslation = root_object.mmd_root.translation
|
||||
batch_operation_script = mmd_translation.batch_operation_script
|
||||
if not batch_operation_script:
|
||||
return ({}, None)
|
||||
|
||||
translator = DictionaryEnum.get_translator(mmd_translation.dictionary)
|
||||
|
||||
def translate(name: str) -> str:
|
||||
if translator:
|
||||
return translator.translate(name, name)
|
||||
return name
|
||||
|
||||
batch_operation_script_ast = compile(mmd_translation.batch_operation_script, "<string>", "eval")
|
||||
batch_operation_target: str = mmd_translation.batch_operation_target
|
||||
|
||||
mmd_translation_element_index: MMDTranslationElementIndex
|
||||
for mmd_translation_element_index in mmd_translation.filtered_translation_element_indices:
|
||||
mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements[mmd_translation_element_index.value]
|
||||
|
||||
handler: MMDDataHandlerABC = MMD_DATA_TYPE_TO_HANDLERS[mmd_translation_element.type]
|
||||
|
||||
name = mmd_translation_element.name
|
||||
name_j = mmd_translation_element.name_j
|
||||
name_e = mmd_translation_element.name_e
|
||||
org_name, org_name_j, org_name_e = handler.get_names(mmd_translation_element)
|
||||
|
||||
# pylint: disable=eval-used
|
||||
result_name = str(
|
||||
eval(
|
||||
batch_operation_script_ast,
|
||||
{"__builtins__": {}},
|
||||
{
|
||||
"to_english": translate,
|
||||
"to_mmd_lr": convertLRToName,
|
||||
"to_blender_lr": convertNameToLR,
|
||||
"name": name,
|
||||
"name_j": name_j if name_j != "" else name,
|
||||
"name_e": name_e if name_e != "" else name,
|
||||
"org_name": org_name,
|
||||
"org_name_j": org_name_j,
|
||||
"org_name_e": org_name_e,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
if batch_operation_target == "BLENDER":
|
||||
mmd_translation_element.name = result_name
|
||||
elif batch_operation_target == "JAPANESE":
|
||||
mmd_translation_element.name_j = result_name
|
||||
elif batch_operation_target == "ENGLISH":
|
||||
mmd_translation_element.name_e = result_name
|
||||
|
||||
return (translator.fails, translator.save_fails())
|
||||
|
||||
@staticmethod
|
||||
def update_index(mmd_translation: "MMDTranslation"):
|
||||
if mmd_translation.filtered_translation_element_indices_active_index < 0:
|
||||
return
|
||||
|
||||
mmd_translation_element_index: MMDTranslationElementIndex = mmd_translation.filtered_translation_element_indices[mmd_translation.filtered_translation_element_indices_active_index]
|
||||
mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements[mmd_translation_element_index.value]
|
||||
|
||||
MMD_DATA_TYPE_TO_HANDLERS[mmd_translation_element.type].update_index(mmd_translation_element)
|
||||
|
||||
@staticmethod
|
||||
def collect_data(mmd_translation: "MMDTranslation"):
|
||||
mmd_translation.translation_elements.clear()
|
||||
for handler in MMD_DATA_HANDLERS:
|
||||
handler.collect_data(mmd_translation)
|
||||
|
||||
@staticmethod
|
||||
def update_query(mmd_translation: "MMDTranslation"):
|
||||
mmd_translation.filtered_translation_element_indices.clear()
|
||||
mmd_translation.filtered_translation_element_indices_active_index = -1
|
||||
|
||||
filter_japanese_blank: bool = mmd_translation.filter_japanese_blank
|
||||
filter_english_blank: bool = mmd_translation.filter_english_blank
|
||||
|
||||
filter_selected: bool = mmd_translation.filter_selected
|
||||
filter_visible: bool = mmd_translation.filter_visible
|
||||
|
||||
def check_blank_name(name_j: str, name_e: str) -> bool:
|
||||
return (filter_japanese_blank and name_j) or (filter_english_blank and name_e)
|
||||
|
||||
for handler in MMD_DATA_HANDLERS:
|
||||
if handler.type_name in mmd_translation.filter_types:
|
||||
handler.update_query(mmd_translation, filter_selected, filter_visible, check_blank_name)
|
||||
|
||||
@staticmethod
|
||||
def clear_data(mmd_translation: "MMDTranslation"):
|
||||
mmd_translation.translation_elements.clear()
|
||||
mmd_translation.filtered_translation_element_indices.clear()
|
||||
mmd_translation.filtered_translation_element_indices_active_index = -1
|
||||
mmd_translation.filter_restorable = False
|
||||
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2014 MMD Tools authors
|
||||
# This file was originally part of the MMD Tools add-on for Blender
|
||||
# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools
|
||||
# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements.
|
||||
# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit.
|
||||
@@ -0,0 +1,695 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2014 MMD Tools authors
|
||||
# This file was originally part of the MMD Tools add-on for Blender
|
||||
# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools
|
||||
# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements.
|
||||
# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit.
|
||||
|
||||
from .....core.logging_setup import logger
|
||||
import math
|
||||
import os
|
||||
from typing import Union
|
||||
|
||||
import bpy
|
||||
from bpy_extras import anim_utils
|
||||
from mathutils import Quaternion, Vector
|
||||
|
||||
from ... import utils
|
||||
from .. import vmd
|
||||
from ..camera import MMDCamera
|
||||
from ..lamp import MMDLamp
|
||||
|
||||
|
||||
class _MirrorMapper:
|
||||
def __init__(self, data_map=None):
|
||||
from ...operators.view import FlipPose
|
||||
|
||||
self.__data_map = data_map
|
||||
self.__flip_name = FlipPose.flip_name
|
||||
|
||||
def get(self, name, default=None):
|
||||
return self.__data_map.get(self.__flip_name(name), None) or self.__data_map.get(name, default)
|
||||
|
||||
@staticmethod
|
||||
def get_location(location):
|
||||
return (-location[0], location[1], location[2])
|
||||
|
||||
@staticmethod
|
||||
def get_rotation(rotation_xyzw):
|
||||
return (rotation_xyzw[0], -rotation_xyzw[1], -rotation_xyzw[2], rotation_xyzw[3])
|
||||
|
||||
@staticmethod
|
||||
def get_rotation3(rotation_xyz):
|
||||
return (rotation_xyz[0], -rotation_xyz[1], -rotation_xyz[2])
|
||||
|
||||
|
||||
class RenamedBoneMapper:
|
||||
def __init__(self, armObj=None, rename_LR_bones=True, use_underscore=False, translator=None):
|
||||
self.__pose_bones = armObj.pose.bones if armObj else None
|
||||
self.__rename_LR_bones = rename_LR_bones
|
||||
self.__use_underscore = use_underscore
|
||||
self.__translator = translator
|
||||
|
||||
def init(self, armObj):
|
||||
self.__pose_bones = armObj.pose.bones
|
||||
return self
|
||||
|
||||
def get(self, bone_name, default=None):
|
||||
bl_bone_name = bone_name
|
||||
if self.__rename_LR_bones:
|
||||
bl_bone_name = utils.convertNameToLR(bl_bone_name, self.__use_underscore)
|
||||
if self.__translator:
|
||||
bl_bone_name = self.__translator.translate(bl_bone_name)
|
||||
return self.__pose_bones.get(bl_bone_name, default)
|
||||
|
||||
|
||||
class _InterpolationHelper:
|
||||
def __init__(self, mat):
|
||||
self.__indices = indices = [0, 1, 2]
|
||||
l = sorted((-abs(mat[i][j]), i, j) for i in range(3) for j in range(3))
|
||||
_, i, j = l[0]
|
||||
if i != j:
|
||||
indices[i], indices[j] = indices[j], indices[i]
|
||||
_, i, j = next(k for k in l if k[1] != i and k[2] != j)
|
||||
if indices[i] != j:
|
||||
idx = indices.index(j)
|
||||
indices[i], indices[idx] = indices[idx], indices[i]
|
||||
|
||||
def convert(self, interpolation_xyz):
|
||||
return (interpolation_xyz[i] for i in self.__indices)
|
||||
|
||||
|
||||
class BoneConverter:
|
||||
def __init__(self, pose_bone, scale, invert=False):
|
||||
mat = pose_bone.bone.matrix_local.to_3x3()
|
||||
mat[1], mat[2] = mat[2].copy(), mat[1].copy()
|
||||
self.__mat = mat.transposed()
|
||||
self.__scale = scale
|
||||
if invert:
|
||||
self.__mat.invert()
|
||||
self.convert_interpolation = _InterpolationHelper(self.__mat).convert
|
||||
|
||||
def convert_location(self, location):
|
||||
return (self.__mat @ Vector(location)) * self.__scale
|
||||
|
||||
def convert_rotation(self, rotation_xyzw):
|
||||
rot = Quaternion()
|
||||
rot.x, rot.y, rot.z, rot.w = rotation_xyzw
|
||||
return Quaternion((self.__mat @ rot.axis) * -1, rot.angle).normalized()
|
||||
|
||||
|
||||
class BoneConverterPoseMode:
|
||||
def __init__(self, pose_bone, scale, invert=False):
|
||||
mat = pose_bone.matrix.to_3x3()
|
||||
mat[1], mat[2] = mat[2].copy(), mat[1].copy()
|
||||
self.__mat = mat.transposed()
|
||||
self.__scale = scale
|
||||
self.__mat_rot = pose_bone.matrix_basis.to_3x3()
|
||||
self.__mat_loc = self.__mat_rot @ self.__mat
|
||||
self.__offset = pose_bone.location.copy()
|
||||
self.convert_location = self._convert_location
|
||||
self.convert_rotation = self._convert_rotation
|
||||
if invert:
|
||||
self.__mat.invert()
|
||||
self.__mat_rot.invert()
|
||||
self.__mat_loc.invert()
|
||||
self.convert_location = self._convert_location_inverted
|
||||
self.convert_rotation = self._convert_rotation_inverted
|
||||
self.convert_interpolation = _InterpolationHelper(self.__mat_loc).convert
|
||||
|
||||
def _convert_location(self, location):
|
||||
return self.__offset + (self.__mat_loc @ Vector(location)) * self.__scale
|
||||
|
||||
def _convert_rotation(self, rotation_xyzw):
|
||||
rot = Quaternion()
|
||||
rot.x, rot.y, rot.z, rot.w = rotation_xyzw
|
||||
rot = Quaternion((self.__mat @ rot.axis) * -1, rot.angle)
|
||||
return (self.__mat_rot @ rot.to_matrix()).to_quaternion()
|
||||
|
||||
def _convert_location_inverted(self, location):
|
||||
return (self.__mat_loc @ (Vector(location) - self.__offset)) * self.__scale
|
||||
|
||||
def _convert_rotation_inverted(self, rotation_xyzw):
|
||||
rot = Quaternion()
|
||||
rot.x, rot.y, rot.z, rot.w = rotation_xyzw
|
||||
rot = (self.__mat_rot @ rot.to_matrix()).to_quaternion()
|
||||
return Quaternion((self.__mat @ rot.axis) * -1, rot.angle).normalized()
|
||||
|
||||
|
||||
class _FnBezier:
|
||||
@classmethod
|
||||
def from_fcurve(cls, kp0, kp1):
|
||||
p0, p1, p2, p3 = kp0.co, kp0.handle_right, kp1.handle_left, kp1.co
|
||||
if p1.x > p3.x:
|
||||
t = (p3.x - p0.x) / (p1.x - p0.x)
|
||||
p1 = (1 - t) * p0 + p1 * t
|
||||
if p0.x > p2.x:
|
||||
t = (p3.x - p0.x) / (p3.x - p2.x)
|
||||
p2 = (1 - t) * p3 + p2 * t
|
||||
return cls(p0, p1, p2, p3)
|
||||
|
||||
def __init__(self, p0, p1, p2, p3): # assuming VMD's bezier or F-Curve's bezier
|
||||
# assert(p0.x <= p1.x <= p3.x and p0.x <= p2.x <= p3.x)
|
||||
self._p0, self._p1, self._p2, self._p3 = p0, p1, p2, p3
|
||||
|
||||
@property
|
||||
def points(self):
|
||||
return self._p0, self._p1, self._p2, self._p3
|
||||
|
||||
def split(self, t):
|
||||
p0, p1, p2, p3 = self._p0, self._p1, self._p2, self._p3
|
||||
p01t = (1 - t) * p0 + t * p1
|
||||
p12t = (1 - t) * p1 + t * p2
|
||||
p23t = (1 - t) * p2 + t * p3
|
||||
p012t = (1 - t) * p01t + t * p12t
|
||||
p123t = (1 - t) * p12t + t * p23t
|
||||
pt = (1 - t) * p012t + t * p123t
|
||||
return _FnBezier(p0, p01t, p012t, pt), _FnBezier(pt, p123t, p23t, p3), pt
|
||||
|
||||
def evaluate(self, t):
|
||||
p0, p1, p2, p3 = self._p0, self._p1, self._p2, self._p3
|
||||
p01t = (1 - t) * p0 + t * p1
|
||||
p12t = (1 - t) * p1 + t * p2
|
||||
p23t = (1 - t) * p2 + t * p3
|
||||
p012t = (1 - t) * p01t + t * p12t
|
||||
p123t = (1 - t) * p12t + t * p23t
|
||||
return (1 - t) * p012t + t * p123t
|
||||
|
||||
def split_by_x(self, x):
|
||||
return self.split(self.axis_to_t(x))
|
||||
|
||||
def evaluate_by_x(self, x):
|
||||
return self.evaluate(self.axis_to_t(x))
|
||||
|
||||
def axis_to_t(self, val, axis=0):
|
||||
p0, p1, p2, p3 = self._p0[axis], self._p1[axis], self._p2[axis], self._p3[axis]
|
||||
a = p3 - p0 + 3 * (p1 - p2)
|
||||
b = 3 * (p0 - 2 * p1 + p2)
|
||||
c = 3 * (p1 - p0)
|
||||
d = p0 - val
|
||||
return next(self.__find_roots(a, b, c, d))
|
||||
|
||||
def find_critical(self):
|
||||
p0, p1, p2, p3 = self._p0.y, self._p1.y, self._p2.y, self._p3.y
|
||||
p_min, p_max = (p0, p3) if p0 < p3 else (p3, p0)
|
||||
if p1 > p_max or p1 < p_min or p2 > p_max or p2 < p_min:
|
||||
a = 3 * (p3 - p0 + 3 * (p1 - p2))
|
||||
b = 6 * (p0 - 2 * p1 + p2)
|
||||
c = 3 * (p1 - p0)
|
||||
yield from self.__find_roots(0, a, b, c)
|
||||
|
||||
@staticmethod
|
||||
def __find_roots(a, b, c, d): # a*t*t*t + b*t*t + c*t + d = 0
|
||||
# TODO fix precision errors (ex: t=0 and t=1) and improve performance
|
||||
if a == 0:
|
||||
if b == 0:
|
||||
t = -d / c
|
||||
if 0 <= t <= 1:
|
||||
yield t
|
||||
else:
|
||||
D = c * c - 4 * b * d
|
||||
if D < 0:
|
||||
return
|
||||
D = D**0.5
|
||||
b2 = 2 * b
|
||||
t = (-c + D) / b2
|
||||
if 0 <= t <= 1:
|
||||
yield t
|
||||
t = (-c - D) / b2
|
||||
if 0 <= t <= 1:
|
||||
yield t
|
||||
return
|
||||
|
||||
def _sqrt3(v):
|
||||
return -((-v) ** (1 / 3)) if v < 0 else v ** (1 / 3)
|
||||
|
||||
A = b * c / (6 * a * a) - b * b * b / (27 * a * a * a) - d / (2 * a)
|
||||
B = c / (3 * a) - b * b / (9 * a * a)
|
||||
b_3a = -b / (3 * a)
|
||||
D = A * A + B * B * B
|
||||
|
||||
if D > 0:
|
||||
D = D**0.5
|
||||
t = b_3a + _sqrt3(A + D) + _sqrt3(A - D)
|
||||
if 0 <= t <= 1:
|
||||
yield t
|
||||
elif D == 0:
|
||||
t = b_3a + _sqrt3(A) * 2
|
||||
if 0 <= t <= 1:
|
||||
yield t
|
||||
t = b_3a - _sqrt3(A)
|
||||
if 0 <= t <= 1:
|
||||
yield t
|
||||
else:
|
||||
R = A / (-B * B * B) ** 0.5
|
||||
t = b_3a + 2 * (-B) ** 0.5 * math.cos(math.acos(R) / 3)
|
||||
if 0 <= t <= 1:
|
||||
yield t
|
||||
t = b_3a + 2 * (-B) ** 0.5 * math.cos((math.acos(R) + 2 * math.pi) / 3)
|
||||
if 0 <= t <= 1:
|
||||
yield t
|
||||
t = b_3a + 2 * (-B) ** 0.5 * math.cos((math.acos(R) - 2 * math.pi) / 3)
|
||||
if 0 <= t <= 1:
|
||||
yield t
|
||||
|
||||
|
||||
class HasAnimationData:
|
||||
animation_data: bpy.types.AnimData
|
||||
|
||||
|
||||
class VMDImporter:
|
||||
def __init__(self, filepath, scale=1.0, bone_mapper=None, use_pose_mode=False, convert_mmd_camera=True, convert_mmd_lamp=True, frame_margin=5, use_mirror=False, use_NLA=False):
|
||||
self.__vmdFile = vmd.File()
|
||||
self.__vmdFile.load(filepath=filepath)
|
||||
logger.debug(str(self.__vmdFile.header))
|
||||
self.__scale = scale
|
||||
self.__convert_mmd_camera = convert_mmd_camera
|
||||
self.__convert_mmd_lamp = convert_mmd_lamp
|
||||
self.__bone_mapper = bone_mapper
|
||||
self.__bone_util_cls = BoneConverterPoseMode if use_pose_mode else BoneConverter
|
||||
self.__frame_margin = frame_margin + 1
|
||||
self.__mirror = use_mirror
|
||||
self.__use_NLA = use_NLA
|
||||
|
||||
@staticmethod
|
||||
def __minRotationDiff(prev_q, curr_q):
|
||||
t1 = (prev_q.w - curr_q.w) ** 2 + (prev_q.x - curr_q.x) ** 2 + (prev_q.y - curr_q.y) ** 2 + (prev_q.z - curr_q.z) ** 2
|
||||
t2 = (prev_q.w + curr_q.w) ** 2 + (prev_q.x + curr_q.x) ** 2 + (prev_q.y + curr_q.y) ** 2 + (prev_q.z + curr_q.z) ** 2
|
||||
# t1 = prev_q.rotation_difference(curr_q).angle
|
||||
# t2 = prev_q.rotation_difference(-curr_q).angle
|
||||
return -curr_q if t2 < t1 else curr_q
|
||||
|
||||
@staticmethod
|
||||
def __setInterpolation(bezier, kp0, kp1):
|
||||
if bezier[0] == bezier[1] and bezier[2] == bezier[3]:
|
||||
kp0.interpolation = "LINEAR"
|
||||
else:
|
||||
kp0.interpolation = "BEZIER"
|
||||
kp0.handle_right_type = "FREE"
|
||||
kp1.handle_left_type = "FREE"
|
||||
d = (kp1.co - kp0.co) / 127.0
|
||||
kp0.handle_right = kp0.co + Vector((d.x * bezier[0], d.y * bezier[1]))
|
||||
kp1.handle_left = kp0.co + Vector((d.x * bezier[2], d.y * bezier[3]))
|
||||
|
||||
@staticmethod
|
||||
def __fixFcurveHandles(fcurve):
|
||||
kp0 = fcurve.keyframe_points[0]
|
||||
kp0.handle_left_type = "FREE"
|
||||
kp0.handle_left = kp0.co + Vector((-1, 0))
|
||||
kp = fcurve.keyframe_points[-1]
|
||||
kp.handle_right_type = "FREE"
|
||||
kp.handle_right = kp.co + Vector((1, 0))
|
||||
|
||||
@staticmethod
|
||||
def __get_channelbag(action: bpy.types.Action, target_id=None):
|
||||
"""Get or create channelbag for action using Blender 5.0 API."""
|
||||
if not action.slots:
|
||||
slot = action.slots.new(for_id=target_id)
|
||||
else:
|
||||
slot = action.slots[0]
|
||||
return anim_utils.action_ensure_channelbag_for_slot(action, slot)
|
||||
|
||||
@staticmethod
|
||||
def __keyframe_insert_inner(action: bpy.types.Action, path: str, index: int, frame: float, value: float, target_id=None, group_name=None):
|
||||
channelbag = VMDImporter.__get_channelbag(action, target_id)
|
||||
fcurve = channelbag.fcurves.find(path, index=index)
|
||||
if fcurve is None:
|
||||
fcurve = channelbag.fcurves.new(path, index=index, group_name=group_name)
|
||||
fcurve.keyframe_points.insert(frame, value, options={"FAST"})
|
||||
|
||||
@staticmethod
|
||||
def __keyframe_insert(action: bpy.types.Action, path: str, frame: float, value: Union[int, float, Vector], target_id=None, group_name=None):
|
||||
if isinstance(value, (int, float)):
|
||||
VMDImporter.__keyframe_insert_inner(action, path, 0, frame, value, target_id, group_name)
|
||||
|
||||
elif isinstance(value, Vector):
|
||||
VMDImporter.__keyframe_insert_inner(action, path, 0, frame, value[0], target_id, group_name)
|
||||
VMDImporter.__keyframe_insert_inner(action, path, 1, frame, value[1], target_id, group_name)
|
||||
VMDImporter.__keyframe_insert_inner(action, path, 2, frame, value[2], target_id, group_name)
|
||||
|
||||
else:
|
||||
raise TypeError("Unsupported type: {0}".format(type(value)))
|
||||
|
||||
def __getBoneConverter(self, bone):
|
||||
converter = self.__bone_util_cls(bone, self.__scale)
|
||||
mode = bone.rotation_mode
|
||||
compatible_quaternion = self.__minRotationDiff
|
||||
|
||||
class _ConverterWrap:
|
||||
convert_location = converter.convert_location
|
||||
convert_interpolation = converter.convert_interpolation
|
||||
if mode == "QUATERNION":
|
||||
convert_rotation = converter.convert_rotation
|
||||
compatible_rotation = compatible_quaternion
|
||||
elif mode == "AXIS_ANGLE":
|
||||
|
||||
@staticmethod
|
||||
def convert_rotation(rot):
|
||||
(x, y, z), angle = converter.convert_rotation(rot).to_axis_angle()
|
||||
return (angle, x, y, z)
|
||||
|
||||
@staticmethod
|
||||
def compatible_rotation(prev, curr):
|
||||
angle, x, y, z = curr
|
||||
if prev[1] * x + prev[2] * y + prev[3] * z < 0:
|
||||
angle, x, y, z = -angle, -x, -y, -z
|
||||
angle_diff = prev[0] - angle
|
||||
if abs(angle_diff) > math.pi:
|
||||
pi_2 = math.pi * 2
|
||||
bias = -0.5 if angle_diff < 0 else 0.5
|
||||
angle += int(bias + angle_diff / pi_2) * pi_2
|
||||
return (angle, x, y, z)
|
||||
|
||||
else:
|
||||
convert_rotation = lambda rot: converter.convert_rotation(rot).to_euler(mode)
|
||||
compatible_rotation = lambda prev, curr: curr.make_compatible(prev) or curr
|
||||
|
||||
return _ConverterWrap
|
||||
|
||||
def __assign_action(self, target: Union[bpy.types.ID, HasAnimationData], action: bpy.types.Action):
|
||||
if target.animation_data is None:
|
||||
target.animation_data_create()
|
||||
|
||||
if not self.__use_NLA:
|
||||
target.animation_data.action = action
|
||||
else:
|
||||
frame_current = bpy.context.scene.frame_current
|
||||
target_track: bpy.types.NlaTrack = target.animation_data.nla_tracks.new()
|
||||
target_track.name = action.name
|
||||
target_strip = target_track.strips.new(action.name, frame_current, action)
|
||||
target_strip.blend_type = "COMBINE"
|
||||
|
||||
def __assignToArmature(self, armObj, action_name=None):
|
||||
boneAnim = self.__vmdFile.boneAnimation
|
||||
logger.info("---- bone animations:%5d target: %s", len(boneAnim), armObj.name)
|
||||
if len(boneAnim) < 1:
|
||||
return
|
||||
|
||||
action_name = action_name or armObj.name
|
||||
action = bpy.data.actions.new(name=action_name)
|
||||
|
||||
extra_frame = 1 if self.__frame_margin > 1 else 0
|
||||
|
||||
pose_bones = armObj.pose.bones
|
||||
if self.__bone_mapper:
|
||||
pose_bones = self.__bone_mapper(armObj)
|
||||
|
||||
_loc = _rot = lambda i: i
|
||||
if self.__mirror:
|
||||
pose_bones = _MirrorMapper(pose_bones)
|
||||
_loc, _rot = _MirrorMapper.get_location, _MirrorMapper.get_rotation
|
||||
|
||||
class _Dummy:
|
||||
pass
|
||||
|
||||
dummy_keyframe_points = iter(lambda: _Dummy, None)
|
||||
prop_rot_map = {"QUATERNION": "rotation_quaternion", "AXIS_ANGLE": "rotation_axis_angle"}
|
||||
|
||||
bone_name_table = {}
|
||||
for name, keyFrames in boneAnim.items():
|
||||
num_frame = len(keyFrames)
|
||||
if num_frame < 1:
|
||||
continue
|
||||
bone = pose_bones.get(name, None)
|
||||
if bone is None:
|
||||
logger.warning("WARNING: not found bone %s (%d frames)", name, len(keyFrames))
|
||||
continue
|
||||
logger.info("(bone) frames:%5d name: %s", len(keyFrames), name)
|
||||
assert bone_name_table.get(bone.name, name) == name
|
||||
bone_name_table[bone.name] = name
|
||||
|
||||
# Get channelbag for this action
|
||||
channelbag = self.__get_channelbag(action, armObj.data)
|
||||
|
||||
fcurves = [dummy_keyframe_points] * 7 # x, y, z, r0, r1, r2, (r3)
|
||||
data_path_rot = prop_rot_map.get(bone.rotation_mode, "rotation_euler")
|
||||
bone_rotation = getattr(bone, data_path_rot)
|
||||
default_values = list(bone.location) + list(bone_rotation)
|
||||
data_path = 'pose.bones["%s"].location' % bone.name
|
||||
for axis_i in range(3):
|
||||
fcurves[axis_i] = channelbag.fcurves.new(data_path=data_path, index=axis_i, group_name=bone.name)
|
||||
data_path = 'pose.bones["%s"].%s' % (bone.name, data_path_rot)
|
||||
for axis_i in range(len(bone_rotation)):
|
||||
fcurves[3 + axis_i] = channelbag.fcurves.new(data_path=data_path, index=axis_i, group_name=bone.name)
|
||||
|
||||
for i in range(len(default_values)):
|
||||
c = fcurves[i]
|
||||
c.keyframe_points.add(extra_frame + num_frame)
|
||||
kp_iter = iter(c.keyframe_points)
|
||||
if extra_frame:
|
||||
kp = next(kp_iter)
|
||||
kp.co = (1, default_values[i])
|
||||
kp.interpolation = "LINEAR"
|
||||
fcurves[i] = kp_iter
|
||||
|
||||
converter = self.__getBoneConverter(bone)
|
||||
prev_rot = bone_rotation if extra_frame else None
|
||||
prev_kps, indices = None, tuple(converter.convert_interpolation((0, 16, 32))) + (48,) * len(bone_rotation)
|
||||
keyFrames.sort(key=lambda x: x.frame_number)
|
||||
for k, x, y, z, r0, r1, r2, r3 in zip(keyFrames, *fcurves):
|
||||
frame = k.frame_number + self.__frame_margin
|
||||
loc = converter.convert_location(_loc(k.location))
|
||||
curr_rot = converter.convert_rotation(_rot(k.rotation))
|
||||
if prev_rot is not None:
|
||||
curr_rot = converter.compatible_rotation(prev_rot, curr_rot)
|
||||
# FIXME the rotation interpolation has slightly different result
|
||||
# Blender: rot(x) = prev_rot*(1 - bezier(t)) + curr_rot*bezier(t)
|
||||
# MMD: rot(x) = prev_rot.slerp(curr_rot, factor=bezier(t))
|
||||
prev_rot = curr_rot
|
||||
|
||||
x.co = (frame, loc[0])
|
||||
y.co = (frame, loc[1])
|
||||
z.co = (frame, loc[2])
|
||||
r0.co = (frame, curr_rot[0])
|
||||
r1.co = (frame, curr_rot[1])
|
||||
r2.co = (frame, curr_rot[2])
|
||||
r3.co = (frame, curr_rot[-1])
|
||||
|
||||
curr_kps = (x, y, z, r0, r1, r2, r3)
|
||||
if prev_kps is not None:
|
||||
interp = k.interp
|
||||
for idx, prev_kp, kp in zip(indices, prev_kps, curr_kps):
|
||||
self.__setInterpolation(interp[idx : idx + 16 : 4], prev_kp, kp)
|
||||
prev_kps = curr_kps
|
||||
|
||||
# Get channelbag to iterate fcurves
|
||||
channelbag = self.__get_channelbag(action, armObj.data)
|
||||
for c in channelbag.fcurves:
|
||||
self.__fixFcurveHandles(c)
|
||||
|
||||
# property animation
|
||||
propertyAnim = self.__vmdFile.propertyAnimation
|
||||
if len(propertyAnim) > 0:
|
||||
logger.info("---- IK animations:%5d target: %s", len(propertyAnim), armObj.name)
|
||||
for keyFrame in propertyAnim:
|
||||
logger.debug("(IK) frame:%5d list: %s", keyFrame.frame_number, keyFrame.ik_states)
|
||||
frame = keyFrame.frame_number + self.__frame_margin
|
||||
for ikName, enable in keyFrame.ik_states:
|
||||
bone = pose_bones.get(ikName, None)
|
||||
if not bone:
|
||||
continue
|
||||
|
||||
self.__keyframe_insert(action.fcurves, f'pose.bones["{bone.name}"].mmd_ik_toggle', frame, enable)
|
||||
|
||||
self.__assign_action(armObj, action)
|
||||
|
||||
# Ensure IK toggle state is set based on the first frame of VMD animation
|
||||
if len(propertyAnim) > 0:
|
||||
# Collect IK states from the first frame
|
||||
first_frame_ik_states = {}
|
||||
first_frame = float('inf')
|
||||
for keyFrame in propertyAnim:
|
||||
frame_num = keyFrame.frame_number
|
||||
if frame_num < first_frame:
|
||||
first_frame = frame_num
|
||||
for ikName, enable in keyFrame.ik_states:
|
||||
first_frame_ik_states[ikName] = enable
|
||||
elif frame_num == first_frame:
|
||||
for ikName, enable in keyFrame.ik_states:
|
||||
if ikName not in first_frame_ik_states:
|
||||
first_frame_ik_states[ikName] = enable
|
||||
# Set the mmd_ik_toggle property for each bone based on the collected first frame IK states
|
||||
for ikName, enable in first_frame_ik_states.items():
|
||||
bone = pose_bones.get(ikName, None)
|
||||
if bone and bone.mmd_ik_toggle != enable:
|
||||
bone.mmd_ik_toggle = enable # This will trigger the _pose_bone_update_mmd_ik_toggle method
|
||||
|
||||
def __assignToMesh(self, meshObj, action_name=None):
|
||||
shapeKeyAnim = self.__vmdFile.shapeKeyAnimation
|
||||
logger.info("---- morph animations:%5d target: %s", len(shapeKeyAnim), meshObj.name)
|
||||
if len(shapeKeyAnim) < 1:
|
||||
return
|
||||
|
||||
action_name = action_name or meshObj.name
|
||||
action = bpy.data.actions.new(name=action_name)
|
||||
|
||||
mirror_map = _MirrorMapper(meshObj.data.shape_keys.key_blocks) if self.__mirror else {}
|
||||
shapeKeyDict = {k: mirror_map.get(k, v) for k, v in meshObj.data.shape_keys.key_blocks.items()}
|
||||
|
||||
from math import ceil, floor
|
||||
|
||||
for name, keyFrames in shapeKeyAnim.items():
|
||||
if name not in shapeKeyDict:
|
||||
logger.warning("WARNING: not found shape key %s (%d frames)", name, len(keyFrames))
|
||||
continue
|
||||
logger.info("(mesh) frames:%5d name: %s", len(keyFrames), name)
|
||||
shapeKey = shapeKeyDict[name]
|
||||
channelbag = self.__get_channelbag(action, meshObj.data.shape_keys)
|
||||
fcurve = channelbag.fcurves.new(data_path='key_blocks["%s"].value' % shapeKey.name)
|
||||
fcurve.keyframe_points.add(len(keyFrames))
|
||||
keyFrames.sort(key=lambda x: x.frame_number)
|
||||
for k, v in zip(keyFrames, fcurve.keyframe_points):
|
||||
v.co = (k.frame_number + self.__frame_margin, k.weight)
|
||||
v.interpolation = "LINEAR"
|
||||
weights = tuple(i.weight for i in keyFrames)
|
||||
shapeKey.slider_min = min(shapeKey.slider_min, floor(min(weights)))
|
||||
shapeKey.slider_max = max(shapeKey.slider_max, ceil(max(weights)))
|
||||
|
||||
self.__assign_action(meshObj.data.shape_keys, action)
|
||||
|
||||
def __assignToRoot(self, rootObj, action_name=None):
|
||||
propertyAnim = self.__vmdFile.propertyAnimation
|
||||
logger.info("---- display animations:%5d target: %s", len(propertyAnim), rootObj.name)
|
||||
if len(propertyAnim) < 1:
|
||||
return
|
||||
|
||||
action_name = action_name or rootObj.name
|
||||
action = bpy.data.actions.new(name=action_name)
|
||||
|
||||
logger.debug("(Display) list(frame, show): %s", [(keyFrame.frame_number, bool(keyFrame.visible)) for keyFrame in propertyAnim])
|
||||
for keyFrame in propertyAnim:
|
||||
self.__keyframe_insert(action, "mmd_root.show_meshes", keyFrame.frame_number + self.__frame_margin, float(keyFrame.visible), rootObj)
|
||||
|
||||
self.__assign_action(rootObj, action)
|
||||
|
||||
@staticmethod
|
||||
def detectCameraChange(fcurve, threshold=10.0):
|
||||
frames = list(fcurve.keyframe_points)
|
||||
frameCount = len(frames)
|
||||
frames.sort(key=lambda x: x.co[0])
|
||||
for i, f in enumerate(frames):
|
||||
if i + 1 < frameCount:
|
||||
n = frames[i + 1]
|
||||
if n.co[0] - f.co[0] <= 1.0 and abs(f.co[1] - n.co[1]) > threshold:
|
||||
f.interpolation = "CONSTANT"
|
||||
|
||||
def __assignToCamera(self, cameraObj, action_name=None):
|
||||
mmdCameraInstance = MMDCamera.convertToMMDCamera(cameraObj, self.__scale)
|
||||
mmdCamera = mmdCameraInstance.object()
|
||||
cameraObj = mmdCameraInstance.camera()
|
||||
|
||||
cameraAnim = self.__vmdFile.cameraAnimation
|
||||
logger.info("(camera) frames:%5d name: %s", len(cameraAnim), mmdCamera.name)
|
||||
if len(cameraAnim) < 1:
|
||||
return
|
||||
|
||||
action_name = action_name or mmdCamera.name
|
||||
parent_action = bpy.data.actions.new(name=action_name)
|
||||
distance_action = bpy.data.actions.new(name=action_name + "_dis")
|
||||
|
||||
_loc = _rot = lambda i: i
|
||||
if self.__mirror:
|
||||
_loc, _rot = _MirrorMapper.get_location, _MirrorMapper.get_rotation3
|
||||
|
||||
# Get channelbags for camera actions
|
||||
parent_channelbag = self.__get_channelbag(parent_action, mmdCamera.parent)
|
||||
distance_channelbag = self.__get_channelbag(distance_action, mmdCamera.distance)
|
||||
|
||||
fcurves = []
|
||||
for i in range(3):
|
||||
fcurves.append(parent_channelbag.fcurves.new(data_path="location", index=i)) # x, y, z
|
||||
for i in range(3):
|
||||
fcurves.append(parent_channelbag.fcurves.new(data_path="rotation_euler", index=i)) # rx, ry, rz
|
||||
fcurves.append(parent_channelbag.fcurves.new(data_path="mmd_camera.angle")) # fov
|
||||
fcurves.append(parent_channelbag.fcurves.new(data_path="mmd_camera.is_perspective")) # persp
|
||||
fcurves.append(distance_channelbag.fcurves.new(data_path="location", index=1)) # dis
|
||||
for c in fcurves:
|
||||
c.keyframe_points.add(len(cameraAnim))
|
||||
|
||||
prev_kps, indices = None, (0, 8, 4, 12, 12, 12, 16, 20) # x, z, y, rx, ry, rz, dis, fov
|
||||
cameraAnim.sort(key=lambda x: x.frame_number)
|
||||
for k, x, y, z, rx, ry, rz, fov, persp, dis in zip(cameraAnim, *(c.keyframe_points for c in fcurves)):
|
||||
frame = k.frame_number + self.__frame_margin
|
||||
x.co, z.co, y.co = ((frame, val * self.__scale) for val in _loc(k.location))
|
||||
rx.co, rz.co, ry.co = ((frame, val) for val in _rot(k.rotation))
|
||||
fov.co = (frame, math.radians(k.angle))
|
||||
dis.co = (frame, k.distance * self.__scale)
|
||||
persp.co = (frame, k.persp)
|
||||
|
||||
persp.interpolation = "CONSTANT"
|
||||
curr_kps = (x, y, z, rx, ry, rz, dis, fov)
|
||||
if prev_kps is not None:
|
||||
interp = k.interp
|
||||
for idx, prev_kp, kp in zip(indices, prev_kps, curr_kps):
|
||||
self.__setInterpolation(interp[idx : idx + 4 : 2] + interp[idx + 1 : idx + 4 : 2], prev_kp, kp)
|
||||
prev_kps = curr_kps
|
||||
|
||||
for fcurve in fcurves:
|
||||
self.__fixFcurveHandles(fcurve)
|
||||
if fcurve.data_path == "rotation_euler":
|
||||
self.detectCameraChange(fcurve)
|
||||
|
||||
self.__assign_action(mmdCamera, parent_action)
|
||||
self.__assign_action(cameraObj, distance_action)
|
||||
|
||||
@staticmethod
|
||||
def detectLampChange(fcurve, threshold=0.1):
|
||||
frames = list(fcurve.keyframe_points)
|
||||
frameCount = len(frames)
|
||||
frames.sort(key=lambda x: x.co[0])
|
||||
for i, f in enumerate(frames):
|
||||
f.interpolation = "LINEAR"
|
||||
if i + 1 < frameCount:
|
||||
n = frames[i + 1]
|
||||
if n.co[0] - f.co[0] <= 1.0 and abs(f.co[1] - n.co[1]) > threshold:
|
||||
f.interpolation = "CONSTANT"
|
||||
|
||||
def __assignToLamp(self, lampObj, action_name=None):
|
||||
mmdLampInstance = MMDLamp.convertToMMDLamp(lampObj, self.__scale)
|
||||
mmdLamp = mmdLampInstance.object()
|
||||
lampObj = mmdLampInstance.lamp()
|
||||
|
||||
lampAnim = self.__vmdFile.lampAnimation
|
||||
logger.info("(lamp) frames:%5d name: %s", len(lampAnim), mmdLamp.name)
|
||||
if len(lampAnim) < 1:
|
||||
return
|
||||
|
||||
action_name = action_name or mmdLamp.name
|
||||
color_action = bpy.data.actions.new(name=action_name + "_color")
|
||||
location_action = bpy.data.actions.new(name=action_name + "_loc")
|
||||
|
||||
_loc = _MirrorMapper.get_location if self.__mirror else lambda i: i
|
||||
for keyFrame in lampAnim:
|
||||
frame = keyFrame.frame_number + self.__frame_margin
|
||||
self.__keyframe_insert(color_action, "color", frame, Vector(keyFrame.color), lampObj)
|
||||
self.__keyframe_insert(location_action, "location", frame, Vector(_loc(keyFrame.direction)).xzy * -1, mmdLamp)
|
||||
|
||||
location_channelbag = self.__get_channelbag(location_action, mmdLamp)
|
||||
for fcurve in location_channelbag.fcurves:
|
||||
self.detectLampChange(fcurve)
|
||||
|
||||
self.__assign_action(lampObj.data, color_action)
|
||||
self.__assign_action(lampObj, location_action)
|
||||
|
||||
def assign(self, obj, action_name=None):
|
||||
if obj is None:
|
||||
return
|
||||
if action_name is None:
|
||||
action_name = os.path.splitext(os.path.basename(self.__vmdFile.filepath))[0]
|
||||
|
||||
if MMDCamera.isMMDCamera(obj):
|
||||
self.__assignToCamera(obj, action_name + "_camera")
|
||||
elif MMDLamp.isMMDLamp(obj):
|
||||
self.__assignToLamp(obj, action_name + "_lamp")
|
||||
elif getattr(obj.data, "shape_keys", None):
|
||||
self.__assignToMesh(obj, action_name + "_facial")
|
||||
elif obj.type == "ARMATURE":
|
||||
self.__assignToArmature(obj, action_name + "_bone")
|
||||
elif obj.type == "CAMERA" and self.__convert_mmd_camera:
|
||||
self.__assignToCamera(obj, action_name + "_camera")
|
||||
elif obj.type == "LAMP" and self.__convert_mmd_lamp:
|
||||
self.__assignToLamp(obj, action_name + "_lamp")
|
||||
elif obj.mmd_type == "ROOT":
|
||||
self.__assignToRoot(obj, action_name + "_display")
|
||||
else:
|
||||
pass
|
||||
@@ -0,0 +1,243 @@
|
||||
# Copyright 2012 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
from typing import Iterable, Optional
|
||||
|
||||
import bpy
|
||||
|
||||
from .core.material import FnMaterial
|
||||
from .core.shader import _NodeGroupUtils
|
||||
|
||||
|
||||
def __switchToCyclesRenderEngine():
|
||||
if bpy.context.scene.render.engine != "CYCLES":
|
||||
bpy.context.scene.render.engine = "CYCLES"
|
||||
|
||||
|
||||
def __exposeNodeTreeInput(in_socket, name, default_value, node_input, shader):
|
||||
_NodeGroupUtils(shader).new_input_socket(name, in_socket, default_value)
|
||||
|
||||
|
||||
def __exposeNodeTreeOutput(out_socket, name, node_output, shader):
|
||||
_NodeGroupUtils(shader).new_output_socket(name, out_socket)
|
||||
|
||||
|
||||
def __getMaterialOutput(nodes, bl_idname):
|
||||
o = next((n for n in nodes if n.bl_idname == bl_idname and n.is_active_output), None) or nodes.new(bl_idname)
|
||||
o.is_active_output = True
|
||||
return o
|
||||
|
||||
|
||||
def create_MMDAlphaShader():
|
||||
__switchToCyclesRenderEngine()
|
||||
|
||||
if "MMDAlphaShader" in bpy.data.node_groups:
|
||||
return bpy.data.node_groups["MMDAlphaShader"]
|
||||
|
||||
shader = bpy.data.node_groups.new(name="MMDAlphaShader", type="ShaderNodeTree")
|
||||
|
||||
node_input = shader.nodes.new("NodeGroupInput")
|
||||
node_output = shader.nodes.new("NodeGroupOutput")
|
||||
node_output.location.x += 250
|
||||
node_input.location.x -= 500
|
||||
|
||||
trans = shader.nodes.new("ShaderNodeBsdfTransparent")
|
||||
trans.location.x -= 250
|
||||
trans.location.y += 150
|
||||
mix = shader.nodes.new("ShaderNodeMixShader")
|
||||
|
||||
shader.links.new(mix.inputs[1], trans.outputs["BSDF"])
|
||||
|
||||
__exposeNodeTreeInput(mix.inputs[2], "Shader", None, node_input, shader)
|
||||
__exposeNodeTreeInput(mix.inputs["Fac"], "Alpha", 1.0, node_input, shader)
|
||||
__exposeNodeTreeOutput(mix.outputs["Shader"], "Shader", node_output, shader)
|
||||
|
||||
return shader
|
||||
|
||||
|
||||
def create_MMDBasicShader():
|
||||
__switchToCyclesRenderEngine()
|
||||
|
||||
if "MMDBasicShader" in bpy.data.node_groups:
|
||||
return bpy.data.node_groups["MMDBasicShader"]
|
||||
|
||||
shader: bpy.types.ShaderNodeTree = bpy.data.node_groups.new(name="MMDBasicShader", type="ShaderNodeTree")
|
||||
|
||||
node_input: bpy.types.NodeGroupInput = shader.nodes.new("NodeGroupInput")
|
||||
node_output: bpy.types.NodeGroupOutput = shader.nodes.new("NodeGroupOutput")
|
||||
node_output.location.x += 250
|
||||
node_input.location.x -= 500
|
||||
|
||||
dif: bpy.types.ShaderNodeBsdfDiffuse = shader.nodes.new("ShaderNodeBsdfDiffuse")
|
||||
dif.location.x -= 250
|
||||
dif.location.y += 150
|
||||
glo: bpy.types.ShaderNodeBsdfAnisotropic = shader.nodes.new("ShaderNodeBsdfAnisotropic")
|
||||
glo.location.x -= 250
|
||||
glo.location.y -= 150
|
||||
mix: bpy.types.ShaderNodeMixShader = shader.nodes.new("ShaderNodeMixShader")
|
||||
shader.links.new(mix.inputs[1], dif.outputs["BSDF"])
|
||||
shader.links.new(mix.inputs[2], glo.outputs["BSDF"])
|
||||
|
||||
__exposeNodeTreeInput(dif.inputs["Color"], "diffuse", [1.0, 1.0, 1.0, 1.0], node_input, shader)
|
||||
__exposeNodeTreeInput(glo.inputs["Color"], "glossy", [1.0, 1.0, 1.0, 1.0], node_input, shader)
|
||||
__exposeNodeTreeInput(glo.inputs["Roughness"], "glossy_rough", 0.0, node_input, shader)
|
||||
__exposeNodeTreeInput(mix.inputs["Fac"], "reflection", 0.02, node_input, shader)
|
||||
__exposeNodeTreeOutput(mix.outputs["Shader"], "shader", node_output, shader)
|
||||
|
||||
return shader
|
||||
|
||||
|
||||
def __enum_linked_nodes(node: bpy.types.Node) -> Iterable[bpy.types.Node]:
|
||||
yield node
|
||||
if node.parent:
|
||||
yield node.parent
|
||||
for n in {link.from_node for i in node.inputs for link in i.links}:
|
||||
yield from __enum_linked_nodes(n)
|
||||
|
||||
|
||||
def __cleanNodeTree(material: bpy.types.Material):
|
||||
nodes = material.node_tree.nodes
|
||||
node_names = {n.name for n in nodes}
|
||||
for o in (n for n in nodes if n.bl_idname in {"ShaderNodeOutput", "ShaderNodeOutputMaterial"}):
|
||||
if any(i.is_linked for i in o.inputs):
|
||||
node_names -= {linked.name for linked in __enum_linked_nodes(o)}
|
||||
for name in node_names:
|
||||
nodes.remove(nodes[name])
|
||||
|
||||
|
||||
def convertToCyclesShader(obj: bpy.types.Object, use_principled=False, clean_nodes=False, subsurface=0.001):
|
||||
__switchToCyclesRenderEngine()
|
||||
convertToBlenderShader(obj, use_principled, clean_nodes, subsurface)
|
||||
|
||||
|
||||
def convertToBlenderShader(obj: bpy.types.Object, use_principled=False, clean_nodes=False, subsurface=0.001):
|
||||
for i in obj.material_slots:
|
||||
if not i.material:
|
||||
continue
|
||||
# use_nodes is deprecated in 5.0 but always returns True and setting it is safe
|
||||
if not i.material.use_nodes:
|
||||
i.material.use_nodes = True
|
||||
__convertToMMDBasicShader(i.material)
|
||||
if use_principled:
|
||||
__convertToPrincipledBsdf(i.material, subsurface)
|
||||
if clean_nodes:
|
||||
__cleanNodeTree(i.material)
|
||||
|
||||
|
||||
def convertToMMDShader(obj):
|
||||
"""BSDF -> MMDShaderDev conversion."""
|
||||
for i in obj.material_slots:
|
||||
if not i.material:
|
||||
continue
|
||||
# use_nodes is deprecated in 5.0 but always returns True and setting it is safe
|
||||
if not i.material.use_nodes:
|
||||
i.material.use_nodes = True
|
||||
FnMaterial.convert_to_mmd_material(i.material)
|
||||
|
||||
|
||||
def __convertToMMDBasicShader(material: bpy.types.Material):
|
||||
# TODO: test me
|
||||
mmd_basic_shader_grp = create_MMDBasicShader()
|
||||
mmd_alpha_shader_grp = create_MMDAlphaShader()
|
||||
|
||||
if not any(filter(lambda x: isinstance(x, bpy.types.ShaderNodeGroup) and x.node_tree.name in {"MMDBasicShader", "MMDAlphaShader"}, material.node_tree.nodes)):
|
||||
# Add nodes for Cycles Render
|
||||
shader: bpy.types.ShaderNodeGroup = material.node_tree.nodes.new("ShaderNodeGroup")
|
||||
shader.node_tree = mmd_basic_shader_grp
|
||||
shader.inputs[0].default_value[:3] = material.diffuse_color[:3]
|
||||
shader.inputs[1].default_value[:3] = material.specular_color[:3]
|
||||
shader.inputs["glossy_rough"].default_value = 1.0 / getattr(material, "specular_hardness", 50)
|
||||
outplug = shader.outputs[0]
|
||||
|
||||
location = shader.location.copy()
|
||||
location.x -= 1000
|
||||
|
||||
alpha_value = 1.0
|
||||
if len(material.diffuse_color) > 3:
|
||||
alpha_value = material.diffuse_color[3]
|
||||
|
||||
if alpha_value < 1.0:
|
||||
alpha_shader: bpy.types.ShaderNodeGroup = material.node_tree.nodes.new("ShaderNodeGroup")
|
||||
alpha_shader.location.x = shader.location.x + 250
|
||||
alpha_shader.location.y = shader.location.y - 150
|
||||
alpha_shader.node_tree = mmd_alpha_shader_grp
|
||||
alpha_shader.inputs[1].default_value = alpha_value
|
||||
material.node_tree.links.new(alpha_shader.inputs[0], outplug)
|
||||
outplug = alpha_shader.outputs[0]
|
||||
|
||||
material_output: bpy.types.ShaderNodeOutputMaterial = __getMaterialOutput(material.node_tree.nodes, "ShaderNodeOutputMaterial")
|
||||
material.node_tree.links.new(material_output.inputs["Surface"], outplug)
|
||||
material_output.location.x = shader.location.x + 500
|
||||
material_output.location.y = shader.location.y - 150
|
||||
|
||||
|
||||
def __convertToPrincipledBsdf(material: bpy.types.Material, subsurface: float):
|
||||
node_names = set()
|
||||
for s in (n for n in material.node_tree.nodes if isinstance(n, bpy.types.ShaderNodeGroup)):
|
||||
if s.node_tree.name == "MMDBasicShader":
|
||||
link: bpy.types.NodeLink
|
||||
for link in s.outputs[0].links:
|
||||
to_node = link.to_node
|
||||
# assuming there is no bpy.types.NodeReroute between MMDBasicShader and MMDAlphaShader
|
||||
if isinstance(to_node, bpy.types.ShaderNodeGroup) and to_node.node_tree.name == "MMDAlphaShader":
|
||||
__switchToPrincipledBsdf(material.node_tree, s, subsurface, node_alpha=to_node)
|
||||
node_names.add(to_node.name)
|
||||
else:
|
||||
__switchToPrincipledBsdf(material.node_tree, s, subsurface)
|
||||
node_names.add(s.name)
|
||||
elif s.node_tree.name == "MMDShaderDev":
|
||||
__switchToPrincipledBsdf(material.node_tree, s, subsurface)
|
||||
node_names.add(s.name)
|
||||
# remove MMD shader nodes
|
||||
nodes = material.node_tree.nodes
|
||||
for name in node_names:
|
||||
nodes.remove(nodes[name])
|
||||
|
||||
|
||||
def __switchToPrincipledBsdf(node_tree: bpy.types.NodeTree, node_basic: bpy.types.ShaderNodeGroup, subsurface: float, node_alpha: Optional[bpy.types.ShaderNodeGroup] = None):
|
||||
shader: bpy.types.ShaderNodeBsdfPrincipled = node_tree.nodes.new("ShaderNodeBsdfPrincipled")
|
||||
shader.parent = node_basic.parent
|
||||
shader.location.x = node_basic.location.x
|
||||
shader.location.y = node_basic.location.y
|
||||
|
||||
alpha_socket_name = "Alpha"
|
||||
if node_basic.node_tree.name == "MMDShaderDev":
|
||||
node_alpha, alpha_socket_name = node_basic, "Base Alpha"
|
||||
if "Base Tex" in node_basic.inputs and node_basic.inputs["Base Tex"].is_linked:
|
||||
node_tree.links.new(node_basic.inputs["Base Tex"].links[0].from_socket, shader.inputs["Base Color"])
|
||||
elif "Diffuse Color" in node_basic.inputs:
|
||||
shader.inputs["Base Color"].default_value[:3] = node_basic.inputs["Diffuse Color"].default_value[:3]
|
||||
elif "diffuse" in node_basic.inputs:
|
||||
shader.inputs["Base Color"].default_value[:3] = node_basic.inputs["diffuse"].default_value[:3]
|
||||
if node_basic.inputs["diffuse"].is_linked:
|
||||
node_tree.links.new(node_basic.inputs["diffuse"].links[0].from_socket, shader.inputs["Base Color"])
|
||||
|
||||
shader.inputs["IOR"].default_value = 1.0
|
||||
shader.inputs["Subsurface Weight"].default_value = subsurface
|
||||
|
||||
output_links = node_basic.outputs[0].links
|
||||
if node_alpha:
|
||||
output_links = node_alpha.outputs[0].links
|
||||
shader.parent = node_alpha.parent or shader.parent
|
||||
shader.location.x = node_alpha.location.x
|
||||
|
||||
if alpha_socket_name in node_alpha.inputs:
|
||||
if "Alpha" in shader.inputs:
|
||||
shader.inputs["Alpha"].default_value = node_alpha.inputs["Alpha"].default_value
|
||||
if node_alpha.inputs[alpha_socket_name].is_linked:
|
||||
node_tree.links.new(node_alpha.inputs[alpha_socket_name].links[0].from_socket, shader.inputs["Alpha"])
|
||||
else:
|
||||
shader.inputs["Transmission"].default_value = 1 - node_alpha.inputs[alpha_socket_name].default_value
|
||||
if node_alpha.inputs[alpha_socket_name].is_linked:
|
||||
node_invert = node_tree.nodes.new("ShaderNodeMath")
|
||||
node_invert.parent = shader.parent
|
||||
node_invert.location.x = node_alpha.location.x - 250
|
||||
node_invert.location.y = node_alpha.location.y - 300
|
||||
node_invert.operation = "SUBTRACT"
|
||||
node_invert.use_clamp = True
|
||||
node_invert.inputs[0].default_value = 1
|
||||
node_tree.links.new(node_alpha.inputs[alpha_socket_name].links[0].from_socket, node_invert.inputs[1])
|
||||
node_tree.links.new(node_invert.outputs[0], shader.inputs["Transmission"])
|
||||
|
||||
for link in output_links:
|
||||
node_tree.links.new(shader.outputs[0], link.to_socket)
|
||||
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2014 MMD Tools authors
|
||||
# This file was originally part of the MMD Tools add-on for Blender
|
||||
# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools
|
||||
# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements.
|
||||
# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,495 @@
|
||||
# Copyright 2014 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
import bpy
|
||||
from bpy.props import BoolProperty, StringProperty
|
||||
from bpy.types import Operator
|
||||
|
||||
from .. import cycles_converter
|
||||
from ..core.exceptions import MaterialNotFoundError
|
||||
from ..core.material import FnMaterial
|
||||
from ..core.shader import _NodeGroupUtils
|
||||
|
||||
|
||||
class ConvertMaterialsForCycles(Operator):
|
||||
bl_idname = "mmd_tools.convert_materials_for_cycles"
|
||||
bl_label = "Convert Materials For Cycles"
|
||||
bl_description = "Convert materials of selected objects for Cycles."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
use_principled: bpy.props.BoolProperty(
|
||||
name="Convert to Principled BSDF",
|
||||
description="Convert MMD shader nodes to Principled BSDF as well if enabled",
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
|
||||
clean_nodes: bpy.props.BoolProperty(
|
||||
name="Clean Nodes",
|
||||
description="Remove redundant nodes as well if enabled. Disable it to keep node data.",
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return any(x.type == "MESH" for x in context.selected_objects)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.prop(self, "use_principled")
|
||||
layout.prop(self, "clean_nodes")
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
context.scene.render.engine = "CYCLES"
|
||||
except Exception:
|
||||
self.report({"ERROR"}, " * Failed to change to Cycles render engine.")
|
||||
return {"CANCELLED"}
|
||||
for obj in (x for x in context.selected_objects if x.type == "MESH"):
|
||||
cycles_converter.convertToCyclesShader(obj, use_principled=self.use_principled, clean_nodes=self.clean_nodes)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ConvertMaterials(Operator):
|
||||
bl_idname = "mmd_tools.convert_materials"
|
||||
bl_label = "Convert Materials"
|
||||
bl_description = "Convert materials of selected objects."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
use_principled: bpy.props.BoolProperty(
|
||||
name="Convert to Principled BSDF",
|
||||
description="Convert MMD shader nodes to Principled BSDF as well if enabled",
|
||||
default=True,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
|
||||
clean_nodes: bpy.props.BoolProperty(
|
||||
name="Clean Nodes",
|
||||
description="Remove redundant nodes as well if enabled. Disable it to keep node data.",
|
||||
default=True,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
|
||||
subsurface: bpy.props.FloatProperty(
|
||||
name="Subsurface",
|
||||
default=0.001,
|
||||
soft_min=0.000,
|
||||
soft_max=1.000,
|
||||
precision=3,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return any(x.type == "MESH" for x in context.selected_objects)
|
||||
|
||||
def execute(self, context):
|
||||
for obj in context.selected_objects:
|
||||
if obj.type != "MESH":
|
||||
continue
|
||||
cycles_converter.convertToBlenderShader(obj, use_principled=self.use_principled, clean_nodes=self.clean_nodes, subsurface=self.subsurface)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class MergeMaterials(Operator):
|
||||
bl_idname = "mmd_tools.merge_materials"
|
||||
bl_label = "Merge Materials"
|
||||
bl_description = "Merge materials with the same texture in selected objects. Only merges materials with exactly one texture node. Materials with no texture or with multiple textures are not merged. Please convert to Blender materials first."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return any(x.type == "MESH" for x in context.selected_objects)
|
||||
|
||||
def execute(self, context):
|
||||
# Process all selected mesh objects
|
||||
for obj in context.selected_objects:
|
||||
if obj.type != "MESH":
|
||||
continue
|
||||
|
||||
self.merge_materials_for_object(context, obj)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
def merge_materials_for_object(self, context, obj):
|
||||
"""Merge materials with same texture for a single object"""
|
||||
if not obj.data.materials:
|
||||
self.report({"INFO"}, f"Object '{obj.name}' has no materials")
|
||||
return
|
||||
|
||||
# Map texture paths to material indices and names
|
||||
texture_to_materials = defaultdict(list)
|
||||
|
||||
# Check each material
|
||||
for i, material in enumerate(obj.data.materials):
|
||||
# use_nodes is deprecated in 5.0 but always returns True, so check is safe
|
||||
if not material or not material.use_nodes:
|
||||
continue
|
||||
|
||||
# 1. Check texture node count (must be exactly 1)
|
||||
texture_nodes = [node for node in material.node_tree.nodes if node.type == "TEX_IMAGE"]
|
||||
if len(texture_nodes) != 1:
|
||||
continue
|
||||
|
||||
# 2. Record texture path and material info
|
||||
texture_node = texture_nodes[0]
|
||||
if texture_node.image:
|
||||
texture_path = bpy.path.abspath(texture_node.image.filepath)
|
||||
texture_to_materials[texture_path].append({"index": i, "name": material.name})
|
||||
|
||||
# Find material groups that need merging
|
||||
materials_to_merge = {path: materials for path, materials in texture_to_materials.items() if len(materials) > 1}
|
||||
|
||||
if not materials_to_merge:
|
||||
self.report({"INFO"}, f"No materials to merge in object '{obj.name}'")
|
||||
return
|
||||
|
||||
# Process each texture group
|
||||
context.view_layer.objects.active = obj
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
merge_details = []
|
||||
for texture_path, materials in materials_to_merge.items():
|
||||
# Use first material as target
|
||||
target_material = materials[0]
|
||||
target_index = target_material["index"]
|
||||
target_name = target_material["name"]
|
||||
|
||||
source_materials = []
|
||||
|
||||
# Reassign faces from other materials to target material
|
||||
for source_material in materials[1:]:
|
||||
source_index = source_material["index"]
|
||||
source_name = source_material["name"]
|
||||
source_materials.append(source_name)
|
||||
|
||||
bpy.ops.mesh.select_all(action="DESELECT")
|
||||
obj.active_material_index = source_index
|
||||
bpy.ops.object.material_slot_select()
|
||||
obj.active_material_index = target_index
|
||||
bpy.ops.object.material_slot_assign()
|
||||
|
||||
# Record merge details
|
||||
texture_name = bpy.path.basename(texture_path)
|
||||
merge_details.append({"texture": texture_name, "target": target_name, "sources": source_materials})
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
bpy.ops.object.material_slot_remove_unused()
|
||||
|
||||
merged_count = sum(len(details["sources"]) for details in merge_details)
|
||||
self.report({"INFO"}, f"Object '{obj.name}': Merged {merged_count} materials")
|
||||
|
||||
for details in merge_details:
|
||||
sources_text = ", ".join(details["sources"])
|
||||
self.report({"INFO"}, f"Same Texture '{details['texture']}': Merged materials [{sources_text}] into '{details['target']}'")
|
||||
|
||||
|
||||
class ConvertBSDFMaterials(Operator):
|
||||
bl_idname = "mmd_tools.convert_bsdf_materials"
|
||||
bl_label = "Convert Blender Materials"
|
||||
bl_description = "Convert materials of selected objects."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return any(x.type == "MESH" for x in context.selected_objects)
|
||||
|
||||
def execute(self, context):
|
||||
for obj in context.selected_objects:
|
||||
if obj.type != "MESH":
|
||||
continue
|
||||
cycles_converter.convertToMMDShader(obj)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class _OpenTextureBase:
|
||||
"""Create a texture for mmd model material."""
|
||||
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
filepath: StringProperty(
|
||||
name="File Path",
|
||||
description="Filepath used for importing the file",
|
||||
maxlen=1024,
|
||||
subtype="FILE_PATH",
|
||||
)
|
||||
|
||||
use_filter_image: BoolProperty(
|
||||
default=True,
|
||||
options={"HIDDEN"},
|
||||
)
|
||||
|
||||
def invoke(self, context, event):
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
|
||||
class OpenTexture(Operator, _OpenTextureBase):
|
||||
bl_idname = "mmd_tools.material_open_texture"
|
||||
bl_label = "Open Texture"
|
||||
bl_description = "Create main texture of active material"
|
||||
|
||||
def execute(self, context):
|
||||
mat = context.active_object.active_material
|
||||
fnMat = FnMaterial(mat)
|
||||
fnMat.create_texture(self.filepath)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RemoveTexture(Operator):
|
||||
"""Create a texture for mmd model material."""
|
||||
|
||||
bl_idname = "mmd_tools.material_remove_texture"
|
||||
bl_label = "Remove Texture"
|
||||
bl_description = "Remove main texture of active material"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
def execute(self, context):
|
||||
mat = context.active_object.active_material
|
||||
fnMat = FnMaterial(mat)
|
||||
fnMat.remove_texture()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class OpenSphereTextureSlot(Operator, _OpenTextureBase):
|
||||
"""Create a texture for mmd model material."""
|
||||
|
||||
bl_idname = "mmd_tools.material_open_sphere_texture"
|
||||
bl_label = "Open Sphere Texture"
|
||||
bl_description = "Create sphere texture of active material"
|
||||
|
||||
def execute(self, context):
|
||||
mat = context.active_object.active_material
|
||||
fnMat = FnMaterial(mat)
|
||||
fnMat.create_sphere_texture(self.filepath, context.active_object)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RemoveSphereTexture(Operator):
|
||||
"""Create a texture for mmd model material."""
|
||||
|
||||
bl_idname = "mmd_tools.material_remove_sphere_texture"
|
||||
bl_label = "Remove Sphere Texture"
|
||||
bl_description = "Remove sphere texture of active material"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
def execute(self, context):
|
||||
mat = context.active_object.active_material
|
||||
fnMat = FnMaterial(mat)
|
||||
fnMat.remove_sphere_texture()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class MoveMaterialUp(Operator):
|
||||
bl_idname = "mmd_tools.move_material_up"
|
||||
bl_label = "Move Material Up"
|
||||
bl_description = "Moves selected material one slot up"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
obj = context.active_object
|
||||
return obj is not None and obj.type == "MESH" and obj.mmd_type == "NONE" and obj.active_material_index > 0
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.active_object
|
||||
current_idx = obj.active_material_index
|
||||
prev_index = current_idx - 1
|
||||
try:
|
||||
FnMaterial.swap_materials(obj, current_idx, prev_index, reverse=True, swap_slots=True)
|
||||
except MaterialNotFoundError:
|
||||
self.report({"ERROR"}, "Materials not found")
|
||||
return {"CANCELLED"}
|
||||
obj.active_material_index = prev_index
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class MoveMaterialDown(Operator):
|
||||
bl_idname = "mmd_tools.move_material_down"
|
||||
bl_label = "Move Material Down"
|
||||
bl_description = "Moves the selected material one slot down"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
obj = context.active_object
|
||||
return obj is not None and obj.type == "MESH" and obj.mmd_type == "NONE" and obj.active_material_index < len(obj.material_slots) - 1
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.active_object
|
||||
current_idx = obj.active_material_index
|
||||
next_index = current_idx + 1
|
||||
try:
|
||||
FnMaterial.swap_materials(obj, current_idx, next_index, reverse=True, swap_slots=True)
|
||||
except MaterialNotFoundError:
|
||||
self.report({"ERROR"}, "Materials not found")
|
||||
return {"CANCELLED"}
|
||||
obj.active_material_index = next_index
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class EdgePreviewSetup(Operator):
|
||||
bl_idname = "mmd_tools.edge_preview_setup"
|
||||
bl_label = "Edge Preview Setup"
|
||||
bl_description = 'Preview toon edge settings of active model using "Solidify" modifier'
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
action: bpy.props.EnumProperty(
|
||||
name="Action",
|
||||
description="Select action",
|
||||
items=[
|
||||
("CREATE", "Create", "Create toon edge", 0),
|
||||
("CLEAN", "Clean", "Clear toon edge", 1),
|
||||
],
|
||||
default="CREATE",
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
from ..core.model import FnModel
|
||||
|
||||
root = FnModel.find_root_object(context.active_object)
|
||||
if root is None:
|
||||
self.report({"ERROR"}, "Select a MMD model")
|
||||
return {"CANCELLED"}
|
||||
|
||||
if self.action == "CLEAN":
|
||||
for obj in FnModel.iterate_mesh_objects(root):
|
||||
self.__clean_toon_edge(obj)
|
||||
else:
|
||||
from ..bpyutils import Props
|
||||
|
||||
scale = 0.2 * getattr(root, Props.empty_display_size)
|
||||
counts = sum(self.__create_toon_edge(obj, scale) for obj in FnModel.iterate_mesh_objects(root))
|
||||
self.report({"INFO"}, "Created %d toon edge(s)" % counts)
|
||||
return {"FINISHED"}
|
||||
|
||||
def __clean_toon_edge(self, obj):
|
||||
if "mmd_edge_preview" in obj.modifiers:
|
||||
obj.modifiers.remove(obj.modifiers["mmd_edge_preview"])
|
||||
|
||||
if "mmd_edge_preview" in obj.vertex_groups:
|
||||
obj.vertex_groups.remove(obj.vertex_groups["mmd_edge_preview"])
|
||||
|
||||
FnMaterial.clean_materials(obj, can_remove=lambda m: m and m.name.startswith("mmd_edge."))
|
||||
|
||||
def __create_toon_edge(self, obj, scale=1.0):
|
||||
self.__clean_toon_edge(obj)
|
||||
materials = obj.data.materials
|
||||
material_offset = len(materials)
|
||||
for m in tuple(materials):
|
||||
if m and m.mmd_material.enabled_toon_edge:
|
||||
mat_edge = self.__get_edge_material("mmd_edge." + m.name, m.mmd_material.edge_color, materials)
|
||||
materials.append(mat_edge)
|
||||
elif material_offset > 1:
|
||||
mat_edge = self.__get_edge_material("mmd_edge.disabled", (0, 0, 0, 0), materials)
|
||||
materials.append(mat_edge)
|
||||
if len(materials) > material_offset:
|
||||
mod = obj.modifiers.get("mmd_edge_preview", None)
|
||||
if mod is None:
|
||||
mod = obj.modifiers.new("mmd_edge_preview", "SOLIDIFY")
|
||||
mod.material_offset = material_offset
|
||||
mod.thickness_vertex_group = 1e-3 # avoid overlapped faces
|
||||
mod.use_flip_normals = True
|
||||
mod.use_rim = False
|
||||
mod.offset = 1
|
||||
self.__create_edge_preview_group(obj)
|
||||
mod.thickness = scale
|
||||
mod.vertex_group = "mmd_edge_preview"
|
||||
return len(materials) - material_offset
|
||||
|
||||
def __create_edge_preview_group(self, obj):
|
||||
vertices, materials = obj.data.vertices, obj.data.materials
|
||||
weight_map = {i: m.mmd_material.edge_weight for i, m in enumerate(materials) if m}
|
||||
scale_map = {}
|
||||
vg_scale_index = obj.vertex_groups.find("mmd_edge_scale")
|
||||
if vg_scale_index >= 0:
|
||||
scale_map = {v.index: g.weight for v in vertices for g in v.groups if g.group == vg_scale_index}
|
||||
vg_edge_preview = obj.vertex_groups.new(name="mmd_edge_preview")
|
||||
for i, mi in {v: f.material_index for f in reversed(obj.data.polygons) for v in f.vertices}.items():
|
||||
weight = scale_map.get(i, 1.0) * weight_map.get(mi, 1.0) * 0.02
|
||||
vg_edge_preview.add(index=[i], weight=weight, type="REPLACE")
|
||||
|
||||
def __get_edge_material(self, mat_name, edge_color, materials):
|
||||
if mat_name in materials:
|
||||
return materials[mat_name]
|
||||
mat = bpy.data.materials.get(mat_name, None)
|
||||
if mat is None:
|
||||
mat = bpy.data.materials.new(mat_name)
|
||||
mmd_mat = mat.mmd_material
|
||||
# note: edge affects ground shadow
|
||||
mmd_mat.is_double_sided = mmd_mat.enabled_drop_shadow = False
|
||||
mmd_mat.enabled_self_shadow_map = mmd_mat.enabled_self_shadow = False
|
||||
# mmd_mat.enabled_self_shadow_map = True # for blender 2.78+ BI viewport only
|
||||
mmd_mat.diffuse_color = mmd_mat.specular_color = (0, 0, 0)
|
||||
mmd_mat.ambient_color = edge_color[:3]
|
||||
mmd_mat.alpha = edge_color[3]
|
||||
mmd_mat.edge_color = edge_color
|
||||
self.__make_shader(mat)
|
||||
return mat
|
||||
|
||||
def __make_shader(self, m):
|
||||
m.use_nodes = True
|
||||
nodes, links = m.node_tree.nodes, m.node_tree.links
|
||||
|
||||
node_shader = nodes.get("mmd_edge_preview", None)
|
||||
if node_shader is None or not any(s.is_linked for s in node_shader.outputs):
|
||||
XPOS, YPOS = 210, 110
|
||||
nodes.clear()
|
||||
node_shader = nodes.new("ShaderNodeGroup")
|
||||
node_shader.name = "mmd_edge_preview"
|
||||
node_shader.location = (0, 0)
|
||||
node_shader.width = 200
|
||||
node_shader.node_tree = self.__get_edge_preview_shader()
|
||||
|
||||
node_out = nodes.new("ShaderNodeOutputMaterial")
|
||||
node_out.location = (XPOS * 2, YPOS * 0)
|
||||
links.new(node_shader.outputs["Shader"], node_out.inputs["Surface"])
|
||||
|
||||
node_shader.inputs["Color"].default_value = m.mmd_material.edge_color
|
||||
node_shader.inputs["Alpha"].default_value = m.mmd_material.edge_color[3]
|
||||
|
||||
def __get_edge_preview_shader(self):
|
||||
group_name = "MMDEdgePreview"
|
||||
shader = bpy.data.node_groups.get(group_name, None) or bpy.data.node_groups.new(name=group_name, type="ShaderNodeTree")
|
||||
if len(shader.nodes):
|
||||
return shader
|
||||
|
||||
ng = _NodeGroupUtils(shader)
|
||||
|
||||
ng.new_node("NodeGroupInput", (-5, 0))
|
||||
ng.new_node("NodeGroupOutput", (3, 0))
|
||||
|
||||
############################################################################
|
||||
node_color = ng.new_node("ShaderNodeMixRGB", (-1, -1.5))
|
||||
node_color.mute = True
|
||||
|
||||
ng.new_input_socket("Color", node_color.inputs["Color1"])
|
||||
|
||||
############################################################################
|
||||
node_ray = ng.new_node("ShaderNodeLightPath", (-3, 1.5))
|
||||
node_geo = ng.new_node("ShaderNodeNewGeometry", (-3, 0))
|
||||
node_max = ng.new_math_node("MAXIMUM", (-2, 1.5))
|
||||
node_max.mute = True
|
||||
node_gt = ng.new_math_node("GREATER_THAN", (-1, 1))
|
||||
node_alpha = ng.new_math_node("MULTIPLY", (0, 1))
|
||||
node_trans = ng.new_node("ShaderNodeBsdfTransparent", (0, 0))
|
||||
node_rgb = ng.new_node("ShaderNodeBackground", (0, -0.5))
|
||||
node_mix = ng.new_node("ShaderNodeMixShader", (1, 0.5))
|
||||
|
||||
links = ng.links
|
||||
links.new(node_ray.outputs["Is Camera Ray"], node_max.inputs[0])
|
||||
links.new(node_ray.outputs["Is Glossy Ray"], node_max.inputs[1])
|
||||
links.new(node_max.outputs["Value"], node_gt.inputs[0])
|
||||
links.new(node_geo.outputs["Backfacing"], node_gt.inputs[1])
|
||||
links.new(node_gt.outputs["Value"], node_alpha.inputs[0])
|
||||
links.new(node_alpha.outputs["Value"], node_mix.inputs["Fac"])
|
||||
links.new(node_trans.outputs["BSDF"], node_mix.inputs[1])
|
||||
links.new(node_rgb.outputs[0], node_mix.inputs[2])
|
||||
links.new(node_color.outputs["Color"], node_rgb.inputs["Color"])
|
||||
|
||||
ng.new_input_socket("Alpha", node_alpha.inputs[1])
|
||||
ng.new_output_socket("Shader", node_mix.outputs["Shader"])
|
||||
|
||||
return shader
|
||||
@@ -0,0 +1,318 @@
|
||||
# Copyright 2014 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
import re
|
||||
|
||||
import bpy
|
||||
|
||||
from .. import utils
|
||||
from ..bpyutils import FnContext, FnObject
|
||||
from ..core.bone import FnBone
|
||||
from ..core.model import FnModel, Model
|
||||
from ..core.morph import FnMorph
|
||||
|
||||
|
||||
class SelectObject(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.object_select"
|
||||
bl_label = "Select Object"
|
||||
bl_description = "Select the object"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
name: bpy.props.StringProperty(
|
||||
name="Name",
|
||||
description="The object name",
|
||||
default="",
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
utils.selectAObject(context.scene.objects[self.name])
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class MoveObject(bpy.types.Operator, utils.ItemMoveOp):
|
||||
bl_idname = "mmd_tools.object_move"
|
||||
bl_label = "Move Object"
|
||||
bl_description = "Move active object up/down in the list"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
__PREFIX_REGEXP = re.compile(r"(?P<prefix>[0-9A-Z]{3}_)(?P<name>.*)")
|
||||
|
||||
@classmethod
|
||||
def set_index(cls, obj, index):
|
||||
m = cls.__PREFIX_REGEXP.match(obj.name)
|
||||
name = m.group("name") if m else obj.name
|
||||
obj.name = f"{utils.int2base(index, 36, 3)}_{name}"
|
||||
|
||||
@classmethod
|
||||
def get_name(cls, obj, prefix=None):
|
||||
m = cls.__PREFIX_REGEXP.match(obj.name)
|
||||
name = m.group("name") if m else obj.name
|
||||
return name[len(prefix) :] if prefix and name.startswith(prefix) else name
|
||||
|
||||
@classmethod
|
||||
def normalize_indices(cls, objects):
|
||||
for i, x in enumerate(objects):
|
||||
cls.set_index(x, i)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return context.active_object is not None
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.active_object
|
||||
objects = self.__get_objects(obj)
|
||||
if obj not in objects:
|
||||
self.report({"ERROR"}, f'Can not move object "{obj.name}"')
|
||||
return {"CANCELLED"}
|
||||
|
||||
objects.sort(key=lambda x: x.name)
|
||||
self.move(objects, objects.index(obj), self.type)
|
||||
self.normalize_indices(objects)
|
||||
return {"FINISHED"}
|
||||
|
||||
def __get_objects(self, obj):
|
||||
class __MovableList(list):
|
||||
def move(self, index_old, index_new):
|
||||
item = self[index_old]
|
||||
self.remove(item)
|
||||
self.insert(index_new, item)
|
||||
|
||||
objects = []
|
||||
root = FnModel.find_root_object(obj)
|
||||
if root:
|
||||
rig = Model(root)
|
||||
if obj.mmd_type == "NONE" and obj.type == "MESH":
|
||||
objects = rig.meshes()
|
||||
elif obj.mmd_type == "RIGID_BODY":
|
||||
objects = rig.rigidBodies()
|
||||
elif obj.mmd_type == "JOINT":
|
||||
objects = rig.joints()
|
||||
return __MovableList(objects)
|
||||
|
||||
|
||||
class CleanShapeKeys(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.clean_shape_keys"
|
||||
bl_label = "Clean Shape Keys"
|
||||
bl_description = "Remove unused shape keys of selected mesh objects"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return any(o.type == "MESH" for o in context.selected_objects)
|
||||
|
||||
@staticmethod
|
||||
def __can_remove(key_block):
|
||||
if key_block.relative_key == key_block:
|
||||
return False # Basis
|
||||
for v0, v1 in zip(key_block.relative_key.data, key_block.data, strict=False):
|
||||
if v0.co != v1.co:
|
||||
return False
|
||||
return True
|
||||
|
||||
def __shape_key_clean(self, obj, key_blocks):
|
||||
for kb in key_blocks:
|
||||
if self.__can_remove(kb):
|
||||
FnObject.mesh_remove_shape_key(obj, kb)
|
||||
if len(key_blocks) == 1:
|
||||
FnObject.mesh_remove_shape_key(obj, key_blocks[0])
|
||||
|
||||
def execute(self, context):
|
||||
obj: bpy.types.Object
|
||||
for obj in context.selected_objects:
|
||||
if obj.type != "MESH" or obj.data.shape_keys is None:
|
||||
continue
|
||||
if not obj.data.shape_keys.use_relative:
|
||||
continue # not be considered yet
|
||||
self.__shape_key_clean(obj, obj.data.shape_keys.key_blocks)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class SeparateByMaterials(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.separate_by_materials"
|
||||
bl_label = "Sep by Mat(High Risk)"
|
||||
bl_description = "Separate by Materials (High Risk)\nSeparate the mesh into multiple objects based on materials.\nHIGH RISK & BUGGY: This operation is not reversible and may cause various issues. It splits adjacent geometry by material, and merging later will not reconnect shared edges.\nKnown issues include potential mesh corruption, UV mapping problems, and other unpredictable behaviors. Use with extreme caution and backup your work first."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
clean_shape_keys: bpy.props.BoolProperty(
|
||||
name="Clean Shape Keys",
|
||||
description="Remove unused shape keys of separated objects",
|
||||
default=True,
|
||||
)
|
||||
|
||||
keep_normals: bpy.props.BoolProperty(
|
||||
name="Keep Normals",
|
||||
default=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
obj = context.active_object
|
||||
return obj is not None and obj.type == "MESH"
|
||||
|
||||
def __separate_by_materials(self, obj):
|
||||
utils.separateByMaterials(obj, self.keep_normals)
|
||||
if self.clean_shape_keys:
|
||||
bpy.ops.mmd_tools.clean_shape_keys()
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.active_object
|
||||
root = FnModel.find_root_object(obj)
|
||||
|
||||
# Sep by Mat crashes Blender if used after morph assembly
|
||||
rig = Model(root)
|
||||
rig.morph_slider.unbind()
|
||||
|
||||
if root is None:
|
||||
self.__separate_by_materials(obj)
|
||||
else:
|
||||
bpy.ops.mmd_tools.clear_temp_materials()
|
||||
bpy.ops.mmd_tools.clear_uv_morph_view()
|
||||
|
||||
# Store the current material names
|
||||
rig = Model(root)
|
||||
mat_names = [getattr(mat, "name", None) for mat in rig.materials()]
|
||||
self.__separate_by_materials(obj)
|
||||
for mesh in rig.meshes():
|
||||
FnMorph.clean_uv_morph_vertex_groups(mesh)
|
||||
if len(mesh.data.materials) > 0:
|
||||
mat = mesh.data.materials[0]
|
||||
idx = mat_names.index(getattr(mat, "name", None))
|
||||
MoveObject.set_index(mesh, idx)
|
||||
|
||||
for morph in root.mmd_root.material_morphs:
|
||||
FnMorph(morph, rig).update_mat_related_mesh()
|
||||
utils.clearUnusedMeshes()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class JoinMeshes(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.join_meshes"
|
||||
bl_label = "Join Meshes"
|
||||
bl_description = "Join the Model meshes into a single one"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
sort_shape_keys: bpy.props.BoolProperty(
|
||||
name="Sort Shape Keys",
|
||||
description="Sort shape keys in the order of vertex morph",
|
||||
default=True,
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.active_object
|
||||
root = FnModel.find_root_object(obj)
|
||||
if root is None:
|
||||
self.report({"ERROR"}, "Select a MMD model")
|
||||
return {"CANCELLED"}
|
||||
|
||||
bpy.ops.mmd_tools.clear_temp_materials()
|
||||
bpy.ops.mmd_tools.clear_uv_morph_view()
|
||||
|
||||
# Find all the meshes in mmd_root
|
||||
rig = Model(root)
|
||||
meshes_list = sorted(rig.meshes(), key=lambda x: x.name)
|
||||
if not meshes_list:
|
||||
self.report({"ERROR"}, "The model does not have any meshes")
|
||||
return {"CANCELLED"}
|
||||
active_mesh = meshes_list[0]
|
||||
|
||||
FnContext.select_objects(context, *meshes_list)
|
||||
FnContext.set_active_object(context, active_mesh)
|
||||
|
||||
# Store the current order of the materials
|
||||
for m in meshes_list[1:]:
|
||||
for mat in m.data.materials:
|
||||
if mat not in active_mesh.data.materials[:]:
|
||||
active_mesh.data.materials.append(mat)
|
||||
|
||||
# Join selected meshes
|
||||
bpy.ops.object.join()
|
||||
|
||||
if self.sort_shape_keys:
|
||||
FnMorph.fixShapeKeyOrder(active_mesh, root.mmd_root.vertex_morphs.keys())
|
||||
active_mesh.active_shape_key_index = 0
|
||||
for morph in root.mmd_root.material_morphs:
|
||||
FnMorph(morph, rig).update_mat_related_mesh(active_mesh)
|
||||
utils.clearUnusedMeshes()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class AttachMeshesToMMD(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.attach_meshes"
|
||||
bl_label = "Attach Meshes to Model"
|
||||
bl_description = "Finds existing meshes and attaches them to the selected MMD model"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
add_armature_modifier: bpy.props.BoolProperty(default=True)
|
||||
|
||||
def execute(self, context: bpy.types.Context):
|
||||
root = FnModel.find_root_object(context.active_object)
|
||||
if root is None:
|
||||
self.report({"ERROR"}, "Select a MMD model")
|
||||
return {"CANCELLED"}
|
||||
|
||||
armObj = FnModel.find_armature_object(root)
|
||||
if armObj is None:
|
||||
self.report({"ERROR"}, "Model Armature not found")
|
||||
return {"CANCELLED"}
|
||||
|
||||
FnModel.attach_mesh_objects(root, context.visible_objects, self.add_armature_modifier)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ChangeMMDIKLoopFactor(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.change_mmd_ik_loop_factor"
|
||||
bl_label = "Change MMD IK Loop Factor"
|
||||
bl_description = "Multiplier for all bones' IK iterations in Blender"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
mmd_ik_loop_factor: bpy.props.IntProperty(
|
||||
name="MMD IK Loop Factor",
|
||||
description="Scaling factor of MMD IK loop",
|
||||
min=1,
|
||||
soft_max=10,
|
||||
max=100,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
root = FnModel.find_root_object(context.active_object)
|
||||
return root is not None
|
||||
|
||||
def invoke(self, context, event):
|
||||
root_object = FnModel.find_root_object(context.active_object)
|
||||
self.mmd_ik_loop_factor = root_object.mmd_root.ik_loop_factor
|
||||
vm = context.window_manager
|
||||
return vm.invoke_props_dialog(self)
|
||||
|
||||
def execute(self, context):
|
||||
root_object = FnModel.find_root_object(context.active_object)
|
||||
FnModel.change_mmd_ik_loop_factor(root_object, self.mmd_ik_loop_factor)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RecalculateBoneRoll(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.recalculate_bone_roll"
|
||||
bl_label = "Recalculate bone roll"
|
||||
bl_description = "Recalculate bone roll for arm related bones"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
obj = context.active_object
|
||||
return obj is not None and obj.type == "ARMATURE"
|
||||
|
||||
def invoke(self, context, event):
|
||||
vm = context.window_manager
|
||||
return vm.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
c = layout.column()
|
||||
c.label(text="This operation will break existing f-curve/action.", icon="QUESTION")
|
||||
c.label(text="Click [OK] to run the operation.")
|
||||
|
||||
def execute(self, context):
|
||||
arm = context.active_object
|
||||
FnBone.apply_auto_bone_roll(arm)
|
||||
return {"FINISHED"}
|
||||
@@ -0,0 +1,523 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2014 MMD Tools authors
|
||||
# This file was originally part of the MMD Tools add-on for Blender
|
||||
# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools
|
||||
# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements.
|
||||
# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit.
|
||||
|
||||
import bpy
|
||||
from typing import Optional, Set, Dict, Any, List, Tuple, Union
|
||||
|
||||
from ..bpyutils import FnContext
|
||||
from ..core.bone import FnBone, MigrationFnBone
|
||||
from ..core.model import FnModel, Model
|
||||
from ....core.logging_setup import logger
|
||||
|
||||
|
||||
class MorphSliderSetup(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.morph_slider_setup"
|
||||
bl_label = "Morph Slider Setup"
|
||||
bl_description = "Translate MMD morphs of selected object into format usable by Blender"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
type: bpy.props.EnumProperty(
|
||||
name="Type",
|
||||
description="Select type",
|
||||
items=[
|
||||
("CREATE", "Create", "Create placeholder object for morph sliders", "SHAPEKEY_DATA", 0),
|
||||
("BIND", "Bind", "Bind morph sliders", "DRIVER", 1),
|
||||
("UNBIND", "Unbind", "Unbind morph sliders", "X", 2),
|
||||
],
|
||||
default="CREATE",
|
||||
)
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> Set[str]:
|
||||
active_object = context.active_object
|
||||
root_object = FnModel.find_root_object(active_object)
|
||||
assert root_object is not None
|
||||
|
||||
logger.debug(f"Executing MorphSliderSetup with type: {self.type}")
|
||||
with FnContext.temp_override_active_layer_collection(context, root_object):
|
||||
rig = Model(root_object)
|
||||
if self.type == "BIND":
|
||||
logger.info(f"Binding morph sliders for {root_object.name}")
|
||||
rig.morph_slider.bind()
|
||||
elif self.type == "UNBIND":
|
||||
logger.info(f"Unbinding morph sliders for {root_object.name}")
|
||||
rig.morph_slider.unbind()
|
||||
else:
|
||||
logger.info(f"Creating morph sliders for {root_object.name}")
|
||||
rig.morph_slider.create()
|
||||
FnContext.set_active_object(context, active_object)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class CleanRiggingObjects(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.clean_rig"
|
||||
bl_label = "Clean Rig"
|
||||
bl_description = "Delete temporary physics objects of selected object and revert physics to default MMD state"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> Set[str]:
|
||||
root_object = FnModel.find_root_object(context.active_object)
|
||||
assert root_object is not None
|
||||
|
||||
logger.info(f"Cleaning rig for {root_object.name}")
|
||||
rig = Model(root_object)
|
||||
rig.clean()
|
||||
FnContext.set_active_object(context, root_object)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BuildRig(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.build_rig"
|
||||
bl_label = "Build Rig"
|
||||
bl_description = "Translate physics of selected object into format usable by Blender"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
non_collision_distance_scale: bpy.props.FloatProperty(
|
||||
name="Non-Collision Distance Scale",
|
||||
description="The distance scale for creating extra non-collision constraints while building physics",
|
||||
min=0,
|
||||
soft_max=10,
|
||||
default=1.5,
|
||||
)
|
||||
|
||||
collision_margin: bpy.props.FloatProperty(
|
||||
name="Collision Margin",
|
||||
description="The collision margin between rigid bodies. If 0, the default value for each shape is adopted.",
|
||||
unit="LENGTH",
|
||||
min=0,
|
||||
soft_max=10,
|
||||
default=1e-06,
|
||||
)
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> Set[str]:
|
||||
root_object = FnModel.find_root_object(context.active_object)
|
||||
|
||||
logger.info(f"Building rig for {root_object.name} with non_collision_distance_scale={self.non_collision_distance_scale}, collision_margin={self.collision_margin}")
|
||||
with FnContext.temp_override_active_layer_collection(context, root_object):
|
||||
rig = Model(root_object)
|
||||
rig.build(self.non_collision_distance_scale, self.collision_margin)
|
||||
FnContext.set_active_object(context, root_object)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class CleanAdditionalTransformConstraints(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.clean_additional_transform"
|
||||
bl_label = "Clean Additional Transform"
|
||||
bl_description = "Delete shadow bones of selected object and revert bones to default MMD state"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> Set[str]:
|
||||
active_object = context.active_object
|
||||
root_object = FnModel.find_root_object(active_object)
|
||||
assert root_object is not None
|
||||
|
||||
logger.info(f"Cleaning additional transform constraints for {root_object.name}")
|
||||
armature_object = FnModel.find_armature_object(root_object)
|
||||
FnBone.clean_additional_transformation(armature_object)
|
||||
FnContext.set_active_object(context, active_object)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ApplyAdditionalTransformConstraints(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.apply_additional_transform"
|
||||
bl_label = "Apply Additional Transform"
|
||||
bl_description = "Translate appended bones of selected object for Blender"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> Set[str]:
|
||||
active_object = context.active_object
|
||||
root_object = FnModel.find_root_object(active_object)
|
||||
assert root_object is not None
|
||||
|
||||
logger.info(f"Applying additional transform constraints for {root_object.name}")
|
||||
armature_object = FnModel.find_armature_object(root_object)
|
||||
assert armature_object is not None
|
||||
|
||||
MigrationFnBone.fix_mmd_ik_limit_override(armature_object)
|
||||
FnBone.apply_additional_transformation(armature_object)
|
||||
FnContext.set_active_object(context, active_object)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class SetupBoneFixedAxes(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.bone_fixed_axis_setup"
|
||||
bl_label = "Setup Bone Fixed Axis"
|
||||
bl_description = "Setup fixed axis of selected bones"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
type: bpy.props.EnumProperty(
|
||||
name="Type",
|
||||
description="Select type",
|
||||
items=[
|
||||
("DISABLE", "Disable", "Disable MMD fixed axis of selected bones", 0),
|
||||
("LOAD", "Load", "Load/Enable MMD fixed axis of selected bones from their Y-axis or the only rotatable axis", 1),
|
||||
("APPLY", "Apply", "Align bone axes to MMD fixed axis of each bone", 2),
|
||||
],
|
||||
default="LOAD",
|
||||
)
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> Set[str]:
|
||||
armature_object = context.active_object
|
||||
if not armature_object or armature_object.type != "ARMATURE":
|
||||
self.report({"ERROR"}, "Active object is not an armature object")
|
||||
logger.error("Setup Bone Fixed Axis failed: Active object is not an armature object")
|
||||
return {"CANCELLED"}
|
||||
|
||||
logger.info(f"Setting up bone fixed axes with type: {self.type}")
|
||||
if self.type == "APPLY":
|
||||
FnBone.apply_bone_fixed_axis(armature_object)
|
||||
FnBone.apply_additional_transformation(armature_object)
|
||||
else:
|
||||
FnBone.load_bone_fixed_axis(armature_object, enable=(self.type == "LOAD"))
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class SetupBoneLocalAxes(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.bone_local_axes_setup"
|
||||
bl_label = "Setup Bone Local Axes"
|
||||
bl_description = "Setup local axes of each bone"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
type: bpy.props.EnumProperty(
|
||||
name="Type",
|
||||
description="Select type",
|
||||
items=[
|
||||
("DISABLE", "Disable", "Disable MMD local axes of selected bones", 0),
|
||||
("LOAD", "Load", "Load/Enable MMD local axes of selected bones from their bone axes", 1),
|
||||
("APPLY", "Apply", "Align bone axes to MMD local axes of each bone", 2),
|
||||
],
|
||||
default="LOAD",
|
||||
)
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> Set[str]:
|
||||
armature_object = context.active_object
|
||||
if not armature_object or armature_object.type != "ARMATURE":
|
||||
self.report({"ERROR"}, "Active object is not an armature object")
|
||||
logger.error("Setup Bone Local Axes failed: Active object is not an armature object")
|
||||
return {"CANCELLED"}
|
||||
|
||||
logger.info(f"Setting up bone local axes with type: {self.type}")
|
||||
if self.type == "APPLY":
|
||||
FnBone.apply_bone_local_axes(armature_object)
|
||||
FnBone.apply_additional_transformation(armature_object)
|
||||
else:
|
||||
FnBone.load_bone_local_axes(armature_object, enable=(self.type == "LOAD"))
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class AddMissingVertexGroupsFromBones(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.add_missing_vertex_groups_from_bones"
|
||||
bl_label = "Add Missing Vertex Groups from Bones"
|
||||
bl_description = "Add the missing vertex groups to the selected mesh"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
search_in_all_meshes: bpy.props.BoolProperty(
|
||||
name="Search in all meshes",
|
||||
description="Search for vertex groups in all meshes",
|
||||
default=False,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
return FnModel.find_root_object(context.active_object) is not None
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> Set[str]:
|
||||
active_object: bpy.types.Object = context.active_object
|
||||
root_object = FnModel.find_root_object(active_object)
|
||||
assert root_object is not None
|
||||
|
||||
logger.info(f"Adding missing vertex groups from bones for {root_object.name}, search_in_all_meshes={self.search_in_all_meshes}")
|
||||
bone_order_mesh_object = FnModel.find_bone_order_mesh_object(root_object)
|
||||
if bone_order_mesh_object is None:
|
||||
logger.error("Failed to find bone order mesh object")
|
||||
return {"CANCELLED"}
|
||||
|
||||
FnModel.add_missing_vertex_groups_from_bones(root_object, bone_order_mesh_object, self.search_in_all_meshes)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class CreateMMDModelRoot(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.create_mmd_model_root_object"
|
||||
bl_label = "Create a MMD Model Root Object"
|
||||
bl_description = "Create a MMD model root object with a basic armature"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
name_j: bpy.props.StringProperty(
|
||||
name="Name",
|
||||
description="The name of the MMD model",
|
||||
default="New MMD Model",
|
||||
)
|
||||
name_e: bpy.props.StringProperty(
|
||||
name="Name(Eng)",
|
||||
description="The english name of the MMD model",
|
||||
default="New MMD Model",
|
||||
)
|
||||
scale: bpy.props.FloatProperty(
|
||||
name="Scale",
|
||||
description="Scale",
|
||||
default=0.08,
|
||||
)
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> Set[str]:
|
||||
logger.info(f"Creating MMD model root object with name_j={self.name_j}, name_e={self.name_e}, scale={self.scale}")
|
||||
rig = Model.create(self.name_j, self.name_e, self.scale, add_root_bone=True)
|
||||
rig.initialDisplayFrames()
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context: bpy.types.Context, event: Any) -> Set[str]:
|
||||
vm = context.window_manager
|
||||
return vm.invoke_props_dialog(self)
|
||||
|
||||
|
||||
class ConvertToMMDModel(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.convert_to_mmd_model"
|
||||
bl_label = "Convert to a MMD Model"
|
||||
bl_description = "Convert active armature with its meshes to a MMD model (experimental)"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
ambient_color_source: bpy.props.EnumProperty(
|
||||
name="Ambient Color Source",
|
||||
description="Select ambient color source",
|
||||
items=[
|
||||
("DIFFUSE", "Diffuse", "Diffuse color", 0),
|
||||
("MIRROR", "Mirror", 'Mirror color (if property "mirror_color" is available)', 1),
|
||||
],
|
||||
default="DIFFUSE",
|
||||
)
|
||||
edge_threshold: bpy.props.FloatProperty(
|
||||
name="Edge Threshold",
|
||||
description="MMD toon edge will not be enabled if freestyle line color alpha less than this value",
|
||||
min=0,
|
||||
max=1.001,
|
||||
precision=3,
|
||||
step=0.1,
|
||||
default=0.1,
|
||||
)
|
||||
edge_alpha_min: bpy.props.FloatProperty(
|
||||
name="Minimum Edge Alpha",
|
||||
description="Minimum alpha of MMD toon edge color",
|
||||
min=0,
|
||||
max=1,
|
||||
precision=3,
|
||||
step=0.1,
|
||||
default=0.5,
|
||||
)
|
||||
scale: bpy.props.FloatProperty(
|
||||
name="Scale",
|
||||
description="Scaling factor for converting the model",
|
||||
default=0.08,
|
||||
)
|
||||
convert_material_nodes: bpy.props.BoolProperty(
|
||||
name="Convert Material Nodes",
|
||||
default=True,
|
||||
)
|
||||
middle_joint_bones_lock: bpy.props.BoolProperty(
|
||||
name="Middle Joint Bones Lock",
|
||||
description="Lock specific bones for backward compatibility.",
|
||||
default=False,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
obj = context.active_object
|
||||
return obj and obj.type == "ARMATURE" and obj.mode != "EDIT"
|
||||
|
||||
def invoke(self, context: bpy.types.Context, event: Any) -> Set[str]:
|
||||
vm = context.window_manager
|
||||
return vm.invoke_props_dialog(self)
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> Set[str]:
|
||||
logger.info(f"Converting to MMD model with scale={self.scale}, convert_material_nodes={self.convert_material_nodes}")
|
||||
# TODO convert some basic MMD properties
|
||||
armature_object = context.active_object
|
||||
scale = self.scale
|
||||
model_name = "New MMD Model"
|
||||
|
||||
root_object = FnModel.find_root_object(armature_object)
|
||||
if root_object is None or root_object != armature_object.parent:
|
||||
logger.debug("Creating new MMD model")
|
||||
Model.create(model_name, model_name, scale, armature_object=armature_object)
|
||||
|
||||
self.__attach_meshes_to(armature_object, FnContext.get_scene_objects(context))
|
||||
self.__configure_rig(context, Model(armature_object.parent))
|
||||
return {"FINISHED"}
|
||||
|
||||
def __attach_meshes_to(self, armature_object: bpy.types.Object, objects: bpy.types.SceneObjects) -> None:
|
||||
def __is_child_of_armature(mesh: bpy.types.Object) -> bool:
|
||||
if mesh.parent is None:
|
||||
return False
|
||||
return mesh.parent == armature_object or __is_child_of_armature(mesh.parent)
|
||||
|
||||
def __is_using_armature(mesh: bpy.types.Object) -> bool:
|
||||
for m in mesh.modifiers:
|
||||
if m.type == "ARMATURE" and m.object == armature_object:
|
||||
return True
|
||||
return False
|
||||
|
||||
def __get_root(mesh: bpy.types.Object) -> bpy.types.Object:
|
||||
if mesh.parent is None:
|
||||
return mesh
|
||||
return __get_root(mesh.parent)
|
||||
|
||||
attached_count = 0
|
||||
for x in objects:
|
||||
if __is_using_armature(x) and not __is_child_of_armature(x):
|
||||
x_root = __get_root(x)
|
||||
m = x_root.matrix_world
|
||||
x_root.parent_type = "OBJECT"
|
||||
x_root.parent = armature_object
|
||||
x_root.matrix_world = m
|
||||
attached_count += 1
|
||||
|
||||
logger.debug(f"Attached {attached_count} meshes to armature")
|
||||
|
||||
def __configure_rig(self, context: bpy.types.Context, mmd_model: Model) -> None:
|
||||
root_object = mmd_model.rootObject()
|
||||
armature_object = mmd_model.armature()
|
||||
mesh_objects = tuple(mmd_model.meshes())
|
||||
|
||||
logger.info(f"Configuring rig for {root_object.name} with {len(mesh_objects)} meshes")
|
||||
mmd_model.loadMorphs()
|
||||
|
||||
if self.middle_joint_bones_lock:
|
||||
vertex_groups = {g.name for mesh in mesh_objects for g in mesh.vertex_groups}
|
||||
locked_bones = 0
|
||||
for pose_bone in armature_object.pose.bones:
|
||||
if not pose_bone.parent:
|
||||
continue
|
||||
if not pose_bone.bone.use_connect and pose_bone.name not in vertex_groups:
|
||||
continue
|
||||
pose_bone.lock_location = (True, True, True)
|
||||
locked_bones += 1
|
||||
logger.debug(f"Locked {locked_bones} middle joint bones")
|
||||
|
||||
from ..core.material import FnMaterial
|
||||
|
||||
FnMaterial.set_nodes_are_readonly(not self.convert_material_nodes)
|
||||
try:
|
||||
converted_materials = 0
|
||||
for m in (x for mesh in mesh_objects for x in mesh.data.materials if x):
|
||||
FnMaterial.convert_to_mmd_material(m, context)
|
||||
mmd_material = m.mmd_material
|
||||
if self.ambient_color_source == "MIRROR" and hasattr(m, "mirror_color"):
|
||||
mmd_material.ambient_color = m.mirror_color
|
||||
else:
|
||||
mmd_material.ambient_color = [0.5 * c for c in mmd_material.diffuse_color]
|
||||
|
||||
if hasattr(m, "line_color"): # freestyle line color
|
||||
line_color = list(m.line_color)
|
||||
mmd_material.enabled_toon_edge = line_color[3] >= self.edge_threshold
|
||||
mmd_material.edge_color = line_color[:3] + [max(line_color[3], self.edge_alpha_min)]
|
||||
converted_materials += 1
|
||||
logger.debug(f"Converted {converted_materials} materials")
|
||||
finally:
|
||||
FnMaterial.set_nodes_are_readonly(False)
|
||||
from .display_item import DisplayItemQuickSetup
|
||||
|
||||
FnBone.sync_display_item_frames_from_bone_collections(armature_object)
|
||||
mmd_model.initialDisplayFrames(reset=False) # ensure default frames
|
||||
DisplayItemQuickSetup.load_facial_items(root_object.mmd_root)
|
||||
root_object.mmd_root.active_display_item_frame = 0
|
||||
|
||||
|
||||
class ResetObjectVisibility(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.reset_object_visibility"
|
||||
bl_label = "Reset Object Visivility"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
active_object: bpy.types.Object = context.active_object
|
||||
return FnModel.find_root_object(active_object) is not None
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> Set[str]:
|
||||
active_object: bpy.types.Object = context.active_object
|
||||
mmd_root_object = FnModel.find_root_object(active_object)
|
||||
assert mmd_root_object is not None
|
||||
mmd_root = mmd_root_object.mmd_root
|
||||
|
||||
logger.info(f"Resetting object visibility for {mmd_root_object.name}")
|
||||
mmd_root_object.hide_set(False)
|
||||
|
||||
rigid_group_object = FnModel.find_rigid_group_object(mmd_root_object)
|
||||
if rigid_group_object:
|
||||
rigid_group_object.hide_set(True)
|
||||
|
||||
joint_group_object = FnModel.find_joint_group_object(mmd_root_object)
|
||||
if joint_group_object:
|
||||
joint_group_object.hide_set(True)
|
||||
|
||||
temporary_group_object = FnModel.find_temporary_group_object(mmd_root_object)
|
||||
if temporary_group_object:
|
||||
temporary_group_object.hide_set(True)
|
||||
|
||||
mmd_root.show_meshes = True
|
||||
mmd_root.show_armature = True
|
||||
mmd_root.show_temporary_objects = False
|
||||
mmd_root.show_rigid_bodies = False
|
||||
mmd_root.show_names_of_rigid_bodies = False
|
||||
mmd_root.show_joints = False
|
||||
mmd_root.show_names_of_joints = False
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class AssembleAll(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.assemble_all"
|
||||
bl_label = "Assemble All"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> Set[str]:
|
||||
active_object = context.active_object
|
||||
root_object = FnModel.find_root_object(active_object)
|
||||
assert root_object is not None
|
||||
|
||||
logger.info(f"Assembling all components for {root_object.name}")
|
||||
with FnContext.temp_override_active_layer_collection(context, root_object) as context:
|
||||
rig = Model(root_object)
|
||||
MigrationFnBone.fix_mmd_ik_limit_override(rig.armature())
|
||||
FnBone.apply_additional_transformation(rig.armature())
|
||||
rig.build()
|
||||
rig.morph_slider.bind()
|
||||
|
||||
logger.debug("Binding SDEF weights")
|
||||
with context.temp_override(selected_objects=[active_object]):
|
||||
bpy.ops.mmd_tools.sdef_bind()
|
||||
root_object.mmd_root.use_property_driver = True
|
||||
|
||||
FnContext.set_active_object(context, active_object)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DisassembleAll(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.disassemble_all"
|
||||
bl_label = "Disassemble All"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> Set[str]:
|
||||
active_object = context.active_object
|
||||
root_object = FnModel.find_root_object(active_object)
|
||||
assert root_object is not None
|
||||
|
||||
logger.info(f"Disassembling all components for {root_object.name}")
|
||||
with FnContext.temp_override_active_layer_collection(context, root_object) as context:
|
||||
root_object.mmd_root.use_property_driver = False
|
||||
logger.debug("Unbinding SDEF weights")
|
||||
with context.temp_override(selected_objects=[active_object]):
|
||||
bpy.ops.mmd_tools.sdef_unbind()
|
||||
|
||||
rig = Model(root_object)
|
||||
rig.morph_slider.unbind()
|
||||
rig.clean()
|
||||
FnBone.clean_additional_transformation(rig.armature())
|
||||
|
||||
FnContext.set_active_object(context, active_object)
|
||||
|
||||
return {"FINISHED"}
|
||||
@@ -0,0 +1,455 @@
|
||||
# Copyright 2022 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
import itertools
|
||||
from operator import itemgetter
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
from mathutils import Matrix
|
||||
|
||||
from ..bpyutils import FnContext, select_object
|
||||
from ..core.model import FnModel, Model
|
||||
|
||||
|
||||
class NoModelSelectedError(Exception):
|
||||
"""Raised when no MMD model is selected."""
|
||||
|
||||
|
||||
class ModelJoinByBonesOperator(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.model_join_by_bones"
|
||||
bl_label = "Model Join by Bones"
|
||||
bl_description = "Join multiple MMD models into one.\n\nWARNING: To align models before joining, only adjust the root (cross under the model) transformation. Do not move armatures, meshes, rigid bodies, or joints directly as they will not move together.\n\nIMPORTANT: Don't use any of the 'Assembly' functions before using this function. This function requires the models to be in a clean state."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
join_type: bpy.props.EnumProperty(
|
||||
name="Join Type",
|
||||
items=[
|
||||
("CONNECTED", "Connected", ""),
|
||||
("OFFSET", "Keep Offset", ""),
|
||||
],
|
||||
default="OFFSET",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context):
|
||||
active_object: Optional[bpy.types.Object] = context.active_object
|
||||
|
||||
if context.mode != "POSE":
|
||||
return False
|
||||
|
||||
if active_object is None:
|
||||
return False
|
||||
|
||||
if active_object.type != "ARMATURE":
|
||||
return False
|
||||
|
||||
if len(list(filter(lambda o: o.type == "ARMATURE", context.selected_objects))) < 2:
|
||||
return False
|
||||
|
||||
return len(context.selected_pose_bones) > 0
|
||||
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def execute(self, context: bpy.types.Context):
|
||||
try:
|
||||
self.join(context)
|
||||
except NoModelSelectedError as ex:
|
||||
self.report(type={"ERROR"}, message=str(ex))
|
||||
return {"CANCELLED"}
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
def join(self, context: bpy.types.Context):
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
|
||||
parent_root_object = FnModel.find_root_object(context.active_object)
|
||||
child_root_objects = {FnModel.find_root_object(o) for o in context.selected_objects}
|
||||
child_root_objects.remove(parent_root_object)
|
||||
|
||||
if parent_root_object is None or len(child_root_objects) == 0:
|
||||
raise NoModelSelectedError("No MMD Models selected")
|
||||
|
||||
# Save original active_layer_collection
|
||||
orig_active_layer_collection = context.view_layer.active_layer_collection
|
||||
|
||||
# Find layer collection containing parent_root_object and set it as active
|
||||
layer_collection = FnContext.find_user_layer_collection_by_object(context, parent_root_object)
|
||||
if layer_collection:
|
||||
context.view_layer.active_layer_collection = layer_collection
|
||||
|
||||
# Execute the join operation
|
||||
FnModel.join_models(parent_root_object, child_root_objects)
|
||||
|
||||
# Restore original active_layer_collection
|
||||
context.view_layer.active_layer_collection = orig_active_layer_collection
|
||||
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
parent_armature_object = FnModel.find_armature_object(parent_root_object)
|
||||
FnContext.set_active_and_select_single_object(context, parent_armature_object)
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
bpy.ops.armature.parent_set(type="OFFSET")
|
||||
|
||||
# Connect child bones
|
||||
if self.join_type == "CONNECTED":
|
||||
parent_edit_bone: bpy.types.EditBone = context.active_bone
|
||||
child_edit_bones: Set[bpy.types.EditBone] = set(context.selected_bones)
|
||||
child_edit_bones.remove(parent_edit_bone)
|
||||
|
||||
child_edit_bone: bpy.types.EditBone
|
||||
for child_edit_bone in child_edit_bones:
|
||||
child_edit_bone.use_connect = True
|
||||
|
||||
bpy.ops.object.mode_set(mode="POSE")
|
||||
|
||||
|
||||
class ModelSeparateByBonesOperator(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.model_separate_by_bones"
|
||||
bl_label = "Model Separate by Bones"
|
||||
bl_description = "Separate MMD model into multiple models based on selected bones.\n\nWARNING: This operation will split meshes, armatures, rigid bodies and joints. To move models before separating, only adjust the root (cross under the model) transformation. Do not move armatures, meshes, rigid bodies, or joints directly before separating as they will not move together.\n\nIMPORTANT: Don't use any of the 'Assembly' functions before using this function. This function requires the model to be in a clean state."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
separate_armature: bpy.props.BoolProperty(name="Separate Armature", default=True)
|
||||
include_descendant_bones: bpy.props.BoolProperty(name="Include Descendant Bones", default=True)
|
||||
weight_threshold: bpy.props.FloatProperty(name="Weight Threshold", default=0.001, min=0.0, max=1.0, precision=4, subtype="FACTOR")
|
||||
boundary_joint_owner: bpy.props.EnumProperty(
|
||||
name="Boundary Joint Owner",
|
||||
items=[
|
||||
("SOURCE", "Source Model", ""),
|
||||
("DESTINATION", "Destination Model", ""),
|
||||
],
|
||||
default="DESTINATION",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context):
|
||||
active_object: Optional[bpy.types.Object] = context.active_object
|
||||
|
||||
if context.mode != "POSE":
|
||||
return False
|
||||
|
||||
if active_object is None:
|
||||
return False
|
||||
|
||||
if active_object.type != "ARMATURE":
|
||||
return False
|
||||
|
||||
if FnModel.find_root_object(active_object) is None:
|
||||
return False
|
||||
|
||||
return len(context.selected_pose_bones) > 0
|
||||
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def execute(self, context: bpy.types.Context):
|
||||
try:
|
||||
self.separate(context)
|
||||
except NoModelSelectedError as ex:
|
||||
self.report(type={"ERROR"}, message=str(ex))
|
||||
return {"CANCELLED"}
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
def separate(self, context: bpy.types.Context):
|
||||
weight_threshold: float = self.weight_threshold
|
||||
mmd_scale = 0.08
|
||||
|
||||
target_armature_object: bpy.types.Object = context.active_object
|
||||
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
root_bones: Set[bpy.types.EditBone] = set(context.selected_bones)
|
||||
if self.include_descendant_bones:
|
||||
original_active_bone = context.active_bone
|
||||
for edit_bone in root_bones:
|
||||
context.active_object.data.edit_bones.active = edit_bone
|
||||
bpy.ops.armature.select_similar(type="CHILDREN", threshold=0.1)
|
||||
self._select_related_ik_bones(target_armature_object)
|
||||
if original_active_bone:
|
||||
context.active_object.data.edit_bones.active = original_active_bone
|
||||
|
||||
separate_bones: Dict[str, bpy.types.EditBone] = {b.name: b for b in context.selected_bones}
|
||||
deform_bones: Dict[str, bpy.types.EditBone] = {b.name: b for b in target_armature_object.data.edit_bones if b.use_deform}
|
||||
mmd_root_object: bpy.types.Object = FnModel.find_root_object(context.active_object)
|
||||
mmd_model = Model(mmd_root_object)
|
||||
mmd_model_mesh_objects: List[bpy.types.Object] = list(mmd_model.meshes())
|
||||
mmd_model_mesh_objects = list(self._select_weighted_vertices(mmd_model_mesh_objects, separate_bones, deform_bones, weight_threshold).keys())
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
|
||||
# Store original transform matrix for root object
|
||||
original_matrix_world = mmd_root_object.matrix_world.copy()
|
||||
mmd_root_object.matrix_world = Matrix.Identity(4)
|
||||
|
||||
# Reset object visibility
|
||||
FnContext.set_active_and_select_single_object(context, mmd_root_object)
|
||||
bpy.ops.mmd_tools.reset_object_visibility()
|
||||
|
||||
# Clean additional transform
|
||||
FnContext.set_active_and_select_single_object(context, mmd_root_object)
|
||||
bpy.ops.mmd_tools.clean_additional_transform()
|
||||
|
||||
# Create new separate model first
|
||||
separate_model: Model = Model.create(mmd_root_object.mmd_root.name, mmd_root_object.mmd_root.name_e, mmd_scale, obj_name=mmd_root_object.name, add_root_bone=False)
|
||||
separate_model.initialDisplayFrames()
|
||||
separate_root_object = separate_model.rootObject()
|
||||
separate_root_object.matrix_world = mmd_root_object.matrix_world
|
||||
separate_model_armature_object = separate_model.armature()
|
||||
|
||||
# Now separate armature bones from original model
|
||||
separate_armature_object: Optional[bpy.types.Object] = None
|
||||
if self.separate_armature:
|
||||
FnContext.set_active_and_select_single_object(context, target_armature_object)
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
|
||||
# Re-select the bones that should be separated (they might have been deselected)
|
||||
for bone_name in separate_bones.keys():
|
||||
if bone_name in target_armature_object.data.edit_bones:
|
||||
target_armature_object.data.edit_bones[bone_name].select = True
|
||||
|
||||
bpy.ops.armature.separate()
|
||||
separate_armature_object = next(iter([a for a in context.selected_objects if a != target_armature_object and a.type == "ARMATURE"]), None)
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
|
||||
# Collect separate rigid bodies
|
||||
separate_rigid_bodies: Set[bpy.types.Object] = {rigid_body_object for rigid_body_object in mmd_model.rigidBodies() if rigid_body_object.mmd_rigid.bone in separate_bones}
|
||||
|
||||
boundary_joint_owner_condition = any if self.boundary_joint_owner == "DESTINATION" else all
|
||||
|
||||
# Collect separate joints
|
||||
separate_joints: Set[bpy.types.Object] = {
|
||||
joint_object
|
||||
for joint_object in mmd_model.joints()
|
||||
if boundary_joint_owner_condition(
|
||||
[
|
||||
joint_object.rigid_body_constraint.object1 in separate_rigid_bodies,
|
||||
joint_object.rigid_body_constraint.object2 in separate_rigid_bodies,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
separate_mesh_objects: List[bpy.types.Object] = []
|
||||
model2separate_mesh_objects: Dict[bpy.types.Object, bpy.types.Object] = {}
|
||||
if len(mmd_model_mesh_objects) > 0:
|
||||
# Find a single unique attribute name that doesn't conflict with any existing attributes.
|
||||
all_attribute_names = {attr.name for obj in mmd_model_mesh_objects for attr in obj.data.attributes}
|
||||
temp_normal_name = "mmd_temp_normal"
|
||||
i = 0
|
||||
while temp_normal_name in all_attribute_names:
|
||||
temp_normal_name = f"mmd_temp_normal.{i:03d}"
|
||||
i += 1
|
||||
|
||||
# Backup custom normals to the unique temporary attribute.
|
||||
for mesh_obj in mmd_model_mesh_objects:
|
||||
mesh_data = mesh_obj.data
|
||||
existing_custom_normal = mesh_data.attributes.get("custom_normal")
|
||||
if not existing_custom_normal:
|
||||
continue
|
||||
|
||||
if existing_custom_normal.data_type == "INT16_2D":
|
||||
normals_data = np.empty(len(mesh_data.loops) * 2, dtype=np.int16)
|
||||
existing_custom_normal.data.foreach_get("value", normals_data)
|
||||
temp_normal_attr = mesh_data.attributes.new(temp_normal_name, "INT16_2D", "CORNER")
|
||||
temp_normal_attr.data.foreach_set("value", normals_data)
|
||||
else:
|
||||
raise TypeError(f"Unsupported custom_normal data type: '{existing_custom_normal.data_type}'. Supported types: 'INT16_2D'")
|
||||
|
||||
# Select meshes
|
||||
obj: bpy.types.Object
|
||||
for obj in context.view_layer.objects:
|
||||
obj.select_set(obj in mmd_model_mesh_objects)
|
||||
context.view_layer.objects.active = mmd_model_mesh_objects[0]
|
||||
|
||||
# Separate mesh by selected vertices
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
bpy.ops.mesh.separate(type="SELECTED")
|
||||
separate_mesh_objects = [m for m in context.selected_objects if m.type == "MESH" and m not in mmd_model_mesh_objects]
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
|
||||
model2separate_mesh_objects = dict(zip(mmd_model_mesh_objects, separate_mesh_objects, strict=False))
|
||||
|
||||
# Restore normal data for all meshes (original and separated)
|
||||
all_mesh_objects = list(mmd_model_mesh_objects) + list(separate_mesh_objects)
|
||||
for mesh_obj in all_mesh_objects:
|
||||
mesh_data = mesh_obj.data
|
||||
temp_normal_attr = mesh_data.attributes.get(temp_normal_name)
|
||||
if not temp_normal_attr:
|
||||
continue
|
||||
|
||||
try:
|
||||
if temp_normal_attr.data_type == "INT16_2D":
|
||||
normals_data = np.empty(len(mesh_data.loops) * 2, dtype=np.int16)
|
||||
temp_normal_attr.data.foreach_get("value", normals_data)
|
||||
custom_normal_attr = mesh_data.attributes.get("custom_normal")
|
||||
if not custom_normal_attr:
|
||||
custom_normal_attr = mesh_data.attributes.new("custom_normal", "INT16_2D", "CORNER")
|
||||
custom_normal_attr.data.foreach_set("value", normals_data)
|
||||
else:
|
||||
raise TypeError(f"Unsupported custom_normal data type: '{temp_normal_attr.data_type}'. Supported types: 'INT16_2D'")
|
||||
finally:
|
||||
mesh_data.attributes.remove(temp_normal_attr)
|
||||
|
||||
if self.separate_armature and separate_armature_object:
|
||||
separate_armature_data = separate_armature_object.data
|
||||
with select_object(separate_model_armature_object, objects=[separate_model_armature_object, separate_armature_object]):
|
||||
bpy.ops.object.join()
|
||||
if separate_armature_data.users == 0:
|
||||
bpy.data.armatures.remove(separate_armature_data)
|
||||
|
||||
if separate_mesh_objects:
|
||||
with select_object(separate_model_armature_object, objects=[separate_model_armature_object] + separate_mesh_objects):
|
||||
bpy.ops.object.parent_set(type="OBJECT", keep_transform=True)
|
||||
|
||||
# Replace mesh armature modifier.object
|
||||
for separate_mesh in separate_mesh_objects:
|
||||
armature_modifier: Optional[bpy.types.ArmatureModifier] = next(iter([m for m in separate_mesh.modifiers if m.type == "ARMATURE"]), None)
|
||||
if armature_modifier is None:
|
||||
armature_modifier: bpy.types.ArmatureModifier = separate_mesh.modifiers.new("mmd_armature", "ARMATURE")
|
||||
|
||||
armature_modifier.object = separate_model_armature_object
|
||||
|
||||
if separate_rigid_bodies:
|
||||
with select_object(separate_model.rigidGroupObject(), objects=[separate_model.rigidGroupObject()] + list(separate_rigid_bodies)):
|
||||
bpy.ops.object.parent_set(type="OBJECT", keep_transform=True)
|
||||
|
||||
if separate_joints:
|
||||
with select_object(separate_model.jointGroupObject(), objects=[separate_model.jointGroupObject()] + list(separate_joints)):
|
||||
bpy.ops.object.parent_set(type="OBJECT", keep_transform=True)
|
||||
|
||||
# Move separate objects to new collection
|
||||
mmd_layer_collection = FnContext.find_user_layer_collection_by_object(context, mmd_root_object)
|
||||
assert mmd_layer_collection is not None
|
||||
|
||||
separate_layer_collection = FnContext.find_user_layer_collection_by_object(context, separate_root_object)
|
||||
assert separate_layer_collection is not None
|
||||
|
||||
if mmd_layer_collection.name != separate_layer_collection.name:
|
||||
for separate_object in itertools.chain(separate_mesh_objects, separate_rigid_bodies, separate_joints):
|
||||
if separate_object.name not in separate_layer_collection.collection.objects:
|
||||
separate_layer_collection.collection.objects.link(separate_object)
|
||||
if separate_object.name in mmd_layer_collection.collection.objects:
|
||||
mmd_layer_collection.collection.objects.unlink(separate_object)
|
||||
|
||||
FnModel.copy_mmd_root(
|
||||
separate_root_object,
|
||||
mmd_root_object,
|
||||
overwrite=True,
|
||||
replace_name2values={
|
||||
# Replace related_mesh property values
|
||||
"related_mesh": {m.data.name: s.data.name for m, s in model2separate_mesh_objects.items()},
|
||||
},
|
||||
)
|
||||
|
||||
# Apply additional transform
|
||||
FnContext.set_active_and_select_single_object(context, mmd_root_object)
|
||||
bpy.ops.mmd_tools.apply_additional_transform()
|
||||
FnContext.set_active_and_select_single_object(context, separate_root_object)
|
||||
bpy.ops.mmd_tools.apply_additional_transform()
|
||||
|
||||
# Restore original transform matrix for root object
|
||||
mmd_root_object.matrix_world = original_matrix_world
|
||||
separate_root_object.matrix_world = original_matrix_world
|
||||
|
||||
# End state
|
||||
FnContext.set_active_and_select_single_object(context, separate_root_object)
|
||||
|
||||
def _select_weighted_vertices(self, mmd_model_mesh_objects: List[bpy.types.Object], separate_bones: Dict[str, bpy.types.EditBone], deform_bones: Dict[str, bpy.types.EditBone], weight_threshold: float) -> Dict[bpy.types.Object, int]:
|
||||
mesh2selected_vertex_count: Dict[bpy.types.Object, int] = {}
|
||||
target_bmesh: bmesh.types.BMesh = bmesh.new()
|
||||
for mesh_object in mmd_model_mesh_objects:
|
||||
vertex_groups: bpy.types.VertexGroups = mesh_object.vertex_groups
|
||||
|
||||
mesh: bpy.types.Mesh = mesh_object.data
|
||||
target_bmesh.from_mesh(mesh, face_normals=False)
|
||||
target_bmesh.select_mode |= {"VERT"}
|
||||
deform_layer = target_bmesh.verts.layers.deform.verify()
|
||||
|
||||
selected_vertex_count = 0
|
||||
vert: bmesh.types.BMVert
|
||||
for vert in target_bmesh.verts:
|
||||
vert.select_set(False)
|
||||
|
||||
# Find the largest weight vertex group
|
||||
weights = [(group_index, weight) for group_index, weight in vert[deform_layer].items() if vertex_groups[group_index].name in deform_bones]
|
||||
|
||||
weights.sort(key=lambda i: vertex_groups[i[0]].name in separate_bones, reverse=True)
|
||||
weights.sort(key=itemgetter(1), reverse=True)
|
||||
group_index, weight = next(iter(weights), (0, -1))
|
||||
|
||||
if weight < weight_threshold:
|
||||
continue
|
||||
|
||||
if vertex_groups[group_index].name not in separate_bones:
|
||||
continue
|
||||
|
||||
selected_vertex_count += 1
|
||||
vert.select_set(True)
|
||||
|
||||
if selected_vertex_count > 0:
|
||||
mesh2selected_vertex_count[mesh_object] = selected_vertex_count
|
||||
target_bmesh.select_flush_mode()
|
||||
target_bmesh.to_mesh(mesh)
|
||||
|
||||
target_bmesh.clear()
|
||||
|
||||
return mesh2selected_vertex_count
|
||||
|
||||
def _select_related_ik_bones(self, armature_object: bpy.types.Object) -> None:
|
||||
"""
|
||||
Expand the current selection to include any full IK systems that are
|
||||
partially selected. An IK system includes the chain bones, the IK
|
||||
target bone, and the pole target bone.
|
||||
|
||||
NOTE: This method operates entirely in EDIT mode and avoids mode switching
|
||||
to prevent segmentation faults.
|
||||
"""
|
||||
edit_bones = armature_object.data.edit_bones
|
||||
initial_selection_names = {b.name for b in edit_bones if b.select}
|
||||
|
||||
# Access pose bones constraints directly without mode switching
|
||||
pose_bones = armature_object.pose.bones
|
||||
|
||||
# Find all complete IK systems
|
||||
ik_systems = []
|
||||
|
||||
for pose_bone in pose_bones:
|
||||
for constraint in pose_bone.constraints:
|
||||
if constraint.type == "IK":
|
||||
# Build the set of bones in this IK system
|
||||
system_bones = {pose_bone.name}
|
||||
|
||||
# Add the main IK Target bone
|
||||
if constraint.target and constraint.subtarget:
|
||||
system_bones.add(constraint.subtarget)
|
||||
|
||||
# Add the Pole Target bone
|
||||
if constraint.pole_target and constraint.pole_subtarget:
|
||||
system_bones.add(constraint.pole_subtarget)
|
||||
|
||||
# Add all other bones in the IK chain
|
||||
current_bone_name = pose_bone.name
|
||||
chain_count = constraint.chain_count
|
||||
|
||||
# Walk up the parent chain
|
||||
for _ in range(chain_count - 1):
|
||||
if current_bone_name not in edit_bones:
|
||||
break
|
||||
current_bone = edit_bones[current_bone_name]
|
||||
if not current_bone.parent:
|
||||
break
|
||||
current_bone_name = current_bone.parent.name
|
||||
system_bones.add(current_bone_name)
|
||||
|
||||
ik_systems.append(system_bones)
|
||||
|
||||
# Expand selection to include any related, full IK systems
|
||||
final_selection_names = set(initial_selection_names)
|
||||
for system in ik_systems:
|
||||
if not system.isdisjoint(initial_selection_names):
|
||||
final_selection_names.update(system)
|
||||
|
||||
# Apply the final selection
|
||||
for bone in edit_bones:
|
||||
bone.select = bone.name in final_selection_names
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,575 @@
|
||||
# Copyright 2015 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
import math
|
||||
from typing import Dict, Optional, Tuple, cast
|
||||
|
||||
import bpy
|
||||
from mathutils import Euler, Vector
|
||||
|
||||
from .. import utils
|
||||
from ..bpyutils import FnContext, Props
|
||||
from ..core import rigid_body
|
||||
from ..core.model import FnModel, Model
|
||||
from ..core.rigid_body import FnRigidBody
|
||||
|
||||
|
||||
class SelectRigidBody(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.rigid_body_select"
|
||||
bl_label = "Select Rigid Body"
|
||||
bl_description = "Select similar rigidbody objects which have the same property values with active rigidbody object"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
properties: bpy.props.EnumProperty(
|
||||
name="Properties",
|
||||
description="Select the properties to be compared",
|
||||
options={"ENUM_FLAG"},
|
||||
items=[
|
||||
("collision_group_number", "Collision Group", "Collision group", 1),
|
||||
("collision_group_mask", "Collision Group Mask", "Collision group mask", 2),
|
||||
("type", "Rigid Type", "Rigid type", 4),
|
||||
("shape", "Shape", "Collision shape", 8),
|
||||
("bone", "Bone", "Target bone", 16),
|
||||
],
|
||||
default=set(),
|
||||
)
|
||||
hide_others: bpy.props.BoolProperty(
|
||||
name="Hide Others",
|
||||
description="Hide the rigidbody object which does not have the same property values with active rigidbody object",
|
||||
default=False,
|
||||
)
|
||||
|
||||
def invoke(self, context, event):
|
||||
vm = context.window_manager
|
||||
return vm.invoke_props_dialog(self)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return FnModel.is_rigid_body_object(context.active_object)
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.active_object
|
||||
root = FnModel.find_root_object(obj)
|
||||
if root is None:
|
||||
self.report({"ERROR"}, "The model root can't be found")
|
||||
return {"CANCELLED"}
|
||||
|
||||
selection = set(FnModel.iterate_rigid_body_objects(root))
|
||||
|
||||
for prop_name in self.properties:
|
||||
prop_value = getattr(obj.mmd_rigid, prop_name)
|
||||
if prop_name == "collision_group_mask":
|
||||
prop_value = tuple(prop_value)
|
||||
for i in selection.copy():
|
||||
if tuple(i.mmd_rigid.collision_group_mask) != prop_value:
|
||||
selection.remove(i)
|
||||
if self.hide_others:
|
||||
i.select_set(False)
|
||||
i.hide_set(True)
|
||||
else:
|
||||
for i in selection.copy():
|
||||
if getattr(i.mmd_rigid, prop_name) != prop_value:
|
||||
selection.remove(i)
|
||||
if self.hide_others:
|
||||
i.select_set(False)
|
||||
i.hide_set(True)
|
||||
|
||||
for i in selection:
|
||||
i.hide_set(False)
|
||||
i.select_set(True)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class AddRigidBody(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.rigid_body_add"
|
||||
bl_label = "Add Rigid Body"
|
||||
bl_description = "Add Rigid Bodies to selected bones"
|
||||
bl_options = {"REGISTER", "UNDO", "PRESET", "INTERNAL"}
|
||||
|
||||
name_j: bpy.props.StringProperty(
|
||||
name="Name",
|
||||
description="The name of rigid body ($name_j means use the japanese name of target bone)",
|
||||
default="$name_j",
|
||||
)
|
||||
name_e: bpy.props.StringProperty(
|
||||
name="Name(Eng)",
|
||||
description="The english name of rigid body ($name_e means use the english name of target bone)",
|
||||
default="$name_e",
|
||||
)
|
||||
collision_group_number: bpy.props.IntProperty(
|
||||
name="Collision Group",
|
||||
description="The collision group of the object",
|
||||
min=0,
|
||||
max=15,
|
||||
)
|
||||
collision_group_mask: bpy.props.BoolVectorProperty(
|
||||
name="Collision Group Mask",
|
||||
description="The groups the object can not collide with",
|
||||
size=16,
|
||||
subtype="LAYER",
|
||||
)
|
||||
rigid_type: bpy.props.EnumProperty(
|
||||
name="Rigid Type",
|
||||
description="Select rigid type",
|
||||
items=[
|
||||
(str(rigid_body.MODE_STATIC), "Bone", "Rigid body's orientation completely determined by attached bone", 1),
|
||||
(str(rigid_body.MODE_DYNAMIC), "Physics", "Attached bone's orientation completely determined by rigid body", 2),
|
||||
(str(rigid_body.MODE_DYNAMIC_BONE), "Physics + Bone", "Bone determined by combination of parent and attached rigid body", 3),
|
||||
],
|
||||
)
|
||||
rigid_shape: bpy.props.EnumProperty(
|
||||
name="Shape",
|
||||
description="Select the collision shape",
|
||||
items=[
|
||||
("SPHERE", "Sphere", "", 1),
|
||||
("BOX", "Box", "", 2),
|
||||
("CAPSULE", "Capsule", "", 3),
|
||||
],
|
||||
)
|
||||
size: bpy.props.FloatVectorProperty(
|
||||
name="Size",
|
||||
description="Size of the object, the values will multiply the length of target bone",
|
||||
subtype="XYZ",
|
||||
size=3,
|
||||
min=0,
|
||||
default=[0.6, 0.6, 0.6],
|
||||
)
|
||||
mass: bpy.props.FloatProperty(
|
||||
name="Mass",
|
||||
description="How much the object 'weights' irrespective of gravity",
|
||||
min=0.001,
|
||||
default=1,
|
||||
)
|
||||
friction: bpy.props.FloatProperty(
|
||||
name="Friction",
|
||||
description="Resistance of object to movement",
|
||||
min=0,
|
||||
soft_max=1,
|
||||
default=0.5,
|
||||
)
|
||||
bounce: bpy.props.FloatProperty(
|
||||
name="Restitution",
|
||||
description="Tendency of object to bounce after colliding with another (0 = stays still, 1 = perfectly elastic)",
|
||||
min=0,
|
||||
soft_max=1,
|
||||
)
|
||||
linear_damping: bpy.props.FloatProperty(
|
||||
name="Linear Damping",
|
||||
description="Amount of linear velocity that is lost over time",
|
||||
min=0,
|
||||
max=1,
|
||||
default=0.04,
|
||||
)
|
||||
angular_damping: bpy.props.FloatProperty(
|
||||
name="Angular Damping",
|
||||
description="Amount of angular velocity that is lost over time",
|
||||
min=0,
|
||||
max=1,
|
||||
default=0.1,
|
||||
)
|
||||
|
||||
def __add_rigid_body(self, context: bpy.types.Context, root_object: bpy.types.Object, pose_bone: Optional[bpy.types.PoseBone] = None):
|
||||
name_j: str = self.name_j
|
||||
name_e: str = self.name_e
|
||||
size = self.size.copy()
|
||||
loc = Vector((0.0, 0.0, 0.0))
|
||||
rot = Euler((0.0, 0.0, 0.0))
|
||||
bone_name: Optional[str] = None
|
||||
|
||||
if pose_bone is None:
|
||||
size *= getattr(root_object, Props.empty_display_size)
|
||||
else:
|
||||
bone_name = pose_bone.name
|
||||
mmd_bone = pose_bone.mmd_bone
|
||||
name_j = name_j.replace("$name_j", mmd_bone.name_j or bone_name)
|
||||
name_e = name_e.replace("$name_e", mmd_bone.name_e or bone_name)
|
||||
|
||||
target_bone = pose_bone.bone
|
||||
loc = (target_bone.head_local + target_bone.tail_local) / 2
|
||||
rot = target_bone.matrix_local.to_euler("YXZ")
|
||||
rot.rotate_axis("X", math.pi / 2)
|
||||
|
||||
size *= target_bone.length
|
||||
if 1:
|
||||
pass # bypass resizing
|
||||
elif self.rigid_shape == "SPHERE":
|
||||
size.x *= 0.8
|
||||
elif self.rigid_shape == "BOX":
|
||||
size.x /= 3
|
||||
size.y /= 3
|
||||
size.z *= 0.8
|
||||
elif self.rigid_shape == "CAPSULE":
|
||||
size.x /= 3
|
||||
|
||||
return FnRigidBody.setup_rigid_body_object(
|
||||
obj=FnRigidBody.new_rigid_body_object(context, FnModel.ensure_rigid_group_object(context, root_object)),
|
||||
shape_type=rigid_body.shapeType(self.rigid_shape),
|
||||
location=loc,
|
||||
rotation=rot,
|
||||
size=size,
|
||||
dynamics_type=int(self.rigid_type),
|
||||
name=name_j,
|
||||
name_e=name_e,
|
||||
collision_group_number=self.collision_group_number,
|
||||
collision_group_mask=self.collision_group_mask,
|
||||
mass=self.mass,
|
||||
friction=self.friction,
|
||||
bounce=self.bounce,
|
||||
linear_damping=self.linear_damping,
|
||||
angular_damping=self.angular_damping,
|
||||
bone=bone_name,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
root_object = FnModel.find_root_object(context.active_object)
|
||||
if root_object is None:
|
||||
return False
|
||||
|
||||
armature_object = FnModel.find_armature_object(root_object)
|
||||
if armature_object is None:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
active_object = context.active_object
|
||||
|
||||
root_object = cast("bpy.types.Object", FnModel.find_root_object(active_object))
|
||||
armature_object = cast("bpy.types.Object", FnModel.find_armature_object(root_object))
|
||||
|
||||
if active_object != armature_object:
|
||||
FnContext.select_single_object(context, root_object).select_set(False)
|
||||
elif armature_object.mode != "POSE":
|
||||
bpy.ops.object.mode_set(mode="POSE")
|
||||
|
||||
selected_pose_bones = []
|
||||
if context.selected_pose_bones:
|
||||
selected_pose_bones = context.selected_pose_bones
|
||||
|
||||
armature_object.select_set(False)
|
||||
if len(selected_pose_bones) > 0:
|
||||
for pose_bone in selected_pose_bones:
|
||||
rigid = self.__add_rigid_body(context, root_object, pose_bone)
|
||||
rigid.select_set(True)
|
||||
else:
|
||||
rigid = self.__add_rigid_body(context, root_object)
|
||||
rigid.select_set(True)
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context, event):
|
||||
no_bone = True
|
||||
if context.selected_bones and len(context.selected_bones) > 0:
|
||||
no_bone = False
|
||||
elif context.selected_pose_bones and len(context.selected_pose_bones) > 0:
|
||||
no_bone = False
|
||||
|
||||
if no_bone:
|
||||
self.name_j = "Rigid"
|
||||
self.name_e = "Rigid_e"
|
||||
else:
|
||||
if self.name_j == "Rigid":
|
||||
self.name_j = "$name_j"
|
||||
if self.name_e == "Rigid_e":
|
||||
self.name_e = "$name_e"
|
||||
vm = context.window_manager
|
||||
return vm.invoke_props_dialog(self)
|
||||
|
||||
|
||||
class RemoveRigidBody(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.rigid_body_remove"
|
||||
bl_label = "Remove Rigid Body"
|
||||
bl_description = "Deletes the currently selected Rigid Body"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return FnModel.is_rigid_body_object(context.active_object)
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.active_object
|
||||
root = FnModel.find_root_object(obj)
|
||||
utils.selectAObject(obj) # ensure this is the only one object select
|
||||
bpy.ops.object.delete(use_global=True)
|
||||
if root:
|
||||
utils.selectAObject(root)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RigidBodyBake(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.ptcache_rigid_body_bake"
|
||||
bl_label = "Bake"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
def execute(self, context: bpy.types.Context):
|
||||
with context.temp_override(scene=context.scene, point_cache=context.scene.rigidbody_world.point_cache):
|
||||
bpy.ops.ptcache.bake("INVOKE_DEFAULT", bake=True)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RigidBodyDeleteBake(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.ptcache_rigid_body_delete_bake"
|
||||
bl_label = "Delete Bake"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
def execute(self, context: bpy.types.Context):
|
||||
with context.temp_override(scene=context.scene, point_cache=context.scene.rigidbody_world.point_cache):
|
||||
bpy.ops.ptcache.free_bake("INVOKE_DEFAULT")
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class AddJoint(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.joint_add"
|
||||
bl_label = "Add Joint"
|
||||
bl_description = "Add Joint(s) to selected rigidbody objects"
|
||||
bl_options = {"REGISTER", "UNDO", "PRESET", "INTERNAL"}
|
||||
|
||||
use_bone_rotation: bpy.props.BoolProperty(
|
||||
name="Use Bone Rotation",
|
||||
description="Match joint orientation to bone orientation if enabled",
|
||||
default=True,
|
||||
)
|
||||
limit_linear_lower: bpy.props.FloatVectorProperty(
|
||||
name="Limit Linear Lower",
|
||||
description="Lower limit of translation",
|
||||
subtype="XYZ",
|
||||
size=3,
|
||||
)
|
||||
limit_linear_upper: bpy.props.FloatVectorProperty(
|
||||
name="Limit Linear Upper",
|
||||
description="Upper limit of translation",
|
||||
subtype="XYZ",
|
||||
size=3,
|
||||
)
|
||||
limit_angular_lower: bpy.props.FloatVectorProperty(
|
||||
name="Limit Angular Lower",
|
||||
description="Lower limit of rotation",
|
||||
subtype="EULER",
|
||||
size=3,
|
||||
min=-math.pi * 2,
|
||||
max=math.pi * 2,
|
||||
default=[-math.pi / 4] * 3,
|
||||
)
|
||||
limit_angular_upper: bpy.props.FloatVectorProperty(
|
||||
name="Limit Angular Upper",
|
||||
description="Upper limit of rotation",
|
||||
subtype="EULER",
|
||||
size=3,
|
||||
min=-math.pi * 2,
|
||||
max=math.pi * 2,
|
||||
default=[math.pi / 4] * 3,
|
||||
)
|
||||
spring_linear: bpy.props.FloatVectorProperty(
|
||||
name="Spring(Linear)",
|
||||
description="Spring constant of movement",
|
||||
subtype="XYZ",
|
||||
size=3,
|
||||
min=0,
|
||||
)
|
||||
spring_angular: bpy.props.FloatVectorProperty(
|
||||
name="Spring(Angular)",
|
||||
description="Spring constant of rotation",
|
||||
subtype="XYZ",
|
||||
size=3,
|
||||
min=0,
|
||||
)
|
||||
|
||||
def __enumerate_rigid_pair(self, bone_map: Dict[bpy.types.Object, Optional[bpy.types.Bone]]):
|
||||
obj_seq = tuple(bone_map.keys())
|
||||
for rigid_a, bone_a in bone_map.items():
|
||||
for rigid_b, bone_b in bone_map.items():
|
||||
if bone_a and bone_b and bone_b.parent == bone_a:
|
||||
obj_seq = ()
|
||||
yield (rigid_a, rigid_b)
|
||||
if len(obj_seq) == 2:
|
||||
if obj_seq[1].mmd_rigid.type == str(rigid_body.MODE_STATIC):
|
||||
yield (obj_seq[1], obj_seq[0])
|
||||
else:
|
||||
yield obj_seq
|
||||
|
||||
def __add_joint(self, context: bpy.types.Context, root_object: bpy.types.Object, rigid_pair: Tuple[bpy.types.Object, bpy.types.Object], bone_map):
|
||||
loc: Optional[Vector] = None
|
||||
rot = Euler((0.0, 0.0, 0.0))
|
||||
rigid_a, rigid_b = rigid_pair
|
||||
bone_a = bone_map[rigid_a]
|
||||
bone_b = bone_map[rigid_b]
|
||||
if bone_a and bone_b:
|
||||
if bone_a.parent == bone_b:
|
||||
rigid_b, rigid_a = rigid_a, rigid_b
|
||||
bone_b, bone_a = bone_a, bone_b
|
||||
if bone_b.parent == bone_a:
|
||||
loc = bone_b.head_local
|
||||
if self.use_bone_rotation:
|
||||
rot = bone_b.matrix_local.to_euler("YXZ")
|
||||
rot.rotate_axis("X", math.pi / 2)
|
||||
if loc is None:
|
||||
loc = (rigid_a.location + rigid_b.location) / 2
|
||||
|
||||
name_j = rigid_b.mmd_rigid.name_j or rigid_b.name
|
||||
name_e = rigid_b.mmd_rigid.name_e or rigid_b.name
|
||||
|
||||
return FnRigidBody.setup_joint_object(
|
||||
obj=FnRigidBody.new_joint_object(context, FnModel.ensure_joint_group_object(context, root_object), FnModel.get_empty_display_size(root_object)),
|
||||
name=name_j,
|
||||
name_e=name_e,
|
||||
location=loc,
|
||||
rotation=rot,
|
||||
rigid_a=rigid_a,
|
||||
rigid_b=rigid_b,
|
||||
maximum_location=self.limit_linear_upper,
|
||||
minimum_location=self.limit_linear_lower,
|
||||
maximum_rotation=self.limit_angular_upper,
|
||||
minimum_rotation=self.limit_angular_lower,
|
||||
spring_linear=self.spring_linear,
|
||||
spring_angular=self.spring_angular,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
root_object = FnModel.find_root_object(context.active_object)
|
||||
if root_object is None:
|
||||
return False
|
||||
|
||||
armature_object = FnModel.find_armature_object(root_object)
|
||||
if armature_object is None:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
active_object = context.active_object
|
||||
root_object = cast("bpy.types.Object", FnModel.find_root_object(active_object))
|
||||
armature_object = cast("bpy.types.Object", FnModel.find_armature_object(root_object))
|
||||
bones = cast("bpy.types.Armature", armature_object.data).bones
|
||||
bone_map: Dict[bpy.types.Object, Optional[bpy.types.Bone]] = {r: bones.get(r.mmd_rigid.bone, None) for r in FnModel.iterate_rigid_body_objects(root_object) if r.select_get()}
|
||||
|
||||
if len(bone_map) < 2:
|
||||
self.report({"ERROR"}, "Please select two or more mmd rigid objects")
|
||||
return {"CANCELLED"}
|
||||
|
||||
FnContext.select_single_object(context, root_object).select_set(False)
|
||||
if context.scene.rigidbody_world is None:
|
||||
bpy.ops.rigidbody.world_add()
|
||||
|
||||
for pair in self.__enumerate_rigid_pair(bone_map):
|
||||
joint = self.__add_joint(context, root_object, pair, bone_map)
|
||||
joint.select_set(True)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context, event):
|
||||
vm = context.window_manager
|
||||
return vm.invoke_props_dialog(self)
|
||||
|
||||
|
||||
class RemoveJoint(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.joint_remove"
|
||||
bl_label = "Remove Joint"
|
||||
bl_description = "Deletes the currently selected Joint"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return FnModel.is_joint_object(context.active_object)
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.active_object
|
||||
root = FnModel.find_root_object(obj)
|
||||
utils.selectAObject(obj) # ensure this is the only one object select
|
||||
bpy.ops.object.delete(use_global=True)
|
||||
if root:
|
||||
utils.selectAObject(root)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class UpdateRigidBodyWorld(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.rigid_body_world_update"
|
||||
bl_label = "Update Rigid Body World"
|
||||
bl_description = "Update rigid body world and references of rigid body constraint according to current scene objects (experimental)"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@staticmethod
|
||||
def __get_rigid_body_world_objects():
|
||||
rigid_body.setRigidBodyWorldEnabled(True)
|
||||
rbw = bpy.context.scene.rigidbody_world
|
||||
if not rbw.collection:
|
||||
rbw.collection = bpy.data.collections.new("RigidBodyWorld")
|
||||
rbw.collection.use_fake_user = True
|
||||
if not rbw.constraints:
|
||||
rbw.constraints = bpy.data.collections.new("RigidBodyConstraints")
|
||||
rbw.constraints.use_fake_user = True
|
||||
|
||||
bpy.context.scene.rigidbody_world.substeps_per_frame = 6
|
||||
bpy.context.scene.rigidbody_world.solver_iterations = 10
|
||||
|
||||
return rbw.collection.objects, rbw.constraints.objects
|
||||
|
||||
def execute(self, context):
|
||||
scene = context.scene
|
||||
scene_objs = set(scene.objects)
|
||||
scene_objs.union(o for x in scene.objects if x.instance_type == "COLLECTION" and x.instance_collection for o in x.instance_collection.objects)
|
||||
|
||||
def _update_group(obj, group):
|
||||
if obj in scene_objs:
|
||||
if obj not in group.values():
|
||||
group.link(obj)
|
||||
return True
|
||||
if obj in group.values():
|
||||
group.unlink(obj)
|
||||
return False
|
||||
|
||||
def _references(obj):
|
||||
yield obj
|
||||
if getattr(obj, "proxy", None):
|
||||
yield from _references(obj.proxy)
|
||||
if getattr(obj, "override_library", None):
|
||||
yield from _references(obj.override_library.reference)
|
||||
|
||||
need_rebuild_physics = scene.rigidbody_world is None or scene.rigidbody_world.collection is None or scene.rigidbody_world.constraints is None
|
||||
rb_objs, rbc_objs = self.__get_rigid_body_world_objects()
|
||||
objects = bpy.data.objects
|
||||
table = {}
|
||||
|
||||
# Perhaps due to a bug in Blender,
|
||||
# when bpy.ops.rigidbody.world_remove(),
|
||||
# Object.rigid_body are removed,
|
||||
# but Object.rigid_body_constraint are retained.
|
||||
# Therefore, it must be checked with Object.mmd_type.
|
||||
for i in (x for x in objects if x.mmd_type == "RIGID_BODY"):
|
||||
if not _update_group(i, rb_objs):
|
||||
continue
|
||||
|
||||
rb_map = table.setdefault(FnModel.find_root_object(i), {})
|
||||
if i in rb_map: # means rb_map[i] will replace i
|
||||
rb_objs.unlink(i)
|
||||
continue
|
||||
for r in _references(i):
|
||||
rb_map[r] = i
|
||||
|
||||
# TODO Modify mmd_rigid to allow recovery of the remaining rigidbody parameters.
|
||||
# mass, friction, restitution, linear_dumping, angular_dumping
|
||||
|
||||
for i in (x for x in objects if x.rigid_body_constraint):
|
||||
if not _update_group(i, rbc_objs):
|
||||
continue
|
||||
|
||||
rbc, root_object = i.rigid_body_constraint, FnModel.find_root_object(i)
|
||||
rb_map = table.get(root_object, {})
|
||||
rbc.object1 = rb_map.get(rbc.object1, rbc.object1)
|
||||
rbc.object2 = rb_map.get(rbc.object2, rbc.object2)
|
||||
|
||||
if need_rebuild_physics:
|
||||
for root_object in scene.objects:
|
||||
if root_object.mmd_type != "ROOT":
|
||||
continue
|
||||
if not root_object.mmd_root.is_built:
|
||||
continue
|
||||
with FnContext.temp_override_active_layer_collection(context, root_object):
|
||||
Model(root_object).build()
|
||||
# After rebuild. First play. Will be crash!
|
||||
# But saved it before. Reload after crash. The play can be work.
|
||||
|
||||
return {"FINISHED"}
|
||||
@@ -0,0 +1,106 @@
|
||||
# Copyright 2018 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
from typing import Set
|
||||
|
||||
import bpy
|
||||
from bpy.types import Operator
|
||||
|
||||
from ..core.model import FnModel
|
||||
from ..core.sdef import FnSDEF
|
||||
|
||||
|
||||
def _get_target_objects(context):
|
||||
root_objects: Set[bpy.types.Object] = set()
|
||||
selected_objects: Set[bpy.types.Object] = set()
|
||||
for i in context.selected_objects:
|
||||
if i.type == "MESH":
|
||||
selected_objects.add(i)
|
||||
continue
|
||||
|
||||
root_object = FnModel.find_root_object(i)
|
||||
if root_object is None:
|
||||
continue
|
||||
if root_object in root_objects:
|
||||
continue
|
||||
|
||||
root_objects.add(root_object)
|
||||
|
||||
selected_objects |= set(FnModel.iterate_mesh_objects(root_object))
|
||||
return selected_objects, root_objects
|
||||
|
||||
|
||||
class ResetSDEFCache(Operator):
|
||||
bl_idname = "mmd_tools.sdef_cache_reset"
|
||||
bl_label = "Reset MMD SDEF cache"
|
||||
bl_description = "Reset MMD SDEF cache of selected objects and clean unused cache"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
def execute(self, context):
|
||||
target_meshes, _ = _get_target_objects(context)
|
||||
for i in target_meshes:
|
||||
FnSDEF.clear_cache(i)
|
||||
FnSDEF.clear_cache(unused_only=True)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BindSDEF(Operator):
|
||||
bl_idname = "mmd_tools.sdef_bind"
|
||||
bl_label = "Bind SDEF Driver"
|
||||
bl_description = "Bind MMD SDEF data of selected objects"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
mode: bpy.props.EnumProperty(
|
||||
name="Mode",
|
||||
description="Select mode",
|
||||
items=[
|
||||
("2", "Bulk", "Speed up with numpy (may be slower in some cases)", 2),
|
||||
("1", "Normal", "Normal mode", 1),
|
||||
("0", "- Auto -", "Select best mode by benchmark result", 0),
|
||||
],
|
||||
default="0",
|
||||
)
|
||||
use_skip: bpy.props.BoolProperty(
|
||||
name="Skip",
|
||||
description="Skip when the bones are not moving",
|
||||
default=True,
|
||||
)
|
||||
use_scale: bpy.props.BoolProperty(
|
||||
name="Scale",
|
||||
description="Support bone scaling (slow)",
|
||||
default=False,
|
||||
)
|
||||
|
||||
def invoke(self, context, event):
|
||||
vm = context.window_manager
|
||||
return vm.invoke_props_dialog(self)
|
||||
|
||||
# TODO: Utility Functionalize
|
||||
def execute(self, context):
|
||||
target_meshes, root_objects = _get_target_objects(context)
|
||||
|
||||
for r in root_objects:
|
||||
r.mmd_root.use_sdef = True
|
||||
|
||||
param = ((None, False, True)[int(self.mode)], self.use_skip, self.use_scale)
|
||||
count = sum(FnSDEF.bind(i, *param) for i in target_meshes)
|
||||
self.report({"INFO"}, f"Binded {count} of {len(target_meshes)} selected mesh(es)")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class UnbindSDEF(Operator):
|
||||
bl_idname = "mmd_tools.sdef_unbind"
|
||||
bl_label = "Unbind SDEF Driver"
|
||||
bl_description = "Unbind MMD SDEF data of selected objects"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
# TODO: Utility Functionalize
|
||||
def execute(self, context):
|
||||
target_meshes, root_objects = _get_target_objects(context)
|
||||
for i in target_meshes:
|
||||
FnSDEF.unbind(i)
|
||||
|
||||
for r in root_objects:
|
||||
r.mmd_root.use_sdef = False
|
||||
|
||||
return {"FINISHED"}
|
||||
@@ -0,0 +1,566 @@
|
||||
# Copyright 2021 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
import csv
|
||||
import os
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
import bpy
|
||||
|
||||
from ..core.model import FnModel, Model
|
||||
from ..core.translations import MMD_DATA_TYPE_TO_HANDLERS, FnTranslations
|
||||
from ..translations import DictionaryEnum
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..properties.translations import (
|
||||
MMDTranslation,
|
||||
MMDTranslationElement,
|
||||
MMDTranslationElementIndex,
|
||||
)
|
||||
|
||||
|
||||
class TranslateMMDModel(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.translate_mmd_model"
|
||||
bl_label = "Translate a MMD Model"
|
||||
bl_description = "Translate Japanese names of a MMD model"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
dictionary: bpy.props.EnumProperty(
|
||||
name="Dictionary",
|
||||
items=DictionaryEnum.get_dictionary_items,
|
||||
description="Translate names from Japanese to English using selected dictionary",
|
||||
)
|
||||
types: bpy.props.EnumProperty(
|
||||
name="Types",
|
||||
description="Select which parts will be translated",
|
||||
options={"ENUM_FLAG"},
|
||||
items=[
|
||||
("BONE", "Bones", "Bones", 1),
|
||||
("MORPH", "Morphs", "Morphs", 2),
|
||||
("MATERIAL", "Materials", "Materials", 4),
|
||||
("DISPLAY", "Display", "Display frames", 8),
|
||||
("PHYSICS", "Physics", "Rigidbodies and joints", 16),
|
||||
("INFO", "Information", "Model name and comments", 32),
|
||||
],
|
||||
default={
|
||||
"BONE",
|
||||
"MORPH",
|
||||
"MATERIAL",
|
||||
"DISPLAY",
|
||||
"PHYSICS",
|
||||
},
|
||||
)
|
||||
modes: bpy.props.EnumProperty(
|
||||
name="Modes",
|
||||
description="Select translation mode",
|
||||
options={"ENUM_FLAG"},
|
||||
items=[
|
||||
("MMD", "MMD Names", "Fill MMD English names", 1),
|
||||
("BLENDER", "Blender Names", "Translate blender names (experimental)", 2),
|
||||
],
|
||||
default={"MMD"},
|
||||
)
|
||||
use_morph_prefix: bpy.props.BoolProperty(
|
||||
name="Use Morph Prefix",
|
||||
description="Add/remove prefix to English name of morph",
|
||||
default=False,
|
||||
)
|
||||
overwrite: bpy.props.BoolProperty(
|
||||
name="Overwrite",
|
||||
description="Overwrite a translated English name",
|
||||
default=False,
|
||||
)
|
||||
allow_fails: bpy.props.BoolProperty(
|
||||
name="Allow Fails",
|
||||
description="Allow incompletely translated names",
|
||||
default=False,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
obj = context.active_object
|
||||
root = FnModel.find_root_object(obj)
|
||||
return obj is not None and obj in context.selected_objects and root is not None
|
||||
|
||||
def invoke(self, context, event):
|
||||
vm = context.window_manager
|
||||
return vm.invoke_props_dialog(self)
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
self.__translator = DictionaryEnum.get_translator(self.dictionary)
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to load dictionary: {e}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
obj = context.active_object
|
||||
root = FnModel.find_root_object(obj)
|
||||
rig = Model(root)
|
||||
|
||||
if "MMD" in self.modes:
|
||||
for i in self.types:
|
||||
getattr(self, f"translate_{i.lower()}")(rig)
|
||||
|
||||
if "BLENDER" in self.modes:
|
||||
self.translate_blender_names(rig)
|
||||
|
||||
translator = self.__translator
|
||||
txt = translator.save_fails()
|
||||
if translator.fails:
|
||||
self.report(
|
||||
{"WARNING"},
|
||||
"Failed to translate %d names, see '%s' in text editor"
|
||||
% (len(translator.fails), txt.name),
|
||||
)
|
||||
return {"FINISHED"}
|
||||
|
||||
def translate(self, name_j, name_e):
|
||||
if not self.overwrite and name_e and self.__translator.is_translated(name_e):
|
||||
return name_e
|
||||
if self.allow_fails:
|
||||
name_e = None
|
||||
return self.__translator.translate(name_j, name_e)
|
||||
|
||||
def translate_blender_names(self, rig: Model):
|
||||
if "BONE" in self.types:
|
||||
for b in rig.armature().pose.bones:
|
||||
rig.renameBone(b.name, self.translate(b.name, b.name))
|
||||
|
||||
if "MORPH" in self.types:
|
||||
for i in (x for x in rig.meshes() if x.data.shape_keys):
|
||||
for kb in i.data.shape_keys.key_blocks:
|
||||
kb.name = self.translate(kb.name, kb.name)
|
||||
|
||||
if "MATERIAL" in self.types:
|
||||
for m in (x for x in rig.materials() if x):
|
||||
m.name = self.translate(m.name, m.name)
|
||||
|
||||
if "DISPLAY" in self.types:
|
||||
g: bpy.types.BoneCollection
|
||||
for g in cast("bpy.types.Armature", rig.armature().data).collections:
|
||||
g.name = self.translate(g.name, g.name)
|
||||
|
||||
if "PHYSICS" in self.types:
|
||||
for i in rig.rigidBodies():
|
||||
i.name = self.translate(i.name, i.name)
|
||||
|
||||
for i in rig.joints():
|
||||
i.name = self.translate(i.name, i.name)
|
||||
|
||||
if "INFO" in self.types:
|
||||
objects = [rig.rootObject(), rig.armature()]
|
||||
objects.extend(rig.meshes())
|
||||
for i in objects:
|
||||
i.name = self.translate(i.name, i.name)
|
||||
|
||||
def translate_info(self, rig):
|
||||
mmd_root = rig.rootObject().mmd_root
|
||||
mmd_root.name_e = self.translate(mmd_root.name, mmd_root.name_e)
|
||||
|
||||
comment_text = bpy.data.texts.get(mmd_root.comment_text, None)
|
||||
comment_e_text = bpy.data.texts.get(mmd_root.comment_e_text, None)
|
||||
if comment_text and comment_e_text:
|
||||
comment_e = self.translate(
|
||||
comment_text.as_string(), comment_e_text.as_string(),
|
||||
)
|
||||
comment_e_text.from_string(comment_e)
|
||||
|
||||
def translate_bone(self, rig):
|
||||
bones = rig.armature().pose.bones
|
||||
for b in bones:
|
||||
if b.is_mmd_shadow_bone:
|
||||
continue
|
||||
b.mmd_bone.name_e = self.translate(b.mmd_bone.name_j, b.mmd_bone.name_e)
|
||||
|
||||
def translate_morph(self, rig):
|
||||
mmd_root = rig.rootObject().mmd_root
|
||||
attr_list = ("group", "vertex", "bone", "uv", "material")
|
||||
prefix_list = ("G_", "", "B_", "UV_", "M_")
|
||||
for attr, prefix in zip(attr_list, prefix_list, strict=False):
|
||||
for m in getattr(mmd_root, attr + "_morphs", []):
|
||||
m.name_e = self.translate(m.name, m.name_e)
|
||||
if not prefix:
|
||||
continue
|
||||
if self.use_morph_prefix:
|
||||
if not m.name_e.startswith(prefix):
|
||||
m.name_e = prefix + m.name_e
|
||||
elif m.name_e.startswith(prefix):
|
||||
m.name_e = m.name_e[len(prefix) :]
|
||||
|
||||
def translate_material(self, rig):
|
||||
for m in rig.materials():
|
||||
if m is None:
|
||||
continue
|
||||
m.mmd_material.name_e = self.translate(
|
||||
m.mmd_material.name_j, m.mmd_material.name_e,
|
||||
)
|
||||
|
||||
def translate_display(self, rig):
|
||||
mmd_root = rig.rootObject().mmd_root
|
||||
for f in mmd_root.display_item_frames:
|
||||
f.name_e = self.translate(f.name, f.name_e)
|
||||
|
||||
def translate_physics(self, rig):
|
||||
for i in rig.rigidBodies():
|
||||
i.mmd_rigid.name_e = self.translate(i.mmd_rigid.name_j, i.mmd_rigid.name_e)
|
||||
|
||||
for i in rig.joints():
|
||||
i.mmd_joint.name_e = self.translate(i.mmd_joint.name_j, i.mmd_joint.name_e)
|
||||
|
||||
|
||||
DEFAULT_SHOW_ROW_COUNT = 20
|
||||
|
||||
|
||||
class MMD_TOOLS_LOCAL_UL_MMDTranslationElementIndex(bpy.types.UIList):
|
||||
def draw_item(
|
||||
self,
|
||||
context,
|
||||
layout: bpy.types.UILayout,
|
||||
data,
|
||||
mmd_translation_element_index: "MMDTranslationElementIndex",
|
||||
icon,
|
||||
active_data,
|
||||
active_propname,
|
||||
index: int,
|
||||
):
|
||||
mmd_translation_element: MMDTranslationElement = data.translation_elements[
|
||||
mmd_translation_element_index.value
|
||||
]
|
||||
MMD_DATA_TYPE_TO_HANDLERS[mmd_translation_element.type].draw_item(
|
||||
layout, mmd_translation_element, index,
|
||||
)
|
||||
|
||||
|
||||
class RestoreMMDDataReferenceOperator(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.restore_mmd_translation_element_name"
|
||||
bl_label = "Restore this Name"
|
||||
bl_options = {"INTERNAL"}
|
||||
|
||||
index: bpy.props.IntProperty()
|
||||
prop_name: bpy.props.StringProperty()
|
||||
restore_value: bpy.props.StringProperty()
|
||||
|
||||
def execute(self, context: bpy.types.Context):
|
||||
root_object = FnModel.find_root_object(context.active_object)
|
||||
mmd_translation_element_index = (
|
||||
root_object.mmd_root.translation.filtered_translation_element_indices[
|
||||
self.index
|
||||
].value
|
||||
)
|
||||
mmd_translation_element = root_object.mmd_root.translation.translation_elements[
|
||||
mmd_translation_element_index
|
||||
]
|
||||
setattr(mmd_translation_element, self.prop_name, self.restore_value)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class GlobalTranslationPopup(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.global_translation_popup"
|
||||
bl_label = "Global Translation Popup"
|
||||
bl_options = {"INTERNAL", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
root = FnModel.find_root_object(context.active_object)
|
||||
return root is not None
|
||||
|
||||
def draw(self, _context):
|
||||
layout = self.layout
|
||||
mmd_translation = self._mmd_translation
|
||||
|
||||
col = layout.column(align=True)
|
||||
col.label(text="Filter", icon="FILTER")
|
||||
row = col.row()
|
||||
row.prop(mmd_translation, "filter_types")
|
||||
|
||||
group = row.row(align=True, heading="is Blank:")
|
||||
group.alignment = "RIGHT"
|
||||
group.prop(
|
||||
mmd_translation, "filter_japanese_blank", toggle=True, text="Japanese",
|
||||
)
|
||||
group.prop(mmd_translation, "filter_english_blank", toggle=True, text="English")
|
||||
|
||||
group = row.row(align=True)
|
||||
group.prop(
|
||||
mmd_translation,
|
||||
"filter_restorable",
|
||||
toggle=True,
|
||||
icon="FILE_REFRESH",
|
||||
icon_only=True,
|
||||
)
|
||||
group.prop(
|
||||
mmd_translation,
|
||||
"filter_selected",
|
||||
toggle=True,
|
||||
icon="RESTRICT_SELECT_OFF",
|
||||
icon_only=True,
|
||||
)
|
||||
group.prop(
|
||||
mmd_translation,
|
||||
"filter_visible",
|
||||
toggle=True,
|
||||
icon="HIDE_OFF",
|
||||
icon_only=True,
|
||||
)
|
||||
|
||||
col = layout.column(align=True)
|
||||
box = col.box().column(align=True)
|
||||
row = box.row(align=True)
|
||||
row.label(text="Select the target column for Batch Operations:", icon="TRACKER")
|
||||
row = box.row(align=True)
|
||||
row.label(text="", icon="BLANK1")
|
||||
row.prop(mmd_translation, "batch_operation_target", expand=True)
|
||||
row.label(text="", icon="RESTRICT_SELECT_OFF")
|
||||
row.label(text="", icon="HIDE_OFF")
|
||||
|
||||
if (
|
||||
len(mmd_translation.filtered_translation_element_indices)
|
||||
> DEFAULT_SHOW_ROW_COUNT
|
||||
):
|
||||
row.label(text="", icon="BLANK1")
|
||||
|
||||
col.template_list(
|
||||
"mmd_tools_UL_MMDTranslationElementIndex",
|
||||
"",
|
||||
mmd_translation,
|
||||
"filtered_translation_element_indices",
|
||||
mmd_translation,
|
||||
"filtered_translation_element_indices_active_index",
|
||||
rows=DEFAULT_SHOW_ROW_COUNT,
|
||||
)
|
||||
|
||||
box = layout.box().column(align=True)
|
||||
box.label(text="Batch Operation:", icon="MODIFIER")
|
||||
box.prop(mmd_translation, "batch_operation_script", text="", icon="SCRIPT")
|
||||
|
||||
box.separator()
|
||||
row = box.row()
|
||||
row.prop(
|
||||
mmd_translation,
|
||||
"batch_operation_script_preset",
|
||||
text="Preset",
|
||||
icon="CON_TRANSFORM_CACHE",
|
||||
)
|
||||
row.operator(ExecuteTranslationBatchOperator.bl_idname, text="Execute")
|
||||
|
||||
box.separator()
|
||||
translation_box = box.box().column(align=True)
|
||||
translation_box.label(text="Dictionaries:", icon="HELP")
|
||||
row = translation_box.row()
|
||||
row.prop(mmd_translation, "dictionary", text="to_english")
|
||||
|
||||
translation_box.separator()
|
||||
row = translation_box.row()
|
||||
row.prop(mmd_translation, "dictionary", text="replace")
|
||||
|
||||
# CSV import/export
|
||||
box.separator()
|
||||
translation_box = box.box().column(align=True)
|
||||
translation_box.label(text="CSV:", icon="FILE_TEXT")
|
||||
row = translation_box.row()
|
||||
row.operator(ImportTranslationCSVOperator.bl_idname, text="Import CSV")
|
||||
row.operator(ExportTranslationCSVOperator.bl_idname, text="Export CSV")
|
||||
|
||||
def invoke(self, context: bpy.types.Context, _event):
|
||||
root_object = FnModel.find_root_object(context.active_object)
|
||||
if root_object is None:
|
||||
return {"CANCELLED"}
|
||||
|
||||
mmd_translation: MMDTranslation = root_object.mmd_root.translation
|
||||
self._mmd_translation = mmd_translation
|
||||
FnTranslations.clear_data(mmd_translation)
|
||||
FnTranslations.collect_data(mmd_translation)
|
||||
FnTranslations.update_query(mmd_translation)
|
||||
|
||||
return context.window_manager.invoke_props_dialog(self, width=800)
|
||||
|
||||
def execute(self, context):
|
||||
root_object = FnModel.find_root_object(context.active_object)
|
||||
if root_object is None:
|
||||
return {"CANCELLED"}
|
||||
|
||||
FnTranslations.apply_translations(root_object)
|
||||
FnTranslations.clear_data(root_object.mmd_root.translation)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ExecuteTranslationBatchOperator(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.execute_translation_batch"
|
||||
bl_label = "Execute Translation Batch"
|
||||
bl_options = {"INTERNAL"}
|
||||
|
||||
def execute(self, context: bpy.types.Context):
|
||||
root = FnModel.find_root_object(context.active_object)
|
||||
if root is None:
|
||||
return {"CANCELLED"}
|
||||
|
||||
fails, text = FnTranslations.execute_translation_batch(root)
|
||||
if fails:
|
||||
self.report(
|
||||
{"WARNING"},
|
||||
"Failed to translate %d names, see '%s' in text editor"
|
||||
% (len(fails), text.name),
|
||||
)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ExportTranslationCSVOperator(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.export_translation_csv"
|
||||
bl_description = "Export CSV for external translation."
|
||||
bl_label = "Export Translation CSV"
|
||||
|
||||
filter_glob: bpy.props.StringProperty(default="*.csv", options={"HIDDEN"})
|
||||
filename_ext = ".csv"
|
||||
filepath: bpy.props.StringProperty(
|
||||
name="File Path",
|
||||
description="Path to save the translation CSV",
|
||||
subtype="FILE_PATH",
|
||||
default="mmd_translation.csv",
|
||||
)
|
||||
|
||||
def _ensure_csv_extension(self):
|
||||
"""Ensure the file path ends with a .csv extension (case-insensitive)."""
|
||||
if not self.filepath.lower().endswith(".csv"):
|
||||
self.filepath = bpy.path.ensure_ext(self.filepath, ".csv")
|
||||
|
||||
def invoke(self, context, event):
|
||||
self._ensure_csv_extension()
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def execute(self, context):
|
||||
self._ensure_csv_extension()
|
||||
root_object = FnModel.find_root_object(context.active_object)
|
||||
if root_object is None:
|
||||
self.report({"ERROR"}, "Root object not found")
|
||||
return {"CANCELLED"}
|
||||
|
||||
mmd_translation = root_object.mmd_root.translation
|
||||
|
||||
try:
|
||||
with open(self.filepath, "w", newline="", encoding="utf-8") as csvfile:
|
||||
writer = csv.writer(csvfile)
|
||||
writer.writerow(["type", "blender", "japanese", "english"])
|
||||
for idx in mmd_translation.filtered_translation_element_indices:
|
||||
element = mmd_translation.translation_elements[idx.value]
|
||||
writer.writerow(
|
||||
[element.type, element.name, element.name_j, element.name_e],
|
||||
)
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to write CSV: {e}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
self.report({"INFO"}, f"Exported to {os.path.basename(self.filepath)}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ImportTranslationCSVOperator(bpy.types.Operator):
|
||||
bl_idname = "mmd_tools.import_translation_csv"
|
||||
bl_description = "Import translated CSV."
|
||||
bl_label = "Import Translation CSV"
|
||||
|
||||
only_update_english_name: bpy.props.BoolProperty(
|
||||
name="Only Update English Name",
|
||||
description="(Enabled by default) Only update English name (name_e). otherwise, update all names when different",
|
||||
default=True,
|
||||
)
|
||||
|
||||
filter_glob: bpy.props.StringProperty(default="*.csv", options={"HIDDEN"})
|
||||
filepath: bpy.props.StringProperty(
|
||||
name="File Path",
|
||||
description="Path to import the translation CSV",
|
||||
subtype="FILE_PATH",
|
||||
default="*.csv",
|
||||
)
|
||||
|
||||
def invoke(self, context, event):
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def execute(self, context):
|
||||
root_object = FnModel.find_root_object(context.active_object)
|
||||
if root_object is None:
|
||||
self.report({"ERROR"}, "Root object not found")
|
||||
return {"CANCELLED"}
|
||||
|
||||
mmd_translation = root_object.mmd_root.translation
|
||||
updated_count = 0
|
||||
warnings = []
|
||||
|
||||
try:
|
||||
with open(self.filepath, encoding="utf-8") as csvfile:
|
||||
reader = csv.DictReader(csvfile)
|
||||
required_headers = {"blender", "japanese", "english"}
|
||||
if not required_headers.issubset(set(reader.fieldnames or [])):
|
||||
missing = required_headers - set(reader.fieldnames or [])
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
f"Missing required headers in CSV: {', '.join(missing)}",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
|
||||
visible_indices = [
|
||||
i.value
|
||||
for i in mmd_translation.filtered_translation_element_indices
|
||||
]
|
||||
translation_elements_list = list(mmd_translation.translation_elements)
|
||||
row_count = 0
|
||||
|
||||
for row in reader:
|
||||
if row_count >= len(visible_indices):
|
||||
row_count += 1
|
||||
continue
|
||||
|
||||
element = translation_elements_list[visible_indices[row_count]]
|
||||
|
||||
b_name = row.get("blender", "").strip()
|
||||
j_name = row.get("japanese", "").strip()
|
||||
e_name = row.get("english", "").strip()
|
||||
|
||||
updated = False
|
||||
if self.only_update_english_name:
|
||||
if element.name_e != e_name:
|
||||
element.name_e = e_name
|
||||
updated = True
|
||||
else:
|
||||
if element.name != b_name:
|
||||
element.name = b_name
|
||||
updated = True
|
||||
if element.name_j != j_name:
|
||||
element.name_j = j_name
|
||||
updated = True
|
||||
if element.name_e != e_name:
|
||||
element.name_e = e_name
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
updated_count += 1
|
||||
|
||||
row_count += 1
|
||||
|
||||
# Output warnings
|
||||
if row_count > len(visible_indices):
|
||||
warnings.append(
|
||||
f"{row_count - len(visible_indices)} extra lines in CSV! (ignored)",
|
||||
)
|
||||
elif row_count < len(visible_indices):
|
||||
warnings.append(
|
||||
f"{len(visible_indices) - row_count} missing lines in CSV! (aborted translation)",
|
||||
)
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to read CSV: {e}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
FnTranslations.update_query(mmd_translation)
|
||||
|
||||
msg = f"Imported {updated_count} entries from CSV"
|
||||
if warnings:
|
||||
for w in warnings:
|
||||
self.report({"WARNING"}, w)
|
||||
msg += " with warnings"
|
||||
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
@@ -0,0 +1,147 @@
|
||||
# Copyright 2014 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
import re
|
||||
|
||||
from bpy.types import Operator
|
||||
from mathutils import Matrix, Quaternion
|
||||
|
||||
|
||||
class _SetShadingBase:
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@staticmethod
|
||||
def _get_view3d_spaces(context):
|
||||
if getattr(context.area, "type", None) == "VIEW_3D":
|
||||
return (context.area.spaces[0],)
|
||||
return (area.spaces[0] for area in getattr(context.screen, "areas", ()) if area.type == "VIEW_3D")
|
||||
|
||||
@staticmethod
|
||||
def _reset_color_management(context, use_display_device=True):
|
||||
try:
|
||||
context.scene.display_settings.display_device = ("None", "sRGB")[use_display_device]
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _reset_material_shading(context, use_shadeless=False):
|
||||
for i in (x for x in context.scene.objects if x.type == "MESH" and x.mmd_type == "NONE"):
|
||||
for s in i.material_slots:
|
||||
if s.material is None:
|
||||
continue
|
||||
# use_nodes is deprecated in 5.0 but harmless to set
|
||||
s.material.use_nodes = False
|
||||
s.material.use_shadeless = use_shadeless
|
||||
|
||||
def execute(self, context):
|
||||
# Changed from BLENDER_EEVEE_NEXT to BLENDER_EEVEE for Blender 5.0
|
||||
context.scene.render.engine = "BLENDER_EEVEE"
|
||||
|
||||
shading_mode = getattr(self, "_shading_mode", None)
|
||||
for space in self._get_view3d_spaces(context):
|
||||
shading = space.shading
|
||||
shading.type = "SOLID"
|
||||
shading.light = "FLAT" if shading_mode == "SHADELESS" else "STUDIO"
|
||||
shading.color_type = "TEXTURE" if shading_mode else "MATERIAL"
|
||||
shading.show_object_outline = False
|
||||
shading.show_backface_culling = False
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class SetGLSLShading(Operator, _SetShadingBase):
|
||||
bl_idname = "mmd_tools.set_glsl_shading"
|
||||
bl_label = "GLSL View"
|
||||
bl_description = "Use GLSL shading with additional lighting"
|
||||
|
||||
_shading_mode = "GLSL"
|
||||
|
||||
|
||||
class SetShadelessGLSLShading(Operator, _SetShadingBase):
|
||||
bl_idname = "mmd_tools.set_shadeless_glsl_shading"
|
||||
bl_label = "Shadeless GLSL View"
|
||||
bl_description = "Use only toon shading"
|
||||
|
||||
_shading_mode = "SHADELESS"
|
||||
|
||||
|
||||
class ResetShading(Operator, _SetShadingBase):
|
||||
bl_idname = "mmd_tools.reset_shading"
|
||||
bl_label = "Reset View"
|
||||
bl_description = "Reset to default Blender shading"
|
||||
|
||||
|
||||
class FlipPose(Operator):
|
||||
bl_idname = "mmd_tools.flip_pose"
|
||||
bl_label = "Flip Pose"
|
||||
bl_description = "Apply the current pose of selected bones to matching bone on opposite side of X-Axis."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
# https://docs.blender.org/manual/en/dev/rigging/armatures/bones/editing/naming.html
|
||||
__LR_REGEX = [
|
||||
{"re": re.compile(r"^(.+)(RIGHT|LEFT)(\.\d+)?$", re.IGNORECASE), "lr": 1},
|
||||
{"re": re.compile(r"^(.+)([\.\- _])(L|R)(\.\d+)?$", re.IGNORECASE), "lr": 2},
|
||||
{"re": re.compile(r"^(LEFT|RIGHT)(.+)$", re.IGNORECASE), "lr": 0},
|
||||
{"re": re.compile(r"^(L|R)([\.\- _])(.+)$", re.IGNORECASE), "lr": 0},
|
||||
{"re": re.compile(r"^(.+)(左|右)(\.\d+)?$"), "lr": 1},
|
||||
{"re": re.compile(r"^(左|右)(.+)$"), "lr": 0},
|
||||
]
|
||||
__LR_MAP = {
|
||||
"RIGHT": "LEFT",
|
||||
"Right": "Left",
|
||||
"right": "left",
|
||||
"LEFT": "RIGHT",
|
||||
"Left": "Right",
|
||||
"left": "right",
|
||||
"L": "R",
|
||||
"l": "r",
|
||||
"R": "L",
|
||||
"r": "l",
|
||||
"左": "右",
|
||||
"右": "左",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def flip_name(cls, name):
|
||||
for regex in cls.__LR_REGEX:
|
||||
match = regex["re"].match(name)
|
||||
if match:
|
||||
groups = match.groups()
|
||||
lr = groups[regex["lr"]]
|
||||
if lr in cls.__LR_MAP:
|
||||
flip_lr = cls.__LR_MAP[lr]
|
||||
name = ""
|
||||
for i, s in enumerate(groups):
|
||||
if i == regex["lr"]:
|
||||
name += flip_lr
|
||||
elif s:
|
||||
name += s
|
||||
return name
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def __cmul(vec1, vec2):
|
||||
return type(vec1)([x * y for x, y in zip(vec1, vec2, strict=False)])
|
||||
|
||||
@staticmethod
|
||||
def __matrix_compose(loc, rot, scale):
|
||||
return (Matrix.Translation(loc) @ rot.to_matrix().to_4x4()) @ Matrix([(scale[0], 0, 0, 0), (0, scale[1], 0, 0), (0, 0, scale[2], 0), (0, 0, 0, 1)])
|
||||
|
||||
@classmethod
|
||||
def __flip_pose(cls, matrix_basis, bone_src, bone_dest):
|
||||
m = bone_dest.bone.matrix_local.to_3x3().transposed()
|
||||
mi = bone_src.bone.matrix_local.to_3x3().transposed().inverted() if bone_src != bone_dest else m.inverted()
|
||||
loc, rot, scale = matrix_basis.decompose()
|
||||
loc = cls.__cmul(mi @ loc, (-1, 1, 1))
|
||||
rot = cls.__cmul(Quaternion(mi @ rot.axis, rot.angle).normalized(), (1, 1, -1, -1))
|
||||
bone_dest.matrix_basis = cls.__matrix_compose(m @ loc, Quaternion(m @ rot.axis, rot.angle).normalized(), scale)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
obj = context.active_object
|
||||
return obj is not None and obj.type == "ARMATURE" and obj.mode == "POSE"
|
||||
|
||||
def execute(self, context):
|
||||
pose_bones = context.active_object.pose.bones
|
||||
for b, mat in [(x, x.matrix_basis.copy()) for x in context.selected_pose_bones]:
|
||||
self.__flip_pose(mat, b, pose_bones.get(self.flip_name(b.name), b))
|
||||
return {"FINISHED"}
|
||||
@@ -0,0 +1,34 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2014 MMD Tools authors
|
||||
# This file was originally part of the MMD Tools add-on for Blender
|
||||
# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools
|
||||
# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements.
|
||||
# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit.
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def patch_library_overridable(property: "bpy.props._PropertyDeferred") -> "bpy.props._PropertyDeferred":
|
||||
"""Apply recursively for each mmd_tools property class annotations.
|
||||
Args:
|
||||
property: The property to be patched.
|
||||
|
||||
Returns:
|
||||
The patched property.
|
||||
"""
|
||||
property.keywords.setdefault("override", set()).add("LIBRARY_OVERRIDABLE")
|
||||
|
||||
if property.function.__name__ not in {"PointerProperty", "CollectionProperty"}:
|
||||
return property
|
||||
|
||||
property_type = property.keywords["type"]
|
||||
# The __annotations__ cannot be inherited. Manually search for base classes.
|
||||
for inherited_type in (property_type, *property_type.__bases__):
|
||||
if not inherited_type.__module__.startswith("mmd_tools.properties"):
|
||||
continue
|
||||
for annotation in inherited_type.__annotations__.values():
|
||||
if not isinstance(annotation, bpy.props._PropertyDeferred):
|
||||
continue
|
||||
patch_library_overridable(annotation)
|
||||
|
||||
return property
|
||||
@@ -0,0 +1,283 @@
|
||||
# Copyright 2014 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
import bpy
|
||||
|
||||
from .. import utils
|
||||
from ..core import material
|
||||
from ..core.material import FnMaterial
|
||||
from ..core.model import FnModel
|
||||
from . import patch_library_overridable
|
||||
|
||||
|
||||
def _mmd_material_update_ambient_color(prop: "MMDMaterial", _context):
|
||||
FnMaterial(prop.id_data).update_ambient_color()
|
||||
|
||||
|
||||
def _mmd_material_update_diffuse_color(prop: "MMDMaterial", _context):
|
||||
FnMaterial(prop.id_data).update_diffuse_color()
|
||||
|
||||
|
||||
def _mmd_material_update_alpha(prop: "MMDMaterial", _context):
|
||||
FnMaterial(prop.id_data).update_alpha()
|
||||
|
||||
|
||||
def _mmd_material_update_specular_color(prop: "MMDMaterial", _context):
|
||||
FnMaterial(prop.id_data).update_specular_color()
|
||||
|
||||
|
||||
def _mmd_material_update_shininess(prop: "MMDMaterial", _context):
|
||||
FnMaterial(prop.id_data).update_shininess()
|
||||
|
||||
|
||||
def _mmd_material_update_is_double_sided(prop: "MMDMaterial", _context):
|
||||
FnMaterial(prop.id_data).update_is_double_sided()
|
||||
|
||||
|
||||
def _mmd_material_update_sphere_texture_type(prop: "MMDMaterial", context):
|
||||
FnMaterial(prop.id_data).update_sphere_texture_type(context.active_object)
|
||||
|
||||
|
||||
def _mmd_material_update_toon_texture(prop: "MMDMaterial", _context):
|
||||
FnMaterial(prop.id_data).update_toon_texture()
|
||||
|
||||
|
||||
def _mmd_material_update_enabled_drop_shadow(prop: "MMDMaterial", _context):
|
||||
FnMaterial(prop.id_data).update_drop_shadow()
|
||||
|
||||
|
||||
def _mmd_material_update_enabled_self_shadow_map(prop: "MMDMaterial", _context):
|
||||
FnMaterial(prop.id_data).update_self_shadow_map()
|
||||
|
||||
|
||||
def _mmd_material_update_enabled_self_shadow(prop: "MMDMaterial", _context):
|
||||
FnMaterial(prop.id_data).update_self_shadow()
|
||||
|
||||
|
||||
def _mmd_material_update_enabled_toon_edge(prop: "MMDMaterial", _context):
|
||||
FnMaterial(prop.id_data).update_enabled_toon_edge()
|
||||
|
||||
|
||||
def _mmd_material_update_edge_color(prop: "MMDMaterial", _context):
|
||||
FnMaterial(prop.id_data).update_edge_color()
|
||||
|
||||
|
||||
def _mmd_material_update_edge_weight(prop: "MMDMaterial", _context):
|
||||
FnMaterial(prop.id_data).update_edge_weight()
|
||||
|
||||
|
||||
def _mmd_material_get_name_j(prop: "MMDMaterial"):
|
||||
return prop.get("name_j", "")
|
||||
|
||||
|
||||
def _mmd_material_set_name_j(prop: "MMDMaterial", value: str):
|
||||
prop_value = value
|
||||
if prop_value and prop_value != prop.get("name_j"):
|
||||
root = FnModel.find_root_object(bpy.context.active_object)
|
||||
if root is None:
|
||||
prop_value = utils.unique_name(value, {mat.mmd_material.name_j for mat in bpy.data.materials})
|
||||
else:
|
||||
prop_value = utils.unique_name(value, {mat.mmd_material.name_j for mat in FnModel.iterate_materials(root)})
|
||||
|
||||
prop["name_j"] = prop_value
|
||||
|
||||
|
||||
# ===========================================
|
||||
# Property classes
|
||||
# ===========================================
|
||||
|
||||
|
||||
class MMDMaterial(bpy.types.PropertyGroup):
|
||||
"""マテリアル"""
|
||||
|
||||
name_j: bpy.props.StringProperty(
|
||||
name="Name",
|
||||
description="Japanese Name",
|
||||
default="",
|
||||
set=_mmd_material_set_name_j,
|
||||
get=_mmd_material_get_name_j,
|
||||
)
|
||||
|
||||
name_e: bpy.props.StringProperty(
|
||||
name="Name(Eng)",
|
||||
description="English Name",
|
||||
default="",
|
||||
)
|
||||
|
||||
material_id: bpy.props.IntProperty(
|
||||
name="Material ID",
|
||||
description="Unique ID for the reference of material morph",
|
||||
default=-1,
|
||||
min=-1,
|
||||
)
|
||||
|
||||
ambient_color: bpy.props.FloatVectorProperty(
|
||||
name="Ambient Color",
|
||||
description="Ambient color",
|
||||
subtype="COLOR",
|
||||
size=3,
|
||||
min=0,
|
||||
max=1,
|
||||
precision=3,
|
||||
step=0.1,
|
||||
default=[0.4, 0.4, 0.4],
|
||||
update=_mmd_material_update_ambient_color,
|
||||
)
|
||||
|
||||
diffuse_color: bpy.props.FloatVectorProperty(
|
||||
name="Diffuse Color",
|
||||
description="Diffuse color",
|
||||
subtype="COLOR",
|
||||
size=3,
|
||||
min=0,
|
||||
max=1,
|
||||
precision=3,
|
||||
step=0.1,
|
||||
default=[0.8, 0.8, 0.8],
|
||||
update=_mmd_material_update_diffuse_color,
|
||||
)
|
||||
|
||||
alpha: bpy.props.FloatProperty(
|
||||
name="Alpha",
|
||||
description="Alpha transparency",
|
||||
min=0,
|
||||
max=1,
|
||||
precision=3,
|
||||
step=0.1,
|
||||
default=1.0,
|
||||
update=_mmd_material_update_alpha,
|
||||
)
|
||||
|
||||
specular_color: bpy.props.FloatVectorProperty(
|
||||
name="Specular Color",
|
||||
description="Specular color",
|
||||
subtype="COLOR",
|
||||
size=3,
|
||||
min=0,
|
||||
max=1,
|
||||
precision=3,
|
||||
step=0.1,
|
||||
default=[0.625, 0.625, 0.625],
|
||||
update=_mmd_material_update_specular_color,
|
||||
)
|
||||
|
||||
shininess: bpy.props.FloatProperty(
|
||||
name="Reflect",
|
||||
description="Sharpness of reflected highlights",
|
||||
min=0,
|
||||
soft_max=512,
|
||||
step=100.0,
|
||||
default=50.0,
|
||||
update=_mmd_material_update_shininess,
|
||||
)
|
||||
|
||||
is_double_sided: bpy.props.BoolProperty(
|
||||
name="Double Sided",
|
||||
description="Both sides of mesh should be rendered",
|
||||
default=False,
|
||||
update=_mmd_material_update_is_double_sided,
|
||||
)
|
||||
|
||||
enabled_drop_shadow: bpy.props.BoolProperty(
|
||||
name="Ground Shadow",
|
||||
description="Display ground shadow",
|
||||
default=True,
|
||||
update=_mmd_material_update_enabled_drop_shadow,
|
||||
)
|
||||
|
||||
enabled_self_shadow_map: bpy.props.BoolProperty(
|
||||
name="Self Shadow Map",
|
||||
description="Object can become shadowed by other objects",
|
||||
default=True,
|
||||
update=_mmd_material_update_enabled_self_shadow_map,
|
||||
)
|
||||
|
||||
enabled_self_shadow: bpy.props.BoolProperty(
|
||||
name="Self Shadow",
|
||||
description="Object can cast shadows",
|
||||
default=True,
|
||||
update=_mmd_material_update_enabled_self_shadow,
|
||||
)
|
||||
|
||||
enabled_toon_edge: bpy.props.BoolProperty(
|
||||
name="Toon Edge",
|
||||
description="Use toon edge",
|
||||
default=False,
|
||||
update=_mmd_material_update_enabled_toon_edge,
|
||||
)
|
||||
|
||||
edge_color: bpy.props.FloatVectorProperty(
|
||||
name="Edge Color",
|
||||
description="Toon edge color",
|
||||
subtype="COLOR",
|
||||
size=4,
|
||||
min=0,
|
||||
max=1,
|
||||
precision=3,
|
||||
step=0.1,
|
||||
default=[0, 0, 0, 1],
|
||||
update=_mmd_material_update_edge_color,
|
||||
)
|
||||
|
||||
edge_weight: bpy.props.FloatProperty(
|
||||
name="Edge Weight",
|
||||
description="Toon edge size",
|
||||
min=0,
|
||||
max=100,
|
||||
soft_max=2,
|
||||
step=1.0,
|
||||
default=1.0,
|
||||
update=_mmd_material_update_edge_weight,
|
||||
)
|
||||
|
||||
sphere_texture_type: bpy.props.EnumProperty(
|
||||
name="Sphere Map Type",
|
||||
description="Choose sphere texture blend type",
|
||||
items=[
|
||||
(str(material.SPHERE_MODE_OFF), "Off", "", 1),
|
||||
(str(material.SPHERE_MODE_MULT), "Multiply", "", 2),
|
||||
(str(material.SPHERE_MODE_ADD), "Add", "", 3),
|
||||
(str(material.SPHERE_MODE_SUBTEX), "SubTexture", "", 4),
|
||||
],
|
||||
update=_mmd_material_update_sphere_texture_type,
|
||||
)
|
||||
|
||||
is_shared_toon_texture: bpy.props.BoolProperty(
|
||||
name="Use Shared Toon Texture",
|
||||
description="Use shared toon texture or custom toon texture",
|
||||
default=False,
|
||||
update=_mmd_material_update_toon_texture,
|
||||
)
|
||||
|
||||
toon_texture: bpy.props.StringProperty(
|
||||
name="Toon Texture",
|
||||
subtype="FILE_PATH",
|
||||
description="The file path of custom toon texture",
|
||||
default="",
|
||||
update=_mmd_material_update_toon_texture,
|
||||
)
|
||||
|
||||
shared_toon_texture: bpy.props.IntProperty(
|
||||
name="Shared Toon Texture",
|
||||
description="Shared toon texture id (toon01.bmp ~ toon10.bmp)",
|
||||
default=0,
|
||||
min=0,
|
||||
max=9,
|
||||
update=_mmd_material_update_toon_texture,
|
||||
)
|
||||
|
||||
comment: bpy.props.StringProperty(
|
||||
name="Comment",
|
||||
description="Comment",
|
||||
)
|
||||
|
||||
def is_id_unique(self):
|
||||
return self.material_id < 0 or not next((m for m in bpy.data.materials if m.mmd_material != self and m.mmd_material.material_id == self.material_id), None)
|
||||
|
||||
@staticmethod
|
||||
def register():
|
||||
bpy.types.Material.mmd_material = patch_library_overridable(bpy.props.PointerProperty(type=MMDMaterial))
|
||||
|
||||
@staticmethod
|
||||
def unregister():
|
||||
del bpy.types.Material.mmd_material
|
||||
@@ -0,0 +1,485 @@
|
||||
# Copyright 2015 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
import bpy
|
||||
|
||||
from .. import utils
|
||||
from ..core.bone import FnBone
|
||||
from ..core.material import FnMaterial
|
||||
from ..core.model import FnModel, Model
|
||||
from ..core.morph import FnMorph
|
||||
|
||||
|
||||
def _morph_base_get_name(prop: "_MorphBase") -> str:
|
||||
return prop.get("name", "")
|
||||
|
||||
|
||||
def _morph_base_set_name(prop: "_MorphBase", value: str):
|
||||
mmd_root = prop.id_data.mmd_root
|
||||
# morph_type = mmd_root.active_morph_type
|
||||
morph_type = f"{prop.bl_rna.identifier[:-5].lower()}_morphs"
|
||||
# assert(prop.bl_rna.identifier.endswith('Morph'))
|
||||
# logging.debug('_set_name: %s %s %s', prop, value, morph_type)
|
||||
prop_name = prop.get("name", None)
|
||||
if prop_name == value:
|
||||
return
|
||||
|
||||
used_names = {x.name for x in getattr(mmd_root, morph_type) if x != prop}
|
||||
value = utils.unique_name(value, used_names)
|
||||
if prop_name is not None:
|
||||
if morph_type == "vertex_morphs":
|
||||
kb_list = {}
|
||||
for mesh in FnModel.iterate_mesh_objects(prop.id_data):
|
||||
for kb in getattr(mesh.data.shape_keys, "key_blocks", ()):
|
||||
kb_list.setdefault(kb.name, []).append(kb)
|
||||
|
||||
if prop_name in kb_list:
|
||||
value = utils.unique_name(value, used_names | kb_list.keys())
|
||||
for kb in kb_list[prop_name]:
|
||||
kb.name = value
|
||||
|
||||
elif morph_type == "uv_morphs":
|
||||
vg_list = {}
|
||||
for mesh in FnModel.iterate_mesh_objects(prop.id_data):
|
||||
for vg, n, x in FnMorph.get_uv_morph_vertex_groups(mesh):
|
||||
vg_list.setdefault(n, []).append(vg)
|
||||
|
||||
if prop_name in vg_list:
|
||||
value = utils.unique_name(value, used_names | vg_list.keys())
|
||||
for vg in vg_list[prop_name]:
|
||||
vg.name = vg.name.replace(prop_name, value)
|
||||
|
||||
if 1: # morph_type != 'group_morphs':
|
||||
for m in mmd_root.group_morphs:
|
||||
for d in m.data:
|
||||
if d.name == prop_name and d.morph_type == morph_type:
|
||||
d.name = value
|
||||
|
||||
frame_facial = mmd_root.display_item_frames.get("表情")
|
||||
for item in getattr(frame_facial, "data", []):
|
||||
if item.name == prop_name and item.morph_type == morph_type:
|
||||
item.name = value
|
||||
break
|
||||
|
||||
obj = Model(prop.id_data).morph_slider.placeholder()
|
||||
if obj and value not in obj.data.shape_keys.key_blocks:
|
||||
kb = obj.data.shape_keys.key_blocks.get(prop_name, None)
|
||||
if kb:
|
||||
kb.name = value
|
||||
|
||||
prop["name"] = value
|
||||
|
||||
|
||||
class _MorphBase:
|
||||
name: bpy.props.StringProperty(
|
||||
name="Name",
|
||||
description="Japanese Name",
|
||||
set=_morph_base_set_name,
|
||||
get=_morph_base_get_name,
|
||||
)
|
||||
name_e: bpy.props.StringProperty(
|
||||
name="Name(Eng)",
|
||||
description="English Name",
|
||||
default="",
|
||||
)
|
||||
category: bpy.props.EnumProperty(
|
||||
name="Category",
|
||||
description="Select category",
|
||||
items=[
|
||||
("SYSTEM", "Hidden", "", 0),
|
||||
("EYEBROW", "Eye Brow", "", 1),
|
||||
("EYE", "Eye", "", 2),
|
||||
("MOUTH", "Mouth", "", 3),
|
||||
("OTHER", "Other", "", 4),
|
||||
],
|
||||
default="OTHER",
|
||||
)
|
||||
|
||||
|
||||
def _bone_morph_data_update_bone_id(prop: "BoneMorphData", context: bpy.types.Context):
|
||||
pass # Empty function is sufficient to trigger UI update
|
||||
|
||||
|
||||
def _bone_morph_data_get_bone(prop: "BoneMorphData") -> str:
|
||||
bone_id = prop.get("bone_id", -1)
|
||||
if bone_id < 0:
|
||||
return ""
|
||||
root_object = prop.id_data
|
||||
armature_object = FnModel.find_armature_object(root_object)
|
||||
if armature_object is None:
|
||||
return ""
|
||||
pose_bone = FnBone.find_pose_bone_by_bone_id(armature_object, bone_id)
|
||||
if pose_bone is None:
|
||||
return ""
|
||||
return pose_bone.name
|
||||
|
||||
|
||||
def _bone_morph_data_set_bone(prop: "BoneMorphData", value: str):
|
||||
root = prop.id_data
|
||||
arm = FnModel.find_armature_object(root)
|
||||
|
||||
# Load the library_override file. This function is triggered when loading, but the arm obj cannot be found.
|
||||
# The arm obj is exist, but the relative relationship has not yet been established.
|
||||
if arm is None:
|
||||
return
|
||||
|
||||
if value not in arm.pose.bones.keys():
|
||||
prop.bone_id = -1
|
||||
return
|
||||
pose_bone = arm.pose.bones[value]
|
||||
prop.bone_id = FnBone.get_or_assign_bone_id(pose_bone)
|
||||
|
||||
|
||||
def _bone_morph_data_update_location_or_rotation(prop: "BoneMorphData", _context):
|
||||
if not prop.name.startswith("mmd_bind"):
|
||||
return
|
||||
arm = FnModel(prop.id_data).morph_slider.dummy_armature
|
||||
if arm:
|
||||
bone = arm.pose.bones.get(prop.name, None)
|
||||
if bone:
|
||||
bone.location = prop.location
|
||||
bone.rotation_quaternion = prop.rotation.__class__(*prop.rotation.to_axis_angle()) # Fix for consistency
|
||||
|
||||
|
||||
class BoneMorphData(bpy.types.PropertyGroup):
|
||||
bone: bpy.props.StringProperty(
|
||||
name="Bone",
|
||||
description="Target bone",
|
||||
set=_bone_morph_data_set_bone,
|
||||
get=_bone_morph_data_get_bone,
|
||||
)
|
||||
|
||||
bone_id: bpy.props.IntProperty(
|
||||
name="Bone ID",
|
||||
update=_bone_morph_data_update_bone_id,
|
||||
)
|
||||
|
||||
location: bpy.props.FloatVectorProperty(
|
||||
name="Location",
|
||||
description="Location",
|
||||
subtype="TRANSLATION",
|
||||
size=3,
|
||||
default=[0, 0, 0],
|
||||
update=_bone_morph_data_update_location_or_rotation,
|
||||
)
|
||||
|
||||
rotation: bpy.props.FloatVectorProperty(
|
||||
name="Rotation",
|
||||
description="Rotation in quaternions",
|
||||
subtype="QUATERNION",
|
||||
size=4,
|
||||
default=[1, 0, 0, 0],
|
||||
update=_bone_morph_data_update_location_or_rotation,
|
||||
)
|
||||
|
||||
|
||||
class BoneMorph(_MorphBase, bpy.types.PropertyGroup):
|
||||
"""Bone Morph"""
|
||||
|
||||
data: bpy.props.CollectionProperty(
|
||||
name="Morph Data",
|
||||
type=BoneMorphData,
|
||||
)
|
||||
active_data: bpy.props.IntProperty(
|
||||
name="Active Bone Data",
|
||||
min=0,
|
||||
default=0,
|
||||
)
|
||||
|
||||
|
||||
def _material_morph_data_get_material(prop: "MaterialMorphData"):
|
||||
mat_data = prop.get("material_data", None)
|
||||
if mat_data is not None:
|
||||
return mat_data.name
|
||||
return ""
|
||||
|
||||
|
||||
def _material_morph_data_set_material(prop: "MaterialMorphData", value: str):
|
||||
if value not in bpy.data.materials:
|
||||
prop.material_data = None
|
||||
prop.material_id = -1
|
||||
else:
|
||||
mat = bpy.data.materials[value]
|
||||
fnMat = FnMaterial(mat)
|
||||
prop.material_data = mat
|
||||
prop.material_id = fnMat.material_id
|
||||
|
||||
|
||||
def _material_morph_data_set_related_mesh(prop: "MaterialMorphData", value: str):
|
||||
mesh = FnModel.find_mesh_object_by_name(prop.id_data, value)
|
||||
if mesh is not None:
|
||||
prop.related_mesh_data = mesh.data
|
||||
else:
|
||||
prop.related_mesh_data = None
|
||||
|
||||
|
||||
def _material_morph_data_get_related_mesh(prop):
|
||||
mesh_data = prop.get("related_mesh_data", None)
|
||||
if mesh_data is not None:
|
||||
return mesh_data.name
|
||||
return ""
|
||||
|
||||
|
||||
def _material_morph_data_update_modifiable_values(prop: "MaterialMorphData", _context):
|
||||
if not prop.name.startswith("mmd_bind"):
|
||||
return
|
||||
from ..core.shader import _MaterialMorph
|
||||
|
||||
mat_data = prop.get("material_data", None)
|
||||
if mat_data is not None:
|
||||
_MaterialMorph.update_morph_inputs(mat_data, prop)
|
||||
else:
|
||||
for mat_data in FnModel(prop.id_data).materials():
|
||||
_MaterialMorph.update_morph_inputs(mat_data, prop)
|
||||
|
||||
|
||||
class MaterialMorphData(bpy.types.PropertyGroup):
|
||||
related_mesh: bpy.props.StringProperty(
|
||||
name="Related Mesh",
|
||||
description="Stores a reference to the mesh where this morph data belongs to",
|
||||
set=_material_morph_data_set_related_mesh,
|
||||
get=_material_morph_data_get_related_mesh,
|
||||
)
|
||||
|
||||
related_mesh_data: bpy.props.PointerProperty(
|
||||
name="Related Mesh Data",
|
||||
type=bpy.types.Mesh,
|
||||
)
|
||||
|
||||
offset_type: bpy.props.EnumProperty(name="Offset Type", description="Select offset type", items=[("MULT", "Multiply", "", 0), ("ADD", "Add", "", 1)], default="ADD")
|
||||
|
||||
material: bpy.props.StringProperty(
|
||||
name="Material",
|
||||
description="Target material",
|
||||
get=_material_morph_data_get_material,
|
||||
set=_material_morph_data_set_material,
|
||||
)
|
||||
|
||||
material_id: bpy.props.IntProperty(
|
||||
name="Material ID",
|
||||
default=-1,
|
||||
)
|
||||
|
||||
material_data: bpy.props.PointerProperty(
|
||||
name="Material Data",
|
||||
type=bpy.types.Material,
|
||||
)
|
||||
|
||||
diffuse_color: bpy.props.FloatVectorProperty(
|
||||
name="Diffuse Color",
|
||||
description="Diffuse color",
|
||||
subtype="COLOR",
|
||||
size=4,
|
||||
soft_min=0,
|
||||
soft_max=1,
|
||||
precision=3,
|
||||
step=0.1,
|
||||
default=[0, 0, 0, 1],
|
||||
update=_material_morph_data_update_modifiable_values,
|
||||
)
|
||||
|
||||
specular_color: bpy.props.FloatVectorProperty(
|
||||
name="Specular Color",
|
||||
description="Specular color",
|
||||
subtype="COLOR",
|
||||
size=3,
|
||||
soft_min=0,
|
||||
soft_max=1,
|
||||
precision=3,
|
||||
step=0.1,
|
||||
default=[0, 0, 0],
|
||||
update=_material_morph_data_update_modifiable_values,
|
||||
)
|
||||
|
||||
shininess: bpy.props.FloatProperty(
|
||||
name="Reflect",
|
||||
description="Reflect",
|
||||
soft_min=0,
|
||||
soft_max=500,
|
||||
step=100.0,
|
||||
default=0.0,
|
||||
update=_material_morph_data_update_modifiable_values,
|
||||
)
|
||||
|
||||
ambient_color: bpy.props.FloatVectorProperty(
|
||||
name="Ambient Color",
|
||||
description="Ambient color",
|
||||
subtype="COLOR",
|
||||
size=3,
|
||||
soft_min=0,
|
||||
soft_max=1,
|
||||
precision=3,
|
||||
step=0.1,
|
||||
default=[0, 0, 0],
|
||||
update=_material_morph_data_update_modifiable_values,
|
||||
)
|
||||
|
||||
edge_color: bpy.props.FloatVectorProperty(
|
||||
name="Edge Color",
|
||||
description="Edge color",
|
||||
subtype="COLOR",
|
||||
size=4,
|
||||
soft_min=0,
|
||||
soft_max=1,
|
||||
precision=3,
|
||||
step=0.1,
|
||||
default=[0, 0, 0, 1],
|
||||
update=_material_morph_data_update_modifiable_values,
|
||||
)
|
||||
|
||||
edge_weight: bpy.props.FloatProperty(
|
||||
name="Edge Weight",
|
||||
description="Edge weight",
|
||||
soft_min=0,
|
||||
soft_max=2,
|
||||
step=0.1,
|
||||
default=0,
|
||||
update=_material_morph_data_update_modifiable_values,
|
||||
)
|
||||
|
||||
texture_factor: bpy.props.FloatVectorProperty(
|
||||
name="Texture factor",
|
||||
description="Texture factor",
|
||||
subtype="COLOR",
|
||||
size=4,
|
||||
soft_min=0,
|
||||
soft_max=1,
|
||||
precision=3,
|
||||
step=0.1,
|
||||
default=[0, 0, 0, 1],
|
||||
update=_material_morph_data_update_modifiable_values,
|
||||
)
|
||||
|
||||
sphere_texture_factor: bpy.props.FloatVectorProperty(
|
||||
name="Sphere Texture factor",
|
||||
description="Sphere texture factor",
|
||||
subtype="COLOR",
|
||||
size=4,
|
||||
soft_min=0,
|
||||
soft_max=1,
|
||||
precision=3,
|
||||
step=0.1,
|
||||
default=[0, 0, 0, 1],
|
||||
update=_material_morph_data_update_modifiable_values,
|
||||
)
|
||||
|
||||
toon_texture_factor: bpy.props.FloatVectorProperty(
|
||||
name="Toon Texture factor",
|
||||
description="Toon texture factor",
|
||||
subtype="COLOR",
|
||||
size=4,
|
||||
soft_min=0,
|
||||
soft_max=1,
|
||||
precision=3,
|
||||
step=0.1,
|
||||
default=[0, 0, 0, 1],
|
||||
update=_material_morph_data_update_modifiable_values,
|
||||
)
|
||||
|
||||
|
||||
class MaterialMorph(_MorphBase, bpy.types.PropertyGroup):
|
||||
"""Material Morph"""
|
||||
|
||||
data: bpy.props.CollectionProperty(
|
||||
name="Morph Data",
|
||||
type=MaterialMorphData,
|
||||
)
|
||||
active_data: bpy.props.IntProperty(
|
||||
name="Active Material Data",
|
||||
min=0,
|
||||
default=0,
|
||||
)
|
||||
|
||||
|
||||
class UVMorphOffset(bpy.types.PropertyGroup):
|
||||
"""UV Morph Offset"""
|
||||
|
||||
index: bpy.props.IntProperty(
|
||||
name="Vertex Index",
|
||||
description="Vertex index",
|
||||
min=0,
|
||||
default=0,
|
||||
)
|
||||
offset: bpy.props.FloatVectorProperty(
|
||||
name="UV Offset",
|
||||
description="UV offset",
|
||||
size=4,
|
||||
# min=-1,
|
||||
# max=1,
|
||||
# precision=3,
|
||||
step=0.1,
|
||||
default=[0, 0, 0, 0],
|
||||
)
|
||||
|
||||
|
||||
class UVMorph(_MorphBase, bpy.types.PropertyGroup):
|
||||
"""UV Morph"""
|
||||
|
||||
uv_index: bpy.props.IntProperty(
|
||||
name="UV Index",
|
||||
description="UV index (UV, UV1 ~ UV4)",
|
||||
min=0,
|
||||
max=4,
|
||||
default=0,
|
||||
)
|
||||
data_type: bpy.props.EnumProperty(
|
||||
name="Data Type",
|
||||
description="Select data type",
|
||||
items=[
|
||||
("DATA", "Data", "Store offset data in root object (deprecated)", 0),
|
||||
("VERTEX_GROUP", "Vertex Group", "Store offset data in vertex groups", 1),
|
||||
],
|
||||
default="DATA",
|
||||
)
|
||||
data: bpy.props.CollectionProperty(
|
||||
name="Morph Data",
|
||||
type=UVMorphOffset,
|
||||
)
|
||||
active_data: bpy.props.IntProperty(
|
||||
name="Active UV Data",
|
||||
min=0,
|
||||
default=0,
|
||||
)
|
||||
vertex_group_scale: bpy.props.FloatProperty(
|
||||
name="Vertex Group Scale",
|
||||
description='The value scale of "Vertex Group" data type',
|
||||
precision=3,
|
||||
step=0.1,
|
||||
default=1,
|
||||
)
|
||||
|
||||
|
||||
class GroupMorphOffset(bpy.types.PropertyGroup):
|
||||
"""Group Morph Offset"""
|
||||
|
||||
morph_type: bpy.props.EnumProperty(
|
||||
name="Morph Type",
|
||||
description="Select morph type",
|
||||
items=[
|
||||
("material_morphs", "Material", "Material Morphs", 0),
|
||||
("uv_morphs", "UV", "UV Morphs", 1),
|
||||
("bone_morphs", "Bone", "Bone Morphs", 2),
|
||||
("vertex_morphs", "Vertex", "Vertex Morphs", 3),
|
||||
("group_morphs", "Group", "Group Morphs", 4),
|
||||
],
|
||||
default="vertex_morphs",
|
||||
)
|
||||
factor: bpy.props.FloatProperty(name="Factor", description="Factor", soft_min=0, soft_max=1, precision=3, step=0.1, default=0)
|
||||
|
||||
|
||||
class GroupMorph(_MorphBase, bpy.types.PropertyGroup):
|
||||
"""Group Morph"""
|
||||
|
||||
data: bpy.props.CollectionProperty(
|
||||
name="Morph Data",
|
||||
type=GroupMorphOffset,
|
||||
)
|
||||
active_data: bpy.props.IntProperty(
|
||||
name="Active Group Data",
|
||||
min=0,
|
||||
default=0,
|
||||
)
|
||||
|
||||
|
||||
class VertexMorph(_MorphBase, bpy.types.PropertyGroup):
|
||||
"""Vertex Morph"""
|
||||
@@ -0,0 +1,286 @@
|
||||
# Copyright 2014 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
from typing import cast
|
||||
|
||||
import bpy
|
||||
|
||||
from ..core.bone import FnBone
|
||||
from . import patch_library_overridable
|
||||
|
||||
|
||||
def _mmd_bone_update_additional_transform(prop: "MMDBone", context: bpy.types.Context):
|
||||
prop.is_additional_transform_dirty = True
|
||||
# Apply additional transform (Assembly -> Bone button) (Very Slow)
|
||||
p_bone = context.active_pose_bone
|
||||
if p_bone and p_bone.mmd_bone.as_pointer() == prop.as_pointer():
|
||||
FnBone.apply_additional_transformation(prop.id_data)
|
||||
|
||||
|
||||
def _mmd_bone_update_additional_transform_influence(prop: "MMDBone", context: bpy.types.Context):
|
||||
pose_bone = context.active_pose_bone
|
||||
if pose_bone and pose_bone.mmd_bone.as_pointer() == prop.as_pointer():
|
||||
FnBone.update_additional_transform_influence(pose_bone)
|
||||
else:
|
||||
prop.is_additional_transform_dirty = True
|
||||
|
||||
|
||||
def _mmd_bone_get_additional_transform_bone(prop: "MMDBone"):
|
||||
arm = prop.id_data
|
||||
bone_id = prop.get("additional_transform_bone_id", -1)
|
||||
if bone_id < 0:
|
||||
return ""
|
||||
pose_bone = FnBone.find_pose_bone_by_bone_id(arm, bone_id)
|
||||
if pose_bone is None:
|
||||
return ""
|
||||
return pose_bone.name
|
||||
|
||||
|
||||
def _mmd_bone_set_additional_transform_bone(prop: "MMDBone", value: str):
|
||||
arm = prop.id_data
|
||||
prop.is_additional_transform_dirty = True
|
||||
|
||||
if value not in arm.pose.bones.keys():
|
||||
prop.additional_transform_bone_id = -1
|
||||
return
|
||||
|
||||
pose_bone = arm.pose.bones[value]
|
||||
target_bone_id = FnBone.get_or_assign_bone_id(pose_bone)
|
||||
|
||||
if prop.bone_id == target_bone_id:
|
||||
prop.additional_transform_bone_id = -1
|
||||
return
|
||||
|
||||
prop.additional_transform_bone_id = target_bone_id
|
||||
|
||||
|
||||
def _mmd_bone_update_display_connection(prop: "MMDBone", context: bpy.types.Context):
|
||||
pass # Empty function is sufficient to trigger UI update
|
||||
|
||||
|
||||
def _mmd_bone_get_display_connection_bone(prop: "MMDBone"):
|
||||
arm = prop.id_data
|
||||
bone_id = prop.get("display_connection_bone_id", -1)
|
||||
if bone_id < 0:
|
||||
return ""
|
||||
pose_bone = FnBone.find_pose_bone_by_bone_id(arm, bone_id)
|
||||
if pose_bone is None:
|
||||
return ""
|
||||
return pose_bone.name
|
||||
|
||||
|
||||
def _mmd_bone_set_display_connection_bone(prop: "MMDBone", value: str):
|
||||
arm = prop.id_data
|
||||
|
||||
if value not in arm.pose.bones.keys():
|
||||
prop.display_connection_bone_id = -1
|
||||
return
|
||||
|
||||
pose_bone = arm.pose.bones[value]
|
||||
target_bone_id = FnBone.get_or_assign_bone_id(pose_bone)
|
||||
|
||||
if prop.bone_id == target_bone_id:
|
||||
prop.display_connection_bone_id = -1
|
||||
return
|
||||
|
||||
prop.display_connection_bone_id = target_bone_id
|
||||
|
||||
|
||||
class MMDBone(bpy.types.PropertyGroup):
|
||||
name_j: bpy.props.StringProperty(
|
||||
name="Name",
|
||||
description="Japanese Name",
|
||||
default="",
|
||||
)
|
||||
|
||||
name_e: bpy.props.StringProperty(
|
||||
name="Name(Eng)",
|
||||
description="English Name",
|
||||
default="",
|
||||
)
|
||||
|
||||
bone_id: bpy.props.IntProperty(
|
||||
name="Bone ID",
|
||||
description="Unique ID for the reference of bone morph and rotate+/move+",
|
||||
default=-1,
|
||||
min=-1,
|
||||
)
|
||||
|
||||
transform_order: bpy.props.IntProperty(
|
||||
name="Transform Order",
|
||||
description="Deformation tier",
|
||||
min=0,
|
||||
max=100,
|
||||
soft_max=7,
|
||||
)
|
||||
|
||||
is_controllable: bpy.props.BoolProperty(
|
||||
name="Controllable",
|
||||
description="Is controllable",
|
||||
default=True,
|
||||
)
|
||||
|
||||
transform_after_dynamics: bpy.props.BoolProperty(
|
||||
name="After Dynamics",
|
||||
description="After physics",
|
||||
default=False,
|
||||
)
|
||||
|
||||
enabled_fixed_axis: bpy.props.BoolProperty(
|
||||
name="Fixed Axis",
|
||||
description="Use fixed axis",
|
||||
default=False,
|
||||
)
|
||||
|
||||
fixed_axis: bpy.props.FloatVectorProperty(
|
||||
name="Fixed Axis",
|
||||
description="Fixed axis",
|
||||
subtype="XYZ",
|
||||
size=3,
|
||||
precision=3,
|
||||
step=0.1, # 0.1 / 100
|
||||
default=[0, 0, 0],
|
||||
)
|
||||
|
||||
enabled_local_axes: bpy.props.BoolProperty(
|
||||
name="Local Axes",
|
||||
description="Use local axes",
|
||||
default=False,
|
||||
)
|
||||
|
||||
local_axis_x: bpy.props.FloatVectorProperty(
|
||||
name="Local X-Axis",
|
||||
description="Local x-axis",
|
||||
subtype="XYZ",
|
||||
size=3,
|
||||
precision=3,
|
||||
step=0.1,
|
||||
default=[1, 0, 0],
|
||||
)
|
||||
|
||||
local_axis_z: bpy.props.FloatVectorProperty(
|
||||
name="Local Z-Axis",
|
||||
description="Local z-axis",
|
||||
subtype="XYZ",
|
||||
size=3,
|
||||
precision=3,
|
||||
step=0.1,
|
||||
default=[0, 0, 1],
|
||||
)
|
||||
|
||||
is_tip: bpy.props.BoolProperty(
|
||||
name="Tip Bone",
|
||||
description="Is zero length bone",
|
||||
default=False,
|
||||
)
|
||||
|
||||
ik_rotation_constraint: bpy.props.FloatProperty(
|
||||
name="IK Rotation Constraint",
|
||||
description="The unit angle of IK",
|
||||
subtype="ANGLE",
|
||||
soft_min=0,
|
||||
soft_max=4,
|
||||
default=1,
|
||||
)
|
||||
|
||||
has_additional_rotation: bpy.props.BoolProperty(
|
||||
name="Additional Rotation",
|
||||
description="Additional rotation",
|
||||
default=False,
|
||||
update=_mmd_bone_update_additional_transform,
|
||||
)
|
||||
|
||||
has_additional_location: bpy.props.BoolProperty(
|
||||
name="Additional Location",
|
||||
description="Additional location",
|
||||
default=False,
|
||||
update=_mmd_bone_update_additional_transform,
|
||||
)
|
||||
|
||||
additional_transform_bone: bpy.props.StringProperty(
|
||||
name="Additional Transform Bone",
|
||||
description="Additional transform bone",
|
||||
set=_mmd_bone_set_additional_transform_bone,
|
||||
get=_mmd_bone_get_additional_transform_bone,
|
||||
update=_mmd_bone_update_additional_transform,
|
||||
)
|
||||
|
||||
additional_transform_bone_id: bpy.props.IntProperty(
|
||||
name="Additional Transform Bone ID",
|
||||
default=-1,
|
||||
update=_mmd_bone_update_additional_transform,
|
||||
)
|
||||
|
||||
additional_transform_influence: bpy.props.FloatProperty(
|
||||
name="Additional Transform Influence",
|
||||
description="Additional transform influence",
|
||||
default=1,
|
||||
soft_min=-1,
|
||||
soft_max=1,
|
||||
update=_mmd_bone_update_additional_transform_influence,
|
||||
)
|
||||
|
||||
is_additional_transform_dirty: bpy.props.BoolProperty(name="", default=True)
|
||||
|
||||
display_connection_bone: bpy.props.StringProperty(
|
||||
name="Display Connection Bone",
|
||||
description="Target bone for display connection",
|
||||
set=_mmd_bone_set_display_connection_bone,
|
||||
get=_mmd_bone_get_display_connection_bone,
|
||||
)
|
||||
|
||||
display_connection_bone_id: bpy.props.IntProperty(
|
||||
name="Display Connection Bone ID",
|
||||
description="Bone ID for display connection (PMX displayConnection)",
|
||||
default=-1,
|
||||
update=_mmd_bone_update_display_connection,
|
||||
)
|
||||
|
||||
display_connection_type: bpy.props.EnumProperty(
|
||||
name="Display Connection Type",
|
||||
description="Type of display connection",
|
||||
items=[
|
||||
("BONE", "Bone", "Connected to a bone"),
|
||||
("OFFSET", "Offset", "Connected to an offset position"),
|
||||
],
|
||||
default="OFFSET",
|
||||
)
|
||||
|
||||
def is_id_unique(self):
|
||||
return self.bone_id < 0 or not next((b for b in self.id_data.pose.bones if b.mmd_bone != self and b.mmd_bone.bone_id == self.bone_id), None)
|
||||
|
||||
@staticmethod
|
||||
def register():
|
||||
bpy.types.PoseBone.mmd_bone = patch_library_overridable(bpy.props.PointerProperty(type=MMDBone))
|
||||
bpy.types.PoseBone.is_mmd_shadow_bone = patch_library_overridable(bpy.props.BoolProperty(name="is_mmd_shadow_bone", default=False))
|
||||
bpy.types.PoseBone.mmd_shadow_bone_type = patch_library_overridable(bpy.props.StringProperty(name="mmd_shadow_bone_type"))
|
||||
bpy.types.PoseBone.mmd_ik_toggle = patch_library_overridable(
|
||||
bpy.props.BoolProperty(
|
||||
name="MMD IK Toggle",
|
||||
description="MMD IK toggle is used to import/export animation of IK on-off",
|
||||
update=_pose_bone_update_mmd_ik_toggle,
|
||||
default=True,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def unregister():
|
||||
del bpy.types.PoseBone.mmd_ik_toggle
|
||||
del bpy.types.PoseBone.mmd_shadow_bone_type
|
||||
del bpy.types.PoseBone.is_mmd_shadow_bone
|
||||
del bpy.types.PoseBone.mmd_bone
|
||||
|
||||
|
||||
def _pose_bone_update_mmd_ik_toggle(prop: bpy.types.PoseBone, _context):
|
||||
v = prop.mmd_ik_toggle
|
||||
armature_object = cast("bpy.types.Object", prop.id_data)
|
||||
for b in armature_object.pose.bones:
|
||||
for c in b.constraints:
|
||||
if c.type == "IK" and c.subtarget == prop.name:
|
||||
# logging.debug(' %s %s', b.name, c.name)
|
||||
c.influence = v
|
||||
b = b if c.use_tail else b.parent
|
||||
for b in ([b] + b.parent_recursive)[: c.chain_count]:
|
||||
c = next((c for c in b.constraints if c.type == "LIMIT_ROTATION" and not c.mute), None)
|
||||
if c:
|
||||
c.influence = v
|
||||
@@ -0,0 +1,294 @@
|
||||
# Copyright 2014 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
"""Properties for rigid bodies and joints"""
|
||||
|
||||
import bpy
|
||||
|
||||
from .. import bpyutils
|
||||
from ..core import rigid_body
|
||||
from ..core.model import FnModel
|
||||
from ..core.rigid_body import FnRigidBody, RigidBodyMaterial
|
||||
from . import patch_library_overridable
|
||||
|
||||
|
||||
def _updateCollisionGroup(prop, _context):
|
||||
obj = prop.id_data
|
||||
materials = obj.data.materials
|
||||
if len(materials) == 0:
|
||||
materials.append(RigidBodyMaterial.getMaterial(prop.collision_group_number))
|
||||
else:
|
||||
obj.material_slots[0].material = RigidBodyMaterial.getMaterial(prop.collision_group_number)
|
||||
|
||||
|
||||
def _updateType(prop, _context):
|
||||
obj = prop.id_data
|
||||
rb = obj.rigid_body
|
||||
if rb:
|
||||
rb.kinematic = int(prop.type) == rigid_body.MODE_STATIC
|
||||
|
||||
|
||||
def _updateShape(prop, _context):
|
||||
obj = prop.id_data
|
||||
|
||||
if len(obj.data.vertices) > 0:
|
||||
size = prop.size
|
||||
prop.size = size # update mesh
|
||||
|
||||
rb = obj.rigid_body
|
||||
if rb:
|
||||
rb.collision_shape = prop.shape
|
||||
|
||||
|
||||
def _get_bone(prop):
|
||||
obj = prop.id_data
|
||||
relation = obj.constraints.get("mmd_tools_rigid_parent", None)
|
||||
if relation:
|
||||
arm = relation.target
|
||||
bone_name = relation.subtarget
|
||||
if arm is not None and bone_name in arm.data.bones:
|
||||
return bone_name
|
||||
return prop.get("bone", "")
|
||||
|
||||
|
||||
def _set_bone(prop, value):
|
||||
bone_name = value
|
||||
obj = prop.id_data
|
||||
relation = obj.constraints.get("mmd_tools_rigid_parent", None)
|
||||
if relation is None:
|
||||
relation = obj.constraints.new("CHILD_OF")
|
||||
relation.name = "mmd_tools_rigid_parent"
|
||||
relation.mute = True
|
||||
|
||||
arm = relation.target
|
||||
if arm is None:
|
||||
root = FnModel.find_root_object(obj)
|
||||
if root:
|
||||
arm = relation.target = FnModel.find_armature_object(root)
|
||||
|
||||
if arm is not None and bone_name in arm.data.bones:
|
||||
relation.subtarget = bone_name
|
||||
else:
|
||||
relation.subtarget = bone_name = ""
|
||||
|
||||
prop["bone"] = bone_name
|
||||
|
||||
|
||||
def _get_size(prop):
|
||||
if prop.id_data.mmd_type != "RIGID_BODY":
|
||||
return (0, 0, 0)
|
||||
return FnRigidBody.get_rigid_body_size(prop.id_data)
|
||||
|
||||
|
||||
def _set_size(prop, value):
|
||||
obj = prop.id_data
|
||||
assert obj.mode == "OBJECT" # not support other mode yet
|
||||
shape = prop.shape
|
||||
|
||||
mesh = obj.data
|
||||
rb = obj.rigid_body
|
||||
|
||||
current_size = FnRigidBody.get_rigid_body_size(obj)
|
||||
is_zero_size = all(abs(s) < 1e-6 for s in current_size)
|
||||
|
||||
if len(mesh.vertices) == 0 or rb is None or rb.collision_shape != shape or is_zero_size:
|
||||
if shape == "SPHERE":
|
||||
bpyutils.makeSphere(
|
||||
radius=value[0],
|
||||
target_object=obj,
|
||||
)
|
||||
elif shape == "BOX":
|
||||
bpyutils.makeBox(
|
||||
size=value,
|
||||
target_object=obj,
|
||||
)
|
||||
elif shape == "CAPSULE":
|
||||
bpyutils.makeCapsule(
|
||||
radius=value[0],
|
||||
height=value[1],
|
||||
target_object=obj,
|
||||
)
|
||||
mesh.update()
|
||||
if rb:
|
||||
rb.collision_shape = shape
|
||||
else:
|
||||
if shape == "SPHERE":
|
||||
radius = max(value[0], 1e-3)
|
||||
for v in mesh.vertices:
|
||||
vec = v.co.normalized()
|
||||
v.co = vec * radius
|
||||
elif shape == "BOX":
|
||||
x = max(value[0], 1e-3)
|
||||
y = max(value[1], 1e-3)
|
||||
z = max(value[2], 1e-3)
|
||||
for v in mesh.vertices:
|
||||
x0, y0, z0 = v.co
|
||||
x0 = -x if x0 < 0 else x
|
||||
y0 = -y if y0 < 0 else y
|
||||
z0 = -z if z0 < 0 else z
|
||||
v.co = [x0, y0, z0]
|
||||
elif shape == "CAPSULE":
|
||||
r0, h0, xx = FnRigidBody.get_rigid_body_size(prop.id_data)
|
||||
h0 *= 0.5
|
||||
radius = max(value[0], 1e-3)
|
||||
height = max(value[1], 1e-3) * 0.5
|
||||
scale = radius / max(r0, 1e-3)
|
||||
for v in mesh.vertices:
|
||||
x0, y0, z0 = v.co
|
||||
x0 *= scale
|
||||
y0 *= scale
|
||||
if z0 < 0:
|
||||
z0 = (z0 + h0) * scale - height
|
||||
else:
|
||||
z0 = (z0 - h0) * scale + height
|
||||
v.co = [x0, y0, z0]
|
||||
mesh.update()
|
||||
|
||||
|
||||
def _get_rigid_name(prop):
|
||||
return prop.get("name", "")
|
||||
|
||||
|
||||
def _set_rigid_name(prop, value):
|
||||
prop["name"] = value
|
||||
|
||||
|
||||
class MMDRigidBody(bpy.types.PropertyGroup):
|
||||
name_j: bpy.props.StringProperty(
|
||||
name="Name",
|
||||
description="Japanese Name",
|
||||
default="",
|
||||
get=_get_rigid_name,
|
||||
set=_set_rigid_name,
|
||||
)
|
||||
|
||||
name_e: bpy.props.StringProperty(
|
||||
name="Name(Eng)",
|
||||
description="English Name",
|
||||
default="",
|
||||
)
|
||||
|
||||
collision_group_number: bpy.props.IntProperty(
|
||||
name="Collision Group",
|
||||
description="The collision group of the object",
|
||||
min=0,
|
||||
max=15,
|
||||
default=1,
|
||||
update=_updateCollisionGroup,
|
||||
)
|
||||
|
||||
collision_group_mask: bpy.props.BoolVectorProperty(
|
||||
name="Collision Group Mask",
|
||||
description="The groups the object can not collide with",
|
||||
size=16,
|
||||
subtype="LAYER",
|
||||
)
|
||||
|
||||
type: bpy.props.EnumProperty(
|
||||
name="Rigid Type",
|
||||
description="Select rigid type",
|
||||
items=[
|
||||
(str(rigid_body.MODE_STATIC), "Bone", "Rigid body's orientation completely determined by attached bone", 1),
|
||||
(str(rigid_body.MODE_DYNAMIC), "Physics", "Attached bone's orientation completely determined by rigid body", 2),
|
||||
(str(rigid_body.MODE_DYNAMIC_BONE), "Physics + Bone", "Bone determined by combination of parent and attached rigid body", 3),
|
||||
],
|
||||
update=_updateType,
|
||||
)
|
||||
|
||||
shape: bpy.props.EnumProperty(
|
||||
name="Shape",
|
||||
description="Select the collision shape",
|
||||
items=[
|
||||
("SPHERE", "Sphere", "", 1),
|
||||
("BOX", "Box", "", 2),
|
||||
("CAPSULE", "Capsule", "", 3),
|
||||
],
|
||||
update=_updateShape,
|
||||
)
|
||||
|
||||
bone: bpy.props.StringProperty(
|
||||
name="Bone",
|
||||
description="Target bone",
|
||||
default="",
|
||||
get=_get_bone,
|
||||
set=_set_bone,
|
||||
)
|
||||
|
||||
size: bpy.props.FloatVectorProperty(
|
||||
name="Size",
|
||||
description="Size of the object",
|
||||
subtype="XYZ",
|
||||
size=3,
|
||||
min=0,
|
||||
step=0.1,
|
||||
get=_get_size,
|
||||
set=_set_size,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def register():
|
||||
bpy.types.Object.mmd_rigid = patch_library_overridable(bpy.props.PointerProperty(type=MMDRigidBody))
|
||||
|
||||
@staticmethod
|
||||
def unregister():
|
||||
del bpy.types.Object.mmd_rigid
|
||||
|
||||
|
||||
def _updateSpringLinear(prop, context):
|
||||
obj = prop.id_data
|
||||
rbc = obj.rigid_body_constraint
|
||||
if rbc:
|
||||
rbc.spring_stiffness_x = prop.spring_linear[0]
|
||||
rbc.spring_stiffness_y = prop.spring_linear[1]
|
||||
rbc.spring_stiffness_z = prop.spring_linear[2]
|
||||
|
||||
|
||||
def _updateSpringAngular(prop, context):
|
||||
obj = prop.id_data
|
||||
rbc = obj.rigid_body_constraint
|
||||
if rbc and hasattr(rbc, "use_spring_ang_x"):
|
||||
rbc.spring_stiffness_ang_x = prop.spring_angular[0]
|
||||
rbc.spring_stiffness_ang_y = prop.spring_angular[1]
|
||||
rbc.spring_stiffness_ang_z = prop.spring_angular[2]
|
||||
|
||||
|
||||
class MMDJoint(bpy.types.PropertyGroup):
|
||||
name_j: bpy.props.StringProperty(
|
||||
name="Name",
|
||||
description="Japanese Name",
|
||||
default="",
|
||||
)
|
||||
|
||||
name_e: bpy.props.StringProperty(
|
||||
name="Name(Eng)",
|
||||
description="English Name",
|
||||
default="",
|
||||
)
|
||||
|
||||
spring_linear: bpy.props.FloatVectorProperty(
|
||||
name="Spring(Linear)",
|
||||
description="Spring constant of movement",
|
||||
subtype="XYZ",
|
||||
size=3,
|
||||
min=0,
|
||||
step=0.1,
|
||||
update=_updateSpringLinear,
|
||||
)
|
||||
|
||||
spring_angular: bpy.props.FloatVectorProperty(
|
||||
name="Spring(Angular)",
|
||||
description="Spring constant of rotation",
|
||||
subtype="XYZ",
|
||||
size=3,
|
||||
min=0,
|
||||
step=0.1,
|
||||
update=_updateSpringAngular,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def register():
|
||||
bpy.types.Object.mmd_joint = patch_library_overridable(bpy.props.PointerProperty(type=MMDJoint))
|
||||
|
||||
@staticmethod
|
||||
def unregister():
|
||||
del bpy.types.Object.mmd_joint
|
||||
@@ -0,0 +1,624 @@
|
||||
# Copyright 2014 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
"""Properties for MMD model root object"""
|
||||
|
||||
import bpy
|
||||
|
||||
from ..bpyutils import FnContext
|
||||
from ..core.material import FnMaterial
|
||||
from ..core.model import FnModel
|
||||
from ..core.sdef import FnSDEF
|
||||
from . import patch_library_overridable
|
||||
from .morph import BoneMorph, GroupMorph, MaterialMorph, UVMorph, VertexMorph
|
||||
from .translations import MMDTranslation
|
||||
|
||||
IS_BLENDER_50_UP = bpy.app.version >= (5, 0)
|
||||
|
||||
|
||||
def __driver_variables(constraint: bpy.types.Constraint, path: str, index=-1):
|
||||
d = constraint.driver_add(path, index)
|
||||
variables = d.driver.variables
|
||||
for x in reversed(variables):
|
||||
variables.remove(x)
|
||||
return d.driver, variables
|
||||
|
||||
|
||||
def __add_single_prop(variables, id_obj, data_path, prefix):
|
||||
var = variables.new()
|
||||
var.name = prefix + str(len(variables))
|
||||
var.type = "SINGLE_PROP"
|
||||
target = var.targets[0]
|
||||
target.id_type = "OBJECT"
|
||||
target.id = id_obj
|
||||
target.data_path = data_path
|
||||
return var
|
||||
|
||||
|
||||
def _toggleUsePropertyDriver(self: "MMDRoot", _context):
|
||||
root_object: bpy.types.Object = self.id_data
|
||||
armature_object = FnModel.find_armature_object(root_object)
|
||||
|
||||
if armature_object is None:
|
||||
ik_map = {}
|
||||
else:
|
||||
bones = armature_object.pose.bones
|
||||
ik_map = {bones[c.subtarget]: (b, c) for b in bones for c in b.constraints if c.type == "IK" and c.is_valid and c.subtarget in bones}
|
||||
|
||||
if self.use_property_driver:
|
||||
for ik, (b, c) in ik_map.items():
|
||||
driver, variables = __driver_variables(c, "influence")
|
||||
driver.expression = "%s" % __add_single_prop(variables, ik.id_data, ik.path_from_id("mmd_ik_toggle"), "use_ik").name
|
||||
b = b if c.use_tail else b.parent
|
||||
for b in ([b] + b.parent_recursive)[: c.chain_count]:
|
||||
c = next((c for c in b.constraints if c.type == "LIMIT_ROTATION" and not c.mute), None)
|
||||
if c:
|
||||
driver, variables = __driver_variables(c, "influence")
|
||||
driver.expression = "%s" % __add_single_prop(variables, ik.id_data, ik.path_from_id("mmd_ik_toggle"), "use_ik").name
|
||||
for i in FnModel.iterate_mesh_objects(root_object):
|
||||
for prop_hide in ("hide_viewport", "hide_render"):
|
||||
driver, variables = __driver_variables(i, prop_hide)
|
||||
driver.expression = "not %s" % __add_single_prop(variables, root_object, "mmd_root.show_meshes", "show").name
|
||||
else:
|
||||
for ik, (b, c) in ik_map.items():
|
||||
c.driver_remove("influence")
|
||||
b = b if c.use_tail else b.parent
|
||||
for b in ([b] + b.parent_recursive)[: c.chain_count]:
|
||||
c = next((c for c in b.constraints if c.type == "LIMIT_ROTATION" and not c.mute), None)
|
||||
if c:
|
||||
c.driver_remove("influence")
|
||||
for i in FnModel.iterate_mesh_objects(root_object):
|
||||
for prop_hide in ("hide_viewport", "hide_render"):
|
||||
i.driver_remove(prop_hide)
|
||||
|
||||
|
||||
# ===========================================
|
||||
# Callback functions
|
||||
# ===========================================
|
||||
|
||||
|
||||
def _toggleUseToonTexture(self: "MMDRoot", _context):
|
||||
use_toon = self.use_toon_texture
|
||||
for i in FnModel.iterate_mesh_objects(self.id_data):
|
||||
for m in i.data.materials:
|
||||
if m:
|
||||
FnMaterial(m).use_toon_texture(use_toon)
|
||||
|
||||
|
||||
def _toggleUseSphereTexture(self: "MMDRoot", _context):
|
||||
use_sphere = self.use_sphere_texture
|
||||
for i in FnModel.iterate_mesh_objects(self.id_data):
|
||||
for m in i.data.materials:
|
||||
if m:
|
||||
FnMaterial(m).use_sphere_texture(use_sphere, i)
|
||||
|
||||
|
||||
def _toggleUseSDEF(self: "MMDRoot", _context):
|
||||
mute_sdef = not self.use_sdef
|
||||
for i in FnModel.iterate_mesh_objects(self.id_data):
|
||||
FnSDEF.mute_sdef_set(i, mute_sdef)
|
||||
|
||||
|
||||
def _toggleVisibilityOfMeshes(self: "MMDRoot", context: bpy.types.Context):
|
||||
root = self.id_data
|
||||
hide = not self.show_meshes
|
||||
for i in FnModel.iterate_mesh_objects(self.id_data):
|
||||
i.hide_set(hide)
|
||||
i.hide_render = hide
|
||||
if hide and context.active_object is None:
|
||||
FnContext.set_active_object(context, root)
|
||||
|
||||
|
||||
def _toggleVisibilityOfRigidBodies(self: "MMDRoot", context: bpy.types.Context):
|
||||
root = self.id_data
|
||||
hide = not self.show_rigid_bodies
|
||||
for i in FnModel.iterate_rigid_body_objects(root):
|
||||
i.hide_set(hide)
|
||||
if hide and context.active_object is None:
|
||||
FnContext.set_active_object(context, root)
|
||||
|
||||
|
||||
def _toggleVisibilityOfJoints(self: "MMDRoot", context):
|
||||
root_object = self.id_data
|
||||
hide = not self.show_joints
|
||||
for i in FnModel.iterate_joint_objects(root_object):
|
||||
i.hide_set(hide)
|
||||
if hide and context.active_object is None:
|
||||
FnContext.set_active_object(context, root_object)
|
||||
|
||||
|
||||
def _toggleVisibilityOfTemporaryObjects(self: "MMDRoot", context: bpy.types.Context):
|
||||
root_object: bpy.types.Object = self.id_data
|
||||
hide = not self.show_temporary_objects
|
||||
with FnContext.temp_override_active_layer_collection(context, root_object):
|
||||
for i in FnModel.iterate_temporary_objects(root_object):
|
||||
i.hide_set(hide)
|
||||
if hide and context.active_object is None:
|
||||
FnContext.set_active_object(context, root_object)
|
||||
|
||||
|
||||
def _toggleShowNamesOfRigidBodies(self: "MMDRoot", _context):
|
||||
root = self.id_data
|
||||
show_names = root.mmd_root.show_names_of_rigid_bodies
|
||||
for i in FnModel.iterate_rigid_body_objects(root):
|
||||
i.show_name = show_names
|
||||
|
||||
|
||||
def _toggleShowNamesOfJoints(self: "MMDRoot", _context):
|
||||
root = self.id_data
|
||||
show_names = root.mmd_root.show_names_of_joints
|
||||
for i in FnModel.iterate_joint_objects(root):
|
||||
i.show_name = show_names
|
||||
|
||||
|
||||
def _setVisibilityOfMMDRigArmature(prop: "MMDRoot", v: bool):
|
||||
root = prop.id_data
|
||||
arm = FnModel.find_armature_object(root)
|
||||
if arm is None:
|
||||
return
|
||||
if not v and bpy.context.active_object == arm:
|
||||
FnContext.set_active_object(bpy.context, root)
|
||||
arm.hide_set(not v)
|
||||
|
||||
|
||||
def _getVisibilityOfMMDRigArmature(prop: "MMDRoot"):
|
||||
if prop.id_data.mmd_type != "ROOT":
|
||||
return False
|
||||
arm = FnModel.find_armature_object(prop.id_data)
|
||||
return arm is not None and not arm.hide_get()
|
||||
|
||||
|
||||
def _setActiveRigidbodyObject(prop: "MMDRoot", v: int):
|
||||
obj = FnContext.get_scene_objects(bpy.context)[v]
|
||||
if FnModel.is_rigid_body_object(obj):
|
||||
FnContext.set_active_and_select_single_object(bpy.context, obj)
|
||||
prop["active_rigidbody_object_index"] = v
|
||||
|
||||
|
||||
def _getActiveRigidbodyObject(prop: "MMDRoot"):
|
||||
context = bpy.context
|
||||
active_obj = FnContext.get_active_object(context)
|
||||
if FnModel.is_rigid_body_object(active_obj):
|
||||
prop["active_rigidbody_object_index"] = FnContext.get_scene_objects(context).find(active_obj.name)
|
||||
return prop.get("active_rigidbody_object_index", 0)
|
||||
|
||||
|
||||
def _setActiveJointObject(prop: "MMDRoot", v: int):
|
||||
obj = FnContext.get_scene_objects(bpy.context)[v]
|
||||
if FnModel.is_joint_object(obj):
|
||||
FnContext.set_active_and_select_single_object(bpy.context, obj)
|
||||
prop["active_joint_object_index"] = v
|
||||
|
||||
|
||||
def _getActiveJointObject(prop: "MMDRoot"):
|
||||
context = bpy.context
|
||||
active_obj = FnContext.get_active_object(context)
|
||||
if FnModel.is_joint_object(active_obj):
|
||||
prop["active_joint_object_index"] = FnContext.get_scene_objects(context).find(active_obj.name)
|
||||
return prop.get("active_joint_object_index", 0)
|
||||
|
||||
|
||||
def _setActiveMorph(prop: "MMDRoot", v: bool):
|
||||
if "active_morph_indices" not in prop:
|
||||
prop["active_morph_indices"] = [0] * 5
|
||||
prop["active_morph_indices"][prop.get("active_morph_type", 3)] = v
|
||||
|
||||
|
||||
def _getActiveMorph(prop: "MMDRoot"):
|
||||
if "active_morph_indices" in prop:
|
||||
return prop["active_morph_indices"][prop.get("active_morph_type", 3)]
|
||||
return 0
|
||||
|
||||
|
||||
def _setActiveMeshObject(prop: "MMDRoot", v: int):
|
||||
obj = FnContext.get_scene_objects(bpy.context)[v]
|
||||
if FnModel.is_mesh_object(obj):
|
||||
FnContext.set_active_and_select_single_object(bpy.context, obj)
|
||||
prop["active_mesh_index"] = v
|
||||
|
||||
|
||||
def _getActiveMeshObject(prop: "MMDRoot"):
|
||||
context = bpy.context
|
||||
active_obj = FnContext.get_active_object(context)
|
||||
if FnModel.is_mesh_object(active_obj):
|
||||
prop["active_mesh_index"] = FnContext.get_scene_objects(context).find(active_obj.name)
|
||||
return prop.get("active_mesh_index", -1)
|
||||
|
||||
|
||||
# ===========================================
|
||||
# Property classes
|
||||
# ===========================================
|
||||
|
||||
|
||||
class MMDDisplayItem(bpy.types.PropertyGroup):
|
||||
"""PMX 表示項目(表示枠内の1項目)"""
|
||||
|
||||
type: bpy.props.EnumProperty(
|
||||
name="Type",
|
||||
description="Select item type",
|
||||
items=[
|
||||
("BONE", "Bone", "", 1),
|
||||
("MORPH", "Morph", "", 2),
|
||||
],
|
||||
)
|
||||
|
||||
morph_type: bpy.props.EnumProperty(
|
||||
name="Morph Type",
|
||||
description="Select morph type",
|
||||
items=[
|
||||
("material_morphs", "Material", "Material Morphs", 0),
|
||||
("uv_morphs", "UV", "UV Morphs", 1),
|
||||
("bone_morphs", "Bone", "Bone Morphs", 2),
|
||||
("vertex_morphs", "Vertex", "Vertex Morphs", 3),
|
||||
("group_morphs", "Group", "Group Morphs", 4),
|
||||
],
|
||||
default="vertex_morphs",
|
||||
)
|
||||
|
||||
|
||||
class MMDDisplayItemFrame(bpy.types.PropertyGroup):
|
||||
"""PMX 表示枠
|
||||
|
||||
PMXファイル内では表示枠がリストで格納されています。
|
||||
"""
|
||||
|
||||
name_e: bpy.props.StringProperty(
|
||||
name="Name(Eng)",
|
||||
description="English Name",
|
||||
default="",
|
||||
)
|
||||
|
||||
# 特殊枠フラグ
|
||||
# 特殊枠はファイル仕様上の固定枠(削除、リネーム不可)
|
||||
is_special: bpy.props.BoolProperty(
|
||||
name="Special",
|
||||
description="Is special",
|
||||
default=False,
|
||||
)
|
||||
|
||||
# 表示項目のリスト
|
||||
data: bpy.props.CollectionProperty(
|
||||
name="Display Items",
|
||||
type=MMDDisplayItem,
|
||||
)
|
||||
|
||||
# 現在アクティブな項目のインデックス
|
||||
active_item: bpy.props.IntProperty(
|
||||
name="Active Display Item",
|
||||
min=0,
|
||||
default=0,
|
||||
)
|
||||
|
||||
|
||||
class MMDRoot(bpy.types.PropertyGroup):
|
||||
"""MMDモデルデータ
|
||||
|
||||
モデルルート用に作成されたEmtpyオブジェクトで使用します
|
||||
"""
|
||||
|
||||
name: bpy.props.StringProperty(
|
||||
name="Name",
|
||||
description="The name of the MMD model",
|
||||
default="",
|
||||
)
|
||||
|
||||
name_e: bpy.props.StringProperty(
|
||||
name="Name (English)",
|
||||
description="The english name of the MMD model",
|
||||
default="",
|
||||
)
|
||||
|
||||
comment_text: bpy.props.StringProperty(
|
||||
name="Comment",
|
||||
description="The text datablock of the comment",
|
||||
default="",
|
||||
)
|
||||
|
||||
comment_e_text: bpy.props.StringProperty(
|
||||
name="Comment (English)",
|
||||
description="The text datablock of the english comment",
|
||||
default="",
|
||||
)
|
||||
|
||||
ik_loop_factor: bpy.props.IntProperty(
|
||||
name="MMD IK Loop Factor",
|
||||
description="Scaling factor of MMD IK loop",
|
||||
min=1,
|
||||
soft_max=10,
|
||||
max=100,
|
||||
default=1,
|
||||
)
|
||||
|
||||
# TODO: Replace to driver for NLA
|
||||
show_meshes: bpy.props.BoolProperty(
|
||||
name="Show Meshes",
|
||||
description="Show all meshes of the MMD model",
|
||||
# get=_show_meshes_get,
|
||||
# set=_show_meshes_set,
|
||||
update=_toggleVisibilityOfMeshes,
|
||||
default=True,
|
||||
)
|
||||
|
||||
show_rigid_bodies: bpy.props.BoolProperty(
|
||||
name="Show Rigid Bodies",
|
||||
description="Show all rigid bodies of the MMD model",
|
||||
update=_toggleVisibilityOfRigidBodies,
|
||||
)
|
||||
|
||||
show_joints: bpy.props.BoolProperty(
|
||||
name="Show Joints",
|
||||
description="Show all joints of the MMD model",
|
||||
update=_toggleVisibilityOfJoints,
|
||||
)
|
||||
|
||||
show_temporary_objects: bpy.props.BoolProperty(
|
||||
name="Show Temps",
|
||||
description="Show all temporary objects of the MMD model",
|
||||
update=_toggleVisibilityOfTemporaryObjects,
|
||||
)
|
||||
|
||||
show_armature: bpy.props.BoolProperty(
|
||||
name="Show Armature",
|
||||
description="Show the armature object of the MMD model",
|
||||
get=_getVisibilityOfMMDRigArmature,
|
||||
set=_setVisibilityOfMMDRigArmature,
|
||||
)
|
||||
|
||||
show_names_of_rigid_bodies: bpy.props.BoolProperty(
|
||||
name="Show Rigid Body Names",
|
||||
description="Show rigid body names",
|
||||
update=_toggleShowNamesOfRigidBodies,
|
||||
)
|
||||
|
||||
show_names_of_joints: bpy.props.BoolProperty(
|
||||
name="Show Joint Names",
|
||||
description="Show joint names",
|
||||
update=_toggleShowNamesOfJoints,
|
||||
)
|
||||
|
||||
show_japanese_name: bpy.props.BoolProperty(
|
||||
name="Japanese name",
|
||||
description="Toggle Japanese name display",
|
||||
default=True,
|
||||
)
|
||||
|
||||
show_english_name: bpy.props.BoolProperty(
|
||||
name="English name",
|
||||
description="Toggle English name display",
|
||||
default=True,
|
||||
)
|
||||
|
||||
use_toon_texture: bpy.props.BoolProperty(
|
||||
name="Use Toon Texture",
|
||||
description="Use toon texture",
|
||||
update=_toggleUseToonTexture,
|
||||
default=True,
|
||||
)
|
||||
|
||||
use_sphere_texture: bpy.props.BoolProperty(
|
||||
name="Use Sphere Texture",
|
||||
description="Use sphere texture",
|
||||
update=_toggleUseSphereTexture,
|
||||
default=True,
|
||||
)
|
||||
|
||||
use_sdef: bpy.props.BoolProperty(
|
||||
name="Use SDEF",
|
||||
description="Use SDEF",
|
||||
update=_toggleUseSDEF,
|
||||
default=True,
|
||||
)
|
||||
|
||||
use_property_driver: bpy.props.BoolProperty(
|
||||
name="Use Property Driver",
|
||||
description="Setup drivers for MMD property animation (Visibility and IK toggles)",
|
||||
update=_toggleUsePropertyDriver,
|
||||
default=False,
|
||||
)
|
||||
|
||||
is_built: bpy.props.BoolProperty(
|
||||
name="Is Built",
|
||||
)
|
||||
|
||||
active_rigidbody_index: bpy.props.IntProperty(
|
||||
name="Active Rigidbody Index",
|
||||
min=0,
|
||||
get=_getActiveRigidbodyObject,
|
||||
set=_setActiveRigidbodyObject,
|
||||
)
|
||||
|
||||
active_joint_index: bpy.props.IntProperty(
|
||||
name="Active Joint Index",
|
||||
min=0,
|
||||
get=_getActiveJointObject,
|
||||
set=_setActiveJointObject,
|
||||
)
|
||||
|
||||
# *************************
|
||||
# Display Items
|
||||
# *************************
|
||||
display_item_frames: bpy.props.CollectionProperty(
|
||||
name="Display Frames",
|
||||
type=MMDDisplayItemFrame,
|
||||
)
|
||||
|
||||
active_display_item_frame: bpy.props.IntProperty(
|
||||
name="Active Display Item Frame",
|
||||
min=0,
|
||||
default=0,
|
||||
)
|
||||
|
||||
# *************************
|
||||
# Bone
|
||||
# *************************
|
||||
active_bone_index: bpy.props.IntProperty(
|
||||
name="Active Bone Index",
|
||||
description="Index of the active bone in the armature",
|
||||
default=0,
|
||||
)
|
||||
|
||||
# *************************
|
||||
# Morph
|
||||
# *************************
|
||||
material_morphs: bpy.props.CollectionProperty(
|
||||
name="Material Morphs",
|
||||
type=MaterialMorph,
|
||||
)
|
||||
uv_morphs: bpy.props.CollectionProperty(
|
||||
name="UV Morphs",
|
||||
type=UVMorph,
|
||||
)
|
||||
bone_morphs: bpy.props.CollectionProperty(
|
||||
name="Bone Morphs",
|
||||
type=BoneMorph,
|
||||
)
|
||||
vertex_morphs: bpy.props.CollectionProperty(name="Vertex Morphs", type=VertexMorph)
|
||||
group_morphs: bpy.props.CollectionProperty(
|
||||
name="Group Morphs",
|
||||
type=GroupMorph,
|
||||
)
|
||||
active_morph_type: bpy.props.EnumProperty(
|
||||
name="Active Morph Type",
|
||||
description="Select current morph type",
|
||||
items=[
|
||||
("material_morphs", "Material", "Material Morphs", 0),
|
||||
("uv_morphs", "UV", "UV Morphs", 1),
|
||||
("bone_morphs", "Bone", "Bone Morphs", 2),
|
||||
("vertex_morphs", "Vertex", "Vertex Morphs", 3),
|
||||
("group_morphs", "Group", "Group Morphs", 4),
|
||||
],
|
||||
default="vertex_morphs",
|
||||
)
|
||||
active_morph: bpy.props.IntProperty(
|
||||
name="Active Morph",
|
||||
min=0,
|
||||
set=_setActiveMorph,
|
||||
get=_getActiveMorph,
|
||||
)
|
||||
morph_panel_show_settings: bpy.props.BoolProperty(
|
||||
name="Morph Panel Show Settings",
|
||||
description="Show Morph Settings",
|
||||
default=True,
|
||||
)
|
||||
active_mesh_index: bpy.props.IntProperty(
|
||||
name="Active Mesh",
|
||||
min=0,
|
||||
set=_setActiveMeshObject,
|
||||
get=_getActiveMeshObject,
|
||||
)
|
||||
|
||||
# *************************
|
||||
# Translation
|
||||
# *************************
|
||||
translation: bpy.props.PointerProperty(
|
||||
name="Translation",
|
||||
type=MMDTranslation,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def __get_select(prop: bpy.types.Object) -> bool:
|
||||
# TODO: Object.select is deprecated since v4.0.0, use Object.select_get() method instead
|
||||
# utils.warn_deprecation("Object.select", "v4.0.0", "Use Object.select_get() method instead")
|
||||
return prop.select_get()
|
||||
|
||||
@staticmethod
|
||||
def __set_select(prop: bpy.types.Object, value: bool) -> None:
|
||||
# TODO: Object.select is deprecated since v4.0.0, use Object.select_set() method instead
|
||||
# utils.warn_deprecation("Object.select", "v4.0.0", "Use Object.select_set() method instead")
|
||||
prop.select_set(value)
|
||||
|
||||
@staticmethod
|
||||
def __get_hide(prop: bpy.types.Object) -> bool:
|
||||
# TODO: Object.hide is deprecated since v4.0.0, use Object.hide_get() method instead
|
||||
# utils.warn_deprecation("Object.hide", "v4.0.0", "Use Object.hide_get() method instead")
|
||||
return prop.hide_get()
|
||||
|
||||
@staticmethod
|
||||
def __set_hide(prop: bpy.types.Object, value: bool) -> None:
|
||||
# TODO: Object.hide is deprecated since v4.0.0, use Object.hide_set() method instead
|
||||
# utils.warn_deprecation("Object.hide", "v4.0.0", "Use Object.hide_set() method instead")
|
||||
prop.hide_set(value)
|
||||
if prop.hide_viewport != value:
|
||||
prop.hide_viewport = value
|
||||
|
||||
@staticmethod
|
||||
def __get_pose_bone_select(prop: bpy.types.PoseBone) -> bool:
|
||||
return prop.bone.select
|
||||
|
||||
@staticmethod
|
||||
def __set_pose_bone_select(prop: bpy.types.PoseBone, value: bool) -> None:
|
||||
prop.bone.select = value
|
||||
|
||||
@staticmethod
|
||||
def register():
|
||||
bpy.types.Object.mmd_type = patch_library_overridable(
|
||||
bpy.props.EnumProperty(
|
||||
name="Type",
|
||||
description="Internal MMD type of this object (DO NOT CHANGE IT DIRECTLY)",
|
||||
default="NONE",
|
||||
items=[
|
||||
("NONE", "None", "", 1),
|
||||
("ROOT", "Root", "", 2),
|
||||
("RIGID_GRP_OBJ", "Rigid Body Grp Empty", "", 3),
|
||||
("JOINT_GRP_OBJ", "Joint Grp Empty", "", 4),
|
||||
("TEMPORARY_GRP_OBJ", "Temporary Grp Empty", "", 5),
|
||||
("PLACEHOLDER", "Place Holder", "", 6),
|
||||
("CAMERA", "Camera", "", 21),
|
||||
("JOINT", "Joint", "", 22),
|
||||
("RIGID_BODY", "Rigid body", "", 23),
|
||||
("LIGHT", "Light", "", 24),
|
||||
("TRACK_TARGET", "Track Target", "", 51),
|
||||
("NON_COLLISION_CONSTRAINT", "Non Collision Constraint", "", 52),
|
||||
("SPRING_CONSTRAINT", "Spring Constraint", "", 53),
|
||||
("SPRING_GOAL", "Spring Goal", "", 54),
|
||||
],
|
||||
),
|
||||
)
|
||||
bpy.types.Object.mmd_root = patch_library_overridable(bpy.props.PointerProperty(type=MMDRoot))
|
||||
|
||||
bpy.types.Object.select = patch_library_overridable(
|
||||
bpy.props.BoolProperty(
|
||||
get=MMDRoot.__get_select,
|
||||
set=MMDRoot.__set_select,
|
||||
options={
|
||||
"SKIP_SAVE",
|
||||
"ANIMATABLE",
|
||||
"LIBRARY_EDITABLE",
|
||||
},
|
||||
),
|
||||
)
|
||||
bpy.types.Object.hide = patch_library_overridable(
|
||||
bpy.props.BoolProperty(
|
||||
get=MMDRoot.__get_hide,
|
||||
set=MMDRoot.__set_hide,
|
||||
options={
|
||||
"SKIP_SAVE",
|
||||
"ANIMATABLE",
|
||||
"LIBRARY_EDITABLE",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
if not IS_BLENDER_50_UP:
|
||||
bpy.types.PoseBone.select = patch_library_overridable(
|
||||
bpy.props.BoolProperty(
|
||||
name="Select",
|
||||
description="Pose bone selection state (compatibility layer for Blender 4.x, forwards to bone.select)",
|
||||
get=MMDRoot.__get_pose_bone_select,
|
||||
set=MMDRoot.__set_pose_bone_select,
|
||||
options={
|
||||
"SKIP_SAVE",
|
||||
"ANIMATABLE",
|
||||
"LIBRARY_EDITABLE",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def unregister():
|
||||
del bpy.types.Object.hide
|
||||
del bpy.types.Object.select
|
||||
del bpy.types.Object.mmd_root
|
||||
del bpy.types.Object.mmd_type
|
||||
if not IS_BLENDER_50_UP:
|
||||
del bpy.types.PoseBone.select
|
||||
@@ -0,0 +1,123 @@
|
||||
# Copyright 2021 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import bpy
|
||||
|
||||
from ..core.translations import FnTranslations, MMDTranslationElementType
|
||||
from ..translations import DictionaryEnum
|
||||
|
||||
MMD_TRANSLATION_ELEMENT_TYPE_ENUM_ITEMS = [
|
||||
(MMDTranslationElementType.BONE.name, MMDTranslationElementType.BONE.value, "Bones", 1),
|
||||
(MMDTranslationElementType.MORPH.name, MMDTranslationElementType.MORPH.value, "Morphs", 2),
|
||||
(MMDTranslationElementType.MATERIAL.name, MMDTranslationElementType.MATERIAL.value, "Materials", 4),
|
||||
(MMDTranslationElementType.DISPLAY.name, MMDTranslationElementType.DISPLAY.value, "Display frames", 8),
|
||||
(MMDTranslationElementType.PHYSICS.name, MMDTranslationElementType.PHYSICS.value, "Rigidbodies and joints", 16),
|
||||
(MMDTranslationElementType.INFO.name, MMDTranslationElementType.INFO.value, "Model name and comments", 32),
|
||||
]
|
||||
|
||||
|
||||
class MMDTranslationElement(bpy.types.PropertyGroup):
|
||||
type: bpy.props.EnumProperty(items=MMD_TRANSLATION_ELEMENT_TYPE_ENUM_ITEMS)
|
||||
object: bpy.props.PointerProperty(type=bpy.types.Object)
|
||||
data_path: bpy.props.StringProperty()
|
||||
name: bpy.props.StringProperty()
|
||||
name_j: bpy.props.StringProperty()
|
||||
name_e: bpy.props.StringProperty()
|
||||
|
||||
|
||||
class MMDTranslationElementIndex(bpy.types.PropertyGroup):
|
||||
value: bpy.props.IntProperty()
|
||||
|
||||
|
||||
BATCH_OPERATION_SCRIPT_PRESETS: Dict[str, Tuple[Optional[str], str, str, int]] = {
|
||||
"NOTHING": ("", "", "", 1),
|
||||
"CLEAR": (None, "Clear", '""', 10),
|
||||
"TO_ENGLISH": ("BLENDER", "Translate to English", "to_english(name)", 2),
|
||||
"TO_MMD_LR": ("JAPANESE", "Blender L/R to MMD L/R", "to_mmd_lr(name)", 3),
|
||||
"TO_BLENDER_LR": ("BLENDER", "MMD L/R to Blender L/R", "to_blender_lr(name_j)", 4),
|
||||
"RESTORE_BLENDER": ("BLENDER", "Restore Blender Names", "org_name", 5),
|
||||
"RESTORE_JAPANESE": ("JAPANESE", "Restore Japanese MMD Names", "org_name_j", 6),
|
||||
"RESTORE_ENGLISH": ("ENGLISH", "Restore English MMD Names", "org_name_e", 7),
|
||||
"ENGLISH_IF_EMPTY_JAPANESE": (None, "Copy English MMD Names, if empty copy Japanese MMD Name", "name_e if name_e else name_j", 8),
|
||||
"JAPANESE_IF_EMPTY_ENGLISH": (None, "Copy Japanese MMD Names, if empty copy English MMD Name", "name_j if name_j else name_e", 9),
|
||||
}
|
||||
|
||||
BATCH_OPERATION_SCRIPT_PRESET_ITEMS: List[Tuple[str, str, str, int]] = [(k, t[1], t[2], t[3]) for k, t in BATCH_OPERATION_SCRIPT_PRESETS.items()]
|
||||
|
||||
|
||||
class MMDTranslation(bpy.types.PropertyGroup):
|
||||
@staticmethod
|
||||
def _update_index(mmd_translation: "MMDTranslation", _context):
|
||||
FnTranslations.update_index(mmd_translation)
|
||||
|
||||
@staticmethod
|
||||
def _collect_data(mmd_translation: "MMDTranslation", _context):
|
||||
FnTranslations.collect_data(mmd_translation)
|
||||
|
||||
@staticmethod
|
||||
def _update_query(mmd_translation: "MMDTranslation", _context):
|
||||
FnTranslations.update_query(mmd_translation)
|
||||
|
||||
@staticmethod
|
||||
def _update_batch_operation_script_preset(mmd_translation: "MMDTranslation", _context):
|
||||
if mmd_translation.batch_operation_script_preset == "NOTHING":
|
||||
return
|
||||
|
||||
id2scripts: Dict[str, str] = {i[0]: i[2] for i in BATCH_OPERATION_SCRIPT_PRESET_ITEMS}
|
||||
|
||||
batch_operation_script = id2scripts.get(mmd_translation.batch_operation_script_preset)
|
||||
if batch_operation_script is None:
|
||||
return
|
||||
|
||||
mmd_translation.batch_operation_script = batch_operation_script
|
||||
batch_operation_target = BATCH_OPERATION_SCRIPT_PRESETS[mmd_translation.batch_operation_script_preset][0]
|
||||
if batch_operation_target:
|
||||
mmd_translation.batch_operation_target = batch_operation_target
|
||||
|
||||
translation_elements: bpy.props.CollectionProperty(type=MMDTranslationElement)
|
||||
filtered_translation_element_indices_active_index: bpy.props.IntProperty(update=_update_index.__func__)
|
||||
filtered_translation_element_indices: bpy.props.CollectionProperty(type=MMDTranslationElementIndex)
|
||||
|
||||
filter_japanese_blank: bpy.props.BoolProperty(name="Japanese Blank", default=False, update=_update_query.__func__)
|
||||
filter_english_blank: bpy.props.BoolProperty(name="English Blank", default=False, update=_update_query.__func__)
|
||||
filter_restorable: bpy.props.BoolProperty(name="Restorable", default=False, update=_update_query.__func__)
|
||||
filter_selected: bpy.props.BoolProperty(name="Selected", default=False, update=_update_query.__func__)
|
||||
filter_visible: bpy.props.BoolProperty(name="Visible", default=False, update=_update_query.__func__)
|
||||
filter_types: bpy.props.EnumProperty(
|
||||
items=MMD_TRANSLATION_ELEMENT_TYPE_ENUM_ITEMS,
|
||||
default={
|
||||
"BONE",
|
||||
"MORPH",
|
||||
"MATERIAL",
|
||||
"DISPLAY",
|
||||
"PHYSICS",
|
||||
},
|
||||
options={"ENUM_FLAG"},
|
||||
update=_update_query.__func__,
|
||||
)
|
||||
|
||||
dictionary: bpy.props.EnumProperty(
|
||||
items=DictionaryEnum.get_dictionary_items,
|
||||
name="Dictionary",
|
||||
)
|
||||
|
||||
batch_operation_target: bpy.props.EnumProperty(
|
||||
items=[
|
||||
("BLENDER", "Blender Name (name)", "", 1),
|
||||
("JAPANESE", "Japanese MMD Name (name_j)", "", 2),
|
||||
("ENGLISH", "English MMD Name (name_e)", "", 3),
|
||||
],
|
||||
name="Operation Target",
|
||||
default="JAPANESE",
|
||||
)
|
||||
|
||||
batch_operation_script_preset: bpy.props.EnumProperty(
|
||||
items=BATCH_OPERATION_SCRIPT_PRESET_ITEMS,
|
||||
name="Operation Script Preset",
|
||||
default="NOTHING",
|
||||
update=_update_batch_operation_script_preset.__func__,
|
||||
)
|
||||
|
||||
batch_operation_script: bpy.props.StringProperty()
|
||||
@@ -0,0 +1,455 @@
|
||||
# Copyright 2016 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
import csv
|
||||
from ...core.logging_setup import logger
|
||||
import os
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
import bpy
|
||||
|
||||
from .bpyutils import FnContext
|
||||
|
||||
jp_half_to_full_tuples = (
|
||||
("ヴ", "ヴ"),
|
||||
("ガ", "ガ"),
|
||||
("ギ", "ギ"),
|
||||
("グ", "グ"),
|
||||
("ゲ", "ゲ"),
|
||||
("ゴ", "ゴ"),
|
||||
("ザ", "ザ"),
|
||||
("ジ", "ジ"),
|
||||
("ズ", "ズ"),
|
||||
("ゼ", "ゼ"),
|
||||
("ゾ", "ゾ"),
|
||||
("ダ", "ダ"),
|
||||
("ヂ", "ヂ"),
|
||||
("ヅ", "ヅ"),
|
||||
("デ", "デ"),
|
||||
("ド", "ド"),
|
||||
("バ", "バ"),
|
||||
("パ", "パ"),
|
||||
("ビ", "ビ"),
|
||||
("ピ", "ピ"),
|
||||
("ブ", "ブ"),
|
||||
("プ", "プ"),
|
||||
("ベ", "ベ"),
|
||||
("ペ", "ペ"),
|
||||
("ボ", "ボ"),
|
||||
("ポ", "ポ"),
|
||||
("。", "。"),
|
||||
("「", "「"),
|
||||
("」", "」"),
|
||||
("、", "、"),
|
||||
("・", "・"),
|
||||
("ヲ", "ヲ"),
|
||||
("ァ", "ァ"),
|
||||
("ィ", "ィ"),
|
||||
("ゥ", "ゥ"),
|
||||
("ェ", "ェ"),
|
||||
("ォ", "ォ"),
|
||||
("ャ", "ャ"),
|
||||
("ュ", "ュ"),
|
||||
("ョ", "ョ"),
|
||||
("ッ", "ッ"),
|
||||
("ー", "ー"),
|
||||
("ア", "ア"),
|
||||
("イ", "イ"),
|
||||
("ウ", "ウ"),
|
||||
("エ", "エ"),
|
||||
("オ", "オ"),
|
||||
("カ", "カ"),
|
||||
("キ", "キ"),
|
||||
("ク", "ク"),
|
||||
("ケ", "ケ"),
|
||||
("コ", "コ"),
|
||||
("サ", "サ"),
|
||||
("シ", "シ"),
|
||||
("ス", "ス"),
|
||||
("セ", "セ"),
|
||||
("ソ", "ソ"),
|
||||
("タ", "タ"),
|
||||
("チ", "チ"),
|
||||
("ツ", "ツ"),
|
||||
("テ", "テ"),
|
||||
("ト", "ト"),
|
||||
("ナ", "ナ"),
|
||||
("ニ", "ニ"),
|
||||
("ヌ", "ヌ"),
|
||||
("ネ", "ネ"),
|
||||
("ノ", "ノ"),
|
||||
("ハ", "ハ"),
|
||||
("ヒ", "ヒ"),
|
||||
("フ", "フ"),
|
||||
("ヘ", "ヘ"),
|
||||
("ホ", "ホ"),
|
||||
("マ", "マ"),
|
||||
("ミ", "ミ"),
|
||||
("ム", "ム"),
|
||||
("メ", "メ"),
|
||||
("モ", "モ"),
|
||||
("ヤ", "ヤ"),
|
||||
("ユ", "ユ"),
|
||||
("ヨ", "ヨ"),
|
||||
("ラ", "ラ"),
|
||||
("リ", "リ"),
|
||||
("ル", "ル"),
|
||||
("レ", "レ"),
|
||||
("ロ", "ロ"),
|
||||
("ワ", "ワ"),
|
||||
("ン", "ン"),
|
||||
)
|
||||
|
||||
jp_to_en_tuples = [
|
||||
("全ての親", "ParentNode"),
|
||||
("操作中心", "ControlNode"),
|
||||
("センター", "Center"),
|
||||
("センター", "Center"),
|
||||
("グループ", "Group"),
|
||||
("グルーブ", "Groove"),
|
||||
("キャンセル", "Cancel"),
|
||||
("上半身", "UpperBody"),
|
||||
("下半身", "LowerBody"),
|
||||
("手首", "Wrist"),
|
||||
("足首", "Ankle"),
|
||||
("首", "Neck"),
|
||||
("頭", "Head"),
|
||||
("顔", "Face"),
|
||||
("下顎", "Chin"),
|
||||
("下あご", "Chin"),
|
||||
("あご", "Jaw"),
|
||||
("顎", "Jaw"),
|
||||
("両目", "Eyes"),
|
||||
("目", "Eye"),
|
||||
("眉", "Eyebrow"),
|
||||
("舌", "Tongue"),
|
||||
("涙", "Tears"),
|
||||
("泣き", "Cry"),
|
||||
("歯", "Teeth"),
|
||||
("照れ", "Blush"),
|
||||
("青ざめ", "Pale"),
|
||||
("ガーン", "Gloom"),
|
||||
("汗", "Sweat"),
|
||||
("怒", "Anger"),
|
||||
("感情", "Emotion"),
|
||||
("符", "Marks"),
|
||||
("暗い", "Dark"),
|
||||
("腰", "Waist"),
|
||||
("髪", "Hair"),
|
||||
("三つ編み", "Braid"),
|
||||
("胸", "Breast"),
|
||||
("乳", "Boob"),
|
||||
("おっぱい", "Tits"),
|
||||
("筋", "Muscle"),
|
||||
("腹", "Belly"),
|
||||
("鎖骨", "Clavicle"),
|
||||
("肩", "Shoulder"),
|
||||
("腕", "Arm"),
|
||||
("うで", "Arm"),
|
||||
("ひじ", "Elbow"),
|
||||
("肘", "Elbow"),
|
||||
("手", "Hand"),
|
||||
("親指", "Thumb"),
|
||||
("人指", "IndexFinger"),
|
||||
("人差指", "IndexFinger"),
|
||||
("中指", "MiddleFinger"),
|
||||
("薬指", "RingFinger"),
|
||||
("小指", "LittleFinger"),
|
||||
("足", "Leg"),
|
||||
("ひざ", "Knee"),
|
||||
("つま", "Toe"),
|
||||
("袖", "Sleeve"),
|
||||
("新規", "New"),
|
||||
("ボーン", "Bone"),
|
||||
("捩", "Twist"),
|
||||
("回転", "Rotation"),
|
||||
("軸", "Axis"),
|
||||
("ネクタイ", "Necktie"),
|
||||
("ネクタイ", "Necktie"),
|
||||
("ヘッドセット", "Headset"),
|
||||
("飾り", "Accessory"),
|
||||
("リボン", "Ribbon"),
|
||||
("襟", "Collar"),
|
||||
("紐", "String"),
|
||||
("コード", "Cord"),
|
||||
("イヤリング", "Earring"),
|
||||
("メガネ", "Eyeglasses"),
|
||||
("眼鏡", "Glasses"),
|
||||
("帽子", "Hat"),
|
||||
("スカート", "Skirt"),
|
||||
("スカート", "Skirt"),
|
||||
("パンツ", "Pantsu"),
|
||||
("シャツ", "Shirt"),
|
||||
("フリル", "Frill"),
|
||||
("マフラー", "Muffler"),
|
||||
("マフラー", "Muffler"),
|
||||
("服", "Clothes"),
|
||||
("ブーツ", "Boots"),
|
||||
("ねこみみ", "CatEars"),
|
||||
("ジップ", "Zip"),
|
||||
("ジップ", "Zip"),
|
||||
("ダミー", "Dummy"),
|
||||
("ダミー", "Dummy"),
|
||||
("基", "Category"),
|
||||
("あほ毛", "Antenna"),
|
||||
("アホ毛", "Antenna"),
|
||||
("モミアゲ", "Sideburn"),
|
||||
("もみあげ", "Sideburn"),
|
||||
("ツインテ", "Twintail"),
|
||||
("おさげ", "Pigtail"),
|
||||
("ひらひら", "Flutter"),
|
||||
("調整", "Adjustment"),
|
||||
("補助", "Aux"),
|
||||
("右", "Right"),
|
||||
("左", "Left"),
|
||||
("前", "Front"),
|
||||
("後ろ", "Behind"),
|
||||
("後", "Back"),
|
||||
("横", "Side"),
|
||||
("中", "Middle"),
|
||||
("上", "Upper"),
|
||||
("下", "Lower"),
|
||||
("親", "Parent"),
|
||||
("先", "Tip"),
|
||||
("パーツ", "Part"),
|
||||
("光", "Light"),
|
||||
("戻", "Return"),
|
||||
("羽", "Wing"),
|
||||
("根", "Base"), # ideally 'Root' but to avoid confusion
|
||||
("毛", "Strand"),
|
||||
("尾", "Tail"),
|
||||
("尻", "Butt"),
|
||||
# full-width unicode forms I think: https://en.wikipedia.org/wiki/Halfwidth_and_fullwidth_forms
|
||||
("0", "0"),
|
||||
("1", "1"),
|
||||
("2", "2"),
|
||||
("3", "3"),
|
||||
("4", "4"),
|
||||
("5", "5"),
|
||||
("6", "6"),
|
||||
("7", "7"),
|
||||
("8", "8"),
|
||||
("9", "9"),
|
||||
("a", "a"),
|
||||
("b", "b"),
|
||||
("c", "c"),
|
||||
("d", "d"),
|
||||
("e", "e"),
|
||||
("f", "f"),
|
||||
("g", "g"),
|
||||
("h", "h"),
|
||||
("i", "i"),
|
||||
("j", "j"),
|
||||
("k", "k"),
|
||||
("l", "l"),
|
||||
("m", "m"),
|
||||
("n", "n"),
|
||||
("o", "o"),
|
||||
("p", "p"),
|
||||
("q", "q"),
|
||||
("r", "r"),
|
||||
("s", "s"),
|
||||
("t", "t"),
|
||||
("u", "u"),
|
||||
("v", "v"),
|
||||
("w", "w"),
|
||||
("x", "x"),
|
||||
("y", "y"),
|
||||
("z", "z"),
|
||||
("A", "A"),
|
||||
("B", "B"),
|
||||
("C", "C"),
|
||||
("D", "D"),
|
||||
("E", "E"),
|
||||
("F", "F"),
|
||||
("G", "G"),
|
||||
("H", "H"),
|
||||
("I", "I"),
|
||||
("J", "J"),
|
||||
("K", "K"),
|
||||
("L", "L"),
|
||||
("M", "M"),
|
||||
("N", "N"),
|
||||
("O", "O"),
|
||||
("P", "P"),
|
||||
("Q", "Q"),
|
||||
("R", "R"),
|
||||
("S", "S"),
|
||||
("T", "T"),
|
||||
("U", "U"),
|
||||
("V", "V"),
|
||||
("W", "W"),
|
||||
("X", "X"),
|
||||
("Y", "Y"),
|
||||
("Z", "Z"),
|
||||
("+", "+"),
|
||||
("-", "-"),
|
||||
("_", "_"),
|
||||
("/", "/"),
|
||||
(".", "_"), # probably should be combined with the global 'use underscore' option
|
||||
]
|
||||
|
||||
|
||||
def translateFromJp(name):
|
||||
for t in jp_to_en_tuples:
|
||||
if t[0] in name:
|
||||
name = name.replace(t[0], t[1])
|
||||
return name
|
||||
|
||||
|
||||
def getTranslator(csvfile="", keep_order=False):
|
||||
translator = MMDTranslator()
|
||||
if isinstance(csvfile, bpy.types.Text):
|
||||
translator.load_from_stream(csvfile)
|
||||
elif isinstance(csvfile, dict):
|
||||
translator.csv_tuples.extend(csvfile.items())
|
||||
elif csvfile in bpy.data.texts.keys():
|
||||
translator.load_from_stream(bpy.data.texts[csvfile])
|
||||
else:
|
||||
translator.load(csvfile)
|
||||
|
||||
if not keep_order:
|
||||
translator.sort()
|
||||
translator.update()
|
||||
return translator
|
||||
|
||||
|
||||
class MMDTranslator:
|
||||
def __init__(self):
|
||||
self.__csv_tuples = []
|
||||
self.__fails = {}
|
||||
|
||||
@staticmethod
|
||||
def default_csv_filepath():
|
||||
return __file__[:-3] + ".csv"
|
||||
|
||||
@staticmethod
|
||||
def get_csv_text(text_name=None):
|
||||
text_name = text_name or bpy.path.basename(MMDTranslator.default_csv_filepath())
|
||||
csv_text = bpy.data.texts.get(text_name, None)
|
||||
if csv_text is None:
|
||||
csv_text = bpy.data.texts.new(text_name)
|
||||
return csv_text
|
||||
|
||||
@staticmethod
|
||||
def replace_from_tuples(name, tuples):
|
||||
for pair in tuples:
|
||||
if pair[0] in name:
|
||||
name = name.replace(pair[0], pair[1])
|
||||
return name
|
||||
|
||||
@property
|
||||
def csv_tuples(self):
|
||||
return self.__csv_tuples
|
||||
|
||||
@property
|
||||
def fails(self):
|
||||
return self.__fails
|
||||
|
||||
def sort(self):
|
||||
self.__csv_tuples.sort(key=lambda row: (-len(row[0]), row))
|
||||
|
||||
def update(self):
|
||||
count_old = len(self.__csv_tuples)
|
||||
tuples_dict = OrderedDict((row[0], row) for row in self.__csv_tuples if len(row) >= 2 and row[0])
|
||||
self.__csv_tuples.clear()
|
||||
self.__csv_tuples.extend(tuples_dict.values())
|
||||
logger.info(" - removed items:\t%d\t(of %d)", count_old - len(self.__csv_tuples), count_old)
|
||||
|
||||
def half_to_full(self, name):
|
||||
return self.replace_from_tuples(name, jp_half_to_full_tuples)
|
||||
|
||||
def is_translated(self, name):
|
||||
try:
|
||||
name.encode("ascii", errors="strict")
|
||||
except UnicodeEncodeError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def translate(self, name, default=None, from_full_width=True):
|
||||
if from_full_width:
|
||||
name = self.half_to_full(name)
|
||||
name_new = self.replace_from_tuples(name, self.__csv_tuples)
|
||||
if default is not None and not self.is_translated(name_new):
|
||||
self.__fails[name] = name_new
|
||||
return default
|
||||
return name_new
|
||||
|
||||
def save_fails(self, text_name=None):
|
||||
text_name = text_name or (__name__ + ".fails")
|
||||
txt = self.get_csv_text(text_name)
|
||||
fmt = '"%s","%s"'
|
||||
items = sorted(self.__fails.items(), key=lambda row: (-len(row[0]), row))
|
||||
txt.from_string("\n".join(fmt % (k, v) for k, v in items))
|
||||
return txt
|
||||
|
||||
def load_from_stream(self, csvfile=None):
|
||||
csvfile = csvfile or self.get_csv_text()
|
||||
if isinstance(csvfile, bpy.types.Text):
|
||||
csvfile = (line.body + "\n" for line in csvfile.lines)
|
||||
spamreader = csv.reader(csvfile, delimiter=",", skipinitialspace=True)
|
||||
csv_tuples = [tuple(row) for row in spamreader if len(row) >= 2]
|
||||
self.__csv_tuples = csv_tuples
|
||||
logger.info(" - load items:\t%d", len(self.__csv_tuples))
|
||||
|
||||
def save_to_stream(self, csvfile=None):
|
||||
csvfile = csvfile or self.get_csv_text()
|
||||
lineterminator = "\r\n"
|
||||
if isinstance(csvfile, bpy.types.Text):
|
||||
csvfile.clear()
|
||||
lineterminator = "\n"
|
||||
spamwriter = csv.writer(csvfile, delimiter=",", lineterminator=lineterminator, quoting=csv.QUOTE_ALL)
|
||||
spamwriter.writerows(self.__csv_tuples)
|
||||
logger.info(" - save items:\t%d", len(self.__csv_tuples))
|
||||
|
||||
def load(self, filepath=None):
|
||||
filepath = filepath or self.default_csv_filepath()
|
||||
logger.info("Loading csv file:\t%s", filepath)
|
||||
with open(filepath, encoding="utf-8", newline="") as csvfile:
|
||||
self.load_from_stream(csvfile)
|
||||
|
||||
def save(self, filepath=None):
|
||||
filepath = filepath or self.default_csv_filepath()
|
||||
logger.info("Saving csv file:\t%s", filepath)
|
||||
with open(filepath, "w", encoding="utf-8", newline="") as csvfile:
|
||||
self.save_to_stream(csvfile)
|
||||
|
||||
|
||||
class DictionaryEnum:
|
||||
__items_ttl = 0.0
|
||||
__items_cache = None
|
||||
|
||||
@staticmethod
|
||||
def get_dictionary_items(prop, context):
|
||||
if DictionaryEnum.__items_ttl > time.time():
|
||||
return DictionaryEnum.__items_cache
|
||||
|
||||
DictionaryEnum.__items_ttl = time.time() + 5
|
||||
DictionaryEnum.__items_cache = items = []
|
||||
if "import" in prop.bl_rna.identifier:
|
||||
items.append(("DISABLED", "Disabled", "", 0))
|
||||
|
||||
items.append(("INTERNAL", "Internal Dictionary", "The dictionary defined in " + __name__, len(items)))
|
||||
|
||||
for txt_name in sorted(x.name for x in bpy.data.texts if x.name.lower().endswith(".csv")):
|
||||
items.append((txt_name, txt_name, f"bpy.data.texts['{txt_name}']", "TEXT", len(items)))
|
||||
|
||||
folder = FnContext.get_addon_preferences_attribute(context, "dictionary_folder", "")
|
||||
if os.path.isdir(folder):
|
||||
for filename in sorted(x for x in os.listdir(folder) if x.lower().endswith(".csv")):
|
||||
filepath = os.path.join(folder, filename)
|
||||
if os.path.isfile(filepath):
|
||||
items.append((filepath, filename, filepath, "FILE", len(items)))
|
||||
|
||||
if "dictionary" in prop:
|
||||
prop["dictionary"] = min(prop["dictionary"], len(items) - 1)
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def get_translator(dictionary):
|
||||
if dictionary == "DISABLED":
|
||||
return None
|
||||
if dictionary == "INTERNAL":
|
||||
return getTranslator(dict(jp_to_en_tuples))
|
||||
return getTranslator(dictionary)
|
||||
@@ -0,0 +1,360 @@
|
||||
# Copyright 2012 MMD Tools authors
|
||||
# This file is part of MMD Tools.
|
||||
|
||||
from ...core.logging_setup import logger
|
||||
import os
|
||||
import re
|
||||
import string
|
||||
from typing import Callable, Optional, Set
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
from .bpyutils import FnContext
|
||||
|
||||
|
||||
# 指定したオブジェクトのみを選択状態かつアクティブにする
|
||||
def selectAObject(obj):
|
||||
try:
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
except Exception:
|
||||
pass
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
FnContext.select_object(FnContext.ensure_context(), obj)
|
||||
FnContext.set_active_object(FnContext.ensure_context(), obj)
|
||||
|
||||
|
||||
# 現在のモードを指定したオブジェクトのEdit Modeに変更する
|
||||
def enterEditMode(obj):
|
||||
selectAObject(obj)
|
||||
if obj.mode != "EDIT":
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
|
||||
|
||||
def setParentToBone(obj, parent, bone_name):
|
||||
selectAObject(obj)
|
||||
FnContext.set_active_object(FnContext.ensure_context(), parent)
|
||||
bpy.ops.object.mode_set(mode="POSE")
|
||||
parent.data.bones.active = parent.data.bones[bone_name]
|
||||
bpy.ops.object.parent_set(type="BONE", xmirror=False, keep_transform=False)
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
|
||||
|
||||
def selectSingleBone(context, armature, bone_name, reset_pose=False):
|
||||
try:
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to set object mode: {e}")
|
||||
for i in context.selected_objects:
|
||||
i.select_set(False)
|
||||
FnContext.set_active_object(context, armature)
|
||||
bpy.ops.object.mode_set(mode="POSE")
|
||||
if reset_pose:
|
||||
for p_bone in armature.pose.bones:
|
||||
p_bone.matrix_basis.identity()
|
||||
|
||||
for p_bone in armature.pose.bones:
|
||||
is_target = p_bone.name == bone_name
|
||||
p_bone.select = is_target
|
||||
if is_target:
|
||||
armature.data.bones.active = p_bone.bone
|
||||
p_bone.bone.hide = False
|
||||
|
||||
|
||||
__CONVERT_NAME_TO_L_REGEXP = re.compile(r"^(.*)左(.*)$")
|
||||
__CONVERT_NAME_TO_R_REGEXP = re.compile(r"^(.*)右(.*)$")
|
||||
|
||||
|
||||
# 日本語で左右を命名されている名前をblender方式のL(R)に変更する
|
||||
def convertNameToLR(name, use_underscore=False):
|
||||
m = __CONVERT_NAME_TO_L_REGEXP.match(name)
|
||||
delimiter = "_" if use_underscore else "."
|
||||
if m:
|
||||
name = m.group(1) + m.group(2) + delimiter + "L"
|
||||
m = __CONVERT_NAME_TO_R_REGEXP.match(name)
|
||||
if m:
|
||||
name = m.group(1) + m.group(2) + delimiter + "R"
|
||||
return name
|
||||
|
||||
|
||||
__CONVERT_L_TO_NAME_REGEXP = re.compile(r"(?P<lr>(?P<separator>[._])[lL])(?P<after>($|(?P=separator)))")
|
||||
__CONVERT_R_TO_NAME_REGEXP = re.compile(r"(?P<lr>(?P<separator>[._])[rR])(?P<after>($|(?P=separator)))")
|
||||
|
||||
|
||||
def convertLRToName(name):
|
||||
match = __CONVERT_L_TO_NAME_REGEXP.search(name)
|
||||
if match:
|
||||
return f"左{name[0:match.start()]}{match['after']}{name[match.end():]}"
|
||||
|
||||
match = __CONVERT_R_TO_NAME_REGEXP.search(name)
|
||||
if match:
|
||||
return f"右{name[0:match.start()]}{match['after']}{name[match.end():]}"
|
||||
|
||||
return name
|
||||
|
||||
|
||||
# src_vertex_groupのWeightをdest_vertex_groupにaddする
|
||||
def mergeVertexGroup(meshObj, src_vertex_group_name, dest_vertex_group_name):
|
||||
mesh = meshObj.data
|
||||
src_vertex_group = meshObj.vertex_groups[src_vertex_group_name]
|
||||
dest_vertex_group = meshObj.vertex_groups[dest_vertex_group_name]
|
||||
|
||||
vtxIndex = src_vertex_group.index
|
||||
for v in mesh.vertices:
|
||||
try:
|
||||
gi = [i.group for i in v.groups].index(vtxIndex)
|
||||
dest_vertex_group.add([v.index], v.groups[gi].weight, "ADD")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def separateByMaterials(meshObj: bpy.types.Object, keep_normals: bool = False):
|
||||
meshData = meshObj.data
|
||||
if len(meshData.materials) < 2:
|
||||
selectAObject(meshObj)
|
||||
return
|
||||
|
||||
dummy_parent = None
|
||||
try:
|
||||
dummy_parent = bpy.data.objects.new(name="tmp", object_data=None)
|
||||
matrix_parent_inverse = meshObj.matrix_parent_inverse.copy()
|
||||
prev_parent = meshObj.parent
|
||||
meshObj.parent = dummy_parent
|
||||
meshObj.active_shape_key_index = 0
|
||||
mmd_normal_name = None # To avoid conflict ("mmd_normal.001", etc.)
|
||||
if keep_normals:
|
||||
existing_custom_normal = meshData.attributes.get("custom_normal")
|
||||
if existing_custom_normal:
|
||||
if existing_custom_normal.data_type == "INT16_2D":
|
||||
normals_data = np.empty(len(meshData.loops) * 2, dtype=np.int16)
|
||||
existing_custom_normal.data.foreach_get("value", normals_data)
|
||||
mmd_normal = meshData.attributes.new("mmd_normal", "INT16_2D", "CORNER")
|
||||
mmd_normal_name = mmd_normal.name
|
||||
mmd_normal.data.foreach_set("value", normals_data)
|
||||
else:
|
||||
raise TypeError(f"Unsupported custom_normal data type: '{existing_custom_normal.data_type}'. Supported types: 'INT16_2D'")
|
||||
|
||||
try:
|
||||
enterEditMode(meshObj)
|
||||
bpy.ops.mesh.separate(type="MATERIAL")
|
||||
finally:
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
|
||||
for i in dummy_parent.children:
|
||||
materials = i.data.materials
|
||||
i.name = getattr(materials[0], "name", "None") if len(materials) else "None"
|
||||
i.parent = prev_parent
|
||||
i.matrix_parent_inverse = matrix_parent_inverse
|
||||
|
||||
if keep_normals and mmd_normal_name:
|
||||
mmd_normal = i.data.attributes.get(mmd_normal_name)
|
||||
if mmd_normal:
|
||||
if mmd_normal.data_type == "INT16_2D":
|
||||
normals_data = np.empty(len(i.data.loops) * 2, dtype=np.int16)
|
||||
mmd_normal.data.foreach_get("value", normals_data)
|
||||
custom_normal_attr = i.data.attributes.get("custom_normal")
|
||||
if not custom_normal_attr:
|
||||
custom_normal_attr = i.data.attributes.new("custom_normal", "INT16_2D", "CORNER")
|
||||
custom_normal_attr.data.foreach_set("value", normals_data)
|
||||
else:
|
||||
raise TypeError(f"Unsupported custom_normal data type: '{mmd_normal.data_type}'. Supported types: 'INT16_2D'")
|
||||
i.data.attributes.remove(mmd_normal)
|
||||
finally:
|
||||
if dummy_parent and dummy_parent.name in bpy.data.objects:
|
||||
bpy.data.objects.remove(dummy_parent)
|
||||
|
||||
|
||||
def clearUnusedMeshes():
|
||||
meshes_to_delete = [mesh for mesh in bpy.data.meshes if mesh.users == 0]
|
||||
|
||||
for mesh in meshes_to_delete:
|
||||
bpy.data.meshes.remove(mesh)
|
||||
|
||||
|
||||
# Boneのカスタムプロパティにname_jが存在する場合、name_jの値を
|
||||
# それ以外の場合は通常のbone名をキーとしたpose_boneへの辞書を作成
|
||||
def makePmxBoneMap(armObj):
|
||||
# Maintain backward compatibility with mmd_tools_local v0.4.x or older.
|
||||
return {(i.mmd_bone.name_j or i.get("mmd_bone_name_j", i.get("name_j", i.name))): i for i in armObj.pose.bones}
|
||||
|
||||
|
||||
__REMOVE_PREFIX_DIGITS_REGEXP = re.compile(r"\.\d{1,}$")
|
||||
|
||||
|
||||
def unique_name(name: str, used_names: Set[str]) -> str:
|
||||
"""Generate a unique name from the given name.
|
||||
This function is a limited and simplified version of bpy_extras.io_utils.unique_name.
|
||||
|
||||
Args:
|
||||
name (str): The name to make unique.
|
||||
used_names (Set[str]): A set of names that are already used.
|
||||
|
||||
Returns:
|
||||
str: The unique name, formatted as "{name}.{number:03d}".
|
||||
"""
|
||||
if name not in used_names:
|
||||
return name
|
||||
count = 1
|
||||
new_name = orig_name = __REMOVE_PREFIX_DIGITS_REGEXP.sub("", name)
|
||||
while new_name in used_names:
|
||||
new_name = f"{orig_name}.{count:03d}"
|
||||
count += 1
|
||||
return new_name
|
||||
|
||||
|
||||
def int2base(x, base, width=0):
|
||||
"""
|
||||
Convert an int to a base
|
||||
Source: http://stackoverflow.com/questions/2267362
|
||||
"""
|
||||
digs = string.digits + string.ascii_uppercase
|
||||
assert 2 <= base <= len(digs)
|
||||
digits, negtive = "", False
|
||||
if x <= 0:
|
||||
if x == 0:
|
||||
return "0" * max(1, width)
|
||||
x, negtive, width = -x, True, width - 1
|
||||
while x:
|
||||
digits = digs[x % base] + digits
|
||||
x //= base
|
||||
digits = "0" * (width - len(digits)) + digits
|
||||
if negtive:
|
||||
digits = "-" + digits
|
||||
return digits
|
||||
|
||||
|
||||
def saferelpath(path, start, strategy="inside"):
|
||||
"""
|
||||
On Windows relpath will raise a ValueError
|
||||
when trying to calculate the relative path to a
|
||||
different drive.
|
||||
This method will behave different depending on the strategy
|
||||
choosen to handle the different drive issue.
|
||||
Strategies:
|
||||
- inside: this will just return the basename of the path given
|
||||
- outside: this will prepend '..' to the basename
|
||||
- absolute: this will return the absolute path instead of a relative.
|
||||
See http://bugs.python.org/issue7195
|
||||
"""
|
||||
if strategy == "inside":
|
||||
return os.path.basename(path)
|
||||
|
||||
if strategy == "absolute":
|
||||
return os.path.abspath(path)
|
||||
|
||||
if strategy == "outside" and os.name == "nt":
|
||||
d1, _ = os.path.splitdrive(path)
|
||||
d2, _ = os.path.splitdrive(start)
|
||||
if d1 != d2:
|
||||
return ".." + os.sep + os.path.basename(path)
|
||||
|
||||
return os.path.relpath(path, start)
|
||||
|
||||
|
||||
class ItemOp:
|
||||
@staticmethod
|
||||
def get_by_index(items, index):
|
||||
if 0 <= index < len(items):
|
||||
return items[index]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def resize(items: bpy.types.bpy_prop_collection, length: int):
|
||||
count = length - len(items)
|
||||
if count > 0:
|
||||
for i in range(count):
|
||||
items.add()
|
||||
elif count < 0:
|
||||
for i in range(-count):
|
||||
items.remove(length)
|
||||
|
||||
@staticmethod
|
||||
def add_after(items, index):
|
||||
index_end = len(items)
|
||||
index = max(0, min(index_end, index + 1))
|
||||
items.add()
|
||||
items.move(index_end, index)
|
||||
return items[index], index
|
||||
|
||||
|
||||
class ItemMoveOp:
|
||||
type: bpy.props.EnumProperty(
|
||||
name="Type",
|
||||
description="Move type",
|
||||
items=[
|
||||
("UP", "Up", "", 0),
|
||||
("DOWN", "Down", "", 1),
|
||||
("TOP", "Top", "", 2),
|
||||
("BOTTOM", "Bottom", "", 3),
|
||||
],
|
||||
default="UP",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def move(items, index, move_type, index_min=0, index_max=None):
|
||||
if index_max is None:
|
||||
index_max = len(items) - 1
|
||||
else:
|
||||
index_max = min(index_max, len(items) - 1)
|
||||
index_min = min(index_min, index_max)
|
||||
|
||||
if index < index_min:
|
||||
items.move(index, index_min)
|
||||
return index_min
|
||||
if index > index_max:
|
||||
items.move(index, index_max)
|
||||
return index_max
|
||||
|
||||
index_new = index
|
||||
if move_type == "UP":
|
||||
index_new = max(index_min, index - 1)
|
||||
elif move_type == "DOWN":
|
||||
index_new = min(index + 1, index_max)
|
||||
elif move_type == "TOP":
|
||||
index_new = index_min
|
||||
elif move_type == "BOTTOM":
|
||||
index_new = index_max
|
||||
|
||||
if index_new != index:
|
||||
items.move(index, index_new)
|
||||
return index_new
|
||||
|
||||
|
||||
def deprecated(deprecated_in: Optional[str] = None, details: Optional[str] = None):
|
||||
"""Mark a function as deprecated.
|
||||
Args:
|
||||
deprecated_in (Optional[str]): Version in which the function was deprecated.
|
||||
details (Optional[str]): Additional details about the deprecation.
|
||||
Returns:
|
||||
Callable: The decorated function.
|
||||
"""
|
||||
|
||||
def _function_wrapper(function: Callable):
|
||||
def _inner_wrapper(*args, **kwargs):
|
||||
warn_deprecation(function.__name__, deprecated_in, details)
|
||||
return function(*args, **kwargs)
|
||||
|
||||
return _inner_wrapper
|
||||
|
||||
return _function_wrapper
|
||||
|
||||
|
||||
def warn_deprecation(function_name: str, deprecated_in: Optional[str] = None, details: Optional[str] = None) -> None:
|
||||
"""Report a deprecation warning.
|
||||
Args:
|
||||
function_name (str): Name of the deprecated function.
|
||||
deprecated_in (Optional[str]): Version in which the function was deprecated.
|
||||
details (Optional[str]): Additional details about the deprecation.
|
||||
"""
|
||||
logger.warning(
|
||||
"%s is deprecated%s%s",
|
||||
function_name,
|
||||
f" since {deprecated_in}" if deprecated_in else "",
|
||||
f": {details}" if details else "",
|
||||
stack_info=True,
|
||||
stacklevel=4,
|
||||
)
|
||||
|
||||
# import warnings # pylint: disable=import-outside-toplevel
|
||||
|
||||
# warnings.warn(f"""{function_name}is deprecated{f" since {deprecated_in}" if deprecated_in else ""}{f": {details}" if details else ""}""", category=DeprecationWarning, stacklevel=2)
|
||||
@@ -0,0 +1,152 @@
|
||||
# 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
|
||||
+608
-27
@@ -14,15 +14,33 @@ from .logging_setup import logger
|
||||
from .translations import t, get_languages_list, update_language
|
||||
from .addon_preferences import get_preference, save_preference
|
||||
from .updater import get_version_list
|
||||
from .common import get_armature_list, get_active_armature, get_all_meshes
|
||||
from .common import get_armature_list, get_active_armature, get_all_meshes, SceneMatClass
|
||||
from ..functions.visemes import VisemePreview
|
||||
from ..functions.eye_tracking import set_rotation
|
||||
|
||||
class ValidationMessageItem(PropertyGroup):
|
||||
"""Property group for validation message items"""
|
||||
name: StringProperty(name="Message")
|
||||
|
||||
class ZeroWeightBoneItem(PropertyGroup):
|
||||
"""Property group for zero weight bone list items"""
|
||||
name: StringProperty(name="Bone Name")
|
||||
selected: BoolProperty(name="Selected", default=True)
|
||||
has_children: BoolProperty(name="Has Children", default=False)
|
||||
is_deform: BoolProperty(name="Is Deform Bone", default=False)
|
||||
|
||||
|
||||
def update_validation_mode(self: PropertyGroup, context: Context) -> None:
|
||||
"""Updates validation mode and saves preference"""
|
||||
logger.info(f"Updating validation mode to: {self.validation_mode}")
|
||||
save_preference("validation_mode", self.validation_mode)
|
||||
|
||||
# Hide validation results if mode is set to NONE
|
||||
if self.validation_mode == 'NONE':
|
||||
self.show_validation_results = False
|
||||
logger.debug("Validation mode set to NONE, hiding validation results")
|
||||
|
||||
|
||||
def update_logging_state(self: PropertyGroup, context: Context) -> None:
|
||||
"""Updates logging state and configures logging"""
|
||||
logger.info(f"Updating logging state to: {self.enable_logging}")
|
||||
@@ -30,14 +48,256 @@ def update_logging_state(self: PropertyGroup, context: Context) -> None:
|
||||
from .logging_setup import configure_logging
|
||||
configure_logging(self.enable_logging)
|
||||
|
||||
def update_log_level(self: PropertyGroup, context: Context) -> None:
|
||||
"""Updates log level and configures logging"""
|
||||
logger.info(f"Updating log level to: {self.log_level}")
|
||||
save_preference("log_level", self.log_level)
|
||||
from .logging_setup import configure_logging
|
||||
configure_logging(self.enable_logging, self.log_level)
|
||||
|
||||
|
||||
def update_shape_intensity(self: PropertyGroup, context: Context) -> None:
|
||||
"""Updates shape key intensity and refreshes preview"""
|
||||
if self.viseme_preview_mode:
|
||||
VisemePreview.update_preview(context)
|
||||
|
||||
def highlight_problem_bones(self: PropertyGroup, context: Context) -> None:
|
||||
"""Updates problem bone highlighting state and saves preference"""
|
||||
logger.info(f"Updating problem bone highlighting to: {self.highlight_problem_bones}")
|
||||
save_preference("highlight_problem_bones", self.highlight_problem_bones)
|
||||
|
||||
def get_mesh_objects(self, context):
|
||||
"""Get list of all mesh objects with ASCII-safe identifiers
|
||||
|
||||
Returns tuples of (identifier, display_name, description) where:
|
||||
- identifier: ASCII-safe unique ID (uses object's memory address)
|
||||
- display_name: The actual object name (can contain Japanese/non-ASCII characters)
|
||||
- description: Empty string
|
||||
|
||||
Uses caching to prevent encoding issues with Blender's EnumProperty system
|
||||
"""
|
||||
# Create a cache key based on mesh objects
|
||||
mesh_objects = [obj for obj in bpy.data.objects if obj.type == 'MESH']
|
||||
cache_key = tuple((obj.name, obj.as_pointer()) for obj in mesh_objects)
|
||||
|
||||
# Check if we have a cached result
|
||||
if hasattr(get_mesh_objects, '_cache_key') and get_mesh_objects._cache_key == cache_key:
|
||||
if hasattr(get_mesh_objects, '_cached_items'):
|
||||
return get_mesh_objects._cached_items
|
||||
|
||||
# Build the list
|
||||
meshes = []
|
||||
for obj in mesh_objects:
|
||||
safe_id = f"MESH_{obj.as_pointer()}"
|
||||
# Use the name directly - Blender should handle Unicode in display names
|
||||
display_name = obj.name
|
||||
meshes.append((safe_id, display_name, ""))
|
||||
|
||||
if not meshes:
|
||||
result = [('NONE', t("Visemes.no_meshes"), '')]
|
||||
else:
|
||||
result = meshes
|
||||
|
||||
# Cache the result
|
||||
get_mesh_objects._cache_key = cache_key
|
||||
get_mesh_objects._cached_items = result
|
||||
|
||||
return result
|
||||
|
||||
def auto_populate_merge_armatures(context: Context) -> None:
|
||||
"""Auto-populate merge armature fields when there are 2+ armatures"""
|
||||
armatures = [obj for obj in bpy.data.objects if obj.type == 'ARMATURE']
|
||||
|
||||
if len(armatures) >= 2:
|
||||
toolkit = context.scene.avatar_toolkit
|
||||
|
||||
if not toolkit.merge_armature_into and not toolkit.merge_armature:
|
||||
toolkit.merge_armature_into = armatures[0].name
|
||||
toolkit.merge_armature = armatures[1].name
|
||||
logger.debug(f"Auto-populated merge armatures: {armatures[0].name} <- {armatures[1].name}")
|
||||
|
||||
elif toolkit.merge_armature_into and not toolkit.merge_armature:
|
||||
for armature in armatures:
|
||||
if armature.name != toolkit.merge_armature_into:
|
||||
toolkit.merge_armature = armature.name
|
||||
logger.debug(f"Auto-populated merge_armature: {armature.name}")
|
||||
break
|
||||
|
||||
elif not toolkit.merge_armature_into and toolkit.merge_armature:
|
||||
for armature in armatures:
|
||||
if armature.name != toolkit.merge_armature:
|
||||
toolkit.merge_armature_into = armature.name
|
||||
logger.debug(f"Auto-populated merge_armature_into: {armature.name}")
|
||||
break
|
||||
|
||||
def update_merge_armature_into(self: PropertyGroup, context: Context) -> None:
|
||||
"""Update function for merge_armature_into property"""
|
||||
auto_populate_merge_armatures(context)
|
||||
|
||||
def update_merge_armature(self: PropertyGroup, context: Context) -> None:
|
||||
"""Update function for merge_armature property"""
|
||||
auto_populate_merge_armatures(context)
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def depsgraph_update_handler(scene: Scene, depsgraph) -> None:
|
||||
"""Handler to auto-populate merge armatures when objects change"""
|
||||
# Check for any armature-related updates
|
||||
armature_updated = False
|
||||
for update in depsgraph.updates:
|
||||
if hasattr(update, 'id') and update.id and hasattr(update.id, 'type'):
|
||||
if update.id.type == 'ARMATURE':
|
||||
armature_updated = True
|
||||
break
|
||||
|
||||
if armature_updated:
|
||||
# Use a timer to defer the update to avoid context issues
|
||||
bpy.app.timers.register(lambda: auto_populate_safe(), first_interval=0.1)
|
||||
|
||||
def auto_populate_safe() -> None:
|
||||
"""Safe auto-populate function that can be called from timer"""
|
||||
try:
|
||||
if bpy.context and hasattr(bpy.context, 'scene') and hasattr(bpy.context.scene, 'avatar_toolkit'):
|
||||
auto_populate_merge_armatures(bpy.context)
|
||||
except (AttributeError, ReferenceError):
|
||||
pass
|
||||
return None # Don't repeat the timer
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def undo_post_handler(scene: Scene) -> None:
|
||||
"""Handler for undo operations that might add/remove armatures"""
|
||||
bpy.app.timers.register(lambda: auto_populate_safe(), first_interval=0.1)
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def redo_post_handler(scene: Scene) -> None:
|
||||
"""Handler for redo operations that might add/remove armatures"""
|
||||
bpy.app.timers.register(lambda: auto_populate_safe(), first_interval=0.1)
|
||||
|
||||
class AvatarToolkitSceneProperties(PropertyGroup):
|
||||
"""Property group containing Avatar Toolkit scene-level settings and properties"""
|
||||
|
||||
show_found_bones: BoolProperty(
|
||||
name="Show Found Bones",
|
||||
default=False
|
||||
)
|
||||
|
||||
show_non_standard: BoolProperty(
|
||||
name="Show Non-Standard Bones",
|
||||
default=False
|
||||
)
|
||||
|
||||
show_hierarchy: BoolProperty(
|
||||
name="Show Hierarchy Issues",
|
||||
default=False
|
||||
)
|
||||
|
||||
show_validation_results: BoolProperty(
|
||||
name="Show Validation Results",
|
||||
default=False,
|
||||
description="Show the validation results section"
|
||||
)
|
||||
|
||||
material_search_filter: StringProperty(
|
||||
name=t("TextureAtlas.search_materials"),
|
||||
description=t("TextureAtlas.search_materials_desc"),
|
||||
default=""
|
||||
)
|
||||
|
||||
def get_texture_node_list(self: Material, context: Context) -> list[tuple]:
|
||||
if self.use_nodes:
|
||||
Object.Enum = [((i.image.name if i.image else i.name+"_image"),
|
||||
(i.image.name if i.image else "node with no image..."),
|
||||
(i.image.name if i.image else i.name), index+1)
|
||||
for index, i in enumerate(self.node_tree.nodes)
|
||||
if i.bl_idname == "ShaderNodeTexImage"]
|
||||
if not len(Object.Enum):
|
||||
Object.Enum = [(t("TextureAtlas.error.label"),
|
||||
t("TextureAtlas.no_images_error.desc"),
|
||||
t("TextureAtlas.error.label"), 0)]
|
||||
else:
|
||||
Object.Enum = [(t("TextureAtlas.error.label"),
|
||||
t("TextureAtlas.no_nodes_error.desc"),
|
||||
t("TextureAtlas.error.label"), 0)]
|
||||
Object.Enum.append((t("TextureAtlas.none.label"),
|
||||
t("TextureAtlas.none.label"),
|
||||
t("TextureAtlas.none.label"), 0))
|
||||
return Object.Enum
|
||||
|
||||
Material.texture_atlas_albedo = EnumProperty(
|
||||
name=t("TextureAtlas.albedo"),
|
||||
description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.albedo").lower()),
|
||||
default=0,
|
||||
items=get_texture_node_list
|
||||
)
|
||||
|
||||
Material.texture_atlas_normal = EnumProperty(
|
||||
name=t("TextureAtlas.normal"),
|
||||
description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.normal").lower()),
|
||||
default=0,
|
||||
items=get_texture_node_list
|
||||
)
|
||||
|
||||
Material.texture_atlas_emission = EnumProperty(
|
||||
name=t("TextureAtlas.emission"),
|
||||
description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.emission").lower()),
|
||||
default=0,
|
||||
items=get_texture_node_list
|
||||
)
|
||||
|
||||
Material.texture_atlas_ambient_occlusion = EnumProperty(
|
||||
name=t("TextureAtlas.ambient_occlusion"),
|
||||
description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.ambient_occlusion").lower()),
|
||||
default=0,
|
||||
items=get_texture_node_list
|
||||
)
|
||||
|
||||
Material.texture_atlas_height = EnumProperty(
|
||||
name=t("TextureAtlas.height"),
|
||||
description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.height").lower()),
|
||||
default=0,
|
||||
items=get_texture_node_list
|
||||
)
|
||||
|
||||
Material.texture_atlas_roughness = EnumProperty(
|
||||
name=t("TextureAtlas.roughness"),
|
||||
description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.roughness").lower()),
|
||||
default=0,
|
||||
items=get_texture_node_list
|
||||
)
|
||||
|
||||
list_only_mode: BoolProperty(
|
||||
name=t("Tools.list_only_mode"),
|
||||
description=t("Tools.list_only_mode_desc"),
|
||||
default=False
|
||||
)
|
||||
|
||||
Material.include_in_atlas = BoolProperty(
|
||||
name=t("TextureAtlas.include_in_atlas"),
|
||||
description=t("TextureAtlas.include_in_atlas_desc"),
|
||||
default=False
|
||||
)
|
||||
|
||||
Material.material_expanded = BoolProperty(
|
||||
name=t("TextureAtlas.material_expanded"),
|
||||
description=t("TextureAtlas.material_expanded_desc"),
|
||||
default=False
|
||||
)
|
||||
|
||||
texture_atlas_Has_Mat_List_Shown: BoolProperty(
|
||||
name=t("TextureAtlas.list_shown"),
|
||||
description=t("TextureAtlas.list_shown_desc"),
|
||||
default=False
|
||||
)
|
||||
|
||||
texture_atlas_material_index: IntProperty(
|
||||
default=-1,
|
||||
get=lambda self: -1,
|
||||
set=lambda self, context: None
|
||||
)
|
||||
|
||||
materials: CollectionProperty(
|
||||
type=SceneMatClass
|
||||
)
|
||||
|
||||
avatar_toolkit_updater_version_list: EnumProperty(
|
||||
items=get_version_list,
|
||||
name=t("Scene.avatar_toolkit_updater_version_list.name"),
|
||||
@@ -48,6 +308,7 @@ class AvatarToolkitSceneProperties(PropertyGroup):
|
||||
items=get_armature_list,
|
||||
name=t("QuickAccess.select_armature"),
|
||||
description=t("QuickAccess.select_armature"),
|
||||
update=lambda self, context: update_active_armature(self, context)
|
||||
)
|
||||
|
||||
language: EnumProperty(
|
||||
@@ -65,7 +326,7 @@ class AvatarToolkitSceneProperties(PropertyGroup):
|
||||
('BASIC', t("Settings.validation_mode.basic"), t("Settings.validation_mode.basic_desc")),
|
||||
('NONE', t("Settings.validation_mode.none"), t("Settings.validation_mode.none_desc"))
|
||||
],
|
||||
default=get_preference("validation_mode", "STRICT"),
|
||||
default=get_preference("validation_mode", "NONE"),
|
||||
update=update_validation_mode
|
||||
)
|
||||
|
||||
@@ -151,9 +412,10 @@ class AvatarToolkitSceneProperties(PropertyGroup):
|
||||
description=t("Visemes.mouth_ch_desc")
|
||||
)
|
||||
|
||||
viseme_mesh: StringProperty(
|
||||
viseme_mesh: EnumProperty(
|
||||
name=t("Visemes.mesh_select"),
|
||||
description=t("Visemes.mesh_select_desc"),
|
||||
items=get_mesh_objects
|
||||
)
|
||||
|
||||
shape_intensity: FloatProperty(
|
||||
@@ -187,8 +449,7 @@ class AvatarToolkitSceneProperties(PropertyGroup):
|
||||
('vrc.v_th', 'TH', 'Th as in "think"')
|
||||
],
|
||||
update=lambda s, c: VisemePreview.update_preview(c)
|
||||
|
||||
)
|
||||
)
|
||||
|
||||
eye_tracking_type: EnumProperty(
|
||||
name=t("EyeTracking.type"),
|
||||
@@ -198,7 +459,7 @@ class AvatarToolkitSceneProperties(PropertyGroup):
|
||||
('SDK2', t("EyeTracking.type.sdk2"), t("EyeTracking.type.sdk2_desc"))
|
||||
],
|
||||
default='AV3'
|
||||
)
|
||||
)
|
||||
|
||||
eye_mode: EnumProperty(
|
||||
name=t("EyeTracking.mode"),
|
||||
@@ -316,13 +577,15 @@ class AvatarToolkitSceneProperties(PropertyGroup):
|
||||
merge_armature_into: StringProperty(
|
||||
name=t('MergeArmature.into'),
|
||||
description=t('MergeArmature.into_desc'),
|
||||
default=""
|
||||
default="",
|
||||
update=update_merge_armature_into
|
||||
)
|
||||
|
||||
merge_armature: StringProperty(
|
||||
name=t('MergeArmature.from'),
|
||||
description=t('MergeArmature.from_desc'),
|
||||
default=""
|
||||
default="",
|
||||
update=update_merge_armature
|
||||
)
|
||||
|
||||
attach_mesh: StringProperty(
|
||||
@@ -337,12 +600,6 @@ class AvatarToolkitSceneProperties(PropertyGroup):
|
||||
default=""
|
||||
)
|
||||
|
||||
merge_all_bones: BoolProperty(
|
||||
name=t('MergeArmature.merge_all'),
|
||||
description=t('MergeArmature.merge_all_desc'),
|
||||
default=True
|
||||
)
|
||||
|
||||
apply_transforms: BoolProperty(
|
||||
name=t('MergeArmature.apply_transforms'),
|
||||
description=t('MergeArmature.apply_transforms_desc'),
|
||||
@@ -361,33 +618,357 @@ class AvatarToolkitSceneProperties(PropertyGroup):
|
||||
default=True
|
||||
)
|
||||
|
||||
preserve_parent_bones: BoolProperty(
|
||||
name=t("Tools.preserve_parent_bones"),
|
||||
description=t("Tools.preserve_parent_bones_desc"),
|
||||
default=True
|
||||
)
|
||||
|
||||
target_bone_type: EnumProperty(
|
||||
name=t("Tools.target_bone_type"),
|
||||
description=t("Tools.target_bone_type_desc"),
|
||||
items=[
|
||||
('ALL', t("Tools.target_all_bones"), ""),
|
||||
('DEFORM', t("Tools.target_deform_bones"), ""),
|
||||
('NON_DEFORM', t("Tools.target_non_deform_bones"), "")
|
||||
],
|
||||
default='ALL'
|
||||
)
|
||||
|
||||
zero_weight_bones: CollectionProperty(
|
||||
type=ZeroWeightBoneItem,
|
||||
name="Zero Weight Bones",
|
||||
description="List of bones with zero weights"
|
||||
)
|
||||
|
||||
zero_weight_bones_index: IntProperty(
|
||||
name="Zero Weight Bone Index",
|
||||
default=0
|
||||
)
|
||||
|
||||
list_only_mode: BoolProperty(
|
||||
name=t("Tools.list_only_mode"),
|
||||
description=t("Tools.list_only_mode_desc"),
|
||||
default=False
|
||||
)
|
||||
|
||||
cleanup_shape_keys: BoolProperty(
|
||||
name=t('MergeArmature.cleanup_shape_keys'),
|
||||
description=t('MergeArmature.cleanup_shape_keys_desc'),
|
||||
default=True
|
||||
)
|
||||
|
||||
merge_twist_bones: BoolProperty(
|
||||
name=t("Tools.merge_twist_bones"),
|
||||
description=t("Tools.merge_twist_bones_desc"),
|
||||
default=True
|
||||
)
|
||||
|
||||
highlight_problem_bones: BoolProperty(
|
||||
name=t("Settings.highlight_problem_bones"),
|
||||
description=t("Settings.highlight_problem_bones_desc"),
|
||||
default=get_preference("highlight_problem_bones", True),
|
||||
update=highlight_problem_bones
|
||||
)
|
||||
|
||||
show_scale_issues: BoolProperty(
|
||||
name="Show Scale Issues",
|
||||
default=False
|
||||
)
|
||||
|
||||
tpose_validation_result: BoolProperty(
|
||||
name="T-Pose Validation Result",
|
||||
default=True
|
||||
)
|
||||
|
||||
tpose_validation_messages: CollectionProperty(
|
||||
type=bpy.types.PropertyGroup,
|
||||
name="T-Pose Validation Messages"
|
||||
)
|
||||
|
||||
show_tpose_validation: BoolProperty(
|
||||
name="Show T-Pose Validation Results",
|
||||
default=False
|
||||
)
|
||||
|
||||
standardize_fix_names: BoolProperty(
|
||||
name=t("Tools.standardize_fix_names"),
|
||||
description=t("Tools.standardize_fix_names_desc"),
|
||||
default=True
|
||||
)
|
||||
|
||||
standardize_fix_hierarchy: BoolProperty(
|
||||
name=t("Tools.standardize_fix_hierarchy"),
|
||||
description=t("Tools.standardize_fix_hierarchy_desc"),
|
||||
default=True
|
||||
)
|
||||
|
||||
standardize_fix_scale: BoolProperty(
|
||||
name=t("Tools.standardize_fix_scale"),
|
||||
description=t("Tools.standardize_fix_scale_desc"),
|
||||
default=True
|
||||
)
|
||||
|
||||
log_level: EnumProperty(
|
||||
name=t("Settings.log_level"),
|
||||
description=t("Settings.log_level_desc"),
|
||||
items=[
|
||||
('DEBUG', t("Settings.log_level.debug"), t("Settings.log_level.debug_desc")),
|
||||
('INFO', t("Settings.log_level.info"), t("Settings.log_level.info_desc")),
|
||||
('WARNING', t("Settings.log_level.warning"), t("Settings.log_level.warning_desc")),
|
||||
('ERROR', t("Settings.log_level.error"), t("Settings.log_level.error_desc")),
|
||||
],
|
||||
default=get_preference("log_level", "WARNING"),
|
||||
update=update_log_level
|
||||
)
|
||||
|
||||
# VRM Conversion Properties
|
||||
vrm_remove_colliders: BoolProperty(
|
||||
name=t("VRM.remove_colliders"),
|
||||
description=t("VRM.remove_colliders_desc"),
|
||||
default=True
|
||||
)
|
||||
|
||||
vrm_remove_root: BoolProperty(
|
||||
name=t("VRM.remove_root"),
|
||||
description=t("VRM.remove_root_desc"),
|
||||
default=True
|
||||
)
|
||||
|
||||
# MMD Conversion Properties
|
||||
mmd_make_parent: BoolProperty(
|
||||
name=t("MMD.make_armature_parent"),
|
||||
description="Remove parent Empty object and make armature the main parent",
|
||||
default=True
|
||||
)
|
||||
|
||||
mmd_rename_armature: BoolProperty(
|
||||
name=t("MMD.rename_to_armature"),
|
||||
description="Rename the armature object to 'Armature'",
|
||||
default=True
|
||||
)
|
||||
|
||||
mmd_translate_names: BoolProperty(
|
||||
name=t("MMD.translate_names"),
|
||||
description="Translate Japanese names to English using MMD dictionary and translation services",
|
||||
default=True
|
||||
)
|
||||
|
||||
mmd_translate_bones: BoolProperty(
|
||||
name=t("MMD.translate_bones"),
|
||||
description="Translate bone names",
|
||||
default=True
|
||||
)
|
||||
|
||||
mmd_translate_materials: BoolProperty(
|
||||
name=t("MMD.translate_materials"),
|
||||
description="Translate material names",
|
||||
default=True
|
||||
)
|
||||
|
||||
mmd_translate_shapekeys: BoolProperty(
|
||||
name=t("MMD.translate_shapekeys"),
|
||||
description="Translate shape key names",
|
||||
default=True
|
||||
)
|
||||
|
||||
mmd_translate_objects: BoolProperty(
|
||||
name=t("MMD.translate_objects"),
|
||||
description="Translate object names",
|
||||
default=True
|
||||
)
|
||||
|
||||
mmd_restructure_bones: BoolProperty(
|
||||
name=t("MMD.restructure_bones"),
|
||||
description="Restructure bone hierarchy to Unity humanoid format (Hips, Spine, Chest, etc.)",
|
||||
default=True
|
||||
)
|
||||
|
||||
mmd_remove_twist_bones: BoolProperty(
|
||||
name=t("MMD.remove_twist_bones"),
|
||||
description="Remove twist bones",
|
||||
default=True
|
||||
)
|
||||
|
||||
mmd_remove_zero_weight_bones: BoolProperty(
|
||||
name=t("MMD.remove_zero_weight_bones"),
|
||||
description="Remove bones with zero or near-zero vertex weights",
|
||||
default=False
|
||||
)
|
||||
|
||||
# Translation System Properties
|
||||
translation_service: EnumProperty(
|
||||
name=t("Translation.service"),
|
||||
description=t("Translation.service_desc"),
|
||||
items=[
|
||||
('mymemory', t("Translation.service.mymemory"), t("Translation.service.mymemory_desc")),
|
||||
('libretranslate', t("Translation.service.libretranslate"), t("Translation.service.libretranslate_desc")),
|
||||
('deepl', t("Translation.service.deepl"), t("Translation.service.deepl_desc"))
|
||||
],
|
||||
default=get_preference("translation_service", "mymemory"),
|
||||
update=lambda self, context: update_translation_service(self, context)
|
||||
)
|
||||
|
||||
translation_mode: EnumProperty(
|
||||
name=t("Translation.mode"),
|
||||
description=t("Translation.mode_desc"),
|
||||
items=[
|
||||
('hybrid', t("Translation.mode.hybrid"), t("Translation.mode.hybrid_desc")),
|
||||
('dictionary_only', t("Translation.mode.dictionary_only"), t("Translation.mode.dictionary_only_desc")),
|
||||
('api_only', t("Translation.mode.api_only"), t("Translation.mode.api_only_desc"))
|
||||
],
|
||||
default=get_preference("translation_mode", "hybrid"),
|
||||
update=lambda self, context: update_translation_mode(self, context)
|
||||
)
|
||||
|
||||
translation_expand: BoolProperty(
|
||||
name="Translation Settings Expanded",
|
||||
default=False
|
||||
)
|
||||
|
||||
|
||||
translation_target_language: EnumProperty(
|
||||
name=t("Translation.target_language"),
|
||||
description=t("Translation.target_language_desc"),
|
||||
items=[
|
||||
('en', 'English', 'Translate to English'),
|
||||
('ja', 'Japanese', 'Translate to Japanese'),
|
||||
('ko', 'Korean', 'Translate to Korean'),
|
||||
('zh', 'Chinese', 'Translate to Chinese'),
|
||||
('es', 'Spanish', 'Translate to Spanish'),
|
||||
('fr', 'French', 'Translate to French'),
|
||||
('de', 'German', 'Translate to German')
|
||||
],
|
||||
default='en'
|
||||
)
|
||||
|
||||
translation_source_language: EnumProperty(
|
||||
name=t("Translation.source_language"),
|
||||
description=t("Translation.source_language_desc"),
|
||||
items=[
|
||||
('auto', 'Auto-detect', 'Automatically detect source language'),
|
||||
('ja', 'Japanese', 'Source is Japanese'),
|
||||
('en', 'English', 'Source is English'),
|
||||
('ko', 'Korean', 'Source is Korean'),
|
||||
('zh', 'Chinese', 'Source is Chinese')
|
||||
],
|
||||
default='ja'
|
||||
)
|
||||
|
||||
|
||||
def update_translation_service(self: PropertyGroup, context: Context) -> None:
|
||||
"""Update translation service preference"""
|
||||
logger.info(f"Updating translation service to: {self.translation_service}")
|
||||
save_preference("translation_service", self.translation_service)
|
||||
|
||||
# Clear module-level translation caches when service changes
|
||||
try:
|
||||
from ..ui.translation_panel import _ui_cache
|
||||
_ui_cache['deepl_config'].clear()
|
||||
_ui_cache['libretranslate_config'].clear()
|
||||
_ui_cache['translation_status'].clear()
|
||||
if 'batch_info' in _ui_cache:
|
||||
del _ui_cache['batch_info'] # Clear batch info cache when service changes
|
||||
except ImportError:
|
||||
pass # UI module might not be loaded yet
|
||||
|
||||
# Set the primary service
|
||||
try:
|
||||
from .translation_manager import get_avatar_translation_manager
|
||||
manager = get_avatar_translation_manager()
|
||||
manager.service_manager.set_primary_service(self.translation_service)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update translation service: {e}")
|
||||
|
||||
|
||||
def update_translation_mode(self: PropertyGroup, context: Context) -> None:
|
||||
"""Update translation mode preference"""
|
||||
logger.info(f"Updating translation mode to: {self.translation_mode}")
|
||||
save_preference("translation_mode", self.translation_mode)
|
||||
|
||||
# Clear module-level translation status cache when mode changes
|
||||
try:
|
||||
from ..ui.translation_panel import _ui_cache
|
||||
_ui_cache['translation_status'].clear()
|
||||
if 'batch_info' in _ui_cache:
|
||||
del _ui_cache['batch_info'] # Clear batch info cache when mode changes
|
||||
except ImportError:
|
||||
pass # UI module might not be loaded yet
|
||||
|
||||
try:
|
||||
from .translation_manager import get_avatar_translation_manager, TranslationMode
|
||||
manager = get_avatar_translation_manager()
|
||||
manager.set_translation_mode(TranslationMode(self.translation_mode))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update translation mode: {e}")
|
||||
|
||||
|
||||
def update_active_armature(self: PropertyGroup, context: Context) -> None:
|
||||
"""Update the active armature when selection changes"""
|
||||
if self.active_armature and self.active_armature != 'NONE':
|
||||
# Get the actual armature object from the identifier
|
||||
armature = get_active_armature(context)
|
||||
|
||||
if armature:
|
||||
logger.info(f"Active armature set to: {armature.name}")
|
||||
# Deselect all objects first
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
# Select and make active the chosen armature
|
||||
armature.select_set(True)
|
||||
context.view_layer.objects.active = armature
|
||||
logger.info(f"Selected and activated armature: {armature.name}")
|
||||
|
||||
# Clear armature caches when armature changes to ensure fresh validation
|
||||
try:
|
||||
from ..ui.quick_access_panel import clear_armature_caches
|
||||
clear_armature_caches()
|
||||
except ImportError:
|
||||
pass # UI module might not be loaded yet
|
||||
else:
|
||||
logger.warning("Failed to get armature object from identifier")
|
||||
else:
|
||||
logger.info("No armature selected")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def register() -> None:
|
||||
"""Register the Avatar Toolkit property group"""
|
||||
logger.info("Registering Avatar Toolkit properties")
|
||||
try:
|
||||
bpy.utils.register_class(AvatarToolkitSceneProperties)
|
||||
except ValueError:
|
||||
# Class already registered, we can continue
|
||||
pass
|
||||
|
||||
# Only register the property, not the classes (auto_load will handle that)
|
||||
bpy.types.Scene.avatar_toolkit = PointerProperty(type=AvatarToolkitSceneProperties)
|
||||
|
||||
# Register handlers for auto-populating merge armatures
|
||||
bpy.app.handlers.depsgraph_update_post.append(depsgraph_update_handler)
|
||||
bpy.app.handlers.undo_post.append(undo_post_handler)
|
||||
bpy.app.handlers.redo_post.append(redo_post_handler)
|
||||
|
||||
# Initial auto-populate
|
||||
bpy.app.timers.register(lambda: auto_populate_safe(), first_interval=1.0)
|
||||
|
||||
logger.debug("Properties registered successfully")
|
||||
|
||||
|
||||
def unregister() -> None:
|
||||
"""Unregister the Avatar Toolkit property group"""
|
||||
logger.info("Unregistering Avatar Toolkit properties")
|
||||
|
||||
# Remove handlers
|
||||
if depsgraph_update_handler in bpy.app.handlers.depsgraph_update_post:
|
||||
bpy.app.handlers.depsgraph_update_post.remove(depsgraph_update_handler)
|
||||
if undo_post_handler in bpy.app.handlers.undo_post:
|
||||
bpy.app.handlers.undo_post.remove(undo_post_handler)
|
||||
if redo_post_handler in bpy.app.handlers.redo_post:
|
||||
bpy.app.handlers.redo_post.remove(redo_post_handler)
|
||||
|
||||
# Remove the property
|
||||
if hasattr(bpy.types.Scene, "avatar_toolkit"):
|
||||
try:
|
||||
del bpy.types.Scene.avatar_toolkit
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
bpy.utils.unregister_class(AvatarToolkitSceneProperties)
|
||||
except RuntimeError:
|
||||
pass
|
||||
logger.debug("Properties unregistered successfully")
|
||||
|
||||
logger.debug("Removed avatar_toolkit property")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to remove avatar_toolkit property: {e}")
|
||||
# Not fatal - continue
|
||||
|
||||
@@ -3,7 +3,6 @@ from os import replace
|
||||
from re import S
|
||||
from types import FrameType
|
||||
|
||||
import lz4.block
|
||||
from . import resonite_types
|
||||
from . import common
|
||||
|
||||
|
||||
+48
-31
@@ -1,16 +1,21 @@
|
||||
import traceback
|
||||
from types import FrameType
|
||||
import bpy
|
||||
import bpy_extras
|
||||
from bpy_extras import anim_utils
|
||||
from numpy import double
|
||||
from typing import Set, Dict
|
||||
import re
|
||||
import traceback
|
||||
|
||||
from .common import get_active_armature, simplify_bonename, validate_armature, ProgressTracker
|
||||
from .common import get_active_armature, ProgressTracker, identify_bones
|
||||
from bpy.types import Context, Operator
|
||||
from ..core.translations import t
|
||||
from ..core.dictionaries import bone_names, resonite_translations
|
||||
from ..core.dictionaries import bone_names, resonite_translations, simplify_bonename
|
||||
from ..core.logging_setup import logger
|
||||
from ..core.armature_validation import validate_armature
|
||||
|
||||
|
||||
import re
|
||||
from .resonite_loader import resonite_animx, resonite_types
|
||||
import os
|
||||
|
||||
@@ -50,7 +55,7 @@ class AvatarToolkit_OT_ConvertResonite(Operator):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
is_valid, _ = validate_armature(armature)
|
||||
is_valid, _, _ = validate_armature(armature)
|
||||
return is_valid
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
@@ -64,30 +69,21 @@ class AvatarToolkit_OT_ConvertResonite(Operator):
|
||||
untranslated_bones: Set[str] = set()
|
||||
simplified_names: Dict[str, str] = {}
|
||||
|
||||
# Create reverse lookup dictionary
|
||||
reverse_bone_lookup = {}
|
||||
for preferred_name, name_list in bone_names.items():
|
||||
for name in name_list:
|
||||
reverse_bone_lookup[name] = preferred_name
|
||||
|
||||
try:
|
||||
context.view_layer.objects.active = armature
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
arm_data: bpy.types.Armature = armature.data
|
||||
# Cache simplified bone names
|
||||
for bone in armature.data.bones:
|
||||
simplified_names[bone.name] = simplify_bonename(bone.name)
|
||||
|
||||
total_bones = len(armature.data.bones)
|
||||
with ProgressTracker(context, total_bones, t("Tools.convert_resonite.operation")) as progress:
|
||||
for bone in armature.data.bones:
|
||||
# Remove any existing "<noik>" tags
|
||||
for bone in arm_data.bones:
|
||||
bone.name = re.compile(re.escape("<noik>"), re.IGNORECASE).sub("", bone.name)
|
||||
simplified_name = simplified_names[bone.name]
|
||||
|
||||
if simplified_name in reverse_bone_lookup and reverse_bone_lookup[simplified_name] in resonite_translations:
|
||||
new_name = resonite_translations[reverse_bone_lookup[simplified_name]]
|
||||
total_bones = len(arm_data.bones)
|
||||
with ProgressTracker(context, total_bones, t("Tools.convert_resonite.operation")) as progress:
|
||||
for key_simple,bone_name in identify_bones(arm_data).items():
|
||||
|
||||
if key_simple in resonite_translations:
|
||||
new_name = resonite_translations[key_simple]
|
||||
logger.debug(f"Translating bone: {bone.name} -> {new_name}")
|
||||
bone.name = new_name
|
||||
else:
|
||||
@@ -98,16 +94,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")
|
||||
@@ -120,12 +116,33 @@ class AvatarToolkit_OT_ConvertResonite(Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
def makeorexistingfcurve(action: bpy.types.Action,data_path: str,action_group: str, index=0) -> bpy.types.FCurve:
|
||||
fcurve = action.fcurves.find(data_path=data_path,index=index)
|
||||
if fcurve == None:
|
||||
return action.fcurves.new(data_path,action_group=action_group,index=index)
|
||||
def makeorexistingfcurve(action: bpy.types.Action, data_path: str, action_group: str, index=0) -> bpy.types.FCurve:
|
||||
"""Get or create an F-Curve using Blender 5.0 channelbag system.
|
||||
|
||||
Blender 5.0 Breaking Change: The legacy action.fcurves API has been removed.
|
||||
F-Curves are now accessed through channelbags. Each slot of an Action can have a channelbag.
|
||||
This function has been migrated to use bpy_extras.anim_utils.action_ensure_channelbag_for_slot().
|
||||
"""
|
||||
# Get the action slot (assumes single slot for now - armature actions typically use first slot)
|
||||
if not action.slots:
|
||||
slot = action.slots.new(for_id=bpy.context.object.data if bpy.context.object and bpy.context.object.type == 'ARMATURE' else None)
|
||||
else:
|
||||
print("fcurve with data \""+data_path+"\" already exists")
|
||||
slot = action.slots[0]
|
||||
|
||||
# Get or create channelbag for this slot
|
||||
channelbag = anim_utils.action_ensure_channelbag_for_slot(action, slot)
|
||||
|
||||
# Use ensure() to get existing or create new F-Curve
|
||||
fcurve = channelbag.fcurves.ensure(data_path, index=index, group_name=action_group)
|
||||
|
||||
if fcurve:
|
||||
return fcurve
|
||||
else:
|
||||
print(f"fcurve with data \"{data_path}\" creation failed")
|
||||
# Fallback: try to find or create manually
|
||||
fcurve = channelbag.fcurves.find(data_path, index=index)
|
||||
if fcurve is None:
|
||||
fcurve = channelbag.fcurves.new(data_path, index=index, group_name=action_group)
|
||||
return fcurve
|
||||
|
||||
class AvatarToolKit_OT_AnimX_Importer(Operator,bpy_extras.io_utils.ImportHelper):
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
# GPL License
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from typing import Dict, List, Optional, Tuple, Set, Any, Callable
|
||||
from dataclasses import dataclass, asdict
|
||||
from enum import Enum
|
||||
|
||||
import bpy
|
||||
from bpy.types import Object, Material, ShapeKey
|
||||
|
||||
from .translation_service import get_translation_manager, TranslationServiceManager
|
||||
from .enhanced_dictionaries import get_enhanced_translator, EnhancedDictionaryTranslator
|
||||
from .logging_setup import logger
|
||||
from .addon_preferences import get_preference, save_preference
|
||||
from .translations import t
|
||||
|
||||
|
||||
class TranslationMode(Enum):
|
||||
"""Translation modes for different approaches"""
|
||||
DICTIONARY_ONLY = "dictionary_only"
|
||||
API_ONLY = "api_only"
|
||||
HYBRID = "hybrid" # Default: Dictionary first, then API fallback
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranslationJob:
|
||||
"""Represents a translation job for batch processing"""
|
||||
name: str
|
||||
category: str
|
||||
source_lang: str = "ja"
|
||||
target_lang: str = "en"
|
||||
object_ref: Optional[Any] = None
|
||||
property_name: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranslationResult:
|
||||
"""Result of a translation operation"""
|
||||
original: str
|
||||
translated: str
|
||||
method: str # "dictionary", "api", "failed"
|
||||
service: Optional[str] = None
|
||||
category: str = "unknown"
|
||||
confidence: float = 1.0
|
||||
|
||||
|
||||
class TranslationCache:
|
||||
"""Persistent translation cache with file storage"""
|
||||
|
||||
def __init__(self):
|
||||
self._cache: Dict[str, Dict[str, str]] = {}
|
||||
self._cache_file = self._get_cache_file_path()
|
||||
self._cache_lock = threading.Lock()
|
||||
self._load_cache()
|
||||
|
||||
def _get_cache_file_path(self) -> str:
|
||||
"""Get the cache file path in user preferences directory"""
|
||||
user_path = bpy.utils.resource_path('USER')
|
||||
cache_dir = os.path.join(user_path, "config", "avatar_toolkit_prefs")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
return os.path.join(cache_dir, "translation_cache.json")
|
||||
|
||||
def _load_cache(self) -> None:
|
||||
"""Load cache from file"""
|
||||
try:
|
||||
if os.path.exists(self._cache_file):
|
||||
# Try UTF-8 first, fallback to other encodings
|
||||
try:
|
||||
with open(self._cache_file, 'r', encoding='utf-8') as f:
|
||||
self._cache = json.load(f)
|
||||
except UnicodeDecodeError:
|
||||
# Try with UTF-8 error handling
|
||||
with open(self._cache_file, 'r', encoding='utf-8', errors='replace') as f:
|
||||
self._cache = json.load(f)
|
||||
logger.debug(f"Loaded translation cache with {len(self._cache)} entries")
|
||||
else:
|
||||
self._cache = {}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load translation cache: {e}")
|
||||
self._cache = {}
|
||||
|
||||
def _save_cache(self) -> None:
|
||||
"""Save cache to file"""
|
||||
try:
|
||||
with open(self._cache_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self._cache, f, indent=2, ensure_ascii=False)
|
||||
logger.debug(f"Saved translation cache with {len(self._cache)} entries")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save translation cache: {e}")
|
||||
|
||||
def get(self, text: str, source_lang: str, target_lang: str) -> Optional[str]:
|
||||
"""Get cached translation"""
|
||||
cache_key = f"{source_lang}_{target_lang}"
|
||||
with self._cache_lock:
|
||||
if cache_key in self._cache and text in self._cache[cache_key]:
|
||||
return self._cache[cache_key][text]
|
||||
return None
|
||||
|
||||
def put(self, text: str, translation: str, source_lang: str, target_lang: str) -> None:
|
||||
"""Store translation in cache"""
|
||||
cache_key = f"{source_lang}_{target_lang}"
|
||||
with self._cache_lock:
|
||||
if cache_key not in self._cache:
|
||||
self._cache[cache_key] = {}
|
||||
self._cache[cache_key][text] = translation
|
||||
|
||||
# Save cache periodically (every 10 new entries)
|
||||
if len(self._cache.get(cache_key, {})) % 10 == 0:
|
||||
self._save_cache()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all cached translations"""
|
||||
with self._cache_lock:
|
||||
self._cache.clear()
|
||||
self._save_cache()
|
||||
logger.info("Translation cache cleared")
|
||||
|
||||
def get_stats(self) -> Dict[str, int]:
|
||||
"""Get cache statistics"""
|
||||
with self._cache_lock:
|
||||
total_entries = sum(len(lang_cache) for lang_cache in self._cache.values())
|
||||
return {
|
||||
"language_pairs": len(self._cache),
|
||||
"total_entries": total_entries
|
||||
}
|
||||
|
||||
|
||||
class AvatarToolkitTranslationManager:
|
||||
"""Main translation manager for Avatar Toolkit"""
|
||||
|
||||
def __init__(self):
|
||||
self.service_manager: TranslationServiceManager = get_translation_manager()
|
||||
self.dictionary_translator: EnhancedDictionaryTranslator = get_enhanced_translator()
|
||||
self.cache: TranslationCache = TranslationCache()
|
||||
self.translation_mode: TranslationMode = TranslationMode(
|
||||
get_preference("translation_mode", "hybrid")
|
||||
)
|
||||
self._progress_callback: Optional[Callable[[int, int, str], None]] = None
|
||||
|
||||
def set_translation_mode(self, mode: TranslationMode) -> None:
|
||||
"""Set the translation mode"""
|
||||
self.translation_mode = mode
|
||||
save_preference("translation_mode", mode.value)
|
||||
logger.info(f"Translation mode set to: {mode.value}")
|
||||
|
||||
def set_progress_callback(self, callback: Optional[Callable[[int, int, str], None]]) -> None:
|
||||
"""Set progress callback for batch operations"""
|
||||
self._progress_callback = callback
|
||||
|
||||
def translate_single(self, name: str, category: str = "auto",
|
||||
source_lang: str = "ja", target_lang: str = "en") -> TranslationResult:
|
||||
"""Translate a single name with comprehensive fallback logic"""
|
||||
# Import safe_decode_text from translation_service
|
||||
from .translation_service import safe_decode_text
|
||||
|
||||
# Ensure name is properly encoded
|
||||
try:
|
||||
name = safe_decode_text(name)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode name: {e}")
|
||||
|
||||
if not name or not name.strip():
|
||||
return TranslationResult(name, name, "skipped")
|
||||
|
||||
original_name = name.strip()
|
||||
|
||||
# Check cache first
|
||||
cached_result = self.cache.get(original_name, source_lang, target_lang)
|
||||
if cached_result:
|
||||
return TranslationResult(original_name, cached_result, "cache", category=category)
|
||||
|
||||
# Dictionary translation (always try first in hybrid mode)
|
||||
if self.translation_mode in [TranslationMode.DICTIONARY_ONLY, TranslationMode.HYBRID]:
|
||||
dict_result, detected_category = self.dictionary_translator.translate_name(original_name, category)
|
||||
if dict_result:
|
||||
self.cache.put(original_name, dict_result, source_lang, target_lang)
|
||||
return TranslationResult(original_name, dict_result, "dictionary",
|
||||
category=detected_category, confidence=1.0)
|
||||
|
||||
if self.translation_mode in [TranslationMode.API_ONLY, TranslationMode.HYBRID]:
|
||||
try:
|
||||
api_result, service_name = self.service_manager.translate_with_fallback(
|
||||
original_name, source_lang, target_lang
|
||||
)
|
||||
if api_result != original_name: # Translation succeeded
|
||||
self.cache.put(original_name, api_result, source_lang, target_lang)
|
||||
return TranslationResult(original_name, api_result, "api",
|
||||
service=service_name, category=category, confidence=0.8)
|
||||
except Exception as e:
|
||||
logger.warning(f"API translation failed for '{original_name}': {e}")
|
||||
|
||||
# No translation available
|
||||
return TranslationResult(original_name, original_name, "failed", category=category)
|
||||
|
||||
def translate_batch(self, jobs: List[TranslationJob],
|
||||
apply_results: bool = True) -> List[TranslationResult]:
|
||||
"""Translate multiple items in batch with progress reporting and interruption handling"""
|
||||
results = []
|
||||
total_jobs = len(jobs)
|
||||
|
||||
logger.info(f"Starting batch translation of {total_jobs} items")
|
||||
|
||||
# Group jobs by category for more efficient processing
|
||||
jobs_by_category: Dict[str, List[TranslationJob]] = {}
|
||||
for job in jobs:
|
||||
if job.category not in jobs_by_category:
|
||||
jobs_by_category[job.category] = []
|
||||
jobs_by_category[job.category].append(job)
|
||||
|
||||
completed = 0
|
||||
start_time = time.time()
|
||||
|
||||
for category, category_jobs in jobs_by_category.items():
|
||||
logger.debug(f"Processing {len(category_jobs)} {category} translations")
|
||||
|
||||
# Check if we can use optimized batch translation for API calls
|
||||
can_use_api_batch = (self.translation_mode in [TranslationMode.API_ONLY, TranslationMode.HYBRID] and
|
||||
len(category_jobs) > 3)
|
||||
|
||||
if can_use_api_batch:
|
||||
# Try optimized batch translation with API
|
||||
batch_results = self._process_category_batch_optimized(category_jobs, completed, total_jobs, start_time)
|
||||
if batch_results:
|
||||
# Apply results to Blender objects if requested
|
||||
for i, (job, result) in enumerate(zip(category_jobs, batch_results)):
|
||||
if apply_results and result.method != "failed" and job.object_ref:
|
||||
try:
|
||||
self._apply_translation_to_object(job, result)
|
||||
logger.debug(f"Successfully applied translation: {job.name} -> {result.translated}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply translation to object {job.name}: {e}")
|
||||
result.method = "apply_failed"
|
||||
result.translated = job.name
|
||||
|
||||
results.extend(batch_results)
|
||||
completed += len(category_jobs)
|
||||
|
||||
progress_percent = (completed / total_jobs) * 100
|
||||
logger.info(f"Batch translation progress: {completed}/{total_jobs} ({progress_percent:.1f}%) - completed {category} batch")
|
||||
continue
|
||||
|
||||
# Fallback to individual processing
|
||||
for job in category_jobs:
|
||||
# Check if we should continue (for potential cancellation support)
|
||||
current_time = time.time()
|
||||
elapsed_time = current_time - start_time
|
||||
|
||||
# Progress callback with detailed status
|
||||
if self._progress_callback:
|
||||
avg_time_per_item = elapsed_time / max(completed, 1)
|
||||
remaining_items = total_jobs - completed
|
||||
estimated_remaining = avg_time_per_item * remaining_items
|
||||
|
||||
status_msg = f"Translating {job.name}"
|
||||
if completed > 0:
|
||||
status_msg += f" (ETA: {estimated_remaining:.1f}s)"
|
||||
|
||||
self._progress_callback(completed, total_jobs, status_msg)
|
||||
|
||||
try:
|
||||
logger.debug(f"Translating job {completed + 1}/{total_jobs}: {job.name} ({job.category})")
|
||||
|
||||
result = self.translate_single(job.name, job.category,
|
||||
job.source_lang, job.target_lang)
|
||||
|
||||
if apply_results and result.method != "failed" and job.object_ref:
|
||||
try:
|
||||
self._apply_translation_to_object(job, result)
|
||||
logger.debug(f"Successfully applied translation: {job.name} -> {result.translated}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply translation to object {job.name}: {e}")
|
||||
result.method = "apply_failed"
|
||||
result.translated = job.name
|
||||
|
||||
results.append(result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Translation failed for job {job.name}: {e}")
|
||||
# Create a failed result
|
||||
failed_result = TranslationResult(
|
||||
original=job.name,
|
||||
translated=job.name,
|
||||
method="failed",
|
||||
category=job.category
|
||||
)
|
||||
results.append(failed_result)
|
||||
|
||||
completed += 1
|
||||
|
||||
# Log progress periodically
|
||||
if completed % 10 == 0 or completed == total_jobs:
|
||||
progress_percent = (completed / total_jobs) * 100
|
||||
logger.info(f"Batch translation progress: {completed}/{total_jobs} ({progress_percent:.1f}%)")
|
||||
|
||||
if self._progress_callback:
|
||||
total_time = time.time() - start_time
|
||||
self._progress_callback(total_jobs, total_jobs, f"Translation complete ({total_time:.1f}s)")
|
||||
|
||||
successful = sum(1 for r in results if r.method not in ["failed", "skipped", "apply_failed"])
|
||||
failed = sum(1 for r in results if r.method in ["failed", "apply_failed"])
|
||||
skipped = sum(1 for r in results if r.method == "skipped")
|
||||
|
||||
dictionary_count = sum(1 for r in results if r.method == "dictionary")
|
||||
api_count = sum(1 for r in results if r.method == "api")
|
||||
cache_count = sum(1 for r in results if r.method == "cache")
|
||||
|
||||
logger.info(f"Batch translation complete: {successful}/{total_jobs} successful, {failed} failed, {skipped} skipped")
|
||||
logger.info(f"Translation methods used: Dictionary: {dictionary_count}, API: {api_count}, Cache: {cache_count}")
|
||||
|
||||
return results
|
||||
|
||||
def _process_category_batch_optimized(self, category_jobs: List[TranslationJob],
|
||||
completed: int, total_jobs: int, start_time: float) -> Optional[List[TranslationResult]]:
|
||||
"""Process a batch of jobs from the same category using optimized API batch translation"""
|
||||
from .translation_service import safe_decode_text
|
||||
|
||||
if not category_jobs:
|
||||
return []
|
||||
|
||||
logger.info(f"Starting optimized batch translation for {len(category_jobs)} {category_jobs[0].category} items")
|
||||
|
||||
api_batch_jobs = []
|
||||
api_batch_texts = []
|
||||
results = [None] * len(category_jobs)
|
||||
|
||||
# First pass: try dictionary translations and collect API candidates
|
||||
for i, job in enumerate(category_jobs):
|
||||
if not job.name or not job.name.strip():
|
||||
results[i] = TranslationResult(job.name, job.name, "skipped", category=job.category)
|
||||
continue
|
||||
|
||||
# Ensure name is properly encoded
|
||||
try:
|
||||
original_name = safe_decode_text(job.name.strip())
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode job name: {e}")
|
||||
original_name = job.name.strip()
|
||||
continue
|
||||
|
||||
original_name = job.name.strip()
|
||||
|
||||
# Check cache first
|
||||
cached_result = self.cache.get(original_name, job.source_lang, job.target_lang)
|
||||
if cached_result:
|
||||
results[i] = TranslationResult(original_name, cached_result, "cache", category=job.category)
|
||||
continue
|
||||
|
||||
# Try dictionary translation first (if in hybrid mode)
|
||||
if self.translation_mode == TranslationMode.HYBRID:
|
||||
dict_result, detected_category = self.dictionary_translator.translate_name(original_name, job.category)
|
||||
if dict_result:
|
||||
self.cache.put(original_name, dict_result, job.source_lang, job.target_lang)
|
||||
results[i] = TranslationResult(original_name, dict_result, "dictionary",
|
||||
category=detected_category, confidence=1.0)
|
||||
continue
|
||||
|
||||
# Add to API batch candidates
|
||||
api_batch_jobs.append((i, job))
|
||||
api_batch_texts.append(original_name)
|
||||
|
||||
# Process API batch if we have candidates
|
||||
if api_batch_texts:
|
||||
logger.info(f"Sending {len(api_batch_texts)} items to API batch translation")
|
||||
|
||||
if self._progress_callback:
|
||||
elapsed_time = time.time() - start_time
|
||||
avg_time_per_item = elapsed_time / max(completed, 1) if completed > 0 else 1.0
|
||||
remaining_items = total_jobs - completed
|
||||
estimated_remaining = avg_time_per_item * remaining_items
|
||||
|
||||
status_msg = f"Batch translating {len(api_batch_texts)} {category_jobs[0].category} items"
|
||||
if completed > 0:
|
||||
status_msg += f" (ETA: {estimated_remaining:.1f}s)"
|
||||
|
||||
self._progress_callback(completed, total_jobs, status_msg)
|
||||
|
||||
try:
|
||||
# Use the service manager's optimized batch translation
|
||||
if len(set(job.source_lang for _, job in api_batch_jobs)) == 1 and len(set(job.target_lang for _, job in api_batch_jobs)) == 1:
|
||||
source_lang = api_batch_jobs[0][1].source_lang
|
||||
target_lang = api_batch_jobs[0][1].target_lang
|
||||
|
||||
batch_results = self.service_manager.batch_translate_with_fallback(
|
||||
api_batch_texts, source_lang, target_lang
|
||||
)
|
||||
|
||||
for j, (result_idx, job) in enumerate(api_batch_jobs):
|
||||
if j < len(batch_results):
|
||||
translated_text, service_name = batch_results[j]
|
||||
|
||||
# Cache successful translations
|
||||
if translated_text != job.name:
|
||||
self.cache.put(job.name.strip(), translated_text, job.source_lang, job.target_lang)
|
||||
|
||||
results[result_idx] = TranslationResult(
|
||||
original=job.name.strip(),
|
||||
translated=translated_text,
|
||||
method="api" if translated_text != job.name else "failed",
|
||||
service=service_name,
|
||||
category=job.category,
|
||||
confidence=0.8
|
||||
)
|
||||
else:
|
||||
# Fallback for missing results
|
||||
results[result_idx] = TranslationResult(job.name, job.name, "failed", category=job.category)
|
||||
else:
|
||||
# Mixed language pairs - fallback to individual translations
|
||||
logger.info("Mixed language pairs detected, falling back to individual API translations")
|
||||
for result_idx, job in api_batch_jobs:
|
||||
try:
|
||||
result = self.translate_single(job.name, job.category, job.source_lang, job.target_lang)
|
||||
results[result_idx] = result
|
||||
except Exception as e:
|
||||
logger.error(f"Individual API translation failed for {job.name}: {e}")
|
||||
results[result_idx] = TranslationResult(job.name, job.name, "failed", category=job.category)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Batch API translation failed: {e}")
|
||||
# Fallback to individual translations
|
||||
for result_idx, job in api_batch_jobs:
|
||||
try:
|
||||
result = self.translate_single(job.name, job.category, job.source_lang, job.target_lang)
|
||||
results[result_idx] = result
|
||||
except Exception as individual_e:
|
||||
logger.error(f"Individual fallback translation failed for {job.name}: {individual_e}")
|
||||
results[result_idx] = TranslationResult(job.name, job.name, "failed", category=job.category)
|
||||
|
||||
for i, result in enumerate(results):
|
||||
if result is None:
|
||||
results[i] = TranslationResult(category_jobs[i].name, category_jobs[i].name, "failed", category=category_jobs[i].category)
|
||||
|
||||
successful_batch = sum(1 for r in results if r.method not in ["failed", "skipped"])
|
||||
logger.info(f"Optimized batch complete: {successful_batch}/{len(category_jobs)} successful")
|
||||
|
||||
return results
|
||||
|
||||
def _apply_translation_to_object(self, job: TranslationJob, result: TranslationResult) -> None:
|
||||
"""Apply translation result to a Blender object"""
|
||||
if not job.object_ref or not job.property_name:
|
||||
return
|
||||
|
||||
try:
|
||||
setattr(job.object_ref, job.property_name, result.translated)
|
||||
logger.debug(f"Applied translation: {job.object_ref.name}.{job.property_name} = '{result.translated}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set property {job.property_name}: {e}")
|
||||
raise
|
||||
|
||||
def translate_armature_bones(self, armature: Object, apply_results: bool = True) -> List[TranslationResult]:
|
||||
"""Translate all bone names in an armature"""
|
||||
from .translation_service import safe_decode_text
|
||||
|
||||
if not armature or armature.type != 'ARMATURE':
|
||||
return []
|
||||
|
||||
jobs = []
|
||||
for bone in armature.data.bones:
|
||||
try:
|
||||
bone_name = safe_decode_text(bone.name)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode bone name, using as-is: {e}")
|
||||
bone_name = bone.name
|
||||
|
||||
jobs.append(TranslationJob(
|
||||
name=bone_name,
|
||||
category="bones",
|
||||
object_ref=bone,
|
||||
property_name="name"
|
||||
))
|
||||
|
||||
return self.translate_batch(jobs, apply_results)
|
||||
|
||||
def translate_object_shapekeys(self, mesh_obj: Object, apply_results: bool = True) -> List[TranslationResult]:
|
||||
"""Translate all shape key names in a mesh object"""
|
||||
from .translation_service import safe_decode_text
|
||||
|
||||
if not mesh_obj or mesh_obj.type != 'MESH' or not mesh_obj.data.shape_keys:
|
||||
return []
|
||||
|
||||
jobs = []
|
||||
for shape_key in mesh_obj.data.shape_keys.key_blocks:
|
||||
try:
|
||||
sk_name = safe_decode_text(shape_key.name)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode shape key name, using as-is: {e}")
|
||||
sk_name = shape_key.name
|
||||
|
||||
jobs.append(TranslationJob(
|
||||
name=sk_name,
|
||||
category="shapekeys",
|
||||
object_ref=shape_key,
|
||||
property_name="name"
|
||||
))
|
||||
|
||||
return self.translate_batch(jobs, apply_results)
|
||||
|
||||
def translate_scene_materials(self, apply_results: bool = True) -> List[TranslationResult]:
|
||||
"""Translate all material names in the scene"""
|
||||
from .translation_service import safe_decode_text
|
||||
|
||||
jobs = []
|
||||
processed_materials: Set[str] = set()
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type == 'MESH' and obj.data.materials:
|
||||
for material in obj.data.materials:
|
||||
if material and material.name not in processed_materials:
|
||||
try:
|
||||
mat_name = safe_decode_text(material.name)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode material name, using as-is: {e}")
|
||||
mat_name = material.name
|
||||
|
||||
jobs.append(TranslationJob(
|
||||
name=mat_name,
|
||||
category="materials",
|
||||
object_ref=material,
|
||||
property_name="name"
|
||||
))
|
||||
processed_materials.add(material.name)
|
||||
|
||||
return self.translate_batch(jobs, apply_results)
|
||||
|
||||
def translate_scene_objects(self, object_types: Optional[Set[str]] = None,
|
||||
apply_results: bool = True) -> List[TranslationResult]:
|
||||
"""Translate all object names in the scene"""
|
||||
from .translation_service import safe_decode_text
|
||||
|
||||
if object_types is None:
|
||||
object_types = {'MESH', 'ARMATURE', 'EMPTY'}
|
||||
|
||||
jobs = []
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type in object_types:
|
||||
try:
|
||||
obj_name = safe_decode_text(obj.name)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode object name, using as-is: {e}")
|
||||
obj_name = obj.name
|
||||
|
||||
jobs.append(TranslationJob(
|
||||
name=obj_name,
|
||||
category="objects",
|
||||
object_ref=obj,
|
||||
property_name="name"
|
||||
))
|
||||
|
||||
return self.translate_batch(jobs, apply_results)
|
||||
|
||||
def get_translation_stats(self) -> Dict[str, Any]:
|
||||
"""Get comprehensive translation statistics"""
|
||||
dict_stats = self.dictionary_translator.get_statistics()
|
||||
cache_stats = self.cache.get_stats()
|
||||
available_services = self.service_manager.get_available_services()
|
||||
|
||||
return {
|
||||
"dictionary_translations": dict_stats,
|
||||
"cache_stats": cache_stats,
|
||||
"available_services": available_services,
|
||||
"current_mode": self.translation_mode.value,
|
||||
"primary_service": get_preference("translation_service", "microsoft")
|
||||
}
|
||||
|
||||
def clear_all_caches(self) -> None:
|
||||
"""Clear all translation caches"""
|
||||
self.cache.clear()
|
||||
for service_id, service in self.service_manager._services.items():
|
||||
service.clear_cache()
|
||||
logger.info("All translation caches cleared")
|
||||
|
||||
|
||||
_translation_manager: Optional[AvatarToolkitTranslationManager] = None
|
||||
|
||||
|
||||
def get_avatar_translation_manager() -> AvatarToolkitTranslationManager:
|
||||
"""Get the global Avatar Toolkit translation manager"""
|
||||
global _translation_manager
|
||||
if _translation_manager is None:
|
||||
_translation_manager = AvatarToolkitTranslationManager()
|
||||
return _translation_manager
|
||||
|
||||
|
||||
def translate_name_simple(name: str, category: str = "auto") -> str:
|
||||
"""Simple translation function for quick use"""
|
||||
manager = get_avatar_translation_manager()
|
||||
result = manager.translate_single(name, category)
|
||||
return result.translated
|
||||
|
||||
|
||||
def is_translation_service_available(service_name: str) -> bool:
|
||||
"""Check if a specific translation service is available"""
|
||||
manager = get_avatar_translation_manager()
|
||||
available_services = manager.service_manager.get_available_services()
|
||||
return any(service_id == service_name for service_id, _ in available_services)
|
||||
|
||||
|
||||
def get_available_translation_services() -> List[Tuple[str, str]]:
|
||||
"""Get list of available translation services"""
|
||||
manager = get_avatar_translation_manager()
|
||||
return manager.service_manager.get_available_services()
|
||||
|
||||
|
||||
def get_batch_translation_info() -> Dict[str, Dict[str, Any]]:
|
||||
"""Get information about batch translation capabilities of available services"""
|
||||
manager = get_avatar_translation_manager()
|
||||
batch_info = {}
|
||||
|
||||
for service_id, service_name in manager.service_manager.get_available_services():
|
||||
service = manager.service_manager.get_service(service_id)
|
||||
if service:
|
||||
batch_info[service_id] = {
|
||||
'name': service_name,
|
||||
'supports_batch': service.supports_batch_translation(),
|
||||
'batch_type': 'native' if service_id == 'deepl' else 'concurrent' if service_id in ['libretranslate', 'mymemory'] else 'individual'
|
||||
}
|
||||
|
||||
return batch_info
|
||||
|
||||
|
||||
def configure_translation_service(service_id: str, **config) -> bool:
|
||||
"""Configure a translation service with the provided settings (now with batch support)"""
|
||||
try:
|
||||
success = False
|
||||
if service_id == "deepl":
|
||||
from .translation_service import configure_deepl_translator
|
||||
success = configure_deepl_translator(
|
||||
config.get("api_key", ""),
|
||||
config.get("use_free_api", True)
|
||||
)
|
||||
if success:
|
||||
logger.info("DeepL configured with native batch translation support (up to 50 texts per request)")
|
||||
elif service_id == "libretranslate":
|
||||
from .translation_service import configure_libretranslate_server
|
||||
success = configure_libretranslate_server(
|
||||
config.get("server_url", "https://libretranslate.com"),
|
||||
config.get("api_key", None)
|
||||
)
|
||||
if success:
|
||||
logger.info("LibreTranslate configured with concurrent batch processing (3x faster)")
|
||||
elif service_id == "microsoft":
|
||||
from .translation_service import configure_microsoft_translator
|
||||
success = configure_microsoft_translator(
|
||||
config.get("api_key", ""),
|
||||
config.get("region", "global")
|
||||
)
|
||||
|
||||
else:
|
||||
logger.error(f"Unknown translation service: {service_id}")
|
||||
success = False
|
||||
|
||||
return success
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to configure translation service {service_id}: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,993 @@
|
||||
# GPL License
|
||||
|
||||
import json
|
||||
import time
|
||||
import requests
|
||||
import threading
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, List, Optional, Tuple, Any, Set
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlencode
|
||||
import uuid
|
||||
|
||||
from .logging_setup import logger
|
||||
from .addon_preferences import save_preference, get_preference
|
||||
|
||||
|
||||
def safe_decode_text(text: str) -> str:
|
||||
"""Safely decode text that might be in various encodings (UTF-8, Shift-JIS, etc.)"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
# If it's already a proper string, return it
|
||||
if isinstance(text, str):
|
||||
try:
|
||||
# Test if it's valid UTF-8
|
||||
text.encode('utf-8')
|
||||
return text
|
||||
except (UnicodeDecodeError, UnicodeEncodeError):
|
||||
pass
|
||||
|
||||
# Try common encodings for Japanese text
|
||||
encodings = ['utf-8', 'shift-jis', 'cp932', 'euc-jp', 'iso-2022-jp']
|
||||
|
||||
for encoding in encodings:
|
||||
try:
|
||||
if isinstance(text, bytes):
|
||||
return text.decode(encoding)
|
||||
else:
|
||||
# Try to re-encode and decode
|
||||
return text.encode('latin-1', errors='ignore').decode(encoding, errors='ignore')
|
||||
except (UnicodeDecodeError, UnicodeEncodeError, AttributeError):
|
||||
continue
|
||||
|
||||
# Fallback: replace problematic characters
|
||||
try:
|
||||
if isinstance(text, bytes):
|
||||
return text.decode('utf-8', errors='replace')
|
||||
else:
|
||||
return str(text).encode('utf-8', errors='replace').decode('utf-8')
|
||||
except:
|
||||
return str(text)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranslationRequest:
|
||||
"""Represents a translation request"""
|
||||
text: str
|
||||
source_lang: str = "ja"
|
||||
target_lang: str = "en"
|
||||
category: str = "general"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranslationResult:
|
||||
"""Represents a translation result"""
|
||||
original: str
|
||||
translated: str
|
||||
service: str
|
||||
confidence: float = 1.0
|
||||
cached: bool = False
|
||||
|
||||
|
||||
class TranslationError(Exception):
|
||||
"""Custom exception for translation errors"""
|
||||
pass
|
||||
|
||||
|
||||
class TranslationService(ABC):
|
||||
"""Abstract base class for translation services"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self._cache: Dict[str, str] = {}
|
||||
self._rate_limit_lock = threading.Lock()
|
||||
self._last_request_time = 0.0
|
||||
self._request_count = 0
|
||||
self._rate_limit_per_second = 10 # Default rate limit
|
||||
|
||||
@abstractmethod
|
||||
def translate_text(self, text: str, source_lang: str = "ja", target_lang: str = "en") -> str:
|
||||
"""Translate a single text string"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_available(self) -> bool:
|
||||
"""Check if the service is available"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_supported_languages(self) -> List[Tuple[str, str]]:
|
||||
"""Get list of supported language pairs (code, name)"""
|
||||
pass
|
||||
|
||||
def batch_translate(self, texts: List[str], source_lang: str = "ja", target_lang: str = "en") -> List[str]:
|
||||
"""Translate multiple texts with rate limiting - base implementation for services without native batch support"""
|
||||
results = []
|
||||
for text in texts:
|
||||
# Check cache first
|
||||
cache_key = f"{source_lang}_{target_lang}_{text}"
|
||||
if cache_key in self._cache:
|
||||
results.append(self._cache[cache_key])
|
||||
continue
|
||||
|
||||
# Rate limiting
|
||||
with self._rate_limit_lock:
|
||||
current_time = time.time()
|
||||
if current_time - self._last_request_time < (1.0 / self._rate_limit_per_second):
|
||||
time.sleep((1.0 / self._rate_limit_per_second) - (current_time - self._last_request_time))
|
||||
|
||||
try:
|
||||
translated = self.translate_text(text, source_lang, target_lang)
|
||||
self._cache[cache_key] = translated
|
||||
results.append(translated)
|
||||
self._last_request_time = time.time()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Translation failed for '{text}': {e}")
|
||||
results.append(text)
|
||||
|
||||
return results
|
||||
|
||||
def supports_batch_translation(self) -> bool:
|
||||
"""Check if service supports native batch translation"""
|
||||
return False
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the translation cache"""
|
||||
self._cache.clear()
|
||||
logger.info(f"Cleared cache for {self.name}")
|
||||
|
||||
|
||||
|
||||
|
||||
class DeepLService(TranslationService):
|
||||
"""DeepL translation service - requires API key"""
|
||||
|
||||
def __init__(self, api_key: str = "", use_free_api: bool = True):
|
||||
super().__init__("DeepL" + (" (Free)" if use_free_api else " (Pro)"))
|
||||
self.api_key = api_key
|
||||
self.use_free_api = use_free_api
|
||||
self._rate_limit_per_second = 5 # DeepL allows more requests
|
||||
self._base_url = "https://api-free.deepl.com" if use_free_api else "https://api.deepl.com"
|
||||
|
||||
def translate_text(self, text: str, source_lang: str = "ja", target_lang: str = "en") -> str:
|
||||
"""Translate text using DeepL API"""
|
||||
# Ensure text is properly encoded
|
||||
text = safe_decode_text(text)
|
||||
logger.info(f"DeepL: Starting translation of '{text}' from {source_lang} to {target_lang}")
|
||||
|
||||
if not text or not text.strip():
|
||||
logger.debug("Empty text provided, returning as-is")
|
||||
return text
|
||||
|
||||
if not self.api_key:
|
||||
raise TranslationError("DeepL API key is required")
|
||||
|
||||
# DeepL language codes mapping
|
||||
lang_map = {
|
||||
"ja": "JA", "en": "EN", "ko": "KO", "zh": "ZH",
|
||||
"es": "ES", "fr": "FR", "de": "DE", "it": "IT",
|
||||
"pt": "PT", "ru": "RU", "nl": "NL", "pl": "PL"
|
||||
}
|
||||
source_lang = lang_map.get(source_lang, source_lang.upper())
|
||||
target_lang = lang_map.get(target_lang, target_lang.upper())
|
||||
|
||||
endpoint = f"{self._base_url}/v2/translate"
|
||||
headers = {
|
||||
"Authorization": f"DeepL-Auth-Key {self.api_key}",
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}
|
||||
|
||||
data = {
|
||||
"text": text,
|
||||
"source_lang": source_lang,
|
||||
"target_lang": target_lang
|
||||
}
|
||||
|
||||
try:
|
||||
logger.debug(f"Making request to DeepL API: {endpoint}")
|
||||
response = requests.post(endpoint, headers=headers, data=data, timeout=15)
|
||||
logger.debug(f"DeepL response status: {response.status_code}")
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
logger.debug(f"DeepL response: {result}")
|
||||
|
||||
if "translations" in result and len(result["translations"]) > 0:
|
||||
translated_text = result["translations"][0]["text"]
|
||||
logger.info(f"DeepL SUCCESS: '{text}' -> '{translated_text}'")
|
||||
return translated_text
|
||||
else:
|
||||
raise TranslationError("DeepL API returned no translations")
|
||||
|
||||
except requests.HTTPError as e:
|
||||
if e.response.status_code == 401:
|
||||
raise TranslationError("DeepL API key is invalid")
|
||||
elif e.response.status_code == 403:
|
||||
raise TranslationError("DeepL API key access denied or quota exceeded")
|
||||
elif e.response.status_code == 456:
|
||||
raise TranslationError("DeepL quota exceeded")
|
||||
else:
|
||||
logger.error(f"DeepL HTTP error: {e}")
|
||||
raise TranslationError(f"DeepL API error: {e}")
|
||||
except requests.Timeout:
|
||||
logger.error("DeepL request timed out")
|
||||
raise TranslationError("DeepL request timed out after 15 seconds")
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"DeepL API request failed: {e}")
|
||||
raise TranslationError(f"DeepL API request failed: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in DeepL: {e}")
|
||||
raise TranslationError(f"Unexpected error: {e}")
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if DeepL service is available"""
|
||||
if not self.api_key:
|
||||
return False
|
||||
|
||||
try:
|
||||
headers = {"Authorization": f"DeepL-Auth-Key {self.api_key}"}
|
||||
response = requests.get(f"{self._base_url}/v2/usage", headers=headers, timeout=5)
|
||||
return response.status_code == 200
|
||||
except:
|
||||
return False
|
||||
|
||||
def get_supported_languages(self) -> List[Tuple[str, str]]:
|
||||
"""Get supported languages for DeepL"""
|
||||
return [
|
||||
("ja", "Japanese"),
|
||||
("en", "English"),
|
||||
("ko", "Korean"),
|
||||
("zh", "Chinese"),
|
||||
("es", "Spanish"),
|
||||
("fr", "French"),
|
||||
("de", "German"),
|
||||
("it", "Italian"),
|
||||
("pt", "Portuguese"),
|
||||
("ru", "Russian"),
|
||||
("nl", "Dutch"),
|
||||
("pl", "Polish")
|
||||
]
|
||||
|
||||
def supports_batch_translation(self) -> bool:
|
||||
"""DeepL supports native batch translation"""
|
||||
return True
|
||||
|
||||
def batch_translate(self, texts: List[str], source_lang: str = "ja", target_lang: str = "en") -> List[str]:
|
||||
"""Translate multiple texts using DeepL batch API"""
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
# Ensure all texts are properly encoded
|
||||
texts = [safe_decode_text(text) for text in texts]
|
||||
logger.info(f"DeepL: Starting batch translation of {len(texts)} texts from {source_lang} to {target_lang}")
|
||||
|
||||
results = [None] * len(texts)
|
||||
uncached_indices = []
|
||||
uncached_texts = []
|
||||
|
||||
for i, text in enumerate(texts):
|
||||
if not text or not text.strip():
|
||||
results[i] = text
|
||||
continue
|
||||
|
||||
cache_key = f"{source_lang}_{target_lang}_{text}"
|
||||
if cache_key in self._cache:
|
||||
results[i] = self._cache[cache_key]
|
||||
continue
|
||||
|
||||
uncached_indices.append(i)
|
||||
uncached_texts.append(text)
|
||||
|
||||
if not uncached_texts:
|
||||
logger.info(f"DeepL: All {len(texts)} texts found in cache")
|
||||
return results
|
||||
|
||||
logger.info(f"DeepL: Translating {len(uncached_texts)} uncached texts")
|
||||
|
||||
if not self.api_key:
|
||||
logger.error("DeepL API key is required for batch translation")
|
||||
for i, idx in enumerate(uncached_indices):
|
||||
results[idx] = texts[idx]
|
||||
return results
|
||||
|
||||
# DeepL language codes mapping
|
||||
lang_map = {
|
||||
"ja": "JA", "en": "EN", "ko": "KO", "zh": "ZH",
|
||||
"es": "ES", "fr": "FR", "de": "DE", "it": "IT",
|
||||
"pt": "PT", "ru": "RU", "nl": "NL", "pl": "PL"
|
||||
}
|
||||
source_lang_code = lang_map.get(source_lang, source_lang.upper())
|
||||
target_lang_code = lang_map.get(target_lang, target_lang.upper())
|
||||
|
||||
# Batch size limit for DeepL
|
||||
batch_size = 50
|
||||
|
||||
for batch_start in range(0, len(uncached_texts), batch_size):
|
||||
batch_end = min(batch_start + batch_size, len(uncached_texts))
|
||||
batch_texts = uncached_texts[batch_start:batch_end]
|
||||
batch_indices = uncached_indices[batch_start:batch_end]
|
||||
|
||||
logger.debug(f"DeepL batch {batch_start//batch_size + 1}: Processing {len(batch_texts)} texts")
|
||||
|
||||
endpoint = f"{self._base_url}/v2/translate"
|
||||
headers = {
|
||||
"Authorization": f"DeepL-Auth-Key {self.api_key}",
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}
|
||||
|
||||
# Build form data with multiple text parameters (DeepL supports multiple 'text' params)
|
||||
form_data = [
|
||||
('source_lang', source_lang_code),
|
||||
('target_lang', target_lang_code)
|
||||
]
|
||||
for text in batch_texts:
|
||||
form_data.append(('text', text))
|
||||
|
||||
try:
|
||||
logger.debug(f"Making batch request to DeepL API: {endpoint}")
|
||||
|
||||
import requests
|
||||
response = requests.post(endpoint, headers=headers, data=form_data, timeout=30)
|
||||
logger.debug(f"DeepL batch response status: {response.status_code}")
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
logger.debug(f"DeepL batch response: {result}")
|
||||
|
||||
if "translations" in result and len(result["translations"]) == len(batch_texts):
|
||||
for i, translation_data in enumerate(result["translations"]):
|
||||
original_text = batch_texts[i]
|
||||
translated_text = translation_data["text"]
|
||||
original_idx = batch_indices[i]
|
||||
|
||||
cache_key = f"{source_lang}_{target_lang}_{original_text}"
|
||||
self._cache[cache_key] = translated_text
|
||||
|
||||
results[original_idx] = translated_text
|
||||
logger.debug(f"DeepL batch SUCCESS: '{original_text}' -> '{translated_text}'")
|
||||
else:
|
||||
logger.error(f"DeepL batch API returned unexpected response: {result}")
|
||||
for i, idx in enumerate(batch_indices):
|
||||
results[idx] = batch_texts[i]
|
||||
|
||||
# Rate limiting between batches
|
||||
if batch_end < len(uncached_texts):
|
||||
time.sleep(1.0 / self._rate_limit_per_second)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DeepL batch translation failed: {e}")
|
||||
for i, idx in enumerate(batch_indices):
|
||||
results[idx] = batch_texts[i]
|
||||
|
||||
# Ensure all results are filled
|
||||
for i, result in enumerate(results):
|
||||
if result is None:
|
||||
results[i] = texts[i]
|
||||
|
||||
successful_translations = sum(1 for i, result in enumerate(results) if result != texts[i])
|
||||
logger.info(f"DeepL batch translation complete: {successful_translations}/{len(texts)} successfully translated")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class MyMemoryService(TranslationService):
|
||||
"""MyMemory free translation service - no API key required"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("MyMemory (Free)")
|
||||
self._rate_limit_per_second = 1 # Conservative rate limiting for free service
|
||||
self._base_url = "https://api.mymemory.translated.net"
|
||||
|
||||
def translate_text(self, text: str, source_lang: str = "ja", target_lang: str = "en") -> str:
|
||||
"""Translate text using MyMemory free API"""
|
||||
# Ensure text is properly encoded
|
||||
text = safe_decode_text(text)
|
||||
logger.info(f"MyMemory: Starting translation of '{text}' from {source_lang} to {target_lang}")
|
||||
|
||||
if not text or not text.strip():
|
||||
logger.debug("Empty text provided, returning as-is")
|
||||
return text
|
||||
|
||||
# MyMemory uses different language codes
|
||||
lang_map = {"ja": "ja", "en": "en", "ko": "ko", "zh": "zh", "es": "es", "fr": "fr", "de": "de"}
|
||||
source_lang = lang_map.get(source_lang, source_lang)
|
||||
target_lang = lang_map.get(target_lang, target_lang)
|
||||
|
||||
endpoint = f"{self._base_url}/get"
|
||||
params = {
|
||||
'q': text,
|
||||
'langpair': f"{source_lang}|{target_lang}",
|
||||
'de': 'neoneko@avatartoolkit.com' # Optional email for higher quotas
|
||||
}
|
||||
|
||||
try:
|
||||
logger.debug(f"Making request to MyMemory API: {endpoint} with params: {params}")
|
||||
response = requests.get(endpoint, params=params, timeout=15) # Increased timeout
|
||||
logger.debug(f"MyMemory response status: {response.status_code}")
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
logger.debug(f"MyMemory response: {result}")
|
||||
|
||||
if result.get('responseStatus') == 200 and 'responseData' in result:
|
||||
translated_text = result['responseData']['translatedText']
|
||||
matches = result.get('matches', [])
|
||||
if matches and len(matches) > 0:
|
||||
match_quality = matches[0].get('quality', '0')
|
||||
logger.debug(f"MyMemory translation quality: {match_quality}")
|
||||
|
||||
logger.info(f"MyMemory SUCCESS: '{text}' -> '{translated_text}'")
|
||||
return translated_text
|
||||
else:
|
||||
error_msg = result.get('responseDetails', 'Unknown error')
|
||||
logger.error(f"MyMemory API error: {error_msg}")
|
||||
|
||||
if 'QUOTA_EXCEEDED' in error_msg:
|
||||
raise TranslationError(f"MyMemory daily quota (1000 requests) exceeded. Try again tomorrow or switch to another service.")
|
||||
else:
|
||||
raise TranslationError(f"MyMemory API error: {error_msg}")
|
||||
|
||||
except requests.Timeout as e:
|
||||
logger.error(f"MyMemory request timed out: {e}")
|
||||
raise TranslationError(f"MyMemory request timed out after 15 seconds")
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"MyMemory API request failed: {e}")
|
||||
raise TranslationError(f"MyMemory API request failed: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in MyMemory: {e}")
|
||||
raise TranslationError(f"Unexpected error: {e}")
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if MyMemory service is available"""
|
||||
try:
|
||||
response = requests.get(f"{self._base_url}/get",
|
||||
params={'q': 'test', 'langpair': 'en|en'},
|
||||
timeout=5)
|
||||
return response.status_code == 200
|
||||
except:
|
||||
return False
|
||||
|
||||
def get_supported_languages(self) -> List[Tuple[str, str]]:
|
||||
"""Get supported languages for MyMemory"""
|
||||
return [
|
||||
("ja", "Japanese"),
|
||||
("en", "English"),
|
||||
("ko", "Korean"),
|
||||
("zh", "Chinese"),
|
||||
("es", "Spanish"),
|
||||
("fr", "French"),
|
||||
("de", "German"),
|
||||
("it", "Italian"),
|
||||
("pt", "Portuguese"),
|
||||
("ru", "Russian")
|
||||
]
|
||||
|
||||
def supports_batch_translation(self) -> bool:
|
||||
"""MyMemory optimized batch processing"""
|
||||
return True
|
||||
|
||||
def batch_translate(self, texts: List[str], source_lang: str = "ja", target_lang: str = "en") -> List[str]:
|
||||
"""Translate multiple texts using MyMemory with optimized batching and caching"""
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
# Ensure all texts are properly encoded
|
||||
texts = [safe_decode_text(text) for text in texts]
|
||||
logger.info(f"MyMemory: Starting batch translation of {len(texts)} texts from {source_lang} to {target_lang}")
|
||||
|
||||
results = [None] * len(texts)
|
||||
uncached_indices = []
|
||||
uncached_texts = []
|
||||
|
||||
for i, text in enumerate(texts):
|
||||
if not text or not text.strip():
|
||||
results[i] = text
|
||||
continue
|
||||
|
||||
cache_key = f"{source_lang}_{target_lang}_{text}"
|
||||
if cache_key in self._cache:
|
||||
results[i] = self._cache[cache_key]
|
||||
continue
|
||||
|
||||
uncached_indices.append(i)
|
||||
uncached_texts.append(text)
|
||||
|
||||
if not uncached_texts:
|
||||
logger.info(f"MyMemory: All {len(texts)} texts found in cache")
|
||||
return results
|
||||
|
||||
logger.info(f"MyMemory: Translating {len(uncached_texts)} uncached texts using concurrent processing")
|
||||
|
||||
# Use concurrent processing for MyMemory to speed up translations
|
||||
import concurrent.futures
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import threading
|
||||
|
||||
def translate_single_text(text_info):
|
||||
idx, text = text_info
|
||||
try:
|
||||
with self._rate_limit_lock:
|
||||
current_time = time.time()
|
||||
if current_time - self._last_request_time < (1.0 / self._rate_limit_per_second):
|
||||
sleep_time = (1.0 / self._rate_limit_per_second) - (current_time - self._last_request_time)
|
||||
time.sleep(sleep_time)
|
||||
self._last_request_time = time.time()
|
||||
|
||||
translated = self.translate_text(text, source_lang, target_lang)
|
||||
|
||||
cache_key = f"{source_lang}_{target_lang}_{text}"
|
||||
self._cache[cache_key] = translated
|
||||
|
||||
return idx, translated, None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"MyMemory concurrent translation failed for '{text}': {e}")
|
||||
return idx, text, e
|
||||
|
||||
# Use conservative concurrent processing (2 workers max for free service)
|
||||
max_workers = min(len(uncached_texts), 2)
|
||||
batch_size = 8
|
||||
|
||||
for batch_start in range(0, len(uncached_texts), batch_size):
|
||||
batch_end = min(batch_start + batch_size, len(uncached_texts))
|
||||
batch_texts = uncached_texts[batch_start:batch_end]
|
||||
batch_indices = uncached_indices[batch_start:batch_end]
|
||||
|
||||
text_info_batch = [(batch_indices[i], text) for i, text in enumerate(batch_texts)]
|
||||
|
||||
logger.debug(f"MyMemory concurrent batch {batch_start//batch_size + 1}: Processing {len(batch_texts)} texts with {max_workers} workers")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_to_text = {executor.submit(translate_single_text, text_info): text_info for text_info in text_info_batch}
|
||||
|
||||
for future in concurrent.futures.as_completed(future_to_text):
|
||||
try:
|
||||
original_idx, translated_text, error = future.result(timeout=25)
|
||||
results[original_idx] = translated_text
|
||||
|
||||
if error is None:
|
||||
logger.debug(f"MyMemory concurrent SUCCESS: -> '{translated_text}'")
|
||||
else:
|
||||
logger.debug(f"MyMemory concurrent FAILED: {error}")
|
||||
|
||||
except concurrent.futures.TimeoutError:
|
||||
text_info = future_to_text[future]
|
||||
original_idx, original_text = text_info
|
||||
results[original_idx] = original_text
|
||||
logger.warning(f"MyMemory concurrent timeout for text: '{original_text}'")
|
||||
except Exception as e:
|
||||
text_info = future_to_text[future]
|
||||
original_idx, original_text = text_info
|
||||
results[original_idx] = original_text
|
||||
logger.error(f"MyMemory concurrent thread error for '{original_text}': {e}")
|
||||
|
||||
# Shorter pause between batches since we're not hammering the API
|
||||
if batch_end < len(uncached_texts):
|
||||
time.sleep(0.5)
|
||||
|
||||
for i, result in enumerate(results):
|
||||
if result is None:
|
||||
results[i] = texts[i]
|
||||
|
||||
successful_translations = sum(1 for i, result in enumerate(results) if result != texts[i])
|
||||
logger.info(f"MyMemory concurrent batch translation complete: {successful_translations}/{len(texts)} successfully translated")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class LibreTranslateService(TranslationService):
|
||||
"""LibreTranslate translation service with configurable server"""
|
||||
|
||||
def __init__(self, api_url: str = "https://libretranslate.com", api_key: str = None):
|
||||
super().__init__("LibreTranslate")
|
||||
# Ensure URL has trailing slash like official implementation
|
||||
self.api_url = api_url.rstrip('/') + '/'
|
||||
self.api_key = api_key
|
||||
self._rate_limit_per_second = 2 # Conservative rate limiting
|
||||
self._is_paid_service = "libretranslate.com" in api_url.lower()
|
||||
|
||||
def translate_text(self, text: str, source_lang: str = "ja", target_lang: str = "en") -> str:
|
||||
"""Translate text using LibreTranslate API"""
|
||||
# Ensure text is properly encoded
|
||||
text = safe_decode_text(text)
|
||||
logger.info(f"LibreTranslate: Starting translation of '{text}' from {source_lang} to {target_lang}")
|
||||
|
||||
if not text or not text.strip():
|
||||
logger.debug("Empty text provided, returning as-is")
|
||||
return text
|
||||
|
||||
lang_map = {"ja": "ja", "en": "en", "ko": "ko", "zh": "zh", "es": "es", "fr": "fr", "de": "de", "it": "it", "pt": "pt", "ru": "ru"}
|
||||
source_lang = lang_map.get(source_lang, source_lang)
|
||||
target_lang = lang_map.get(target_lang, target_lang)
|
||||
|
||||
endpoint = f"{self.api_url}translate"
|
||||
data = {
|
||||
"q": text,
|
||||
"source": source_lang,
|
||||
"target": target_lang
|
||||
}
|
||||
# Add API key if available (required for libretranslate.com, optional for self-hosted)
|
||||
if self.api_key:
|
||||
data["api_key"] = self.api_key
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
try:
|
||||
logger.debug(f"Making request to LibreTranslate API: {endpoint}")
|
||||
# Use JSON format like official API documentation
|
||||
response = requests.post(endpoint, json=data, headers=headers, timeout=15)
|
||||
logger.debug(f"LibreTranslate response status: {response.status_code}")
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
logger.debug(f"LibreTranslate response: {result}")
|
||||
|
||||
if "translatedText" in result:
|
||||
translated_text = result["translatedText"]
|
||||
logger.info(f"LibreTranslate SUCCESS: '{text}' -> '{translated_text}'")
|
||||
return translated_text
|
||||
else:
|
||||
raise TranslationError("LibreTranslate API returned no translation")
|
||||
|
||||
except requests.HTTPError as e:
|
||||
if e.response.status_code == 429:
|
||||
raise TranslationError("LibreTranslate rate limit exceeded")
|
||||
elif e.response.status_code == 400:
|
||||
raise TranslationError("LibreTranslate: Invalid language pair or text")
|
||||
else:
|
||||
logger.error(f"LibreTranslate HTTP error: {e}")
|
||||
raise TranslationError(f"LibreTranslate API error: {e}")
|
||||
except requests.Timeout:
|
||||
logger.error("LibreTranslate request timed out")
|
||||
raise TranslationError("LibreTranslate request timed out after 15 seconds")
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"LibreTranslate API request failed: {e}")
|
||||
raise TranslationError(f"LibreTranslate API request failed: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in LibreTranslate: {e}")
|
||||
raise TranslationError(f"Unexpected error: {e}")
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if LibreTranslate service is available"""
|
||||
try:
|
||||
endpoint = f"{self.api_url}languages"
|
||||
|
||||
params = {}
|
||||
if self.api_key:
|
||||
params["api_key"] = self.api_key
|
||||
|
||||
response = requests.get(endpoint, params=params if params else None, timeout=5)
|
||||
return response.status_code == 200
|
||||
except:
|
||||
return False
|
||||
|
||||
def get_supported_languages(self) -> List[Tuple[str, str]]:
|
||||
"""Get supported languages for LibreTranslate"""
|
||||
try:
|
||||
endpoint = f"{self.api_url}languages"
|
||||
|
||||
params = {}
|
||||
if self.api_key:
|
||||
params["api_key"] = self.api_key
|
||||
|
||||
response = requests.get(endpoint, params=params if params else None, timeout=5)
|
||||
|
||||
if response.status_code == 200:
|
||||
languages = response.json()
|
||||
return [(lang["code"], lang["name"]) for lang in languages]
|
||||
except:
|
||||
pass
|
||||
|
||||
# Fallback to common languages
|
||||
return [
|
||||
("ja", "Japanese"),
|
||||
("en", "English"),
|
||||
("ko", "Korean"),
|
||||
("zh", "Chinese"),
|
||||
("es", "Spanish"),
|
||||
("fr", "French"),
|
||||
("de", "German"),
|
||||
("it", "Italian"),
|
||||
("pt", "Portuguese"),
|
||||
("ru", "Russian")
|
||||
]
|
||||
|
||||
def supports_batch_translation(self) -> bool:
|
||||
"""LibreTranslate optimized batch processing (concurrent requests)"""
|
||||
return True
|
||||
|
||||
def batch_translate(self, texts: List[str], source_lang: str = "ja", target_lang: str = "en") -> List[str]:
|
||||
"""Translate multiple texts using LibreTranslate with optimized concurrent requests"""
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
# Ensure all texts are properly encoded
|
||||
texts = [safe_decode_text(text) for text in texts]
|
||||
logger.info(f"LibreTranslate: Starting batch translation of {len(texts)} texts from {source_lang} to {target_lang}")
|
||||
|
||||
# Check cache and separate cached vs uncached texts
|
||||
results = [None] * len(texts)
|
||||
uncached_indices = []
|
||||
uncached_texts = []
|
||||
|
||||
for i, text in enumerate(texts):
|
||||
if not text or not text.strip():
|
||||
results[i] = text
|
||||
continue
|
||||
|
||||
cache_key = f"{source_lang}_{target_lang}_{text}"
|
||||
if cache_key in self._cache:
|
||||
results[i] = self._cache[cache_key]
|
||||
continue
|
||||
|
||||
uncached_indices.append(i)
|
||||
uncached_texts.append(text)
|
||||
|
||||
if not uncached_texts:
|
||||
logger.info(f"LibreTranslate: All {len(texts)} texts found in cache")
|
||||
return results
|
||||
|
||||
logger.info(f"LibreTranslate: Translating {len(uncached_texts)} uncached texts")
|
||||
|
||||
# LibreTranslate language mapping
|
||||
lang_map = {"ja": "ja", "en": "en", "ko": "ko", "zh": "zh", "es": "es", "fr": "fr", "de": "de", "it": "it", "pt": "pt", "ru": "ru"}
|
||||
source_lang_code = lang_map.get(source_lang, source_lang)
|
||||
target_lang_code = lang_map.get(target_lang, target_lang)
|
||||
|
||||
# Batch process in groups to avoid overwhelming the server
|
||||
import concurrent.futures
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
def translate_single_text(text_info):
|
||||
idx, text = text_info
|
||||
try:
|
||||
translated = self.translate_text(text, source_lang, target_lang)
|
||||
cache_key = f"{source_lang}_{target_lang}_{text}"
|
||||
self._cache[cache_key] = translated
|
||||
return idx, translated, None
|
||||
except Exception as e:
|
||||
logger.warning(f"LibreTranslate translation failed for '{text}': {e}")
|
||||
return idx, text, e
|
||||
|
||||
# Use thread pool for concurrent requests (limited to avoid server overload)
|
||||
max_workers = min(len(uncached_texts), 3)
|
||||
batch_size = 10 # Process in smaller batches
|
||||
|
||||
for batch_start in range(0, len(uncached_texts), batch_size):
|
||||
batch_end = min(batch_start + batch_size, len(uncached_texts))
|
||||
batch_texts = uncached_texts[batch_start:batch_end]
|
||||
batch_indices = uncached_indices[batch_start:batch_end]
|
||||
|
||||
text_info_batch = [(batch_indices[i], text) for i, text in enumerate(batch_texts)]
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_to_text = {executor.submit(translate_single_text, text_info): text_info for text_info in text_info_batch}
|
||||
|
||||
for future in concurrent.futures.as_completed(future_to_text):
|
||||
try:
|
||||
original_idx, translated_text, error = future.result(timeout=30)
|
||||
results[original_idx] = translated_text
|
||||
|
||||
if error is None:
|
||||
logger.debug(f"LibreTranslate SUCCESS: -> '{translated_text}'")
|
||||
else:
|
||||
logger.debug(f"LibreTranslate FAILED: {error}")
|
||||
|
||||
except concurrent.futures.TimeoutError:
|
||||
text_info = future_to_text[future]
|
||||
original_idx, original_text = text_info
|
||||
results[original_idx] = original_text
|
||||
logger.warning(f"LibreTranslate timeout for text: '{original_text}'")
|
||||
except Exception as e:
|
||||
text_info = future_to_text[future]
|
||||
original_idx, original_text = text_info
|
||||
results[original_idx] = original_text
|
||||
logger.error(f"LibreTranslate thread error for '{original_text}': {e}")
|
||||
|
||||
if batch_end < len(uncached_texts):
|
||||
time.sleep(0.5)
|
||||
|
||||
for i, result in enumerate(results):
|
||||
if result is None:
|
||||
results[i] = texts[i]
|
||||
|
||||
successful_translations = sum(1 for i, result in enumerate(results) if result != texts[i])
|
||||
logger.info(f"LibreTranslate batch translation complete: {successful_translations}/{len(texts)} successfully translated")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class TranslationServiceManager:
|
||||
"""Manages multiple translation services with fallback logic"""
|
||||
|
||||
def __init__(self):
|
||||
self._services: Dict[str, TranslationService] = {}
|
||||
self._primary_service: Optional[str] = None
|
||||
self._initialize_services()
|
||||
|
||||
def _initialize_services(self):
|
||||
"""Initialize available translation services"""
|
||||
mymemory = MyMemoryService()
|
||||
self._services["mymemory"] = mymemory
|
||||
|
||||
libretranslate_url = get_preference("libretranslate_url", "https://libretranslate.com")
|
||||
libretranslate_api_key = get_preference("libretranslate_api_key", "")
|
||||
libretranslate = LibreTranslateService(api_url=libretranslate_url, api_key=libretranslate_api_key if libretranslate_api_key else None)
|
||||
self._services["libretranslate"] = libretranslate
|
||||
|
||||
deepl_api_key = get_preference("deepl_api_key", "")
|
||||
if deepl_api_key:
|
||||
deepl = DeepLService(api_key=deepl_api_key, use_free_api=True)
|
||||
self._services["deepl"] = deepl
|
||||
|
||||
# Set primary service from preferences (default to free service)
|
||||
self._primary_service = get_preference("translation_service", "mymemory")
|
||||
|
||||
logger.info(f"Initialized translation services: {list(self._services.keys())}")
|
||||
logger.info(f"Primary service: {self._primary_service}")
|
||||
|
||||
def get_available_services(self) -> List[Tuple[str, str]]:
|
||||
"""Get list of available translation services"""
|
||||
available = []
|
||||
for service_id, service in self._services.items():
|
||||
if service.is_available():
|
||||
available.append((service_id, service.name))
|
||||
else:
|
||||
logger.debug(f"Service {service.name} is not available")
|
||||
return available
|
||||
|
||||
def set_primary_service(self, service_id: str) -> bool:
|
||||
"""Set the primary translation service"""
|
||||
if service_id in self._services:
|
||||
self._primary_service = service_id
|
||||
save_preference("translation_service", service_id)
|
||||
logger.info(f"Set primary translation service to: {service_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_service(self, service_id: Optional[str] = None) -> Optional[TranslationService]:
|
||||
"""Get a translation service by ID"""
|
||||
if service_id is None:
|
||||
service_id = self._primary_service
|
||||
|
||||
if service_id and service_id in self._services:
|
||||
service = self._services[service_id]
|
||||
if service.is_available():
|
||||
return service
|
||||
|
||||
return None
|
||||
|
||||
def translate_with_fallback(self, text: str, source_lang: str = "ja", target_lang: str = "en") -> Tuple[str, str]:
|
||||
"""Translate text with automatic fallback to other services"""
|
||||
# Ensure text is properly encoded
|
||||
text = safe_decode_text(text)
|
||||
if not text or not text.strip():
|
||||
return text, "none"
|
||||
|
||||
# Try primary service first
|
||||
primary_service = self.get_service()
|
||||
if primary_service:
|
||||
try:
|
||||
result = primary_service.translate_text(text, source_lang, target_lang)
|
||||
return result, primary_service.name
|
||||
except Exception as e:
|
||||
logger.warning(f"Primary service {primary_service.name} failed: {e}")
|
||||
|
||||
for service_id, service in self._services.items():
|
||||
if service_id == self._primary_service:
|
||||
continue
|
||||
|
||||
if service.is_available():
|
||||
try:
|
||||
result = service.translate_text(text, source_lang, target_lang)
|
||||
logger.info(f"Fallback to {service.name} successful")
|
||||
return result, service.name
|
||||
except Exception as e:
|
||||
logger.warning(f"Fallback service {service.name} failed: {e}")
|
||||
|
||||
logger.error(f"All translation services failed for: {text}")
|
||||
return text, "failed"
|
||||
|
||||
def batch_translate_with_fallback(self, texts: List[str], source_lang: str = "ja", target_lang: str = "en") -> List[Tuple[str, str]]:
|
||||
"""Batch translate with fallback - uses optimized batch processing when available"""
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
logger.info(f"Starting batch translation of {len(texts)} texts using service manager")
|
||||
|
||||
primary_service = self.get_service()
|
||||
if primary_service:
|
||||
try:
|
||||
if primary_service.supports_batch_translation():
|
||||
logger.info(f"Using native batch translation with {primary_service.name}")
|
||||
translations = primary_service.batch_translate(texts, source_lang, target_lang)
|
||||
return [(translation, primary_service.name) for translation in translations]
|
||||
else:
|
||||
logger.info(f"Service {primary_service.name} does not support batch translation, using individual requests")
|
||||
# Use the base implementation for services without batch support
|
||||
translations = []
|
||||
for text in texts:
|
||||
translated = primary_service.translate_text(text, source_lang, target_lang)
|
||||
translations.append(translated)
|
||||
return [(translation, primary_service.name) for translation in translations]
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Batch translation failed with {primary_service.name}: {e}")
|
||||
|
||||
results = []
|
||||
for text in texts:
|
||||
translation, service_name = self.translate_with_fallback(text, source_lang, target_lang)
|
||||
results.append((translation, service_name))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# Global translation service manager instance
|
||||
_translation_manager: Optional[TranslationServiceManager] = None
|
||||
|
||||
|
||||
def get_translation_manager() -> TranslationServiceManager:
|
||||
"""Get the global translation service manager"""
|
||||
global _translation_manager
|
||||
if _translation_manager is None:
|
||||
_translation_manager = TranslationServiceManager()
|
||||
return _translation_manager
|
||||
|
||||
|
||||
def configure_deepl_translator(api_key: str, use_free_api: bool = True) -> bool:
|
||||
"""Configure DeepL translation service"""
|
||||
try:
|
||||
save_preference("deepl_api_key", api_key)
|
||||
save_preference("deepl_use_free_api", use_free_api)
|
||||
|
||||
# Test the API key
|
||||
deepl = DeepLService(api_key=api_key, use_free_api=use_free_api)
|
||||
if deepl.is_available():
|
||||
# Re-initialize the global manager to pick up new service
|
||||
global _translation_manager
|
||||
_translation_manager = None
|
||||
logger.info("DeepL translator configured successfully")
|
||||
return True
|
||||
else:
|
||||
logger.error("DeepL API key test failed")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to configure DeepL translator: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def configure_libretranslate_server(server_url: str, api_key: str = None) -> bool:
|
||||
"""Configure LibreTranslate server URL and optional API key"""
|
||||
try:
|
||||
if not server_url.strip():
|
||||
server_url = "https://libretranslate.com"
|
||||
|
||||
# Ensure proper URL format
|
||||
if not server_url.startswith(('http://', 'https://')):
|
||||
server_url = 'https://' + server_url
|
||||
|
||||
save_preference("libretranslate_url", server_url)
|
||||
save_preference("libretranslate_api_key", api_key if api_key else "")
|
||||
|
||||
# Test the server
|
||||
libretranslate = LibreTranslateService(api_url=server_url, api_key=api_key)
|
||||
if libretranslate.is_available():
|
||||
# Re-initialize the global manager to pick up new service
|
||||
global _translation_manager
|
||||
_translation_manager = None
|
||||
logger.info(f"LibreTranslate server configured successfully: {server_url}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"LibreTranslate server test failed: {server_url}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to configure LibreTranslate server: {e}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
|
||||
+77
-6
@@ -17,11 +17,17 @@ from typing import Dict, List, Tuple, Optional, Set, Any
|
||||
|
||||
GITHUB_REPO = "teamneoneko/Avatar-Toolkit"
|
||||
|
||||
# Define which version series this installation can update to
|
||||
# For example: ["0.1"] means only look for 0.1.x updates
|
||||
# ["0.2", "0.3"] would look for both 0.2.x and 0.3.x
|
||||
ALLOWED_VERSION_SERIES = ["0.6"]
|
||||
|
||||
is_checking_for_update: bool = False
|
||||
update_needed: bool = False
|
||||
latest_version: Optional[str] = None
|
||||
latest_version_str: str = ''
|
||||
version_list: Optional[Dict[str, List[str]]] = None
|
||||
last_manual_check_time: float = 0
|
||||
|
||||
main_dir: str = os.path.dirname(os.path.dirname(__file__))
|
||||
downloads_dir: str = os.path.join(main_dir, "downloads")
|
||||
@@ -34,7 +40,9 @@ class AvatarToolkit_OT_CheckForUpdate(bpy.types.Operator):
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> Set[str]:
|
||||
global last_manual_check_time
|
||||
check_for_update_background()
|
||||
last_manual_check_time = time.time() # Reset the timer on manual check
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
@@ -76,11 +84,20 @@ class AvatarToolkit_PT_UpdaterPanel(bpy.types.Panel):
|
||||
bl_region_type = 'UI'
|
||||
bl_category = CATEGORY_NAME
|
||||
bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
|
||||
bl_order = 8
|
||||
bl_order = 9
|
||||
bl_options = {'DEFAULT_CLOSED'}
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
global last_manual_check_time
|
||||
layout = self.layout
|
||||
|
||||
# Auto-check for updates when panel is drawn, but not too frequently
|
||||
current_time = time.time()
|
||||
if current_time - last_manual_check_time > 300: # 5 minutes between auto-checks
|
||||
if not is_checking_for_update and not update_needed:
|
||||
check_for_update_background()
|
||||
last_manual_check_time = current_time
|
||||
|
||||
draw_updater_panel(context, layout)
|
||||
|
||||
|
||||
@@ -158,11 +175,23 @@ def get_github_releases() -> bool:
|
||||
return True
|
||||
|
||||
def check_for_update_available() -> bool:
|
||||
global latest_version, latest_version_str
|
||||
global latest_version, latest_version_str, version_list
|
||||
if not version_list:
|
||||
return False
|
||||
|
||||
latest_version = max(version_list.keys(), key=lambda v: [int(x) for x in v.split('.')])
|
||||
# Filter versions by allowed version series
|
||||
compatible_versions = {}
|
||||
for v, info in version_list.items():
|
||||
for prefix in ALLOWED_VERSION_SERIES:
|
||||
if v.startswith(prefix):
|
||||
compatible_versions[v] = info
|
||||
break
|
||||
|
||||
if not compatible_versions:
|
||||
print(f"No compatible versions found in series: {', '.join(ALLOWED_VERSION_SERIES)}")
|
||||
return False
|
||||
|
||||
latest_version = max(compatible_versions.keys(), key=lambda v: [int(x) for x in v.split('.')])
|
||||
latest_version_str = latest_version
|
||||
|
||||
current_version = get_current_version()
|
||||
@@ -197,9 +226,35 @@ def update_now(latest: bool = False) -> None:
|
||||
return
|
||||
|
||||
if latest:
|
||||
update_link = version_list[latest_version_str][0]
|
||||
# Filter compatible versions
|
||||
compatible_versions = {}
|
||||
for v, info in version_list.items():
|
||||
for prefix in ALLOWED_VERSION_SERIES:
|
||||
if v.startswith(prefix):
|
||||
compatible_versions[v] = info
|
||||
break
|
||||
|
||||
if not compatible_versions:
|
||||
print(f"No compatible versions found in series: {', '.join(ALLOWED_VERSION_SERIES)}")
|
||||
return
|
||||
|
||||
latest_compatible = max(compatible_versions.keys(), key=lambda v: [int(x) for x in v.split('.')])
|
||||
update_link = version_list[latest_compatible][0]
|
||||
else:
|
||||
update_link = version_list[bpy.context.scene.avatar_toolkit_updater_version_list][0]
|
||||
selected_version = bpy.context.scene.avatar_toolkit_updater_version_list
|
||||
|
||||
# Check if selected version is compatible
|
||||
is_compatible = False
|
||||
for prefix in ALLOWED_VERSION_SERIES:
|
||||
if selected_version.startswith(prefix):
|
||||
is_compatible = True
|
||||
break
|
||||
|
||||
if not is_compatible:
|
||||
print(f"Selected version {selected_version} is not in allowed series: {', '.join(ALLOWED_VERSION_SERIES)}")
|
||||
return
|
||||
|
||||
update_link = version_list[selected_version][0]
|
||||
|
||||
download_file(update_link)
|
||||
ui_refresh()
|
||||
@@ -274,7 +329,17 @@ def finish_update(error: str = '') -> None:
|
||||
ui_refresh()
|
||||
|
||||
def get_version_list(self, context: bpy.types.Context) -> List[Tuple[str, str, str]]:
|
||||
return [(v, v, '') for v in version_list.keys()] if version_list else []
|
||||
if not version_list:
|
||||
return []
|
||||
|
||||
compatible_versions = []
|
||||
for v in version_list.keys():
|
||||
for prefix in ALLOWED_VERSION_SERIES:
|
||||
if v.startswith(prefix):
|
||||
compatible_versions.append(v)
|
||||
break
|
||||
|
||||
return [(v, v, '') for v in compatible_versions]
|
||||
|
||||
def draw_updater_panel(context: bpy.types.Context, layout: bpy.types.UILayout) -> None:
|
||||
box = layout.box()
|
||||
@@ -287,6 +352,12 @@ def draw_updater_panel(context: bpy.types.Context, layout: bpy.types.UILayout) -
|
||||
|
||||
col.separator()
|
||||
|
||||
# Show compatibility info
|
||||
col.label(text=f"Update series: {', '.join(s + '.x' for s in ALLOWED_VERSION_SERIES)}", icon='INFO')
|
||||
col.label(text=f"Blender version: {bpy.app.version_string}", icon='BLENDER')
|
||||
|
||||
col.separator()
|
||||
|
||||
# Update check/status section
|
||||
if is_checking_for_update:
|
||||
col.operator(AvatarToolkit_OT_CheckForUpdate.bl_idname,
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
import bpy
|
||||
from typing import Dict, List, Optional, Tuple, Set
|
||||
from bpy.types import Object, Bone
|
||||
from .common import get_active_armature
|
||||
from .dictionaries import simplify_bonename, standard_bones, bone_hierarchy, reverse_bone_lookup
|
||||
from .logging_setup import logger
|
||||
from .translations import t
|
||||
|
||||
|
||||
def detect_vrm_armature(armature: Object) -> bool:
|
||||
"""
|
||||
Detect if armature uses VRM bone naming conventions
|
||||
"""
|
||||
if not armature or armature.type != 'ARMATURE':
|
||||
return False
|
||||
|
||||
vrm_patterns = [
|
||||
'jbipchips', 'jbipcspine', 'jbipcchest', 'jbipcneck', 'jbipchead',
|
||||
# Right arm patterns (both single and double R)
|
||||
'jbiprlshoulder', 'jbiprshoulder', 'jbiprupperarm', 'jbiprforearm', 'jbiprhand', 'jbiprlowerarm',
|
||||
'jbiprrupperarm', 'jbiprrforearm', 'jbiprrhand',
|
||||
# Left arm patterns
|
||||
'jbipllshoulder', 'jbiplshoulder', 'jbiplupperarm', 'jbipllforearm', 'jbipllhand', 'jbipllowerarm', 'jbiplhand',
|
||||
# Right leg patterns (both single and double R)
|
||||
'jbiprupperleg', 'jbiprlowerleg', 'jbiprfoot', 'jbiprtoe', 'jbiprtoebase',
|
||||
'jbiprrupperleg', 'jbiprrlowerleg', 'jbiprrfoot', 'jbiprrtoe',
|
||||
# Left leg patterns
|
||||
'jbiplupperleg', 'jbipllowerleg', 'jbipllfoot', 'jbiplfoot', 'jbiplltoe', 'jbipltoebase',
|
||||
# Finger patterns
|
||||
'jbipllittle1', 'jbiprlittle1',
|
||||
'jbiplthumb1', 'jbiplthumb2', 'jbiplthumb3',
|
||||
'jbiplindex1', 'jbiplindex2', 'jbiplindex3',
|
||||
'jbiplmiddle1', 'jbiplmiddle2', 'jbiplmiddle3',
|
||||
'jbiplring1', 'jbiplring2', 'jbiplring3',
|
||||
# Face eye patterns
|
||||
'jadjlfaceeye', 'jadjrfaceeye',
|
||||
# Breast patterns
|
||||
'jseclbust1', 'jseclbust2', 'jseclbust3',
|
||||
'jsecrbust1', 'jsecrbust2', 'jsecrbust3',
|
||||
'jbipc', 'jbipr', 'jbipl'
|
||||
]
|
||||
|
||||
found_vrm_bones = 0
|
||||
for bone_name in armature.data.bones.keys():
|
||||
simplified_name = simplify_bonename(bone_name)
|
||||
if simplified_name.startswith('jbip') or any(pattern in simplified_name for pattern in vrm_patterns):
|
||||
found_vrm_bones += 1
|
||||
|
||||
# Consider it VRM if we find at least 5 VRM bones
|
||||
logger.debug(f"Found {found_vrm_bones} VRM bones in armature {armature.name}")
|
||||
return found_vrm_bones >= 5
|
||||
|
||||
|
||||
|
||||
|
||||
def find_vrm_bones_in_armature(armature: Object) -> Dict[str, str]:
|
||||
"""
|
||||
Find VRM bones in armature and return mapping to their actual names using dictionary lookup
|
||||
"""
|
||||
found_bones = {}
|
||||
|
||||
for bone_name in armature.data.bones.keys():
|
||||
simplified_name = simplify_bonename(bone_name)
|
||||
|
||||
# Check if this bone exists in our reverse lookup dictionary
|
||||
if simplified_name in reverse_bone_lookup:
|
||||
standard_bone_key = reverse_bone_lookup[simplified_name]
|
||||
|
||||
# Get the Unity name from standard_bones
|
||||
if standard_bone_key in standard_bones:
|
||||
unity_name = standard_bones[standard_bone_key]
|
||||
found_bones[bone_name] = unity_name
|
||||
logger.debug(f"Found VRM bone via dictionary: {bone_name} -> {unity_name}")
|
||||
else:
|
||||
logger.debug(f"Standard bone key '{standard_bone_key}' not found in standard_bones for bone '{bone_name}'")
|
||||
|
||||
# Fallback for unrecognized VRM bones that start with 'jbip'
|
||||
elif simplified_name.startswith('jbip') and bone_name not in found_bones:
|
||||
unity_equivalent = guess_unity_name_from_vrm(simplified_name)
|
||||
if unity_equivalent:
|
||||
found_bones[bone_name] = unity_equivalent
|
||||
logger.debug(f"Guessed VRM bone mapping: {bone_name} -> {unity_equivalent}")
|
||||
|
||||
return found_bones
|
||||
|
||||
|
||||
def guess_unity_name_from_vrm(vrm_simplified: str) -> Optional[str]:
|
||||
"""
|
||||
Attempt to guess Unity bone name from VRM simplified name using dictionary lookup
|
||||
"""
|
||||
if vrm_simplified in reverse_bone_lookup:
|
||||
standard_bone_key = reverse_bone_lookup[vrm_simplified]
|
||||
|
||||
if standard_bone_key in standard_bones:
|
||||
return standard_bones[standard_bone_key]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_vrm_collider_object(obj_name: str) -> bool:
|
||||
"""
|
||||
Test if an object name represents a VRM collider
|
||||
"""
|
||||
obj_name_lower = obj_name.lower()
|
||||
collider_patterns = ['collider', 'collision', 'dynamic', 'spring', 'physics', 'secondary']
|
||||
|
||||
# Must contain a collider pattern
|
||||
contains_collider = any(pattern in obj_name_lower for pattern in collider_patterns)
|
||||
if not contains_collider:
|
||||
return False
|
||||
|
||||
# Must be VRM-related (multiple detection methods)
|
||||
is_vrm = (
|
||||
'j_bip' in obj_name_lower or
|
||||
'jbip' in simplify_bonename(obj_name) or
|
||||
any(vrm_part in obj_name_lower for vrm_part in ['j_bip_c_', 'j_bip_l_', 'j_bip_r_'])
|
||||
)
|
||||
|
||||
return is_vrm
|
||||
|
||||
|
||||
def remove_collection_from_hierarchy(collection_to_remove) -> bool:
|
||||
"""
|
||||
Recursively remove a collection from all parent collections in the hierarchy
|
||||
"""
|
||||
removed_from_any_parent = False
|
||||
|
||||
try:
|
||||
# Check scene collection
|
||||
scene_collection = bpy.context.scene.collection
|
||||
if collection_to_remove in scene_collection.children:
|
||||
scene_collection.children.unlink(collection_to_remove)
|
||||
logger.debug(f" Unlinked '{collection_to_remove.name}' from scene collection")
|
||||
removed_from_any_parent = True
|
||||
|
||||
# Check all other collections recursively
|
||||
for parent_collection in list(bpy.data.collections):
|
||||
if parent_collection != collection_to_remove and collection_to_remove in parent_collection.children:
|
||||
try:
|
||||
parent_collection.children.unlink(collection_to_remove)
|
||||
logger.debug(f" Unlinked '{collection_to_remove.name}' from parent '{parent_collection.name}'")
|
||||
removed_from_any_parent = True
|
||||
except Exception as unlink_error:
|
||||
logger.warning(f" Failed to unlink '{collection_to_remove.name}' from '{parent_collection.name}': {str(unlink_error)}")
|
||||
|
||||
return removed_from_any_parent
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing collection '{collection_to_remove.name}' from hierarchy: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def remove_vrm_colliders(armature: Object = None) -> Tuple[int, List[str], int]:
|
||||
"""
|
||||
Simple approach: Remove ALL objects with 'collider' in their name and clean up empty collections
|
||||
"""
|
||||
objects_to_remove = []
|
||||
removed_names = []
|
||||
collections_to_check = set()
|
||||
|
||||
# Store the current mode and active object
|
||||
current_mode = bpy.context.mode
|
||||
original_active = bpy.context.view_layer.objects.active
|
||||
|
||||
if current_mode != 'OBJECT':
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
try:
|
||||
logger.info("Starting simple collider removal - removing ALL objects with 'collider' in name")
|
||||
|
||||
collider_object_names = []
|
||||
for obj in bpy.data.objects:
|
||||
if 'collider' in obj.name.lower():
|
||||
collider_object_names.append(obj.name)
|
||||
# Track collections this object is in
|
||||
for collection in obj.users_collection:
|
||||
collections_to_check.add(collection)
|
||||
logger.info(f"Found collider object: {obj.name}")
|
||||
|
||||
logger.info(f"Found {len(collider_object_names)} collider objects to remove")
|
||||
|
||||
# Remove collider objects by name
|
||||
removed_count = 0
|
||||
for obj_name in collider_object_names:
|
||||
try:
|
||||
# Check if object still exists
|
||||
if obj_name in bpy.data.objects:
|
||||
obj = bpy.data.objects[obj_name]
|
||||
logger.info(f"Removing collider object: {obj_name}")
|
||||
|
||||
# Remove from all collections first
|
||||
for collection in list(obj.users_collection):
|
||||
collection.objects.unlink(obj)
|
||||
logger.debug(f" Unlinked from collection: {collection.name}")
|
||||
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
removed_count += 1
|
||||
removed_names.append(obj_name)
|
||||
logger.info(f" Successfully removed: {obj_name}")
|
||||
else:
|
||||
logger.debug(f"Object {obj_name} already removed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove collider object {obj_name}: {str(e)}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
|
||||
logger.info(f"Successfully removed {removed_count} collider objects")
|
||||
|
||||
# Clean up empty collections (prioritize collider-related collections)
|
||||
empty_collections_removed = 0
|
||||
|
||||
# Also check all collections in the scene for collider-related names
|
||||
all_collections_to_check = set(collections_to_check)
|
||||
for collection in bpy.data.collections:
|
||||
collection_name_lower = collection.name.lower()
|
||||
if any(pattern in collection_name_lower for pattern in ['collider', 'collision', 'physics', 'dynamic']):
|
||||
all_collections_to_check.add(collection)
|
||||
logger.debug(f"Found collider-related collection to check: {collection.name}")
|
||||
|
||||
for collection in list(all_collections_to_check):
|
||||
try:
|
||||
# Check if collection exists and is empty
|
||||
if collection.name not in bpy.data.collections:
|
||||
logger.debug(f"Collection {collection.name} already removed")
|
||||
continue
|
||||
|
||||
collection_name_lower = collection.name.lower()
|
||||
is_collider_collection = any(pattern in collection_name_lower for pattern in ['collider', 'collision', 'physics', 'dynamic'])
|
||||
is_empty = len(collection.objects) == 0 and len(collection.children) == 0
|
||||
is_protected = collection.name in ["Collection", "Master Collection"]
|
||||
|
||||
# Remove if empty and (was used by colliders OR has collider-related name)
|
||||
if is_empty and not is_protected and (collection in collections_to_check or is_collider_collection):
|
||||
logger.info(f"Removing empty {'collider-related ' if is_collider_collection else ''}collection: {collection.name}")
|
||||
|
||||
# Use helper function to remove from all parent collections
|
||||
removed_from_parents = remove_collection_from_hierarchy(collection)
|
||||
|
||||
if not removed_from_parents:
|
||||
logger.debug(f" Collection {collection.name} was not found in any parent collections")
|
||||
|
||||
# Remove the collection data
|
||||
try:
|
||||
bpy.data.collections.remove(collection)
|
||||
empty_collections_removed += 1
|
||||
logger.info(f" Successfully removed collection: {collection.name}")
|
||||
except Exception as remove_error:
|
||||
logger.warning(f" Failed to remove collection {collection.name}: {str(remove_error)}")
|
||||
# Continue with other collections even if this one fails
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to remove empty collection {collection.name}: {str(e)}")
|
||||
import traceback
|
||||
logger.debug(f"Collection removal traceback: {traceback.format_exc()}")
|
||||
|
||||
if empty_collections_removed > 0:
|
||||
logger.info(f"Cleaned up {empty_collections_removed} empty collections")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during collider removal: {str(e)}")
|
||||
return 0, [], 0
|
||||
|
||||
finally:
|
||||
if original_active and original_active.name in bpy.data.objects:
|
||||
bpy.context.view_layer.objects.active = original_active
|
||||
|
||||
if current_mode != 'OBJECT':
|
||||
try:
|
||||
bpy.ops.object.mode_set(mode=current_mode)
|
||||
except:
|
||||
pass
|
||||
|
||||
logger.info(f"Collider removal complete. Removed {len(removed_names)} objects and {empty_collections_removed} collections")
|
||||
return len(removed_names), removed_names, empty_collections_removed
|
||||
|
||||
|
||||
def remove_vrm_root_bone(armature: Object) -> Tuple[bool, str]:
|
||||
"""
|
||||
Remove unnecessary VRM root bone and make Hips the root bone
|
||||
|
||||
"""
|
||||
if not armature or armature.type != 'ARMATURE':
|
||||
return False, "No valid armature provided"
|
||||
|
||||
# Look for potential root bones and Hips bone
|
||||
potential_roots = []
|
||||
hips_bone = None
|
||||
|
||||
for bone in armature.data.edit_bones:
|
||||
bone_name_lower = bone.name.lower()
|
||||
|
||||
# Check if this could be Hips (various naming conventions)
|
||||
if any(hips_name in bone_name_lower for hips_name in ['hips', 'hip', 'pelvis', 'jbipchips']):
|
||||
hips_bone = bone
|
||||
logger.debug(f"Found Hips bone: {bone.name}")
|
||||
|
||||
# Check if this could be a root bone
|
||||
if bone.parent is None and len(bone.children) > 0:
|
||||
# Common VRM root bone names
|
||||
if any(root_name in bone_name_lower for root_name in ['root', 'vrm', 'armature', 'rig']):
|
||||
potential_roots.append(bone)
|
||||
logger.debug(f"Found potential root bone: {bone.name}")
|
||||
|
||||
if not hips_bone:
|
||||
return False, "Could not find Hips bone to promote as root"
|
||||
|
||||
if not potential_roots:
|
||||
logger.info("No unnecessary root bone found - Hips may already be root")
|
||||
return True, "No root bone removal needed"
|
||||
|
||||
# Find the root bone that is the parent of Hips
|
||||
root_to_remove = None
|
||||
for root_bone in potential_roots:
|
||||
if hips_bone.parent == root_bone:
|
||||
root_to_remove = root_bone
|
||||
break
|
||||
|
||||
if not root_to_remove:
|
||||
# Check if Hips is already parentless (already root)
|
||||
if hips_bone.parent is None:
|
||||
logger.info("Hips bone is already the root bone")
|
||||
return True, "Hips is already root - no changes needed"
|
||||
else:
|
||||
logger.warning(f"Hips bone has parent '{hips_bone.parent.name}' but no matching root found")
|
||||
return False, "Could not identify safe root bone to remove"
|
||||
|
||||
root_name = root_to_remove.name
|
||||
logger.info(f"Removing root bone '{root_name}' and promoting Hips to root")
|
||||
|
||||
# Reparent all children of the root bone (except Hips) to Hips
|
||||
children_to_reparent = []
|
||||
for child in root_to_remove.children:
|
||||
if child != hips_bone:
|
||||
children_to_reparent.append(child)
|
||||
|
||||
hips_bone.parent = None
|
||||
|
||||
for child in children_to_reparent:
|
||||
child.parent = hips_bone
|
||||
logger.debug(f"Reparented {child.name} from {root_name} to {hips_bone.name}")
|
||||
|
||||
armature.data.edit_bones.remove(root_to_remove)
|
||||
|
||||
message = f"Removed root bone '{root_name}' - Hips is now the root bone"
|
||||
logger.info(message)
|
||||
return True, message
|
||||
|
||||
|
||||
def convert_vrm_to_unity(armature: Object, remove_colliders: bool = True, remove_root: bool = True) -> Tuple[bool, List[str], int]:
|
||||
"""
|
||||
Convert VRM armature bone names to Unity humanoid format
|
||||
"""
|
||||
if not armature or armature.type != 'ARMATURE':
|
||||
return False, ["No valid armature selected"], 0
|
||||
|
||||
logger.info(f"Starting VRM to Unity conversion for armature: {armature.name}")
|
||||
|
||||
# Check if this is a VRM armature
|
||||
if not detect_vrm_armature(armature):
|
||||
return False, ["Selected armature does not appear to be a VRM armature"], 0
|
||||
|
||||
messages = []
|
||||
converted_count = 0
|
||||
failed_conversions = []
|
||||
collider_count = 0
|
||||
|
||||
current_mode = bpy.context.mode
|
||||
if current_mode != 'EDIT':
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
try:
|
||||
# First, remove collider objects and bones if requested
|
||||
if remove_colliders:
|
||||
collider_count, removed_colliders, collections_removed = remove_vrm_colliders(armature)
|
||||
if collider_count > 0 or collections_removed > 0:
|
||||
if collections_removed > 0:
|
||||
messages.append(f"Removed {collider_count} VRM collider objects and {collections_removed} empty collections")
|
||||
else:
|
||||
messages.append(f"Removed {collider_count} VRM collider objects")
|
||||
logger.info(f"Removed {collider_count} VRM colliders: {removed_colliders}")
|
||||
|
||||
vrm_bones = find_vrm_bones_in_armature(armature)
|
||||
|
||||
if not vrm_bones:
|
||||
if remove_colliders and (collider_count > 0 or collections_removed > 0):
|
||||
messages.append("No VRM bones found to convert (colliders were removed)")
|
||||
return True, messages, 0
|
||||
else:
|
||||
return False, ["No VRM bones found in armature"], 0
|
||||
|
||||
if bpy.context.mode != 'EDIT':
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
# Remove unnecessary root bone if requested
|
||||
if remove_root:
|
||||
root_success, root_message = remove_vrm_root_bone(armature)
|
||||
messages.append(root_message)
|
||||
if not root_success:
|
||||
logger.warning(f"Root bone removal failed: {root_message}")
|
||||
|
||||
# Rename bones
|
||||
for vrm_bone_name, unity_name in vrm_bones.items():
|
||||
if vrm_bone_name in armature.data.edit_bones:
|
||||
bone = armature.data.edit_bones[vrm_bone_name]
|
||||
|
||||
# Check if target name already exists
|
||||
if unity_name in armature.data.edit_bones and unity_name != vrm_bone_name:
|
||||
failed_conversions.append(f"{vrm_bone_name} -> {unity_name} (name conflict)")
|
||||
continue
|
||||
|
||||
# Rename the bone
|
||||
bone.name = unity_name
|
||||
converted_count += 1
|
||||
logger.debug(f"Renamed bone: {vrm_bone_name} -> {unity_name}")
|
||||
|
||||
messages.append(f"Successfully converted {converted_count} VRM bones to Unity format")
|
||||
|
||||
if failed_conversions:
|
||||
messages.append("Failed conversions due to name conflicts:")
|
||||
messages.extend(failed_conversions)
|
||||
|
||||
logger.info(f"VRM to Unity conversion completed. Converted {converted_count} bones")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during VRM conversion: {str(e)}")
|
||||
messages.append(f"Error during conversion: {str(e)}")
|
||||
return False, messages, converted_count
|
||||
|
||||
finally:
|
||||
# Restore original mode
|
||||
if current_mode != 'EDIT':
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
return converted_count > 0 or (remove_colliders and collider_count > 0), messages, converted_count
|
||||
|
||||
|
||||
def validate_unity_hierarchy(armature: Object) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate that the converted armature has proper Unity humanoid hierarchy
|
||||
"""
|
||||
if not armature or armature.type != 'ARMATURE':
|
||||
return False, ["No valid armature to validate"]
|
||||
|
||||
messages = []
|
||||
is_valid = True
|
||||
|
||||
# Check for essential Unity bones
|
||||
essential_unity_bones = [
|
||||
standard_bones['hips'],
|
||||
standard_bones['spine'],
|
||||
standard_bones['chest'],
|
||||
standard_bones['neck'],
|
||||
standard_bones['head']
|
||||
]
|
||||
|
||||
missing_bones = []
|
||||
for bone_name in essential_unity_bones:
|
||||
if bone_name not in armature.data.bones:
|
||||
missing_bones.append(bone_name)
|
||||
|
||||
if missing_bones:
|
||||
is_valid = False
|
||||
messages.append("Missing essential Unity bones:")
|
||||
messages.extend([f"- {bone}" for bone in missing_bones])
|
||||
|
||||
# Validate basic hierarchy
|
||||
hierarchy_issues = []
|
||||
for parent_name, child_name in bone_hierarchy:
|
||||
if parent_name in armature.data.bones and child_name in armature.data.bones:
|
||||
parent_bone = armature.data.bones[parent_name]
|
||||
child_bone = armature.data.bones[child_name]
|
||||
|
||||
if child_bone.parent != parent_bone:
|
||||
hierarchy_issues.append(f"{parent_name} -> {child_name}")
|
||||
|
||||
if hierarchy_issues:
|
||||
is_valid = False
|
||||
messages.append("Hierarchy issues found:")
|
||||
messages.extend([f"- {issue}" for issue in hierarchy_issues])
|
||||
|
||||
if is_valid:
|
||||
messages.append("Unity hierarchy validation passed")
|
||||
|
||||
return is_valid, messages
|
||||
@@ -0,0 +1,326 @@
|
||||
from pathlib import Path
|
||||
import numpy
|
||||
import bpy
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from bpy.types import Material, Operator, Context, Object, Image, Mesh, MeshUVLoopLayer, Float2AttributeValue, ShaderNodeTexImage, ShaderNodeBsdfPrincipled, ShaderNodeNormalMap
|
||||
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):
|
||||
self.albedo: Image = None
|
||||
self.normal: Image = None
|
||||
self.emission: Image = None
|
||||
self.ambient_occlusion: Image = None
|
||||
self.height: Image = None
|
||||
self.roughness: Image = None
|
||||
self.material: Material = None
|
||||
self.parent_mesh: Object = None
|
||||
self.w: int = 0
|
||||
self.h: int = 0
|
||||
self.fit = None
|
||||
|
||||
def scale_images_to_largest(images: List[Image]) -> tuple[int, int]:
|
||||
x: int = 0
|
||||
y: int = 0
|
||||
|
||||
valid_images = []
|
||||
for img in images:
|
||||
if img:
|
||||
try:
|
||||
if img.has_data:
|
||||
valid_images.append(img)
|
||||
except ReferenceError:
|
||||
# Image has been removed from Blender's memory
|
||||
pass
|
||||
|
||||
if not valid_images:
|
||||
return 0, 0
|
||||
|
||||
for image in valid_images:
|
||||
x = max(x, image.size[0])
|
||||
y = max(y, image.size[1])
|
||||
|
||||
for image in valid_images:
|
||||
image.scale(width=int(x), height=int(y))
|
||||
|
||||
return x, y
|
||||
|
||||
def MaterialImageList_to_Image_list(classitem: MaterialImageList) -> List[Image]:
|
||||
return [
|
||||
classitem.albedo,
|
||||
classitem.normal,
|
||||
classitem.emission,
|
||||
classitem.ambient_occlusion,
|
||||
classitem.height,
|
||||
classitem.roughness
|
||||
]
|
||||
|
||||
def get_material_images_from_scene(context: Context) -> list[MaterialImageList]:
|
||||
material_image_list: list[MaterialImageList] = []
|
||||
|
||||
with ProgressTracker(context, len(context.scene.objects), "Processing Materials") as progress:
|
||||
for obj in context.scene.objects:
|
||||
if obj.type == 'MESH':
|
||||
for mat_slot in obj.material_slots:
|
||||
# Only process materials that are selected for atlas
|
||||
if mat_slot.material and mat_slot.material.include_in_atlas is True:
|
||||
new_mat_image_item = MaterialImageList()
|
||||
try:
|
||||
new_mat_image_item.albedo = bpy.data.images[mat_slot.material.texture_atlas_albedo]
|
||||
except Exception:
|
||||
name = mat_slot.material.name + "_albedo_replacement"
|
||||
if name not in bpy.data.images:
|
||||
new_mat_image_item.albedo = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
|
||||
new_mat_image_item.albedo.pixels[:] = numpy.tile(numpy.array([0.0,0.0,0.0,1.0]), 32*32)
|
||||
else:
|
||||
new_mat_image_item.albedo = bpy.data.images[name]
|
||||
try:
|
||||
new_mat_image_item.normal = bpy.data.images[mat_slot.material.texture_atlas_normal]
|
||||
except Exception:
|
||||
name = mat_slot.material.name + "_normal_replacement"
|
||||
if name not in bpy.data.images:
|
||||
new_mat_image_item.normal = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
|
||||
new_mat_image_item.normal.pixels[:] = numpy.tile(numpy.array([0.5,0.5,1.0,1.0]), 32*32)
|
||||
else:
|
||||
new_mat_image_item.normal = bpy.data.images[name]
|
||||
try:
|
||||
new_mat_image_item.emission = bpy.data.images[mat_slot.material.texture_atlas_emission]
|
||||
except Exception:
|
||||
name = mat_slot.material.name + "_emission_replacement"
|
||||
if name not in bpy.data.images:
|
||||
new_mat_image_item.emission = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
|
||||
new_mat_image_item.emission.pixels[:] = numpy.tile(numpy.array([0.0,0.0,0.0,1.0]), 32*32)
|
||||
else:
|
||||
new_mat_image_item.emission = bpy.data.images[name]
|
||||
try:
|
||||
new_mat_image_item.ambient_occlusion = bpy.data.images[mat_slot.material.texture_atlas_ambient_occlusion]
|
||||
except Exception:
|
||||
name = mat_slot.material.name + "_ambient_occlusion_replacement"
|
||||
if name not in bpy.data.images:
|
||||
new_mat_image_item.ambient_occlusion = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
|
||||
new_mat_image_item.ambient_occlusion.pixels[:] = numpy.tile(numpy.array([1.0,1.0,1.0,1.0]), 32*32)
|
||||
else:
|
||||
new_mat_image_item.ambient_occlusion = bpy.data.images[name]
|
||||
try:
|
||||
new_mat_image_item.height = bpy.data.images[mat_slot.material.texture_atlas_height]
|
||||
except Exception:
|
||||
name = mat_slot.material.name + "_height_replacement"
|
||||
if name not in bpy.data.images:
|
||||
new_mat_image_item.height = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
|
||||
new_mat_image_item.height.pixels[:] = numpy.tile(numpy.array([0.5,0.5,0.5,1.0]), 32*32)
|
||||
else:
|
||||
new_mat_image_item.height = bpy.data.images[name]
|
||||
try:
|
||||
new_mat_image_item.roughness = bpy.data.images[mat_slot.material.texture_atlas_roughness]
|
||||
except Exception:
|
||||
name = mat_slot.material.name + "_roughness_replacement"
|
||||
if name not in bpy.data.images:
|
||||
new_mat_image_item.roughness = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
|
||||
new_mat_image_item.roughness.pixels[:] = numpy.tile(numpy.array([1.0,1.0,1.0,0.0]), 32*32)
|
||||
else:
|
||||
new_mat_image_item.roughness = bpy.data.images[name]
|
||||
|
||||
new_mat_image_item.material = mat_slot.material
|
||||
new_mat_image_item.parent_mesh = obj
|
||||
material_image_list.append(new_mat_image_item)
|
||||
|
||||
progress.step(f"Processed {obj.name}")
|
||||
|
||||
return material_image_list
|
||||
|
||||
def prep_images_in_scene(context: Context) -> List[MaterialImageList]:
|
||||
preped_images = get_material_images_from_scene(context)
|
||||
|
||||
with ProgressTracker(context, len(preped_images), "Preparing Images") as progress:
|
||||
for MaterialImageClass in preped_images:
|
||||
ImageList = MaterialImageList_to_Image_list(MaterialImageClass)
|
||||
MaterialImageClass.w, MaterialImageClass.h = scale_images_to_largest(ImageList)
|
||||
progress.step(f"Scaled images for {MaterialImageClass.material.name}")
|
||||
|
||||
return preped_images
|
||||
|
||||
class AvatarToolKit_OT_AtlasMaterials(Operator):
|
||||
bl_idname = "avatar_toolkit.atlas_materials"
|
||||
bl_label = t("TextureAtlas.atlas_materials")
|
||||
bl_description = t("TextureAtlas.atlas_materials_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
# Only allow operation if the file is saved and materials are selected.
|
||||
if not bpy.data.filepath:
|
||||
cls.poll_message_set(t("TextureAtlas.save_file_first"))
|
||||
return False
|
||||
return context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown
|
||||
|
||||
def execute(self, context: Context) -> set:
|
||||
try:
|
||||
selected_materials = [m for m in prep_images_in_scene(context)
|
||||
if m.material and m.material.include_in_atlas]
|
||||
|
||||
if not selected_materials:
|
||||
self.report({'WARNING'}, t("TextureAtlas.no_materials_selected"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
logger.info("Starting material atlas creation")
|
||||
|
||||
packer = BinPacker(selected_materials)
|
||||
mat_images = packer.fit()
|
||||
|
||||
size = [
|
||||
max([matimg.fit.w + matimg.albedo.size[0] for matimg in mat_images]),
|
||||
max([matimg.fit.h + matimg.albedo.size[1] for matimg in mat_images])
|
||||
]
|
||||
|
||||
atlased_mat = MaterialImageList()
|
||||
|
||||
# UV Remapping
|
||||
with ProgressTracker(context, len(bpy.data.objects), "Remapping UVs") as progress:
|
||||
for mat in mat_images:
|
||||
x, y = int(mat.fit.x), int(mat.fit.y)
|
||||
w, h = int(mat.albedo.size[0]), int(mat.albedo.size[1])
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type == 'MESH':
|
||||
mesh = obj.data
|
||||
for layer in mesh.polygons:
|
||||
if (obj.material_slots[layer.material_index].material and
|
||||
obj.material_slots[layer.material_index].material == mat.material):
|
||||
for loop_idx in layer.loop_indices:
|
||||
for layer_loops in mesh.uv_layers:
|
||||
uv_item = layer_loops.uv[loop_idx]
|
||||
uv_item.vector.x = (uv_item.vector.x*(w/size[0]))+(x/size[0])
|
||||
uv_item.vector.y = (uv_item.vector.y*(h/size[1]))+(y/size[1])
|
||||
progress.step(f"Processed UVs for {obj.name}")
|
||||
|
||||
# Create atlas textures
|
||||
texture_types = ["albedo", "normal", "emission", "ambient_occlusion", "height", "roughness"]
|
||||
|
||||
with ProgressTracker(context, len(texture_types), "Creating Atlas Textures") as progress:
|
||||
for type_name in texture_types:
|
||||
new_image_name = f"Atlas_{type_name}_{context.scene.name}_{Path(bpy.data.filepath).stem}"
|
||||
logger.debug(f"Processing {type_name} atlas image")
|
||||
|
||||
if new_image_name in bpy.data.images:
|
||||
bpy.data.images.remove(bpy.data.images[new_image_name])
|
||||
|
||||
canvas = bpy.data.images.new(name=new_image_name, width=int(size[0]),
|
||||
height=int(size[1]), alpha=True)
|
||||
c_w = canvas.size[0]
|
||||
canvas_pixels = list(canvas.pixels[:])
|
||||
|
||||
for mat in mat_images:
|
||||
x, y = int(mat.fit.x), int(mat.fit.y)
|
||||
w, h = int(mat.albedo.size[0]), int(mat.albedo.size[1])
|
||||
image_var = getattr(mat, type_name)
|
||||
image_pixels = list(image_var.pixels[:])
|
||||
|
||||
for k in range(h):
|
||||
for i in range(w):
|
||||
for channel in range(4):
|
||||
canvas_pixels[int((((k+y)*c_w)+(i+x))*4)+channel] = \
|
||||
image_pixels[int(((k*w)+i)*4)+channel]
|
||||
|
||||
canvas.pixels[:] = canvas_pixels[:]
|
||||
|
||||
try:
|
||||
save_dir = os.path.dirname(bpy.data.filepath)
|
||||
canvas.save(filepath=os.path.join(save_dir, new_image_name+".png"))
|
||||
except Exception as save_error:
|
||||
logger.error(f"Failed to save atlas texture: {str(save_error)}")
|
||||
self.report({'WARNING'}, f"Could not save texture to disk, This may be due to a lack of permissions.")
|
||||
|
||||
setattr(atlased_mat, type_name, canvas)
|
||||
progress.step(f"Created {type_name} atlas")
|
||||
|
||||
# Create material nodes
|
||||
atlased_mat.material = bpy.data.materials.new(
|
||||
name=f"Atlas_Final_{context.scene.name}_{Path(bpy.data.filepath).stem}")
|
||||
# Note: material.use_nodes is deprecated in Blender 5.0 - materials always use nodes
|
||||
atlased_mat.material.node_tree.nodes.clear()
|
||||
|
||||
principled_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeBsdfPrincipled")
|
||||
principled_node.location.x = 7.29706335067749
|
||||
principled_node.location.y = 298.918212890625
|
||||
|
||||
output_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeOutputMaterial")
|
||||
output_node.location.x = 297.29705810546875
|
||||
output_node.location.y = 298.918212890625
|
||||
|
||||
albedo_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
||||
albedo_node.location.x = -588.6177978515625
|
||||
albedo_node.location.y = 414.1948547363281
|
||||
albedo_node.image = atlased_mat.albedo
|
||||
|
||||
emission_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
||||
emission_node.location.x = -588.6177978515625
|
||||
emission_node.location.y = -173.9259033203125
|
||||
emission_node.image = atlased_mat.emission
|
||||
|
||||
normal_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
||||
normal_node.location.x = -941.4189453125
|
||||
normal_node.location.y = -20.8391780853271
|
||||
normal_node.image = atlased_mat.normal
|
||||
|
||||
normal_map_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeNormalMap")
|
||||
normal_map_node.location.x = -545.550537109375
|
||||
normal_map_node.location.y = -0.7543716430664062
|
||||
|
||||
roughness_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
||||
roughness_node.location.x = -592.1703491210938
|
||||
roughness_node.location.y = 206.74075317382812
|
||||
roughness_node.image = atlased_mat.roughness
|
||||
|
||||
ambient_occlusion_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
||||
ambient_occlusion_node.location.x = -906.4371337890625
|
||||
ambient_occlusion_node.location.y = -389.9602355957031
|
||||
ambient_occlusion_node.image = atlased_mat.ambient_occlusion
|
||||
|
||||
height_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
||||
height_node.location.x = -1222.383056640625
|
||||
height_node.location.y = -375.48406982421875
|
||||
height_node.image = atlased_mat.height
|
||||
|
||||
atlased_mat.material.node_tree.links.new(principled_node.inputs["Base Color"], albedo_node.outputs["Color"])
|
||||
atlased_mat.material.node_tree.links.new(principled_node.inputs["Metallic"], roughness_node.outputs["Alpha"])
|
||||
atlased_mat.material.node_tree.links.new(principled_node.inputs["Roughness"], roughness_node.outputs["Color"])
|
||||
atlased_mat.material.node_tree.links.new(principled_node.inputs["Alpha"], albedo_node.outputs["Alpha"])
|
||||
atlased_mat.material.node_tree.links.new(principled_node.inputs["Normal"], normal_map_node.outputs["Normal"])
|
||||
atlased_mat.material.node_tree.links.new(principled_node.inputs["Emission Color"], emission_node.outputs["Color"])
|
||||
atlased_mat.material.node_tree.links.new(output_node.inputs["Surface"], principled_node.outputs["BSDF"])
|
||||
atlased_mat.material.node_tree.links.new(normal_map_node.inputs["Color"], normal_node.outputs["Color"])
|
||||
|
||||
# Update materials
|
||||
with ProgressTracker(context, len(context.scene.objects), "Updating Materials") as progress:
|
||||
for obj in context.scene.objects:
|
||||
if obj.type == 'MESH':
|
||||
mesh = obj.data
|
||||
for i, mat_slot in enumerate(obj.material_slots):
|
||||
if mat_slot.material and mat_slot.material.include_in_atlas:
|
||||
mesh.materials[i] = atlased_mat.material
|
||||
progress.step(f"Updated materials for {obj.name}")
|
||||
|
||||
MaterialListBool.old_list.pop(context.scene.name, None)
|
||||
was_open = context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown
|
||||
context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown = False
|
||||
|
||||
if was_open:
|
||||
bpy.ops.avatar_toolkit.expand_section_materials()
|
||||
|
||||
for area in context.screen.areas:
|
||||
if area.type == 'VIEW_3D':
|
||||
area.tag_redraw()
|
||||
|
||||
logger.info("Material atlas creation completed successfully")
|
||||
self.report({'INFO'}, t("TextureAtlas.atlas_completed"))
|
||||
return {"FINISHED"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating material atlas: {traceback.format_exc()}", exc_info=True)
|
||||
self.report({'ERROR'}, t("TextureAtlas.atlas_error"))
|
||||
raise e
|
||||
@@ -1,17 +1,24 @@
|
||||
import traceback
|
||||
import bpy
|
||||
import numpy as np
|
||||
from typing import List, Optional, Dict, Set, Tuple, Any
|
||||
from bpy.types import Context, Object, Operator, ArmatureModifier, EditBone, VertexGroup, Mesh, ShapeKey
|
||||
|
||||
from ...core.logging_setup import logger
|
||||
from ...core.translations import t
|
||||
import traceback
|
||||
from ...core.common import (
|
||||
get_all_meshes,
|
||||
get_meshes_for_armature,
|
||||
fix_zero_length_bones,
|
||||
remove_unused_vertex_groups,
|
||||
clear_unused_data_blocks,
|
||||
join_mesh_objects,
|
||||
remove_unused_shapekeys
|
||||
remove_unused_shapekeys,
|
||||
identify_bones,
|
||||
store_breaking_settings_armature,
|
||||
restore_breaking_settings_armature,
|
||||
)
|
||||
from ...core.dictionaries import simplify_bonename
|
||||
|
||||
class AvatarToolkit_OT_MergeArmature(bpy.types.Operator):
|
||||
"""Operator for merging two armatures together with their associated meshes"""
|
||||
@@ -22,10 +29,32 @@ class AvatarToolkit_OT_MergeArmature(bpy.types.Operator):
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
return len(get_all_meshes(context)) > 1
|
||||
# Check if we have valid armature selections for merging
|
||||
base_armature_name: str = context.scene.avatar_toolkit.merge_armature_into
|
||||
merge_armature_name: str = context.scene.avatar_toolkit.merge_armature
|
||||
|
||||
if not base_armature_name or not merge_armature_name:
|
||||
return False
|
||||
|
||||
base_armature: Optional[Object] = bpy.data.objects.get(base_armature_name)
|
||||
merge_armature: Optional[Object] = bpy.data.objects.get(merge_armature_name)
|
||||
|
||||
return (base_armature is not None and
|
||||
merge_armature is not None and
|
||||
base_armature.type == 'ARMATURE' and
|
||||
merge_armature.type == 'ARMATURE' and
|
||||
base_armature != merge_armature)
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
# Store original mode to restore later
|
||||
original_mode: str = context.mode
|
||||
logger.debug(f"Original mode: {original_mode}")
|
||||
|
||||
# Switch to object mode if not already
|
||||
if context.mode != 'OBJECT':
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
wm = context.window_manager
|
||||
wm.progress_begin(0, 100)
|
||||
|
||||
@@ -39,6 +68,12 @@ 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)
|
||||
|
||||
# Store the merge armature name before it gets removed during join
|
||||
merge_armature_name_stored = merge_armature.name
|
||||
|
||||
# Remove Rigid Bodies and Joints
|
||||
delete_rigidbodies_and_joints(base_armature)
|
||||
@@ -52,7 +87,6 @@ class AvatarToolkit_OT_MergeArmature(bpy.types.Operator):
|
||||
wm.progress_update(80)
|
||||
|
||||
# Get settings from scene properties
|
||||
merge_all_bones: bool = context.scene.avatar_toolkit.merge_all_bones
|
||||
join_meshes: bool = context.scene.avatar_toolkit.join_meshes
|
||||
|
||||
# Merge armatures
|
||||
@@ -60,7 +94,6 @@ class AvatarToolkit_OT_MergeArmature(bpy.types.Operator):
|
||||
base_armature_name,
|
||||
merge_armature_name,
|
||||
mesh_only=False,
|
||||
merge_all_bones=merge_all_bones,
|
||||
join_meshes=join_meshes,
|
||||
operator=self
|
||||
)
|
||||
@@ -69,12 +102,43 @@ class AvatarToolkit_OT_MergeArmature(bpy.types.Operator):
|
||||
wm.progress_update(100)
|
||||
wm.progress_end()
|
||||
|
||||
# Restore settings only for the base armature since merge_armature is removed during join
|
||||
restore_breaking_settings_armature(base_armature, data_breaking_base)
|
||||
if merge_armature_name_stored in bpy.data.objects:
|
||||
merge_armature_obj = bpy.data.objects[merge_armature_name_stored]
|
||||
restore_breaking_settings_armature(merge_armature_obj, data_breaking_merge)
|
||||
|
||||
# Restore original mode if it wasn't OBJECT
|
||||
try:
|
||||
if original_mode == 'EDIT_ARMATURE':
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
elif original_mode == 'POSE':
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
elif original_mode != 'OBJECT':
|
||||
logger.debug(f"Restoring to original mode: {original_mode}")
|
||||
# For other modes, stay in object mode as it's safest
|
||||
except Exception:
|
||||
logger.warning(f"Could not restore original mode: {original_mode}")
|
||||
|
||||
self.report({'INFO'}, t('MergeArmature.success'))
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error merging armatures: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
errormessage: str = traceback.format_exc()
|
||||
logger.error(f"Error merging armatures: {str(e)}\n{errormessage}")
|
||||
self.report({'ERROR'}, f"Error merging armatures: {errormessage}")
|
||||
|
||||
# Try to restore original mode even on error
|
||||
try:
|
||||
if 'original_mode' in locals() and original_mode != 'OBJECT':
|
||||
if original_mode == 'EDIT_ARMATURE':
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
elif original_mode == 'POSE':
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
except Exception:
|
||||
logger.warning("Could not restore mode after error")
|
||||
|
||||
return {'CANCELLED'}
|
||||
|
||||
def delete_rigidbodies_and_joints(armature: Object) -> None:
|
||||
@@ -100,16 +164,12 @@ def validate_parents_and_transforms(merge_armature: Object, base_armature: Objec
|
||||
base_parent: Optional[Object] = base_armature.parent
|
||||
|
||||
if merge_parent or base_parent:
|
||||
if context.scene.merge_all_bones:
|
||||
for armature, parent in [(merge_armature, merge_parent), (base_armature, base_parent)]:
|
||||
if parent:
|
||||
if not is_transform_clean(parent):
|
||||
logger.error("Parent transforms are not clean")
|
||||
return False
|
||||
bpy.data.objects.remove(parent, do_unlink=True)
|
||||
else:
|
||||
logger.error("Parent relationships need fixing")
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_transform_clean(obj: Object) -> bool:
|
||||
@@ -135,7 +195,6 @@ def merge_armatures(
|
||||
base_armature_name: str,
|
||||
merge_armature_name: str,
|
||||
mesh_only: bool,
|
||||
merge_all_bones: bool = False,
|
||||
join_meshes: bool = False,
|
||||
operator: Optional[Operator] = None
|
||||
) -> None:
|
||||
@@ -152,6 +211,12 @@ def merge_armatures(
|
||||
operator.report({'ERROR'}, t('MergeArmature.error.notFound', name=merge_armature_name))
|
||||
return
|
||||
|
||||
# Store meshes that need to be reparented
|
||||
meshes_to_reparent = [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == merge_armature]
|
||||
|
||||
base_armature.hide_set(False)
|
||||
merge_armature.hide_set(False)
|
||||
|
||||
# Check transforms early
|
||||
if not validate_merge_armature_transforms(base_armature, merge_armature, None, tolerance):
|
||||
if not bpy.context.scene.avatar_toolkit.apply_transforms:
|
||||
@@ -172,27 +237,31 @@ def merge_armatures(
|
||||
fix_zero_length_bones(base_armature)
|
||||
fix_zero_length_bones(merge_armature)
|
||||
|
||||
|
||||
|
||||
# Store original parent relationships
|
||||
original_parents: Dict[str, Optional[str]] = {}
|
||||
for bone in merge_armature.data.bones:
|
||||
merge_armature_data: bpy.types.Armature = merge_armature.data
|
||||
for bone in merge_armature_data.bones:
|
||||
original_parents[bone.name] = bone.parent.name if bone.parent else None
|
||||
|
||||
# Get base bone names
|
||||
base_bone_names: Set[str] = {bone.name for bone in base_armature.data.bones}
|
||||
|
||||
# Switch to edit mode on merge armature and rename bones
|
||||
bpy.context.view_layer.objects.active = merge_armature
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
# Handle bone renaming based on merge_all_bones setting
|
||||
for bone in merge_armature.data.edit_bones:
|
||||
if not merge_all_bones:
|
||||
# Only rename bones that don't exist in base armature
|
||||
if bone.name not in base_bone_names:
|
||||
bone.name += '.merge'
|
||||
else:
|
||||
# Rename all bones from merge armature
|
||||
bone.name += '.merge'
|
||||
# Identify our bones to what their standard name is like "hips" for source and target armature bones.
|
||||
identifed_base_bone_names: Dict[str,str] = identify_bones(base_armature.data)
|
||||
identified_bone_names_source: Dict[str,str] = identify_bones(merge_armature_data)
|
||||
|
||||
for standard,bone_name in identified_bone_names_source.items():
|
||||
if standard in identifed_base_bone_names: #if the bone we are at on our merge armature has a standard name translation for the target armature
|
||||
merge_armature_data.edit_bones[bone_name].name = identifed_base_bone_names[standard] #change it's name to the one on the target merge to armature's coorisponding standard bone
|
||||
bone_name = identifed_base_bone_names[standard]
|
||||
#adjust original parents list to point to the new name.
|
||||
for child_bone in merge_armature_data.edit_bones[bone_name].children:
|
||||
original_parents[child_bone.name] = bone_name
|
||||
#then remove so it doesn't clash when merged.
|
||||
merge_armature_data.edit_bones.remove(merge_armature_data.edit_bones[bone_name])
|
||||
|
||||
# Return to object mode
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
@@ -201,26 +270,32 @@ def merge_armatures(
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
base_armature.select_set(True)
|
||||
merge_armature.select_set(True)
|
||||
|
||||
bpy.context.view_layer.objects.active = base_armature
|
||||
bpy.ops.object.join()
|
||||
|
||||
# Explicitly set active object after join
|
||||
bpy.context.view_layer.objects.active = base_armature
|
||||
base_armature_data: bpy.types.Armature = base_armature.data
|
||||
|
||||
# Restore parent relationships
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
for bone in base_armature.data.edit_bones:
|
||||
base_name: str = bone.name.replace('.merge', '')
|
||||
if base_name in original_parents:
|
||||
parent_name: Optional[str] = original_parents[base_name]
|
||||
for bone in base_armature_data.edit_bones:
|
||||
if bone.name in original_parents:
|
||||
parent_name: Optional[str] = original_parents[bone.name]
|
||||
if parent_name:
|
||||
parent_bone: Optional[EditBone] = base_armature.data.edit_bones.get(parent_name)
|
||||
parent_bone: Optional[EditBone] = base_armature_data.edit_bones.get(parent_name)
|
||||
if parent_bone:
|
||||
bone.parent = parent_bone
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Update mesh parenting
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type == 'MESH' and obj.parent == merge_armature:
|
||||
obj.parent = base_armature
|
||||
for mesh_obj in meshes_to_reparent:
|
||||
if mesh_obj and mesh_obj.name in bpy.data.objects:
|
||||
mesh_obj.parent = base_armature
|
||||
for mod in mesh_obj.modifiers:
|
||||
if mod.type == 'ARMATURE':
|
||||
mod.object = base_armature
|
||||
|
||||
# Process vertex groups if not mesh_only
|
||||
if not mesh_only:
|
||||
@@ -241,6 +316,8 @@ def merge_armatures(
|
||||
joined_mesh: Optional[Object] = join_mesh_objects(bpy.context, meshes_to_join)
|
||||
if joined_mesh:
|
||||
logger.info(f"Joined meshes into {joined_mesh.name}")
|
||||
# Ensure the joined mesh is properly parented
|
||||
joined_mesh.parent = base_armature
|
||||
|
||||
# Clean up shape keys if enabled
|
||||
if bpy.context.scene.avatar_toolkit.cleanup_shape_keys:
|
||||
@@ -250,11 +327,6 @@ def merge_armatures(
|
||||
|
||||
# Remove any remaining .merge bones
|
||||
bpy.context.view_layer.objects.active = base_armature
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
edit_bones: List[EditBone] = base_armature.data.edit_bones
|
||||
bones_to_remove: List[EditBone] = [bone for bone in edit_bones if bone.name.endswith('.merge')]
|
||||
for bone in bones_to_remove:
|
||||
edit_bones.remove(bone)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Final cleanup
|
||||
@@ -298,8 +370,7 @@ def adjust_merge_armature_transforms(
|
||||
def detect_bones_to_merge(
|
||||
base_edit_bones: bpy.types.ArmatureEditBones,
|
||||
merge_edit_bones: bpy.types.ArmatureEditBones,
|
||||
tolerance: float,
|
||||
merge_all_bones: bool
|
||||
tolerance: float
|
||||
) -> List[str]:
|
||||
"""Detect corresponding bones between base and merge armatures using smart detection and position tolerance"""
|
||||
bones_to_merge: List[str] = []
|
||||
@@ -314,7 +385,7 @@ def detect_bones_to_merge(
|
||||
merge_bone_position: np.ndarray = np.array(merge_bone.head)
|
||||
found_match: bool = False
|
||||
|
||||
if merge_all_bones and merge_bone.name in base_bones_positions:
|
||||
if merge_bone.name in base_bones_positions:
|
||||
# If merging same bones by name
|
||||
bones_to_merge.append(merge_bone.name)
|
||||
found_match = True
|
||||
@@ -377,20 +448,6 @@ def mix_vertex_groups(mesh: Object, vg_from_name: str, vg_to_name: str) -> None:
|
||||
vg_to.add(range(num_vertices), weights_combined.tolist(), 'REPLACE')
|
||||
mesh.vertex_groups.remove(vg_from)
|
||||
|
||||
def remove_unused_vertex_groups(mesh: Object) -> None:
|
||||
"""Remove vertex groups with no weights"""
|
||||
for vg in mesh.vertex_groups:
|
||||
has_weights: bool = False
|
||||
for vert in mesh.data.vertices:
|
||||
for group in vert.groups:
|
||||
if group.group == vg.index and group.weight > 0.001:
|
||||
has_weights = True
|
||||
break
|
||||
if has_weights:
|
||||
break
|
||||
if not has_weights:
|
||||
mesh.vertex_groups.remove(vg)
|
||||
|
||||
def apply_armature_to_mesh(armature: Object, mesh: Object) -> None:
|
||||
"""Apply armature deformation to mesh"""
|
||||
armature_mod: ArmatureModifier = mesh.modifiers.new('PoseToRest', 'ARMATURE')
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import traceback
|
||||
import bpy
|
||||
import re
|
||||
from typing import Any, Set, Dict, List, Optional, Tuple
|
||||
from bpy.types import (
|
||||
Operator,
|
||||
Context,
|
||||
Object,
|
||||
Material,
|
||||
NodeTree,
|
||||
ShaderNodeTexImage
|
||||
)
|
||||
import mathutils
|
||||
import bmesh
|
||||
from ...core.logging_setup import logger
|
||||
from ...core.translations import t
|
||||
from ...core.common import (
|
||||
get_active_armature,
|
||||
get_all_meshes,
|
||||
ProgressTracker,
|
||||
calculate_bone_orientation,
|
||||
add_armature_modifier,
|
||||
get_modifiers,
|
||||
has_shapekeys
|
||||
)
|
||||
from ...core.armature_validation import validate_armature
|
||||
|
||||
class AvatarToolkit_OT_ApplyModifierForShapkeyObj(bpy.types.Operator):
|
||||
"""Operator for forcing the application of a modifier. A shortened way of saying \"Apply modifier for object with shapekeys\""""
|
||||
bl_idname: str = 'avatar_toolkit.apply_shapekey_force'
|
||||
bl_label: str = t('Tools.apply_modifier_on_shapekey_obj')
|
||||
bl_description: str = t('Tools.apply_modifier_on_shapekey_obj_desc')
|
||||
bl_options: Set[str] = {'REGISTER', 'UNDO'}
|
||||
|
||||
modifier: bpy.props.EnumProperty(items=get_modifiers,name="Modifier To Apply")
|
||||
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
"""Draw the operator's UI"""
|
||||
layout = self.layout
|
||||
layout.prop(self, "modifier")
|
||||
|
||||
def invoke(self, context: Context, event: bpy.types.Event) -> set[str]:
|
||||
"""Initialize the operator"""
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
if context.active_object != None:
|
||||
return context.active_object.type == "MESH"
|
||||
return False
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
|
||||
obj: bpy.types.Object = context.active_object
|
||||
mesh: bpy.types.Mesh = obj.data
|
||||
|
||||
shapes: list[bpy.types.Object] = []
|
||||
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
|
||||
if has_shapekeys(obj):
|
||||
#reset shapekeys
|
||||
for idx,key in enumerate(mesh.shape_keys.key_blocks):
|
||||
obj.active_shape_key_index = idx
|
||||
obj.active_shape_key.value = 0
|
||||
|
||||
for idx,key in enumerate(mesh.shape_keys.key_blocks):
|
||||
# duplicate object for shapekey
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
context.view_layer.objects.active = obj
|
||||
obj.select_set(True)
|
||||
bpy.ops.object.duplicate()
|
||||
|
||||
# name new object after shapekey
|
||||
new_obj = context.view_layer.objects.active
|
||||
new_obj.select_set(True)
|
||||
new_obj.active_shape_key_index = idx
|
||||
new_obj.name = new_obj.active_shape_key.name
|
||||
|
||||
#add to cleanup list
|
||||
shapes.append(new_obj)
|
||||
|
||||
#make basis the same shape as shapekey
|
||||
for idx,point in enumerate(new_obj.active_shape_key.points):
|
||||
new_obj.data.vertices[idx].co.xyz = point.co.xyz
|
||||
|
||||
#remove all shaoekeys on new object and then apply modifier
|
||||
bpy.ops.object.shape_key_remove(all=True,apply_mix=False)
|
||||
try:
|
||||
bpy.ops.object.modifier_apply(modifier=self.modifier)
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, f"Shapekey modifier apply for shapekey \"{new_obj.name}\" failed!!")
|
||||
print(f"Shapekey modifier apply for shapekey \"{new_obj.name}\" failed!!")
|
||||
print(traceback.format_exc(e))
|
||||
#clean up after critical failure
|
||||
for shape in shapes:
|
||||
bpy.data.objects.remove(shape)#faster than ops delete
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
|
||||
|
||||
|
||||
try:
|
||||
#remove shapekeys on original object
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
obj.select_set(True)
|
||||
context.view_layer.objects.active = obj
|
||||
bpy.ops.object.shape_key_remove(all=True,apply_mix=False)
|
||||
bpy.ops.object.modifier_apply(modifier=self.modifier)
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
#delete first shapekey object aka basis
|
||||
bpy.data.objects.remove(shapes.pop(0))
|
||||
|
||||
#join all objects with applied modifiers back together as shapes
|
||||
for shape in shapes:
|
||||
shape.select_set(True)
|
||||
obj.select_set(True)
|
||||
context.view_layer.objects.active = obj
|
||||
bpy.ops.object.join_shapes()
|
||||
except Exception:
|
||||
|
||||
self.report({'ERROR'}, f"Shapekey joining failed!!")
|
||||
print(f"Shapekey joining failed!!")
|
||||
print(traceback.format_exc())
|
||||
|
||||
#final clean up
|
||||
for shape in shapes:
|
||||
bpy.data.objects.remove(shape)#faster than ops delete
|
||||
|
||||
else:
|
||||
#mesh has no shapekeys, just apply normally.
|
||||
bpy.ops.object.modifier_apply(modifier=self.modifier)
|
||||
|
||||
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import traceback
|
||||
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
|
||||
from ...core.common import (
|
||||
get_active_armature,
|
||||
validate_armature,
|
||||
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
|
||||
|
||||
class AvatarToolkit_OT_AttachMesh(Operator):
|
||||
"""Operator to attach a mesh to an armature bone with automatic weight setup"""
|
||||
@@ -27,8 +31,8 @@ class AvatarToolkit_OT_AttachMesh(Operator):
|
||||
armature: Optional[Object] = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
is_valid, _ = validate_armature(armature)
|
||||
return is_valid
|
||||
valid, _, _ = validate_armature(armature)
|
||||
return valid
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
@@ -83,7 +87,7 @@ 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"))
|
||||
@@ -104,6 +108,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 +119,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]:
|
||||
|
||||
+34
-10
@@ -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
|
||||
@@ -18,11 +19,11 @@ from ..core.common import (
|
||||
get_active_armature,
|
||||
get_all_meshes,
|
||||
get_armature_list,
|
||||
validate_armature,
|
||||
validate_mesh_for_pose,
|
||||
cache_vertex_positions,
|
||||
apply_vertex_positions
|
||||
)
|
||||
from ..core.armature_validation import validate_armature
|
||||
|
||||
VALID_EYE_NAMES: Dict[str, List[str]] = {
|
||||
'left': ['LeftEye', 'Eye_L', 'eye_L', 'eye.L', 'EyeLeft', 'left_eye', 'l_eye'],
|
||||
@@ -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:
|
||||
@@ -406,6 +407,14 @@ def set_rotation(self, context):
|
||||
StartTestingButton.execute(StartTestingButton, context)
|
||||
return None
|
||||
|
||||
# Check if rotation data is available
|
||||
if not eye_left_rot or len(eye_left_rot) < 3 or not eye_right_rot or len(eye_right_rot) < 3:
|
||||
# Initialize rotation data if missing
|
||||
eye_left.rotation_mode = 'XYZ'
|
||||
eye_left_rot = list(eye_left.rotation_euler)
|
||||
eye_right.rotation_mode = 'XYZ'
|
||||
eye_right_rot = list(eye_right.rotation_euler)
|
||||
|
||||
eye_left.rotation_mode = 'XYZ'
|
||||
eye_right.rotation_mode = 'XYZ'
|
||||
|
||||
@@ -898,9 +907,24 @@ class ResetEyeTrackingButton(Operator):
|
||||
|
||||
def execute(self, context):
|
||||
global eye_left, eye_right, eye_left_data, eye_right_data, eye_left_rot, eye_right_rot
|
||||
eye_left = eye_right = eye_left_data = eye_right_data = None
|
||||
eye_left_rot = eye_right_rot = []
|
||||
|
||||
context.scene.avatar_toolkit.eye_mode = 'CREATION'
|
||||
context.scene.avatar_toolkit.eye_rotation_x = 0
|
||||
context.scene.avatar_toolkit.eye_rotation_y = 0
|
||||
|
||||
eye_left = None
|
||||
eye_right = None
|
||||
eye_left_data = None
|
||||
eye_right_data = None
|
||||
eye_left_rot = []
|
||||
eye_right_rot = []
|
||||
|
||||
mesh_name = context.scene.avatar_toolkit.mesh_name_eye
|
||||
mesh = bpy.data.objects.get(mesh_name)
|
||||
if mesh and mesh.data.shape_keys:
|
||||
for shape_key in mesh.data.shape_keys.key_blocks:
|
||||
shape_key.value = 0
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
def validate_weights(mesh_obj: Object, vertex_group: str) -> bool:
|
||||
|
||||
@@ -1,792 +0,0 @@
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
from typing import Dict, List, Tuple, Set, Optional
|
||||
from bpy.types import Object, Armature, EditBone, Bone, Operator, Context
|
||||
from ..core.logging_setup import logger
|
||||
from ..core.common import (
|
||||
ProgressTracker,
|
||||
get_active_armature,
|
||||
validate_armature,
|
||||
get_vertex_weights,
|
||||
transfer_vertex_weights,
|
||||
get_all_meshes
|
||||
)
|
||||
from ..core.translations import t
|
||||
from ..core.dictionaries import bone_names, dont_delete_these_main_bones
|
||||
|
||||
class AVATAR_TOOLKIT_OT_StandardizeMmd(Operator):
|
||||
"""MMD Bone standardization system"""
|
||||
bl_idname = "avatar_toolkit.standardize_mmd"
|
||||
bl_label = t("MMD.standardize")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def __init__(self):
|
||||
self.bone_mapping: Dict[str, str] = {}
|
||||
self.processed_bones: Set[str] = set()
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
self.armature = get_active_armature(context)
|
||||
|
||||
if not self.armature:
|
||||
self.report({'ERROR'}, t("MMD.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
try:
|
||||
with ProgressTracker(context, 5, "MMD Standardization") as progress:
|
||||
# Step 1: Process bone names
|
||||
self.process_bone_names(context)
|
||||
progress.step("Processed bone names")
|
||||
|
||||
# Step 2: Fix bone structure
|
||||
self.fix_bone_structure(context)
|
||||
progress.step("Fixed bone structure")
|
||||
|
||||
# Step 3: Process weights
|
||||
self.process_weights(context)
|
||||
progress.step("Processed weights")
|
||||
|
||||
# Step 4: Clean up
|
||||
self.cleanup_armature(context)
|
||||
progress.step("Cleaned up armature")
|
||||
|
||||
# Step 5: Final validation
|
||||
self.validate_results(context)
|
||||
progress.step("Validated results")
|
||||
|
||||
self.report({'INFO'}, t("MMD.standardization_complete"))
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"MMD Standardization failed: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
def process_bone_names(self, context: Context) -> None:
|
||||
"""Process and standardize bone names"""
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
edit_bones = self.armature.data.edit_bones
|
||||
|
||||
# First pass - handle IK bones
|
||||
ik_bones = [bone for bone in edit_bones if 'IK' in bone.name or 'IK' in bone.name]
|
||||
for bone in ik_bones:
|
||||
new_name = f"ik_{self.standardize_bone_name(bone.name.replace('IK', '').replace('IK', ''))}"
|
||||
self.bone_mapping[bone.name] = new_name
|
||||
bone.name = new_name
|
||||
|
||||
# Second pass - standard bones
|
||||
for bone in edit_bones:
|
||||
if bone not in ik_bones:
|
||||
new_name = self.standardize_bone_name(bone.name)
|
||||
if new_name != bone.name:
|
||||
self.bone_mapping[bone.name] = new_name
|
||||
bone.name = new_name
|
||||
|
||||
def translate_japanese_bone_name(self, name: str) -> str:
|
||||
"""Translate Japanese bone names to English standardized names"""
|
||||
name_lower = name.lower()
|
||||
|
||||
for bone_category, variations in bone_names.items():
|
||||
for variation in variations:
|
||||
if variation in name_lower:
|
||||
return bone_category
|
||||
|
||||
return name
|
||||
|
||||
def standardize_bone_name(self, name: str) -> str:
|
||||
"""Standardize individual bone names"""
|
||||
result = self.translate_japanese_bone_name(name)
|
||||
|
||||
prefixes = ['ValveBiped_', 'Bip01_', 'MMD_', 'Armature|']
|
||||
for prefix in prefixes:
|
||||
if result.lower().startswith(prefix.lower()):
|
||||
result = result[len(prefix):]
|
||||
|
||||
if result.endswith('_L') or result.endswith('.L'):
|
||||
result = f"{result[:-2]}.L"
|
||||
elif result.endswith('_R') or result.endswith('.R'):
|
||||
result = f"{result[:-2]}.R"
|
||||
|
||||
return result
|
||||
return result
|
||||
|
||||
def fix_bone_structure(self, context: Context) -> None:
|
||||
"""Fix bone hierarchy and orientations"""
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
edit_bones = self.armature.data.edit_bones
|
||||
|
||||
self.process_spine_chain(context)
|
||||
self.fix_bone_orientations(context)
|
||||
self.connect_bones(context)
|
||||
|
||||
def process_weights(self, context: Context) -> None:
|
||||
"""Process and clean up vertex weights"""
|
||||
for mesh in self.get_associated_meshes(context):
|
||||
# Transfer weights based on bone mapping
|
||||
for old_name, new_name in self.bone_mapping.items():
|
||||
if old_name != new_name:
|
||||
transfer_vertex_weights(mesh, old_name, new_name)
|
||||
|
||||
# Clean up zero weights
|
||||
self.cleanup_vertex_groups(mesh, context)
|
||||
|
||||
def cleanup_armature(self, context: Context) -> None:
|
||||
"""Perform final cleanup operations"""
|
||||
self.remove_unused_bones(context)
|
||||
self.cleanup_constraints(context)
|
||||
self.fix_zero_length_bones(context)
|
||||
|
||||
def get_associated_meshes(self, context: Context) -> List[Object]:
|
||||
"""Get all mesh objects associated with the armature"""
|
||||
return [obj for obj in bpy.data.objects
|
||||
if obj.type == 'MESH'
|
||||
and obj.parent == self.armature]
|
||||
|
||||
def process_spine_chain(self, context: Context) -> None:
|
||||
"""Process and fix spine bone chain hierarchy"""
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
edit_bones = self.armature.data.edit_bones
|
||||
spine_bones = {
|
||||
'hips': None,
|
||||
'spine': None,
|
||||
'chest': None,
|
||||
'upper_chest': None,
|
||||
'neck': None,
|
||||
'head': None
|
||||
}
|
||||
|
||||
# Find spine bones using bone_names dictionary
|
||||
for bone in edit_bones:
|
||||
for spine_part, _ in spine_bones.items():
|
||||
if any(alt_name in bone.name.lower() for alt_name in bone_names[spine_part]):
|
||||
spine_bones[spine_part] = bone
|
||||
break
|
||||
|
||||
# Set up spine hierarchy
|
||||
hierarchy = [
|
||||
('hips', 'spine'),
|
||||
('spine', 'chest'),
|
||||
('chest', 'neck'),
|
||||
('neck', 'head')
|
||||
]
|
||||
|
||||
for parent_name, child_name in hierarchy:
|
||||
parent = spine_bones.get(parent_name)
|
||||
child = spine_bones.get(child_name)
|
||||
if parent and child:
|
||||
child.parent = parent
|
||||
child.use_connect = True
|
||||
|
||||
def fix_bone_orientations(self, context: Context) -> None:
|
||||
"""Fix bone orientations for standard pose compatibility"""
|
||||
edit_bones = self.armature.data.edit_bones
|
||||
|
||||
# Define standardized roll values for key bones
|
||||
roll_values = {
|
||||
'upper_arm.L': -0.1,
|
||||
'upper_arm.R': 0.1,
|
||||
'forearm.L': -0.1,
|
||||
'forearm.R': 0.1,
|
||||
'thigh.L': 0.0,
|
||||
'thigh.R': 0.0,
|
||||
'shin.L': 0.0,
|
||||
'shin.R': 0.0,
|
||||
'foot.L': 0.0,
|
||||
'foot.R': 0.0,
|
||||
'spine': 0.0,
|
||||
'chest': 0.0,
|
||||
'neck': 0.0
|
||||
}
|
||||
|
||||
# Apply roll corrections
|
||||
for bone in edit_bones:
|
||||
if bone.name.lower() in roll_values:
|
||||
bone.roll = roll_values[bone.name.lower()]
|
||||
|
||||
# Process arm chains
|
||||
arm_pairs = [
|
||||
('upper_arm', 'forearm'),
|
||||
('forearm', 'hand')
|
||||
]
|
||||
|
||||
for side in ['.L', '.R']:
|
||||
for parent, child in arm_pairs:
|
||||
parent_bone = next((b for b in edit_bones if b.name.lower().startswith(parent) and b.name.endswith(side)), None)
|
||||
child_bone = next((b for b in edit_bones if b.name.lower().startswith(child) and b.name.endswith(side)), None)
|
||||
|
||||
if parent_bone and child_bone:
|
||||
child_bone.use_connect = True
|
||||
child_bone.use_inherit_rotation = True
|
||||
|
||||
# Process leg chains
|
||||
leg_pairs = [
|
||||
('thigh', 'shin'),
|
||||
('shin', 'foot')
|
||||
]
|
||||
|
||||
for side in ['.L', '.R']:
|
||||
for parent, child in leg_pairs:
|
||||
parent_bone = next((b for b in edit_bones if b.name.lower().startswith(parent) and b.name.endswith(side)), None)
|
||||
child_bone = next((b for b in edit_bones if b.name.lower().startswith(child) and b.name.endswith(side)), None)
|
||||
|
||||
if parent_bone and child_bone:
|
||||
child_bone.use_connect = True
|
||||
child_bone.use_inherit_rotation = True
|
||||
|
||||
# Align twist bones if present
|
||||
twist_bones = [b for b in edit_bones if 'twist' in b.name.lower()]
|
||||
for twist_bone in twist_bones:
|
||||
if twist_bone.parent:
|
||||
twist_bone.roll = twist_bone.parent.roll
|
||||
|
||||
def remove_unused_bones(self, context: Context) -> None:
|
||||
"""Remove unused and unnecessary bones from the armature"""
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
edit_bones = self.armature.data.edit_bones
|
||||
|
||||
# Get list of bones that have vertex weights
|
||||
used_bones = set()
|
||||
for mesh in self.get_associated_meshes(context):
|
||||
for group in mesh.vertex_groups:
|
||||
used_bones.add(group.name)
|
||||
|
||||
# Get list of essential bones to always keep
|
||||
essential_bones = {
|
||||
'hips', 'spine', 'chest', 'upper_chest', 'neck', 'head',
|
||||
'left_leg', 'right_leg', 'left_knee', 'right_knee',
|
||||
'left_ankle', 'right_ankle', 'left_toe', 'right_toe'
|
||||
}
|
||||
|
||||
# Add any additional bones you want to preserve
|
||||
essential_bones.update(dont_delete_these_main_bones)
|
||||
|
||||
# Remove unused bones
|
||||
for bone in edit_bones:
|
||||
# Skip if bone is essential
|
||||
if bone.name.lower() in essential_bones:
|
||||
continue
|
||||
|
||||
# Skip if bone has weights
|
||||
if bone.name in used_bones:
|
||||
continue
|
||||
|
||||
# Remove the bone
|
||||
edit_bones.remove(bone)
|
||||
|
||||
|
||||
def connect_bones(self, context: Context) -> None:
|
||||
"""Connect bones that should be connected in the hierarchy"""
|
||||
edit_bones = self.armature.data.edit_bones
|
||||
|
||||
connect_chains = [
|
||||
['hips', 'spine', 'chest', 'neck', 'head'],
|
||||
['shoulder.L', 'upper_arm.L', 'forearm.L', 'hand.L'],
|
||||
['shoulder.R', 'upper_arm.R', 'forearm.R', 'hand.R'],
|
||||
['thigh.L', 'shin.L', 'foot.L', 'toe.L'],
|
||||
['thigh.R', 'shin.R', 'foot.R', 'toe.R']
|
||||
]
|
||||
|
||||
for chain in connect_chains:
|
||||
prev_bone = None
|
||||
for bone_name in chain:
|
||||
bone = next((b for b in edit_bones if b.name.lower().endswith(bone_name.lower())), None)
|
||||
if bone and prev_bone:
|
||||
bone.parent = prev_bone
|
||||
bone.use_connect = True
|
||||
prev_bone = bone
|
||||
|
||||
def cleanup_vertex_groups(self, mesh_obj: Object, context: Context) -> None:
|
||||
"""Clean up vertex groups by removing zero weights and merging similar groups"""
|
||||
threshold = context.scene.avatar_toolkit.merge_weights_threshold
|
||||
|
||||
vertex_groups = mesh_obj.vertex_groups
|
||||
|
||||
groups_to_remove = set()
|
||||
|
||||
for group in vertex_groups:
|
||||
weights = get_vertex_weights(mesh_obj, group.name)
|
||||
|
||||
if not any(weight > threshold for weight in weights.values()):
|
||||
groups_to_remove.add(group.name)
|
||||
|
||||
for group_name in groups_to_remove:
|
||||
group = vertex_groups.get(group_name)
|
||||
if group:
|
||||
vertex_groups.remove(group)
|
||||
|
||||
def validate_results(self, context: Context) -> None:
|
||||
"""Validate the results of standardization"""
|
||||
valid, messages = validate_armature(self.armature)
|
||||
if not valid:
|
||||
raise ValueError("\n".join(messages))
|
||||
|
||||
def cleanup_constraints(self, context: Context) -> None:
|
||||
"""Remove all constraints from the armature."""
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
|
||||
for pose_bone in self.armature.pose.bones:
|
||||
constraints_to_remove = [constraint for constraint in pose_bone.constraints]
|
||||
for constraint in constraints_to_remove:
|
||||
pose_bone.constraints.remove(constraint)
|
||||
|
||||
def fix_zero_length_bones(self, context: Context) -> None:
|
||||
"""Fix zero-length bones by setting minimal length"""
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
edit_bones = self.armature.data.edit_bones
|
||||
|
||||
min_length = 0.01 # Minimum bone length in Blender units
|
||||
|
||||
for bone in edit_bones:
|
||||
bone_length = (bone.tail - bone.head).length
|
||||
|
||||
if bone_length < min_length:
|
||||
if bone.parent:
|
||||
direction = bone.parent.tail - bone.parent.head
|
||||
direction.normalize()
|
||||
else:
|
||||
direction = Vector((0, 0, 1))
|
||||
|
||||
bone.tail = bone.head + (direction * min_length)
|
||||
|
||||
|
||||
class ReparentMeshesOperator(bpy.types.Operator):
|
||||
bl_idname = "avatar_toolkit.reparent_meshes"
|
||||
bl_label = t("MMD.reparent_meshes")
|
||||
bl_description = t("MMD.reparent_meshes_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
armature = get_active_armature(context)
|
||||
return armature is not None and get_all_meshes(context)
|
||||
|
||||
def execute(self, context):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
self.report({'ERROR'}, t("MMD.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
meshes = get_all_meshes(context)
|
||||
if not meshes:
|
||||
self.report({'ERROR'}, t("MMD.no_meshes"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
try:
|
||||
with ProgressTracker(context, len(meshes) + 1, "Reparenting Meshes") as progress:
|
||||
# Get or create main collection
|
||||
main_collection = self._get_main_collection(context)
|
||||
progress.step("Setting up collections")
|
||||
|
||||
# Process each mesh
|
||||
for mesh in meshes:
|
||||
progress.step(f"Processing {mesh.name}")
|
||||
self._process_mesh(mesh, armature, main_collection)
|
||||
|
||||
self.report({'INFO'}, t("MMD.reparenting_complete"))
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reparenting meshes: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
def _get_main_collection(self, context) -> bpy.types.Collection:
|
||||
"""Get or create the main collection for the armature"""
|
||||
if hasattr(context.scene, 'collection'):
|
||||
return context.scene.collection
|
||||
return context.scene.collection
|
||||
|
||||
def _process_mesh(self, mesh: bpy.types.Object,
|
||||
armature: bpy.types.Object,
|
||||
main_collection: bpy.types.Collection) -> None:
|
||||
"""Process individual mesh parenting and collection management"""
|
||||
# Unlink from other collections
|
||||
for col in mesh.users_collection:
|
||||
if col != main_collection:
|
||||
col.objects.unlink(mesh)
|
||||
|
||||
# Ensure mesh is in main collection
|
||||
if mesh.name not in main_collection.objects:
|
||||
main_collection.objects.link(mesh)
|
||||
|
||||
# Set parent to armature
|
||||
mesh.parent = armature
|
||||
if not mesh.parent_type == 'ARMATURE':
|
||||
mesh.parent_type = 'ARMATURE'
|
||||
|
||||
class AVATAR_TOOLKIT_OT_ConvertMmdMorphs(Operator):
|
||||
"""Convert MMD morph data to shape keys"""
|
||||
bl_idname = "avatar_toolkit.convert_mmd_morphs"
|
||||
bl_label = t("MMD.convert_morphs")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
armature = get_active_armature(context)
|
||||
return armature is not None and get_all_meshes(context)
|
||||
|
||||
def execute(self, context):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
self.report({'ERROR'}, t("MMD.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
try:
|
||||
with ProgressTracker(context, 3, "Converting MMD Morphs") as progress:
|
||||
# Convert bone morphs to shape keys
|
||||
if hasattr(armature, 'mmd_root') and armature.mmd_root.bone_morphs:
|
||||
self.process_bone_morphs(context, armature, progress)
|
||||
|
||||
progress.step("Processed bone morphs")
|
||||
|
||||
# Clean up unused data
|
||||
self.cleanup_unused_data(context)
|
||||
progress.step("Cleaned up data")
|
||||
|
||||
# Validate results
|
||||
self.validate_results(context)
|
||||
progress.step("Validated results")
|
||||
|
||||
self.report({'INFO'}, t("MMD.conversion_complete"))
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error converting MMD morphs: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
def process_bone_morphs(self, context, armature, progress):
|
||||
"""Process bone morphs into shape keys"""
|
||||
for morph in armature.mmd_root.bone_morphs:
|
||||
for mesh in get_all_meshes(context):
|
||||
# Create armature modifier
|
||||
mod = mesh.modifiers.new(morph.name, 'ARMATURE')
|
||||
mod.object = armature
|
||||
|
||||
# Apply as shape key
|
||||
with context.temp_override(object=mesh):
|
||||
bpy.ops.object.modifier_apply(modifier=mod.name)
|
||||
|
||||
class AVATAR_TOOLKIT_OT_CleanupMmdModel(Operator):
|
||||
"""Clean up MMD model by removing unused data and fixing display settings"""
|
||||
bl_idname = "avatar_toolkit.cleanup_mmd"
|
||||
bl_label = t("MMD.cleanup")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
self.report({'ERROR'}, t("MMD.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
try:
|
||||
with ProgressTracker(context, 4, "Cleaning MMD Model") as progress:
|
||||
# Remove rigid bodies and joints
|
||||
self.remove_physics_objects(armature)
|
||||
progress.step("Removed physics objects")
|
||||
|
||||
# Clean up collections and hierarchy
|
||||
self.cleanup_hierarchy(context, armature)
|
||||
progress.step("Cleaned hierarchy")
|
||||
|
||||
# Fix viewport settings
|
||||
self.fix_viewport_settings(context)
|
||||
progress.step("Fixed viewport")
|
||||
|
||||
# Final cleanup
|
||||
clear_unused_data_blocks()
|
||||
progress.step("Cleared unused data")
|
||||
|
||||
self.report({'INFO'}, t("MMD.cleanup_complete"))
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning MMD model: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
def remove_physics_objects(self, armature):
|
||||
"""Remove physics-related objects"""
|
||||
to_delete = []
|
||||
for child in armature.children:
|
||||
if any(x in child.name.lower() for x in ['rigidbodies', 'joints', 'physics']):
|
||||
to_delete.append(child)
|
||||
|
||||
for obj in to_delete:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
|
||||
def cleanup_hierarchy(self, context, armature):
|
||||
"""Clean up object hierarchy and collections"""
|
||||
meshes = get_all_meshes(context)
|
||||
for mesh in meshes:
|
||||
# Ensure proper parenting
|
||||
mesh.parent = armature
|
||||
mesh.parent_type = 'ARMATURE'
|
||||
|
||||
# Clean up collections
|
||||
for col in mesh.users_collection:
|
||||
if col != context.scene.collection:
|
||||
col.objects.unlink(mesh)
|
||||
|
||||
if mesh.name not in context.scene.collection.objects:
|
||||
context.scene.collection.objects.link(mesh)
|
||||
|
||||
def fix_viewport_settings(self, context):
|
||||
"""Fix viewport display settings"""
|
||||
# Set armature display
|
||||
armature = get_active_armature(context)
|
||||
armature.data.display_type = 'OCTAHEDRAL'
|
||||
armature.show_in_front = True
|
||||
|
||||
# Set viewport shading
|
||||
for area in context.screen.areas:
|
||||
if area.type == 'VIEW_3D':
|
||||
space = area.spaces[0]
|
||||
space.shading.type = 'MATERIAL'
|
||||
space.clip_start = 0.01
|
||||
space.clip_end = 300
|
||||
|
||||
class AVATAR_TOOLKIT_OT_FixMeshes(Operator):
|
||||
"""Clean up and optimize mesh materials, shading, and shape keys"""
|
||||
bl_idname = "avatar_toolkit.fix_meshes"
|
||||
bl_label = t("Optimization.fix_meshes")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
armature = get_active_armature(context)
|
||||
return armature is not None and get_all_meshes(context)
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
meshes = get_all_meshes(context)
|
||||
if not meshes:
|
||||
self.report({'ERROR'}, t("Optimization.no_meshes"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
with ProgressTracker(context, len(meshes), "Fixing Meshes") as progress:
|
||||
for mesh in meshes:
|
||||
self.process_mesh(context, mesh)
|
||||
progress.step(f"Processed {mesh.name}")
|
||||
|
||||
self.report({'INFO'}, t("Optimization.meshes_fixed"))
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fixing meshes: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
def process_mesh(self, context: Context, mesh: Object) -> None:
|
||||
"""Process and fix individual mesh"""
|
||||
# Unlock transforms
|
||||
for i in range(3):
|
||||
mesh.lock_location[i] = False
|
||||
mesh.lock_rotation[i] = False
|
||||
mesh.lock_scale[i] = False
|
||||
|
||||
# Process shape keys
|
||||
if mesh.data.shape_keys:
|
||||
self.fix_shape_keys(mesh)
|
||||
|
||||
# Process materials
|
||||
self.fix_materials(context, mesh)
|
||||
|
||||
def fix_shape_keys(self, mesh: Object) -> None:
|
||||
"""Fix and clean up shape keys"""
|
||||
if not mesh.data.shape_keys:
|
||||
return
|
||||
|
||||
shape_keys = mesh.data.shape_keys.key_blocks
|
||||
|
||||
# Rename basis
|
||||
if shape_keys[0].name != "Basis":
|
||||
shape_keys[0].name = "Basis"
|
||||
|
||||
# Clean up names
|
||||
for key in shape_keys:
|
||||
# Remove common prefixes/suffixes
|
||||
clean_name = key.name
|
||||
for prefix in ['Face.M F00 000 Fcl ', 'Face.M F00 000 00 Fcl ']:
|
||||
clean_name = clean_name.replace(prefix, '')
|
||||
|
||||
# Replace underscores with spaces
|
||||
clean_name = clean_name.replace('_', ' ')
|
||||
key.name = clean_name
|
||||
|
||||
# Sort shape keys by category
|
||||
categories = ['MTH', 'EYE', 'BRW', 'ALL']
|
||||
|
||||
# Create sorted list of shape key names
|
||||
ordered_names = []
|
||||
|
||||
# Add categorized keys first
|
||||
for category in categories:
|
||||
category_keys = [key.name for key in shape_keys if key.name.startswith(category)]
|
||||
ordered_names.extend(sorted(category_keys))
|
||||
|
||||
# Add remaining keys
|
||||
remaining = [key.name for key in shape_keys if not any(key.name.startswith(c) for c in categories)]
|
||||
ordered_names.extend(sorted(remaining))
|
||||
|
||||
# Reorder using context override
|
||||
with bpy.context.temp_override(active_object=mesh, selected_objects=[mesh]):
|
||||
for idx, name in enumerate(ordered_names):
|
||||
mesh.active_shape_key_index = shape_keys.find(name)
|
||||
while mesh.active_shape_key_index > idx:
|
||||
bpy.ops.object.shape_key_move(type='UP')
|
||||
|
||||
|
||||
def fix_materials(self, context: Context, mesh: Object) -> None:
|
||||
"""Fix and optimize materials"""
|
||||
for slot in mesh.material_slots:
|
||||
if not slot.material:
|
||||
continue
|
||||
|
||||
material = slot.material
|
||||
|
||||
# Set up basic material properties
|
||||
material.use_backface_culling = True
|
||||
material.blend_method = 'HASHED'
|
||||
material.shadow_method = 'HASHED'
|
||||
|
||||
# Clean up material name
|
||||
material.name = self.clean_material_name(material.name)
|
||||
|
||||
# Consolidate similar materials
|
||||
for other_slot in mesh.material_slots:
|
||||
if other_slot.material and other_slot.material != material:
|
||||
if materials_match(material, other_slot.material):
|
||||
other_slot.material = material
|
||||
|
||||
def clean_material_name(self, name: str) -> str:
|
||||
"""Clean up material name"""
|
||||
# Remove common prefixes/suffixes
|
||||
prefixes = ['material', 'mat', 'mtl', 'material.']
|
||||
for prefix in prefixes:
|
||||
if name.lower().startswith(prefix):
|
||||
name = name[len(prefix):]
|
||||
|
||||
# Remove numbers at end
|
||||
while name and name[-1].isdigit():
|
||||
name = name[:-1]
|
||||
|
||||
return name.strip()
|
||||
|
||||
class AVATAR_TOOLKIT_OT_ValidateMeshes(Operator):
|
||||
"""Validate meshes and UV maps for common issues"""
|
||||
bl_idname = "avatar_toolkit.validate_meshes"
|
||||
bl_label = t("Validation.check_meshes")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
self.report({'ERROR'}, t("Validation.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
try:
|
||||
with ProgressTracker(context, 3, "Validating Meshes") as progress:
|
||||
# Check bone hierarchy
|
||||
hierarchy_issues = self.validate_bone_hierarchy(armature)
|
||||
progress.step("Checked bone hierarchy")
|
||||
|
||||
# Check UV coordinates
|
||||
uv_issues = self.validate_uv_maps(context)
|
||||
progress.step("Checked UV maps")
|
||||
|
||||
# Generate report
|
||||
self.generate_validation_report(context, hierarchy_issues, uv_issues)
|
||||
progress.step("Generated report")
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating meshes: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
def validate_bone_hierarchy(self, armature: Object) -> List[str]:
|
||||
"""Validate bone hierarchy against standard structure"""
|
||||
issues = []
|
||||
|
||||
# Define expected hierarchy
|
||||
hierarchy = [
|
||||
['hips', 'spine', 'chest', 'neck', 'head'],
|
||||
['hips', 'left_leg', 'left_knee', 'left_ankle'],
|
||||
['hips', 'right_leg', 'right_knee', 'right_ankle'],
|
||||
['chest', 'left_shoulder', 'left_arm', 'left_elbow', 'left_wrist'],
|
||||
['chest', 'right_shoulder', 'right_arm', 'right_elbow', 'right_wrist']
|
||||
]
|
||||
|
||||
for chain in hierarchy:
|
||||
previous = None
|
||||
for bone_name in chain:
|
||||
# Check if bone exists
|
||||
bone = None
|
||||
for alt_name in bone_names[bone_name]:
|
||||
if alt_name in armature.data.bones:
|
||||
bone = armature.data.bones[alt_name]
|
||||
break
|
||||
|
||||
if not bone:
|
||||
issues.append(t("Validation.missing_bone", bone=bone_name))
|
||||
continue
|
||||
|
||||
# Check parent relationship
|
||||
if previous:
|
||||
if not bone.parent:
|
||||
issues.append(t("Validation.no_parent", bone=bone.name))
|
||||
elif bone.parent.name != previous.name:
|
||||
issues.append(t("Validation.wrong_parent",
|
||||
bone=bone.name,
|
||||
expected=previous.name,
|
||||
actual=bone.parent.name))
|
||||
previous = bone
|
||||
|
||||
return issues
|
||||
|
||||
def validate_uv_maps(self, context: Context) -> Dict[str, int]:
|
||||
"""Check UV maps for issues"""
|
||||
issues = {'nan_coords': 0, 'missing_uvs': 0}
|
||||
|
||||
for mesh in get_all_meshes(context):
|
||||
if not mesh.data.uv_layers:
|
||||
issues['missing_uvs'] += 1
|
||||
continue
|
||||
|
||||
for uv_layer in mesh.data.uv_layers:
|
||||
for uv in uv_layer.data:
|
||||
if math.isnan(uv.uv.x):
|
||||
uv.uv.x = 0
|
||||
issues['nan_coords'] += 1
|
||||
if math.isnan(uv.uv.y):
|
||||
uv.uv.y = 0
|
||||
issues['nan_coords'] += 1
|
||||
|
||||
return issues
|
||||
|
||||
def generate_validation_report(self, context: Context,
|
||||
hierarchy_issues: List[str],
|
||||
uv_issues: Dict[str, int]) -> None:
|
||||
"""Generate and display validation report"""
|
||||
report_lines = []
|
||||
|
||||
# Add hierarchy issues
|
||||
if hierarchy_issues:
|
||||
report_lines.append(t("Validation.hierarchy_issues"))
|
||||
report_lines.extend(hierarchy_issues)
|
||||
|
||||
# Add UV issues
|
||||
if uv_issues['nan_coords'] > 0:
|
||||
report_lines.append(t("Validation.uv_nan_coords",
|
||||
count=uv_issues['nan_coords']))
|
||||
|
||||
if uv_issues['missing_uvs'] > 0:
|
||||
report_lines.append(t("Validation.missing_uvs",
|
||||
count=uv_issues['missing_uvs']))
|
||||
|
||||
# Show report
|
||||
if report_lines:
|
||||
self.report({'WARNING'}, "\n".join(report_lines))
|
||||
else:
|
||||
self.report({'INFO'}, t("Validation.no_issues"))
|
||||
@@ -1,3 +1,4 @@
|
||||
import traceback
|
||||
import bpy
|
||||
import re
|
||||
from typing import Set, Dict, List, Optional, Tuple
|
||||
@@ -14,10 +15,11 @@ from ...core.translations import t
|
||||
from ...core.common import (
|
||||
get_active_armature,
|
||||
get_all_meshes,
|
||||
validate_armature,
|
||||
clear_unused_data_blocks,
|
||||
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"""
|
||||
@@ -92,7 +94,7 @@ class AvatarToolkit_OT_CombineMaterials(Operator):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
valid, _ = validate_armature(armature)
|
||||
valid, _, _ = validate_armature(armature)
|
||||
return valid
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
@@ -112,24 +114,25 @@ 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 +144,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:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import traceback
|
||||
import bpy
|
||||
from typing import Set, List, Tuple, ClassVar
|
||||
from bpy.types import Operator, Context, Object
|
||||
@@ -6,11 +7,12 @@ from ...core.translations import t
|
||||
from ...core.common import (
|
||||
get_active_armature,
|
||||
get_all_meshes,
|
||||
validate_armature,
|
||||
validate_meshes,
|
||||
join_mesh_objects,
|
||||
ProgressTracker
|
||||
)
|
||||
from ...core.armature_validation import validate_armature
|
||||
import traceback
|
||||
|
||||
class AvatarToolkit_OT_JoinAllMeshes(Operator):
|
||||
"""Operator to join all meshes in the scene"""
|
||||
@@ -25,7 +27,7 @@ class AvatarToolkit_OT_JoinAllMeshes(Operator):
|
||||
if not armature:
|
||||
return False
|
||||
valid: bool
|
||||
valid, _ = validate_armature(armature)
|
||||
valid, _, _ = validate_armature(armature)
|
||||
return valid
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
@@ -51,9 +53,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):
|
||||
@@ -69,7 +71,7 @@ class AvatarToolkit_OT_JoinSelectedMeshes(Operator):
|
||||
if not armature:
|
||||
return False
|
||||
valid: bool
|
||||
valid, _ = validate_armature(armature)
|
||||
valid, _, _ = validate_armature(armature)
|
||||
return (valid and
|
||||
context.mode == 'OBJECT' and
|
||||
len([obj for obj in context.selected_objects if obj.type == 'MESH']) > 1)
|
||||
@@ -95,7 +97,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'}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import traceback
|
||||
import bpy
|
||||
import numpy as np
|
||||
from typing import List, TypedDict, Any, Literal, TypeAlias, cast
|
||||
@@ -7,8 +8,10 @@ from ...core.translations import t
|
||||
from ...core.common import (
|
||||
get_active_armature,
|
||||
get_all_meshes,
|
||||
validate_armature
|
||||
)
|
||||
from ...core.armature_validation import validate_armature
|
||||
import bmesh
|
||||
import mathutils
|
||||
|
||||
# Constants
|
||||
MERGE_ITERATION_COUNT = 20
|
||||
@@ -19,61 +22,36 @@ ModalReturnType: TypeAlias = Literal['RUNNING_MODAL', 'FINISHED', 'CANCELLED']
|
||||
|
||||
class MeshEntry(TypedDict):
|
||||
mesh: Object
|
||||
shapekeys: list[str]
|
||||
vertices: int
|
||||
cur_vertex_pass: int
|
||||
shapekeys: list[bpy.types.Object]
|
||||
|
||||
def create_duplicate_for_merge(context: Context, mesh: Object, shapekey_name: str) -> Object:
|
||||
def create_duplicate_for_merge(context: Context, mesh: Object, shapekey_name: str = "") -> Object:
|
||||
"""Creates a duplicate mesh object for merge testing"""
|
||||
context.view_layer.objects.active = mesh
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
mesh.select_set(True)
|
||||
context.view_layer.objects.active = mesh
|
||||
bpy.ops.object.duplicate()
|
||||
bpy.ops.object.shape_key_move(type='TOP')
|
||||
|
||||
duplicate = context.view_layer.objects.active
|
||||
|
||||
if(shapekey_name != ""):
|
||||
for shape in duplicate.data.shape_keys.key_blocks:
|
||||
shape.value = 0
|
||||
duplicate.active_shape_key_index = mesh.data.shape_keys.key_blocks.find(shapekey_name)
|
||||
duplicate.active_shape_key.value = 1
|
||||
bpy.ops.object.shape_key_remove(all=True,apply_mix=True)
|
||||
duplicate.name = f"{shapekey_name}_object_is_{mesh.name}"
|
||||
else:
|
||||
duplicate.name = f"object_is_{mesh.name}"
|
||||
return duplicate
|
||||
|
||||
def process_vertex_merging(mesh_data: bpy.types.Mesh, vertices_original: dict[int, Any], current_vertex: int) -> list[int]:
|
||||
"""Process vertex merging and return merged vertex indices"""
|
||||
merged_vertices = []
|
||||
i, j = 0, 0
|
||||
def select_obj(context: Context, obj: Object, target_mode='OBJECT'):
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
obj.select_set(True)
|
||||
context.view_layer.objects.active = obj
|
||||
bpy.ops.object.mode_set(mode=target_mode)
|
||||
|
||||
while i < len(vertices_original):
|
||||
if j + 1 > len(mesh_data.vertices):
|
||||
merged_vertices.append(i)
|
||||
j = j - 1
|
||||
elif mesh_data.vertices[j].co.xyz != vertices_original[i]:
|
||||
merged_vertices.append(i)
|
||||
j = j - 1
|
||||
elif vertices_original[i] == vertices_original[current_vertex]:
|
||||
merged_vertices.append(i)
|
||||
i, j = i + 1, j + 1
|
||||
|
||||
return merged_vertices
|
||||
|
||||
class AvatarToolkit_OT_RemoveDoublesAdvanced(Operator):
|
||||
bl_idname = "avatar_toolkit.remove_doubles_advanced"
|
||||
bl_label = t("Optimization.remove_doubles_advanced")
|
||||
bl_description = t("Optimization.remove_doubles_advanced_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
"""Check if the operator can be executed"""
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
valid, _ = validate_armature(armature)
|
||||
return valid
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
"""Execute the advanced remove doubles operator"""
|
||||
context.scene.avatar_toolkit.remove_doubles_advanced = True
|
||||
bpy.ops.avatar_toolkit.remove_doubles('INVOKE_DEFAULT')
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
class AvatarToolkit_OT_RemoveDoubles(Operator):
|
||||
bl_idname = "avatar_toolkit.remove_doubles"
|
||||
@@ -82,40 +60,40 @@ class AvatarToolkit_OT_RemoveDoubles(Operator):
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
objects_to_do: list[MeshEntry] = []
|
||||
|
||||
merge_distance: bpy.props.FloatProperty(name=t("Optimization.merge_distance"), description=t("Optimization.merge_distance_desc"), default=.001)
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
"""Check if the operator can be executed"""
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
valid, _ = validate_armature(armature)
|
||||
valid, _, _ = validate_armature(armature)
|
||||
return valid
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
"""Draw the operator's UI"""
|
||||
layout = self.layout
|
||||
layout.prop(context.scene.avatar_toolkit, "remove_doubles_merge_distance")
|
||||
layout.label(text=t("Optimization.remove_doubles_warning"))
|
||||
layout.label(text=t("Optimization.remove_doubles_wait"))
|
||||
layout.prop(self, "merge_distance")
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||
"""Initialize the operator"""
|
||||
logger.info("Starting modal execution of merge doubles safely")
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def setup_mesh_entry(self, mesh: Object) -> MeshEntry:
|
||||
def setup_mesh_entry(self, context: Context, mesh: Object) -> MeshEntry:
|
||||
"""Set up mesh entry data structure"""
|
||||
#create shapekey objects to merge doubles on.
|
||||
shapes: list[bpy.types.Object] = []
|
||||
if(mesh.data.shape_keys):
|
||||
for shape in mesh.data.shape_keys.key_blocks:
|
||||
shapes.append(create_duplicate_for_merge(context,mesh,shape.name))
|
||||
else:
|
||||
shapes.append(create_duplicate_for_merge(context,mesh))
|
||||
mesh_entry: MeshEntry = {
|
||||
"mesh": mesh,
|
||||
"shapekeys": [],
|
||||
"vertices": len(mesh.data.vertices),
|
||||
"cur_vertex_pass": 0
|
||||
"shapekeys": shapes
|
||||
}
|
||||
|
||||
if mesh.data.shape_keys:
|
||||
mesh_entry["shapekeys"] = [shape.name for shape in mesh.data.shape_keys.key_blocks]
|
||||
|
||||
return mesh_entry
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
@@ -135,147 +113,77 @@ class AvatarToolkit_OT_RemoveDoubles(Operator):
|
||||
for mesh in objects:
|
||||
if mesh.data.name not in [obj["mesh"].data.name for obj in self.objects_to_do]:
|
||||
logger.debug(f"Setting up data for object {mesh.name}")
|
||||
mesh_entry = self.setup_mesh_entry(mesh)
|
||||
mesh_entry = self.setup_mesh_entry(context, mesh)
|
||||
self.objects_to_do.append(mesh_entry)
|
||||
|
||||
context.window_manager.modal_handler_add(self)
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in execute: {str(e)}")
|
||||
except Exception:
|
||||
logger.error(f"Error in execute: {traceback.format_exc()}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
def modify_mesh(self, context: Context, mesh: MeshEntry) -> None:
|
||||
"""Basic mesh modification for simple cases"""
|
||||
try:
|
||||
mesh["mesh"].select_set(True)
|
||||
context.view_layer.objects.active = mesh["mesh"]
|
||||
mesh_data = mesh["mesh"].data
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Select vertices with different positions in shape keys
|
||||
for index, point in enumerate(mesh["mesh"].active_shape_key.points):
|
||||
if point.co.xyz != mesh_data.shape_keys.key_blocks[0].points[index].co.xyz:
|
||||
mesh_data.vertices[index].select = True
|
||||
logger.debug(f"Shapekey has moved vertex at index {index}")
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
mesh["mesh"].select_set(False)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in modify_mesh: {str(e)}")
|
||||
|
||||
def modify_mesh_advanced(self, context: Context, mesh_entry: MeshEntry) -> bool:
|
||||
"""Advanced mesh modification with shape key handling"""
|
||||
try:
|
||||
final_merged_vertex_group = []
|
||||
initialized_final = False
|
||||
merge_distance = context.scene.avatar_toolkit.remove_doubles_merge_distance
|
||||
|
||||
for shapekey_name in mesh_entry["shapekeys"]:
|
||||
duplicate = create_duplicate_for_merge(context, mesh_entry["mesh"], shapekey_name)
|
||||
vertices_original = {i: v.co.xyz for i, v in enumerate(duplicate.data.vertices)}
|
||||
|
||||
# Process merging
|
||||
merged_vertices = process_vertex_merging(duplicate.data, vertices_original, mesh_entry["cur_vertex_pass"])
|
||||
|
||||
if not initialized_final:
|
||||
final_merged_vertex_group = merged_vertices.copy()
|
||||
initialized_final = True
|
||||
else:
|
||||
final_merged_vertex_group = [v for v in final_merged_vertex_group if v in merged_vertices]
|
||||
|
||||
bpy.ops.object.delete()
|
||||
|
||||
# Apply final merging
|
||||
if final_merged_vertex_group:
|
||||
self.apply_final_merging(context, mesh_entry, final_merged_vertex_group, merge_distance)
|
||||
|
||||
return not (len(final_merged_vertex_group) > 1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in modify_mesh_advanced: {str(e)}")
|
||||
return True
|
||||
|
||||
def apply_final_merging(self, context: Context, mesh_entry: MeshEntry, vertex_group: list[int], merge_distance: float) -> None:
|
||||
"""Apply final vertex merging operations"""
|
||||
mesh = mesh_entry["mesh"]
|
||||
context.view_layer.objects.active = mesh
|
||||
mesh.select_set(True)
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
select_target_group = [False] * len(mesh.data.vertices)
|
||||
for vertex_index in vertex_group:
|
||||
select_target_group[vertex_index] = True
|
||||
|
||||
mesh.data.vertices.foreach_set("select", select_target_group)
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.remove_doubles(threshold=merge_distance, use_unselected=False)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
def process_simple_mesh(self, context: Context, mesh: MeshEntry, merge_distance: float) -> None:
|
||||
"""Process mesh without shapekeys using simple merge operation"""
|
||||
logger.debug(f"Processing mesh without shapekeys: {mesh['mesh'].name}")
|
||||
mesh["mesh"].select_set(True)
|
||||
context.view_layer.objects.active = mesh["mesh"]
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
mesh["mesh"].data.vertices.foreach_set("select", [False] * len(mesh["mesh"].data.vertices))
|
||||
|
||||
bpy.ops.mesh.select_all(action="INVERT")
|
||||
bpy.ops.mesh.remove_doubles(threshold=merge_distance, use_unselected=False)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
mesh["mesh"].select_set(False)
|
||||
|
||||
def finish_mesh_processing(self, context: Context, mesh: MeshEntry, advanced: bool, merge_distance: float) -> None:
|
||||
"""Complete the mesh processing by performing final merge operations"""
|
||||
logger.debug("Finishing mesh processing")
|
||||
|
||||
if not advanced:
|
||||
mesh["mesh"].select_set(True)
|
||||
context.view_layer.objects.active = mesh["mesh"]
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action="INVERT")
|
||||
bpy.ops.mesh.remove_doubles(threshold=merge_distance, use_unselected=False)
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
mesh["mesh"].select_set(False)
|
||||
|
||||
def modal(self, context: Context, event: Event) -> set[ModalReturnType]:
|
||||
"""Modal operator execution"""
|
||||
try:
|
||||
if not self.objects_to_do:
|
||||
if not self.objects_to_do or len(self.objects_to_do) <= 0:
|
||||
self.report({'INFO'}, t("Optimization.remove_doubles_completed"))
|
||||
logger.info("Finishing modal execution of merge doubles safely")
|
||||
return {'FINISHED'}
|
||||
|
||||
mesh = self.objects_to_do[0]
|
||||
mesh_data = mesh["mesh"].data
|
||||
advanced = context.scene.avatar_toolkit.remove_doubles_advanced
|
||||
merge_distance = context.scene.avatar_toolkit.remove_doubles_merge_distance
|
||||
mesh: MeshEntry = self.objects_to_do.pop(0)
|
||||
merge_distance: float = self.merge_distance
|
||||
|
||||
if len(mesh['shapekeys']) > 0 and not advanced:
|
||||
shapekeyname = mesh['shapekeys'].pop(0)
|
||||
mesh["mesh"].active_shape_key_index = mesh_data.shape_keys.key_blocks.find(shapekeyname)
|
||||
logger.debug(f"Processing shapekey {shapekeyname}")
|
||||
self.modify_mesh(context, mesh)
|
||||
|
||||
elif not mesh_data.shape_keys:
|
||||
self.process_simple_mesh(context, mesh, merge_distance)
|
||||
self.objects_to_do.pop(0)
|
||||
#find which vertices merge on all shapekeys using bmesh, a fast way of doing it - @989onan
|
||||
#final_merged_vertex_group = [i for i in range(0,len(mesh['mesh'].data.vertices))]
|
||||
final_merged_vertex_group: dict[set[int],list[int]] = []
|
||||
for shape in mesh["shapekeys"]:
|
||||
select_obj(context, shape, target_mode='EDIT')
|
||||
bmesh_mesh: bmesh.types.BMesh = bmesh.from_edit_mesh(shape.data)
|
||||
selected_verts: list[bmesh.types.BMVert] = [vert for vert in bmesh_mesh.verts if vert.select == True]
|
||||
i: int = 0
|
||||
merged_vertices: dict[set[int],list[int]] = {} #make a list of sets which act as pairs. the pairs being sets means it doesn't matter if element 0 is at index 1, it is still considered the same pair
|
||||
mergers: dict[bmesh.types.BMVert, bmesh.types.BMVert]
|
||||
for name,mergers in bmesh.ops.find_doubles(bmesh_mesh,verts=selected_verts,dist=merge_distance).items():
|
||||
for source_vert,target_vert in mergers.items():
|
||||
pair: set[int] = set()
|
||||
pair.add(source_vert.index)
|
||||
pair.add(target_vert.index)
|
||||
frozen_pair = frozenset(pair)
|
||||
merged_vertices[frozen_pair] = [source_vert.index,target_vert.index] #put the pairs we have found into a list.
|
||||
|
||||
elif not (mesh["cur_vertex_pass"] > mesh["vertices"]) and advanced:
|
||||
if self.modify_mesh_advanced(context, mesh):
|
||||
mesh["cur_vertex_pass"] += 1
|
||||
if(final_merged_vertex_group == []): #populate list if it is empty
|
||||
final_merged_vertex_group = merged_vertices
|
||||
new_dict: dict[set[int],list[int]] = {}
|
||||
|
||||
else:
|
||||
self.finish_mesh_processing(context, mesh, advanced, merge_distance)
|
||||
self.objects_to_do.pop(0)
|
||||
#update our final list, keeping pairs that exist on all shapekeys and not just one.
|
||||
for key,value in final_merged_vertex_group.items():
|
||||
if key in merged_vertices.keys():
|
||||
new_dict[key] = value
|
||||
final_merged_vertex_group = new_dict
|
||||
|
||||
#create an edit mesh and ensure it's vertex table
|
||||
select_obj(context, mesh['mesh'], target_mode='EDIT')
|
||||
data_mesh: bpy.types.Mesh = mesh['mesh'].data
|
||||
mappings: dict[bmesh.types.BMVert,bmesh.types.BMVert] = {}
|
||||
bmesh_mesh: bmesh.types.BMesh = bmesh.from_edit_mesh(data_mesh)
|
||||
bmesh_mesh.verts.ensure_lookup_table()
|
||||
|
||||
#turn our pairs into a dictionary, which allows for merging vertices based on the shared pairs.
|
||||
for key,value in final_merged_vertex_group.items():
|
||||
mappings[bmesh_mesh.verts[value[0]]] = bmesh_mesh.verts[value[1]]
|
||||
|
||||
#weld the verts and update the source mesh
|
||||
bmesh.ops.weld_verts(bmesh_mesh,targetmap=mappings)
|
||||
bmesh.update_edit_mesh(data_mesh, destructive=True)
|
||||
|
||||
#delete the shapekey reading meshes.
|
||||
for shape in mesh["shapekeys"]:
|
||||
bpy.data.objects.remove(shape)
|
||||
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in modal: {str(e)}")
|
||||
print(traceback.format_exception(e))
|
||||
logger.error(f"Error in modal: {traceback.format_exception(e)}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
+19
-17
@@ -1,3 +1,4 @@
|
||||
import traceback
|
||||
import bpy
|
||||
from typing import Set, Dict, List, Tuple, Optional, Any
|
||||
from bpy.props import StringProperty
|
||||
@@ -8,13 +9,14 @@ from ..core.common import (
|
||||
get_active_armature,
|
||||
get_all_meshes,
|
||||
apply_pose_as_rest,
|
||||
validate_armature,
|
||||
cache_vertex_positions,
|
||||
apply_vertex_positions,
|
||||
validate_mesh_for_pose,
|
||||
process_armature_modifiers,
|
||||
ProgressTracker
|
||||
)
|
||||
import traceback
|
||||
from ..core.armature_validation import validate_armature
|
||||
|
||||
class BatchPoseOperationMixin:
|
||||
"""Base class for batch pose operations"""
|
||||
@@ -23,7 +25,7 @@ class BatchPoseOperationMixin:
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
valid, _ = validate_armature(armature)
|
||||
valid, _, _ = validate_armature(armature)
|
||||
return valid and context.mode == 'POSE'
|
||||
|
||||
def validate_meshes(self, meshes: List[Object]) -> List[Tuple[Object, str]]:
|
||||
@@ -46,7 +48,7 @@ class AvatarToolkit_OT_StartPoseMode(Operator):
|
||||
armature = get_active_armature(context)
|
||||
if not armature or context.mode == "POSE":
|
||||
return False
|
||||
valid, _ = validate_armature(armature)
|
||||
valid, _, _ = validate_armature(armature)
|
||||
return valid
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
@@ -62,9 +64,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,12 +87,12 @@ 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):
|
||||
class AvatarToolkit_OT_ApplyPoseAsShapekey(Operator, BatchPoseOperationMixin):
|
||||
bl_idname = 'avatar_toolkit.apply_pose_as_shapekey'
|
||||
bl_label = t("QuickAccess.apply_pose_as_shapekey.label")
|
||||
bl_description = t("QuickAccess.apply_pose_as_shapekey.desc")
|
||||
@@ -129,12 +131,12 @@ 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):
|
||||
class AvatarToolkit_OT_ApplyPoseAsRest(Operator, BatchPoseOperationMixin):
|
||||
bl_idname = 'avatar_toolkit.apply_pose_as_rest'
|
||||
bl_label = t("QuickAccess.apply_pose_as_rest.label")
|
||||
bl_description = t("QuickAccess.apply_pose_as_rest.desc")
|
||||
@@ -160,7 +162,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'}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import traceback
|
||||
import bpy
|
||||
import numpy as np
|
||||
from bpy.types import Operator, Context
|
||||
from typing import Set
|
||||
from ...core.translations import t
|
||||
from ...core.logging_setup import logger
|
||||
from ...core.common import get_active_armature, get_all_meshes, validate_armature, remove_unused_shapekeys
|
||||
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"""
|
||||
@@ -18,8 +21,8 @@ class AvatarToolkit_OT_ApplyTransforms(Operator):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
is_valid, _ = validate_armature(armature)
|
||||
return is_valid and context.mode == 'OBJECT'
|
||||
valid, _, _ = validate_armature(armature)
|
||||
return valid and context.mode == 'OBJECT'
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
@@ -41,9 +44,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):
|
||||
@@ -66,8 +69,8 @@ class AvatarToolkit_OT_CleanShapekeys(Operator):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
is_valid, _ = validate_armature(armature)
|
||||
return is_valid and context.mode == 'OBJECT' and len(get_all_meshes(context)) > 0
|
||||
valid, _, _ = validate_armature(armature)
|
||||
return valid and context.mode == 'OBJECT' and len(get_all_meshes(context)) > 0
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
@@ -85,7 +88,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'}
|
||||
|
||||
@@ -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")
|
||||
+273
-89
@@ -1,26 +1,25 @@
|
||||
import traceback
|
||||
import bpy
|
||||
import bpy_extras
|
||||
from bpy_extras import anim_utils
|
||||
import re
|
||||
from bpy.types import Operator, Context, EditBone, Object, Armature, Mesh
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
from ...core.translations import t
|
||||
from ...core.common import (
|
||||
get_active_armature,
|
||||
validate_armature,
|
||||
get_all_meshes,
|
||||
ProgressTracker,
|
||||
validate_bone_hierarchy,
|
||||
restore_bone_transforms
|
||||
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"""
|
||||
@@ -35,33 +34,17 @@ class AvatarToolKit_OT_CreateDigitigradeLegs(Operator):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
is_valid, _ = validate_armature(armature)
|
||||
return (is_valid and
|
||||
context.mode == 'EDIT_ARMATURE' and
|
||||
valid, _, _ = validate_armature(armature)
|
||||
return (valid 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 store_bone_chain_data(self, digi0: EditBone) -> Dict[str, Any]:
|
||||
"""Store initial bone chain data"""
|
||||
chain_data = {}
|
||||
current = digi0
|
||||
while current:
|
||||
chain_data[current.name] = {
|
||||
'head': current.head.copy(),
|
||||
'tail': current.tail.copy(),
|
||||
'roll': current.roll,
|
||||
'matrix': current.matrix.copy(),
|
||||
'parent': current.parent.name if current.parent else None
|
||||
}
|
||||
if current.children:
|
||||
current = current.children[0]
|
||||
else:
|
||||
break
|
||||
return chain_data
|
||||
|
||||
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]
|
||||
@@ -74,45 +57,50 @@ class AvatarToolKit_OT_CreateDigitigradeLegs(Operator):
|
||||
bpy.ops.armature.roll_clear()
|
||||
bpy.ops.armature.select_all(action='DESELECT')
|
||||
|
||||
# Create thigh bone
|
||||
thigh = duplicate_bone(digi0)
|
||||
base_name = digi0.name.split('.')[0]
|
||||
thigh.name = base_name
|
||||
|
||||
# Create and position calf bone
|
||||
prev_connect = digi1.use_connect
|
||||
digi1.use_connect = False
|
||||
calf = duplicate_bone(digi1)
|
||||
digi1.use_connect = prev_connect
|
||||
calf.name = digi1.name.split('.')[0]
|
||||
calf.parent = thigh
|
||||
calf.parent = digi0
|
||||
|
||||
# Calculate new positions
|
||||
midpoint = (digi1.tail + digi2.tail) * 0.5
|
||||
calf.head = thigh.tail
|
||||
calf.tail = midpoint
|
||||
|
||||
|
||||
end = (((digi0.tail-digi0.head)*(1/digi0.length))*(digi0.length+digi2.length) + digi0.head)
|
||||
calf.head = end
|
||||
calf.tail = (digi1.tail-digi1.head)+calf.head
|
||||
digi2.tail = calf.tail
|
||||
|
||||
# Reparent foot to new calf
|
||||
digi3.parent = calf
|
||||
|
||||
#enforce parallelagram onto midparts.
|
||||
digi1.tail = (digi0.tail)+(calf.tail-calf.head)
|
||||
|
||||
calf.name = calf.name.replace("<noik>","")
|
||||
|
||||
# Mark original bones as non-IK
|
||||
for bone in [digi0, digi1, digi2]:
|
||||
for bone in [digi1, digi2]:
|
||||
if "<noik>" not in bone.name:
|
||||
bone.name = bone.name.split('.')[0] + "<noik>"
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, t("Tools.digitigrade_error", error=str(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.active_object)
|
||||
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.active_object, data_breaking)
|
||||
self.report({'INFO'}, t("Tools.digitigrade_success"))
|
||||
return {'FINISHED'}
|
||||
|
||||
@@ -129,22 +117,18 @@ class AvatarToolKit_OT_DeleteBoneConstraints(Operator):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
is_valid, _ = validate_armature(armature)
|
||||
return is_valid
|
||||
valid, _, _ = validate_armature(armature)
|
||||
return valid
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
"""Execute the constraint removal operation"""
|
||||
|
||||
# Make sure we are in Object mode first or it will error
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
armature = get_active_armature(context)
|
||||
|
||||
# Select armature and make it active before changing mode
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
armature.select_set(True)
|
||||
context.view_layer.objects.active = armature
|
||||
data_breaking = store_breaking_settings_armature(armature)
|
||||
|
||||
context.view_layer.objects.active = armature
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
|
||||
constraints_removed = 0
|
||||
@@ -154,10 +138,10 @@ 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'}
|
||||
|
||||
|
||||
class AvatarToolKit_OT_RemoveZeroWeightBones(Operator):
|
||||
"""Operator to remove bones with no vertex weights"""
|
||||
bl_idname = "avatar_toolkit.clean_weights"
|
||||
@@ -167,19 +151,47 @@ class AvatarToolKit_OT_RemoveZeroWeightBones(Operator):
|
||||
|
||||
def should_preserve_bone(self, bone_name: str, context: Context) -> bool:
|
||||
"""Check if bone should be preserved based on settings"""
|
||||
if context.scene.avatar_toolkit.merge_twist_bones:
|
||||
return "twist" in bone_name.lower()
|
||||
toolkit = context.scene.avatar_toolkit
|
||||
bone = context.active_object.data.bones.get(bone_name)
|
||||
|
||||
if not bone:
|
||||
return False
|
||||
|
||||
if toolkit.preserve_parent_bones and bone.children:
|
||||
return True
|
||||
|
||||
if toolkit.target_bone_type == 'DEFORM' and not bone.use_deform:
|
||||
return True
|
||||
|
||||
if toolkit.target_bone_type == 'NON_DEFORM' and bone.use_deform:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def populate_bone_list(self, context: Context, zero_weight_bones: List[str]) -> None:
|
||||
"""Populate the zero weight bones list"""
|
||||
toolkit = context.scene.avatar_toolkit
|
||||
toolkit.zero_weight_bones.clear()
|
||||
|
||||
armature = get_active_armature(context)
|
||||
for bone_name in zero_weight_bones:
|
||||
bone = armature.data.bones.get(bone_name)
|
||||
if bone:
|
||||
item = toolkit.zero_weight_bones.add()
|
||||
item.name = bone_name
|
||||
item.has_children = len(bone.children) > 0
|
||||
item.is_deform = bone.use_deform
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
"""Execute the zero weight bone removal operation"""
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return {'CANCELLED'}
|
||||
|
||||
# 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(),
|
||||
@@ -189,49 +201,221 @@ class AvatarToolKit_OT_RemoveZeroWeightBones(Operator):
|
||||
'parent': bone.parent.name if bone.parent else None
|
||||
}
|
||||
|
||||
# Get weighted bones
|
||||
# Get bones with any weight
|
||||
weighted_bones: List[str] = []
|
||||
meshes = get_all_meshes(context)
|
||||
|
||||
for mesh in meshes:
|
||||
mesh_data: Mesh = mesh.data
|
||||
for vertex in mesh_data.vertices:
|
||||
for vertex in mesh.data.vertices:
|
||||
for group in vertex.groups:
|
||||
if group.weight > context.scene.avatar_toolkit.merge_weights_threshold:
|
||||
weighted_bones.append(mesh.vertex_groups[group.group].name)
|
||||
vg = mesh.vertex_groups[group.group]
|
||||
if vg.name not in weighted_bones:
|
||||
weighted_bones.append(vg.name)
|
||||
|
||||
# Process bone removal
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
armature_data: Armature = armature.data
|
||||
armature_data = armature.data
|
||||
removed_count = 0
|
||||
zero_weight_bones: List[str] = []
|
||||
|
||||
for bone in armature_data.edit_bones[:]: # Create a copy of the list
|
||||
if (bone.name not in weighted_bones and
|
||||
not self.should_preserve_bone(bone.name, context)):
|
||||
def is_zero_weight_chain(bone, weighted_bones, preserve_check_fn):
|
||||
if bone.name in weighted_bones or preserve_check_fn(bone.name, context):
|
||||
return False
|
||||
return all(is_zero_weight_chain(child, weighted_bones, preserve_check_fn) for child in bone.children)
|
||||
|
||||
# Store children data
|
||||
children = bone.children
|
||||
children_data = {child.name: initial_transforms[child.name] for child in children}
|
||||
for bone in armature_data.edit_bones[:]:
|
||||
if bone.name in weighted_bones or self.should_preserve_bone(bone.name, context):
|
||||
continue
|
||||
|
||||
# Reparent children
|
||||
for child in children:
|
||||
if not is_zero_weight_chain(bone, weighted_bones, self.should_preserve_bone):
|
||||
continue
|
||||
|
||||
if context.scene.avatar_toolkit.list_only_mode:
|
||||
zero_weight_bones.append(bone.name)
|
||||
continue
|
||||
|
||||
# Traverse and collect the full empty chain
|
||||
stack = [bone]
|
||||
chain = []
|
||||
|
||||
while stack:
|
||||
b = stack.pop()
|
||||
chain.append(b)
|
||||
stack.extend(b.children)
|
||||
|
||||
for b in reversed(chain): # Remove children before parents
|
||||
for child in b.children:
|
||||
child.use_connect = False
|
||||
if bone.parent:
|
||||
child.parent = bone.parent
|
||||
|
||||
# Remove bone
|
||||
armature_data.edit_bones.remove(bone)
|
||||
if b.parent:
|
||||
child.parent = b.parent
|
||||
if b.name in armature_data.edit_bones:
|
||||
armature_data.edit_bones.remove(b)
|
||||
removed_count += 1
|
||||
|
||||
# Restore children positions
|
||||
for child_name, data in children_data.items():
|
||||
if child_name in armature_data.edit_bones:
|
||||
child = armature_data.edit_bones[child_name]
|
||||
child.head = data['head']
|
||||
child.tail = data['tail']
|
||||
child.roll = data['roll']
|
||||
child.matrix = data['matrix']
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
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'}
|
||||
|
||||
class AvatarToolKit_OT_RemoveZeroWeightVertexGroups(Operator):
|
||||
"""Operator to remove vertex groups with no weights"""
|
||||
bl_idname = "avatar_toolkit.clean_vertex_groups"
|
||||
bl_label = t("Tools.clean_vertex_groups")
|
||||
bl_description = t("Tools.clean_vertex_groups_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
meshes: list[bpy.types.Object] = get_all_meshes(context)
|
||||
removed: int = 0
|
||||
for mesh_obj in meshes:
|
||||
removed = removed+remove_unused_vertex_groups(mesh_obj)
|
||||
|
||||
self.report({'INFO'}, t("Tools.vertex_groups_removed", count=removed))
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class AvatarToolKit_OT_RemoveSelectedBones(Operator):
|
||||
"""Operator to remove selected bones from the zero weight bones list"""
|
||||
bl_idname = "avatar_toolkit.remove_selected_bones"
|
||||
bl_label = t("Tools.remove_selected_bones")
|
||||
bl_description = t("Tools.remove_selected_bones_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
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
|
||||
if item.selected]
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
for bone_name in selected_bones:
|
||||
if bone_name in armature.data.edit_bones:
|
||||
armature.data.edit_bones.remove(armature.data.edit_bones[bone_name])
|
||||
|
||||
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'}
|
||||
|
||||
|
||||
class AvatarToolKit_OT_FlipCurrentKeyFrames(Operator):
|
||||
"""Operator to flip the selected bone keyframes using blender's flip pose."""
|
||||
bl_idname = "avatar_toolkit.flip_pose_frames"
|
||||
bl_label = t("Tools.flip_pose_frames")
|
||||
bl_description = t("Tools.flip_pose_frames_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
"""Check if operator can be executed"""
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
if context.mode != 'POSE':
|
||||
return False
|
||||
if not armature.animation_data:
|
||||
return False
|
||||
valid, _, _ = validate_armature(armature)
|
||||
return valid
|
||||
|
||||
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
|
||||
|
||||
standard_mappings: Dict[str,str] = identify_bones(armature_data)
|
||||
|
||||
|
||||
|
||||
|
||||
# Do we need this? If flipping in the future has issues, then uncommenting this may help - @989onan
|
||||
#To make sure our flip pose is extremely reliable, we're gonna temp rename all bones to standard names to make the posing work.
|
||||
#for standard,bone_name in standard_mappings.items():
|
||||
# armature_data.bones[bone_name].name = standard
|
||||
|
||||
#save our selection
|
||||
selected: list[bool] = [False] * len(armature_data.bones)
|
||||
armature_data.bones.foreach_get("select", selected)
|
||||
#select everything
|
||||
armature_data.bones.foreach_set("select", [False] * len(armature_data.bones))
|
||||
|
||||
|
||||
# Get channelbag for the action using Blender 5.0 API
|
||||
action = armature.animation_data.action
|
||||
if not action.slots:
|
||||
slot = action.slots.new(for_id=armature.data)
|
||||
else:
|
||||
slot = action.slots[0]
|
||||
channelbag = anim_utils.action_ensure_channelbag_for_slot(action, slot)
|
||||
|
||||
#create a set for every frame time where we need to key a keyframe for the flipped pose
|
||||
times: Dict[float,list[bpy.types.FCurve]] = {}
|
||||
for curve in channelbag.fcurves:
|
||||
if not curve.data_path.startswith("pose"):
|
||||
continue
|
||||
for point in curve.keyframe_points:
|
||||
if point.select_control_point:
|
||||
if point.co.x not in times:
|
||||
times[point.co.x] = []
|
||||
|
||||
times[point.co.x].append(curve)
|
||||
|
||||
for time,curves in times.items():
|
||||
context.scene.frame_set(frame=int(time), subframe=float(time-float(int(time))))
|
||||
armature_data.bones.foreach_set("select", [True] * len(armature_data.bones))
|
||||
bpy.ops.pose.copy()
|
||||
armature_data.bones.foreach_set("select", [False] * len(armature_data.bones))
|
||||
bpy.ops.pose.paste(flipped=True,selected_mask=False)
|
||||
|
||||
|
||||
|
||||
|
||||
for curve in curves:
|
||||
|
||||
bone_name: str = curve.data_path.replace("pose.bones[\"","")
|
||||
bone_name = bone_name[:bone_name.index("\"")]
|
||||
|
||||
armature_data.bones[bone_name].select = True
|
||||
|
||||
bpy.ops.pose.select_mirror(extend=False)
|
||||
|
||||
#this can get the opposite side bone's data path and key it, if it is ever needed - @989onan
|
||||
#for bone in armature_data.bones:
|
||||
# if bone.select == True:
|
||||
# bone_name = bone.name
|
||||
# break
|
||||
#new_path = curve.data_path[:curve.data_path.index("[")+1]+"\""+bone_name+"\""+curve.data_path[curve.data_path.index("]"):]
|
||||
|
||||
if armature.keyframe_insert(data_path=curve.data_path, index=curve.array_index, frame=time):
|
||||
#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'}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Do we need this? If flipping in the future has issues, then uncommenting this may help - @989onan
|
||||
#bring our names back as to not break their model.
|
||||
#for standard,bone_name in standard_mappings.items():
|
||||
# armature_data.bones[standard].name = bone_name
|
||||
|
||||
# restore selection
|
||||
armature_data.bones.foreach_set("select", selected)
|
||||
restore_breaking_settings_armature(armature, data_breaking)
|
||||
return {'FINISHED'}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import bpy
|
||||
from bpy.types import Operator
|
||||
from ...core.logging_setup import logger
|
||||
|
||||
|
||||
class AvatarToolkit_OT_RemoveAllColliders(Operator):
|
||||
"""Remove all objects with 'collider' in their name"""
|
||||
bl_idname = "avatar_toolkit.remove_all_colliders"
|
||||
bl_label = "Remove All Colliders"
|
||||
bl_description = "Remove all objects that have 'collider' in their name"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context):
|
||||
logger.info("Starting standalone collider removal")
|
||||
|
||||
# Store current mode and active object
|
||||
current_mode = bpy.context.mode
|
||||
original_active = bpy.context.view_layer.objects.active
|
||||
|
||||
# Switch to object mode
|
||||
if current_mode != 'OBJECT':
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
try:
|
||||
# Find all collider objects
|
||||
collider_names = []
|
||||
all_objects = list(bpy.data.objects)
|
||||
|
||||
logger.info(f"Scanning {len(all_objects)} objects for colliders")
|
||||
|
||||
for obj in all_objects:
|
||||
if 'collider' in obj.name.lower():
|
||||
collider_names.append(obj.name)
|
||||
logger.info(f"Found collider: {obj.name}")
|
||||
|
||||
if not collider_names:
|
||||
self.report({'INFO'}, "No collider objects found")
|
||||
logger.info("No collider objects found")
|
||||
return {'FINISHED'}
|
||||
|
||||
logger.info(f"Found {len(collider_names)} collider objects to remove")
|
||||
self.report({'INFO'}, f"Found {len(collider_names)} collider objects")
|
||||
|
||||
# Remove each collider
|
||||
removed_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for obj_name in collider_names:
|
||||
try:
|
||||
if obj_name in bpy.data.objects:
|
||||
obj = bpy.data.objects[obj_name]
|
||||
|
||||
# Deselect all objects first
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
# Select and make active
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
|
||||
# Delete the object
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
|
||||
removed_count += 1
|
||||
logger.info(f"Removed collider: {obj_name}")
|
||||
|
||||
else:
|
||||
logger.debug(f"Object {obj_name} no longer exists")
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logger.error(f"Failed to remove {obj_name}: {str(e)}")
|
||||
self.report({'WARNING'}, f"Failed to remove {obj_name}: {str(e)}")
|
||||
|
||||
# Report results
|
||||
if removed_count > 0:
|
||||
success_msg = f"Successfully removed {removed_count} collider objects"
|
||||
logger.info(success_msg)
|
||||
self.report({'INFO'}, success_msg)
|
||||
|
||||
if failed_count > 0:
|
||||
failure_msg = f"Failed to remove {failed_count} collider objects"
|
||||
logger.warning(failure_msg)
|
||||
self.report({'WARNING'}, failure_msg)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error during collider removal: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
self.report({'ERROR'}, error_msg)
|
||||
return {'CANCELLED'}
|
||||
|
||||
finally:
|
||||
# Restore original state
|
||||
try:
|
||||
if original_active and original_active.name in bpy.data.objects:
|
||||
bpy.context.view_layer.objects.active = original_active
|
||||
|
||||
if current_mode != 'OBJECT':
|
||||
bpy.ops.object.mode_set(mode=current_mode)
|
||||
except:
|
||||
pass
|
||||
|
||||
return {'FINISHED'}
|
||||
@@ -1,89 +0,0 @@
|
||||
import bpy
|
||||
import re
|
||||
from typing import Set, Dict, Optional
|
||||
from bpy.types import Operator, Context
|
||||
from ...core.translations import t
|
||||
from ...core.logging_setup import logger
|
||||
from ...core.common import get_active_armature, simplify_bonename, validate_armature, ProgressTracker
|
||||
from ...core.dictionaries import bone_names, resonite_translations
|
||||
|
||||
class AvatarToolkit_OT_ConvertResonite(Operator):
|
||||
"""Convert armature bone names to Resonite format with progress tracking and validation"""
|
||||
bl_idname = "avatar_toolkit.convert_resonite"
|
||||
bl_label = t("Tools.convert_resonite")
|
||||
bl_description = t("Tools.convert_resonite_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
is_valid, _ = validate_armature(armature)
|
||||
return is_valid
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
logger.warning("No armature selected for Resonite conversion")
|
||||
self.report({'WARNING'}, t("Armature.validation.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
translate_bone_fails: int = 0
|
||||
untranslated_bones: Set[str] = set()
|
||||
simplified_names: Dict[str, str] = {}
|
||||
|
||||
# Create reverse lookup dictionary
|
||||
reverse_bone_lookup = {}
|
||||
for preferred_name, name_list in bone_names.items():
|
||||
for name in name_list:
|
||||
reverse_bone_lookup[name] = preferred_name
|
||||
|
||||
try:
|
||||
context.view_layer.objects.active = armature
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Cache simplified bone names
|
||||
for bone in armature.data.bones:
|
||||
simplified_names[bone.name] = simplify_bonename(bone.name)
|
||||
|
||||
total_bones = len(armature.data.bones)
|
||||
with ProgressTracker(context, total_bones, t("Tools.convert_resonite.operation")) as progress:
|
||||
for bone in armature.data.bones:
|
||||
# Remove any existing "<noik>" tags
|
||||
bone.name = re.compile(re.escape("<noik>"), re.IGNORECASE).sub("", bone.name)
|
||||
simplified_name = simplified_names[bone.name]
|
||||
|
||||
if simplified_name in reverse_bone_lookup and reverse_bone_lookup[simplified_name] in resonite_translations:
|
||||
new_name = resonite_translations[reverse_bone_lookup[simplified_name]]
|
||||
logger.debug(f"Translating bone: {bone.name} -> {new_name}")
|
||||
bone.name = new_name
|
||||
else:
|
||||
untranslated_bones.add(bone.name)
|
||||
bone.name = bone.name + "<noik>"
|
||||
translate_bone_fails += 1
|
||||
logger.debug(f"Failed to translate bone: {bone.name}")
|
||||
|
||||
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))
|
||||
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)}")
|
||||
|
||||
if translate_bone_fails > 0:
|
||||
logger.info(f"Conversion completed with {translate_bone_fails} untranslated bones")
|
||||
logger.debug(f"Untranslated bones: {untranslated_bones}")
|
||||
self.report({'INFO'}, t("Tools.bones_translated_with_fails", translate_bone_fails=translate_bone_fails))
|
||||
else:
|
||||
logger.info("All bones translated successfully")
|
||||
self.report({'INFO'}, t("Tools.bones_translated_success"))
|
||||
|
||||
return {'FINISHED'}
|
||||
@@ -0,0 +1,196 @@
|
||||
import bpy
|
||||
import numpy as np
|
||||
from bpy.types import Operator, Context
|
||||
from typing import Set, Literal
|
||||
from ...core.translations import t
|
||||
from ...core.logging_setup import logger
|
||||
from ...core.common import get_active_armature, get_all_meshes
|
||||
from ...core.armature_validation import validate_armature
|
||||
|
||||
import bmesh
|
||||
|
||||
|
||||
class MapItem():
|
||||
length: int
|
||||
current_node: bmesh.types.BMVert
|
||||
marched_paths: list[bmesh.types.BMEdge]
|
||||
|
||||
class AvatarToolkit_OT_SelectShortestSeamPath(Operator):
|
||||
"""Find the shortest seam path between two vertices."""
|
||||
bl_idname = "avatar_toolkit.find_shortest_seam_path"
|
||||
bl_label = t("Tools.find_shortest_seam_path")
|
||||
bl_description = t("Tools.find_shortest_seam_path_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
if context.mode != "EDIT_MESH":
|
||||
return False
|
||||
mesh_data: bpy.types.Mesh = context.active_object.data
|
||||
mesh = bmesh.from_edit_mesh(mesh_data)
|
||||
selected: int = 0
|
||||
for vert in mesh.verts:
|
||||
if vert.select == True:
|
||||
selected = selected+1
|
||||
if selected > 2:
|
||||
return False
|
||||
found_seam: bool = False
|
||||
for edge in vert.link_edges:
|
||||
if edge.seam:
|
||||
found_seam = True
|
||||
if not found_seam:
|
||||
return False
|
||||
if selected < 2:
|
||||
return False
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
valid, _, _ = validate_armature(armature)
|
||||
return valid
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
mesh_data: bpy.types.Mesh = context.active_object.data
|
||||
mesh = bmesh.from_edit_mesh(mesh_data)
|
||||
vert1: bmesh.types.BMVert = None
|
||||
vert2: bmesh.types.BMVert = None
|
||||
for vert in mesh.verts:
|
||||
if vert.select == True:
|
||||
if vert1 == None:
|
||||
vert1 = vert
|
||||
else:
|
||||
vert2 = vert
|
||||
|
||||
current_verts: list[MapItem] = []
|
||||
|
||||
first_item: MapItem = MapItem()
|
||||
first_item.current_node = vert1
|
||||
first_item.length = 0
|
||||
first_item.marched_paths = []
|
||||
current_verts.append(first_item)
|
||||
|
||||
def find_next_edge() -> list[bmesh.types.BMEdge]:
|
||||
if len(current_verts) == 0: #all paths have been exausted.
|
||||
return []
|
||||
for mapeditem in current_verts:
|
||||
current_verts.remove(mapeditem)
|
||||
for edge in mapeditem.current_node.link_edges:
|
||||
if edge.seam and (edge not in mapeditem.marched_paths):
|
||||
for vert_new in edge.verts:
|
||||
if vert_new != mapeditem.current_node:
|
||||
if vert_new == vert2:
|
||||
mapeditem.marched_paths.append(edge)
|
||||
return mapeditem.marched_paths
|
||||
first_item: MapItem = MapItem()
|
||||
first_item.current_node = vert_new
|
||||
first_item.length = mapeditem.length+1
|
||||
first_item.marched_paths = []
|
||||
first_item.marched_paths.extend(mapeditem.marched_paths)
|
||||
first_item.marched_paths.append(edge)
|
||||
current_verts.append(first_item)
|
||||
return find_next_edge()
|
||||
|
||||
mesh.select_flush(False)
|
||||
path: list[bmesh.types.BMEdge] = find_next_edge()
|
||||
for edge in path:
|
||||
edge.select = True
|
||||
for vert in edge.verts:
|
||||
vert.select = True
|
||||
bpy.ops.mesh.select_mode(type='EDGE')
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
class AvatarToolkit_OT_ExplodeMesh(Operator):
|
||||
"""Explodes the mesh for use with painting programs, or painting inside blender."""
|
||||
bl_idname = "avatar_toolkit.explode_mesh"
|
||||
bl_label = t("Tools.explode_mesh")
|
||||
bl_description = t("Tools.explode_mesh_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
distance: bpy.props.FloatProperty(default=2.0,name=t("Tools.explode_mesh.distance"),description=t("Tools.explode_mesh.distance_desc"))
|
||||
split_on_seams: bpy.props.BoolProperty(default=True,name=t("Tools.explode_mesh.split_on_seams"),description=t("Tools.explode_mesh.split_on_seams_desc"))
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
"""Draw the operator's UI"""
|
||||
layout = self.layout
|
||||
layout.prop(self, "distance")
|
||||
|
||||
def invoke(self, context: Context, event: bpy.types.Event) -> set[str]:
|
||||
"""Initialize the operator"""
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
active_obj = context.view_layer.objects.active
|
||||
return (active_obj is not None and
|
||||
active_obj.type == "MESH" and
|
||||
len(context.view_layer.objects.selected) == 1)
|
||||
|
||||
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
|
||||
mesh_obj: bpy.types.Object = context.view_layer.objects.active.type
|
||||
mesh: bpy.types.Mesh = context.view_layer.objects.active.data
|
||||
if(self.split_on_seams):
|
||||
|
||||
#set to correct mode
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_mode(type='EDGE')
|
||||
|
||||
#mark seams by islands
|
||||
bpy.ops.mesh.select_all(action="SELECT")
|
||||
bpy.ops.uv.select_all(action="SELECT")
|
||||
bpy.ops.uv.seams_from_islands(mark_seams=True,mark_sharp=False)
|
||||
|
||||
#clear selection
|
||||
bpy.ops.mesh.select_all(action="DESELECT")
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bm = bmesh.new() # create an empty BMesh
|
||||
bm.from_mesh(mesh) # fill it in from active mesh
|
||||
|
||||
#select seam edges
|
||||
for idx,edge in enumerate(bm.edges):
|
||||
edge.select = edge.seam
|
||||
bm.to_mesh(mesh)
|
||||
bm.free()
|
||||
|
||||
#split edges.
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.edge_split()
|
||||
|
||||
#separate by loose.
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_mode(type='FACE')
|
||||
|
||||
bpy.ops.mesh.select_all(action="SELECT")
|
||||
|
||||
bpy.ops.mesh.separate(type='LOOSE')
|
||||
|
||||
|
||||
distance: float = self.distance
|
||||
|
||||
|
||||
#set origins to geometry
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.origin_set(type="ORIGIN_GEOMETRY",center="BOUNDS")
|
||||
|
||||
#store original settings
|
||||
origin_only_orig: bool = context.scene.tool_settings.use_transform_data_origin
|
||||
pos_only_orig: bool = context.scene.tool_settings.use_transform_pivot_point_align
|
||||
parents_only_orig: bool = context.scene.tool_settings.use_transform_skip_children
|
||||
original_pivot: Literal['BOUNDING_BOX_CENTER', 'CURSOR', 'INDIVIDUAL_ORIGINS', 'MEDIAN_POINT', 'ACTIVE_ELEMENT'] = context.scene.tool_settings.transform_pivot_point
|
||||
|
||||
#set scene settings correctly.
|
||||
context.scene.tool_settings.use_transform_data_origin = False
|
||||
context.scene.tool_settings.use_transform_pivot_point_align = True
|
||||
context.scene.tool_settings.use_transform_skip_children = False
|
||||
context.scene.tool_settings.transform_pivot_point = 'MEDIAN_POINT'
|
||||
|
||||
#spread out separated objects
|
||||
bpy.ops.transform.resize(value=(self.distance, self.distance, self.distance), orient_type='GLOBAL')
|
||||
|
||||
#restore settings.
|
||||
context.scene.tool_settings.use_transform_data_origin = origin_only_orig
|
||||
context.scene.tool_settings.use_transform_pivot_point_align = pos_only_orig
|
||||
context.scene.tool_settings.use_transform_skip_children = parents_only_orig
|
||||
context.scene.tool_settings.transform_pivot_point = original_pivot
|
||||
return {'FINISHED'}
|
||||
@@ -1,10 +1,13 @@
|
||||
import traceback
|
||||
import bpy
|
||||
import math
|
||||
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, validate_armature
|
||||
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"""
|
||||
@@ -18,12 +21,16 @@ class AvatarToolkit_OT_ConnectBones(Operator):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
is_valid, _ = validate_armature(armature)
|
||||
return is_valid
|
||||
valid, _, _ = validate_armature(armature)
|
||||
return valid
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
armature = get_active_armature(context)
|
||||
data_breaking = store_breaking_settings_armature(armature)
|
||||
try:
|
||||
|
||||
|
||||
|
||||
logger.info("Starting bone connection operation")
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
@@ -46,12 +53,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):
|
||||
@@ -66,11 +75,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]:
|
||||
try:
|
||||
armature = get_active_armature(context)
|
||||
data_breaking = store_breaking_settings_armature(armature)
|
||||
|
||||
try:
|
||||
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]
|
||||
|
||||
@@ -101,11 +114,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):
|
||||
@@ -120,11 +135,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]:
|
||||
try:
|
||||
armature = get_active_armature(context)
|
||||
data_breaking = store_breaking_settings_armature(armature)
|
||||
try:
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
selected_bones = [b for b in context.selected_editable_bones if b.parent]
|
||||
|
||||
if not selected_bones:
|
||||
@@ -152,10 +169,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'}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import traceback
|
||||
import bpy
|
||||
from bpy.types import Operator, Context
|
||||
from ...core.translations import t
|
||||
from ...core.common import get_active_armature, validate_armature
|
||||
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"""
|
||||
@@ -16,10 +19,10 @@ class AvatarToolKit_OT_SeparateByMaterials(Operator):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
is_valid, _ = validate_armature(armature)
|
||||
valid, _, _ = validate_armature(armature)
|
||||
return (context.active_object and
|
||||
context.active_object.type == 'MESH' and
|
||||
is_valid)
|
||||
valid)
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
"""Execute the separation operation"""
|
||||
@@ -31,8 +34,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):
|
||||
@@ -48,10 +51,10 @@ class AvatarToolKit_OT_SeparateByLooseParts(Operator):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
is_valid, _ = validate_armature(armature)
|
||||
valid, _, _ = validate_armature(armature)
|
||||
return (context.active_object and
|
||||
context.active_object.type == 'MESH' and
|
||||
is_valid)
|
||||
valid)
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
"""Execute the separation operation"""
|
||||
@@ -63,6 +66,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'}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import traceback
|
||||
import bpy
|
||||
from typing import Dict, List, Set, Optional, Tuple, Any
|
||||
from bpy.types import Operator, Context, Object, PoseBone, EditBone, Bone, Constraint
|
||||
from ...core.common import get_active_armature, transfer_vertex_weights, get_all_meshes
|
||||
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"""
|
||||
bl_idname = "avatar_toolkit.convert_rigify_to_unity"
|
||||
bl_label = t("Tools.convert_rigify_to_unity")
|
||||
bl_description = t("Tools.convert_rigify_to_unity_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
return ("DEF-spine" in armature.data.bones or
|
||||
"spine" in armature.data.bones and "metarig" in armature.name.lower())
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
logger.info("Starting Rigify to Unity conversion")
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
logger.error("No armature found")
|
||||
self.report({'ERROR'}, t("Tools.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
logger.debug(f"Converting armature: {armature.name}")
|
||||
armature.name = "Armature"
|
||||
armature.data.name = "Armature"
|
||||
logger.debug("Renamed armature to 'Armature'")
|
||||
|
||||
if "DEF-spine" in armature.data.bones:
|
||||
logger.info("Processing DEF bones")
|
||||
self.move_def_bones(armature)
|
||||
self.rename_bones_for_unity(armature)
|
||||
else:
|
||||
logger.info("Processing basic bones")
|
||||
self.cleanup_extra_bones(armature)
|
||||
self.rename_basic_bones_for_unity(armature)
|
||||
|
||||
logger.debug("Cleaning up bone collections")
|
||||
self.cleanup_bone_collections(armature)
|
||||
|
||||
if context.scene.avatar_toolkit.merge_twist_bones:
|
||||
logger.info("Merging twist bones")
|
||||
self.handle_twist_bones(armature)
|
||||
|
||||
logger.info("Successfully converted Rigify armature to Unity format")
|
||||
self.report({'INFO'}, t("Tools.rigify_converted"))
|
||||
return {'FINISHED'}
|
||||
|
||||
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:
|
||||
"""Remove unnecessary bones and merge neck bones"""
|
||||
logger.debug("Starting cleanup of extra bones")
|
||||
|
||||
# Set armature as active object before mode switch
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
|
||||
# Get all meshes for weight transfer
|
||||
meshes = get_all_meshes(bpy.context)
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
bones_to_remove: List[str] = []
|
||||
for bone in armature.data.edit_bones:
|
||||
bone_name_lower = bone.name.lower()
|
||||
if any(bone_name_lower.startswith(pattern) or bone_name_lower == pattern
|
||||
for pattern in rigify_unnecessary_bones):
|
||||
bones_to_remove.append(bone.name)
|
||||
|
||||
# Check for neck bones that need merging
|
||||
merge_neck_bones = 'spine.004' in armature.data.edit_bones and 'spine.005' in armature.data.edit_bones
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Transfer weights from bones being removed
|
||||
for bone_name in bones_to_remove:
|
||||
if bone_name in armature.data.bones:
|
||||
logger.debug(f"Transferring weights from bone: {bone_name}")
|
||||
for mesh in meshes:
|
||||
if bone_name in mesh.vertex_groups:
|
||||
# Remove the vertex group since we don't need the weights
|
||||
mesh.vertex_groups.remove(mesh.vertex_groups[bone_name])
|
||||
|
||||
# Transfer weights for neck bone merging
|
||||
if merge_neck_bones:
|
||||
logger.debug("Transferring weights from spine.005 to spine.004")
|
||||
for mesh in meshes:
|
||||
if 'spine.005' in mesh.vertex_groups:
|
||||
transfer_vertex_weights(mesh, 'spine.005', 'spine.004')
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
# Remove unnecessary bones
|
||||
for bone_name in bones_to_remove:
|
||||
if bone_name in armature.data.edit_bones:
|
||||
logger.debug(f"Removing bone: {bone_name}")
|
||||
armature.data.edit_bones.remove(armature.data.edit_bones[bone_name])
|
||||
|
||||
# Merge neck bones
|
||||
if merge_neck_bones:
|
||||
logger.debug("Merging neck bones")
|
||||
neck_start = armature.data.edit_bones['spine.004']
|
||||
neck_end = armature.data.edit_bones['spine.005']
|
||||
neck_start.tail = neck_end.tail
|
||||
armature.data.edit_bones.remove(neck_end)
|
||||
neck_start.name = "Neck"
|
||||
|
||||
# Rename head bone
|
||||
if 'spine.006' in armature.data.edit_bones:
|
||||
logger.debug("Renaming head bone")
|
||||
head_bone = armature.data.edit_bones['spine.006']
|
||||
head_bone.name = "Head"
|
||||
|
||||
def move_def_bones(self, armature: Object) -> None:
|
||||
"""Move DEF bones to their correct positions"""
|
||||
logger.debug("Moving DEF bones to correct positions")
|
||||
|
||||
# Set armature as active object
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
remap: Dict[str, str] = self.get_org_remap(armature)
|
||||
remap.update(self.get_special_remap())
|
||||
|
||||
remove_bones_in_chain: List[str] = [
|
||||
'DEF-upper_arm.L.001', 'DEF-forearm.L.001',
|
||||
'DEF-upper_arm.R.001', 'DEF-forearm.R.001',
|
||||
'DEF-thigh.L.001', 'DEF-shin.L.001',
|
||||
'DEF-thigh.R.001', 'DEF-shin.R.001'
|
||||
]
|
||||
|
||||
transform_copies: List[str] = self.get_transform_copies(armature)
|
||||
|
||||
logger.debug("Setting up transform copies")
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
for bone_name in transform_copies:
|
||||
bone = armature.pose.bones[bone_name]
|
||||
org_name = 'ORG-' + self.get_proto_name(bone_name)
|
||||
if org_name in armature.pose.bones:
|
||||
constraint = bone.constraints.new('COPY_TRANSFORMS')
|
||||
constraint.target = armature
|
||||
constraint.subtarget = org_name
|
||||
constr_count = len(bone.constraints)
|
||||
if constr_count > 1:
|
||||
bone.constraints.move(constr_count-1, 0)
|
||||
|
||||
logger.debug("Remapping bone parents")
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
for remap_key in remap:
|
||||
if remap_key in armature.data.edit_bones and remap[remap_key] in armature.data.edit_bones:
|
||||
armature.data.edit_bones[remap_key].parent = armature.data.edit_bones[remap[remap_key]]
|
||||
|
||||
logger.debug("Processing bone chain removal")
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
for bone_name in remove_bones_in_chain:
|
||||
if bone_name in armature.data.bones:
|
||||
armature.data.bones[bone_name].use_deform = False
|
||||
|
||||
# Get all meshes for weight transfer
|
||||
meshes = get_all_meshes(bpy.context)
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
for bone_name in remove_bones_in_chain:
|
||||
if bone_name in armature.data.bones:
|
||||
parent_name = armature.data.bones[bone_name].parent.name if armature.data.bones[bone_name].parent else None
|
||||
if parent_name:
|
||||
logger.debug(f"Transferring weights from {bone_name} to {parent_name}")
|
||||
for mesh in meshes:
|
||||
if bone_name in mesh.vertex_groups and parent_name in mesh.vertex_groups:
|
||||
transfer_vertex_weights(mesh, bone_name, parent_name)
|
||||
elif bone_name in mesh.vertex_groups:
|
||||
# Remove weights if no parent to merge to
|
||||
mesh.vertex_groups.remove(mesh.vertex_groups[bone_name])
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
for bone_name in remove_bones_in_chain:
|
||||
if bone_name in armature.data.bones:
|
||||
remove_bone = armature.data.edit_bones[bone_name]
|
||||
parent_bone = remove_bone.parent
|
||||
parent_bone.tail = remove_bone.tail
|
||||
retarget_bones = list(remove_bone.children)
|
||||
for bone in retarget_bones:
|
||||
bone.parent = parent_bone
|
||||
armature.data.edit_bones.remove(remove_bone)
|
||||
|
||||
def rename_bones_for_unity(self, armature: Object) -> None:
|
||||
"""Rename bones to Unity-compatible names"""
|
||||
logger.debug("Renaming bones to Unity format")
|
||||
for old_name, new_name in rigify_unity_names.items():
|
||||
bone = armature.pose.bones.get(old_name)
|
||||
if bone:
|
||||
logger.debug(f"Renaming bone: {old_name} -> {new_name}")
|
||||
bone.name = new_name
|
||||
|
||||
def rename_basic_bones_for_unity(self, armature: Object) -> None:
|
||||
"""Rename basic metarig bones to Unity-compatible names"""
|
||||
logger.debug("Renaming basic metarig bones")
|
||||
for old_name, new_name in rigify_basic_unity_names.items():
|
||||
bone = armature.pose.bones.get(old_name)
|
||||
if bone:
|
||||
logger.debug(f"Renaming basic bone: {old_name} -> {new_name}")
|
||||
bone.name = new_name
|
||||
|
||||
def cleanup_bone_collections(self, armature: Object) -> None:
|
||||
"""Remove all bone collections since they're not needed for Unity"""
|
||||
logger.debug("Cleaning up bone collections")
|
||||
if hasattr(armature.data, 'collections') and armature.data.collections:
|
||||
while len(armature.data.collections) > 0:
|
||||
collection = armature.data.collections[0]
|
||||
armature.data.collections.remove(collection)
|
||||
|
||||
while len(armature.data.collections) > 1:
|
||||
collection = armature.data.collections[1]
|
||||
armature.data.collections.remove(collection)
|
||||
|
||||
def handle_twist_bones(self, armature: Object) -> None:
|
||||
"""Handle twist bones during conversion"""
|
||||
logger.debug("Processing twist bones")
|
||||
twist_bones: List[Tuple[str, str]] = [
|
||||
("DEF-upper_arm_twist.L", "DEF-upper_arm.L"),
|
||||
("DEF-upper_arm_twist.R", "DEF-upper_arm.R"),
|
||||
("DEF-forearm_twist.L", "DEF-forearm.L"),
|
||||
("DEF-forearm_twist.R", "DEF-forearm.R"),
|
||||
("DEF-thigh_twist.L", "DEF-thigh.L"),
|
||||
("DEF-thigh_twist.R", "DEF-thigh.R")
|
||||
]
|
||||
|
||||
# Get all meshes for weight transfer
|
||||
meshes = get_all_meshes(bpy.context)
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
for twist_bone, parent_bone in twist_bones:
|
||||
if twist_bone in armature.data.bones and parent_bone in armature.data.bones:
|
||||
logger.debug(f"Transferring weights from {twist_bone} to {parent_bone}")
|
||||
for mesh in meshes:
|
||||
if twist_bone in mesh.vertex_groups:
|
||||
transfer_vertex_weights(mesh, twist_bone, parent_bone)
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
for twist_bone, parent_bone in twist_bones:
|
||||
if twist_bone in armature.data.edit_bones and parent_bone in armature.data.edit_bones:
|
||||
logger.debug(f"Merging twist bone: {twist_bone} into {parent_bone}")
|
||||
twist = armature.data.edit_bones[twist_bone]
|
||||
parent = armature.data.edit_bones[parent_bone]
|
||||
parent.tail = twist.tail
|
||||
for child in twist.children:
|
||||
child.parent = parent
|
||||
armature.data.edit_bones.remove(twist)
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
def get_org_remap(self, armature: Object) -> Dict[str, str]:
|
||||
"""Get original bone remapping"""
|
||||
logger.debug("Getting original bone remapping")
|
||||
remap: Dict[str, str] = {}
|
||||
for bone in armature.data.bones:
|
||||
if self.is_def_bone(bone.name):
|
||||
name = self.get_proto_name(bone.name)
|
||||
parent = bone.parent
|
||||
while parent:
|
||||
parent_name = self.get_proto_name(parent.name)
|
||||
if parent_name != name:
|
||||
if ('DEF-' + parent_name) in armature.data.bones:
|
||||
remap[bone.name] = 'DEF-' + parent_name
|
||||
break
|
||||
parent = parent.parent
|
||||
return remap
|
||||
|
||||
def get_special_remap(self) -> Dict[str, str]:
|
||||
"""Get special bone remapping cases"""
|
||||
logger.debug("Getting special bone remapping")
|
||||
return {
|
||||
'DEF-thigh.L': 'DEF-pelvis.L',
|
||||
'DEF-thigh.R': 'DEF-pelvis.R',
|
||||
'DEF-upper_arm.L': 'DEF-shoulder.L',
|
||||
'DEF-upper_arm.R': 'DEF-shoulder.R',
|
||||
}
|
||||
|
||||
def get_transform_copies(self, armature: Object) -> List[str]:
|
||||
"""Get bones that need transform copies"""
|
||||
logger.debug("Getting transform copy bones")
|
||||
result: List[str] = []
|
||||
for bone in armature.pose.bones:
|
||||
if self.is_def_bone(bone.name) and not self.has_transform_copies(bone):
|
||||
result.append(bone.name)
|
||||
return result
|
||||
|
||||
def has_transform_copies(self, bone: PoseBone) -> bool:
|
||||
"""Check if bone has transform copy constraints"""
|
||||
return any(constraint.type == 'COPY_TRANSFORMS' for constraint in bone.constraints)
|
||||
|
||||
def is_def_bone(self, bone_name: str) -> bool:
|
||||
"""Check if bone is a DEF bone"""
|
||||
return bone_name.startswith('DEF-')
|
||||
|
||||
def is_org_bone(self, bone_name: str) -> bool:
|
||||
"""Check if bone is an ORG bone"""
|
||||
return bone_name.startswith('ORG-')
|
||||
|
||||
def get_proto_name(self, bone_name: str) -> str:
|
||||
"""Get the prototype name of a bone"""
|
||||
if self.is_def_bone(bone_name) or self.is_org_bone(bone_name):
|
||||
return bone_name[4:]
|
||||
return bone_name
|
||||
@@ -0,0 +1,300 @@
|
||||
import traceback
|
||||
import bpy
|
||||
import math
|
||||
from typing import Dict, List, Set, Tuple, Optional, Any, Union
|
||||
from bpy.types import Operator, Context, Object, EditBone, Bone
|
||||
from ...core.translations import t
|
||||
from ...core.logging_setup import logger
|
||||
from ...core.common import get_active_armature, ProgressTracker
|
||||
from ...core.armature_validation import validate_armature
|
||||
from ...core.dictionaries import (
|
||||
standard_bones,
|
||||
bone_names,
|
||||
bone_hierarchy,
|
||||
acceptable_bone_names,
|
||||
acceptable_bone_hierarchy,
|
||||
non_standard_mappings,
|
||||
reverse_bone_lookup,
|
||||
simplify_bonename
|
||||
)
|
||||
|
||||
class AvatarToolkit_OT_StandardizeArmature(Operator):
|
||||
"""Standardize armature bone names and hierarchy to match Avatar Toolkit requirements"""
|
||||
bl_idname: str = "avatar_toolkit.standardize_armature"
|
||||
bl_label: str = t("Tools.standardize_armature")
|
||||
bl_description: str = t("Tools.standardize_armature_desc")
|
||||
bl_options: Set[str] = {'REGISTER', 'UNDO'}
|
||||
|
||||
@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', 'POSE'}
|
||||
|
||||
def invoke(self, context: Context, event: Any) -> Set[str]:
|
||||
logger.debug("Invoking standardize armature dialog")
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
layout = self.layout
|
||||
toolkit = context.scene.avatar_toolkit
|
||||
|
||||
layout.prop(toolkit, "standardize_fix_names")
|
||||
layout.prop(toolkit, "standardize_fix_hierarchy")
|
||||
layout.prop(toolkit, "standardize_fix_scale")
|
||||
layout.separator()
|
||||
layout.label(text=t("Tools.standardize_warning"), icon='ERROR')
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
armature: Optional[Object] = get_active_armature(context)
|
||||
toolkit = context.scene.avatar_toolkit
|
||||
|
||||
if not armature:
|
||||
logger.warning("No active armature found for standardization")
|
||||
self.report({'ERROR'}, t("Validation.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
logger.info(f"Starting armature standardization for {armature.name}")
|
||||
|
||||
original_mode: str = context.mode
|
||||
logger.debug(f"Original mode: {original_mode}")
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
context.view_layer.objects.active = armature
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
try:
|
||||
with ProgressTracker(context, 3, "Standardizing Armature") as progress:
|
||||
# Step 1: Fix bone names
|
||||
if toolkit.standardize_fix_names:
|
||||
progress.step("Fixing bone names")
|
||||
renamed_bones: Dict[str, str] = self.standardize_bone_names(armature)
|
||||
logger.info(f"Renamed {len(renamed_bones)} bones")
|
||||
for old_name, new_name in renamed_bones.items():
|
||||
logger.debug(f"Renamed bone: {old_name} -> {new_name}")
|
||||
|
||||
# Step 2: Fix hierarchy
|
||||
if toolkit.standardize_fix_hierarchy:
|
||||
progress.step("Fixing bone hierarchy")
|
||||
fixed_hierarchy: int = self.standardize_bone_hierarchy(armature)
|
||||
logger.info(f"Fixed {fixed_hierarchy} hierarchy relationships")
|
||||
|
||||
# Step 3: Fix scale issues
|
||||
if toolkit.standardize_fix_scale:
|
||||
progress.step("Fixing bone scale")
|
||||
fixed_scale: int = self.standardize_bone_scale(armature)
|
||||
logger.info(f"Fixed {fixed_scale} scale issues")
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
is_valid, messages, _ = validate_armature(armature, override_mode='STRICT')
|
||||
|
||||
if is_valid:
|
||||
logger.info("Armature successfully standardized")
|
||||
self.report({'INFO'}, t("Tools.standardize_success"))
|
||||
else:
|
||||
logger.warning(f"Armature partially standardized. {len(messages)} issues remain")
|
||||
bpy.ops.avatar_toolkit.standardize_issues_popup('INVOKE_DEFAULT')
|
||||
self.report({'WARNING'}, t("Tools.standardize_partial"))
|
||||
|
||||
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:
|
||||
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:
|
||||
logger.error(f"Failed to restore original mode: {traceback.format_exc()}")
|
||||
|
||||
return {'CANCELLED'}
|
||||
|
||||
def standardize_bone_names(self, armature: Object) -> Dict[str, str]:
|
||||
"""Rename bones to match standard naming conventions"""
|
||||
logger.debug("Starting bone name standardization")
|
||||
renamed_bones: Dict[str, str] = {}
|
||||
edit_bones = armature.data.edit_bones
|
||||
|
||||
# First, check which standard bones already exist
|
||||
existing_standard_bones: Set[str] = set()
|
||||
for bone in edit_bones:
|
||||
if bone.name in standard_bones.values():
|
||||
existing_standard_bones.add(bone.name)
|
||||
logger.debug(f"Found existing standard bone: {bone.name}")
|
||||
|
||||
# Use the reverse bone lookup that's already built and simplified
|
||||
name_mapping: Dict[str, str] = {}
|
||||
for simplified_name, category in reverse_bone_lookup.items():
|
||||
if category in standard_bones:
|
||||
standard_name = standard_bones[category]
|
||||
# Skip if this standard bone already exists
|
||||
if standard_name not in existing_standard_bones:
|
||||
name_mapping[simplified_name] = standard_name
|
||||
|
||||
# First pass: identify bones to rename
|
||||
bones_to_rename: Dict[str, str] = {}
|
||||
for bone in edit_bones:
|
||||
original_name: str = bone.name
|
||||
|
||||
# Skip if this is already a standard bone name
|
||||
if original_name in standard_bones.values():
|
||||
continue
|
||||
|
||||
simplified_name: str = simplify_bonename(original_name)
|
||||
|
||||
# Check if this simplified bone name has a standard mapping
|
||||
if simplified_name in name_mapping:
|
||||
standard_name = name_mapping[simplified_name]
|
||||
if original_name != standard_name:
|
||||
bones_to_rename[original_name] = standard_name
|
||||
logger.debug(f"Identified bone to rename: {original_name} -> {standard_name}")
|
||||
|
||||
# Special case for spine/chest hierarchy
|
||||
# If we don't have an upper chest, don't rename chest to upper chest because it will break hierarchy
|
||||
has_chest: bool = False
|
||||
has_upper_chest: bool = False
|
||||
|
||||
for bone_name in edit_bones.keys():
|
||||
if bone_name == standard_bones['chest']:
|
||||
has_chest = True
|
||||
elif bone_name == standard_bones['upper_chest']:
|
||||
has_upper_chest = True
|
||||
|
||||
# If we have a chest but no upper chest, don't rename anything to upper chest
|
||||
if has_chest and not has_upper_chest:
|
||||
for original_name, new_name in list(bones_to_rename.items()):
|
||||
if new_name == standard_bones['upper_chest']:
|
||||
logger.debug(f"Skipping upper chest rename for {original_name} as chest already exists")
|
||||
del bones_to_rename[original_name]
|
||||
|
||||
# Second pass: rename bones (in reverse to avoid naming conflicts)
|
||||
for original_name, new_name in sorted(bones_to_rename.items(), reverse=True):
|
||||
if original_name in edit_bones:
|
||||
temp_name: str = f"TEMP_{original_name}"
|
||||
edit_bones[original_name].name = temp_name
|
||||
renamed_bones[original_name] = new_name
|
||||
logger.debug(f"Temporarily renamed: {original_name} -> {temp_name}")
|
||||
|
||||
# Third pass: apply final names
|
||||
for original_name, new_name in renamed_bones.items():
|
||||
temp_name: str = f"TEMP_{original_name}"
|
||||
if temp_name in edit_bones:
|
||||
edit_bones[temp_name].name = new_name
|
||||
logger.debug(f"Applied final rename: {temp_name} -> {new_name}")
|
||||
|
||||
logger.info(f"Standardized {len(renamed_bones)} bone names")
|
||||
return renamed_bones
|
||||
|
||||
def standardize_bone_hierarchy(self, armature: Object) -> int:
|
||||
"""Fix bone hierarchy to match standard relationships"""
|
||||
logger.debug("Starting bone hierarchy standardization")
|
||||
edit_bones = armature.data.edit_bones
|
||||
fixed_count: int = 0
|
||||
|
||||
# Build a mapping of standard bone names to their expected parents
|
||||
hierarchy_map: Dict[str, str] = {}
|
||||
for parent, child in bone_hierarchy:
|
||||
if parent in edit_bones and child in edit_bones:
|
||||
hierarchy_map[child] = parent
|
||||
logger.debug(f"Found standard hierarchy: {parent} -> {child}")
|
||||
|
||||
for parent, child in acceptable_bone_hierarchy:
|
||||
if parent in edit_bones and child in edit_bones:
|
||||
# Only add if not already in the map
|
||||
if child not in hierarchy_map:
|
||||
hierarchy_map[child] = parent
|
||||
logger.debug(f"Found acceptable hierarchy: {parent} -> {child}")
|
||||
|
||||
for child_name, parent_name in hierarchy_map.items():
|
||||
if child_name in edit_bones and parent_name in edit_bones:
|
||||
child_bone: EditBone = edit_bones[child_name]
|
||||
parent_bone: EditBone = edit_bones[parent_name]
|
||||
|
||||
if child_bone.parent != parent_bone:
|
||||
logger.debug(f"Fixing hierarchy: {child_name} parent was {child_bone.parent.name if child_bone.parent else 'None'}, setting to {parent_name}")
|
||||
child_bone.parent = parent_bone
|
||||
fixed_count += 1
|
||||
|
||||
logger.info(f"Fixed {fixed_count} bone hierarchy relationships")
|
||||
return fixed_count
|
||||
|
||||
def standardize_bone_scale(self, armature: Object) -> int:
|
||||
"""Fix bone scale issues by normalizing bone lengths"""
|
||||
logger.debug("Starting bone scale standardization")
|
||||
edit_bones = armature.data.edit_bones
|
||||
fixed_count: int = 0
|
||||
|
||||
# Calculate median bone length for reference
|
||||
lengths: List[float] = [bone.length for bone in edit_bones if bone.length > 0.0001]
|
||||
if not lengths:
|
||||
logger.warning("No valid bone lengths found for scale standardization")
|
||||
return 0
|
||||
|
||||
lengths.sort()
|
||||
median_length: float = lengths[len(lengths) // 2]
|
||||
logger.debug(f"Median bone length: {median_length}")
|
||||
|
||||
# Calculate mean and standard deviation
|
||||
mean: float = sum(lengths) / len(lengths)
|
||||
variance: float = sum((l - mean) ** 2 for l in lengths) / len(lengths)
|
||||
std_dev: float = math.sqrt(variance)
|
||||
logger.debug(f"Mean bone length: {mean}, Standard deviation: {std_dev}")
|
||||
|
||||
small_threshold: float = max(median_length * 0.05, mean - 3 * std_dev)
|
||||
large_threshold: float = min(median_length * 15, mean + 5 * std_dev)
|
||||
logger.debug(f"Scale thresholds - small: {small_threshold}, large: {large_threshold}")
|
||||
|
||||
for bone in edit_bones:
|
||||
is_finger: bool = any(finger in bone.name.lower() for finger in ['thumb', 'index', 'middle', 'ring', 'pinky', 'finger'])
|
||||
|
||||
if bone.length < small_threshold and not is_finger:
|
||||
old_length: float = bone.length
|
||||
bone.length = small_threshold
|
||||
logger.debug(f"Fixed small bone {bone.name}: {old_length} -> {bone.length}")
|
||||
fixed_count += 1
|
||||
elif bone.length > large_threshold:
|
||||
old_length: float = bone.length
|
||||
bone.length = large_threshold
|
||||
logger.debug(f"Fixed large bone {bone.name}: {old_length} -> {bone.length}")
|
||||
fixed_count += 1
|
||||
|
||||
logger.info(f"Fixed {fixed_count} bone scale issues")
|
||||
return fixed_count
|
||||
|
||||
class AvatarToolkit_OT_StandardizeIssuesPopup(Operator):
|
||||
"""Display information about remaining issues after standardization"""
|
||||
bl_idname: str = "avatar_toolkit.standardize_issues_popup"
|
||||
bl_label: str = t("Tools.standardize_issues_title")
|
||||
bl_options: Set[str] = {'INTERNAL'}
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context: Context, event: Any) -> Set[str]:
|
||||
logger.debug("Showing standardization issues popup")
|
||||
return context.window_manager.invoke_props_dialog(self, width=400)
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
layout = self.layout
|
||||
col = layout.column(align=True)
|
||||
|
||||
col.label(text=t("Tools.standardize_issues_header"), icon='INFO')
|
||||
col.separator()
|
||||
|
||||
col.label(text=t("Tools.standardize_issues_line1"))
|
||||
col.label(text=t("Tools.standardize_issues_line2"))
|
||||
col.label(text=t("Tools.standardize_issues_line3"))
|
||||
col.separator()
|
||||
col.label(text=t("Tools.standardize_issues_line4"))
|
||||
col.label(text=t("Tools.standardize_issues_line5"))
|
||||
col.separator()
|
||||
col.label(text=t("Tools.standardize_issues_line6"))
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
from typing import TypedDict, Set, Dict, List, Optional, Any, Tuple
|
||||
import bpy
|
||||
from bpy.types import Operator, Object, Context, Mesh, MeshUVLoopLayer
|
||||
import bmesh
|
||||
import numpy as np
|
||||
import math
|
||||
from ...core.translations import t
|
||||
from ...core.logging_setup import logger
|
||||
import traceback
|
||||
|
||||
|
||||
def get_uv_vertex_selection(mesh: Mesh) -> List[bool]:
|
||||
"""
|
||||
Get UV vertex selection state for Blender 5.0.
|
||||
UV selection is stored in mesh attributes (.uv_select_vert).
|
||||
"""
|
||||
uv_select_attr = mesh.attributes['.uv_select_vert']
|
||||
selection = [False] * len(mesh.loops)
|
||||
uv_select_attr.data.foreach_get('value', selection)
|
||||
return selection
|
||||
|
||||
|
||||
def set_uv_vertex_selection(mesh: Mesh, loop_index: int, value: bool) -> None:
|
||||
"""
|
||||
Set UV vertex selection state for Blender 5.0.
|
||||
UV selection is stored in mesh attributes (.uv_select_vert).
|
||||
"""
|
||||
uv_select_attr = mesh.attributes['.uv_select_vert']
|
||||
uv_select_attr.data[loop_index].value = value
|
||||
|
||||
class GenerateLoopTreeResult(TypedDict):
|
||||
tree: Dict[str, Set[str]]
|
||||
selected_loops: Dict[str, List[int]]
|
||||
selected_verts: Dict[str, int]
|
||||
|
||||
class AvatarToolkit_OT_AlignUVEdgesToTarget(Operator):
|
||||
"""Operator to align selected UV edges to target edge"""
|
||||
bl_idname = "avatar_toolkit.align_uv_edges_to_target"
|
||||
bl_label = t("UVTools.align_edges")
|
||||
bl_description = t("UVTools.align_edges_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
#all selected objects need to be meshes for this to work - @989onan
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
if not ((context.view_layer.objects.active is not None) and (len(context.view_layer.objects.selected) > 0)):
|
||||
return False
|
||||
if context.mode != "EDIT_MESH":
|
||||
return False
|
||||
for obj in context.view_layer.objects.selected:
|
||||
if obj.type != "MESH":
|
||||
return False
|
||||
if not context.space_data:
|
||||
return False
|
||||
if not hasattr(context.space_data, "show_uvedit"):
|
||||
return False
|
||||
if not context.space_data.show_uvedit:
|
||||
return False
|
||||
if context.scene.tool_settings.use_uv_select_sync:
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
target: str = context.view_layer.objects.active.name #The object which we want to align every other selected object's selected UV vertex line to
|
||||
sources: List[str] = [i.name for i in context.view_layer.objects.selected] #The objects which we want to align their selected UV lines to the target's UV line
|
||||
|
||||
prev_mode: str = bpy.context.object.mode
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
def generate_loop_tree(obj_name: str) -> GenerateLoopTreeResult:
|
||||
logger.debug(f"Finding selected line for: {obj_name}")
|
||||
|
||||
vert_target_loops: Dict[str, List[int]] = {}
|
||||
vert_target_verts: Dict[str, int] = {}
|
||||
|
||||
me: Mesh = bpy.data.objects[obj_name].data
|
||||
uv_lay: MeshUVLoopLayer = me.uv_layers.active
|
||||
bm: bmesh.types.BMesh = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
|
||||
# To explain:
|
||||
# So loops in UV maps are X polygons that make up a face (So a MeshLoop represent a face and each vertex on that face is in order)
|
||||
#
|
||||
# For some preknowledge:
|
||||
# When a mesh is UV unwrapped, if a vertice is shared by two different faces on the model in the viewport and the vertice of both faces are in
|
||||
# the same position on the UV map, then it considers it one point and the user can move it
|
||||
# (is why the uv map doesn't split apart when you try to move a vertex because that would be annoying)
|
||||
#
|
||||
# The problem:
|
||||
# The problem is that the data for whether the uv corners of two faces that share a vertex physically being connected and selected as one vertex on the uv map does not exist
|
||||
# Though thankfully, blender forcibly (whether you like it or not) merges vertices of a uv map if the vertex of two different faces are actually shared in the UI,
|
||||
# allowing for the moving of vertices of 4 faces connected by a single vertex. Behavior every normal blender user is familiar with.
|
||||
#
|
||||
# The solution
|
||||
# We can use this to our advantage, by finding vertices on the uv map that share the same coridinate as another vertex that is also selected.
|
||||
# that way we can group each pair shared in a line as the same vertex, and identify the line using these pairs and using the data that says for certain
|
||||
# that two vertices share the same face loop, and therefore are connected.
|
||||
|
||||
#hmmm real stupid grimlin hours with this one. Using a string as the index of a dictionary of loop corners that end up on the same coordinate
|
||||
uv_selection = get_uv_vertex_selection(me)
|
||||
for k, is_selected in enumerate(uv_selection):
|
||||
if (is_selected == True) and (bm.verts[me.loops[k].vertex_index].select == True) and (bm.verts[me.loops[k].vertex_index].hide == False):
|
||||
key = np.array(uv_lay.uv[k].vector[:])
|
||||
key = key.round(decimals=5)
|
||||
|
||||
if str(key) not in vert_target_loops:
|
||||
vert_target_loops[str(key)] = []
|
||||
vert_target_loops[str(key)].append(k)
|
||||
vert_target_verts[str(key)] = me.loops[k].vertex_index
|
||||
|
||||
if len(vert_target_loops) > 4000:
|
||||
self.report({'WARNING'}, t("UVTools.too_many_vertices"))
|
||||
return {"tree": {}, "selected_loops": {}, "selected_verts": {}}
|
||||
|
||||
logger.debug(f"Finding connections on line for {obj_name}")
|
||||
me.validate()
|
||||
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
|
||||
tree: Dict[str, Set[str]] = {}
|
||||
selected_verts = np.hstack(list(vert_target_loops.values()))
|
||||
bm.verts.ensure_lookup_table()
|
||||
|
||||
for uvcoordsstr in vert_target_loops:
|
||||
uv_lay = me.uv_layers.active
|
||||
|
||||
#before this section, each vert_target_loops is just groupings of vertices that share coordinates.
|
||||
# Using the data that determines UV face corners (uvloops) that are associated with the real vertex,
|
||||
# and the uv face corners (loops) that are on the same faces as the vertices that share coordinates in
|
||||
# vert_target_loops, we can now identify them
|
||||
#TL;DR: pairs of vertices that share cooridinates (chain links) find their buddies (make chain connected)
|
||||
|
||||
# Someone explain this better than me if you can please - @989onan
|
||||
extension_loops = []
|
||||
loops = bm.verts[vert_target_verts[uvcoordsstr]].link_loops
|
||||
loops_indexes = [i.index for i in loops]
|
||||
for loop in vert_target_loops[uvcoordsstr]:
|
||||
if loop in loops_indexes:
|
||||
loop_obj = loops[loops_indexes.index(loop)]
|
||||
extension_loops.append(loop_obj.link_loop_next.index)
|
||||
extension_loops.append(loop_obj.link_loop_prev.index)
|
||||
|
||||
#make a tree out of the vertices we identified as sharing faces with the vertices in vert_target_loops, and then link them together in a dictionary.
|
||||
#the order of this dictionary is unknown.
|
||||
# Someone explain this better than me if you can please - @989onan
|
||||
tree[uvcoordsstr] = set()
|
||||
|
||||
for i in extension_loops:
|
||||
if i in selected_verts:
|
||||
key = np.array(uv_lay.uv[i].vector[:])
|
||||
key = key.round(decimals=5)
|
||||
tree[uvcoordsstr].add(str(key))
|
||||
|
||||
if uvcoordsstr in tree:
|
||||
if len(tree[uvcoordsstr]) > 2:
|
||||
self.report({'WARNING'}, t("UVTools.need_line", obj=obj_name))
|
||||
return {"tree": {}, "selected_loops": {}, "selected_verts": {}}
|
||||
|
||||
uv_lay = me.uv_layers.active
|
||||
for uvcoordstr in vert_target_loops:
|
||||
for loop in vert_target_loops[uvcoordstr]:
|
||||
set_uv_vertex_selection(me, loop, True)
|
||||
|
||||
bm.free()
|
||||
me.validate()
|
||||
logger.debug(f"Found UV line connections for {obj_name}")
|
||||
|
||||
return {"tree": tree, "selected_loops": vert_target_loops, "selected_verts": vert_target_verts}
|
||||
|
||||
def sort_uv_tree(originaltree: Dict[str, Set[str]], obj_name: str) -> List[str]:
|
||||
sortedtree: Dict[str, Set[str]] = originaltree.copy()
|
||||
startpoints: List[str] = []
|
||||
for i in sortedtree:
|
||||
if len(sortedtree[i]) < 2:
|
||||
startpoints.append(i)
|
||||
|
||||
if len(startpoints) != 2:
|
||||
self.report({'WARNING'}, t("UVTools.need_line", obj=obj_name))
|
||||
return []
|
||||
|
||||
uvcoords1 = [float(x) for x in startpoints[0].replace("[","").replace("]","").split()]
|
||||
uvcoords2 = [float(x) for x in startpoints[1].replace("[","").replace("]","").split()]
|
||||
|
||||
cursor = context.space_data.cursor_location
|
||||
|
||||
startpoint = startpoints[0] if math.sqrt((uvcoords1[0] - cursor[0])**2 + (uvcoords1[1] - cursor[1])**2) > math.sqrt((uvcoords2[0] - cursor[0])**2 + (uvcoords2[1] - cursor[1])**2) else startpoints[1]
|
||||
|
||||
#Wew my first actual recursive sort! - @989onan
|
||||
def recursive_sort_uv_tree(point: str, sortedfinal: List[str]) -> List[str]:
|
||||
#print("appending "+point)
|
||||
sortedfinal.append(point)
|
||||
|
||||
new_point: str = ""
|
||||
for i in sortedtree:
|
||||
if point in sortedtree[i]:
|
||||
new_point = i
|
||||
removed_value = sortedtree.pop(i)
|
||||
#print(removed_value)
|
||||
break
|
||||
|
||||
if new_point == "":
|
||||
logger.debug("Sorting complete, remaining tree:")
|
||||
logger.debug(sortedtree)
|
||||
return sortedfinal
|
||||
|
||||
return recursive_sort_uv_tree(new_point, sortedfinal)
|
||||
|
||||
sortedtree.pop(startpoint)
|
||||
return recursive_sort_uv_tree(startpoint, [])
|
||||
|
||||
def lerp(v0: float, v1: float, t: float) -> float:
|
||||
return v0 + t * (v1 - v0)
|
||||
|
||||
target_data = generate_loop_tree(target)
|
||||
sorted_target_tree = sort_uv_tree(target_data["tree"], target)
|
||||
logger.debug("Sorted target tree")
|
||||
|
||||
for source in sources:
|
||||
if source == target:
|
||||
continue
|
||||
|
||||
try:
|
||||
source_data = generate_loop_tree(source)
|
||||
sorted_source_tree = sort_uv_tree(source_data["tree"], source)
|
||||
logger.debug(f"Sorted source {source}")
|
||||
|
||||
vertex_factor = float(len(sorted_target_tree)-1) / float(len(sorted_source_tree)-1)
|
||||
logger.debug(f"Vertex factor: {vertex_factor}")
|
||||
|
||||
for k, i in enumerate(sorted_source_tree):
|
||||
try:
|
||||
#find where we are on the target edges, to interpolate the current point we're placing along the target point's line.
|
||||
progress_along_edge = float(k) * vertex_factor
|
||||
previous_vertex_index = math.floor(progress_along_edge)
|
||||
next_vertex_index = math.ceil(progress_along_edge)
|
||||
|
||||
#find the uv coordinates of the previous and next points on the target uv line.
|
||||
previous_point = [float(x) for x in sorted_target_tree[previous_vertex_index].replace("[","").replace("]","").split()]
|
||||
next_point = [float(x) for x in sorted_target_tree[next_vertex_index].replace("[","").replace("]","").split()]
|
||||
|
||||
#create a point between these two values that represents a decimal 0-1 going where we are to where we are going between the two current points on the edge we are targeting this whole shebang with.
|
||||
progress_between_points = progress_along_edge - int(progress_along_edge)
|
||||
lerped_point = [
|
||||
lerp(previous_point[0], next_point[0], progress_between_points),
|
||||
lerp(previous_point[1], next_point[1], progress_between_points)
|
||||
]
|
||||
|
||||
#grab our uv face corners for each uv coord that we saved.
|
||||
#Since each face is considered separate internally, we have to treat each connected face to a vertex in a uv map as separate entities/vertexes.
|
||||
#basically pretend they are split apart.
|
||||
uv_face_corners = source_data["selected_loops"][i]
|
||||
|
||||
me = bpy.data.objects[source].data
|
||||
me.validate()
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
uv_lay = me.uv_layers.active
|
||||
bm.verts.ensure_lookup_table()
|
||||
|
||||
for corner in uv_face_corners:
|
||||
uv_lay.uv[corner].vector = lerped_point
|
||||
|
||||
except:
|
||||
#This is probably fine? - @989onan
|
||||
#TODO: What happened here? The magic of making code so complex you forget if this is even an issue. - @989onan
|
||||
pass
|
||||
|
||||
logger.info(f"Finished mesh {source} for UV's")
|
||||
|
||||
except Exception:
|
||||
logger.error(f"Error processing source {source}: {traceback.format_exc()}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
bpy.ops.object.mode_set(mode=prev_mode)
|
||||
return {'FINISHED'}
|
||||
@@ -0,0 +1,88 @@
|
||||
import bpy
|
||||
from bpy.types import Operator
|
||||
from ...core.common import get_active_armature
|
||||
from ...core.translations import t
|
||||
from ...core.vrm_unity_converter import convert_vrm_to_unity, validate_unity_hierarchy
|
||||
from ...core.logging_setup import logger
|
||||
from ...core.armature_validation import validate_armature
|
||||
|
||||
|
||||
class AvatarToolkit_OT_ConvertVRMToUnity(Operator):
|
||||
"""Convert VRM armature bone names to Unity humanoid format"""
|
||||
bl_idname = "avatar_toolkit.convert_vrm_to_unity"
|
||||
bl_label = t("VRM.convert_to_unity.label")
|
||||
bl_description = t("VRM.convert_to_unity.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
armature = get_active_armature(context)
|
||||
return armature is not None
|
||||
|
||||
def execute(self, context):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
logger.warning("No active armature found for VRM conversion")
|
||||
self.report({'ERROR'}, t("VRM.no_armature_selected"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
logger.info(f"Starting VRM to Unity conversion for armature: {armature.name}")
|
||||
|
||||
# Get conversion settings
|
||||
remove_colliders = context.scene.avatar_toolkit.vrm_remove_colliders
|
||||
remove_root = context.scene.avatar_toolkit.vrm_remove_root
|
||||
logger.info(f"Collider removal setting: {remove_colliders}")
|
||||
logger.info(f"Root bone removal setting: {remove_root}")
|
||||
|
||||
# Log all objects with 'collider' in name for debugging
|
||||
collider_objects = [obj.name for obj in bpy.data.objects if 'collider' in obj.name.lower()]
|
||||
if collider_objects:
|
||||
logger.info(f"Found {len(collider_objects)} objects with 'collider' in name:")
|
||||
for obj_name in collider_objects:
|
||||
logger.info(f" - {obj_name}")
|
||||
|
||||
success, messages, converted_count = convert_vrm_to_unity(armature, remove_colliders, remove_root)
|
||||
|
||||
if not success:
|
||||
logger.warning(f"VRM conversion failed: {messages}")
|
||||
for msg in messages:
|
||||
self.report({'WARNING'}, msg)
|
||||
return {'CANCELLED'}
|
||||
|
||||
logger.info(f"VRM conversion completed successfully. Converted {converted_count} bones")
|
||||
for msg in messages:
|
||||
self.report({'INFO'}, msg)
|
||||
|
||||
# Validate the converted armature
|
||||
try:
|
||||
is_valid, validation_messages = validate_unity_hierarchy(armature)
|
||||
|
||||
if is_valid:
|
||||
logger.info("Unity hierarchy validation passed")
|
||||
self.report({'INFO'}, t("VRM.validation.hierarchy_passed"))
|
||||
else:
|
||||
logger.warning("Unity hierarchy validation found issues")
|
||||
self.report({'WARNING'}, t("VRM.validation.hierarchy_issues"))
|
||||
for msg in validation_messages:
|
||||
self.report({'WARNING'}, msg)
|
||||
|
||||
try:
|
||||
armature_valid, armature_messages, _ = validate_armature(armature)
|
||||
if armature_valid:
|
||||
logger.info("Full armature validation passed")
|
||||
self.report({'INFO'}, t("VRM.validation.armature_passed"))
|
||||
else:
|
||||
logger.info("Full armature validation found minor issues")
|
||||
# Don't report these as errors since the conversion was successful
|
||||
# Just log them for debugging
|
||||
for msg in armature_messages[:3]:
|
||||
logger.debug(f"Armature validation: {msg}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error during full armature validation: {str(e)}")
|
||||
# Don't fail the operation for validation errors
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during hierarchy validation: {str(e)}")
|
||||
self.report({'WARNING'}, t("VRM.validation.failed", error=str(e)))
|
||||
|
||||
return {'FINISHED'}
|
||||
+38
-32
@@ -1,6 +1,7 @@
|
||||
# This code was taken from Cats Blender Plugin Unoffical, some of this code is by the original developers, however was improved by myself.
|
||||
# Didn't think it was necessary to re-make something that works well.
|
||||
|
||||
import traceback
|
||||
import bpy
|
||||
from typing import Dict, List, Optional, Tuple, Any, Set, Union
|
||||
from bpy.types import Operator, Context, Object, ShapeKey
|
||||
@@ -8,11 +9,10 @@ from collections import OrderedDict
|
||||
from ..core.logging_setup import logger
|
||||
from ..core.translations import t
|
||||
from ..core.common import (
|
||||
get_active_armature,
|
||||
validate_armature,
|
||||
get_all_meshes,
|
||||
validate_mesh_for_pose
|
||||
)
|
||||
import traceback
|
||||
|
||||
class VisemeCache:
|
||||
"""Manages caching of generated viseme shape data for performance optimization"""
|
||||
@@ -35,6 +35,7 @@ class VisemePreview:
|
||||
_preview_data: Dict[str, float] = {}
|
||||
_active: bool = False
|
||||
_preview_shapes: Optional[OrderedDict] = None
|
||||
_mesh_name: str = ""
|
||||
|
||||
@classmethod
|
||||
def start_preview(cls, context: Context, mesh: Object, shapes: List[str]) -> bool:
|
||||
@@ -43,6 +44,7 @@ class VisemePreview:
|
||||
|
||||
cls._active = True
|
||||
cls._preview_data = {}
|
||||
cls._mesh_name = mesh.name
|
||||
|
||||
# Store original values
|
||||
for shape_key in mesh.data.shape_keys.key_blocks:
|
||||
@@ -79,7 +81,11 @@ class VisemePreview:
|
||||
if not cls._active or not cls._preview_shapes:
|
||||
return
|
||||
|
||||
mesh = context.active_object
|
||||
# Get the mesh by name instead of using active object
|
||||
mesh = bpy.data.objects.get(cls._mesh_name)
|
||||
if not mesh:
|
||||
return
|
||||
|
||||
props = context.scene.avatar_toolkit
|
||||
viseme_data = cls._preview_shapes.get(props.viseme_preview_selection)
|
||||
if viseme_data:
|
||||
@@ -116,8 +122,9 @@ class VisemePreview:
|
||||
cls._active = False
|
||||
cls._preview_data.clear()
|
||||
cls._preview_shapes = None
|
||||
cls._mesh_name = ""
|
||||
|
||||
class ATOOLKIT_OT_preview_visemes(Operator):
|
||||
class AvatarToolkit_OT_PreviewVisemes(Operator):
|
||||
"""Operator for previewing viseme shapes in real-time"""
|
||||
bl_idname: str = "avatar_toolkit.preview_visemes"
|
||||
bl_label: str = t("Visemes.preview_label")
|
||||
@@ -126,31 +133,27 @@ class ATOOLKIT_OT_preview_visemes(Operator):
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
# Check if we're in object mode
|
||||
if context.mode != 'OBJECT':
|
||||
return False
|
||||
|
||||
# Get mesh from UI selection
|
||||
from ..core.common import get_mesh_from_identifier
|
||||
props = context.scene.avatar_toolkit
|
||||
mesh_obj = bpy.data.objects.get(props.viseme_mesh)
|
||||
|
||||
# Validate armature and mesh
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
valid, _ = validate_armature(armature)
|
||||
return valid and mesh_obj and mesh_obj.type == 'MESH'
|
||||
mesh_obj = get_mesh_from_identifier(props.viseme_mesh)
|
||||
|
||||
# Validate mesh
|
||||
return mesh_obj and mesh_obj.type == 'MESH'
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
from ..core.common import get_mesh_from_identifier
|
||||
props = context.scene.avatar_toolkit
|
||||
mesh = context.active_object
|
||||
mesh = get_mesh_from_identifier(props.viseme_mesh)
|
||||
|
||||
if props.viseme_preview_mode:
|
||||
VisemePreview.end_preview(mesh)
|
||||
props.viseme_preview_mode = False
|
||||
else:
|
||||
if not mesh.data.shape_keys:
|
||||
if not mesh or not mesh.data.shape_keys:
|
||||
self.report({'ERROR'}, t("Visemes.error.no_shapekeys"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
@@ -176,7 +179,7 @@ def validate_deformation(mesh, mix_data):
|
||||
mesh_size = max(mesh.dimensions)
|
||||
return max_deform < (mesh_size * 0.4)
|
||||
|
||||
class ATOOLKIT_OT_create_visemes(Operator):
|
||||
class AvatarToolkit_OT_CreateVisemes(Operator):
|
||||
"""Operator for generating VRChat-compatible viseme shape keys"""
|
||||
bl_idname: str = "avatar_toolkit.create_visemes"
|
||||
bl_label: str = t("Visemes.create_label")
|
||||
@@ -190,22 +193,19 @@ class ATOOLKIT_OT_create_visemes(Operator):
|
||||
return False
|
||||
|
||||
# Get mesh from UI selection
|
||||
from ..core.common import get_mesh_from_identifier
|
||||
props = context.scene.avatar_toolkit
|
||||
mesh_obj = bpy.data.objects.get(props.viseme_mesh)
|
||||
|
||||
# Validate armature and mesh
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
valid, _ = validate_armature(armature)
|
||||
return valid and mesh_obj and mesh_obj.type == 'MESH'
|
||||
mesh_obj = get_mesh_from_identifier(props.viseme_mesh)
|
||||
|
||||
# Validate mesh
|
||||
return mesh_obj and mesh_obj.type == 'MESH'
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
from ..core.common import get_mesh_from_identifier
|
||||
props = context.scene.avatar_toolkit
|
||||
mesh = context.active_object
|
||||
mesh = get_mesh_from_identifier(props.viseme_mesh)
|
||||
|
||||
if not mesh.data.shape_keys:
|
||||
if not mesh or not mesh.data.shape_keys:
|
||||
self.report({'ERROR'}, t("Visemes.error.no_shapekeys"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
@@ -217,9 +217,9 @@ class ATOOLKIT_OT_create_visemes(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:
|
||||
@@ -280,7 +280,7 @@ class ATOOLKIT_OT_create_visemes(Operator):
|
||||
continue
|
||||
|
||||
# Create new shape key
|
||||
self.mix_shapekey(context, renamed_shapes, data['mix'], key)
|
||||
self.mix_shapekey(context, renamed_shapes, data['mix'], key, mesh) # Added mesh parameter
|
||||
|
||||
# Cache the new shape key data
|
||||
shape_data = [v.co.copy() for v in mesh.data.shape_keys.key_blocks[key].data]
|
||||
@@ -293,14 +293,16 @@ class ATOOLKIT_OT_create_visemes(Operator):
|
||||
mesh.active_shape_key_index = 0
|
||||
wm.progress_end()
|
||||
|
||||
def mix_shapekey(self, context: Context, shapes: List[str], mix_data: List, new_name: str) -> None:
|
||||
def mix_shapekey(self, context: Context, shapes: List[str], mix_data: List, new_name: str, mesh: Object) -> None: # Added mesh parameter
|
||||
"""Creates a new shape key by mixing existing ones"""
|
||||
mesh = context.active_object
|
||||
|
||||
# Remove existing shape key if it exists
|
||||
if new_name in mesh.data.shape_keys.key_blocks:
|
||||
mesh.active_shape_key_index = mesh.data.shape_keys.key_blocks.find(new_name)
|
||||
old_active = context.view_layer.objects.active
|
||||
context.view_layer.objects.active = mesh
|
||||
bpy.ops.object.shape_key_remove()
|
||||
context.view_layer.objects.active = old_active
|
||||
|
||||
# Reset all shape keys
|
||||
for shapekey in mesh.data.shape_keys.key_blocks:
|
||||
@@ -313,7 +315,10 @@ class ATOOLKIT_OT_create_visemes(Operator):
|
||||
shapekey.value = value
|
||||
|
||||
# Create mixed shape key
|
||||
old_active = context.view_layer.objects.active
|
||||
context.view_layer.objects.active = mesh
|
||||
mesh.shape_key_add(name=new_name, from_mix=True)
|
||||
context.view_layer.objects.active = old_active
|
||||
|
||||
# Reset values and restore shape key settings
|
||||
for shapekey in mesh.data.shape_keys.key_blocks:
|
||||
@@ -356,3 +361,4 @@ class ATOOLKIT_OT_create_visemes(Operator):
|
||||
props.mouth_a = current_names[0]
|
||||
props.mouth_o = current_names[1]
|
||||
props.mouth_ch = current_names[2]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"authors": ["Avatar Toolkit Team"],
|
||||
"messages": {
|
||||
"AvatarToolkit.label": "Avatar Toolkit (Alpha 0.1.1)",
|
||||
"AvatarToolkit.label": "Avatar Toolkit (Alpha 0.6.0)",
|
||||
"AvatarToolkit.desc1": "Avatar Toolkit is in Early Access there",
|
||||
"AvatarToolkit.desc2": "will be issues, if you find any issues,",
|
||||
"AvatarToolkit.desc3": "please report it on our Github.",
|
||||
@@ -50,6 +50,7 @@
|
||||
"QuickAccess.validation_basic_details": "Only essential bone structure is being validated",
|
||||
"QuickAccess.validation_none_warning": "Validation Disabled",
|
||||
"QuickAccess.validation_none_details": "No armature validation checks are being performed",
|
||||
"Quick_Access.import_success": "Import successful",
|
||||
|
||||
"PoseMode.error.start": "Failed to start pose mode: {error}",
|
||||
"PoseMode.error.stop": "Failed to stop pose mode: {error}",
|
||||
@@ -62,6 +63,13 @@
|
||||
"PoseMode.basis": "Basis",
|
||||
|
||||
"Armature.validation.no_armature": "No armature selected",
|
||||
"Armature.validation.pmx_model_detected": "PMX model detected. Japanese bone names may not match standard naming conventions.",
|
||||
"Armature.validation.pmx_model_strict": "Consider using the 'Standardize Armature' option to convert Japanese bone names to standard names.",
|
||||
"Armature.validation.pmx_model_standardize": "This will make the model compatible with standard avatar systems.",
|
||||
"Armature.validation.pmx_model_basic": "PMX models use Japanese bone names which may not match standard naming conventions.",
|
||||
"Armature.validation.unknown_format": "Unknown armature format detected.",
|
||||
"Validation.mode.none": "Validation is disabled in settings.",
|
||||
"Validation.no_messages": "No validation messages available.",
|
||||
"Armature.validation.not_armature": "Selected object is not an armature",
|
||||
"Armature.validation.no_bones": "Armature has no bones",
|
||||
"Armature.validation.basic_check_failed": "Basic armature validation failed",
|
||||
@@ -69,6 +77,55 @@
|
||||
"Armature.validation.invalid_hierarchy": "Invalid bone hierarchy between {parent} and {child}",
|
||||
"Armature.validation.asymmetric_bones": "Missing symmetric bones for {bone}",
|
||||
"Armature.validation.asymmetric_hand_wrist": "Missing symmetric bones for hands/wrists",
|
||||
"Armature.validation.found_bones": "Found bones in armature:\n- {bones}",
|
||||
"Armature.validation.non_standard_bones": "Non-standard bones found:\n- {bones}",
|
||||
"Armature.validation.accessory_bones_note.line1": "If you have hair bones, skirt bones, or other",
|
||||
"Armature.validation.accessory_bones_note.line2": "accessorybones named similarly to main armature",
|
||||
"Armature.validation.accessory_bones_note.line3": "bones (e.g., Head1, Head2), please rename them to",
|
||||
"Armature.validation.accessory_bones_note.line4": "more descriptive names like Hair_1, Skirt_1.",
|
||||
"Armature.validation.standardize_note.line1": "You can standardize your armature",
|
||||
"Armature.validation.standardize_note.line2": "automatically by using the 'Standardize Armature'",
|
||||
"Armature.validation.standardize_note.line3": "button in the Tools section.",
|
||||
"Validation.section.found_bones": "Found Bones",
|
||||
"Validation.section.non_standard": "Non-Standard Bones",
|
||||
"Validation.section.hierarchy": "Hierarchy Issues",
|
||||
"Validation.status.failed": "Validation has failed",
|
||||
"Validation.message.failed.line1": "Armature validation has failed",
|
||||
"Validation.message.failed.line2": "Please check below what the",
|
||||
"Validation.message.failed.line3": "issues are",
|
||||
"Validation.highlight_problem_bones_desc": "Visually highlight bones that have validation issues in the viewport",
|
||||
"Validation.no_armature": "No armature selected",
|
||||
"Validation.no_issues": "No validation issues found to highlight",
|
||||
"Validation.highlighting_complete": "Problem bones highlighted successfully",
|
||||
"Validation.tpose.no_armature": "No armature found for T-pose validation",
|
||||
"Validation.tpose.left_arm_not_horizontal": "Left arm is not in a horizontal T-pose position",
|
||||
"Validation.tpose.right_arm_not_horizontal": "Right arm is not in a horizontal T-pose position",
|
||||
"Validation.tpose.spine_not_vertical": "Spine is not in a vertical position",
|
||||
"Validation.tpose.warning": "T-Pose Validation Warning",
|
||||
"Validation.tpose.recommendation": "We recommend fixing the T-pose before importing into Unity or other platforms",
|
||||
"Validation.scale_issues": "Bones with abnormal scale detected:",
|
||||
"Validation.scale_issue.too_small": "Bone is extremely small",
|
||||
"Validation.scale_issue.too_large": "Bone is extremely large",
|
||||
"Validation.section.scale_issues": "Scale Issues",
|
||||
"Validation.tpose.label": "Validate T-Pose",
|
||||
"Validation.no_scale_issues": "No scale issues detected",
|
||||
"Validation.no_hierarchy_issues": "No hierarchy issues detected",
|
||||
"Validation.no_non_standard_issues": "No non-standard bone issues detected",
|
||||
"Validation.tpose.valid": "T-Pose validation passed successfully",
|
||||
"Validation.tpose.desc": "Check if armature is in a proper T-pose",
|
||||
"Validation.highlight_problem_bones": "Highlight Problem Bones",
|
||||
"Validation.clear_bone_highlighting": "Clear Bone Highlighting",
|
||||
"Validation.clear_bone_highlighting_desc": "Remove bone highlighting and reset bone colors to default",
|
||||
"Validation.highlighting_cleared": "Bone highlighting cleared successfully",
|
||||
"Validation.label": "Armature Validation",
|
||||
"Validation.validate_now": "Validate Armature Now",
|
||||
"Validation.validate_now_desc": "Run armature validation and display detailed results",
|
||||
"Validation.results": "Validation Results",
|
||||
"Validation.tpose.validate_now": "Validate T-Pose Now",
|
||||
|
||||
"Armature.validation.acceptable_standard.success": "Armature meets acceptable standards",
|
||||
"Armature.validation.acceptable_standard.note": "This is a valid armature format that is compatible with most avatar systems",
|
||||
"Armature.validation.acceptable_standard.option": "You can standardize the armature if desired",
|
||||
|
||||
"Mesh.validation.no_data": "No mesh data",
|
||||
"Mesh.validation.no_vertex_groups": "No vertex groups found",
|
||||
@@ -87,9 +144,7 @@
|
||||
"Optimization.combine_materials": "Combine Materials",
|
||||
"Optimization.combine_materials_desc": "Combine similar materials to reduce draw calls",
|
||||
"Optimization.remove_doubles": "Remove Doubles",
|
||||
"Optimization.remove_doubles_desc": "Remove duplicate vertices",
|
||||
"Optimization.remove_doubles_advanced": "Advanced",
|
||||
"Optimization.remove_doubles_advanced_desc": "Remove duplicate vertices with advanced options",
|
||||
"Optimization.remove_doubles_desc": "Remove duplicate vertices safely, keeping shapekeys preserved.",
|
||||
"Optimization.join_all_meshes": "Join All",
|
||||
"Optimization.join_all_meshes_desc": "Join all meshes in the scene",
|
||||
"Optimization.join_selected_meshes": "Join Selected",
|
||||
@@ -117,8 +172,6 @@
|
||||
"Optimization.error.join_selected": "Failed to join selected meshes: {error}",
|
||||
"Optimization.merge_distance": "Merge Distance",
|
||||
"Optimization.merge_distance_desc": "Distance within which vertices will be merged",
|
||||
"Optimization.remove_doubles_warning": "This process may take a long time",
|
||||
"Optimization.remove_doubles_wait": "Blender may seem unresponsive during this operation",
|
||||
"Optimization.error.remove_doubles": "Failed to remove doubles: {error}",
|
||||
"Optimization.no_armature": "No armature selected",
|
||||
"Optimization.processing_mesh": "Processing mesh: {name}",
|
||||
@@ -126,7 +179,9 @@
|
||||
"Optimization.remove_doubles_completed": "Remove doubles completed successfully",
|
||||
|
||||
"Tools.label": "Tools",
|
||||
"Tools.mesh_title": "Mesh Tools",
|
||||
"Tools.general_title": "General Tools",
|
||||
"Tools.select_armature": "Select an Armature",
|
||||
"Tools.convert_resonite": "Convert to Resonite",
|
||||
"Tools.convert_resonite_desc": "Convert model for use in Resonite",
|
||||
"Tools.convert_resonite.operation": "Converting to Resonite",
|
||||
@@ -145,10 +200,29 @@
|
||||
"Tools.digitigrade_error": "Failed to create digitigrade legs: {error}",
|
||||
"Tools.digitigrade_success": "Successfully created digitigrade leg setup",
|
||||
"Tools.processing_leg": "Processing leg bone: {bone}",
|
||||
"Tools.weight_title": "Weight Tools",
|
||||
"Tools.merge_twist_bones": "Keep Twist Bones",
|
||||
"Tools.merge_twist_bones_desc": "When checked, twist bones will be kept, even if there are zero-weight",
|
||||
"Tools.clean_weights": "Remove Zero Weight Bones",
|
||||
"Tools.clean_weights_desc": "Remove bones with no vertex weights",
|
||||
"Tools.clean_vertex_groups": "Remove Unused Vertex Groups",
|
||||
"Tools.clean_vertex_groups_desc": "Remove vertex groups on meshes assigned to no vertices.",
|
||||
"Tools.preserve_parent_bones": "Preserve Parent Bones",
|
||||
"Tools.preserve_parent_bones_desc": "Keep bones that have children even if they have no weights",
|
||||
"Tools.target_bone_type": "Target Bone Type",
|
||||
"Tools.target_bone_type_desc": "Filter which types of bones to process",
|
||||
"Tools.target_all_bones": "All Bones",
|
||||
"Tools.target_deform_bones": "Deform Bones Only",
|
||||
"Tools.target_non_deform_bones": "Non-Deform Bones Only",
|
||||
"Tools.list_only_mode": "List Mode Only",
|
||||
"Tools.list_only_mode_desc": "List zero weight bones instead of removing them",
|
||||
"Tools.zero_weight_bones_found": "Zero weight bones found: {bones}",
|
||||
"Tools.remove_selected_bones": "Remove Selected Bones",
|
||||
"Tools.remove_selected_bones_desc": "Remove selected zero weight bones from armature",
|
||||
"Tools.flip_pose_frames": "Flip Selected Armature Key Frames",
|
||||
"Tools.flip_pose_frames_desc": "Takes the selected keyframes and sets them to a mirrored pose, gotten from the opposite side of the armature on that frame.\nSelecting the entire animation's keyframes will flip the entire animation.",
|
||||
"Tools.bones_removed": "Removed {count} bones",
|
||||
"Tools.vertex_groups_removed": "Removed {count} vertex groups.",
|
||||
"Tools.clean_constraints": "Delete Bone Constraints",
|
||||
"Tools.clean_constraints_desc": "Remove all bone constraints from armature",
|
||||
"Tools.clean_constraints_success": "Removed {count} bone constraints",
|
||||
@@ -156,6 +230,21 @@
|
||||
"Tools.clean_weights_success": "Removed {count} zero-weight bones",
|
||||
"Tools.clean_weights_threshold": "Weight Threshold",
|
||||
"Tools.clean_weights_threshold_desc": "Minimum weight value to consider a bone as weighted",
|
||||
"Tools.find_shortest_seam_path": "Find Shortest Seam Path",
|
||||
"Tools.find_shortest_seam_path_desc": "Find shortest path of seams between two selected vertices connected to seams.",
|
||||
"Tools.explode_mesh":"Explode Mesh for Painting",
|
||||
"Tools.explode_mesh_desc": "Explodes the mesh for use with painting programs, or painting inside blender.",
|
||||
"Tools.explode_mesh.distance": "Distance",
|
||||
"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",
|
||||
"Tools.merge_to_active": "Merge to Active",
|
||||
"Tools.merge_to_active_desc": "Merge selected bones to active bone",
|
||||
@@ -187,25 +276,38 @@
|
||||
"Tools.shapekey_tolerance": "Shape Key Tolerance",
|
||||
"Tools.shapekey_tolerance_desc": "Minimum difference to consider a shape key as used",
|
||||
"Tools.shapekeys_removed": "Removed {count} unused shape keys",
|
||||
"Tools.rigify_title": "Rigify Tools",
|
||||
"Tools.convert_rigify_to_unity": "Convert Rigify to Unity",
|
||||
"Tools.convert_rigify_to_unity_desc": "Convert Rigify armature to Unity-compatible format",
|
||||
"Tools.rigify_converted": "Rigify armature converted successfully",
|
||||
"Tools.no_armature": "No armature selected",
|
||||
"Tools.standardize_title": "Standardization",
|
||||
"Tools.standardize_armature": "Standardize Armature",
|
||||
"Tools.standardize_armature_desc": "Convert non-standard armature to Avatar Toolkit standards",
|
||||
"Tools.standardize_fix_names": "Fix Bone Names",
|
||||
"Tools.standardize_fix_names_desc": "Rename bones to match standard naming conventions",
|
||||
"Tools.standardize_fix_hierarchy": "Fix Bone Hierarchy",
|
||||
"Tools.standardize_fix_hierarchy_desc": "Correct parent-child relationships between bones",
|
||||
"Tools.standardize_fix_scale": "Fix Bone Scale",
|
||||
"Tools.standardize_fix_scale_desc": "Normalize bone lengths to fix scale issues",
|
||||
"Tools.standardize_warning": "This operation will modify your armature. Make a backup first!",
|
||||
"Tools.standardize_success": "Armature successfully standardized",
|
||||
"Tools.standardize_partial": "Armature partially standardized. Some issues remain.",
|
||||
"Tools.standardize_already_valid": "Armature already meets standards. No changes needed.",
|
||||
"Tools.standardize_issues_title": "Standardization Issues",
|
||||
"Tools.standardize_issues_header": "Some issues still remain after standardization",
|
||||
"Tools.standardize_issues_line1": "This could be because some bones on your avatar have unique names",
|
||||
"Tools.standardize_issues_line2": "that aren't in our list of recognized non-standard bones.",
|
||||
"Tools.standardize_issues_line3": "For example, if your hips bone is named 'THISISMYHIPS', we can't detect it.",
|
||||
"Tools.standardize_issues_line4": "If your main skeleton bones aren't being recognized, please report this",
|
||||
"Tools.standardize_issues_line5": "on our GitHub so we can add them to our database.",
|
||||
"Tools.standardize_issues_line6": "Accessory bones (hair, clothing, etc.) must be renamed manually.",
|
||||
|
||||
"MMD.label": "MMD Tools",
|
||||
"MMD.bone_standardization": "Bone Standardization",
|
||||
"MMD.weight_processing": "Weight Processing",
|
||||
"MMD.hierarchy": "Bone Hierarchy",
|
||||
"MMD.cleanup": "Cleanup",
|
||||
"MMD.no_armature": "No armature selected",
|
||||
"MMD.no_meshes": "No meshes found",
|
||||
"MMD.validation.rigify_unsupported": "Rigify armatures are not supported",
|
||||
"MMD.validation.multi_user_mesh": "Multi-user mesh detected: {mesh}",
|
||||
"MMD.bones_standardized": "Bones standardized successfully",
|
||||
"MMD.weights_processed": "Weights processed successfully",
|
||||
"MMD.hierarchy_fixed": "Bone hierarchy fixed successfully",
|
||||
"MMD.hierarchy_validation_warning": "Some hierarchy relationships could not be validated",
|
||||
"MMD.cleanup_completed": "Armature cleanup completed",
|
||||
"MMD.process_twist_bones": "Process Twist Bones",
|
||||
"MMD.process_twist_bones_desc": "Transfer weights from twist bones to their parent bones",
|
||||
"MMD.connect_bones": "Connect Bones",
|
||||
"MMD.connect_bones_desc": "Connect bones in chain where appropriate",
|
||||
"UVTools.uv_title": "UV Tools",
|
||||
"UVTools.too_many_vertices": "Error! You have too much stuff selected. Are you sure you're selecting two edges?",
|
||||
"UVTools.need_line": "You need one line of selected UV points per selected object. Object \"{obj}\" does not meet this requirement!",
|
||||
"UVTools.align_edges": "Align UV Edges to Target",
|
||||
"UVTools.align_edges_desc": "Aligns a selected line of UV points on each selected mesh to the line of selected UV points on the active mesh. Useful for kitbashing textures of one model onto another. Uses distance from the 2D cursor to identify the start of the line of UV points on each mesh.",
|
||||
|
||||
"Visemes.panel_label": "Visemes",
|
||||
"Visemes.shape_selection": "Shape Key Selection",
|
||||
@@ -233,6 +335,7 @@
|
||||
"Visemes.success": "Visemes created successfully",
|
||||
"Visemes.mesh_select": "Select Mesh",
|
||||
"Visemes.mesh_select_desc": "Select the mesh to create visemes on",
|
||||
"Visemes.no_meshes": "No meshes found",
|
||||
|
||||
"EyeTracking.label": "Eye Tracking",
|
||||
"EyeTracking.setup": "Eye Tracking Setup",
|
||||
@@ -314,10 +417,15 @@
|
||||
"EyeTracking.sdk_version": "SDK Version",
|
||||
"EyeTracking.type.av3": "Avatar 3.0",
|
||||
"EyeTracking.type.av3_desc": "VRChat Avatar 3.0 eye tracking setup",
|
||||
"EyeTracking.type.sdk2": "SDK2 (Legacy)",
|
||||
"EyeTracking.type.sdk2_desc": "VRChat SDK2 eye tracking setup",
|
||||
"EyeTracking.type.sdk2": "Legacy (ChilloutVR",
|
||||
"EyeTracking.type.sdk2_desc": "Legacy (SDK2) eye tracking setup",
|
||||
"EyeTracking.adjust.label": "Adjust Eye Position",
|
||||
"EyeTracking.adjust.desc": "Adjust the position of eye bones based on vertex groups",
|
||||
"EyeTracking.sdk2_warning": "Legacy (SDK2) Eye Tracking Notice",
|
||||
"EyeTracking.sdk2_warning_detail1": "This system SHOULD NOT BE USED FOR VRChat,",
|
||||
"EyeTracking.sdk2_warning_detail2": "as eye tracking is now configured directly",
|
||||
"EyeTracking.sdk2_warning_detail3": "in Unity. It remains for other platforms.",
|
||||
"EyeTracking.sdk2_warning_detail4": "like ChilloutVR.",
|
||||
|
||||
"CustomPanel.label": "Custom Avatar Tools",
|
||||
"CustomPanel.merge_mode": "Merge Mode",
|
||||
@@ -380,6 +488,49 @@
|
||||
"MergeArmature.cleanup_shape_keys": "Clean Shape Keys",
|
||||
"MergeArmature.cleanup_shape_keys_desc": "Remove unused shape keys",
|
||||
|
||||
"TextureAtlas.atlas_completed": "Texture atlas creation completed",
|
||||
"TextureAtlas.atlas_error": "An error occurred during texture atlas creation",
|
||||
"TextureAtlas.atlas_materials": "Atlas Materials",
|
||||
"TextureAtlas.atlas_materials_desc": "Atlas materials to optimize the model",
|
||||
"TextureAtlas.label": "Texture Atlasing",
|
||||
"TextureAtlas.loaded_list": "Loaded Texture Atlas Material List",
|
||||
"TextureAtlas.material_list_label": "Texture Atlas Material List Material",
|
||||
"TextureAtlas.reload_list": "Reload Texture Atlas Material List",
|
||||
"TextureAtlas.error.label": "ERROR",
|
||||
"TextureAtlas.none.label": "None",
|
||||
"TextureAtlas.no_nodes_error.desc": "THIS MATERIAL DOES NOT USE NODES!",
|
||||
"TextureAtlas.no_images_error.desc": "THIS MATERIAL HAS NO IMAGES!",
|
||||
"TextureAtlas.texture_use_atlas.desc": "The texture that will be used for the {name} map atlas",
|
||||
"TextureAtlas.albedo": "Albedo",
|
||||
"TextureAtlas.normal": "Normal",
|
||||
"TextureAtlas.emission": "Emission",
|
||||
"TextureAtlas.ambient_occlusion": "Ambient Occlusion",
|
||||
"TextureAtlas.height": "Height",
|
||||
"TextureAtlas.roughness": "Roughness",
|
||||
"TextureAtlas.description_1": "Create a single material with combined textures",
|
||||
"TextureAtlas.description_2": "to optimize your avatar for better performance.",
|
||||
"TextureAtlas.texture_maps": "Texture Maps",
|
||||
"TextureAtlas.material_ready": "Material is ready for atlas",
|
||||
"TextureAtlas.material_not_ready": "Material needs at least one texture",
|
||||
"TextureAtlas.select_all_tooltip": "Select all materials for atlas",
|
||||
"TextureAtlas.select_none_tooltip": "Deselect all materials",
|
||||
"TextureAtlas.expand_all_tooltip": "Expand all material settings",
|
||||
"TextureAtlas.collapse_all_tooltip": "Collapse all material settings",
|
||||
"TextureAtlas.estimated_size": "Estimated Atlas Size",
|
||||
"TextureAtlas.materials": "materials",
|
||||
"TextureAtlas.no_materials_selected": "No materials selected",
|
||||
"TextureAtlas.select_armature_first": "Please select an armature first",
|
||||
"TextureAtlas.how_to_use_1": "1. Select an armature in the scene",
|
||||
"TextureAtlas.how_to_use_2": "2. Click 'Load Materials' to begin",
|
||||
"TextureAtlas.load_error": "Error loading materials. Check console for details.",
|
||||
"TextureAtlas.material_not_included": "Material is not included in atlas",
|
||||
"TextureAtlas.save_file_first": "Please save your Blender file before creating a texture atlas",
|
||||
"TextureAtlas.save_file_instructions": "Use File > Save As... or click the button below:",
|
||||
"TextureAtlas.save_file_button": "Save Blender File",
|
||||
"TextureAtlas.save_file_required": "Save File Required",
|
||||
"TextureAtlas.search_materials": "Search Materials",
|
||||
"TextureAtlas.search_materials_desc": "Filter materials by name",
|
||||
|
||||
"Settings.label": "Settings",
|
||||
"Settings.language": "Language",
|
||||
"Settings.language_desc": "Select interface language",
|
||||
@@ -397,12 +548,190 @@
|
||||
"Settings.enable_logging_desc": "Enable detailed debug logging for troubleshooting",
|
||||
"Settings.logging_enabled": "Debug logging enabled",
|
||||
"Settings.logging_disabled": "Debug logging disabled",
|
||||
"Settings.highlight_problem_bones": "Highlight Problem Bones",
|
||||
"Settings.highlight_problem_bones_desc": "Highlight bones with validation issues in the viewport",
|
||||
"Settings.bone_highlighting": "Bone Highlighting",
|
||||
"Settings.log_level": "Log Level",
|
||||
"Settings.log_level_desc": "Select the detail level for debug logging",
|
||||
"Settings.log_level.debug": "Debug",
|
||||
"Settings.log_level.debug_desc": "Show all log messages including detailed debug information",
|
||||
"Settings.log_level.info": "Info",
|
||||
"Settings.log_level.info_desc": "Show informational messages, warnings and errors",
|
||||
"Settings.log_level.warning": "Warning",
|
||||
"Settings.log_level.warning_desc": "Show only warnings and errors",
|
||||
"Settings.log_level.error": "Error",
|
||||
"Settings.log_level.error_desc": "Show only error messages",
|
||||
"Language.auto": "Automatic",
|
||||
"Language.en_US": "English",
|
||||
"Language.ja_JP": "Japanese",
|
||||
"Language.ko_KR": "Korean",
|
||||
"Language.changed.title": "Language Changed",
|
||||
"Language.changed.success": "Language changed successfully!",
|
||||
"Language.changed.restart": "Some UI elements may require restarting Blender"
|
||||
"Language.changed.restart": "Some UI elements may require restarting Blender",
|
||||
|
||||
"VRM.panel.label": "VRM to Unity",
|
||||
"VRM.converter.title": "VRM Converter",
|
||||
"VRM.no_armature_selected": "No armature selected",
|
||||
"VRM.select_armature_to_convert": "Select an armature to convert",
|
||||
"VRM.armature_name": "Armature: {name}",
|
||||
"VRM.armature_detected": "VRM armature detected",
|
||||
"VRM.no_vrm_bones_detected": "No VRM bones detected",
|
||||
"VRM.remove_colliders": "Remove Colliders",
|
||||
"VRM.remove_root_bone": "Remove Root Bone",
|
||||
"VRM.convert_to_unity_format": "Convert to Unity Format",
|
||||
"VRM.convert_to_unity.label": "Convert VRM to Unity",
|
||||
"VRM.convert_to_unity.desc": "Convert VRM armature bone names to Unity humanoid naming convention",
|
||||
"VRM.conversion_info.title": "Conversion Info:",
|
||||
"VRM.conversion_info.renames_bones": "• Renames VRM bones to Unity format",
|
||||
"VRM.conversion_info.removes_colliders": "• Removes collider bones (optional)",
|
||||
"VRM.conversion_info.removes_root": "• Removes root bone, makes Hips root (optional)",
|
||||
"VRM.conversion_info.maintains_hierarchy": "• Maintains bone hierarchy",
|
||||
"VRM.conversion_info.validates_results": "• Validates conversion results",
|
||||
"VRM.conversion_info.preserves_animations": "• Preserves all animations",
|
||||
"VRM.detection_failed.title": "VRM Detection Failed:",
|
||||
"VRM.detection_failed.not_vrm_format": "• Selected armature is not VRM format",
|
||||
"VRM.detection_failed.bones_start_with": "• VRM bones start with 'J_Bip_C_'",
|
||||
"VRM.detection_failed.need_five_bones": "• Need at least 5 VRM bones detected",
|
||||
"VRM.detection_failed.check_bone_names": "• Check armature bone names",
|
||||
"VRM.validation.hierarchy_passed": "Unity hierarchy validation passed",
|
||||
"VRM.validation.hierarchy_issues": "Conversion completed but hierarchy validation found issues:",
|
||||
"VRM.validation.armature_passed": "Armature passes standard validation",
|
||||
"VRM.validation.failed": "Conversion completed but validation failed: {error}",
|
||||
"VRM.remove_colliders_desc": "Remove VRM collider bones during conversion",
|
||||
"VRM.remove_root": "Remove Root Bone",
|
||||
"VRM.remove_root_desc": "Remove unnecessary VRM root bone and make Hips the root bone",
|
||||
|
||||
"MMD.panel.label": "MMD Converter",
|
||||
"MMD.converter.title": "MMD Armature Converter",
|
||||
"MMD.no_armature_selected": "No armature selected",
|
||||
"MMD.select_armature_to_convert": "Select an armature to convert",
|
||||
"MMD.armature_name": "Armature: {name}",
|
||||
"MMD.armature_detected": "MMD armature detected",
|
||||
"MMD.no_mmd_bones_detected": "No MMD bones detected",
|
||||
"MMD.not_mmd_armature": "Selected armature does not appear to be MMD format",
|
||||
"MMD.make_armature_parent": "Make Armature Main Parent",
|
||||
"MMD.rename_to_armature": "Rename to 'Armature'",
|
||||
"MMD.translate_names": "Translate Names to English",
|
||||
"MMD.translate_bones": "Bones",
|
||||
"MMD.translate_materials": "Materials",
|
||||
"MMD.translate_shapekeys": "Shape Keys",
|
||||
"MMD.translate_objects": "Objects",
|
||||
"MMD.restructure_bones": "Restructure to Unity Format",
|
||||
"MMD.bone_cleanup": "Bone Cleanup Options:",
|
||||
"MMD.remove_ik_bones": "Remove IK Bones",
|
||||
"MMD.remove_twist_bones": "Remove Twist Bones",
|
||||
"MMD.remove_zero_weight_bones": "Remove Zero Weight Bones",
|
||||
"MMD.translation_options": "Translation Options:",
|
||||
"MMD.convert_armature_button": "Convert MMD Armature",
|
||||
"MMD.convert_armature.label": "Convert MMD Armature",
|
||||
"MMD.convert_armature.desc": "Convert MMD armature to standard Blender format",
|
||||
"MMD.conversion_info.title": "Conversion Info:",
|
||||
"MMD.conversion_info.removes_parent": "• Removes parent Empty object",
|
||||
"MMD.conversion_info.renames_armature": "• Renames armature to 'Armature'",
|
||||
"MMD.conversion_info.restructures_bones": "• Converts to Unity bone structure (Hips/Spine/Chest)",
|
||||
"MMD.conversion_info.removes_ik_bones": "• Removes IK (Inverse Kinematics) bones",
|
||||
"MMD.conversion_info.removes_twist_bones": "• Removes twist bones",
|
||||
"MMD.conversion_info.removes_zero_weight_bones": "• Removes bones with zero vertex weights",
|
||||
"MMD.conversion_info.maintains_hierarchy": "• Maintains object hierarchy",
|
||||
"MMD.conversion_info.translates_names": "• Translates Japanese names to English",
|
||||
"MMD.detection_failed.title": "MMD Detection Failed:",
|
||||
"MMD.detection_failed.not_mmd_format": "• Selected armature is not MMD format",
|
||||
"MMD.detection_failed.need_mmd_bones": "• Need at least 5 MMD bones detected",
|
||||
"MMD.detection_failed.check_bone_names": "• Check armature bone names",
|
||||
"MMD.error.invalid_armature": "Invalid armature object",
|
||||
"MMD.error.not_mmd_armature": "Armature does not appear to be MMD format",
|
||||
"MMD.error.rename_failed": "Failed to rename armature: {error}",
|
||||
"MMD.armature_already_root": "Armature already has no parent",
|
||||
"MMD.armature_already_named": "Armature is already named 'Armature'",
|
||||
"MMD.parent_removed_and_reparented": "Removed parent '{parent_name}' and reparented {count} objects to armature",
|
||||
"MMD.parent_unlinked_and_reparented": "Unlinked from parent '{parent_name}' and reparented {count} objects",
|
||||
"MMD.parent_unlinked": "Unlinked armature from parent '{parent_name}'",
|
||||
"MMD.armature_renamed": "Renamed armature from '{old_name}' to '{new_name}'",
|
||||
"MMD.armature_renamed_with_suffix": "Renamed armature from '{old_name}' to '{new_name}' (name collision)",
|
||||
"MMD.conversion_complete": "MMD armature conversion completed successfully",
|
||||
"MMD.translation_starting": "Starting name translation...",
|
||||
"MMD.bones_translated": "Translated {count} bones",
|
||||
"MMD.bones_failed": "Failed to translate {count} bones",
|
||||
"MMD.materials_translated": "Translated {count} materials",
|
||||
"MMD.materials_failed": "Failed to translate {count} materials",
|
||||
"MMD.shapekeys_translated": "Translated {count} shape keys",
|
||||
"MMD.shapekeys_failed": "Failed to translate {count} shape keys",
|
||||
"MMD.objects_translated": "Translated {count} objects",
|
||||
"MMD.objects_failed": "Failed to translate {count} objects",
|
||||
"MMD.translation_complete": "Translation complete: {total} items translated",
|
||||
"MMD.restructure_starting": "Restructuring bones to Unity format...",
|
||||
"MMD.bones_restructured": "Restructured {count} bones to Unity format",
|
||||
"MMD.bones_removed": "Removed {count} unnecessary bones",
|
||||
"MMD.bones_reparented": "Reparented {count} bones",
|
||||
"MMD.restructure_failed": "Bone restructuring failed: {error}",
|
||||
"MMD.ik_bones_removed": "Removed {count} IK bones",
|
||||
"MMD.no_ik_bones_found": "No IK bones found to remove",
|
||||
"MMD.ik_removal_failed": "IK bone removal failed: {error}",
|
||||
"MMD.twist_bones_removed": "Removed {count} twist bones",
|
||||
"MMD.no_twist_bones_found": "No twist bones found to remove",
|
||||
"MMD.twist_removal_failed": "Twist bone removal failed: {error}",
|
||||
"MMD.zero_weight_bones_removed": "Removed {count} zero weight bones",
|
||||
"MMD.no_zero_weight_bones_found": "No zero weight bones found to remove",
|
||||
"MMD.zero_weight_removal_failed": "Zero weight bone removal failed: {error}",
|
||||
|
||||
"Translation.label": "Translation",
|
||||
"Translation.service": "Translation Service",
|
||||
"Translation.service_desc": "Choose the translation service to use",
|
||||
"Translation.mode": "Translation Mode",
|
||||
"Translation.mode_desc": "Select how translation should work",
|
||||
"Translation.mode.hybrid": "Hybrid (Dictionary + API)",
|
||||
"Translation.mode.hybrid_desc": "Try dictionary first, then use API service as fallback",
|
||||
"Translation.mode.dictionary_only": "Dictionary Only",
|
||||
"Translation.mode.dictionary_only_desc": "Only use built-in dictionaries for translation",
|
||||
"Translation.mode.api_only": "API Only",
|
||||
"Translation.mode.api_only_desc": "Only use online translation services",
|
||||
"Translation.service_settings": "Translation Service",
|
||||
"Translation.language_settings": "Language Settings",
|
||||
"Translation.quick_actions": "Quick Actions",
|
||||
"Translation.utilities": "Utilities",
|
||||
"Translation.advanced_settings": "Advanced Settings",
|
||||
"Translation.source_language": "Source Language",
|
||||
"Translation.source_language_desc": "Language to translate from",
|
||||
"Translation.target_language": "Target Language",
|
||||
"Translation.target_language_desc": "Language to translate to",
|
||||
"Translation.translate_names": "Translate Names",
|
||||
"Translation.translate_names_desc": "Translate names using the selected service and settings",
|
||||
"Translation.test_service": "Test Service",
|
||||
"Translation.test_service_desc": "Test the currently selected translation service",
|
||||
"Translation.clear_cache": "Clear Cache",
|
||||
"Translation.clear_cache_desc": "Clear all cached translations",
|
||||
"Translation.show_stats": "Show Statistics",
|
||||
"Translation.show_stats_desc": "Show translation statistics and information",
|
||||
"Translation.no_armature": "No armature selected",
|
||||
"Translation.test_failed": "Translation service test failed - check configuration",
|
||||
"Translation.cache_cleared": "Translation cache cleared successfully",
|
||||
"Translation.mymemory_info": "MyMemory is completely free with no API key required. Provides 1000 translations per day.",
|
||||
"Translation.service.mymemory": "MyMemory (Free)",
|
||||
"Translation.service.mymemory_desc": "Completely free service - no API key needed!",
|
||||
"Translation.service.libretranslate": "LibreTranslate",
|
||||
"Translation.service.libretranslate_desc": "Configurable server - can be self-hosted",
|
||||
"Translation.service.deepl": "DeepL",
|
||||
"Translation.service.deepl_desc": "High-quality translations - API key required",
|
||||
"Translation.type.bones": "Bones",
|
||||
"Translation.type.bones_desc": "Translate bone names",
|
||||
"Translation.type.shapekeys": "Shape Keys",
|
||||
"Translation.type.shapekeys_desc": "Translate shape key names",
|
||||
"Translation.type.materials": "Materials",
|
||||
"Translation.type.materials_desc": "Translate material names",
|
||||
"Translation.type.objects": "Objects",
|
||||
"Translation.type.objects_desc": "Translate object names",
|
||||
"Translation.type.all": "All",
|
||||
"Translation.type.all_desc": "Translate all supported types",
|
||||
"Translation.configure_deepl": "Configure DeepL API",
|
||||
"Translation.configure_deepl_desc": "Configure DeepL translation service API key",
|
||||
"Translation.deepl_api_key": "DeepL API Key",
|
||||
"Translation.deepl_api_key_desc": "Your DeepL API key (get free key at deepl.com/pro)",
|
||||
"Translation.configure_libretranslate": "Configure LibreTranslate Server",
|
||||
"Translation.configure_libretranslate_desc": "Configure LibreTranslate translation service server URL",
|
||||
"Translation.server_url": "Server URL",
|
||||
"Translation.server_url_desc": "LibreTranslate server URL (e.g., https://your-server.com)",
|
||||
"Translation.api_key": "API Key",
|
||||
"Translation.api_key_desc": "API key for LibreTranslate server (optional for some servers)"
|
||||
|
||||
}
|
||||
}
|
||||
+381
-143
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"authors": ["Avatar Toolkit Team"],
|
||||
"messages": {
|
||||
"AvatarToolkit.label": "アバターツールキット (Alpha 0.1.1)",
|
||||
"AvatarToolkit.desc1": "アバターツールキットは早期アクセス中で",
|
||||
"AvatarToolkit.label": "アバターツールキット (アルファ 0.6.0)",
|
||||
"AvatarToolkit.desc1": "アバターツールキットは早期アクセス中であり、",
|
||||
"AvatarToolkit.desc2": "問題が発生する可能性があります。問題を見つけた場合は、",
|
||||
"AvatarToolkit.desc3": "GitHubで報告してください。",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"Updater.CheckForUpdateButton.desc": "利用可能なアップデートを確認",
|
||||
"UpdateToLatestButton.desc": "最新バージョンにアップデート",
|
||||
"UpdateNotificationPopup.label": "アップデート通知",
|
||||
"UpdateNotificationPopup.desc": "利用可能なアップデートの通知",
|
||||
"UpdateNotificationPopup.desc": "利用可能なアップデートについての通知",
|
||||
"UpdateNotificationPopup.newUpdate": "新しいアップデートが利用可能: {version}",
|
||||
"RestartBlenderPopup.label": "Blenderを再起動",
|
||||
"RestartBlenderPopup.desc": "アップデートを完了するためにBlenderを再起動",
|
||||
@@ -24,7 +24,7 @@
|
||||
"check_for_update.cantCheck": "アップデートを確認できません",
|
||||
"download_file.cantConnect": "アップデートサーバーに接続できません",
|
||||
"download_file.cantFindZip": "アップデートファイルが見つかりません",
|
||||
"download_file.cantFindAvatarToolkit": "アップデートパッケージにAvatarToolkitファイルが見つかりません",
|
||||
"download_file.cantFindAvatarToolkit": "アップデートパッケージにアバターツールキットファイルが見つかりません",
|
||||
|
||||
"QuickAccess.label": "クイックアクセス",
|
||||
"QuickAccess.select_armature": "アーマチュアを選択",
|
||||
@@ -35,7 +35,7 @@
|
||||
"QuickAccess.import_export": "インポート/エクスポート",
|
||||
"QuickAccess.import": "インポート",
|
||||
"QuickAccess.export": "エクスポート",
|
||||
"QuickAccess.export_fbx": "FBXエクスポート",
|
||||
"QuickAccess.export_fbx": "FBXをエクスポート",
|
||||
"QuickAccess.export_resonite": "Resoniteにエクスポート",
|
||||
"QuickAccess.start_pose_mode.label": "ポーズモード開始",
|
||||
"QuickAccess.start_pose_mode.desc": "選択したアーマチュアのポーズモードに入る",
|
||||
@@ -43,37 +43,94 @@
|
||||
"QuickAccess.stop_pose_mode.desc": "ポーズモードを終了し、変形をクリア",
|
||||
"QuickAccess.apply_pose_as_shapekey.label": "ポーズをシェイプキーとして適用",
|
||||
"QuickAccess.apply_pose_as_shapekey.desc": "現在のポーズから新しいシェイプキーを作成",
|
||||
"QuickAccess.apply_pose_as_rest.label": "ポーズを初期位置として適用",
|
||||
"QuickAccess.apply_pose_as_rest.desc": "現在のポーズを初期位置として適用",
|
||||
"QuickAccess.apply_armature_failed": "アーマチュアの修正の適用に失敗しました",
|
||||
"QuickAccess.validation_basic_warning": "基本的な検証のみ有効",
|
||||
"QuickAccess.validation_basic_details": "基本的なボーン構造のみ検証しています",
|
||||
"QuickAccess.validation_none_warning": "検証無効",
|
||||
"QuickAccess.validation_none_details": "アーマチュアの検証は行われていません",
|
||||
"QuickAccess.apply_pose_as_rest.label": "ポーズを静止ポーズとして適用",
|
||||
"QuickAccess.apply_pose_as_rest.desc": "現在のポーズを静止ポーズとして適用",
|
||||
"QuickAccess.apply_armature_failed": "アーマチュアの変更の適用に失敗しました",
|
||||
"QuickAccess.validation_basic_warning": "限定的な検証がアクティブ",
|
||||
"QuickAccess.validation_basic_details": "基本的なボーン構造のみが検証されています",
|
||||
"QuickAccess.validation_none_warning": "検証が無効",
|
||||
"QuickAccess.validation_none_details": "アーマチュアの検証チェックが実行されていません",
|
||||
"Quick_Access.import_success": "インポート成功",
|
||||
|
||||
"PoseMode.error.start": "ポーズモードの開始に失敗: {error}",
|
||||
"PoseMode.error.stop": "ポーズモードの終了に失敗: {error}",
|
||||
"PoseMode.error.shapekey": "ポーズをシェイプキーとして適用に失敗: {error}",
|
||||
"PoseMode.error.rest_pose": "ポーズを初期位置として適用に失敗: {error}",
|
||||
"PoseMode.error.rest_pose": "ポーズを静止ポーズとして適用に失敗: {error}",
|
||||
"PoseMode.shapekey.name": "シェイプキー名",
|
||||
"PoseMode.shapekey.description": "新しいシェイプキーの名前",
|
||||
"PoseMode.shapekey.default": "ポーズ_シェイプキー",
|
||||
"PoseMode.skipped_meshes": "一部のメッシュがスキップされました:\n{message}",
|
||||
"PoseMode.basis": "基準",
|
||||
"PoseMode.basis": "基本形",
|
||||
|
||||
"Armature.validation.no_armature": "アーマチュアが選択されていません",
|
||||
"Armature.validation.pmx_model_detected": "PMXモデルが検出されました。日本語の骨名が標準の命名規則と一致しない場合があります。",
|
||||
"Armature.validation.pmx_model_strict": "「アーマチュアの標準化」オプションを使用して、日本語の骨名を標準名に変換することを検討してください。",
|
||||
"Armature.validation.pmx_model_standardize": "これにより、モデルが標準的なアバターシステムと互換性を持つようになります。",
|
||||
"Armature.validation.pmx_model_basic": "PMXモデルは日本語の骨名を使用しており、標準の命名規則と一致しない場合があります。",
|
||||
"Armature.validation.unknown_format": "不明なアーマチュア形式が検出されました。",
|
||||
"Validation.mode.none": "検証は設定で無効になっています。",
|
||||
"Validation.no_messages": "検証メッセージはありません。",
|
||||
"Armature.validation.not_armature": "選択されたオブジェクトはアーマチュアではありません",
|
||||
"Armature.validation.no_bones": "アーマチュアにボーンがありません",
|
||||
"Armature.validation.basic_check_failed": "基本的なアーマチュアの検証に失敗しました",
|
||||
"Armature.validation.missing_bones": "必須ボーンが不足: {bones}",
|
||||
"Armature.validation.basic_check_failed": "基本的なアーマチュア検証に失敗しました",
|
||||
"Armature.validation.missing_bones": "必須ボーンが不足しています: {bones}",
|
||||
"Armature.validation.invalid_hierarchy": "{parent}と{child}の間のボーン階層が無効です",
|
||||
"Armature.validation.asymmetric_bones": "{bone}の対称ボーンが不足しています",
|
||||
"Armature.validation.asymmetric_hand_wrist": "手首/手のボーンの対称性が不足しています",
|
||||
"Armature.validation.asymmetric_hand_wrist": "手首/手のための対称ボーンが不足しています",
|
||||
"Armature.validation.found_bones": "アーマチュアで見つかったボーン:\n- {bones}",
|
||||
"Armature.validation.non_standard_bones": "非標準ボーンが見つかりました:\n- {bones}",
|
||||
"Armature.validation.accessory_bones_note.line1": "髪のボーン、スカートのボーン、または他の",
|
||||
"Armature.validation.accessory_bones_note.line2": "アクセサリーボーンがメインアーマチュアのボーンと",
|
||||
"Armature.validation.accessory_bones_note.line3": "同様の名前(例:Head1、Head2)を持つ場合は、",
|
||||
"Armature.validation.accessory_bones_note.line4": "Hair_1、Skirt_1などのより説明的な名前に変更してください。",
|
||||
"Armature.validation.standardize_note.line1": "ツールセクションの「アーマチュアを標準化」",
|
||||
"Armature.validation.standardize_note.line2": "ボタンを使用して、アーマチュアを",
|
||||
"Armature.validation.standardize_note.line3": "自動的に標準化できます。",
|
||||
"Validation.section.found_bones": "見つかったボーン",
|
||||
"Validation.section.non_standard": "非標準ボーン",
|
||||
"Validation.section.hierarchy": "階層の問題",
|
||||
"Validation.status.failed": "検証に失敗しました",
|
||||
"Validation.message.failed.line1": "アーマチュアの検証に失敗しました",
|
||||
"Validation.message.failed.line2": "以下の問題を確認してください",
|
||||
"Validation.message.failed.line3": "",
|
||||
"Validation.highlight_problem_bones_desc": "検証に問題のあるボーンをビューポートで視覚的に強調表示",
|
||||
"Validation.no_armature": "アーマチュアが選択されていません",
|
||||
"Validation.no_issues": "強調表示する検証の問題が見つかりません",
|
||||
"Validation.highlighting_complete": "問題のあるボーンが正常に強調表示されました",
|
||||
"Validation.tpose.no_armature": "T-ポーズ検証用のアーマチュアが見つかりません",
|
||||
"Validation.tpose.left_arm_not_horizontal": "左腕が水平なT-ポーズの位置にありません",
|
||||
"Validation.tpose.right_arm_not_horizontal": "右腕が水平なT-ポーズの位置にありません",
|
||||
"Validation.tpose.spine_not_vertical": "背骨が垂直な位置にありません",
|
||||
"Validation.tpose.warning": "T-ポーズ検証の警告",
|
||||
"Validation.tpose.recommendation": "Unityやほかのプラットフォームにインポートする前にT-ポーズを修正することをお勧めします",
|
||||
"Validation.scale_issues": "異常なスケールを持つボーンが検出されました:",
|
||||
"Validation.scale_issue.too_small": "ボーンが極端に小さい",
|
||||
"Validation.scale_issue.too_large": "ボーンが極端に大きい",
|
||||
"Validation.section.scale_issues": "スケールの問題",
|
||||
"Validation.tpose.label": "T-ポーズを検証",
|
||||
"Validation.no_scale_issues": "スケールの問題は検出されませんでした",
|
||||
"Validation.no_hierarchy_issues": "階層の問題は検出されませんでした",
|
||||
"Validation.no_non_standard_issues": "非標準ボーンの問題は検出されませんでした",
|
||||
"Validation.tpose.valid": "T-ポーズの検証に成功しました",
|
||||
"Validation.tpose.desc": "アーマチュアが適切なT-ポーズにあるかチェック",
|
||||
"Validation.highlight_problem_bones": "問題のあるボーンを強調表示",
|
||||
"Validation.clear_bone_highlighting": "ボーンの強調表示をクリア",
|
||||
"Validation.clear_bone_highlighting_desc": "ボーンの強調表示を削除し、ボーンの色をデフォルトにリセット",
|
||||
"Validation.highlighting_cleared": "ボーンの強調表示が正常にクリアされました",
|
||||
"Validation.label": "アーマチュア検証",
|
||||
"Validation.validate_now": "アーマチュアを検証する",
|
||||
"Validation.validate_now_desc": "アーマチュア検証を実行し、詳細な結果を表示",
|
||||
"Validation.results": "検証結果",
|
||||
"Validation.tpose.validate_now": "T-ポーズを検証する",
|
||||
|
||||
"Armature.validation.acceptable_standard.success": "アーマチュアが許容可能な標準を満たしています",
|
||||
"Armature.validation.acceptable_standard.note": "これは、ほとんどのアバターシステムと互換性のある有効なアーマチュア形式です",
|
||||
"Armature.validation.acceptable_standard.option": "必要に応じてアーマチュアを標準化できます",
|
||||
|
||||
"Mesh.validation.no_data": "メッシュデータがありません",
|
||||
"Mesh.validation.no_vertex_groups": "頂点グループが見つかりません",
|
||||
"Mesh.validation.no_armature_modifier": "アーマチュアモディファイアがありません",
|
||||
"Mesh.validation.valid": "ポーズ操作に有効なメッシュです",
|
||||
"Mesh.validation.valid": "ポーズ操作に有効なメッシュ",
|
||||
|
||||
"Operation.pose_applied": "ポーズが正常に適用されました",
|
||||
|
||||
@@ -85,22 +142,22 @@
|
||||
"Optimization.cleanup_title": "メッシュクリーンアップ",
|
||||
"Optimization.join_meshes_title": "メッシュ結合",
|
||||
"Optimization.combine_materials": "マテリアルを結合",
|
||||
"Optimization.combine_materials_desc": "類似したマテリアルを結合してドローコールを減らす",
|
||||
"Optimization.combine_materials_desc": "描画コールを減らすために類似したマテリアルを結合",
|
||||
"Optimization.remove_doubles": "重複頂点を削除",
|
||||
"Optimization.remove_doubles_desc": "重複した頂点を削除",
|
||||
"Optimization.remove_doubles_advanced": "高度な設定",
|
||||
"Optimization.remove_doubles_advanced_desc": "高度なオプションで重複頂点を削除",
|
||||
"Optimization.join_all_meshes": "すべて結合",
|
||||
"Optimization.join_all_meshes_desc": "シーン内のすべてのメッシュを結合",
|
||||
"Optimization.join_selected_meshes": "選択を結合",
|
||||
"Optimization.join_selected_meshes": "選択したものを結合",
|
||||
"Optimization.join_selected_meshes_desc": "選択したメッシュのみを結合",
|
||||
"Optimization.no_meshes": "最適化するメッシュが見つかりません",
|
||||
"Optimization.materials_combined": "{combined}個のマテリアルを結合し、{cleaned}個のスロットをクリーンアップし、{removed}個の未使用データブロックを削除しました",
|
||||
"Optimization.error.combine_materials": "マテリアルの結合に失敗: {error}",
|
||||
"Optimization.materials_total": "合計マテリアル数: {count}",
|
||||
"Optimization.materials_duplicates": "重複の可能性: {count}",
|
||||
"Optimization.materials_total": "合計マテリアル: {count}",
|
||||
"Optimization.materials_duplicates": "潜在的な重複: {count}",
|
||||
"Optimization.no_materials": "メッシュにマテリアルが見つかりません",
|
||||
"Optimization.error.consolidation": "マテリアルの統合に失敗しました。コンソールで詳細を確認してください",
|
||||
"Optimization.error.consolidation": "マテリアルの統合に失敗しました。詳細はコンソールを確認してください",
|
||||
"Optimization.combining_materials": "類似したマテリアルを結合中...",
|
||||
"Optimization.cleaning_slots": "マテリアルスロットをクリーニング中...",
|
||||
"Optimization.removing_unused": "未使用のマテリアルを削除中...",
|
||||
@@ -108,7 +165,7 @@
|
||||
"Optimization.joining_meshes": "メッシュを結合中...",
|
||||
"Optimization.applying_transforms": "変形を適用中...",
|
||||
"Optimization.fixing_uvs": "UV座標を修正中...",
|
||||
"Optimization.finalizing": "完了処理中...",
|
||||
"Optimization.finalizing": "完了中...",
|
||||
"Optimization.meshes_joined": "すべてのメッシュが正常に結合されました",
|
||||
"Optimization.selected_meshes_joined": "選択したメッシュが正常に結合されました",
|
||||
"Optimization.no_mesh_selected": "メッシュが選択されていません",
|
||||
@@ -116,101 +173,129 @@
|
||||
"Optimization.error.join_meshes": "メッシュの結合に失敗: {error}",
|
||||
"Optimization.error.join_selected": "選択したメッシュの結合に失敗: {error}",
|
||||
"Optimization.merge_distance": "結合距離",
|
||||
"Optimization.merge_distance_desc": "頂点を結合する距離の閾値",
|
||||
"Optimization.remove_doubles_warning": "この処理には時間がかかる場合があります",
|
||||
"Optimization.remove_doubles_wait": "この操作中、Blenderは応答しないように見える場合があります",
|
||||
"Optimization.merge_distance_desc": "頂点が結合される距離",
|
||||
"Optimization.remove_doubles_warning": "このプロセスは時間がかかる場合があります",
|
||||
"Optimization.remove_doubles_wait": "この操作中、Blenderが応答しなくなる場合があります",
|
||||
"Optimization.error.remove_doubles": "重複頂点の削除に失敗: {error}",
|
||||
"Optimization.no_armature": "アーマチュアが選択されていません",
|
||||
"Optimization.processing_mesh": "メッシュ処理中: {name}",
|
||||
"Optimization.processing_shapekey": "シェイプキー処理中: {name}",
|
||||
"Optimization.processing_mesh": "メッシュを処理中: {name}",
|
||||
"Optimization.processing_shapekey": "シェイプキーを処理中: {name}",
|
||||
"Optimization.remove_doubles_completed": "重複頂点の削除が正常に完了しました",
|
||||
|
||||
"Tools.label": "ツール",
|
||||
"Tools.general_title": "一般ツール",
|
||||
"Tools.select_armature": "アーマチュアを選択",
|
||||
"Tools.convert_resonite": "Resoniteに変換",
|
||||
"Tools.convert_resonite_desc": "Resonite用にモデルを変換",
|
||||
"Tools.convert_resonite_desc": "Resoniteで使用するためにモデルを変換",
|
||||
"Tools.convert_resonite.operation": "Resoniteに変換中",
|
||||
"Tools.separate_title": "分離ツール",
|
||||
"Tools.separate_materials": "マテリアルで分離",
|
||||
"Tools.separate_materials_desc": "マテリアルごとにメッシュを分離",
|
||||
"Tools.separate_loose": "分離パーツ",
|
||||
"Tools.separate_loose_desc": "メッシュを分離パーツに分割",
|
||||
"Tools.separate_materials_success": "メッシュをマテリアルごとに正常に分離しました",
|
||||
"Tools.separate_loose_success": "メッシュを分離パーツに正常に分割しました",
|
||||
"Tools.separate_loose": "離れた部分で分離",
|
||||
"Tools.separate_loose_desc": "メッシュを離れた部分に分離",
|
||||
"Tools.separate_materials_success": "メッシュがマテリアルごとに正常に分離されました",
|
||||
"Tools.separate_loose_success": "メッシュが離れた部分に正常に分離されました",
|
||||
"Tools.bone_title": "ボーンツール",
|
||||
"Tools.create_digitigrade": "デジタイグレード脚を作成",
|
||||
"Tools.create_digitigrade_desc": "脚をデジタイグレード設定に変換",
|
||||
"Tools.digitigrade": "デジタイグレード脚を作成",
|
||||
"Tools.digitigrade_desc": "選択した脚のボーンをデジタイグレード設定に変換",
|
||||
"Tools.digitigrade_error": "デジタイグレード脚の作成に失敗: {error}",
|
||||
"Tools.digitigrade_success": "デジタイグレード脚の設定が正常に作成されました",
|
||||
"Tools.processing_leg": "脚のボーン処理中: {bone}",
|
||||
"Tools.create_digitigrade": "デジティグレード脚を作成",
|
||||
"Tools.create_digitigrade_desc": "脚をデジティグレード設定に変換",
|
||||
"Tools.digitigrade": "デジティグレード脚を作成",
|
||||
"Tools.digitigrade_desc": "選択した脚のボーンをデジティグレード設定に変換",
|
||||
"Tools.digitigrade_error": "デジティグレード脚の作成に失敗: {error}",
|
||||
"Tools.digitigrade_success": "デジティグレード脚の設定が正常に作成されました",
|
||||
"Tools.processing_leg": "脚のボーンを処理中: {bone}",
|
||||
"Tools.weight_title": "ウェイトツール",
|
||||
"Tools.merge_twist_bones": "ツイストボーンを保持",
|
||||
"Tools.merge_twist_bones_desc": "チェックすると、重みが0でもツイストボーンを保持します",
|
||||
"Tools.clean_weights": "重みなしボーンを削除",
|
||||
"Tools.clean_weights_desc": "頂点の重みがないボーンを削除",
|
||||
"Tools.clean_constraints": "ボーンのコンストレイントを削除",
|
||||
"Tools.clean_constraints_desc": "アーマチュアからすべてのボーンコンストレイントを削除",
|
||||
"Tools.clean_constraints_success": "{count}個のボーンコンストレイントを削除しました",
|
||||
"Tools.processing_bone_constraints": "ボーンのコンストレイント削除中: {bone}",
|
||||
"Tools.clean_weights_success": "{count}個の重みなしボーンを削除しました",
|
||||
"Tools.clean_weights_threshold": "重みの閾値",
|
||||
"Tools.clean_weights_threshold_desc": "ボーンに重みがあると判断する最小値",
|
||||
"Tools.merge_twist_bones_desc": "チェックすると、ウェイトがゼロでもツイストボーンが保持されます",
|
||||
"Tools.clean_weights": "ゼロウェイトボーンを削除",
|
||||
"Tools.clean_weights_desc": "頂点ウェイトのないボーンを削除",
|
||||
"Tools.preserve_parent_bones": "親ボーンを保持",
|
||||
"Tools.preserve_parent_bones_desc": "ウェイトがなくても子を持つボーンを保持",
|
||||
"Tools.target_bone_type": "対象ボーンタイプ",
|
||||
"Tools.target_bone_type_desc": "処理するボーンのタイプをフィルタリング",
|
||||
"Tools.target_all_bones": "すべてのボーン",
|
||||
"Tools.target_deform_bones": "変形ボーンのみ",
|
||||
"Tools.target_non_deform_bones": "非変形ボーンのみ",
|
||||
"Tools.list_only_mode": "リストモードのみ",
|
||||
"Tools.list_only_mode_desc": "ゼロウェイトボーンを削除する代わりにリスト表示",
|
||||
"Tools.zero_weight_bones_found": "ゼロウェイトボーンが見つかりました: {bones}",
|
||||
"Tools.remove_selected_bones": "選択したボーンを削除",
|
||||
"Tools.remove_selected_bones_desc": "選択したゼロウェイトボーンをアーマチュアから削除",
|
||||
"Tools.bones_removed": "{count}個のボーンを削除しました",
|
||||
"Tools.clean_constraints": "ボーン制約を削除",
|
||||
"Tools.clean_constraints_desc": "アーマチュアからすべてのボーン制約を削除",
|
||||
"Tools.clean_constraints_success": "{count}個のボーン制約を削除しました",
|
||||
"Tools.processing_bone_constraints": "ボーンから制約を削除中: {bone}",
|
||||
"Tools.clean_weights_success": "{count}個のゼロウェイトボーンを削除しました",
|
||||
"Tools.clean_weights_threshold": "ウェイトしきい値",
|
||||
"Tools.clean_weights_threshold_desc": "ボーンがウェイト付けされていると見なす最小ウェイト値",
|
||||
"Tools.merge_title": "結合ツール",
|
||||
"Tools.merge_to_active": "アクティブに結合",
|
||||
"Tools.merge_to_active_desc": "選択したボーンをアクティブなボーンに結合",
|
||||
"Tools.merge_to_active_desc": "選択したボーンをアクティブボーンに結合",
|
||||
"Tools.merge_to_parent": "親に結合",
|
||||
"Tools.merge_to_parent_desc": "ボーンをそれぞれの親ボーンに結合",
|
||||
"Tools.merge_to_parent_desc": "ボーンをそれぞれの親に結合",
|
||||
"Tools.connect_bones": "ボーンを接続",
|
||||
"Tools.connect_bones_desc": "チェーン内の未接続のボーンを接続",
|
||||
"Tools.connect_bones_desc": "チェーン内の切断されたボーンを接続",
|
||||
"Tools.additional_title": "追加ツール",
|
||||
"Tools.apply_transforms": "変形を適用",
|
||||
"Tools.apply_transforms_desc": "オブジェクトのすべての変形を適用",
|
||||
"Tools.apply_transforms_desc": "オブジェクトにすべての変形を適用",
|
||||
"Tools.clean_shapekeys": "未使用のシェイプキーを削除",
|
||||
"Tools.clean_shapekeys_desc": "メッシュから未使用のシェイプキーを削除",
|
||||
"Tools.bones_translated_success": "すべてのボーンが正常に変換されました",
|
||||
"Tools.bones_translated_with_fails": "変換完了({translate_bone_fails}個のボーンは未変換)",
|
||||
"Tools.bones_translated_success": "すべてのボーンが正常に翻訳されました",
|
||||
"Tools.bones_translated_with_fails": "翻訳が完了しましたが、{translate_bone_fails}個のボーンは翻訳されませんでした",
|
||||
"Tools.storing_transforms": "ボーンの変形を保存中...",
|
||||
"Tools.analyzing_weights": "頂点の重みを分析中...",
|
||||
"Tools.removing_bones": "重みのないボーンを削除中...",
|
||||
"Tools.analyzing_weights": "頂点ウェイトを分析中...",
|
||||
"Tools.removing_bones": "ウェイトのないボーンを削除中...",
|
||||
"Tools.verifying_hierarchy": "ボーン階層を検証中...",
|
||||
"Tools.connect_bones_min_distance": "最小距離",
|
||||
"Tools.connect_bones_min_distance_desc": "ボーンを接続する最小距離",
|
||||
"Tools.connect_bones_min_distance_desc": "接続を試みるボーン間の最小距離",
|
||||
"Tools.connect_bones_success": "{count}個のボーンを接続しました",
|
||||
"Tools.merge_weights_threshold": "重み転送閾値",
|
||||
"Tools.merge_weights_threshold_desc": "ボーン結合時に転送する最小重み値",
|
||||
"Tools.merge_weights_threshold": "ウェイト転送しきい値",
|
||||
"Tools.merge_weights_threshold_desc": "ボーンを結合する際に転送する最小ウェイト値",
|
||||
"Tools.no_bones_selected": "結合するボーンが選択されていません",
|
||||
"Tools.no_bones_with_parent": "親を持つ選択ボーンが見つかりません",
|
||||
"Tools.no_bones_with_parent": "親を持つ選択されたボーンが見つかりません",
|
||||
"Tools.merge_to_active_success": "{count}個のボーンをアクティブボーンに正常に結合しました",
|
||||
"Tools.merge_to_parent_success": "{count}個のボーンを親ボーンに正常に結合しました",
|
||||
"Tools.merge_to_parent_success": "{count}個のボーンをそれぞれの親に正常に結合しました",
|
||||
"Tools.transforms_applied": "変形が正常に適用されました",
|
||||
"Tools.shapekey_tolerance": "シェイプキーの許容値",
|
||||
"Tools.shapekey_tolerance_desc": "シェイプキーを使用済みと判断する最小差分",
|
||||
"Tools.shapekey_tolerance": "シェイプキー許容値",
|
||||
"Tools.shapekey_tolerance_desc": "シェイプキーが使用されていると見なす最小差異",
|
||||
"Tools.shapekeys_removed": "{count}個の未使用シェイプキーを削除しました",
|
||||
"Tools.rigify_title": "Rigifyツール",
|
||||
"Tools.convert_rigify_to_unity": "RigifyをUnityに変換",
|
||||
"Tools.convert_rigify_to_unity_desc": "RigifyアーマチュアをUnity互換形式に変換",
|
||||
"Tools.rigify_converted": "Rigifyアーマチュアが正常に変換されました",
|
||||
"Tools.no_armature": "アーマチュアが選択されていません",
|
||||
"Tools.standardize_title": "標準化",
|
||||
"Tools.standardize_armature": "アーマチュアを標準化",
|
||||
"Tools.standardize_armature_desc": "非標準アーマチュアをアバターツールキット標準に変換",
|
||||
"Tools.standardize_fix_names": "ボーン名を修正",
|
||||
"Tools.standardize_fix_names_desc": "ボーンの名前を標準的な命名規則に合わせて変更",
|
||||
"Tools.standardize_fix_hierarchy": "ボーン階層を修正",
|
||||
"Tools.standardize_fix_hierarchy_desc": "ボーン間の親子関係を修正",
|
||||
"Tools.standardize_fix_scale": "ボーンスケールを修正",
|
||||
"Tools.standardize_fix_scale_desc": "スケールの問題を修正するためにボーンの長さを正規化",
|
||||
"Tools.standardize_warning": "この操作はアーマチュアを変更します。先にバックアップを作成してください!",
|
||||
"Tools.standardize_success": "アーマチュアが正常に標準化されました",
|
||||
"Tools.standardize_partial": "アーマチュアが部分的に標準化されました。一部の問題が残っています。",
|
||||
"Tools.standardize_already_valid": "アーマチュアはすでに標準を満たしています。変更は必要ありません。",
|
||||
"Tools.standardize_issues_title": "標準化の問題",
|
||||
"Tools.standardize_issues_header": "標準化後もいくつかの問題が残っています",
|
||||
"Tools.standardize_issues_line1": "これは、アバターのボーンに認識されない非標準ボーンの",
|
||||
"Tools.standardize_issues_line2": "リストにない固有の名前があるためかもしれません。",
|
||||
"Tools.standardize_issues_line3": "例えば、ヒップボーンが「THISISMYHIPS」という名前の場合、検出できません。",
|
||||
"Tools.standardize_issues_line4": "メインスケルトンボーンが認識されない場合は、",
|
||||
"Tools.standardize_issues_line5": "データベースに追加できるよう、GitHubで報告してください。",
|
||||
"Tools.standardize_issues_line6": "アクセサリーボーン(髪、服など)は手動で名前を変更する必要があります。",
|
||||
|
||||
"MMD.label": "MMDツール",
|
||||
"MMD.bone_standardization": "ボーン標準化",
|
||||
"MMD.weight_processing": "ウェイト処理",
|
||||
"MMD.hierarchy": "ボーン階層",
|
||||
"MMD.cleanup": "クリーンアップ",
|
||||
"MMD.no_armature": "アーマチュアが選択されていません",
|
||||
"MMD.no_meshes": "メッシュが見つかりません",
|
||||
"MMD.validation.rigify_unsupported": "Rigifyアーマチュアはサポートされていません",
|
||||
"MMD.validation.multi_user_mesh": "複数ユーザーメッシュが検出されました: {mesh}",
|
||||
"MMD.bones_standardized": "ボーンが正常に標準化されました",
|
||||
"MMD.weights_processed": "ウェイトが正常に処理されました",
|
||||
"MMD.hierarchy_fixed": "ボーン階層が正常に修正されました",
|
||||
"MMD.hierarchy_validation_warning": "一部の階層関係を検証できませんでした",
|
||||
"MMD.cleanup_completed": "アーマチュアのクリーンアップが完了しました",
|
||||
"MMD.process_twist_bones": "ツイストボーンを処理",
|
||||
"MMD.process_twist_bones_desc": "ツイストボーンの重みを親ボーンに転送",
|
||||
"MMD.connect_bones": "ボーンを接続",
|
||||
"MMD.connect_bones_desc": "適切な場所でボーンチェーンを接続",
|
||||
"UVTools.uv_title": "UVツール",
|
||||
"UVTools.too_many_vertices": "エラー!選択項目が多すぎます。2つのエッジを選択していますか?",
|
||||
"UVTools.need_line": "選択した各オブジェクトにUVポイントの線が1つ必要です。オブジェクト「{obj}」はこの要件を満たしていません!",
|
||||
"UVTools.align_edges": "UVエッジをターゲットに合わせる",
|
||||
"UVTools.align_edges_desc": "選択した各メッシュのUVポイントの線をアクティブメッシュの選択したUVポイントの線に合わせます。あるモデルのテクスチャを別のモデルに適用する際に便利です。2Dカーソルからの距離を使用して、各メッシュのUVポイントの線の開始点を識別します。",
|
||||
|
||||
"Visemes.panel_label": "ビセーム",
|
||||
"Visemes.panel_label": "口形素",
|
||||
"Visemes.shape_selection": "シェイプキー選択",
|
||||
"Visemes.controls": "ビセームコントロール",
|
||||
"Visemes.no_shapekeys": "シェイプキーのあるメッシュを選択してください",
|
||||
"Visemes.controls": "口形素コントロール",
|
||||
"Visemes.no_shapekeys": "シェイプキーを持つメッシュを選択",
|
||||
"Visemes.mouth_a": "A形状",
|
||||
"Visemes.mouth_a_desc": "'A'音のシェイプキー",
|
||||
"Visemes.mouth_o": "O形状",
|
||||
@@ -218,21 +303,22 @@
|
||||
"Visemes.mouth_ch": "CH形状",
|
||||
"Visemes.mouth_ch_desc": "'CH'音のシェイプキー",
|
||||
"Visemes.shape_intensity": "形状の強度",
|
||||
"Visemes.shape_intensity_desc": "ビセーム形状の強度乗数",
|
||||
"Visemes.shape_intensity_desc": "口形素形状の強度乗数",
|
||||
"Visemes.start_preview": "プレビュー開始",
|
||||
"Visemes.stop_preview": "プレビュー停止",
|
||||
"Visemes.preview_mode_desc": "ビセームプレビューモードの切り替え",
|
||||
"Visemes.preview_mode_desc": "口形素プレビューモードを切り替え",
|
||||
"Visemes.preview_selection": "プレビュー選択",
|
||||
"Visemes.preview_selection_desc": "プレビューするビセームを選択",
|
||||
"Visemes.preview_label": "ビセームプレビュー",
|
||||
"Visemes.preview_desc": "ビューポートでビセーム形状をプレビュー",
|
||||
"Visemes.create_label": "ビセームを作成",
|
||||
"Visemes.create_desc": "VRCビセームシェイプキーを作成",
|
||||
"Visemes.preview_selection_desc": "プレビューする口形素を選択",
|
||||
"Visemes.preview_label": "口形素をプレビュー",
|
||||
"Visemes.preview_desc": "ビューポートで口形素形状をプレビュー",
|
||||
"Visemes.create_label": "口形素を作成",
|
||||
"Visemes.create_desc": "VRC口形素シェイプキーを作成",
|
||||
"Visemes.error.no_shapekeys": "メッシュにシェイプキーがありません",
|
||||
"Visemes.error.select_shapekeys": "A、O、CHのシェイプキーを選択してください",
|
||||
"Visemes.success": "ビセームが正常に作成されました",
|
||||
"Visemes.error.select_shapekeys": "A、OおよびCHのシェイプキーを選択してください",
|
||||
"Visemes.success": "口形素が正常に作成されました",
|
||||
"Visemes.mesh_select": "メッシュを選択",
|
||||
"Visemes.mesh_select_desc": "ビセームを作成するメッシュを選択",
|
||||
"Visemes.mesh_select_desc": "口形素を作成するメッシュを選択",
|
||||
"Visemes.no_meshes": "メッシュが見つかりません",
|
||||
|
||||
"EyeTracking.label": "アイトラッキング",
|
||||
"EyeTracking.setup": "アイトラッキング設定",
|
||||
@@ -252,9 +338,9 @@
|
||||
"EyeTracking.no_armature": "アーマチュアが選択されていません",
|
||||
"EyeTracking.no_mesh": "メッシュが見つかりません",
|
||||
"EyeTracking.create.label": "アイトラッキングを作成",
|
||||
"EyeTracking.create.desc": "アイトラッキングのボーンとシェイプキーを設定",
|
||||
"EyeTracking.create.desc": "アイトラッキングボーンとシェイプキーを設定",
|
||||
"EyeTracking.testing.start.label": "テスト開始",
|
||||
"EyeTracking.testing.start.desc": "アイトラッキングテストモードを開始",
|
||||
"EyeTracking.testing.start.desc": "アイトラッキングテストモードに入る",
|
||||
"EyeTracking.testing.stop.label": "テスト停止",
|
||||
"EyeTracking.testing.stop.desc": "アイトラッキングテストモードを終了",
|
||||
"EyeTracking.reset.label": "アイトラッキングをリセット",
|
||||
@@ -264,22 +350,22 @@
|
||||
"EyeTracking.iris.label": "虹彩の高さを調整",
|
||||
"EyeTracking.iris.desc": "虹彩の頂点の高さを調整",
|
||||
"EyeTracking.blink.test.label": "まばたきテスト",
|
||||
"EyeTracking.blink.test.desc": "まばたきのシェイプキーをテスト",
|
||||
"EyeTracking.blink.test.desc": "目のまばたきシェイプキーをテスト",
|
||||
"EyeTracking.lowerlid.test.label": "下まぶたテスト",
|
||||
"EyeTracking.lowerlid.test.desc": "下まぶたのシェイプキーをテスト",
|
||||
"EyeTracking.lowerlid.test.desc": "下まぶたシェイプキーをテスト",
|
||||
"EyeTracking.blink.reset.label": "まばたきテストをリセット",
|
||||
"EyeTracking.blink.reset.desc": "まばたきテストの値をリセット",
|
||||
"EyeTracking.blink.reset.desc": "まばたきテスト値をリセット",
|
||||
"EyeTracking.validation.noArmature": "シーンにアーマチュアが見つかりません",
|
||||
"EyeTracking.validation.noMesh": "メッシュ'{mesh}'が見つかりません",
|
||||
"EyeTracking.validation.noShapekeys": "選択したメッシュにシェイプキーがありません",
|
||||
"EyeTracking.validation.leftEye": "左目",
|
||||
"EyeTracking.validation.rightEye": "右目",
|
||||
"EyeTracking.validation.missingGroups": "不足している頂点グループ: {groups}",
|
||||
"EyeTracking.validation.missingBones": "必要なボーンが不足: {bones}",
|
||||
"EyeTracking.validation.missingBones": "必要なボーンが不足しています: {bones}",
|
||||
"EyeTracking.validation.success": "アイトラッキング設定が正常に検証されました",
|
||||
"EyeTracking.error.noMesh": "アイトラッキング用のメッシュが選択されていません",
|
||||
"EyeTracking.error.noVertexGroup": "ボーン用の頂点グループが見つかりません: {bone}",
|
||||
"EyeTracking.error.noShapeSelected": "必要なすべてのシェイプキーを選択してください",
|
||||
"EyeTracking.error.noShapeSelected": "すべての必要なシェイプキーを選択してください",
|
||||
"EyeTracking.success": "アイトラッキング設定が正常に完了しました",
|
||||
"EyeTracking.mode_select": "モード選択",
|
||||
"EyeTracking.mesh_setup": "メッシュ設定",
|
||||
@@ -289,13 +375,13 @@
|
||||
"EyeTracking.rotation_controls": "目の回転コントロール",
|
||||
"EyeTracking.adjustments": "目の調整",
|
||||
"EyeTracking.blink_testing": "まばたきテスト",
|
||||
"EyeTracking.wink_left": "左目のウィンク",
|
||||
"EyeTracking.wink_right": "右目のウィンク",
|
||||
"EyeTracking.wink_left": "左ウィンク",
|
||||
"EyeTracking.wink_right": "右ウィンク",
|
||||
"EyeTracking.lowerlid_left": "左下まぶた",
|
||||
"EyeTracking.lowerlid_right": "右下まぶた",
|
||||
"EyeTracking.mode.creation": "作成モード",
|
||||
"EyeTracking.mode.testing": "テストモード",
|
||||
"EyeTracking.disable_blinking": "まばたきを無効化",
|
||||
"EyeTracking.disable_blinking": "目のまばたきを無効化",
|
||||
"EyeTracking.disable_movement": "目の動きを無効化",
|
||||
"EyeTracking.distance": "目の距離",
|
||||
"EyeTracking.distance_desc": "目の間の距離を調整",
|
||||
@@ -303,21 +389,26 @@
|
||||
"EyeTracking.mesh_name": "メッシュ",
|
||||
"EyeTracking.mesh_name_desc": "アイトラッキング用のメッシュを選択",
|
||||
"EyeTracking.head_bone_desc": "頭部ボーンを選択",
|
||||
"EyeTracking.eye_left_desc": "左目のボーンを選択",
|
||||
"EyeTracking.eye_right_desc": "右目のボーンを選択",
|
||||
"EyeTracking.eye_left_desc": "左目ボーンを選択",
|
||||
"EyeTracking.eye_right_desc": "右目ボーンを選択",
|
||||
"EyeTracking.type": "アイトラッキングタイプ",
|
||||
"EyeTracking.type_desc": "作成するアイトラッキング設定のタイプを選択",
|
||||
"EyeTracking.create.av3.label": "AV3アイトラッキングを作成",
|
||||
"EyeTracking.create.av3.desc": "VRChat Avatar 3.0用のアイトラッキングを設定",
|
||||
"EyeTracking.create.av3.desc": "VRChatアバター3.0用のアイトラッキングを設定",
|
||||
"EyeTracking.create.sdk2.label": "SDK2アイトラッキングを作成",
|
||||
"EyeTracking.create.sdk2.desc": "VRChat SDK2用のアイトラッキングを設定",
|
||||
"EyeTracking.sdk_version": "SDKバージョン",
|
||||
"EyeTracking.type.av3": "Avatar 3.0",
|
||||
"EyeTracking.type.av3_desc": "VRChat Avatar 3.0アイトラッキング設定",
|
||||
"EyeTracking.type.sdk2": "SDK2(レガシー)",
|
||||
"EyeTracking.type.sdk2_desc": "VRChat SDK2アイトラッキング設定",
|
||||
"EyeTracking.type.av3": "アバター3.0",
|
||||
"EyeTracking.type.av3_desc": "VRChatアバター3.0アイトラッキング設定",
|
||||
"EyeTracking.type.sdk2": "レガシー (ChilloutVR)",
|
||||
"EyeTracking.type.sdk2_desc": "レガシー (SDK2) アイトラッキング設定",
|
||||
"EyeTracking.adjust.label": "目の位置を調整",
|
||||
"EyeTracking.adjust.desc": "頂点グループに基づいて目のボーンの位置を調整",
|
||||
"EyeTracking.sdk2_warning": "レガシー (SDK2) アイトラッキング注意",
|
||||
"EyeTracking.sdk2_warning_detail1": "このシステムはVRChatには使用すべきではありません。",
|
||||
"EyeTracking.sdk2_warning_detail2": "アイトラッキングは現在Unity内で",
|
||||
"EyeTracking.sdk2_warning_detail3": "直接設定されるためです。ChilloutVRなどの",
|
||||
"EyeTracking.sdk2_warning_detail4": "他のプラットフォーム用に残されています。",
|
||||
|
||||
"CustomPanel.label": "カスタムアバターツール",
|
||||
"CustomPanel.merge_mode": "結合モード",
|
||||
@@ -326,41 +417,41 @@
|
||||
"CustomPanel.select_bone": "ボーンを選択",
|
||||
"CustomPanel.select_armature": "アーマチュアを選択",
|
||||
"CustomPanel.mode.armature": "アーマチュア",
|
||||
"CustomPanel.mode.armature_desc": "アーマチュアを結合",
|
||||
"CustomPanel.mode.armature_desc": "アーマチュアを一緒に結合",
|
||||
"CustomPanel.mode.mesh": "メッシュ",
|
||||
"CustomPanel.mode.mesh_desc": "メッシュをアーマチュアに接続",
|
||||
"CustomPanel.mode.mesh_desc": "メッシュをアーマチュアに取り付け",
|
||||
|
||||
"AttachMesh.label": "メッシュを接続",
|
||||
"AttachMesh.desc": "自動ウェイト設定でメッシュをアーマチュアボーンに接続",
|
||||
"AttachMesh.search_desc": "接続するメッシュを検索",
|
||||
"AttachMesh.select": "接続するメッシュを選択",
|
||||
"AttachMesh.select_desc": "アーマチュアに接続するメッシュを選択",
|
||||
"AttachMesh.success": "メッシュが正常に接続されました",
|
||||
"AttachMesh.warn_no_armature": "アーマチュアとメッシュを選択してください",
|
||||
"AttachMesh.label": "メッシュを取り付け",
|
||||
"AttachMesh.desc": "自動ウェイト設定でメッシュをアーマチュアボーンに取り付け",
|
||||
"AttachMesh.search_desc": "取り付けるメッシュを検索",
|
||||
"AttachMesh.select": "取り付けるメッシュを選択",
|
||||
"AttachMesh.select_desc": "アーマチュアに取り付けるメッシュを選択",
|
||||
"AttachMesh.success": "メッシュが正常に取り付けられました",
|
||||
"AttachMesh.warn_no_armature": "取り付けるアーマチュアとメッシュを選択",
|
||||
"AttachMesh.validate_transforms": "メッシュの変形を検証中",
|
||||
"AttachMesh.validate_name": "メッシュ名を検証中",
|
||||
"AttachMesh.parent_mesh": "メッシュをアーマチュアの子に設定中",
|
||||
"AttachMesh.setup_weights": "頂点ウェイトを設定中",
|
||||
"AttachMesh.create_bone": "接続用ボーンを作成中",
|
||||
"AttachMesh.create_bone": "取り付けボーンを作成中",
|
||||
"AttachMesh.position_bone": "ボーンを配置中",
|
||||
"AttachMesh.add_modifier": "アーマチュアモディファイアを追加中",
|
||||
"AttachMesh.error.bone_not_found": "接続ボーン'{bone}'が見つかりません",
|
||||
"AttachMesh.error.bone_not_found": "取り付けボーン'{bone}'が見つかりません",
|
||||
"AttachMesh.error.mesh_not_found": "メッシュが見つかりません",
|
||||
"AttachMesh.error.non_uniform_scale": "メッシュに不均一なスケールがあります。スケールを適用してください",
|
||||
"AttachBone.search_desc": "対象のボーンを検索",
|
||||
"AttachBone.select": "対象のボーンを選択",
|
||||
"AttachBone.select_desc": "メッシュを接続するボーンを選択",
|
||||
"AttachBone.search_desc": "ターゲットボーンを検索",
|
||||
"AttachBone.select": "ターゲットボーンを選択",
|
||||
"AttachBone.select_desc": "メッシュを取り付けるボーンを選択",
|
||||
|
||||
"MergeArmature.label": "アーマチュアの結合",
|
||||
"MergeArmature.desc": "2つのアーマチュアを結合",
|
||||
"MergeArmature.label": "アーマチュアを結合",
|
||||
"MergeArmature.desc": "2つのアーマチュアを一緒に結合",
|
||||
"MergeArmature.options": "結合オプション",
|
||||
"MergeArmature.warn_two": "結合には少なくとも2つのアーマチュアが必要です",
|
||||
"MergeArmature.warn_two": "結合するには少なくとも2つのアーマチュアが必要です",
|
||||
"MergeArmature.into": "結合先",
|
||||
"MergeArmature.into_desc": "結合先のターゲットアーマチュア",
|
||||
"MergeArmature.into_search_desc": "結合先のアーマチュアを検索",
|
||||
"MergeArmature.into_search_desc": "ターゲットアーマチュアを検索",
|
||||
"MergeArmature.from": "結合元",
|
||||
"MergeArmature.from_desc": "結合元のソースアーマチュア",
|
||||
"MergeArmature.from_search_desc": "結合元のアーマチュアを検索",
|
||||
"MergeArmature.from_search_desc": "ソースアーマチュアを検索",
|
||||
"MergeArmature.error.not_found": "アーマチュア'{name}'が見つかりません",
|
||||
"MergeArmature.error.transforms_not_aligned": "このアーマチュアを結合するには変形を適用する必要があります。手動で行うか、変形適用のチェックマークを使用してください",
|
||||
"MergeArmature.error.check_transforms": "親の変形を確認してください",
|
||||
@@ -370,39 +461,186 @@
|
||||
"MergeArmature.progress.merging": "アーマチュアを結合中",
|
||||
"MergeArmature.success": "アーマチュアが正常に結合されました",
|
||||
"MergeArmature.merge_all": "同名ボーンを結合",
|
||||
"MergeArmature.merge_all_desc": "名前が一致するボーンを結合",
|
||||
"MergeArmature.merge_all_desc": "一致する名前を持つボーンを結合",
|
||||
"MergeArmature.apply_transforms": "変形を適用",
|
||||
"MergeArmature.apply_transforms_desc": "結合前にすべての変形を適用",
|
||||
"MergeArmature.join_meshes": "メッシュを結合",
|
||||
"MergeArmature.join_meshes_desc": "結合後にメッシュを結合",
|
||||
"MergeArmature.remove_zero_weights": "重みなしを削除",
|
||||
"MergeArmature.remove_zero_weights_desc": "重みのない頂点グループを削除",
|
||||
"MergeArmature.cleanup_shape_keys": "シェイプキーをクリーン",
|
||||
"MergeArmature.remove_zero_weights": "ゼロウェイトを削除",
|
||||
"MergeArmature.remove_zero_weights_desc": "ウェイトのない頂点グループを削除",
|
||||
"MergeArmature.cleanup_shape_keys": "シェイプキーをクリーンアップ",
|
||||
"MergeArmature.cleanup_shape_keys_desc": "未使用のシェイプキーを削除",
|
||||
|
||||
"TextureAtlas.atlas_completed": "テクスチャアトラス作成が完了しました",
|
||||
"TextureAtlas.atlas_error": "テクスチャアトラス作成中にエラーが発生しました",
|
||||
"TextureAtlas.atlas_materials": "アトラスマテリアル",
|
||||
"TextureAtlas.atlas_materials_desc": "モデルを最適化するためのアトラスマテリアル",
|
||||
"TextureAtlas.label": "テクスチャアトラス化",
|
||||
"TextureAtlas.loaded_list": "読み込まれたテクスチャアトラスマテリアルリスト",
|
||||
"TextureAtlas.material_list_label": "テクスチャアトラスマテリアルリストマテリアル",
|
||||
"TextureAtlas.reload_list": "テクスチャアトラスマテリアルリストを再読み込み",
|
||||
"TextureAtlas.error.label": "エラー",
|
||||
"TextureAtlas.none.label": "なし",
|
||||
"TextureAtlas.no_nodes_error.desc": "このマテリアルはノードを使用していません!",
|
||||
"TextureAtlas.no_images_error.desc": "このマテリアルには画像がありません!",
|
||||
"TextureAtlas.texture_use_atlas.desc": "{name}マップアトラスに使用されるテクスチャ",
|
||||
"TextureAtlas.albedo": "アルベド",
|
||||
"TextureAtlas.normal": "法線",
|
||||
"TextureAtlas.emission": "発光",
|
||||
"TextureAtlas.ambient_occlusion": "アンビエントオクルージョン",
|
||||
"TextureAtlas.height": "高さ",
|
||||
"TextureAtlas.roughness": "粗さ",
|
||||
"TextureAtlas.description_1": "テクスチャを組み合わせた単一のマテリアルを作成",
|
||||
"TextureAtlas.description_2": "アバターのパフォーマンスを最適化します。",
|
||||
"TextureAtlas.texture_maps": "テクスチャマップ",
|
||||
"TextureAtlas.material_ready": "マテリアルはアトラス作成の準備ができています",
|
||||
"TextureAtlas.material_not_ready": "マテリアルには少なくとも1つのテクスチャが必要です",
|
||||
"TextureAtlas.select_all_tooltip": "すべてのマテリアルを選択",
|
||||
"TextureAtlas.select_none_tooltip": "すべての選択を解除",
|
||||
"TextureAtlas.expand_all_tooltip": "すべてのマテリアル設定を展開",
|
||||
"TextureAtlas.collapse_all_tooltip": "すべてのマテリアル設定を折りたたむ",
|
||||
"TextureAtlas.estimated_size": "推定アトラスサイズ",
|
||||
"TextureAtlas.materials": "マテリアル",
|
||||
"TextureAtlas.no_materials_selected": "マテリアルが選択されていません",
|
||||
"TextureAtlas.select_armature_first": "最初にアーマチュアを選択してください",
|
||||
"TextureAtlas.how_to_use_1": "1. シーン内のアーマチュアを選択",
|
||||
"TextureAtlas.how_to_use_2": "2. 「マテリアルを読み込む」をクリックして開始",
|
||||
"TextureAtlas.load_error": "マテリアルの読み込みエラー。詳細はコンソールを確認してください。",
|
||||
"TextureAtlas.material_not_included": "マテリアルはアトラスに含まれていません",
|
||||
"TextureAtlas.save_file_first": "テクスチャアトラスを作成する前に、Blenderファイルを保存してください",
|
||||
"TextureAtlas.save_file_instructions": "ファイル > 名前を付けて保存... を使用するか、下のボタンをクリックしてください:",
|
||||
"TextureAtlas.save_file_button": "Blenderファイルを保存",
|
||||
"TextureAtlas.save_file_required": "ファイルの保存が必要です",
|
||||
"TextureAtlas.search_materials": "マテリアルを検索",
|
||||
"TextureAtlas.search_materials_desc": "名前でマテリアルをフィルタリング",
|
||||
|
||||
"Settings.label": "設定",
|
||||
"Settings.language": "言語",
|
||||
"Settings.language_desc": "インターフェース言語を選択",
|
||||
"Settings.validation_mode": "検証モード",
|
||||
"Settings.validation_mode_desc": "アーマチュアの検証の厳密さを選択",
|
||||
"Settings.validation_mode.strict": "厳密",
|
||||
"Settings.validation_mode_desc": "アーマチュアをどの程度厳密に検証するかを選択",
|
||||
"Settings.validation_mode.strict": "厳格",
|
||||
"Settings.validation_mode.strict_desc": "ボーン階層と対称性を含む完全な検証",
|
||||
"Settings.validation_mode.basic": "基本",
|
||||
"Settings.validation_mode.basic_desc": "必須ボーンのみチェック",
|
||||
"Settings.validation_mode.basic_desc": "必須ボーンのチェックのみ",
|
||||
"Settings.validation_mode.none": "なし",
|
||||
"Settings.validation_mode.none_desc": "アーマチュアの検証を行わない",
|
||||
"Settings.validation_mode.none_desc": "アーマチュア検証なし",
|
||||
"Settings.debug": "デバッグ設定",
|
||||
"Settings.logging": "ログ記録",
|
||||
"Settings.enable_logging": "デバッグログを有効化",
|
||||
"Settings.enable_logging_desc": "トラブルシューティング用の詳細ログを有効化",
|
||||
"Settings.enable_logging_desc": "トラブルシューティングのための詳細なデバッグログを有効化",
|
||||
"Settings.logging_enabled": "デバッグログが有効になりました",
|
||||
"Settings.logging_disabled": "デバッグログが無効になりました",
|
||||
"Settings.highlight_problem_bones": "問題のあるボーンを強調表示",
|
||||
"Settings.highlight_problem_bones_desc": "ビューポートで検証に問題のあるボーンを強調表示",
|
||||
"Settings.bone_highlighting": "ボーンの強調表示",
|
||||
"Settings.log_level": "ログレベル",
|
||||
"Settings.log_level_desc": "デバッグログの詳細レベルを選択",
|
||||
"Settings.log_level.debug": "デバッグ",
|
||||
"Settings.log_level.debug_desc": "詳細なデバッグ情報を含むすべてのログメッセージを表示",
|
||||
"Settings.log_level.info": "情報",
|
||||
"Settings.log_level.info_desc": "情報メッセージ、警告、エラーを表示",
|
||||
"Settings.log_level.warning": "警告",
|
||||
"Settings.log_level.warning_desc": "警告とエラーのみを表示",
|
||||
"Settings.log_level.error": "エラー",
|
||||
"Settings.log_level.error_desc": "エラーメッセージのみを表示",
|
||||
"Language.auto": "自動",
|
||||
"Language.en_US": "英語",
|
||||
"Language.ja_JP": "日本語",
|
||||
"Language.ko_KR": "韓国語",
|
||||
"Language.changed.title": "言語が変更されました",
|
||||
"Language.changed.success": "言語が正常に変更されました!",
|
||||
"Language.changed.restart": "一部のUI要素の更新にはBlenderの再起動が必要な場合があります"
|
||||
"Language.changed.restart": "一部のUI要素はBlenderの再起動が必要な場合があります",
|
||||
|
||||
"VRM.panel.label": "VRMからUnityへ",
|
||||
"VRM.converter.title": "VRMコンバーター",
|
||||
"VRM.no_armature_selected": "アーマチュアが選択されていません",
|
||||
"VRM.select_armature_to_convert": "変換するアーマチュアを選択してください",
|
||||
"VRM.armature_name": "アーマチュア: {name}",
|
||||
"VRM.armature_detected": "VRMアーマチュアが検出されました",
|
||||
"VRM.no_vrm_bones_detected": "VRMボーンが検出されませんでした",
|
||||
"VRM.remove_root_bone": "ルートボーンを削除",
|
||||
"VRM.convert_to_unity_format": "Unity形式に変換",
|
||||
"VRM.convert_to_unity.label": "VRMをUnityに変換",
|
||||
"VRM.convert_to_unity.desc": "VRMアーマチュアのボーン名をUnityヒューマノイド命名規則に変換",
|
||||
"VRM.conversion_info.title": "変換情報:",
|
||||
"VRM.conversion_info.renames_bones": "• VRMボーンをUnity形式にリネーム",
|
||||
"VRM.conversion_info.removes_colliders": "• コライダーボーンを削除(オプション)",
|
||||
"VRM.conversion_info.removes_root": "• ルートボーンを削除し、Hipsをルートにする(オプション)",
|
||||
"VRM.conversion_info.maintains_hierarchy": "• ボーン階層を維持",
|
||||
"VRM.conversion_info.validates_results": "• 変換結果を検証",
|
||||
"VRM.conversion_info.preserves_animations": "• すべてのアニメーションを保持",
|
||||
"VRM.detection_failed.title": "VRM検出失敗:",
|
||||
"VRM.detection_failed.not_vrm_format": "• 選択されたアーマチュアはVRM形式ではありません",
|
||||
"VRM.detection_failed.bones_start_with": "• VRMボーンは'J_Bip_C_'で始まります",
|
||||
"VRM.detection_failed.need_five_bones": "• 少なくとも5つのVRMボーンが検出される必要があります",
|
||||
"VRM.detection_failed.check_bone_names": "• アーマチュアのボーン名を確認してください",
|
||||
"VRM.validation.hierarchy_passed": "Unity階層検証に合格しました",
|
||||
"VRM.validation.hierarchy_issues": "変換は完了しましたが、階層検証で問題が見つかりました:",
|
||||
"VRM.validation.armature_passed": "アーマチュアは標準検証に合格しました",
|
||||
"VRM.validation.failed": "変換は完了しましたが、検証に失敗しました: {error}",
|
||||
"VRM.remove_colliders": "コライダーを削除",
|
||||
"VRM.remove_colliders_desc": "変換中にVRMコライダーボーンを削除",
|
||||
"VRM.remove_root": "ルートボーンを削除",
|
||||
"VRM.remove_root_desc": "不要なVRMルートボーンを削除し、ヒップをルートボーンにする",
|
||||
|
||||
"Translation.label": "翻訳",
|
||||
"Translation.service": "翻訳サービス",
|
||||
"Translation.service_desc": "使用する翻訳サービスを選択",
|
||||
"Translation.mode": "翻訳モード",
|
||||
"Translation.mode_desc": "翻訳の動作方法を選択",
|
||||
"Translation.mode.hybrid": "ハイブリッド(辞書 + API)",
|
||||
"Translation.mode.hybrid_desc": "まず辞書を試し、その後APIサービスをフォールバックとして使用",
|
||||
"Translation.mode.dictionary_only": "辞書のみ",
|
||||
"Translation.mode.dictionary_only_desc": "翻訳には組み込み辞書のみを使用",
|
||||
"Translation.mode.api_only": "APIのみ",
|
||||
"Translation.mode.api_only_desc": "オンライン翻訳サービスのみを使用",
|
||||
"Translation.service_settings": "翻訳サービス",
|
||||
"Translation.language_settings": "言語設定",
|
||||
"Translation.quick_actions": "クイックアクション",
|
||||
"Translation.utilities": "ユーティリティ",
|
||||
"Translation.advanced_settings": "詳細設定",
|
||||
"Translation.source_language": "ソース言語",
|
||||
"Translation.source_language_desc": "翻訳元の言語",
|
||||
"Translation.target_language": "ターゲット言語",
|
||||
"Translation.target_language_desc": "翻訳先の言語",
|
||||
"Translation.translate_names": "名前を翻訳",
|
||||
"Translation.translate_names_desc": "選択したサービスと設定を使用して名前を翻訳",
|
||||
"Translation.test_service": "サービスをテスト",
|
||||
"Translation.test_service_desc": "現在選択されている翻訳サービスをテスト",
|
||||
"Translation.clear_cache": "キャッシュをクリア",
|
||||
"Translation.clear_cache_desc": "すべてのキャッシュされた翻訳をクリア",
|
||||
"Translation.show_stats": "統計を表示",
|
||||
"Translation.show_stats_desc": "翻訳統計と情報を表示",
|
||||
"Translation.no_armature": "アーマチュアが選択されていません",
|
||||
"Translation.test_failed": "翻訳サービステストが失敗しました - 設定を確認してください",
|
||||
"Translation.cache_cleared": "翻訳キャッシュが正常にクリアされました",
|
||||
"Translation.mymemory_info": "MyMemoryは完全に無料でAPIキー不要です。1日1000回の翻訳を提供します。",
|
||||
"Translation.service.mymemory": "MyMemory(無料)",
|
||||
"Translation.service.mymemory_desc": "完全に無料のサービス - APIキー不要!",
|
||||
"Translation.service.libretranslate": "LibreTranslate",
|
||||
"Translation.service.libretranslate_desc": "設定可能なサーバー - セルフホスト可能",
|
||||
"Translation.service.deepl": "DeepL",
|
||||
"Translation.service.deepl_desc": "高品質な翻訳 - APIキーが必要",
|
||||
"Translation.type.bones": "ボーン",
|
||||
"Translation.type.bones_desc": "ボーン名を翻訳",
|
||||
"Translation.type.shapekeys": "シェイプキー",
|
||||
"Translation.type.shapekeys_desc": "シェイプキー名を翻訳",
|
||||
"Translation.type.materials": "マテリアル",
|
||||
"Translation.type.materials_desc": "マテリアル名を翻訳",
|
||||
"Translation.type.objects": "オブジェクト",
|
||||
"Translation.type.objects_desc": "オブジェクト名を翻訳",
|
||||
"Translation.type.all": "すべて",
|
||||
"Translation.type.all_desc": "サポートされているすべてのタイプを翻訳",
|
||||
"Translation.configure_deepl": "DeepL APIを設定",
|
||||
"Translation.configure_deepl_desc": "DeepL翻訳サービスAPIキーを設定",
|
||||
"Translation.deepl_api_key": "DeepL APIキー",
|
||||
"Translation.deepl_api_key_desc": "あなたのDeepL APIキー(deepl.com/proで無料キーを取得)",
|
||||
"Translation.configure_libretranslate": "LibreTranslateサーバーを設定",
|
||||
"Translation.configure_libretranslate_desc": "LibreTranslate翻訳サービスサーバーURLを設定",
|
||||
"Translation.server_url": "サーバーURL",
|
||||
"Translation.server_url_desc": "LibreTranslateサーバーURL(例:https://your-server.com)",
|
||||
"Translation.api_key": "APIキー",
|
||||
"Translation.api_key_desc": "LibreTranslateサーバー用のAPIキー(一部のサーバーでは任意)"
|
||||
}
|
||||
}
|
||||
+345
-107
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"authors": ["Avatar Toolkit Team"],
|
||||
"messages": {
|
||||
"AvatarToolkit.label": "아바타 툴킷 (알파 0.1.1)",
|
||||
"AvatarToolkit.desc1": "아바타 툴킷은 초기 액세스 단계입니다",
|
||||
"AvatarToolkit.desc2": "문제가 발생할 수 있으며, 문제를 발견하시면",
|
||||
"AvatarToolkit.desc3": "Github에 보고해 주시기 바랍니다.",
|
||||
"AvatarToolkit.label": "아바타 툴킷 (알파 0.6.0)",
|
||||
"AvatarToolkit.desc1": "아바타 툴킷은 초기 액세스 단계에 있으므로",
|
||||
"AvatarToolkit.desc2": "문제가 있을 수 있습니다. 문제를 발견하시면",
|
||||
"AvatarToolkit.desc3": "Github에 보고해 주세요.",
|
||||
|
||||
"Updater.label": "업데이터",
|
||||
"Updater.CheckForUpdateButton.label": "업데이트 확인",
|
||||
@@ -16,7 +16,7 @@
|
||||
"Updater.CheckForUpdateButton.desc": "사용 가능한 업데이트 확인",
|
||||
"UpdateToLatestButton.desc": "최신 버전으로 업데이트",
|
||||
"UpdateNotificationPopup.label": "업데이트 알림",
|
||||
"UpdateNotificationPopup.desc": "사용 가능한 업데이트 알림",
|
||||
"UpdateNotificationPopup.desc": "사용 가능한 업데이트에 대한 알림",
|
||||
"UpdateNotificationPopup.newUpdate": "새 업데이트 사용 가능: {version}",
|
||||
"RestartBlenderPopup.label": "블렌더 재시작",
|
||||
"RestartBlenderPopup.desc": "업데이트를 완료하려면 블렌더를 재시작하세요",
|
||||
@@ -42,40 +42,97 @@
|
||||
"QuickAccess.stop_pose_mode.label": "포즈 모드 종료",
|
||||
"QuickAccess.stop_pose_mode.desc": "포즈 모드 종료 및 변형 초기화",
|
||||
"QuickAccess.apply_pose_as_shapekey.label": "포즈를 쉐이프 키로 적용",
|
||||
"QuickAccess.apply_pose_as_shapekey.desc": "현재 포즈로 새 쉐이프 키 생성",
|
||||
"QuickAccess.apply_pose_as_rest.label": "포즈를 기본 자세로 적용",
|
||||
"QuickAccess.apply_pose_as_rest.desc": "현재 포즈를 기본 자세로 적용",
|
||||
"QuickAccess.apply_pose_as_shapekey.desc": "현재 포즈에서 새 쉐이프 키 생성",
|
||||
"QuickAccess.apply_pose_as_rest.label": "포즈를 기본 포즈로 적용",
|
||||
"QuickAccess.apply_pose_as_rest.desc": "현재 포즈를 기본 포즈로 적용",
|
||||
"QuickAccess.apply_armature_failed": "아마추어 수정 적용 실패",
|
||||
"QuickAccess.validation_basic_warning": "제한된 검증 활성화됨",
|
||||
"QuickAccess.validation_basic_details": "필수 본 구조만 검증됨",
|
||||
"QuickAccess.validation_none_warning": "검증 비활성화됨",
|
||||
"QuickAccess.validation_none_details": "아마추어 검증이 수행되지 않음",
|
||||
"QuickAccess.validation_basic_warning": "제한된 검증 활성화",
|
||||
"QuickAccess.validation_basic_details": "필수 본 구조만 검증 중",
|
||||
"QuickAccess.validation_none_warning": "검증 비활성화",
|
||||
"QuickAccess.validation_none_details": "아마추어 검증 확인이 수행되지 않음",
|
||||
"Quick_Access.import_success": "가져오기 성공",
|
||||
|
||||
"PoseMode.error.start": "포즈 모드 시작 실패: {error}",
|
||||
"PoseMode.error.stop": "포즈 모드 종료 실패: {error}",
|
||||
"PoseMode.error.shapekey": "포즈를 쉐이프 키로 적용 실패: {error}",
|
||||
"PoseMode.error.rest_pose": "포즈를 기본 자세로 적용 실패: {error}",
|
||||
"PoseMode.error.rest_pose": "포즈를 기본 포즈로 적용 실패: {error}",
|
||||
"PoseMode.shapekey.name": "쉐이프 키 이름",
|
||||
"PoseMode.shapekey.description": "새 쉐이프 키의 이름",
|
||||
"PoseMode.shapekey.default": "포즈_쉐이프키",
|
||||
"PoseMode.skipped_meshes": "일부 메시가 건너뛰어짐:\n{message}",
|
||||
"PoseMode.skipped_meshes": "일부 메시가 건너뛰어졌습니다:\n{message}",
|
||||
"PoseMode.basis": "기본",
|
||||
|
||||
"Armature.validation.no_armature": "선택된 아마추어 없음",
|
||||
"Armature.validation.not_armature": "선택된 오브젝트가 아마추어가 아님",
|
||||
"Armature.validation.pmx_model_detected": "PMX 모델이 감지되었습니다. 일본어 본 이름이 표준 명명 규칙과 일치하지 않을 수 있습니다.",
|
||||
"Armature.validation.pmx_model_strict": "'아마추어 표준화' 옵션을 사용하여 일본어 본 이름을 표준 이름으로 변환하는 것을 고려하세요.",
|
||||
"Armature.validation.pmx_model_standardize": "이렇게 하면 모델이 표준 아바타 시스템과 호환됩니다.",
|
||||
"Armature.validation.pmx_model_basic": "PMX 모델은 일본어 본 이름을 사용하며 표준 명명 규칙과 일치하지 않을 수 있습니다.",
|
||||
"Armature.validation.unknown_format": "알 수 없는 아마추어 형식이 감지되었습니다.",
|
||||
"Validation.mode.none": "유효성 검사가 설정에서 비활성화되었습니다.",
|
||||
"Validation.no_messages": "사용 가능한 유효성 검사 메시지가 없습니다.",
|
||||
"Armature.validation.not_armature": "선택된 객체가 아마추어가 아님",
|
||||
"Armature.validation.no_bones": "아마추어에 본이 없음",
|
||||
"Armature.validation.basic_check_failed": "기본 아마추어 검증 실패",
|
||||
"Armature.validation.missing_bones": "필수 본 누락: {bones}",
|
||||
"Armature.validation.invalid_hierarchy": "{parent}와 {child} 사이의 잘못된 본 계층 구조",
|
||||
"Armature.validation.asymmetric_bones": "{bone}의 대칭 본 누락",
|
||||
"Armature.validation.asymmetric_hand_wrist": "손/손목의 대칭 본 누락",
|
||||
"Armature.validation.invalid_hierarchy": "{parent}와 {child} 사이의 유효하지 않은 본 계층 구조",
|
||||
"Armature.validation.asymmetric_bones": "{bone}에 대한 대칭 본 누락",
|
||||
"Armature.validation.asymmetric_hand_wrist": "손/손목에 대한 대칭 본 누락",
|
||||
"Armature.validation.found_bones": "아마추어에서 발견된 본:\n- {bones}",
|
||||
"Armature.validation.non_standard_bones": "비표준 본 발견:\n- {bones}",
|
||||
"Armature.validation.accessory_bones_note.line1": "머리카락 본, 치마 본 또는 기타",
|
||||
"Armature.validation.accessory_bones_note.line2": "액세서리 본의 이름이 주 아마추어",
|
||||
"Armature.validation.accessory_bones_note.line3": "본과 유사하게 지정된 경우(예: Head1, Head2), 이름을",
|
||||
"Armature.validation.accessory_bones_note.line4": "Hair_1, Skirt_1과 같은 더 설명적인 이름으로 변경하세요.",
|
||||
"Armature.validation.standardize_note.line1": "도구 섹션의 '아마추어 표준화'",
|
||||
"Armature.validation.standardize_note.line2": "버튼을 사용하여 아마추어를",
|
||||
"Armature.validation.standardize_note.line3": "자동으로 표준화할 수 있습니다.",
|
||||
"Validation.section.found_bones": "발견된 본",
|
||||
"Validation.section.non_standard": "비표준 본",
|
||||
"Validation.section.hierarchy": "계층 구조 문제",
|
||||
"Validation.status.failed": "검증 실패",
|
||||
"Validation.message.failed.line1": "아마추어 검증 실패",
|
||||
"Validation.message.failed.line2": "아래에서 문제가 무엇인지",
|
||||
"Validation.message.failed.line3": "확인하세요",
|
||||
"Validation.highlight_problem_bones_desc": "뷰포트에서 검증 문제가 있는 본을 시각적으로 강조 표시",
|
||||
"Validation.no_armature": "선택된 아마추어 없음",
|
||||
"Validation.no_issues": "강조 표시할 검증 문제가 없음",
|
||||
"Validation.highlighting_complete": "문제 본 강조 표시 성공",
|
||||
"Validation.tpose.no_armature": "T-포즈 검증을 위한 아마추어를 찾을 수 없음",
|
||||
"Validation.tpose.left_arm_not_horizontal": "왼쪽 팔이 수평 T-포즈 위치에 있지 않음",
|
||||
"Validation.tpose.right_arm_not_horizontal": "오른쪽 팔이 수평 T-포즈 위치에 있지 않음",
|
||||
"Validation.tpose.spine_not_vertical": "척추가 수직 위치에 있지 않음",
|
||||
"Validation.tpose.warning": "T-포즈 검증 경고",
|
||||
"Validation.tpose.recommendation": "Unity 또는 다른 플랫폼으로 가져오기 전에 T-포즈를 수정하는 것이 좋습니다",
|
||||
"Validation.scale_issues": "비정상적인 크기의 본 감지:",
|
||||
"Validation.scale_issue.too_small": "본이 매우 작음",
|
||||
"Validation.scale_issue.too_large": "본이 매우 큼",
|
||||
"Validation.section.scale_issues": "크기 문제",
|
||||
"Validation.tpose.label": "T-포즈 검증",
|
||||
"Validation.no_scale_issues": "크기 문제가 감지되지 않음",
|
||||
"Validation.no_hierarchy_issues": "계층 구조 문제가 감지되지 않음",
|
||||
"Validation.no_non_standard_issues": "비표준 본 문제가 감지되지 않음",
|
||||
"Validation.tpose.valid": "T-포즈 검증 성공",
|
||||
"Validation.tpose.desc": "아마추어가 적절한 T-포즈에 있는지 확인",
|
||||
"Validation.highlight_problem_bones": "문제 본 강조 표시",
|
||||
"Validation.clear_bone_highlighting": "본 강조 표시 지우기",
|
||||
"Validation.clear_bone_highlighting_desc": "본 강조 표시를 제거하고 본 색상을 기본값으로 재설정",
|
||||
"Validation.highlighting_cleared": "본 강조 표시 지우기 성공",
|
||||
"Validation.label": "아마추어 검증",
|
||||
"Validation.validate_now": "지금 아마추어 검증",
|
||||
"Validation.validate_now_desc": "아마추어 검증을 실행하고 자세한 결과 표시",
|
||||
"Validation.results": "검증 결과",
|
||||
"Validation.tpose.validate_now": "지금 T-포즈 검증",
|
||||
|
||||
"Armature.validation.acceptable_standard.success": "아마추어가 허용 가능한 표준을 충족합니다",
|
||||
"Armature.validation.acceptable_standard.note": "이것은 대부분의 아바타 시스템과 호환되는 유효한 아마추어 형식입니다",
|
||||
"Armature.validation.acceptable_standard.option": "필요한 경우 아마추어를 표준화할 수 있습니다",
|
||||
|
||||
"Mesh.validation.no_data": "메시 데이터 없음",
|
||||
"Mesh.validation.no_vertex_groups": "버텍스 그룹 없음",
|
||||
"Mesh.validation.no_vertex_groups": "버텍스 그룹을 찾을 수 없음",
|
||||
"Mesh.validation.no_armature_modifier": "아마추어 모디파이어 없음",
|
||||
"Mesh.validation.valid": "포즈 작업에 유효한 메시",
|
||||
|
||||
"Operation.pose_applied": "포즈가 성공적으로 적용됨",
|
||||
"Operation.pose_applied": "포즈 적용 성공",
|
||||
|
||||
"Scene.avatar_toolkit_updater_version_list.name": "버전 목록",
|
||||
"Scene.avatar_toolkit_updater_version_list.description": "사용 가능한 버전 목록",
|
||||
@@ -87,20 +144,20 @@
|
||||
"Optimization.combine_materials": "재질 결합",
|
||||
"Optimization.combine_materials_desc": "드로우 콜을 줄이기 위해 유사한 재질 결합",
|
||||
"Optimization.remove_doubles": "중복 제거",
|
||||
"Optimization.remove_doubles_desc": "중복된 버텍스 제거",
|
||||
"Optimization.remove_doubles_desc": "중복 버텍스 제거",
|
||||
"Optimization.remove_doubles_advanced": "고급",
|
||||
"Optimization.remove_doubles_advanced_desc": "고급 옵션으로 중복 버텍스 제거",
|
||||
"Optimization.join_all_meshes": "전체 결합",
|
||||
"Optimization.join_all_meshes": "모두 결합",
|
||||
"Optimization.join_all_meshes_desc": "씬의 모든 메시 결합",
|
||||
"Optimization.join_selected_meshes": "선택 결합",
|
||||
"Optimization.join_selected_meshes_desc": "선택된 메시만 결합",
|
||||
"Optimization.join_selected_meshes": "선택 항목 결합",
|
||||
"Optimization.join_selected_meshes_desc": "선택한 메시만 결합",
|
||||
"Optimization.no_meshes": "최적화할 메시를 찾을 수 없음",
|
||||
"Optimization.materials_combined": "{combined}개의 재질 결합, {cleaned}개의 슬롯 정리, {removed}개의 미사용 데이터 블록 제거됨",
|
||||
"Optimization.materials_combined": "{combined}개의 재질 결합, {cleaned}개의 슬롯 정리, {removed}개의 미사용 데이터 블록 제거",
|
||||
"Optimization.error.combine_materials": "재질 결합 실패: {error}",
|
||||
"Optimization.materials_total": "전체 재질: {count}개",
|
||||
"Optimization.materials_total": "총 재질: {count}개",
|
||||
"Optimization.materials_duplicates": "잠재적 중복: {count}개",
|
||||
"Optimization.no_materials": "메시에서 재질을 찾을 수 없음",
|
||||
"Optimization.error.consolidation": "재질 통합 실패. 콘솔에서 세부 정보 확인",
|
||||
"Optimization.error.consolidation": "재질 통합 실패. 자세한 내용은 콘솔을 확인하세요",
|
||||
"Optimization.combining_materials": "유사한 재질 결합 중...",
|
||||
"Optimization.cleaning_slots": "재질 슬롯 정리 중...",
|
||||
"Optimization.removing_unused": "미사용 재질 제거 중...",
|
||||
@@ -109,114 +166,142 @@
|
||||
"Optimization.applying_transforms": "변형 적용 중...",
|
||||
"Optimization.fixing_uvs": "UV 좌표 수정 중...",
|
||||
"Optimization.finalizing": "마무리 중...",
|
||||
"Optimization.meshes_joined": "모든 메시가 성공적으로 결합됨",
|
||||
"Optimization.selected_meshes_joined": "선택된 메시가 성공적으로 결합됨",
|
||||
"Optimization.meshes_joined": "모든 메시 결합 성공",
|
||||
"Optimization.selected_meshes_joined": "선택한 메시 결합 성공",
|
||||
"Optimization.no_mesh_selected": "선택된 메시 없음",
|
||||
"Optimization.select_at_least_two": "최소 두 개의 메시를 선택하세요",
|
||||
"Optimization.error.join_meshes": "메시 결합 실패: {error}",
|
||||
"Optimization.error.join_selected": "선택된 메시 결합 실패: {error}",
|
||||
"Optimization.error.join_selected": "선택한 메시 결합 실패: {error}",
|
||||
"Optimization.merge_distance": "병합 거리",
|
||||
"Optimization.merge_distance_desc": "버텍스를 병합할 거리",
|
||||
"Optimization.merge_distance_desc": "버텍스가 병합될 거리",
|
||||
"Optimization.remove_doubles_warning": "이 과정은 시간이 오래 걸릴 수 있습니다",
|
||||
"Optimization.remove_doubles_wait": "이 작업 중에는 블렌더가 응답하지 않을 수 있습니다",
|
||||
"Optimization.remove_doubles_wait": "이 작업 중에는 블렌더가 응답하지 않는 것처럼 보일 수 있습니다",
|
||||
"Optimization.error.remove_doubles": "중복 제거 실패: {error}",
|
||||
"Optimization.no_armature": "선택된 아마추어 없음",
|
||||
"Optimization.processing_mesh": "메시 처리 중: {name}",
|
||||
"Optimization.processing_shapekey": "쉐이프 키 처리 중: {name}",
|
||||
"Optimization.remove_doubles_completed": "중복 제거가 성공적으로 완료됨",
|
||||
"Optimization.remove_doubles_completed": "중복 제거 완료 성공",
|
||||
|
||||
"Tools.label": "도구",
|
||||
"Tools.general_title": "일반 도구",
|
||||
"Tools.select_armature": "아마추어 선택",
|
||||
"Tools.convert_resonite": "Resonite로 변환",
|
||||
"Tools.convert_resonite_desc": "Resonite에서 사용할 모델 변환",
|
||||
"Tools.convert_resonite_desc": "Resonite에서 사용하기 위해 모델 변환",
|
||||
"Tools.convert_resonite.operation": "Resonite로 변환 중",
|
||||
"Tools.separate_title": "분리 도구",
|
||||
"Tools.separate_materials": "재질별",
|
||||
"Tools.separate_materials_desc": "재질별로 메시 분리",
|
||||
"Tools.separate_loose": "분리된 부분",
|
||||
"Tools.separate_loose_desc": "분리된 부분으로 메시 분리",
|
||||
"Tools.separate_loose_desc": "메시를 분리된 부분으로 나누기",
|
||||
"Tools.separate_materials_success": "메시가 재질별로 성공적으로 분리됨",
|
||||
"Tools.separate_loose_success": "메시가 분리된 부분으로 성공적으로 분리됨",
|
||||
"Tools.separate_loose_success": "메시가 분리된 부분으로 성공적으로 나뉨",
|
||||
"Tools.bone_title": "본 도구",
|
||||
"Tools.create_digitigrade": "디지티그레이드 다리 생성",
|
||||
"Tools.create_digitigrade_desc": "다리를 디지티그레이드 설정으로 변환",
|
||||
"Tools.digitigrade": "디지티그레이드 다리 생성",
|
||||
"Tools.digitigrade_desc": "선택된 다리 본을 디지티그레이드 설정으로 변환",
|
||||
"Tools.digitigrade_desc": "선택한 다리 본을 디지티그레이드 설정으로 변환",
|
||||
"Tools.digitigrade_error": "디지티그레이드 다리 생성 실패: {error}",
|
||||
"Tools.digitigrade_success": "디지티그레이드 다리 설정 생성 성공",
|
||||
"Tools.processing_leg": "다리 본 처리 중: {bone}",
|
||||
"Tools.weight_title": "가중치 도구",
|
||||
"Tools.merge_twist_bones": "트위스트 본 유지",
|
||||
"Tools.merge_twist_bones_desc": "체크하면 가중치가 0이어도 트위스트 본 유지",
|
||||
"Tools.clean_weights": "0 가중치 본 제거",
|
||||
"Tools.merge_twist_bones_desc": "체크하면 가중치가 0이더라도 트위스트 본이 유지됩니다",
|
||||
"Tools.clean_weights": "가중치 0인 본 제거",
|
||||
"Tools.clean_weights_desc": "버텍스 가중치가 없는 본 제거",
|
||||
"Tools.preserve_parent_bones": "부모 본 보존",
|
||||
"Tools.preserve_parent_bones_desc": "가중치가 없더라도 자식이 있는 본 유지",
|
||||
"Tools.target_bone_type": "대상 본 유형",
|
||||
"Tools.target_bone_type_desc": "처리할 본 유형 필터링",
|
||||
"Tools.target_all_bones": "모든 본",
|
||||
"Tools.target_deform_bones": "변형 본만",
|
||||
"Tools.target_non_deform_bones": "비변형 본만",
|
||||
"Tools.list_only_mode": "목록 모드만",
|
||||
"Tools.list_only_mode_desc": "제거하는 대신 가중치 0인 본 나열",
|
||||
"Tools.zero_weight_bones_found": "가중치 0인 본 발견: {bones}",
|
||||
"Tools.remove_selected_bones": "선택한 본 제거",
|
||||
"Tools.remove_selected_bones_desc": "아마추어에서 선택한 가중치 0인 본 제거",
|
||||
"Tools.bones_removed": "{count}개의 본 제거됨",
|
||||
"Tools.clean_constraints": "본 제약 조건 삭제",
|
||||
"Tools.clean_constraints_desc": "아마추어에서 모든 본 제약 조건 제거",
|
||||
"Tools.clean_constraints_success": "{count}개의 본 제약 조건 제거됨",
|
||||
"Tools.processing_bone_constraints": "본의 제약 조건 제거 중: {bone}",
|
||||
"Tools.clean_weights_success": "{count}개의 0 가중치 본 제거됨",
|
||||
"Tools.processing_bone_constraints": "본에서 제약 조건 제거 중: {bone}",
|
||||
"Tools.clean_weights_success": "{count}개의 가중치 0인 본 제거됨",
|
||||
"Tools.clean_weights_threshold": "가중치 임계값",
|
||||
"Tools.clean_weights_threshold_desc": "본이 가중치를 가진 것으로 간주할 최소값",
|
||||
"Tools.clean_weights_threshold_desc": "본이 가중치를 가진 것으로 간주하는 최소 가중치 값",
|
||||
"Tools.merge_title": "병합 도구",
|
||||
"Tools.merge_to_active": "활성 본으로 병합",
|
||||
"Tools.merge_to_active_desc": "선택된 본을 활성 본으로 병합",
|
||||
"Tools.merge_to_active_desc": "선택한 본을 활성 본으로 병합",
|
||||
"Tools.merge_to_parent": "부모로 병합",
|
||||
"Tools.merge_to_parent_desc": "본을 각각의 부모로 병합",
|
||||
"Tools.connect_bones": "본 연결",
|
||||
"Tools.connect_bones_desc": "체인에서 연결되지 않은 본 연결",
|
||||
"Tools.additional_title": "추가 도구",
|
||||
"Tools.apply_transforms": "변형 적용",
|
||||
"Tools.apply_transforms_desc": "오브젝트에 모든 변형 적용",
|
||||
"Tools.clean_shapekeys": "미사용 쉐이프키 제거",
|
||||
"Tools.apply_transforms_desc": "객체에 모든 변형 적용",
|
||||
"Tools.clean_shapekeys": "미사용 쉐이프 키 제거",
|
||||
"Tools.clean_shapekeys_desc": "메시에서 미사용 쉐이프 키 제거",
|
||||
"Tools.bones_translated_success": "모든 본이 성공적으로 변환됨",
|
||||
"Tools.bones_translated_with_fails": "변환 완료됨 (변환되지 않은 본 {translate_bone_fails}개)",
|
||||
"Tools.bones_translated_success": "모든 본 번역 성공",
|
||||
"Tools.bones_translated_with_fails": "번역 완료, {translate_bone_fails}개의 번역되지 않은 본",
|
||||
"Tools.storing_transforms": "본 변형 저장 중...",
|
||||
"Tools.analyzing_weights": "버텍스 가중치 분석 중...",
|
||||
"Tools.removing_bones": "가중치 없는 본 제거 중...",
|
||||
"Tools.verifying_hierarchy": "본 계층 구조 확인 중...",
|
||||
"Tools.connect_bones_min_distance": "최소 거리",
|
||||
"Tools.connect_bones_min_distance_desc": "본 연결을 시도할 최소 거리",
|
||||
"Tools.connect_bones_min_distance_desc": "연결을 시도할 본 사이의 최소 거리",
|
||||
"Tools.connect_bones_success": "{count}개의 본 연결됨",
|
||||
"Tools.merge_weights_threshold": "가중치 전송 임계값",
|
||||
"Tools.merge_weights_threshold_desc": "본 병합 시 전송할 최소 가중치 값",
|
||||
"Tools.no_bones_selected": "병합할 본이 선택되지 않음",
|
||||
"Tools.no_bones_with_parent": "부모가 있는 선택된 본을 찾을 수 없음",
|
||||
"Tools.merge_to_active_success": "{count}개의 본을 활성 본으로 성공적으로 병합함",
|
||||
"Tools.merge_to_parent_success": "{count}개의 본을 부모로 성공적으로 병합함",
|
||||
"Tools.transforms_applied": "변형이 성공적으로 적용됨",
|
||||
"Tools.merge_to_active_success": "{count}개의 본을 활성 본으로 성공적으로 병합",
|
||||
"Tools.merge_to_parent_success": "{count}개의 본을 부모로 성공적으로 병합",
|
||||
"Tools.transforms_applied": "변형 적용 성공",
|
||||
"Tools.shapekey_tolerance": "쉐이프 키 허용 오차",
|
||||
"Tools.shapekey_tolerance_desc": "쉐이프 키를 사용된 것으로 간주할 최소 차이",
|
||||
"Tools.shapekey_tolerance_desc": "쉐이프 키가 사용된 것으로 간주하는 최소 차이",
|
||||
"Tools.shapekeys_removed": "{count}개의 미사용 쉐이프 키 제거됨",
|
||||
"Tools.rigify_title": "Rigify 도구",
|
||||
"Tools.convert_rigify_to_unity": "Rigify를 Unity로 변환",
|
||||
"Tools.convert_rigify_to_unity_desc": "Rigify 아마추어를 Unity 호환 형식으로 변환",
|
||||
"Tools.rigify_converted": "Rigify 아마추어 변환 성공",
|
||||
"Tools.no_armature": "선택된 아마추어 없음",
|
||||
"Tools.standardize_title": "표준화",
|
||||
"Tools.standardize_armature": "아마추어 표준화",
|
||||
"Tools.standardize_armature_desc": "비표준 아마추어를 아바타 툴킷 표준으로 변환",
|
||||
"Tools.standardize_fix_names": "본 이름 수정",
|
||||
"Tools.standardize_fix_names_desc": "본 이름을 표준 명명 규칙에 맞게 변경",
|
||||
"Tools.standardize_fix_hierarchy": "본 계층 구조 수정",
|
||||
"Tools.standardize_fix_hierarchy_desc": "본 사이의 부모-자식 관계 수정",
|
||||
"Tools.standardize_fix_scale": "본 크기 수정",
|
||||
"Tools.standardize_fix_scale_desc": "크기 문제를 해결하기 위해 본 길이 정규화",
|
||||
"Tools.standardize_warning": "이 작업은 아마추어를 수정합니다. 먼저 백업을 만드세요!",
|
||||
"Tools.standardize_success": "아마추어 표준화 성공",
|
||||
"Tools.standardize_partial": "아마추어가 부분적으로 표준화되었습니다. 일부 문제가 남아 있습니다.",
|
||||
"Tools.standardize_already_valid": "아마추어가 이미 표준을 충족합니다. 변경이 필요하지 않습니다.",
|
||||
"Tools.standardize_issues_title": "표준화 문제",
|
||||
"Tools.standardize_issues_header": "표준화 후에도 일부 문제가 남아 있습니다",
|
||||
"Tools.standardize_issues_line1": "이는 아바타의 일부 본이 인식되지 않는 고유한 이름을 가지고 있기 때문일 수 있습니다",
|
||||
"Tools.standardize_issues_line2": "우리의 비표준 본 인식 목록에 없는 이름입니다.",
|
||||
"Tools.standardize_issues_line3": "예를 들어, 엉덩이 본의 이름이 'THISISMYHIPS'인 경우 감지할 수 없습니다.",
|
||||
"Tools.standardize_issues_line4": "주요 골격 본이 인식되지 않는 경우 GitHub에 보고해 주세요",
|
||||
"Tools.standardize_issues_line5": "데이터베이스에 추가할 수 있도록 합니다.",
|
||||
"Tools.standardize_issues_line6": "액세서리 본(머리카락, 의류 등)은 수동으로 이름을 변경해야 합니다.",
|
||||
|
||||
"MMD.label": "MMD 도구",
|
||||
"MMD.bone_standardization": "본 표준화",
|
||||
"MMD.weight_processing": "가중치 처리",
|
||||
"MMD.hierarchy": "본 계층 구조",
|
||||
"MMD.cleanup": "정리",
|
||||
"MMD.no_armature": "선택된 아마추어 없음",
|
||||
"MMD.no_meshes": "메시를 찾을 수 없음",
|
||||
"MMD.validation.rigify_unsupported": "Rigify 아마추어는 지원되지 않음",
|
||||
"MMD.validation.multi_user_mesh": "다중 사용자 메시 감지됨: {mesh}",
|
||||
"MMD.bones_standardized": "본이 성공적으로 표준화됨",
|
||||
"MMD.weights_processed": "가중치가 성공적으로 처리됨",
|
||||
"MMD.hierarchy_fixed": "본 계층 구조가 성공적으로 수정됨",
|
||||
"MMD.hierarchy_validation_warning": "일부 계층 관계를 검증할 수 없음",
|
||||
"MMD.cleanup_completed": "아마추어 정리 완료",
|
||||
"MMD.process_twist_bones": "트위스트 본 처리",
|
||||
"MMD.process_twist_bones_desc": "트위스트 본의 가중치를 부모 본으로 전송",
|
||||
"MMD.connect_bones": "본 연결",
|
||||
"MMD.connect_bones_desc": "적절한 경우 체인의 본 연결",
|
||||
"UVTools.uv_title": "UV 도구",
|
||||
"UVTools.too_many_vertices": "오류! 너무 많은 항목이 선택되었습니다. 두 개의 엣지를 선택하고 있는지 확인하세요!",
|
||||
"UVTools.need_line": "선택된 각 객체에 대해 UV 포인트의 한 줄이 필요합니다. 객체 \"{obj}\"는 이 요구 사항을 충족하지 않습니다!",
|
||||
"UVTools.align_edges": "UV 엣지를 대상에 정렬",
|
||||
"UVTools.align_edges_desc": "각 선택된 메시의 UV 포인트 선을 활성 메시의 선택된 UV 포인트 선에 정렬합니다. 한 모델의 텍스처를 다른 모델에 적용할 때 유용합니다. 각 메시에서 UV 포인트 선의 시작을 식별하기 위해 2D 커서로부터의 거리를 사용합니다.",
|
||||
|
||||
"Visemes.panel_label": "비셈",
|
||||
"Visemes.shape_selection": "쉐이프 키 선택",
|
||||
"Visemes.controls": "비셈 컨트롤",
|
||||
"Visemes.no_shapekeys": "쉐이프 키가 있는 메시 선택",
|
||||
"Visemes.mouth_a": "A 모양",
|
||||
"Visemes.mouth_a_desc": "'A' 소리를 위한 쉐이프 키",
|
||||
"Visemes.mouth_a_desc": "'A' 소리에 대한 쉐이프 키",
|
||||
"Visemes.mouth_o": "O 모양",
|
||||
"Visemes.mouth_o_desc": "'O' 소리를 위한 쉐이프 키",
|
||||
"Visemes.mouth_o_desc": "'O' 소리에 대한 쉐이프 키",
|
||||
"Visemes.mouth_ch": "CH 모양",
|
||||
"Visemes.mouth_ch_desc": "'CH' 소리를 위한 쉐이프 키",
|
||||
"Visemes.mouth_ch_desc": "'CH' 소리에 대한 쉐이프 키",
|
||||
"Visemes.shape_intensity": "쉐이프 강도",
|
||||
"Visemes.shape_intensity_desc": "비셈 쉐이프의 강도 배율",
|
||||
"Visemes.start_preview": "미리보기 시작",
|
||||
@@ -229,10 +314,11 @@
|
||||
"Visemes.create_label": "비셈 생성",
|
||||
"Visemes.create_desc": "VRC 비셈 쉐이프 키 생성",
|
||||
"Visemes.error.no_shapekeys": "메시에 쉐이프 키가 없음",
|
||||
"Visemes.error.select_shapekeys": "A, O, CH 쉐이프 키를 선택하세요",
|
||||
"Visemes.success": "비셈이 성공적으로 생성됨",
|
||||
"Visemes.error.select_shapekeys": "A, O 및 CH에 대한 쉐이프 키를 선택하세요",
|
||||
"Visemes.success": "비셈 생성 성공",
|
||||
"Visemes.mesh_select": "메시 선택",
|
||||
"Visemes.mesh_select_desc": "비셈을 생성할 메시 선택",
|
||||
"Visemes.no_meshes": "메시를 찾을 수 없음",
|
||||
|
||||
"EyeTracking.label": "시선 추적",
|
||||
"EyeTracking.setup": "시선 추적 설정",
|
||||
@@ -248,17 +334,17 @@
|
||||
"EyeTracking.rotation.y": "수평 회전",
|
||||
"EyeTracking.adjust": "눈 조정",
|
||||
"EyeTracking.blinking": "깜빡임 컨트롤",
|
||||
"EyeTracking.no_shapekeys": "선택된 메시에서 쉐이프 키를 찾을 수 없음",
|
||||
"EyeTracking.no_shapekeys": "선택한 메시에서 쉐이프 키를 찾을 수 없음",
|
||||
"EyeTracking.no_armature": "선택된 아마추어 없음",
|
||||
"EyeTracking.no_mesh": "메시를 찾을 수 없음",
|
||||
"EyeTracking.create.label": "시선 추적 생성",
|
||||
"EyeTracking.create.desc": "시선 추적 본과 쉐이프 키 설정",
|
||||
"EyeTracking.create.desc": "시선 추적 본 및 쉐이프 키 설정",
|
||||
"EyeTracking.testing.start.label": "테스트 시작",
|
||||
"EyeTracking.testing.start.desc": "시선 추적 테스트 모드 진입",
|
||||
"EyeTracking.testing.stop.label": "테스트 중지",
|
||||
"EyeTracking.testing.stop.desc": "시선 추적 테스트 모드 종료",
|
||||
"EyeTracking.reset.label": "시선 추적 초기화",
|
||||
"EyeTracking.reset.desc": "모든 시선 추적 설정 초기화",
|
||||
"EyeTracking.reset.label": "시선 추적 재설정",
|
||||
"EyeTracking.reset.desc": "모든 시선 추적 설정 재설정",
|
||||
"EyeTracking.rotate.label": "눈 본 회전",
|
||||
"EyeTracking.rotate.desc": "VRChat 호환성을 위한 눈 본 회전",
|
||||
"EyeTracking.iris.label": "홍채 높이 조정",
|
||||
@@ -267,20 +353,20 @@
|
||||
"EyeTracking.blink.test.desc": "눈 깜빡임 쉐이프 키 테스트",
|
||||
"EyeTracking.lowerlid.test.label": "아래 눈꺼풀 테스트",
|
||||
"EyeTracking.lowerlid.test.desc": "아래 눈꺼풀 쉐이프 키 테스트",
|
||||
"EyeTracking.blink.reset.label": "깜빡임 테스트 초기화",
|
||||
"EyeTracking.blink.reset.desc": "깜빡임 테스트 값 초기화",
|
||||
"EyeTracking.blink.reset.label": "깜빡임 테스트 재설정",
|
||||
"EyeTracking.blink.reset.desc": "깜빡임 테스트 값 재설정",
|
||||
"EyeTracking.validation.noArmature": "씬에서 아마추어를 찾을 수 없음",
|
||||
"EyeTracking.validation.noMesh": "메시 '{mesh}'를 찾을 수 없음",
|
||||
"EyeTracking.validation.noShapekeys": "선택된 메시에 쉐이프 키가 없음",
|
||||
"EyeTracking.validation.noShapekeys": "선택한 메시에 쉐이프 키가 없음",
|
||||
"EyeTracking.validation.leftEye": "왼쪽 눈",
|
||||
"EyeTracking.validation.rightEye": "오른쪽 눈",
|
||||
"EyeTracking.validation.missingGroups": "누락된 버텍스 그룹: {groups}",
|
||||
"EyeTracking.validation.missingBones": "필요한 본 누락: {bones}",
|
||||
"EyeTracking.validation.success": "시선 추적 설정이 성공적으로 검증됨",
|
||||
"EyeTracking.validation.success": "시선 추적 설정 검증 성공",
|
||||
"EyeTracking.error.noMesh": "시선 추적을 위한 메시가 선택되지 않음",
|
||||
"EyeTracking.error.noVertexGroup": "본을 위한 버텍스 그룹을 찾을 수 없음: {bone}",
|
||||
"EyeTracking.error.noShapeSelected": "필요한 모든 쉐이프 키를 선택하세요",
|
||||
"EyeTracking.success": "시선 추적 설정이 성공적으로 완료됨",
|
||||
"EyeTracking.error.noVertexGroup": "본에 대한 버텍스 그룹을 찾을 수 없음: {bone}",
|
||||
"EyeTracking.error.noShapeSelected": "모든 필수 쉐이프 키를 선택하세요",
|
||||
"EyeTracking.success": "시선 추적 설정 완료 성공",
|
||||
"EyeTracking.mode_select": "모드 선택",
|
||||
"EyeTracking.mesh_setup": "메시 설정",
|
||||
"EyeTracking.bone_setup": "본 설정",
|
||||
@@ -308,16 +394,21 @@
|
||||
"EyeTracking.type": "시선 추적 유형",
|
||||
"EyeTracking.type_desc": "생성할 시선 추적 설정 유형 선택",
|
||||
"EyeTracking.create.av3.label": "AV3 시선 추적 생성",
|
||||
"EyeTracking.create.av3.desc": "VRChat Avatar 3.0용 시선 추적 설정",
|
||||
"EyeTracking.create.av3.desc": "VRChat 아바타 3.0용 시선 추적 설정",
|
||||
"EyeTracking.create.sdk2.label": "SDK2 시선 추적 생성",
|
||||
"EyeTracking.create.sdk2.desc": "VRChat SDK2용 시선 추적 설정",
|
||||
"EyeTracking.sdk_version": "SDK 버전",
|
||||
"EyeTracking.type.av3": "Avatar 3.0",
|
||||
"EyeTracking.type.av3_desc": "VRChat Avatar 3.0 시선 추적 설정",
|
||||
"EyeTracking.type.sdk2": "SDK2 (레거시)",
|
||||
"EyeTracking.type.sdk2_desc": "VRChat SDK2 시선 추적 설정",
|
||||
"EyeTracking.type.av3": "아바타 3.0",
|
||||
"EyeTracking.type.av3_desc": "VRChat 아바타 3.0 시선 추적 설정",
|
||||
"EyeTracking.type.sdk2": "레거시 (ChilloutVR)",
|
||||
"EyeTracking.type.sdk2_desc": "레거시 (SDK2) 시선 추적 설정",
|
||||
"EyeTracking.adjust.label": "눈 위치 조정",
|
||||
"EyeTracking.adjust.desc": "버텍스 그룹을 기반으로 눈 본 위치 조정",
|
||||
"EyeTracking.sdk2_warning": "레거시 (SDK2) 시선 추적 알림",
|
||||
"EyeTracking.sdk2_warning_detail1": "이 시스템은 VRChat에 사용해서는 안 됩니다,",
|
||||
"EyeTracking.sdk2_warning_detail2": "시선 추적은 이제 Unity에서 직접",
|
||||
"EyeTracking.sdk2_warning_detail3": "구성됩니다. ChilloutVR과 같은 다른 플랫폼을",
|
||||
"EyeTracking.sdk2_warning_detail4": "위해 남아 있습니다.",
|
||||
|
||||
"CustomPanel.label": "커스텀 아바타 도구",
|
||||
"CustomPanel.merge_mode": "병합 모드",
|
||||
@@ -326,7 +417,7 @@
|
||||
"CustomPanel.select_bone": "본 선택",
|
||||
"CustomPanel.select_armature": "아마추어 선택",
|
||||
"CustomPanel.mode.armature": "아마추어",
|
||||
"CustomPanel.mode.armature_desc": "아마추어 함께 병합",
|
||||
"CustomPanel.mode.armature_desc": "아마추어 병합",
|
||||
"CustomPanel.mode.mesh": "메시",
|
||||
"CustomPanel.mode.mesh_desc": "메시를 아마추어에 부착",
|
||||
|
||||
@@ -335,18 +426,18 @@
|
||||
"AttachMesh.search_desc": "부착할 메시 검색",
|
||||
"AttachMesh.select": "부착할 메시 선택",
|
||||
"AttachMesh.select_desc": "아마추어에 부착할 메시 선택",
|
||||
"AttachMesh.success": "메시가 성공적으로 부착됨",
|
||||
"AttachMesh.success": "메시 부착 성공",
|
||||
"AttachMesh.warn_no_armature": "부착할 아마추어와 메시를 선택하세요",
|
||||
"AttachMesh.validate_transforms": "메시 변형 검증 중",
|
||||
"AttachMesh.validate_name": "메시 이름 검증 중",
|
||||
"AttachMesh.parent_mesh": "메시를 아마추어에 페어런팅",
|
||||
"AttachMesh.parent_mesh": "메시를 아마추어에 부모 설정 중",
|
||||
"AttachMesh.setup_weights": "버텍스 가중치 설정 중",
|
||||
"AttachMesh.create_bone": "부착 본 생성 중",
|
||||
"AttachMesh.position_bone": "본 위치 지정 중",
|
||||
"AttachMesh.add_modifier": "아마추어 모디파이어 추가 중",
|
||||
"AttachMesh.error.bone_not_found": "부착 본 '{bone}'을(를) 찾을 수 없음",
|
||||
"AttachMesh.error.mesh_not_found": "메시를 찾을 수 없음",
|
||||
"AttachMesh.error.non_uniform_scale": "메시에 비균일 스케일이 있습니다. 스케일을 적용하세요",
|
||||
"AttachMesh.error.non_uniform_scale": "메시의 크기가 균일하지 않습니다. 크기를 적용하세요",
|
||||
"AttachBone.search_desc": "대상 본 검색",
|
||||
"AttachBone.select": "대상 본 선택",
|
||||
"AttachBone.select_desc": "메시를 부착할 본 선택",
|
||||
@@ -362,31 +453,74 @@
|
||||
"MergeArmature.from_desc": "병합할 소스 아마추어",
|
||||
"MergeArmature.from_search_desc": "소스 아마추어 검색",
|
||||
"MergeArmature.error.not_found": "아마추어 '{name}'을(를) 찾을 수 없음",
|
||||
"MergeArmature.error.transforms_not_aligned": "이 아마추어를 병합하려면 변형을 적용해야 합니다. 수동 방법이나 변형 적용 체크박스를 통해 수행하세요",
|
||||
"MergeArmature.error.transforms_not_aligned": "이 아마추어를 병합하려면 변형을 적용해야 합니다. 수동 방법 또는 변형 적용 체크박스를 통해 이 작업을 수행하세요",
|
||||
"MergeArmature.error.check_transforms": "부모 변형을 확인하세요",
|
||||
"MergeArmature.error.fix_parents": "부모 관계를 수정하세요",
|
||||
"MergeArmature.progress.removing_rigidbodies": "강체와 조인트 제거 중",
|
||||
"MergeArmature.progress.removing_rigidbodies": "리지드 바디 및 조인트 제거 중",
|
||||
"MergeArmature.progress.validating": "아마추어 검증 중",
|
||||
"MergeArmature.progress.merging": "아마추어 병합 중",
|
||||
"MergeArmature.success": "아마추어가 성공적으로 병합됨",
|
||||
"MergeArmature.success": "아마추어 병합 성공",
|
||||
"MergeArmature.merge_all": "동일한 본 병합",
|
||||
"MergeArmature.merge_all_desc": "일치하는 이름의 본 병합",
|
||||
"MergeArmature.merge_all_desc": "일치하는 이름을 가진 본 병합",
|
||||
"MergeArmature.apply_transforms": "변형 적용",
|
||||
"MergeArmature.apply_transforms_desc": "병합 전 모든 변형 적용",
|
||||
"MergeArmature.join_meshes": "메시 결합",
|
||||
"MergeArmature.join_meshes_desc": "병합 후 메시 결합",
|
||||
"MergeArmature.remove_zero_weights": "0 가중치 제거",
|
||||
"MergeArmature.remove_zero_weights": "가중치 0 제거",
|
||||
"MergeArmature.remove_zero_weights_desc": "가중치가 없는 버텍스 그룹 제거",
|
||||
"MergeArmature.cleanup_shape_keys": "쉐이프 키 정리",
|
||||
"MergeArmature.cleanup_shape_keys_desc": "미사용 쉐이프 키 제거",
|
||||
|
||||
"TextureAtlas.atlas_completed": "텍스처 아틀라스 생성 완료",
|
||||
"TextureAtlas.atlas_error": "텍스처 아틀라스 생성 중 오류 발생",
|
||||
"TextureAtlas.atlas_materials": "아틀라스 재질",
|
||||
"TextureAtlas.atlas_materials_desc": "모델을 최적화하기 위한 아틀라스 재질",
|
||||
"TextureAtlas.label": "텍스처 아틀라싱",
|
||||
"TextureAtlas.loaded_list": "로드된 텍스처 아틀라스 재질 목록",
|
||||
"TextureAtlas.material_list_label": "텍스처 아틀라스 재질 목록 재질",
|
||||
"TextureAtlas.reload_list": "텍스처 아틀라스 재질 목록 다시 로드",
|
||||
"TextureAtlas.error.label": "오류",
|
||||
"TextureAtlas.none.label": "없음",
|
||||
"TextureAtlas.no_nodes_error.desc": "이 재질은 노드를 사용하지 않습니다!",
|
||||
"TextureAtlas.no_images_error.desc": "이 재질에는 이미지가 없습니다!",
|
||||
"TextureAtlas.texture_use_atlas.desc": "{name} 맵 아틀라스에 사용될 텍스처",
|
||||
"TextureAtlas.albedo": "알베도",
|
||||
"TextureAtlas.normal": "노멀",
|
||||
"TextureAtlas.emission": "이미션",
|
||||
"TextureAtlas.ambient_occlusion": "앰비언트 오클루전",
|
||||
"TextureAtlas.height": "높이",
|
||||
"TextureAtlas.roughness": "거칠기",
|
||||
"TextureAtlas.description_1": "텍스처를 결합한 단일 재질 생성",
|
||||
"TextureAtlas.description_2": "아바타의 성능을 최적화합니다.",
|
||||
"TextureAtlas.texture_maps": "텍스처 맵",
|
||||
"TextureAtlas.material_ready": "재질이 아틀라스 생성 준비가 되었습니다",
|
||||
"TextureAtlas.material_not_ready": "재질에는 최소 하나의 텍스처가 필요합니다",
|
||||
"TextureAtlas.select_all_tooltip": "모든 재질 선택",
|
||||
"TextureAtlas.select_none_tooltip": "모든 선택 해제",
|
||||
"TextureAtlas.expand_all_tooltip": "모든 재질 설정 펼치기",
|
||||
"TextureAtlas.collapse_all_tooltip": "모든 재질 설정 접기",
|
||||
"TextureAtlas.estimated_size": "예상 아틀라스 크기",
|
||||
"TextureAtlas.materials": "재질",
|
||||
"TextureAtlas.no_materials_selected": "선택된 재질이 없습니다",
|
||||
"TextureAtlas.select_armature_first": "먼저 아마추어를 선택해주세요",
|
||||
"TextureAtlas.how_to_use_1": "1. 장면에서 아마추어 선택",
|
||||
"TextureAtlas.how_to_use_2": "2. '재질 불러오기'를 클릭하여 시작",
|
||||
"TextureAtlas.load_error": "재질 로딩 오류. 자세한 내용은 콘솔을 확인하세요.",
|
||||
"TextureAtlas.material_not_included": "재질이 아틀라스에 포함되지 않았습니다",
|
||||
"TextureAtlas.save_file_first": "텍스처 아틀라스를 만들기 전에 Blender 파일을 저장하세요",
|
||||
"TextureAtlas.save_file_instructions": "파일 > 다른 이름으로 저장... 을 사용하거나 아래 버튼을 클릭하세요:",
|
||||
"TextureAtlas.save_file_button": "Blender 파일 저장",
|
||||
"TextureAtlas.save_file_required": "파일 저장 필요",
|
||||
"TextureAtlas.search_materials": "재질 검색",
|
||||
"TextureAtlas.search_materials_desc": "이름으로 재질 필터링",
|
||||
|
||||
"Settings.label": "설정",
|
||||
"Settings.language": "언어",
|
||||
"Settings.language_desc": "인터페이스 언어 선택",
|
||||
"Settings.validation_mode": "검증 모드",
|
||||
"Settings.validation_mode_desc": "아마추어 검증의 엄격성 선택",
|
||||
"Settings.validation_mode_desc": "아마추어 검증 엄격성 선택",
|
||||
"Settings.validation_mode.strict": "엄격",
|
||||
"Settings.validation_mode.strict_desc": "본 계층 구조와 대칭성을 포함한 전체 검증",
|
||||
"Settings.validation_mode.strict_desc": "본 계층 구조 및 대칭성을 포함한 전체 검증",
|
||||
"Settings.validation_mode.basic": "기본",
|
||||
"Settings.validation_mode.basic_desc": "필수 본 확인만",
|
||||
"Settings.validation_mode.none": "없음",
|
||||
@@ -395,14 +529,118 @@
|
||||
"Settings.logging": "로깅",
|
||||
"Settings.enable_logging": "디버그 로깅 활성화",
|
||||
"Settings.enable_logging_desc": "문제 해결을 위한 상세 디버그 로깅 활성화",
|
||||
"Settings.logging_enabled": "디버그 로깅이 활성화됨",
|
||||
"Settings.logging_disabled": "디버그 로깅이 비활성화됨",
|
||||
"Settings.logging_enabled": "디버그 로깅 활성화됨",
|
||||
"Settings.logging_disabled": "디버그 로깅 비활성화됨",
|
||||
"Settings.highlight_problem_bones": "문제 본 강조 표시",
|
||||
"Settings.highlight_problem_bones_desc": "뷰포트에서 검증 문제가 있는 본 강조 표시",
|
||||
"Settings.bone_highlighting": "본 강조 표시",
|
||||
"Settings.log_level": "로그 레벨",
|
||||
"Settings.log_level_desc": "디버그 로깅의 상세 수준 선택",
|
||||
"Settings.log_level.debug": "디버그",
|
||||
"Settings.log_level.debug_desc": "상세한 디버그 정보를 포함한 모든 로그 메시지 표시",
|
||||
"Settings.log_level.info": "정보",
|
||||
"Settings.log_level.info_desc": "정보 메시지, 경고 및 오류 표시",
|
||||
"Settings.log_level.warning": "경고",
|
||||
"Settings.log_level.warning_desc": "경고 및 오류만 표시",
|
||||
"Settings.log_level.error": "오류",
|
||||
"Settings.log_level.error_desc": "오류 메시지만 표시",
|
||||
"Language.auto": "자동",
|
||||
"Language.en_US": "영어",
|
||||
"Language.ja_JP": "일본어",
|
||||
"Language.ko_KR": "한국어",
|
||||
"Language.changed.title": "언어 변경됨",
|
||||
"Language.changed.success": "언어가 성공적으로 변경됨!",
|
||||
"Language.changed.restart": "일부 UI 요소는 블렌더 재시작이 필요할 수 있음"
|
||||
}
|
||||
"Language.changed.success": "언어가 성공적으로 변경되었습니다!",
|
||||
"Language.changed.restart": "일부 UI 요소는 블렌더를 다시 시작해야 할 수 있습니다",
|
||||
|
||||
"VRM.panel.label": "VRM에서 Unity로",
|
||||
"VRM.converter.title": "VRM 변환기",
|
||||
"VRM.no_armature_selected": "선택된 아마추어 없음",
|
||||
"VRM.select_armature_to_convert": "변환할 아마추어를 선택하세요",
|
||||
"VRM.armature_name": "아마추어: {name}",
|
||||
"VRM.armature_detected": "VRM 아마추어 감지됨",
|
||||
"VRM.no_vrm_bones_detected": "VRM 본이 감지되지 않음",
|
||||
"VRM.remove_root_bone": "루트 본 제거",
|
||||
"VRM.convert_to_unity_format": "Unity 형식으로 변환",
|
||||
"VRM.convert_to_unity.label": "VRM을 Unity로 변환",
|
||||
"VRM.convert_to_unity.desc": "VRM 아마추어 본 이름을 Unity 휴머노이드 명명 규칙으로 변환",
|
||||
"VRM.conversion_info.title": "변환 정보:",
|
||||
"VRM.conversion_info.renames_bones": "• VRM 본을 Unity 형식으로 이름 변경",
|
||||
"VRM.conversion_info.removes_colliders": "• 콜라이더 본 제거 (선택사항)",
|
||||
"VRM.conversion_info.removes_root": "• 루트 본 제거, Hips를 루트로 설정 (선택사항)",
|
||||
"VRM.conversion_info.maintains_hierarchy": "• 본 계층 구조 유지",
|
||||
"VRM.conversion_info.validates_results": "• 변환 결과 검증",
|
||||
"VRM.conversion_info.preserves_animations": "• 모든 애니메이션 보존",
|
||||
"VRM.detection_failed.title": "VRM 감지 실패:",
|
||||
"VRM.detection_failed.not_vrm_format": "• 선택된 아마추어가 VRM 형식이 아님",
|
||||
"VRM.detection_failed.bones_start_with": "• VRM 본은 'J_Bip_C_'로 시작함",
|
||||
"VRM.detection_failed.need_five_bones": "• 최소 5개의 VRM 본이 감지되어야 함",
|
||||
"VRM.detection_failed.check_bone_names": "• 아마추어 본 이름을 확인하세요",
|
||||
"VRM.validation.hierarchy_passed": "Unity 계층 구조 검증 통과",
|
||||
"VRM.validation.hierarchy_issues": "변환은 완료되었지만 계층 구조 검증에서 문제를 발견했습니다:",
|
||||
"VRM.validation.armature_passed": "아마추어가 표준 검증을 통과했습니다",
|
||||
"VRM.validation.failed": "변환은 완료되었지만 검증에 실패했습니다: {error}",
|
||||
"VRM.remove_colliders": "콜라이더 제거",
|
||||
"VRM.remove_colliders_desc": "변환 중 VRM 콜라이더 본 제거",
|
||||
"VRM.remove_root": "루트 본 제거",
|
||||
"VRM.remove_root_desc": "불필요한 VRM 루트 본을 제거하고 힙을 루트 본으로 설정",
|
||||
|
||||
"Translation.label": "번역",
|
||||
"Translation.service": "번역 서비스",
|
||||
"Translation.service_desc": "사용할 번역 서비스 선택",
|
||||
"Translation.mode": "번역 모드",
|
||||
"Translation.mode_desc": "번역 동작 방식 선택",
|
||||
"Translation.mode.hybrid": "하이브리드 (사전 + API)",
|
||||
"Translation.mode.hybrid_desc": "먼저 사전을 시도하고, 그 다음 API 서비스를 폴백으로 사용",
|
||||
"Translation.mode.dictionary_only": "사전만",
|
||||
"Translation.mode.dictionary_only_desc": "번역에 내장 사전만 사용",
|
||||
"Translation.mode.api_only": "API만",
|
||||
"Translation.mode.api_only_desc": "온라인 번역 서비스만 사용",
|
||||
"Translation.service_settings": "번역 서비스",
|
||||
"Translation.language_settings": "언어 설정",
|
||||
"Translation.quick_actions": "빠른 작업",
|
||||
"Translation.utilities": "유틸리티",
|
||||
"Translation.advanced_settings": "고급 설정",
|
||||
"Translation.source_language": "소스 언어",
|
||||
"Translation.source_language_desc": "번역할 원본 언어",
|
||||
"Translation.target_language": "대상 언어",
|
||||
"Translation.target_language_desc": "번역할 대상 언어",
|
||||
"Translation.translate_names": "이름 번역",
|
||||
"Translation.translate_names_desc": "선택한 서비스와 설정을 사용하여 이름 번역",
|
||||
"Translation.test_service": "서비스 테스트",
|
||||
"Translation.test_service_desc": "현재 선택된 번역 서비스 테스트",
|
||||
"Translation.clear_cache": "캐시 지우기",
|
||||
"Translation.clear_cache_desc": "모든 캐시된 번역 지우기",
|
||||
"Translation.show_stats": "통계 표시",
|
||||
"Translation.show_stats_desc": "번역 통계 및 정보 표시",
|
||||
"Translation.no_armature": "선택된 아마추어 없음",
|
||||
"Translation.test_failed": "번역 서비스 테스트 실패 - 구성을 확인하세요",
|
||||
"Translation.cache_cleared": "번역 캐시가 성공적으로 지워졌습니다",
|
||||
"Translation.mymemory_info": "MyMemory는 API 키 없이 완전히 무료입니다. 하루 1000회 번역을 제공합니다.",
|
||||
"Translation.service.mymemory": "MyMemory (무료)",
|
||||
"Translation.service.mymemory_desc": "완전히 무료 서비스 - API 키 불필요!",
|
||||
"Translation.service.libretranslate": "LibreTranslate",
|
||||
"Translation.service.libretranslate_desc": "구성 가능한 서버 - 셀프 호스팅 가능",
|
||||
"Translation.service.deepl": "DeepL",
|
||||
"Translation.service.deepl_desc": "고품질 번역 - API 키 필요",
|
||||
"Translation.type.bones": "본",
|
||||
"Translation.type.bones_desc": "본 이름 번역",
|
||||
"Translation.type.shapekeys": "쉐이프 키",
|
||||
"Translation.type.shapekeys_desc": "쉐이프 키 이름 번역",
|
||||
"Translation.type.materials": "재질",
|
||||
"Translation.type.materials_desc": "재질 이름 번역",
|
||||
"Translation.type.objects": "객체",
|
||||
"Translation.type.objects_desc": "객체 이름 번역",
|
||||
"Translation.type.all": "모두",
|
||||
"Translation.type.all_desc": "지원되는 모든 유형 번역",
|
||||
"Translation.configure_deepl": "DeepL API 구성",
|
||||
"Translation.configure_deepl_desc": "DeepL 번역 서비스 API 키 구성",
|
||||
"Translation.deepl_api_key": "DeepL API 키",
|
||||
"Translation.deepl_api_key_desc": "당신의 DeepL API 키 (deepl.com/pro에서 무료 키 획득)",
|
||||
"Translation.configure_libretranslate": "LibreTranslate 서버 구성",
|
||||
"Translation.configure_libretranslate_desc": "LibreTranslate 번역 서비스 서버 URL 구성",
|
||||
"Translation.server_url": "서버 URL",
|
||||
"Translation.server_url_desc": "LibreTranslate 서버 URL (예: https://your-server.com)",
|
||||
"Translation.api_key": "API 키",
|
||||
"Translation.api_key_desc": "LibreTranslate 서버용 API 키 (일부 서버는 선택사항)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
from bpy.types import UIList, Panel, UILayout, Object, Context, Material, Operator
|
||||
import bpy
|
||||
from math import sqrt
|
||||
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
|
||||
from .panel_layout import get_panel_order, should_open_by_default
|
||||
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'
|
||||
bl_label = "Select All"
|
||||
bl_description = "Select all materials for atlas"
|
||||
|
||||
def execute(self, context):
|
||||
for item in context.scene.avatar_toolkit.materials:
|
||||
item.mat.include_in_atlas = True
|
||||
return {'FINISHED'}
|
||||
|
||||
class AvatarToolKit_OT_SelectNoneMaterials(Operator):
|
||||
bl_idname = 'avatar_toolkit.select_none_materials'
|
||||
bl_label = "Select None"
|
||||
bl_description = "Deselect all materials"
|
||||
|
||||
def execute(self, context):
|
||||
for item in context.scene.avatar_toolkit.materials:
|
||||
item.mat.include_in_atlas = False
|
||||
return {'FINISHED'}
|
||||
|
||||
class AvatarToolKit_OT_ExpandAllMaterials(Operator):
|
||||
bl_idname = 'avatar_toolkit.expand_all_materials'
|
||||
bl_label = "Expand All"
|
||||
bl_description = "Expand all material settings"
|
||||
|
||||
def execute(self, context):
|
||||
for item in context.scene.avatar_toolkit.materials:
|
||||
item.mat.material_expanded = True
|
||||
return {'FINISHED'}
|
||||
|
||||
class AvatarToolKit_OT_CollapseAllMaterials(Operator):
|
||||
bl_idname = 'avatar_toolkit.collapse_all_materials'
|
||||
bl_label = "Collapse All"
|
||||
bl_description = "Collapse all material settings"
|
||||
|
||||
def execute(self, context):
|
||||
for item in context.scene.avatar_toolkit.materials:
|
||||
item.mat.material_expanded = False
|
||||
return {'FINISHED'}
|
||||
|
||||
class AvatarToolKit_OT_ExpandSectionMaterials(Operator):
|
||||
bl_idname = 'avatar_toolkit.expand_section_materials'
|
||||
bl_label = ""
|
||||
bl_description = ""
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
return True
|
||||
|
||||
def execute(self, context: Context) -> set:
|
||||
try:
|
||||
if not context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown:
|
||||
context.scene.avatar_toolkit.materials.clear()
|
||||
newlist: list[Material] = []
|
||||
|
||||
logger.debug("Loading materials for texture atlas")
|
||||
for obj in 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)
|
||||
newitem: SceneMatClass = context.scene.avatar_toolkit.materials.add()
|
||||
newitem.mat = mat_slot.material
|
||||
|
||||
MaterialListBool.old_list[context.scene.name] = newlist
|
||||
context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown = True
|
||||
logger.info(f"Loaded {len(newlist)} materials for texture atlas")
|
||||
else:
|
||||
context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown = False
|
||||
logger.debug("Hiding material list")
|
||||
|
||||
return {'FINISHED'}
|
||||
except Exception:
|
||||
logger.error(f"Error loading materials: {traceback.format_exc()}", exc_info=True)
|
||||
self.report({'ERROR'}, t("TextureAtlas.load_error"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
class AvatarToolKit_UL_MaterialTextureAtlasProperties(UIList):
|
||||
bl_label = t("TextureAtlas.material_list_label")
|
||||
bl_idname = "Material_UL_avatar_toolkit_texture_atlas_mat_list_mat"
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
|
||||
def draw_header(self, context):
|
||||
layout = self.layout
|
||||
|
||||
row = layout.row(align=True)
|
||||
row.scale_y = 1.2
|
||||
|
||||
row.operator(AvatarToolKit_OT_SelectAllMaterials.bl_idname, text="", icon='CHECKBOX_HLT',
|
||||
emboss=True).tooltip = t("TextureAtlas.select_all_tooltip")
|
||||
row.operator(AvatarToolKit_OT_SelectNoneMaterials.bl_idname, text="", icon='CHECKBOX_DEHLT',
|
||||
emboss=True).tooltip = t("TextureAtlas.select_none_tooltip")
|
||||
row.separator(factor=0.5)
|
||||
row.operator(AvatarToolKit_OT_ExpandAllMaterials.bl_idname, text="", icon='DISCLOSURE_TRI_DOWN',
|
||||
emboss=True).tooltip = t("TextureAtlas.expand_all_tooltip")
|
||||
row.operator(AvatarToolKit_OT_CollapseAllMaterials.bl_idname, text="", icon='DISCLOSURE_TRI_RIGHT',
|
||||
emboss=True).tooltip = t("TextureAtlas.collapse_all_tooltip")
|
||||
|
||||
row.separator(factor=1.0)
|
||||
search_row = row.row()
|
||||
search_row.scale_x = 2.0
|
||||
search_row.prop(context.scene.avatar_toolkit, "material_search_filter", text="", icon='VIEWZOOM')
|
||||
|
||||
box = layout.box()
|
||||
size_row = box.row()
|
||||
size_row.alignment = 'CENTER'
|
||||
size_text = self.calculate_atlas_size(context)
|
||||
size_row.label(text=f"{t('TextureAtlas.estimated_size')}: {size_text}px", icon='TEXTURE')
|
||||
|
||||
def draw_item(self, context: Context, layout: UILayout, data: Object, item: SceneMatClass, icon, active_data, active_propname, index):
|
||||
if context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown:
|
||||
if (context.scene.avatar_toolkit.material_search_filter and
|
||||
context.scene.avatar_toolkit.material_search_filter.lower() not in item.mat.name.lower()):
|
||||
return
|
||||
|
||||
# Main material
|
||||
row = layout.row()
|
||||
row.prop(item.mat, "include_in_atlas", text="",
|
||||
icon='CHECKBOX_HLT' if item.mat.include_in_atlas else 'CHECKBOX_DEHLT',
|
||||
emboss=False)
|
||||
|
||||
# Material name
|
||||
row.prop(item.mat, "material_expanded",
|
||||
text=item.mat.name,
|
||||
icon='DOWNARROW_HLT' if item.mat.material_expanded else 'RIGHTARROW',
|
||||
emboss=False)
|
||||
|
||||
row.label(text="", icon='MATERIAL')
|
||||
|
||||
if item.mat.material_expanded:
|
||||
box = layout.box()
|
||||
col = box.column(align=True)
|
||||
|
||||
header_row = col.row()
|
||||
header_row.alignment = 'CENTER'
|
||||
header_row.label(text=t("TextureAtlas.texture_maps"), icon='IMAGE')
|
||||
col.separator(factor=0.5)
|
||||
self.draw_texture_row(col, item.mat, "texture_atlas_albedo", "IMAGE_RGB", t("TextureAtlas.albedo"))
|
||||
self.draw_texture_row(col, item.mat, "texture_atlas_normal", "NORMALS_FACE", t("TextureAtlas.normal"))
|
||||
self.draw_texture_row(col, item.mat, "texture_atlas_emission", "LIGHT", t("TextureAtlas.emission"))
|
||||
self.draw_texture_row(col, item.mat, "texture_atlas_ambient_occlusion", "SHADING_SOLID", t("TextureAtlas.ambient_occlusion"))
|
||||
self.draw_texture_row(col, item.mat, "texture_atlas_height", "IMAGE_ZDEPTH", t("TextureAtlas.height"))
|
||||
self.draw_texture_row(col, item.mat, "texture_atlas_roughness", "MATERIAL", t("TextureAtlas.roughness"))
|
||||
|
||||
col.separator(factor=0.5)
|
||||
|
||||
status_row = col.row()
|
||||
status_row.alignment = 'CENTER'
|
||||
is_ready = self.is_material_ready(item.mat)
|
||||
|
||||
if item.mat.include_in_atlas:
|
||||
status_text = t("TextureAtlas.material_ready") if is_ready else t("TextureAtlas.material_not_ready")
|
||||
status_icon = 'CHECKMARK' if is_ready else 'ERROR'
|
||||
else:
|
||||
status_text = t("TextureAtlas.material_not_included")
|
||||
status_icon = 'INFO'
|
||||
|
||||
status_row.label(text=status_text, icon=status_icon)
|
||||
|
||||
def draw_texture_row(self, layout, material, prop_name, icon, label_text):
|
||||
row = layout.row(align=True)
|
||||
icon_row = row.row()
|
||||
icon_row.scale_x = 0.5
|
||||
icon_row.label(text="", icon=icon)
|
||||
|
||||
# Texture selector
|
||||
row.prop(material, prop_name, text=label_text)
|
||||
status_row = row.row()
|
||||
status_row.scale_x = 0.5
|
||||
if getattr(material, prop_name):
|
||||
status_row.label(text="", icon='CHECKMARK')
|
||||
else:
|
||||
status_row.label(text="", icon='X')
|
||||
|
||||
def is_material_ready(self, material):
|
||||
return bool(material.texture_atlas_albedo or
|
||||
material.texture_atlas_normal or
|
||||
material.texture_atlas_emission)
|
||||
|
||||
def calculate_atlas_size(self, context):
|
||||
total_size = 0
|
||||
selected_count = 0
|
||||
|
||||
for mat in context.scene.avatar_toolkit.materials:
|
||||
if mat.mat.include_in_atlas:
|
||||
selected_count += 1
|
||||
if mat.mat.texture_atlas_albedo:
|
||||
img = bpy.data.images[mat.mat.texture_atlas_albedo]
|
||||
total_size += img.size[0] * img.size[1]
|
||||
|
||||
if total_size == 0:
|
||||
return f"0x0 ({t('TextureAtlas.no_materials_selected')})"
|
||||
size = int(sqrt(total_size))
|
||||
pot_size = 2 ** (size - 1).bit_length() # Next power of 2
|
||||
|
||||
return f"{pot_size}x{pot_size} ({selected_count} {t('TextureAtlas.materials')})"
|
||||
|
||||
class AvatarToolKit_PT_TextureAtlasPanel(Panel):
|
||||
bl_label = t("TextureAtlas.label")
|
||||
bl_idname = "OBJECT_PT_avatar_toolkit_texture_atlas"
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = CATEGORY_NAME
|
||||
bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
|
||||
bl_order = get_panel_order('texture_atlas')
|
||||
bl_options = set() if not should_open_by_default('TEXTURE_ATLAS') else {'DEFAULT_CLOSED'}
|
||||
|
||||
def draw(self, context: Context):
|
||||
layout = self.layout
|
||||
armature = get_active_armature(context)
|
||||
|
||||
if armature:
|
||||
header_row = layout.row()
|
||||
header_row.label(text=t("TextureAtlas.label"), icon='TEXTURE')
|
||||
layout.separator(factor=0.5)
|
||||
info_box = layout.box()
|
||||
info_col = info_box.column()
|
||||
info_col.scale_y = 0.9
|
||||
info_col.label(text=t("TextureAtlas.description_1"), icon='INFO')
|
||||
info_col.label(text=t("TextureAtlas.description_2"))
|
||||
|
||||
if not bpy.data.filepath:
|
||||
warning_box = layout.box()
|
||||
warning_col = warning_box.column()
|
||||
warning_col.scale_y = 0.9
|
||||
warning_col.alert = True
|
||||
warning_col.label(text=t("TextureAtlas.save_file_first"), icon='ERROR')
|
||||
warning_col.label(text=t("TextureAtlas.save_file_instructions"))
|
||||
warning_col.operator("wm.save_as_mainfile", text=t("TextureAtlas.save_file_button"), icon='FILE_TICK')
|
||||
layout.separator(factor=0.5)
|
||||
|
||||
layout.separator(factor=0.5)
|
||||
box = layout.box()
|
||||
row = box.row(align=True)
|
||||
row.scale_y = 1.2
|
||||
direction_icon = 'RIGHTARROW' if not context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown else 'DOWNARROW_HLT'
|
||||
button_text = t("TextureAtlas.reload_list") if not context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown else t("TextureAtlas.loaded_list")
|
||||
row.operator(AvatarToolKit_OT_ExpandSectionMaterials.bl_idname,
|
||||
text=button_text,
|
||||
icon=direction_icon)
|
||||
|
||||
# Material list expanded
|
||||
if context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown:
|
||||
row = box.row()
|
||||
row.template_list(AvatarToolKit_UL_MaterialTextureAtlasProperties.bl_idname,
|
||||
'material_list',
|
||||
context.scene.avatar_toolkit,
|
||||
'materials',
|
||||
context.scene.avatar_toolkit,
|
||||
'texture_atlas_material_index',
|
||||
rows=12,
|
||||
type='DEFAULT')
|
||||
|
||||
layout.separator(factor=1.0)
|
||||
|
||||
row = layout.row()
|
||||
row.scale_y = 1.5
|
||||
row.enabled = context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown
|
||||
|
||||
has_selected = False
|
||||
if context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown:
|
||||
for item in context.scene.avatar_toolkit.materials:
|
||||
if item.mat.include_in_atlas:
|
||||
has_selected = True
|
||||
break
|
||||
|
||||
if not has_selected and context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown:
|
||||
row.operator(AvatarToolKit_OT_AtlasMaterials.bl_idname,
|
||||
text=t("TextureAtlas.no_materials_selected"),
|
||||
icon='ERROR')
|
||||
else:
|
||||
row.operator(AvatarToolKit_OT_AtlasMaterials.bl_idname,
|
||||
text=t("TextureAtlas.atlas_materials"),
|
||||
icon='NODE_TEXTURE')
|
||||
else:
|
||||
layout.label(text=t("Tools.select_armature"), icon='ERROR')
|
||||
|
||||
box = layout.box()
|
||||
col = box.column()
|
||||
col.scale_y = 0.9
|
||||
col.label(text=t("TextureAtlas.select_armature_first"), icon='INFO')
|
||||
col.label(text=t("TextureAtlas.how_to_use_1"))
|
||||
col.label(text=t("TextureAtlas.how_to_use_2"))
|
||||
+13
-11
@@ -2,13 +2,16 @@ import bpy
|
||||
from typing import Set, List, Tuple, Any
|
||||
from bpy.types import Panel, Context, UILayout, Operator, Event, WindowManager
|
||||
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
|
||||
from .panel_layout import get_panel_order, should_open_by_default
|
||||
from ..functions.custom_tools.mesh_attachment import AvatarToolkit_OT_AttachMesh
|
||||
from ..functions.custom_tools.armature_merging import AvatarToolkit_OT_MergeArmature
|
||||
from ..core.translations import t
|
||||
from ..core.common import (
|
||||
get_active_armature,
|
||||
get_all_meshes,
|
||||
validate_armature,
|
||||
get_armature_list
|
||||
)
|
||||
from ..core.armature_validation import validate_armature
|
||||
|
||||
class AvatarToolkit_OT_SearchMergeArmatureInto(Operator):
|
||||
"""Search operator for selecting target armature to merge into"""
|
||||
@@ -110,8 +113,8 @@ class AvatarToolKit_PT_CustomPanel(Panel):
|
||||
bl_region_type: str = 'UI'
|
||||
bl_category: str = CATEGORY_NAME
|
||||
bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
|
||||
bl_order: int = 4
|
||||
bl_options: Set[str] = {'DEFAULT_CLOSED'}
|
||||
bl_order: int = get_panel_order('custom_avatar')
|
||||
bl_options: Set[str] = set() if not should_open_by_default('CUSTOM_AVATAR') else {'DEFAULT_CLOSED'}
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
"""Draw the custom avatar panel UI"""
|
||||
@@ -155,7 +158,6 @@ class AvatarToolKit_PT_CustomPanel(Panel):
|
||||
|
||||
# Group related options together
|
||||
transform_col: UILayout = col.column(align=True)
|
||||
transform_col.prop(toolkit, "merge_all_bones")
|
||||
transform_col.prop(toolkit, "apply_transforms")
|
||||
|
||||
col.separator(factor=0.5)
|
||||
@@ -174,12 +176,12 @@ class AvatarToolKit_PT_CustomPanel(Panel):
|
||||
# Armature selection with better alignment
|
||||
row: UILayout = col.row(align=True)
|
||||
row.label(text=t('MergeArmature.into'), icon='ARMATURE_DATA')
|
||||
row.operator("avatar_toolkit.search_merge_armature_into",
|
||||
row.operator(AvatarToolkit_OT_SearchMergeArmatureInto.bl_idname,
|
||||
text=toolkit.merge_armature_into)
|
||||
|
||||
row: UILayout = col.row(align=True)
|
||||
row.label(text=t('MergeArmature.from'), icon='ARMATURE_DATA')
|
||||
row.operator("avatar_toolkit.search_merge_armature",
|
||||
row.operator(AvatarToolkit_OT_SearchMergeArmature.bl_idname,
|
||||
text=toolkit.merge_armature)
|
||||
|
||||
# Merge button with emphasis
|
||||
@@ -187,7 +189,7 @@ class AvatarToolKit_PT_CustomPanel(Panel):
|
||||
col: UILayout = merge_box.column(align=True)
|
||||
row: UILayout = col.row(align=True)
|
||||
row.scale_y = 1.5
|
||||
row.operator("avatar_toolkit.merge_armatures", icon='ARMATURE_DATA')
|
||||
row.operator(AvatarToolkit_OT_MergeArmature.bl_idname, icon='ARMATURE_DATA')
|
||||
|
||||
def draw_mesh_tools(self, layout: UILayout, context: Context) -> None:
|
||||
"""Draw the mesh attachment tools section"""
|
||||
@@ -212,17 +214,17 @@ class AvatarToolKit_PT_CustomPanel(Panel):
|
||||
# Selection rows with icons and better alignment
|
||||
row: UILayout = col.row(align=True)
|
||||
row.label(text=t('CustomPanel.select_armature'), icon='ARMATURE_DATA')
|
||||
row.operator("avatar_toolkit.search_merge_armature_into",
|
||||
row.operator(AvatarToolkit_OT_SearchMergeArmatureInto.bl_idname,
|
||||
text=toolkit.merge_armature_into)
|
||||
|
||||
row: UILayout = col.row(align=True)
|
||||
row.label(text=t('CustomPanel.select_mesh'), icon='MESH_DATA')
|
||||
row.operator("avatar_toolkit.search_attach_mesh",
|
||||
row.operator(AvatarToolkit_OT_SearchAttachMesh.bl_idname,
|
||||
text=toolkit.attach_mesh)
|
||||
|
||||
row: UILayout = col.row(align=True)
|
||||
row.label(text=t('CustomPanel.select_bone'), icon='BONE_DATA')
|
||||
row.operator("avatar_toolkit.search_attach_bone",
|
||||
row.operator(AvatarToolkit_OT_SearchAttachBone.bl_idname,
|
||||
text=toolkit.attach_bone)
|
||||
|
||||
# Attach button with emphasis
|
||||
@@ -230,4 +232,4 @@ class AvatarToolKit_PT_CustomPanel(Panel):
|
||||
col: UILayout = attach_box.column(align=True)
|
||||
row: UILayout = col.row(align=True)
|
||||
row.scale_y = 1.5
|
||||
row.operator("avatar_toolkit.attach_mesh", icon='ARMATURE_DATA')
|
||||
row.operator(AvatarToolkit_OT_AttachMesh.bl_idname, icon='ARMATURE_DATA')
|
||||
|
||||
+46
-65
@@ -2,6 +2,8 @@ import bpy
|
||||
from typing import Set
|
||||
from bpy.types import Panel, Context, UILayout, Operator, Event, WindowManager
|
||||
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
|
||||
from .ui_utils import UIStyle, draw_section_header, wrap_text_label
|
||||
from .panel_layout import get_panel_order, should_open_by_default
|
||||
from ..core.translations import t
|
||||
from ..core.common import get_active_armature, get_all_meshes
|
||||
from ..functions.eye_tracking import (
|
||||
@@ -26,28 +28,37 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel):
|
||||
bl_region_type: str = 'UI'
|
||||
bl_category: str = CATEGORY_NAME
|
||||
bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
|
||||
bl_order: int = 6
|
||||
bl_options: Set[str] = {'DEFAULT_CLOSED'}
|
||||
bl_order: int = get_panel_order('eye_tracking')
|
||||
bl_options: Set[str] = set() if not should_open_by_default('EYE_TRACKING') else {'DEFAULT_CLOSED'}
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
"""Draw the eye tracking panel interface"""
|
||||
layout: UILayout = self.layout
|
||||
toolkit = context.scene.avatar_toolkit
|
||||
|
||||
# SDK Version Selection Box
|
||||
sdk_box: UILayout = layout.box()
|
||||
col: UILayout = sdk_box.column(align=True)
|
||||
col.label(text=t("EyeTracking.sdk_version"), icon='PRESET')
|
||||
col.separator(factor=0.5)
|
||||
# SDK Version Selection
|
||||
col = draw_section_header(layout, t("EyeTracking.sdk_version"), icon='PRESET')
|
||||
row: UILayout = col.row(align=True)
|
||||
row.prop(toolkit, "eye_tracking_type", expand=True)
|
||||
|
||||
if toolkit.eye_tracking_type == 'SDK2':
|
||||
# Mode Selection Box
|
||||
mode_box: UILayout = layout.box()
|
||||
col: UILayout = mode_box.column(align=True)
|
||||
col.label(text=t("EyeTracking.setup"), icon='TOOL_SETTINGS')
|
||||
col.separator(factor=0.5)
|
||||
# SDK2 Warning
|
||||
warning_box: UILayout = layout.box()
|
||||
col: UILayout = warning_box.column(align=True)
|
||||
col.alert = True
|
||||
col.label(text=t("EyeTracking.sdk2_warning"), icon='ERROR')
|
||||
col.separator(factor=UIStyle.SUBSECTION_SEPARATOR_FACTOR)
|
||||
|
||||
warning_text = "\n".join([
|
||||
t("EyeTracking.sdk2_warning_detail1"),
|
||||
t("EyeTracking.sdk2_warning_detail2"),
|
||||
t("EyeTracking.sdk2_warning_detail3"),
|
||||
t("EyeTracking.sdk2_warning_detail4")
|
||||
])
|
||||
wrap_text_label(col, warning_text, max_length=45)
|
||||
|
||||
# Mode Selection
|
||||
col = draw_section_header(layout, t("EyeTracking.setup"), icon='TOOL_SETTINGS')
|
||||
col.prop(toolkit, "eye_mode", expand=True)
|
||||
|
||||
if toolkit.eye_mode == 'CREATION':
|
||||
@@ -62,11 +73,9 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel):
|
||||
"""Draw the AV3 eye tracking setup interface"""
|
||||
toolkit = context.scene.avatar_toolkit
|
||||
|
||||
# Bone Setup Box
|
||||
bone_box: UILayout = layout.box()
|
||||
col: UILayout = bone_box.column(align=True)
|
||||
col.label(text=t("EyeTracking.bone_setup"), icon='BONE_DATA')
|
||||
col.separator(factor=0.5)
|
||||
# Bone Setup
|
||||
col = draw_section_header(layout, t("EyeTracking.bone_setup"), icon='BONE_DATA')
|
||||
|
||||
|
||||
armature = get_active_armature(context)
|
||||
if armature:
|
||||
@@ -76,21 +85,16 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel):
|
||||
else:
|
||||
col.label(text=t("EyeTracking.no_armature"), icon='ERROR')
|
||||
|
||||
# Create Button
|
||||
row: UILayout = layout.row(align=True)
|
||||
row.scale_y = 1.5
|
||||
row.scale_y = UIStyle.PRIMARY_BUTTON_SCALE
|
||||
row.operator(CreateEyesAV3Button.bl_idname, icon='PLAY')
|
||||
|
||||
def draw_creation_mode(self, context: Context, layout: UILayout) -> None:
|
||||
"""Draw the eye tracking creation mode interface"""
|
||||
toolkit = context.scene.avatar_toolkit
|
||||
|
||||
# Bone Setup Box
|
||||
bone_box: UILayout = layout.box()
|
||||
col: UILayout = bone_box.column(align=True)
|
||||
col.label(text=t("EyeTracking.bone_setup"), icon='BONE_DATA')
|
||||
col.separator(factor=0.5)
|
||||
|
||||
# Bone Setup
|
||||
col = draw_section_header(layout, t("EyeTracking.bone_setup"), icon='BONE_DATA')
|
||||
armature = get_active_armature(context)
|
||||
if armature:
|
||||
col.prop_search(toolkit, "head", armature.data, "bones", text=t("EyeTracking.head_bone"))
|
||||
@@ -99,19 +103,12 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel):
|
||||
else:
|
||||
col.label(text=t("EyeTracking.no_armature"), icon='ERROR')
|
||||
|
||||
# Mesh Setup Box
|
||||
mesh_box: UILayout = layout.box()
|
||||
col: UILayout = mesh_box.column(align=True)
|
||||
col.label(text=t("EyeTracking.mesh_setup"), icon='MESH_DATA')
|
||||
col.separator(factor=0.5)
|
||||
# Mesh Setup
|
||||
col = draw_section_header(layout, t("EyeTracking.mesh_setup"), icon='MESH_DATA')
|
||||
col.prop_search(toolkit, "mesh_name_eye", bpy.data, "objects", text="")
|
||||
|
||||
# Shape Key Setup Box
|
||||
shape_box: UILayout = layout.box()
|
||||
col: UILayout = shape_box.column(align=True)
|
||||
col.label(text=t("EyeTracking.shapekey_setup"), icon='SHAPEKEY_DATA')
|
||||
col.separator(factor=0.5)
|
||||
|
||||
# Shape Key Setup
|
||||
col = draw_section_header(layout, t("EyeTracking.shapekey_setup"), icon='SHAPEKEY_DATA')
|
||||
mesh = bpy.data.objects.get(toolkit.mesh_name_eye)
|
||||
if mesh and mesh.data.shape_keys:
|
||||
col.prop_search(toolkit, "wink_left", mesh.data.shape_keys, "key_blocks", text=t("EyeTracking.wink_left"))
|
||||
@@ -121,19 +118,15 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel):
|
||||
else:
|
||||
col.label(text=t("EyeTracking.no_shapekeys"), icon='ERROR')
|
||||
|
||||
# Options Box
|
||||
options_box: UILayout = layout.box()
|
||||
col: UILayout = options_box.column(align=True)
|
||||
col.label(text=t("EyeTracking.options"), icon='SETTINGS')
|
||||
col.separator(factor=0.5)
|
||||
# Options
|
||||
col = draw_section_header(layout, t("EyeTracking.options"), icon='SETTINGS')
|
||||
col.prop(toolkit, "disable_eye_blinking")
|
||||
col.prop(toolkit, "disable_eye_movement")
|
||||
if not toolkit.disable_eye_movement:
|
||||
col.prop(toolkit, "eye_distance")
|
||||
|
||||
# Create Button
|
||||
row: UILayout = layout.row(align=True)
|
||||
row.scale_y = 1.5
|
||||
row.scale_y = UIStyle.PRIMARY_BUTTON_SCALE
|
||||
row.operator(CreateEyesSDK2Button.bl_idname, icon='PLAY')
|
||||
|
||||
def draw_testing_mode(self, context: Context, layout: UILayout) -> None:
|
||||
@@ -141,37 +134,25 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel):
|
||||
toolkit = context.scene.avatar_toolkit
|
||||
|
||||
if context.mode != 'POSE':
|
||||
# Testing Start Box
|
||||
test_box: UILayout = layout.box()
|
||||
col: UILayout = test_box.column(align=True)
|
||||
col.label(text=t("EyeTracking.testing"), icon='PLAY')
|
||||
col.separator(factor=0.5)
|
||||
# Testing Start
|
||||
col = draw_section_header(layout, t("EyeTracking.testing"), icon='PLAY')
|
||||
row: UILayout = col.row(align=True)
|
||||
row.scale_y = 1.5
|
||||
row.scale_y = UIStyle.PRIMARY_BUTTON_SCALE
|
||||
row.operator(StartTestingButton.bl_idname, icon='PLAY')
|
||||
else:
|
||||
# Eye Rotation Box
|
||||
rotation_box: UILayout = layout.box()
|
||||
col: UILayout = rotation_box.column(align=True)
|
||||
col.label(text=t("EyeTracking.rotation_controls"), icon='DRIVER_ROTATIONAL_DIFFERENCE')
|
||||
col.separator(factor=0.5)
|
||||
# Eye Rotation
|
||||
col = draw_section_header(layout, t("EyeTracking.rotation_controls"), icon='DRIVER_ROTATIONAL_DIFFERENCE')
|
||||
col.prop(toolkit, "eye_rotation_x", text=t("EyeTracking.rotation.x"))
|
||||
col.prop(toolkit, "eye_rotation_y", text=t("EyeTracking.rotation.y"))
|
||||
col.operator(ResetRotationButton.bl_idname, icon='LOOP_BACK')
|
||||
|
||||
# Eye Adjustment Box
|
||||
adjust_box: UILayout = layout.box()
|
||||
col: UILayout = adjust_box.column(align=True)
|
||||
col.label(text=t("EyeTracking.adjustments"), icon='MODIFIER')
|
||||
col.separator(factor=0.5)
|
||||
# Eye Adjustment
|
||||
col = draw_section_header(layout, t("EyeTracking.adjustments"), icon='MODIFIER')
|
||||
col.prop(toolkit, "eye_distance")
|
||||
col.operator(AdjustEyesButton.bl_idname, icon='CON_TRACKTO')
|
||||
|
||||
# Blinking Test Box
|
||||
blink_box: UILayout = layout.box()
|
||||
col: UILayout = blink_box.column(align=True)
|
||||
col.label(text=t("EyeTracking.blink_testing"), icon='HIDE_OFF')
|
||||
col.separator(factor=0.5)
|
||||
# Blinking Test
|
||||
col = draw_section_header(layout, t("EyeTracking.blink_testing"), icon='HIDE_OFF')
|
||||
row: UILayout = col.row(align=True)
|
||||
row.prop(toolkit, "eye_blink_shape")
|
||||
row.operator(TestBlinking.bl_idname, icon='RESTRICT_VIEW_OFF')
|
||||
@@ -182,7 +163,7 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel):
|
||||
|
||||
# Stop Testing Button
|
||||
row: UILayout = layout.row(align=True)
|
||||
row.scale_y = 1.5
|
||||
row.scale_y = UIStyle.PRIMARY_BUTTON_SCALE
|
||||
row.operator(StopTestingButton.bl_idname, icon='PAUSE')
|
||||
|
||||
# Reset Button
|
||||
|
||||
+9
-7
@@ -1,6 +1,7 @@
|
||||
import bpy
|
||||
from typing import Optional, Set
|
||||
from bpy.types import Panel, Context, UILayout
|
||||
from .ui_utils import UIStyle, wrap_text_label
|
||||
from ..core.translations import t
|
||||
|
||||
CATEGORY_NAME: str = "Avatar Toolkit"
|
||||
@@ -16,13 +17,14 @@ def draw_title(self: Panel) -> None:
|
||||
row.scale_y: float = 1.2
|
||||
row.label(text=t("AvatarToolkit.label"), icon='ARMATURE_DATA')
|
||||
|
||||
# Description as a flowing paragraph
|
||||
desc_col: UILayout = col.column()
|
||||
desc_col.scale_y: float = 0.6
|
||||
desc_col.label(text=t("AvatarToolkit.desc1"))
|
||||
desc_col.label(text=t("AvatarToolkit.desc2"))
|
||||
desc_col.label(text=t("AvatarToolkit.desc3"))
|
||||
col.separator()
|
||||
# Description
|
||||
col.separator(factor=UIStyle.SECTION_SEPARATOR_FACTOR)
|
||||
description = " ".join([
|
||||
t("AvatarToolkit.desc1"),
|
||||
t("AvatarToolkit.desc2"),
|
||||
t("AvatarToolkit.desc3")
|
||||
])
|
||||
wrap_text_label(col, description, max_length=50)
|
||||
|
||||
class AvatarToolKit_PT_AvatarToolkitPanel(Panel):
|
||||
"""Main panel for Avatar Toolkit containing general information and settings"""
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# MMD Tools disabled for the time being unto it can be fixed.
|
||||
|
||||
# import bpy
|
||||
# from typing import Set
|
||||
# from bpy.types import Panel, Context, UILayout
|
||||
# from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
|
||||
# from ..core.translations import t
|
||||
|
||||
# class AvatarToolKit_PT_MMDPanel(Panel):
|
||||
# """Panel containing MMD bone standardization and cleanup tools"""
|
||||
# bl_label = t("MMD.label")
|
||||
# bl_idname = "OBJECT_PT_avatar_toolkit_mmd"
|
||||
# bl_space_type = 'VIEW_3D'
|
||||
# bl_region_type = 'UI'
|
||||
# bl_category = CATEGORY_NAME
|
||||
# bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
|
||||
# bl_order = 3
|
||||
# bl_options = {'DEFAULT_CLOSED'}
|
||||
|
||||
# def draw(self, context: Context) -> None:
|
||||
# layout: UILayout = self.layout
|
||||
# toolkit = context.scene.avatar_toolkit
|
||||
|
||||
# Bone Settings Box
|
||||
# bone_box: UILayout = layout.box()
|
||||
# col: UILayout = bone_box.column(align=True)
|
||||
# col.label(text=t("MMD.bone_settings"), icon='BONE_DATA')
|
||||
# col.separator(factor=0.5)
|
||||
# col.prop(toolkit, "keep_twist_bones")
|
||||
# col.prop(toolkit, "keep_upper_chest")
|
||||
# col.operator("avatar_toolkit.standardize_mmd", icon='BONE_DATA')
|
||||
|
||||
# Mesh Tools Box
|
||||
# mesh_box: UILayout = layout.box()
|
||||
# col = mesh_box.column(align=True)
|
||||
# col.label(text=t("MMD.mesh_tools"), icon='MESH_DATA')
|
||||
# col.separator(factor=0.5)
|
||||
# row: UILayout = col.row(align=True)
|
||||
# row.operator("avatar_toolkit.fix_meshes", icon='MODIFIER')
|
||||
# row.operator("avatar_toolkit.validate_meshes", icon='CHECKMARK')
|
||||
|
||||
# Cleanup Box
|
||||
# cleanup_box: UILayout = layout.box()
|
||||
# col = cleanup_box.column(align=True)
|
||||
# col.label(text=t("MMD.cleanup"), icon='BRUSH_DATA')
|
||||
# col.separator(factor=0.5)
|
||||
# col.operator("avatar_toolkit.cleanup_mmd", icon='SHADERFX')
|
||||
# col.operator("avatar_toolkit.convert_mmd_morphs", icon='SHAPEKEY_DATA')
|
||||
# col.operator("avatar_toolkit.reparent_meshes", icon='OUTLINER_OB_ARMATURE')
|
||||
+19
-30
@@ -2,7 +2,12 @@ import bpy
|
||||
from typing import Set
|
||||
from bpy.types import Panel, Context, UILayout, Operator
|
||||
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
|
||||
from .ui_utils import UIStyle, draw_section_header, draw_operator_row
|
||||
from .panel_layout import get_panel_order, should_open_by_default
|
||||
from ..core.translations import t
|
||||
from ..functions.optimization.materials_tools import AvatarToolkit_OT_CombineMaterials
|
||||
from ..functions.optimization.remove_doubles import AvatarToolkit_OT_RemoveDoubles
|
||||
from ..functions.optimization.mesh_tools import AvatarToolkit_OT_JoinAllMeshes, AvatarToolkit_OT_JoinSelectedMeshes
|
||||
|
||||
class AvatarToolKit_PT_OptimizationPanel(Panel):
|
||||
"""Panel containing mesh and material optimization tools for avatar optimization"""
|
||||
@@ -12,40 +17,24 @@ class AvatarToolKit_PT_OptimizationPanel(Panel):
|
||||
bl_region_type: str = 'UI'
|
||||
bl_category: str = CATEGORY_NAME
|
||||
bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
|
||||
bl_order: int = 1
|
||||
bl_options = {'DEFAULT_CLOSED'}
|
||||
bl_order: int = get_panel_order('optimization')
|
||||
bl_options = set() if not should_open_by_default('OPTIMIZATION') else {'DEFAULT_CLOSED'}
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
"""Draws the optimization panel interface with material, mesh cleanup and join mesh tools"""
|
||||
layout: UILayout = self.layout
|
||||
|
||||
# Materials Box
|
||||
materials_box: UILayout = layout.box()
|
||||
col: UILayout = materials_box.column(align=True)
|
||||
col.label(text=t("Optimization.materials_title"), icon='MATERIAL')
|
||||
col.separator(factor=0.5)
|
||||
# Materials section
|
||||
col = draw_section_header(layout, t("Optimization.materials_title"), icon='MATERIAL')
|
||||
col.operator(AvatarToolkit_OT_CombineMaterials.bl_idname, icon='MATERIAL')
|
||||
|
||||
# Material Operations
|
||||
col.operator("avatar_toolkit.combine_materials", icon='MATERIAL')
|
||||
# Mesh Cleanup section
|
||||
col = draw_section_header(layout, t("Optimization.cleanup_title"), icon='MESH_DATA')
|
||||
col.operator(AvatarToolkit_OT_RemoveDoubles.bl_idname, icon='MESH_DATA')
|
||||
|
||||
# Mesh Cleanup Box
|
||||
cleanup_box: UILayout = layout.box()
|
||||
col: UILayout = cleanup_box.column(align=True)
|
||||
col.label(text=t("Optimization.cleanup_title"), icon='MESH_DATA')
|
||||
col.separator(factor=0.5)
|
||||
|
||||
# Remove Doubles Row
|
||||
row: UILayout = col.row(align=True)
|
||||
row.operator("avatar_toolkit.remove_doubles", icon='MESH_DATA')
|
||||
row.operator("avatar_toolkit.remove_doubles_advanced", icon='PREFERENCES')
|
||||
|
||||
# Join Meshes Box
|
||||
join_box: UILayout = layout.box()
|
||||
col: UILayout = join_box.column(align=True)
|
||||
col.label(text=t("Optimization.join_meshes_title"), icon='OBJECT_DATA')
|
||||
col.separator(factor=0.5)
|
||||
|
||||
# Join Meshes Row
|
||||
row: UILayout = col.row(align=True)
|
||||
row.operator("avatar_toolkit.join_all_meshes", icon='OBJECT_DATA')
|
||||
row.operator("avatar_toolkit.join_selected_meshes", icon='RESTRICT_SELECT_OFF')
|
||||
# Join Meshes section
|
||||
col = draw_section_header(layout, t("Optimization.join_meshes_title"), icon='OBJECT_DATA')
|
||||
draw_operator_row(col, [
|
||||
(AvatarToolkit_OT_JoinAllMeshes.bl_idname, t("Optimization.join_all_meshes"), 'OBJECT_DATA'),
|
||||
(AvatarToolkit_OT_JoinSelectedMeshes.bl_idname, t("Optimization.join_selected_meshes"), 'RESTRICT_SELECT_OFF')
|
||||
])
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Panel ordering and organization guide for Avatar Toolkit UI
|
||||
This module defines the standard panel order and grouping for the Avatar Toolkit.
|
||||
"""
|
||||
|
||||
# Main Panel
|
||||
MAIN_PANEL_ORDER = -1 # Always first (parent panel)
|
||||
QUICK_ACCESS_ORDER = 0
|
||||
OPTIMIZATION_ORDER = 1
|
||||
TOOLS_ORDER = 2
|
||||
CUSTOM_TOOLS_ORDER = 3
|
||||
CUSTOM_AVATAR_ORDER = 4
|
||||
TRANSLATION_ORDER = 5
|
||||
VISEMES_ORDER = 6
|
||||
EYE_TRACKING_ORDER = 7
|
||||
TEXTURE_ATLAS_ORDER = 8
|
||||
VRM_UNITY_ORDER = 9
|
||||
MMD_ORDER = 10
|
||||
SETTINGS_ORDER = 11
|
||||
|
||||
# Panel open/closed by default
|
||||
PANELS_OPEN_BY_DEFAULT = {
|
||||
'QUICK_ACCESS': False,
|
||||
'OPTIMIZATION': True,
|
||||
'TOOLS': True,
|
||||
'CUSTOM_TOOLS': True,
|
||||
'CUSTOM_AVATAR': True,
|
||||
'VISEMES': True,
|
||||
'EYE_TRACKING': True,
|
||||
'TEXTURE_ATLAS': True,
|
||||
'VRM_UNITY': True,
|
||||
'MMD': True,
|
||||
'SETTINGS': True,
|
||||
'TRANSLATION': True,
|
||||
}
|
||||
|
||||
def get_panel_order(panel_name: str) -> int:
|
||||
"""Get the recommended bl_order value for a panel"""
|
||||
order_map = {
|
||||
'quick_access': QUICK_ACCESS_ORDER,
|
||||
'optimization': OPTIMIZATION_ORDER,
|
||||
'tools': TOOLS_ORDER,
|
||||
'custom_tools': CUSTOM_TOOLS_ORDER,
|
||||
'custom_avatar': CUSTOM_AVATAR_ORDER,
|
||||
'translation': TRANSLATION_ORDER,
|
||||
'visemes': VISEMES_ORDER,
|
||||
'eye_tracking': EYE_TRACKING_ORDER,
|
||||
'texture_atlas': TEXTURE_ATLAS_ORDER,
|
||||
'vrm_unity': VRM_UNITY_ORDER,
|
||||
'mmd': MMD_ORDER,
|
||||
'settings': SETTINGS_ORDER,
|
||||
}
|
||||
return order_map.get(panel_name.lower(), 99)
|
||||
|
||||
def should_open_by_default(panel_name: str) -> bool:
|
||||
"""Check if a panel should be open by default"""
|
||||
return PANELS_OPEN_BY_DEFAULT.get(panel_name.upper(), True)
|
||||
+192
-54
@@ -10,20 +10,36 @@ from bpy.types import (
|
||||
Object
|
||||
)
|
||||
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
|
||||
from .ui_utils import UIStyle, draw_section_header, draw_operator_row
|
||||
from .panel_layout import get_panel_order, should_open_by_default
|
||||
from ..core.translations import t
|
||||
from ..core.common import (
|
||||
get_active_armature,
|
||||
clear_default_objects,
|
||||
validate_armature,
|
||||
get_armature_list,
|
||||
get_armature_stats
|
||||
)
|
||||
|
||||
# Module-level cache for UI performance (avoids Blender scene property write restrictions)
|
||||
_validation_cache = {}
|
||||
_stats_cache = {}
|
||||
|
||||
def clear_armature_caches():
|
||||
"""Clear all armature-related caches - called when armature changes"""
|
||||
global _validation_cache, _stats_cache
|
||||
_validation_cache.clear()
|
||||
_stats_cache.clear()
|
||||
|
||||
from ..functions.pose_mode import (
|
||||
AvatarToolkit_OT_StartPoseMode,
|
||||
AvatarToolkit_OT_StopPoseMode,
|
||||
AvatarToolkit_OT_ApplyPoseAsShapekey,
|
||||
AvatarToolkit_OT_ApplyPoseAsRest
|
||||
)
|
||||
from ..core.armature_validation import validate_armature, AvatarToolkit_OT_ValidateTPose, is_pmx_model
|
||||
from ..core.importers.importer import AvatarToolKit_OT_Import
|
||||
from ..core.resonite_utils import AvatarToolKit_OT_ExportResonite
|
||||
from ..functions.tools.standardize_armature import AvatarToolkit_OT_StandardizeArmature
|
||||
|
||||
class AvatarToolKit_OT_ExportFBX(Operator):
|
||||
"""Export selected objects as FBX"""
|
||||
@@ -41,8 +57,8 @@ class AvatarToolKit_MT_ExportMenu(Menu):
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
layout: UILayout = self.layout
|
||||
layout.operator("avatar_toolkit.export_fbx", text=t("QuickAccess.export_fbx"))
|
||||
layout.operator("avatar_toolkit.export_resonite", text=t("QuickAccess.export_resonite"))
|
||||
layout.operator(AvatarToolKit_OT_ExportFBX.bl_idname, text=t("QuickAccess.export_fbx"))
|
||||
layout.operator(AvatarToolKit_OT_ExportResonite.bl_idname, text=t("QuickAccess.export_resonite"))
|
||||
|
||||
class AvatarToolKit_OT_ExportMenu(Operator):
|
||||
"""Open the export menu"""
|
||||
@@ -65,80 +81,202 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel):
|
||||
bl_region_type: str = 'UI'
|
||||
bl_category: str = CATEGORY_NAME
|
||||
bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
|
||||
bl_order: int = 0
|
||||
bl_order: int = get_panel_order('quick_access')
|
||||
bl_options = {'DEFAULT_CLOSED'} if should_open_by_default('QUICK_ACCESS') else set()
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
"""Draw the panel layout"""
|
||||
layout: UILayout = self.layout
|
||||
|
||||
# Armature Selection Box
|
||||
armature_box: UILayout = layout.box()
|
||||
col: UILayout = armature_box.column(align=True)
|
||||
col.label(text=t("QuickAccess.select_armature"), icon='ARMATURE_DATA')
|
||||
col.separator(factor=0.5)
|
||||
props = context.scene.avatar_toolkit
|
||||
|
||||
# Armature Selection
|
||||
col = draw_section_header(layout, t("QuickAccess.select_armature"), icon='ARMATURE_DATA')
|
||||
col.prop(context.scene.avatar_toolkit, "active_armature", text="")
|
||||
|
||||
# Armature Validation
|
||||
# Get active armature
|
||||
active_armature: Optional[Object] = get_active_armature(context)
|
||||
if active_armature:
|
||||
is_valid: bool
|
||||
messages: List[str]
|
||||
is_valid, messages = validate_armature(active_armature)
|
||||
# Validation Section
|
||||
col = draw_section_header(layout, t("Validation.label", "Armature Validation"), icon='CHECKMARK')
|
||||
|
||||
# Create info box for all validation information
|
||||
info_box: UILayout = col.box()
|
||||
# Main validate button with prominent styling
|
||||
validate_row = col.row(align=True)
|
||||
validate_row.scale_y = UIStyle.PRIMARY_BUTTON_SCALE
|
||||
validate_row.operator("avatar_toolkit.validate_armature_manual",
|
||||
text=t("Validation.validate_now", "Validate Armature Now"),
|
||||
icon='CHECKMARK')
|
||||
|
||||
if is_valid:
|
||||
row: UILayout = info_box.row()
|
||||
split: UILayout = row.split(factor=0.6)
|
||||
split.label(text=t("QuickAccess.valid_armature"), icon='CHECKMARK')
|
||||
stats: Dict[str, int] = get_armature_stats(active_armature)
|
||||
# Validation mode selector
|
||||
col.prop(props, "validation_mode", text=t("Settings.validation_mode", "Mode"))
|
||||
|
||||
# Show validation results if flag is set
|
||||
if props.show_validation_results:
|
||||
# Cache validation results
|
||||
cache_key = f"validation_{active_armature.name}_{active_armature.data.name}_{len(active_armature.data.bones)}"
|
||||
|
||||
if cache_key not in _validation_cache:
|
||||
_validation_cache[cache_key] = validate_armature(active_armature, detailed_messages=True)
|
||||
|
||||
is_valid, messages, is_acceptable, hierarchy_messages, scale_messages, non_standard_messages = _validation_cache[cache_key]
|
||||
|
||||
# Check if this is a PMX model
|
||||
pmx_detected = is_pmx_model(active_armature)
|
||||
|
||||
results_box = col.box()
|
||||
row = results_box.row()
|
||||
row.prop(props, "show_validation_results", text=t("Validation.results", "Validation Results"),
|
||||
icon='TRIA_DOWN' if props.show_validation_results else 'TRIA_RIGHT', emboss=False)
|
||||
|
||||
# PMX Model Notice
|
||||
if pmx_detected:
|
||||
pmx_box = results_box.box()
|
||||
pmx_box.label(text=t("Armature.validation.pmx_model_detected"), icon='INFO')
|
||||
|
||||
validation_mode = context.scene.avatar_toolkit.validation_mode
|
||||
if validation_mode == 'STRICT':
|
||||
pmx_box.label(text=t("Armature.validation.pmx_model_strict"))
|
||||
pmx_box.label(text=t("Armature.validation.pmx_model_standardize"))
|
||||
else:
|
||||
pmx_box.label(text=t("Armature.validation.pmx_model_basic"))
|
||||
|
||||
# Validation Results
|
||||
if not is_valid:
|
||||
# Display found bones
|
||||
if messages and len(messages) > 0:
|
||||
bones_section = results_box.box()
|
||||
row = bones_section.row()
|
||||
row.prop(props, "show_found_bones", text=t("Validation.section.found_bones"),
|
||||
icon='TRIA_DOWN' if props.show_found_bones else 'TRIA_RIGHT', emboss=False)
|
||||
if props.show_found_bones:
|
||||
for line in messages[0].split('\n'):
|
||||
bones_section.label(text=line)
|
||||
|
||||
# Status message
|
||||
status_box = results_box.box()
|
||||
row = status_box.row()
|
||||
row.alert = True
|
||||
row.label(text=t("Validation.status.failed"), icon='ERROR')
|
||||
|
||||
# Error explanation
|
||||
error_box = results_box.box()
|
||||
error_box.alert = True
|
||||
error_box.label(text=t("Validation.message.failed.line1"))
|
||||
error_box.label(text=t("Validation.message.failed.line2"))
|
||||
error_box.label(text=t("Validation.message.failed.line3"))
|
||||
|
||||
# Non-Standard Bones section
|
||||
if non_standard_messages or pmx_detected:
|
||||
ns_section = results_box.box()
|
||||
row = ns_section.row()
|
||||
row.alert = True
|
||||
row.prop(props, "show_non_standard", text=t("Validation.section.non_standard"),
|
||||
icon='TRIA_DOWN' if props.show_non_standard else 'TRIA_RIGHT', emboss=False)
|
||||
if props.show_non_standard:
|
||||
if non_standard_messages and len(non_standard_messages) > 0:
|
||||
for message in non_standard_messages:
|
||||
for line in message.split('\n'):
|
||||
sub_row = ns_section.row()
|
||||
sub_row.alert = True
|
||||
sub_row.label(text=line)
|
||||
elif pmx_detected:
|
||||
ns_section.alert = True
|
||||
ns_section.label(text=t("Armature.validation.pmx_model_basic"))
|
||||
ns_section.label(text=t("Armature.validation.pmx_model_strict"))
|
||||
ns_section.label(text=t("Armature.validation.pmx_model_standardize"))
|
||||
else:
|
||||
ns_section.label(text=t("Validation.no_non_standard_issues"))
|
||||
|
||||
# Hierarchy Issues section
|
||||
if hierarchy_messages:
|
||||
hier_section = results_box.box()
|
||||
row = hier_section.row()
|
||||
row.alert = True
|
||||
row.prop(props, "show_hierarchy", text=t("Validation.section.hierarchy"),
|
||||
icon='TRIA_DOWN' if props.show_hierarchy else 'TRIA_RIGHT', emboss=False)
|
||||
if props.show_hierarchy:
|
||||
for message in hierarchy_messages:
|
||||
sub_row = hier_section.row()
|
||||
sub_row.alert = True
|
||||
sub_row.label(text=message)
|
||||
|
||||
# Scale Issues section
|
||||
if scale_messages:
|
||||
scale_section = results_box.box()
|
||||
row = scale_section.row()
|
||||
row.alert = True
|
||||
row.prop(props, "show_scale_issues", text=t("Validation.section.scale_issues"),
|
||||
icon='TRIA_DOWN' if props.show_scale_issues else 'TRIA_RIGHT', emboss=False)
|
||||
if props.show_scale_issues:
|
||||
for scale_msg in scale_messages:
|
||||
sub_row = scale_section.row()
|
||||
sub_row.alert = True
|
||||
sub_row.label(text=scale_msg)
|
||||
|
||||
elif is_valid and not is_acceptable:
|
||||
# Valid armature - show stats
|
||||
stats_cache_key = f"stats_{active_armature.name}_{active_armature.data.name}_{len(active_armature.data.bones)}"
|
||||
|
||||
if stats_cache_key not in _stats_cache:
|
||||
_stats_cache[stats_cache_key] = get_armature_stats(active_armature)
|
||||
|
||||
stats = _stats_cache[stats_cache_key]
|
||||
|
||||
status_box = results_box.box()
|
||||
row = status_box.row()
|
||||
row.label(text=t("QuickAccess.valid_armature"), icon='CHECKMARK')
|
||||
split = row.split(factor=0.4)
|
||||
split.label(text=t("QuickAccess.bones_count", count=stats['bone_count']))
|
||||
|
||||
if stats['has_pose']:
|
||||
info_box.label(text=t("QuickAccess.pose_bones_available"), icon='POSE_HLT')
|
||||
else:
|
||||
# Display validation failure messages
|
||||
for message in messages:
|
||||
info_box.label(text=message, icon='ERROR')
|
||||
results_box.label(text=t("QuickAccess.pose_bones_available"), icon='POSE_HLT')
|
||||
|
||||
# Validation Mode Warnings - always show in info box
|
||||
validation_mode = context.scene.avatar_toolkit.validation_mode
|
||||
if validation_mode == 'BASIC':
|
||||
warning_row = info_box.box()
|
||||
warning_row.alert = True
|
||||
warning_row.label(text=t("QuickAccess.validation_basic_warning"), icon='INFO')
|
||||
warning_row.label(text=t("QuickAccess.validation_basic_details"))
|
||||
elif validation_mode == 'NONE':
|
||||
warning_row = info_box.box()
|
||||
warning_row.alert = True
|
||||
warning_row.label(text=t("QuickAccess.validation_none_warning"), icon='ERROR')
|
||||
warning_row.label(text=t("QuickAccess.validation_none_details"))
|
||||
elif is_valid and is_acceptable:
|
||||
# Acceptable standard
|
||||
status_box = results_box.box()
|
||||
status_box.label(text=t("Armature.validation.acceptable_standard.success"), icon='INFO')
|
||||
status_box.label(text=t("Armature.validation.acceptable_standard.note"))
|
||||
status_box.label(text=t("Armature.validation.acceptable_standard.option"))
|
||||
|
||||
# Add standardize button
|
||||
standardize_box = results_box.box()
|
||||
standardize_box.operator(AvatarToolkit_OT_StandardizeArmature.bl_idname,
|
||||
text=t("QuickAccess.standardize_armature"),
|
||||
icon='MODIFIER')
|
||||
|
||||
# T-Pose Validation
|
||||
col = draw_section_header(layout, t("Validation.tpose.label"), icon='ARMATURE_DATA')
|
||||
col.operator(AvatarToolkit_OT_ValidateTPose.bl_idname, text=t("Validation.tpose.validate_now"), icon='CHECKMARK')
|
||||
|
||||
if props.show_tpose_validation:
|
||||
validation_result_col = col.column(align=True)
|
||||
if props.tpose_validation_result:
|
||||
validation_result_col.label(text=t("Validation.tpose.valid"), icon='CHECKMARK')
|
||||
else:
|
||||
validation_result_col.alert = True
|
||||
validation_result_col.label(text=t("Validation.tpose.warning"), icon='ERROR')
|
||||
|
||||
for msg in props.tpose_validation_messages:
|
||||
validation_result_col.label(text=msg.name)
|
||||
|
||||
# Pose Mode Controls
|
||||
pose_box: UILayout = layout.box()
|
||||
col = pose_box.column(align=True)
|
||||
col.label(text=t("QuickAccess.pose_controls"), icon='ARMATURE_DATA')
|
||||
col.separator(factor=0.5)
|
||||
col = draw_section_header(layout, t("QuickAccess.pose_controls"), icon='ARMATURE_DATA')
|
||||
|
||||
if context.mode == "POSE":
|
||||
col.operator(AvatarToolkit_OT_StopPoseMode.bl_idname, icon='POSE_HLT')
|
||||
col.separator(factor=0.5)
|
||||
col.operator(AvatarToolkit_OT_ApplyPoseAsRest.bl_idname, icon='MOD_ARMATURE')
|
||||
col.operator(AvatarToolkit_OT_ApplyPoseAsShapekey.bl_idname, icon='MOD_ARMATURE')
|
||||
col.separator(factor=UIStyle.SUBSECTION_SEPARATOR_FACTOR)
|
||||
draw_operator_row(col, [
|
||||
(AvatarToolkit_OT_ApplyPoseAsRest.bl_idname, t("QuickAccess.apply_pose_as_rest.label"), 'MOD_ARMATURE'),
|
||||
(AvatarToolkit_OT_ApplyPoseAsShapekey.bl_idname, t("QuickAccess.apply_pose_as_shapekey.label"), 'MOD_ARMATURE')
|
||||
])
|
||||
else:
|
||||
col.operator(AvatarToolkit_OT_StartPoseMode.bl_idname, icon='POSE_HLT')
|
||||
|
||||
# Import/Export Box
|
||||
import_box: UILayout = layout.box()
|
||||
col = import_box.column(align=True)
|
||||
col.label(text=t("QuickAccess.import_export"), icon='IMPORT')
|
||||
col.separator(factor=0.5)
|
||||
# Import/Export Section
|
||||
col = draw_section_header(layout, t("QuickAccess.import_export"), icon='IMPORT')
|
||||
|
||||
# Import/Export Buttons
|
||||
button_row: UILayout = col.row(align=True)
|
||||
button_row.scale_y = 1.5
|
||||
button_row.operator("avatar_toolkit.import", text=t("QuickAccess.import"), icon='IMPORT')
|
||||
button_row.operator("avatar_toolkit.export", text=t("QuickAccess.export"), icon='EXPORT')
|
||||
draw_operator_row(col, [
|
||||
(AvatarToolKit_OT_Import.bl_idname, t("QuickAccess.import"), 'IMPORT'),
|
||||
(AvatarToolKit_OT_ExportMenu.bl_idname, t("QuickAccess.export"), 'EXPORT')
|
||||
], scale_y=UIStyle.PRIMARY_BUTTON_SCALE)
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Base classes for reusable search operators"""
|
||||
|
||||
from typing import Set, Callable, Optional
|
||||
from bpy.types import Operator, Context, Event, WindowManager
|
||||
|
||||
|
||||
class SearchOperatorBase(Operator):
|
||||
"""
|
||||
Reusable base class for search/selection operators.
|
||||
|
||||
This is an abstract base class - do not use directly.
|
||||
Subclass and implement your specific search operator instead.
|
||||
|
||||
Subclasses should:
|
||||
1. Define bl_idname, bl_label, bl_description
|
||||
2. Define search_property_name (name of EnumProperty)
|
||||
3. Define target_property_name (name of property to set on scene)
|
||||
4. Define get_items_func (function to get enum items)
|
||||
5. Optionally override get_enum_property() to customize the enum
|
||||
|
||||
This was created because search in ATK was all over the place and inconsistent, this way we have a standard way to do it.
|
||||
"""
|
||||
|
||||
# Mark this as abstract by setting a non-Blender-compatible idname
|
||||
bl_idname = "wm.search_operator_base" # Will be overridden in subclasses
|
||||
bl_label = "Search and Select"
|
||||
bl_options = {'REGISTER', 'INTERNAL'}
|
||||
|
||||
# These should be overridden in subclasses
|
||||
search_property_name: str = "search_enum"
|
||||
target_property_name: str = "target_property"
|
||||
|
||||
@staticmethod
|
||||
def get_items_func(scene, context) -> list:
|
||||
"""Override this to provide enum items. Return list of (id, name, description) tuples"""
|
||||
return []
|
||||
|
||||
def get_enum_property(self) -> None:
|
||||
"""
|
||||
Create the enum property dynamically. Override if you need custom behavior.
|
||||
This is called during class creation.
|
||||
"""
|
||||
import bpy
|
||||
setattr(
|
||||
type(self),
|
||||
self.search_property_name,
|
||||
bpy.props.EnumProperty(
|
||||
name="Search",
|
||||
description="Select item",
|
||||
items=self.get_items_func
|
||||
)
|
||||
)
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
"""Set the target property from the search selection"""
|
||||
search_value = getattr(self, self.search_property_name, None)
|
||||
if search_value:
|
||||
setattr(context.scene.avatar_toolkit, self.target_property_name, search_value)
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> Set[str]:
|
||||
"""Open search popup"""
|
||||
wm: WindowManager = context.window_manager
|
||||
wm.invoke_search_popup(self)
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class ArmatureSearchOperator(SearchOperatorBase):
|
||||
"""Specialized search operator for selecting armatures"""
|
||||
|
||||
bl_label = "Search Armatures"
|
||||
search_property_name: str = "search_armature_enum"
|
||||
|
||||
@staticmethod
|
||||
def get_items_func(scene, context) -> list:
|
||||
"""Get list of all armature objects in scene"""
|
||||
import bpy
|
||||
return [
|
||||
(obj.name, obj.name, "")
|
||||
for obj in bpy.data.objects
|
||||
if obj.type == 'ARMATURE'
|
||||
]
|
||||
|
||||
|
||||
class MeshSearchOperator(SearchOperatorBase):
|
||||
"""Specialized search operator for selecting meshes"""
|
||||
|
||||
bl_label = "Search Meshes"
|
||||
search_property_name: str = "search_mesh_enum"
|
||||
|
||||
@staticmethod
|
||||
def get_items_func(scene, context) -> list:
|
||||
"""Get list of all mesh objects without armature modifiers"""
|
||||
import bpy
|
||||
return [
|
||||
(obj.name, obj.name, "")
|
||||
for obj in bpy.data.objects
|
||||
if obj.type == 'MESH'
|
||||
and not any(mod.type == 'ARMATURE' for mod in obj.modifiers)
|
||||
]
|
||||
|
||||
|
||||
class BoneSearchOperator(SearchOperatorBase):
|
||||
"""Specialized search operator for selecting bones from active armature"""
|
||||
|
||||
bl_label = "Search Bones"
|
||||
search_property_name: str = "search_bone_enum"
|
||||
|
||||
@staticmethod
|
||||
def get_items_func(scene, context) -> list:
|
||||
"""Get list of all bones from active armature"""
|
||||
from ..core.common import get_active_armature
|
||||
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return []
|
||||
|
||||
return [
|
||||
(bone.name, bone.name, "")
|
||||
for bone in armature.data.bones
|
||||
]
|
||||
+33
-23
@@ -9,7 +9,10 @@ from bpy.types import (
|
||||
Event
|
||||
)
|
||||
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
|
||||
from .ui_utils import UIStyle, draw_section_header, wrap_text_label
|
||||
from .panel_layout import get_panel_order, should_open_by_default
|
||||
from ..core.translations import t, get_languages_list
|
||||
from ..core.armature_validation import AvatarToolkit_OT_HighlightProblemBones, AvatarToolkit_OT_ClearBoneHighlighting
|
||||
|
||||
class AvatarToolkit_OT_TranslationRestartPopup(Operator):
|
||||
"""Popup dialog shown after language change to inform about restart requirement"""
|
||||
@@ -25,8 +28,10 @@ class AvatarToolkit_OT_TranslationRestartPopup(Operator):
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
layout: UILayout = self.layout
|
||||
layout.label(text=t("Language.changed.success"))
|
||||
layout.label(text=t("Language.changed.restart"))
|
||||
col = layout.column(align=True)
|
||||
col.label(text=t("Language.changed.success"))
|
||||
col.separator(factor=UIStyle.SUBSECTION_SEPARATOR_FACTOR)
|
||||
wrap_text_label(col, t("Language.changed.restart"), max_length=50)
|
||||
|
||||
class AvatarToolKit_PT_SettingsPanel(Panel):
|
||||
"""Settings panel for Avatar Toolkit containing language preferences"""
|
||||
@@ -36,41 +41,46 @@ class AvatarToolKit_PT_SettingsPanel(Panel):
|
||||
bl_region_type: str = 'UI'
|
||||
bl_category: str = CATEGORY_NAME
|
||||
bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
|
||||
bl_order: int = 7
|
||||
bl_options = {'DEFAULT_CLOSED'}
|
||||
bl_order: int = get_panel_order('settings')
|
||||
bl_options = set() if not should_open_by_default('SETTINGS') else {'DEFAULT_CLOSED'}
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
"""Draw the settings panel layout with language selection"""
|
||||
layout: UILayout = self.layout
|
||||
props = context.scene.avatar_toolkit
|
||||
|
||||
# Language Settings
|
||||
lang_box: UILayout = layout.box()
|
||||
col: UILayout = lang_box.column(align=True)
|
||||
row: UILayout = col.row()
|
||||
row.scale_y = 1.2
|
||||
row.label(text=t("Settings.language"), icon='WORLD')
|
||||
col.separator()
|
||||
col.prop(context.scene.avatar_toolkit, "language", text="")
|
||||
col = draw_section_header(layout, t("Settings.language"), icon='WORLD')
|
||||
col.prop(props, "language", text="")
|
||||
|
||||
# Validation Settings
|
||||
val_box: UILayout = layout.box()
|
||||
col = val_box.column(align=True)
|
||||
row = col.row()
|
||||
row.scale_y = 1.2
|
||||
row.label(text=t("Settings.validation_mode"), icon='CHECKMARK')
|
||||
col.separator()
|
||||
col.prop(context.scene.avatar_toolkit, "validation_mode", text="")
|
||||
# Validation Settings with help text
|
||||
col = draw_section_header(layout, t("Settings.validation_mode"), icon='CHECKMARK')
|
||||
col.prop(props, "validation_mode", text="")
|
||||
# Help text for validation mode
|
||||
col.separator(factor=UIStyle.SUBSECTION_SEPARATOR_FACTOR)
|
||||
wrap_text_label(col, "Select how strictly to validate armature bone structure and naming conventions.", max_length=40)
|
||||
|
||||
# Bone Highlighting Settings
|
||||
col = draw_section_header(layout, t("Settings.bone_highlighting"), icon='BONE_DATA')
|
||||
col.prop(props, "highlight_problem_bones")
|
||||
if props.highlight_problem_bones:
|
||||
col.operator(AvatarToolkit_OT_HighlightProblemBones.bl_idname, icon='COLOR')
|
||||
else:
|
||||
col.operator(AvatarToolkit_OT_ClearBoneHighlighting.bl_idname, icon='X')
|
||||
|
||||
# Debug Settings
|
||||
debug_box = layout.box()
|
||||
col = debug_box.column()
|
||||
row = col.row(align=True)
|
||||
row.prop(context.scene.avatar_toolkit, "debug_expand",
|
||||
icon="TRIA_DOWN" if context.scene.avatar_toolkit.debug_expand
|
||||
row.prop(props, "debug_expand",
|
||||
icon="TRIA_DOWN" if props.debug_expand
|
||||
else "TRIA_RIGHT",
|
||||
icon_only=True, emboss=False)
|
||||
row.label(text=t("Settings.debug"), icon='CONSOLE')
|
||||
|
||||
if context.scene.avatar_toolkit.debug_expand:
|
||||
if props.debug_expand:
|
||||
col = debug_box.column(align=True)
|
||||
col.prop(context.scene.avatar_toolkit, "enable_logging")
|
||||
col.prop(props, "enable_logging")
|
||||
|
||||
if props.enable_logging:
|
||||
col.prop(props, "log_level")
|
||||
+92
-40
@@ -1,9 +1,28 @@
|
||||
import bpy
|
||||
from typing import Set
|
||||
from bpy.types import Panel, Context, UILayout, Operator
|
||||
from bpy.types import Panel, Context, UILayout, Operator, UIList
|
||||
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
|
||||
from .ui_utils import UIStyle, draw_section_header, draw_operator_row
|
||||
from .panel_layout import get_panel_order, should_open_by_default
|
||||
from ..core.translations import t
|
||||
|
||||
from ..core.resonite_utils import AvatarToolkit_OT_ConvertResonite
|
||||
from ..functions.tools.mesh_separation import AvatarToolKit_OT_SeparateByLooseParts, AvatarToolKit_OT_SeparateByMaterials
|
||||
from ..functions.tools.additional_tools import AvatarToolkit_OT_ApplyTransforms, AvatarToolkit_OT_CleanShapekeys
|
||||
from ..functions.tools.bone_tools import (
|
||||
AvatarToolKit_OT_CreateDigitigradeLegs,
|
||||
AvatarToolKit_OT_DeleteBoneConstraints,
|
||||
AvatarToolKit_OT_RemoveSelectedBones,
|
||||
AvatarToolKit_OT_RemoveZeroWeightBones,
|
||||
AvatarToolKit_OT_RemoveZeroWeightVertexGroups,
|
||||
AvatarToolKit_OT_FlipCurrentKeyFrames
|
||||
)
|
||||
from ..functions.tools.standardize_armature import AvatarToolkit_OT_StandardizeArmature
|
||||
from ..functions.tools.merge_tools import AvatarToolkit_OT_MergeToActive, AvatarToolkit_OT_MergeToParent, AvatarToolkit_OT_ConnectBones
|
||||
from ..functions.tools.rigify_converter import AvatarToolkit_OT_ConvertRigifyToUnity
|
||||
from ..functions.tools.general_mesh_tools import AvatarToolkit_OT_SelectShortestSeamPath, AvatarToolkit_OT_ExplodeMesh
|
||||
from ..functions.custom_tools.force_apply_modifier import AvatarToolkit_OT_ApplyModifierForShapkeyObj
|
||||
|
||||
class AvatarToolKit_PT_ToolsPanel(Panel):
|
||||
"""Panel containing various tools for avatar customization and optimization"""
|
||||
bl_label: str = t("Tools.label")
|
||||
@@ -12,58 +31,91 @@ class AvatarToolKit_PT_ToolsPanel(Panel):
|
||||
bl_region_type: str = 'UI'
|
||||
bl_category: str = CATEGORY_NAME
|
||||
bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
|
||||
bl_order: int = 2
|
||||
bl_options = {'DEFAULT_CLOSED'}
|
||||
bl_order: int = get_panel_order('tools')
|
||||
bl_options = set() if not should_open_by_default('TOOLS') else {'DEFAULT_CLOSED'}
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
"""Draw the tools panel interface"""
|
||||
layout: UILayout = self.layout
|
||||
toolkit = context.scene.avatar_toolkit
|
||||
|
||||
# General Tools
|
||||
tools_box: UILayout = layout.box()
|
||||
col: UILayout = tools_box.column(align=True)
|
||||
col.label(text=t("Tools.general_title"), icon='TOOL_SETTINGS')
|
||||
col.separator(factor=0.5)
|
||||
col.operator("avatar_toolkit.convert_resonite", text=t("Tools.convert_resonite"), icon='EXPORT')
|
||||
col = draw_section_header(layout, t("Tools.general_title"), icon='TOOL_SETTINGS')
|
||||
col.operator(AvatarToolkit_OT_ConvertResonite.bl_idname, text=t("Tools.convert_resonite"), icon='EXPORT')
|
||||
|
||||
# Separation Tools
|
||||
sep_box: UILayout = layout.box()
|
||||
col = sep_box.column(align=True)
|
||||
col.label(text=t("Tools.separate_title"), icon='MOD_EXPLODE')
|
||||
col.separator(factor=0.5)
|
||||
row: UILayout = col.row(align=True)
|
||||
row.operator("avatar_toolkit.separate_materials", text=t("Tools.separate_materials"), icon='MATERIAL')
|
||||
row.operator("avatar_toolkit.separate_loose", text=t("Tools.separate_loose"), icon='MESH_DATA')
|
||||
col = draw_section_header(layout, t("Tools.separate_title"), icon='MOD_EXPLODE')
|
||||
draw_operator_row(col, [
|
||||
(AvatarToolKit_OT_SeparateByMaterials.bl_idname, t("Tools.separate_materials"), 'MATERIAL'),
|
||||
(AvatarToolKit_OT_SeparateByLooseParts.bl_idname, t("Tools.separate_loose"), 'MESH_DATA')
|
||||
])
|
||||
|
||||
# Bone Tools
|
||||
bone_box: UILayout = layout.box()
|
||||
col = bone_box.column(align=True)
|
||||
col.label(text=t("Tools.bone_title"), icon='BONE_DATA')
|
||||
col.separator(factor=0.5)
|
||||
col.operator("avatar_toolkit.create_digitigrade", text=t("Tools.create_digitigrade"), icon='BONE_DATA')
|
||||
col = draw_section_header(layout, t("Tools.bone_title"), icon='BONE_DATA')
|
||||
col.operator(AvatarToolKit_OT_CreateDigitigradeLegs.bl_idname, text=t("Tools.create_digitigrade"), icon='BONE_DATA')
|
||||
col.operator(AvatarToolKit_OT_FlipCurrentKeyFrames.bl_idname, text=t("Tools.flip_pose_frames"), icon="ACTION")
|
||||
|
||||
# Mesh Tools
|
||||
col = draw_section_header(layout, t("Tools.mesh_title"), icon='MESH_DATA')
|
||||
col.operator(AvatarToolkit_OT_SelectShortestSeamPath.bl_idname, text=t("Tools.find_shortest_seam_path"), icon="MESH_DATA")
|
||||
col.operator(AvatarToolkit_OT_ApplyModifierForShapkeyObj.bl_idname, text=t("Tools.apply_modifier_on_shapekey_obj"), icon="SHAPEKEY_DATA")
|
||||
col.operator(AvatarToolkit_OT_ExplodeMesh.bl_idname, text=t("Tools.explode_mesh"), icon="MOD_EXPLODE")
|
||||
|
||||
# Standardization Tools
|
||||
col = draw_section_header(layout, t("Tools.standardize_title"), icon='OUTLINER_OB_ARMATURE')
|
||||
col.operator(AvatarToolkit_OT_StandardizeArmature.bl_idname, icon='CHECKMARK')
|
||||
|
||||
# Weight Tools
|
||||
weight_box: UILayout = bone_box.box()
|
||||
col = weight_box.column(align=True)
|
||||
col.prop(context.scene.avatar_toolkit, "merge_twist_bones", text=t("Tools.merge_twist_bones"))
|
||||
row = col.row(align=True)
|
||||
row.operator("avatar_toolkit.clean_weights", text=t("Tools.clean_weights"), icon='GROUP_BONE')
|
||||
row.operator("avatar_toolkit.clean_constraints", text=t("Tools.clean_constraints"), icon='CONSTRAINT_BONE')
|
||||
col = draw_section_header(layout, t("Tools.weight_title"), icon='GROUP_BONE')
|
||||
col.prop(toolkit, "merge_twist_bones", text=t("Tools.merge_twist_bones"))
|
||||
col.prop(toolkit, "preserve_parent_bones")
|
||||
col.prop(toolkit, "target_bone_type")
|
||||
col.prop(toolkit, "list_only_mode")
|
||||
|
||||
if toolkit.list_only_mode and len(toolkit.zero_weight_bones) > 0:
|
||||
sub_col = col.box()
|
||||
row = sub_col.row()
|
||||
row.template_list("AVATAR_TOOLKIT_UL_ZeroWeightBones", "",
|
||||
toolkit, "zero_weight_bones",
|
||||
toolkit, "zero_weight_bones_index")
|
||||
|
||||
sub_col.operator(AvatarToolKit_OT_RemoveSelectedBones.bl_idname,
|
||||
text=t("Tools.remove_selected_bones"))
|
||||
|
||||
# Combine weight
|
||||
draw_operator_row(col, [
|
||||
(AvatarToolKit_OT_RemoveZeroWeightBones.bl_idname, t("Tools.clean_weights"), 'GROUP_BONE'),
|
||||
(AvatarToolKit_OT_DeleteBoneConstraints.bl_idname, t("Tools.clean_constraints"), 'CONSTRAINT_BONE')
|
||||
])
|
||||
col.operator(AvatarToolKit_OT_RemoveZeroWeightVertexGroups.bl_idname, text=t("Tools.clean_vertex_groups"), icon='CONSTRAINT_BONE')
|
||||
|
||||
# Merge Tools
|
||||
merge_box: UILayout = layout.box()
|
||||
col = merge_box.column(align=True)
|
||||
col.label(text=t("Tools.merge_title"), icon='AUTOMERGE_ON')
|
||||
col.separator(factor=0.5)
|
||||
row = col.row(align=True)
|
||||
row.operator("avatar_toolkit.merge_to_active", text=t("Tools.merge_to_active"), icon='BONE_DATA')
|
||||
row.operator("avatar_toolkit.merge_to_parent", text=t("Tools.merge_to_parent"), icon='BONE_DATA')
|
||||
col.operator("avatar_toolkit.connect_bones", text=t("Tools.connect_bones"), icon='BONE_DATA')
|
||||
col = draw_section_header(layout, t("Tools.merge_title"), icon='AUTOMERGE_ON')
|
||||
draw_operator_row(col, [
|
||||
(AvatarToolkit_OT_MergeToActive.bl_idname, t("Tools.merge_to_active"), 'BONE_DATA'),
|
||||
(AvatarToolkit_OT_MergeToParent.bl_idname, t("Tools.merge_to_parent"), 'BONE_DATA')
|
||||
])
|
||||
col.operator(AvatarToolkit_OT_ConnectBones.bl_idname, text=t("Tools.connect_bones"), icon='BONE_DATA')
|
||||
|
||||
# Additional Tools
|
||||
extra_box: UILayout = layout.box()
|
||||
col = extra_box.column(align=True)
|
||||
col.label(text=t("Tools.additional_title"), icon='TOOL_SETTINGS')
|
||||
col.separator(factor=0.5)
|
||||
col.operator("avatar_toolkit.apply_transforms", text=t("Tools.apply_transforms"), icon='OBJECT_DATA')
|
||||
col.operator("avatar_toolkit.clean_shapekeys", text=t("Tools.clean_shapekeys"), icon='SHAPEKEY_DATA')
|
||||
col = draw_section_header(layout, t("Tools.additional_title"), icon='TOOL_SETTINGS')
|
||||
col.operator(AvatarToolkit_OT_ApplyTransforms.bl_idname, text=t("Tools.apply_transforms"), icon='OBJECT_DATA')
|
||||
col.operator(AvatarToolkit_OT_CleanShapekeys.bl_idname, text=t("Tools.clean_shapekeys"), icon='SHAPEKEY_DATA')
|
||||
|
||||
# Rigify Tools
|
||||
col = draw_section_header(layout, t("Tools.rigify_title"), icon='ARMATURE_DATA')
|
||||
col.operator(AvatarToolkit_OT_ConvertRigifyToUnity.bl_idname, icon='ARMATURE_DATA')
|
||||
col.prop(context.scene.avatar_toolkit, "merge_twist_bones")
|
||||
|
||||
|
||||
class AVATAR_TOOLKIT_UL_ZeroWeightBones(UIList):
|
||||
"""UI List for displaying zero weight bones with selection options"""
|
||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
|
||||
if self.layout_type in {'DEFAULT', 'COMPACT'}:
|
||||
row = layout.row(align=True)
|
||||
row.prop(item, "selected", text="")
|
||||
row.label(text=item.name)
|
||||
if item.has_children:
|
||||
row.label(text="", icon='OUTLINER_OB_ARMATURE')
|
||||
if item.is_deform:
|
||||
row.label(text="", icon='MOD_ARMATURE')
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user