Compare commits

..

88 Commits

Author SHA1 Message Date
Onan Chew 54a1dff122 Merge branch 'Alpha-4' into Alpha-3 2025-10-06 19:28:01 -04:00
Onan Chew 7dc74964e8 Merge pull request #190 from Yusarina/Alpha-4
Alpha 4 Mega PR
2025-10-06 19:25:38 -04:00
989onan 00a015a8d3 unfuck things 2025-10-06 19:18:15 -04:00
Yusarina 444554528d Update en_US.json 2025-08-09 15:49:51 +01:00
Yusarina cae6ce4301 Merge pull request #8 from Yusarina/alpha4-vrmconverter
Alpha4 vrmconverter
2025-08-09 15:47:39 +01:00
Yusarina 74716b187f Merge branch 'Alpha-4' into alpha4-vrmconverter 2025-08-09 15:47:30 +01:00
Yusarina fbb4569e99 Merge pull request #7 from Yusarina/alpha4-modeltranslateions
Alpha4 modeltranslateions
2025-08-09 15:41:19 +01:00
Yusarina 56967fc9a9 Version Bump 2025-08-09 15:35:15 +01:00
Onan Chew 5881180e69 Merge pull request #188 from Yusarina/Current
Bring Alpha 4 Branch up to date with Current
2025-08-09 02:48:35 -04:00
Yusarina 031b78ee7b Avatar Toolkit 0.3.2
- Version bumo
- Fixed standardised avatar only work in strict validation mode.
- Fixed Armature merging is using the armature selection in quick access, not the one you selected in Armature Merging for the base.
- Fixed error where if you were not in object mode merge would fail, it now switches to object mode before merge starting.
_ Merge Armature now attempts to auto populate the merge from and to boxes.
- Fixed bug in general mesh tools spamming the console (It was trying to check nothing).
2025-08-09 00:45:53 +01:00
Yusarina 61c77cf756 Translation Service
- Added translation service with 3 services.
- MyMemory (Free no api key needed but 1000 words a day and Skow)
- Deepl (Free with API key, 500000 words a month and fast)
- Libre Translate (Paid unless you host your own server, open source)
- Added caching for Quick Access and the translate service to speed up the UI. Can be fast depending on the service you use/ PC specs and etc).
2025-08-07 13:58:40 +01:00
Yusarina e19dd78557 Translations update 2025-08-03 20:19:14 +01:00
Yusarina d820edfc64 Change Chest.Up to UpperChest 2025-08-03 20:08:38 +01:00
Yusarina b39e20e647 Removed hardcoded bones names 2025-08-03 16:33:51 +01:00
Onan Chew 929cadd596 Merge pull request #182 from Yusarina/Current
Alpha 3: bump version for patch release
2025-08-03 11:03:49 -04:00
Yusarina f90efb549a Small fix 2025-08-03 15:56:42 +01:00
Yusarina 3e8ab41ab9 Merge pull request #6 from Yusarina/Alpha-4
Alpha 4
2025-08-03 15:52:32 +01:00
Yusarina c28cfe1d1d Merge branch 'alpha4-vrmconverter' into Alpha-4 2025-08-03 15:52:25 +01:00
Yusarina 15ce911256 Alpha 3: bump version for patch release 2025-08-03 15:19:54 +01:00
Yusarina 2f3b8ab0ee Version Bump 2025-08-03 15:00:25 +01:00
Onan Chew 7b58f25913 Merge pull request #180 from Yusarina/bones-fixes
Added more bones to acceptable bones list
2025-08-03 09:05:11 -04:00
Onan Chew d25543d95b Merge pull request #172 from Yusarina/Current
Fixes asymmetric being incorrectly detected #169
2025-08-03 08:47:10 -04:00
Onan Chew ba9a7a8af3 Merge pull request #175 from Yusarina/righfy-fix
Possible Fix for #166
2025-08-03 08:46:14 -04:00
Onan Chew 408d3f24f7 Merge pull request #176 from Yusarina/Armature-merge-fix
Armature Merge Fix #174
2025-08-03 08:44:28 -04:00
Onan Chew bd33efe7ae Merge pull request #177 from Yusarina/dictionary-rejig
Bone standardisation fixes #164
2025-08-03 08:42:45 -04:00
Yusarina c50f275b1b VRM Convert Breast bones 2025-08-02 15:11:34 +01:00
Yusarina 1ddda1336a Added more bone names 2025-08-02 14:57:43 +01:00
Yusarina 634563afb3 Move bones to the dictionary (most was already in there but there were hardcoded for testing) 2025-08-02 01:52:09 +01:00
Yusarina 543869218c Fixes
- All bones should convert now
- Root bone now get's removed.
- Fixed Collections not getting removed
2025-08-02 01:28:28 +01:00
Yusarina e5e09e2cf3 Added more bones to acceptable bones list 2025-08-02 00:23:10 +01:00
Yusarina 29f728442a Initial VRM Conversion
VRM Conversion, converts the vrm armature and removes colliders as there are not used in Unity. There some bugs and i need to optimise it and etc. Also we need to remove root empty bone as it's useless in Unity.

Ran out of time to finish it but proof of concept it works lol. However dont want to release it unto Alpha 4 as it need to be tested and i may seperate some things into different buttons but i have not decided.
2025-08-01 14:40:49 +01:00
Yusarina 8c2c52f882 Fixed issue where some bones was not being renamed 2025-08-01 12:01:22 +01:00
Yusarina 6f5e7a394d Updated Dictionary for #164 2025-08-01 11:42:09 +01:00
Yusarina 6eb253be17 Armature Merge Fix #174
Fixed the struct error and the logger error.
2025-08-01 02:40:39 +01:00
Yusarina 5276aa0fe0 Possible Fix for #166 2025-08-01 02:21:31 +01:00
Yusarina c830938dce Fix for #165 (Hopefully)
We now recursively deletes entire chains of empty bones but keep parents if the toggle is active. It does well in testing but i dont have models with a long chain of bones to test.
2025-07-28 23:23:56 +01:00
Yusarina 60ba1b363f Fixes asymmetric being incorrectly detected #169
The symmetric bone detecting is now much more intelligent and should catch most instances, however uses the bone dictionary as a fallback.
2025-07-28 21:38:45 +01:00
Onan Chew e3052d867d Merge pull request #171 from Yusarina/Current
Fixed case senstive issue and added temp solution for accessories
2025-07-28 11:48:53 -04:00
Yusarina 08082501c9 Broke it but this should fix it
So thing stop working, fixed it.

Also added basic accessory check which should close https://github.com/teamneoneko/Avatar-Toolkit/issues/170

Though it is basic it should get the job done for now unto we come up with a better solution.
2025-07-28 09:28:24 +01:00
Yusarina a8482a87f3 Using validation in the dictionary fixes case sensitive issue
The validation is doing case sensitive string matching, but it should be using the same normalization that's applied to the bone dictionaries. The the most ideal solution but it fixes https://github.com/teamneoneko/Avatar-Toolkit/issues/168
2025-07-28 09:09:41 +01:00
989onan 482fe1b593 oops
Tries to fix #166
Seems like I had -1 brain cells and didn't notice this blaintant oversight in the code
2025-07-23 02:57:12 -04:00
Onan Chew c95c7e596c Merge pull request #162 from teamneoneko/Alpha-3
4.5 Blender version + new features
2025-07-15 18:12:18 -04:00
Onan Chew b9c0a34065 Merge branch 'Current' into Alpha-3 2025-07-15 18:11:58 -04:00
989onan c055d60053 Version Bump 2025-07-15 17:53:10 -04:00
989onan f8ef79e7cc broke digitigrade bones, this fixes that 2025-07-10 20:37:29 -04:00
989onan 6d9f751a16 Housekeeping (bug fixes)
NEW FEATURES:
- added apply shapekey to basis from Cats
  - now that pesky thing I keep going back to cats for is in Avatar Toolkit.

BUG FIXES:
- now we push armature santizers into functions where they are needed
  - this prevents the methods from mirroring changes while working, causing them to blow up when mirror mode is on
  - more changes to come for armature setting santitizers
- fixed error reporting
  - now methods when catching errors will return full error tracebacks
  - this will help make debugging and finding user issues easier.
2025-07-10 18:44:42 -04:00
989onan 89fc8bc9c8 Update visemes.py
- fix viseme creation needing an armature (idk why it needed this)
2025-07-07 13:14:00 -04:00
989onan d31519a51d update again 2025-07-04 16:55:41 -04:00
989onan 1fcd1ad07d REEEE 2025-06-20 22:58:09 -04:00
989onan 5be65501b4 oops still not perfect, here 2025-06-20 22:52:11 -04:00
989onan 9e00234f0d unfuck digitgrade leg tools
the heck happened here!? anyways it's working
2025-06-20 21:56:24 -04:00
989onan 8937077e3a fix issues with merge armatures
please report these issues lol!! I found this after I got my friend to test the addon. OOF!!
2025-06-15 19:31:30 -04:00
Yusarina 80dfaf2cce Update wiki links 2025-06-11 10:49:00 +01:00
Onan Chew ebbebf33f4 Merge pull request #161 from 989onan/patch2
Fix error logging
2025-06-02 21:54:39 -04:00
989onan 316b125fa8 fix more error logging errors 2025-05-11 12:14:47 -04:00
989onan 9a84cf52b5 fix error logging bruh
reee
2025-05-11 12:07:51 -04:00
Onan Chew e2c26a20fa Merge pull request #159 from Yusarina/mmd-tools-improvements
Mmd tools improvements
2025-04-22 23:11:09 -04:00
Yusarina cfe760e8df Updated Operations and Properties
- Updated Operations and Properties with tpying and logging.

I have not updated translation files, this is because i want to gut MMD Tools system and replace it with our own, however I want to make MMD Tools more simple and ajust it to our needs only. This is going to take a while and my aim for this is Alpha 4, also the MMD Translation system hurt my head....

- Fixes a couple of bugs as well, with quick access and the PMX importer.
2025-04-23 00:43:38 +01:00
Yusarina 61e4269764 Update Files and Fixes 2025-04-22 00:28:47 +01:00
Yusarina bf92ca905b Upfate Bone and Camrea 2025-04-17 00:02:18 +01:00
Yusarina d1af3fffed Update importer 2025-04-16 19:02:16 +01:00
Yusarina 19c2ede791 Update Translation.py 2025-04-16 16:17:57 +01:00
Onan Chew e88a952c84 Merge pull request #158 from Yusarina/logging-update
Logging update
2025-04-12 11:12:04 -04:00
Yusarina bb5a314796 Bringing files in-line with Avatar Toolkit
- Adding better typing
- Update to use Avatar Toolkit's logging system.
- Removed some files which were in the wrong location (From my first attempt).
2025-04-12 00:17:11 +01:00
Yusarina 567f5fe541 Merge pull request #5 from Yusarina/Alpha-3
Alpha 3
2025-04-11 23:46:46 +01:00
Yusarina c31d25dd01 Update Logging
You can choose between errors, warning, info or full debug, errors will always log to ensure we don't have silent failures with debug on or off.
2025-04-11 23:45:36 +01:00
Onan Chew d25c95fc73 Merge pull request #156 from Yusarina/MMD-Tools-Alpha-3
PMX Import - First Stage
2025-04-10 20:45:35 -04:00
Yusarina 69cc03098f PMX Import now works 2025-04-10 23:40:51 +01:00
Yusarina 161684dcac Merge pull request #155 from 989onan/patch-1
Add Explode Model
2025-04-10 12:29:36 +01:00
989onan 6bafc7d7ac add explode model
- Add method that allows for exploding the model into pieces for kit bashing or painting in substance painter.
2025-04-05 17:54:39 -04:00
Yusarina cf2a5a22cc Merge pull request #154 from 989onan/patch-1
Vastly improve Merge Doubles
2025-04-04 14:48:21 +01:00
989onan 88e88b94a3 hotfix 2025-04-03 20:14:17 -04:00
989onan 046ebfa72d bugfix
fix pairs not merging if they would merge on one shapekey but not another
2025-04-03 19:44:56 -04:00
989onan 036e260dd6 Vastly improve Merge Doubles
- removed advanced merge doubles, it just does advanced by default
- same behavior as advanced was before, but now completes the task in under a second. Thanks to the power of BMesh!
- Labels now reflect this change
2025-04-03 19:12:55 -04:00
Onan Chew ce2b38b5fe Merge pull request #153 from Yusarina/Alpha-3
Read me update.
2025-04-03 12:19:17 -04:00
Yusarina d1912d2dba Fix url for discord 2025-04-03 15:59:54 +01:00
Yusarina 6e06f73174 Readme update 2025-04-03 15:56:58 +01:00
Yusarina 3414ad8917 Initial MMD Importer Commit
- This is the initial commit I spent several hours trying to get it up two Avatar Toolkit standard, it does not work yet because there are files missing but I been doing this since 6am and it is 4pm almost, i need food.
- I have also removed as much legacy code as i could, MMD Tools contains so much of it even though there have a 4.2+ only version there have not removed any of the legacy code for pre 4.2.... this is going to take a while.

God I hope this works fine once I am done.
2025-04-03 15:39:03 +01:00
Yusarina 3e3e245a4f Merge pull request #151 from 989onan/patch-1
Many feature additions and improvements
2025-04-03 11:21:54 +01:00
989onan f28e1866a9 Add AMFOWSH
Add apply modifier for object with shapekeys tool
2025-04-03 02:57:06 -04:00
989onan 71b22813a8 added request #112
added request #112
2025-04-02 23:21:05 -04:00
989onan f16105517e fix flip animation
add to menu
fix resonite animx importer bug

add flip animations

add flip animation keyframes to help users rekey and remake animations as if they were mirrored.
2025-04-02 23:21:04 -04:00
989onan 199551a505 add remove zero weight vertex groups to panel 2025-04-02 20:45:32 -04:00
989onan 5cad28a41b fix pointers
fix pointers in operators to point to class bl_idname property
2025-04-02 20:35:59 -04:00
989onan e4d3f676a2 make merge armature use the new identify bones method 2025-04-02 19:55:10 -04:00
989onan 3ada550067 bug fixes 2025-04-02 19:30:57 -04:00
989onan 9dd54cd976 update library syntax paths for VSCode 2025-04-02 18:31:38 -04:00
Yusarina c1536f8e06 Merge pull request #150 from teamneoneko/Current
Bring Alpha 3 branch up to date
2025-04-02 16:55:59 +01:00
92 changed files with 23080 additions and 808 deletions
+5 -5
View File
@@ -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"
+16 -9
View File
@@ -3,11 +3,18 @@
## 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-).
@@ -16,17 +23,17 @@ If you like what we do and want to help support the development of cats you can
## Blender version support policies.
You can find them on the wiki here [HERE](https://avatartoolkit.xyz/wiki.html?version=0.2.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.4 or newer is required
- Blender 4.4 is the current recommended version
- Blender 4.5 or newer is required
- Blender 4.5 is the current recommended version
2) Python Requirements
@@ -35,13 +42,13 @@ 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.4 for the best experience
- Use Blender 4.5 for the best experience
#### Additional Plugins Requirements.
Currently None.
## Installation
You can find out how to install Avatar Toolkit [here](https://avatartoolkit.xyz/wiki.html?version=0.2.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
+11 -1
View File
@@ -1,6 +1,7 @@
import bpy
from bpy.app.handlers import persistent
modules = None
ordered_classes = None
@@ -15,7 +16,7 @@ def show_version_error_popup():
def register():
import bpy
version = bpy.app.version
if version[0] > 4 or (version[0] == 4 and version[1] >= 5):
if version[0] > 5 or (version[0] == 5 and version[1] >= 3):
show_version_error_popup()
return
@@ -25,6 +26,7 @@ def register():
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)
@@ -36,7 +38,15 @@ def register():
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():
+2 -2
View File
@@ -3,13 +3,13 @@
schema_version = "1.0.0"
id = "avatar_toolkit"
version = "0.2.1"
version = "0.4.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.4.0"
blender_version_min = "4.5.0"
license = [
"SPDX:GPL-3.0-or-later",
+244 -25
View File
@@ -10,27 +10,34 @@ from ..core.dictionaries import (
bone_hierarchy,
finger_hierarchy,
acceptable_bone_hierarchy,
acceptable_bone_names
acceptable_bone_names,
simplify_bonename
)
from ..core.logging_setup import logger
def validate_armature(armature: Object, detailed_messages: bool = False) -> Union[Tuple[bool, List[str], bool], Tuple[bool, List[str], bool, List[str], List[str], List[str]]]:
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 = bpy.context.scene.avatar_toolkit.validation_mode
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
is_pmx_model = False
if armature and hasattr(armature, 'mmd_type') or (hasattr(armature, 'parent') and armature.parent and hasattr(armature.parent, 'mmd_type')):
is_pmx_model = True
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, [], False, [], [], []
return True, [t("Validation.mode.none")], False, [], [], []
else:
return True, [], False
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")
@@ -98,17 +105,41 @@ def validate_armature(armature: Object, detailed_messages: bool = False) -> Unio
# Non-standard bones check
non_standard_bones = []
required_patterns = [
'Hips', 'Spine', 'Chest', 'Neck', 'Head',
'Upper', 'Lower', 'Hand', 'Foot', 'Toe',
'Thumb', 'Index', 'Middle', 'Ring', 'Pinky',
'Eye'
# 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:
if any(pattern in bone_name for pattern in required_patterns):
is_standard = bone_name in standard_bones.values()
is_acceptable_bone = any(bone_name in names for names in acceptable_bone_names.values())
# 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)
@@ -125,6 +156,21 @@ def validate_armature(armature: Object, detailed_messages: bool = False) -> Unio
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 is_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)
@@ -149,6 +195,10 @@ def validate_armature(armature: Object, detailed_messages: bool = False) -> Unio
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
@@ -161,31 +211,188 @@ def validate_bone_hierarchy(bones: Dict[str, Bone], parent_name: str, child_name
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"""
# Extract left and right bone names from both hierarchies
# 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(value)
left_bone_names.add(simplify_bonename(value))
elif '_r' in key.lower():
right_bone_names.add(value)
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(names)
left_bone_names.update(simplify_bonename(name) for name in names)
elif '_r' in key.lower():
right_bone_names.update(names)
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 bones for name in left_bone_names)
right_exists = any(name in bones for name in right_bone_names)
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
@@ -199,22 +406,34 @@ def validate_finger_chain(bones: Dict[str, Bone], chain: Tuple[str, ...]) -> boo
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:
if name in bones:
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
# Validate acceptable hierarchy using normalized names
for parent, child in acceptable_bone_hierarchy:
if parent in bones and child in bones:
if not validate_bone_hierarchy(bones, parent, child):
logger.debug(f"Invalid acceptable hierarchy: {parent} -> {child}")
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")
+2 -1
View File
@@ -27,7 +27,8 @@ def init() -> None:
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")
+65 -19
View File
@@ -1,3 +1,4 @@
import traceback
import bpy
import numpy as np
import threading
@@ -18,7 +19,7 @@ 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
from .dictionaries import reverse_bone_lookup, bone_names, simplify_bonename
class SceneMatClass(PropertyGroup):
mat: PointerProperty(type=Material)
@@ -139,6 +140,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:
@@ -200,9 +207,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"""
@@ -314,6 +321,7 @@ def join_mesh_objects(context: Context, meshes: List[Object], progress: Optional
for mesh in valid_meshes:
mesh.select_set(True)
mesh.hide_set(False)
context.view_layer.objects.active = valid_meshes[0]
@@ -335,8 +343,8 @@ def join_mesh_objects(context: Context, meshes: List[Object], progress: Optional
return joined_mesh
except Exception as e:
logger.error(f"Failed to join meshes: {str(e)}")
except Exception:
logger.error(f"Failed to join meshes: {traceback.format_exc()}")
return None
@@ -365,8 +373,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')
@@ -383,7 +391,7 @@ def clear_unused_data_blocks() -> int:
if isinstance(getattr(bpy.data, attr), bpy.types.bpy_prop_collection))
return initial_count - final_count
def identify_bones(arm_data: bpy.types.Armature, context: bpy.types.Context) -> Dict[str,str]:
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] = {}
@@ -488,13 +496,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:
@@ -518,6 +543,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,
@@ -601,6 +638,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()
@@ -608,18 +646,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
+303 -154
View File
@@ -255,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
# Add VRM bone name variations
bone_names.update({
'hips': bone_names['hips'] + ['jbipchips', 'jhips', 'vrmhips'],
'hips': bone_names['hips'] + ['jbipchips', 'jhips', 'vrmhips', 'leftupperleg', 'rightupperleg'],
'spine': bone_names['spine'] + ['jbipcspine', 'jspine', 'vrmspine'],
'chest': bone_names['chest'] + ['jbipcchest', 'jchest', 'vrmchest'],
'upper_chest': bone_names['upper_chest'] + ['jbipcupperchest', 'jupperchest', 'vrmupperchest'],
'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'],
'head': bone_names['head'] + ['jbipchead', 'jhead', 'vrmhead', 'lefteye', 'righteye'],
# VRM specific finger naming
'thumb_0_l': bone_names['thumb_0_l'] + ['thumbmetacarpall', 'jthumb1l'],
'index_0_l': bone_names['index_0_l'] + ['indexmetacarpall', 'jindex1l'],
'middle_0_l': bone_names['middle_0_l'] + ['middlemetacarpall', 'jmiddle1l'],
'ring_0_l': bone_names['ring_0_l'] + ['ringmetacarpall', 'jring1l'],
'pinkie_0_l': bone_names['pinkie_0_l'] + ['littlemetacarpall', 'jlittle1l'],
# 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'] + ['thumbmetacarpalr', 'jthumb1r'],
'index_0_r': bone_names['index_0_r'] + ['indexmetacarpalr', 'jindex1r'],
'middle_0_r': bone_names['middle_0_r'] + ['middlemetacarpalr', 'jmiddle1r'],
'ring_0_r': bone_names['ring_0_r'] + ['ringmetacarpalr', 'jring1r'],
'pinkie_0_r': bone_names['pinkie_0_r'] + ['littlemetacarpalr', 'jlittle1r']
'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
@@ -367,113 +452,125 @@ standard_bones = {
'hips': 'Hips',
'spine': 'Spine',
'chest': 'Chest',
'upper_chest': 'Chest.Up',
'upper_chest': 'UpperChest',
'neck': 'Neck',
'head': 'Head',
# Arms
'left_arm': 'UpperArm.L',
'left_elbow': 'LowerArm.L',
'left_wrist': 'Hand.L',
'right_arm': 'UpperArm.R',
'right_elbow': 'LowerArm.R',
'right_wrist': 'Hand.R',
'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': 'Toes.L',
'right_leg': 'UpperLeg.R',
'right_knee': 'LowerLeg.R',
'right_ankle': 'Foot.R',
'right_toe': 'Toes.R',
'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': 'Thumb1.L',
'thumb_2_l': 'Thumb2.L',
'thumb_3_l': 'Thumb3.L',
'index_1_l': 'Index1.L',
'index_2_l': 'Index2.L',
'index_3_l': 'Index3.L',
'middle_1_l': 'Middle1.L',
'middle_2_l': 'Middle2.L',
'middle_3_l': 'Middle3.L',
'ring_1_l': 'Ring1.L',
'ring_2_l': 'Ring2.L',
'ring_3_l': 'Ring3.L',
'pinkie_1_l': 'Pinky1.L',
'pinkie_2_l': 'Pinky2.L',
'pinkie_3_l': 'Pinky3.L',
'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': 'Thumb1.R',
'thumb_2_r': 'Thumb2.R',
'thumb_3_r': 'Thumb3.R',
'index_1_r': 'Index1.R',
'index_2_r': 'Index2.R',
'index_3_r': 'Index3.R',
'middle_1_r': 'Middle1.R',
'middle_2_r': 'Middle2.R',
'middle_3_r': 'Middle3.R',
'ring_1_r': 'Ring1.R',
'ring_2_r': 'Ring2.R',
'ring_3_r': 'Ring3.R',
'pinkie_1_r': 'Pinky1.R',
'pinkie_2_r': 'Pinky2.R',
'pinkie_3_r': 'Pinky3.R',
'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'
'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', 'Chest.Up'),
('Chest.Up', 'Neck'),
('Chest', 'UpperChest'),
('UpperChest', 'Neck'),
('Neck', 'Head'),
('Head', 'Eye.L'),
('Head', 'Eye.R'),
('Head', 'Eye_L'),
('Head', 'Eye_R'),
# Left Arm Chain
('Chest.Up', 'UpperArm.L'),
('UpperArm.L', 'LowerArm.L'),
('LowerArm.L', 'Hand.L'),
('UpperChest', 'Shoulder_L'),
('Shoulder_L', 'UpperArm_L'),
('UpperArm_L', 'LowerArm_L'),
('LowerArm_L', 'Hand_L'),
# Right Arm Chain
('Chest.Up', 'UpperArm.R'),
('UpperArm.R', 'LowerArm.R'),
('LowerArm.R', 'Hand.R'),
('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', 'Toes.L'),
('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', 'Toes.R')
('Hips', 'UpperLeg_R'),
('UpperLeg_R', 'LowerLeg_R'),
('LowerLeg_R', 'Foot_R'),
('Foot_R', 'Toe_R')
]
finger_hierarchy = {
'left': [
('Hand.L', 'Thumb1.L', 'Thumb2.L', 'Thumb3.L'),
('Hand.L', 'Index1.L', 'Index2.L', 'Index3.L'),
('Hand.L', 'Middle1.L', 'Middle2.L', 'Middle3.L'),
('Hand.L', 'Ring1.L', 'Ring2.L', 'Ring3.L'),
('Hand.L', 'Pinky1.L', 'Pinky2.L', 'Pinky3.L')
('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', 'Thumb1.R', 'Thumb2.R', 'Thumb3.R'),
('Hand.R', 'Index1.R', 'Index2.R', 'Index3.R'),
('Hand.R', 'Middle1.R', 'Middle2.R', 'Middle3.R'),
('Hand.R', 'Ring1.R', 'Ring2.R', 'Ring3.R'),
('Hand.R', 'Pinky1.R', 'Pinky2.R', 'Pinky3.R')
('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')
]
}
@@ -506,6 +603,8 @@ acceptable_bone_hierarchy = [
('Head', 'Eye_R'),
('Head', 'LeftEye'),
('Head', 'RightEye'),
('Head', 'Eye.L'),
('Head', 'Eye.R'),
# Unity humanoid naming
('Hips', 'Spine'),
@@ -516,6 +615,40 @@ acceptable_bone_hierarchy = [
('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 = {
@@ -523,59 +656,75 @@ acceptable_bone_names = {
'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_r': ['Eye_R', 'RightEye', 'righteye', 'eye_right', 'EyeRight'],
'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'],
'arm_r': ['Arm.R', 'upperarm_r', 'UpperArmRight', 'RightArm'],
'elbow_r': ['Elbow.R', 'lowerarm_r', 'ForearmRight', 'RightForeArm'],
'wrist_r': ['Wrist.R', 'hand_r', 'HandRight', 'RightHand'],
'leg_r': ['Leg.R', 'thigh_r', 'ThighRight', 'RightLeg', 'RightUpLeg'],
'knee_r': ['Knee.R', 'calf_r', 'CalfRight', 'RightShin', 'RightLowerLeg'],
'foot_r': ['Foot.R', 'foot_r', 'FootRight', 'RightFoot'],
'toes_r': ['Toes.R', 'ball_r', 'ToeRight', 'RightToeBase'],
'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'],
'arm_l': ['Arm.L', 'upperarm_l', 'UpperArmLeft', 'LeftArm'],
'elbow_l': ['Elbow.L', 'lowerarm_l', 'ForearmLeft', 'LeftForeArm'],
'wrist_l': ['Wrist.L', 'hand_l', 'HandLeft', 'LeftHand'],
'leg_l': ['Leg.L', 'thigh_l', 'ThighLeft', 'LeftLeg', 'LeftUpLeg'],
'knee_l': ['Knee.L', 'calf_l', 'CalfLeft', 'LeftShin', 'LeftLowerLeg'],
'foot_l': ['Foot.L', 'foot_l', 'FootLeft', 'LeftFoot'],
'toes_l': ['Toes.L', 'ball_l', 'ToeLeft', 'LeftToeBase'],
'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'],
'thumb_1_l': ['Thumb1_L'],
'thumb_2_l': ['Thumb2_L'],
'index_1_l': ['IndexFinger1_L'],
'index_2_l': ['IndexFinger2_L'],
'index_3_l': ['IndexFinger3_L'],
'middle_1_l': ['MiddleFinger1_L'],
'middle_2_l': ['MiddleFinger2_L'],
'middle_3_l': ['MiddleFinger3_L'],
'ring_1_l': ['RingFinger1_L'],
'ring_2_l': ['RingFinger2_L'],
'ring_3_l': ['RingFinger3_L'],
'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', 'ThumbO_R'],
'thumb_1_r': ['Thumb1_R'],
'thumb_2_r': ['Thumb2_R'],
'index_1_r': ['IndexFinger1_R'],
'index_2_r': ['IndexFinger2_R'],
'index_3_r': ['IndexFinger3_R'],
'middle_1_r': ['MiddleFinger1_R'],
'middle_2_r': ['MiddleFinger2_R'],
'middle_3_r': ['MiddleFinger3_R'],
'ring_1_r': ['RingFinger1_R'],
'ring_2_r': ['RingFinger2_R'],
'ring_3_r': ['RingFinger3_R'],
'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'],
'breast_upper_2_l': ['BreastUpper2_L'],
'breast_upper_1_r': ['BreastUpper1_R'],
'breast_upper_2_r': ['BreastUpper2_R'],
'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'],
@@ -695,17 +844,17 @@ non_standard_mappings = {
'left_arm': [
'mixamorig:LeftArm', 'mixamorig_LeftArm',
'ORG-upper_arm.L', 'upper_arm.L',
'lShldrBend', 'lShldrTwist', 'lArm'
'lShldrBend', 'lShldrTwist', 'lArm', 'UpperArm.L'
],
'left_elbow': [
'mixamorig:LeftForeArm', 'mixamorig_LeftForeArm',
'ORG-forearm.L', 'forearm.L',
'lForearmBend', 'lElbow', 'lForeArm'
'lForearmBend', 'lElbow', 'lForeArm', 'LowerArm.L'
],
'left_wrist': [
'mixamorig:LeftHand', 'mixamorig_LeftHand',
'ORG-hand.L', 'hand.L',
'lHand', 'lWrist'
'lHand', 'lWrist', 'Hand.L'
],
'right_shoulder': [
@@ -716,59 +865,59 @@ non_standard_mappings = {
'right_arm': [
'mixamorig:RightArm', 'mixamorig_RightArm',
'ORG-upper_arm.R', 'upper_arm.R',
'rShldrBend', 'rShldrTwist', 'rArm'
'rShldrBend', 'rShldrTwist', 'rArm', 'UpperArm.R'
],
'right_elbow': [
'mixamorig:RightForeArm', 'mixamorig_RightForeArm',
'ORG-forearm.R', 'forearm.R',
'rForearmBend', 'rElbow', 'rForeArm'
'rForearmBend', 'rElbow', 'rForeArm', 'LowerArm.R'
],
'right_wrist': [
'mixamorig:RightHand', 'mixamorig_RightHand',
'ORG-hand.R', 'hand.R',
'rHand', 'rWrist'
'rHand', 'rWrist', 'Hand.R'
],
'left_leg': [
'mixamorig:LeftUpLeg', 'mixamorig_LeftUpLeg',
'ORG-thigh.L', 'thigh.L',
'lThighBend', 'lThigh'
'lThighBend', 'lThigh', 'UpperLeg.L'
],
'left_knee': [
'mixamorig:LeftLeg', 'mixamorig_LeftLeg',
'ORG-shin.L', 'shin.L',
'lShin', 'lKnee', 'lLeg'
'lShin', 'lKnee', 'lLeg', 'LowerLeg.L'
],
'left_ankle': [
'mixamorig:LeftFoot', 'mixamorig_LeftFoot',
'ORG-foot.L', 'foot.L',
'lFoot', 'lAnkle'
'lFoot', 'lAnkle', 'Foot.L'
],
'left_toe': [
'mixamorig:LeftToeBase', 'mixamorig_LeftToeBase',
'ORG-toe.L', 'toe.L',
'lToe'
'lToe', 'Toes.L'
],
'right_leg': [
'mixamorig:RightUpLeg', 'mixamorig_RightUpLeg',
'ORG-thigh.R', 'thigh.R',
'rThighBend', 'rThigh'
'rThighBend', 'rThigh', 'UpperLeg.R'
],
'right_knee': [
'mixamorig:RightLeg', 'mixamorig_RightLeg',
'ORG-shin.R', 'shin.R',
'rShin', 'rKnee', 'rLeg'
'rShin', 'rKnee', 'rLeg', 'LowerLeg.R'
],
'right_ankle': [
'mixamorig:RightFoot', 'mixamorig_RightFoot',
'ORG-foot.R', 'foot.R',
'rFoot', 'rAnkle'
'rFoot', 'rAnkle', 'Foot.R'
],
'right_toe': [
'mixamorig:RightToeBase', 'mixamorig_RightToeBase',
'ORG-toe.R', 'toe.R',
'rToe'
'rToe', 'Toes.R'
],
'thumb_1_l': [
@@ -934,12 +1083,12 @@ non_standard_mappings = {
'left_eye': [
'mixamorig:LeftEye', 'mixamorig_LeftEye',
'ORG-eye.L', 'eye.L',
'lEye'
'lEye', 'Eye.L'
],
'right_eye': [
'mixamorig:RightEye', 'mixamorig_RightEye',
'ORG-eye.R', 'eye.R',
'rEye'
'rEye', 'Eye.R'
]
}
+372
View File
@@ -0,0 +1,372 @@
# 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"],
}
# 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
+44 -3
View File
@@ -8,6 +8,8 @@ from bpy_extras.io_utils import ImportHelper
from typing import Optional, Callable, Dict, List, Union, Set
from ..common import clear_default_objects
from ..translations import t
from ..mmd.core.pmx.importer import PMXImporter
import traceback
# Configure logging
logging.basicConfig(level=logging.INFO)
@@ -16,7 +18,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"""
@@ -83,8 +85,8 @@ def import_multi_files(
progress_callback(fullpath)
progress.update(file["name"])
except Exception as e:
logger.error(f"Import failed: {str(e)}", exc_info=True)
except Exception:
logger.error(f"Import failed: {traceback.format_exc()}", exc_info=True)
raise
ImportMethod = Callable[[str, List[Dict[str, str]], str], None]
@@ -94,6 +96,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),
@@ -193,3 +201,36 @@ 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 PMXImporter
Args:
filepath: Path to the PMX file
"""
# Default import settings
import_settings = {
"filepath": filepath,
"scale": 0.08,
"types": {"MESH", "ARMATURE", "MORPHS", "DISPLAY"},
"clean_model": True,
"remove_doubles": False,
"fix_IK_links": True,
"ik_loop_factor": 3,
"use_mipmap": True,
"sph_blend_factor": 1.0,
"spa_blend_factor": 1.0,
"rename_LR_bones": False,
"use_underscore": False,
"apply_bone_fixed_axis": False,
}
# Create and execute the importer
importer = PMXImporter()
try:
importer.execute(**import_settings)
logger.info(f"Successfully imported PMX file: {filepath}")
except Exception:
logger.error(f"Failed to import PMX file: {traceback.format_exc()}", exc_info=True)
raise
+19 -14
View File
@@ -6,23 +6,34 @@ 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 kwargs.get('exc_info', False) or isinstance(msg, Exception):
if isinstance(kwargs.get('exception', None), Exception):
full_msg = f"{msg}\n{traceback.format_exc()}"
_original_error(full_msg, *args, **{**kwargs, 'exc_info': False})
else:
@@ -34,12 +45,6 @@ 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)
def highlight_problem_bones(self: Any, context: Context) -> None:
"""Log when problem bones are highlighted"""
from .addon_preferences import save_preference
enabled = self.highlight_problem_bones
save_preference("highlight_problem_bones", enabled)
logger.debug(f"Problem bone highlighting {'enabled' if enabled else 'disabled'}")
configure_logging(enabled, level)
+26
View File
@@ -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'
+536
View File
@@ -0,0 +1,536 @@
# -*- 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 contextlib
from typing import Generator, List, Optional, TypeVar, Any, Set, Tuple, Dict, Union
import bpy
from bpy.types import Object, Context, ID, Key, ShapeKey, FCurve, LayerCollection, Collection
from bpy.types import AddonPreferences, Addon, WindowManager, Area, Region, Window
from ..logging_setup import logger
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: Object):
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) -> Any:
return self.__obj.data
def __exit__(self, type: Any, value: Any, traceback: Any) -> None:
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: Object, selected_objects: Optional[List[Object]] = None):
if not isinstance(active_object, bpy.types.Object):
raise ValueError
try:
bpy.ops.object.mode_set(mode="OBJECT")
except Exception:
logger.debug("Failed to set object mode")
pass
context = FnContext.ensure_context()
for i in context.selected_objects:
i.select_set(False)
self.__active_object = active_object
self.__selected_objects = tuple(set(selected_objects) | set([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(context, i)
FnContext.set_active_object(context, active_object)
def __enter__(self) -> Object:
return self.__active_object
def __exit__(self, type: Any, value: Any, traceback: Any) -> None:
for i, j in zip(self.__selected_objects, self.__hides):
i.hide_set(j)
def setParent(obj: Object, parent: Object) -> None:
with select_object(parent, objects=[parent, obj]):
bpy.ops.object.parent_set(type="OBJECT", xmirror=False, keep_transform=False)
def setParentToBone(obj: Object, parent: Object, bone_name: str) -> None:
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: Object) -> __EditMode:
"""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: Object, objects: Optional[List[Object]] = None) -> __SelectObjects:
"""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: Reimplement with bpy.context.temp_override (If it ain't broke, don't fix it.)
return __SelectObjects(obj, objects)
def duplicateObject(obj: Object, total_len: int) -> List[Object]:
return FnContext.duplicate_object(FnContext.ensure_context(), obj, total_len)
def createObject(name: str = "Object", object_data: Optional[ID] = None, target_scene: Optional[bpy.types.Scene] = None) -> Object:
context = FnContext.ensure_context(target_scene)
return FnContext.set_active_object(context, FnContext.new_and_link_object(context, name, object_data))
def makeSphere(segment: int = 8, ring_count: int = 5, radius: float = 1.0, target_object: Optional[Object] = None) -> Object:
import bmesh
if target_object is None:
target_object = createObject(name="Sphere")
logger.debug(f"Created new sphere object: {target_object.name}")
else:
logger.debug(f"Using existing object for sphere: {target_object.name}")
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: Tuple[float, float, float] = (1, 1, 1), target_object: Optional[Object] = None) -> Object:
import bmesh
from mathutils import Matrix
if target_object is None:
target_object = createObject(name="Box")
logger.debug(f"Created new box object: {target_object.name}")
else:
logger.debug(f"Using existing object for box: {target_object.name}")
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: int = 8, ring_count: int = 2, radius: float = 1.0, height: float = 1.0, target_object: Optional[Object] = None) -> Object:
import math
import bmesh
if target_object is None:
target_object = createObject(name="Capsule")
logger.debug(f"Created new capsule object: {target_object.name}")
else:
logger.debug(f"Using existing object for capsule: {target_object.name}")
height = max(height, 1e-3)
mesh = target_object.data
bm = bmesh.new()
verts = bm.verts
top = (0, 0, height / 2 + radius)
verts.new(top)
# f = lambda i: radius*i/ring_count
f = lambda i: 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: Dict[Union[str, Tuple[str, str]], Union[str, Tuple[str, ...]]] = {"ROTATION": "_rot", "SCALE": "_scale"}
@staticmethod
def create(constraints: bpy.types.ObjectConstraints, name: str, map_type: str) -> bpy.types.TransformConstraint:
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: str, name_id: str = "") -> Tuple[str, ...]:
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: bpy.types.TransformConstraint, value: float, influence: Optional[float] = 1) -> None:
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: Object, shape_key: ShapeKey) -> None:
assert isinstance(mesh_object.data, bpy.types.Mesh)
key: Key = shape_key.id_data
assert key == mesh_object.data.shape_keys
if mesh_object.animation_data is not None:
fc_curve: 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[Context] = None) -> Context:
return context or bpy.context
@staticmethod
def get_active_object(context: Context) -> Optional[Object]:
return context.active_object
@staticmethod
def set_active_object(context: Context, obj: Object) -> Object:
context.view_layer.objects.active = obj
return obj
@staticmethod
def set_active_and_select_single_object(context: Context, obj: Object) -> Object:
return FnContext.set_active_object(context, FnContext.select_single_object(context, obj))
@staticmethod
def get_scene_objects(context: Context) -> bpy.types.SceneObjects:
return context.scene.objects
@staticmethod
def ensure_selectable(context: Context, obj: Object) -> 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: 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: Context, obj: Object) -> Object:
FnContext.ensure_selectable(context, obj).select_set(True)
return obj
@staticmethod
def select_objects(context: Context, *objects: Object) -> List[Object]:
return [FnContext.select_object(context, obj) for obj in objects]
@staticmethod
def select_single_object(context: Context, obj: Object) -> 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: Context, obj: Object) -> Object:
context.collection.objects.link(obj)
return obj
@staticmethod
def new_and_link_object(context: Context, name: str, object_data: Optional[ID]) -> Object:
return FnContext.link_object(context, bpy.data.objects.new(name=name, object_data=object_data))
@staticmethod
def duplicate_object(context: Context, object_to_duplicate: Object, target_count: int) -> List[Object]:
"""
Duplicate object.
This function duplicates the given object and returns a list of duplicated objects.
Args:
context (Context): The context in which the duplication is performed.
object_to_duplicate (Object): The object to be duplicated.
target_count (int): The desired count of duplicated objects.
Returns:
List[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
logger.debug(f"Duplicated object {object_to_duplicate.name} to create {target_count} objects")
return result_objects
@staticmethod
def find_user_layer_collection_by_object(context: Context, target_object: Object) -> Optional[LayerCollection]:
"""
Finds the layer collection that contains the given target_object in the user's collections.
Args:
context (Context): The Blender context.
target_object (Object): The target object to find the layer collection for.
Returns:
Optional[LayerCollection]: The layer collection that contains the target_object, or None if not found.
"""
scene_layer_collection: LayerCollection = context.view_layer.layer_collection
def find_layer_collection_by_name(layer_collection: LayerCollection, name: str) -> Optional[LayerCollection]:
if layer_collection.name == name:
return layer_collection
child_layer_collection: 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: 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: Context, target_object: Object) -> Generator[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 (Context): The context in which the active_layer_collection will be overridden.
target_object (Object): The target object whose layer collection will be set as the active_layer_collection.
Yields:
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: Context) -> Optional[AddonPreferences]:
addon: Addon = context.preferences.addons.get(__package__, None)
return addon.preferences if addon else None
@staticmethod
def get_addon_preferences_attribute(context: 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: Context,
window: Optional[Window] = None,
area: Optional[Area] = None,
region: Optional[Region] = None,
active_object: Optional[Object] = None,
selected_objects: Optional[List[Object]] = None,
**keywords: Any,
) -> Generator[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)
+6
View File
@@ -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.
+655
View File
@@ -0,0 +1,655 @@
# -*- 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 math
from typing import TYPE_CHECKING, Iterable, Optional, Set, List, Dict, Tuple, Any, Union, cast
import bpy
from mathutils import Vector
from bpy.types import Object, EditBone, PoseBone, Constraint, Armature, BoneCollection
from .. import bpyutils
from ..bpyutils import TransformConstraintOp
from ..utils import ItemOp
from ....core.logging_setup import logger
if TYPE_CHECKING:
from ..properties.root import MMDRoot, MMDDisplayItemFrame
from ..properties.pose_bone import MMDBone
def remove_constraint(constraints: Any, name: str) -> bool:
"""Remove a constraint by name if it exists"""
c = constraints.get(name, None)
if c:
constraints.remove(c)
return True
return False
def remove_edit_bones(edit_bones: bpy.types.ArmatureEditBones, bone_names: List[str]) -> None:
"""Remove edit bones by name"""
for name in bone_names:
b = edit_bones.get(name, None)
if b:
edit_bones.remove(b)
BONE_COLLECTION_CUSTOM_PROPERTY_NAME = "mmd_tools"
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: Tuple[str, ...] = ("左肩", "左腕", "左ひじ", "左手首", "右腕", "右肩", "右ひじ", "右手首")
AUTO_LOCAL_AXIS_FINGERS: Tuple[str, ...] = ("親指", "人指", "中指", "薬指", "小指")
AUTO_LOCAL_AXIS_SEMI_STANDARD_ARMS: Tuple[str, ...] = ("左腕捩", "左手捩", "左肩P", "左ダミー", "右腕捩", "右手捩", "右肩P", "右ダミー")
def __init__(self) -> None:
raise NotImplementedError("This class cannot be instantiated.")
@staticmethod
def find_pose_bone_by_bone_id(armature_object: Object, bone_id: int) -> Optional[PoseBone]:
"""Find a pose bone by its bone ID"""
for bone in armature_object.pose.bones:
if bone.mmd_bone.bone_id != bone_id:
continue
return bone
logger.debug(f"Bone with ID {bone_id} not found in armature {armature_object.name}")
return None
@staticmethod
def __new_bone_id(armature_object: Object) -> int:
"""Generate a new unique bone ID"""
return max(b.mmd_bone.bone_id for b in armature_object.pose.bones) + 1
@staticmethod
def get_or_assign_bone_id(pose_bone: PoseBone) -> int:
"""Get the bone ID or assign a new one if not set"""
if pose_bone.mmd_bone.bone_id < 0:
pose_bone.mmd_bone.bone_id = FnBone.__new_bone_id(pose_bone.id_data)
logger.debug(f"Assigned new bone ID {pose_bone.mmd_bone.bone_id} to bone {pose_bone.name}")
return pose_bone.mmd_bone.bone_id
@staticmethod
def __get_selected_pose_bones(armature_object: Object) -> Iterable[PoseBone]:
"""Get selected pose bones from the armature"""
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: Object, enable: bool = True) -> None:
"""Load fixed axis settings for selected bones"""
logger.debug(f"Loading bone fixed axis (enable={enable}) for {armature_object.name}")
for b in FnBone.__get_selected_pose_bones(armature_object):
mmd_bone = 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: Object) -> Object:
"""Set up special bone collections for MMD"""
armature = cast(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)
logger.debug(f"Created special bone collection: {bone_collection_name}")
return armature_object
@staticmethod
def __is_mmd_tools_bone_collection(bone_collection: BoneCollection) -> bool:
"""Check if a bone collection is an MMD Tools collection"""
return BONE_COLLECTION_CUSTOM_PROPERTY_NAME in bone_collection
@staticmethod
def __is_special_bone_collection(bone_collection: BoneCollection) -> bool:
"""Check if a bone collection is a special MMD collection"""
return BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_SPECIAL == bone_collection.get(BONE_COLLECTION_CUSTOM_PROPERTY_NAME)
@staticmethod
def __set_bone_collection_to_special(bone_collection: BoneCollection, is_visible: bool) -> None:
"""Mark a bone collection as special"""
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: BoneCollection) -> bool:
"""Check if a bone collection is a normal MMD collection"""
return BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_NORMAL == bone_collection.get(BONE_COLLECTION_CUSTOM_PROPERTY_NAME)
@staticmethod
def __set_bone_collection_to_normal(bone_collection: BoneCollection) -> None:
"""Mark a bone collection as normal"""
bone_collection[BONE_COLLECTION_CUSTOM_PROPERTY_NAME] = BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_NORMAL
@staticmethod
def __set_edit_bone_to_special(edit_bone: EditBone, bone_collection_name: str) -> EditBone:
"""Set an edit bone to a special collection"""
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: EditBone) -> EditBone:
"""Set an edit bone as a dummy bone"""
logger.debug(f"Setting bone {edit_bone.name} as dummy bone")
return FnBone.__set_edit_bone_to_special(edit_bone, BONE_COLLECTION_NAME_DUMMY)
@staticmethod
def set_edit_bone_to_shadow(edit_bone: EditBone) -> EditBone:
"""Set an edit bone as a shadow bone"""
logger.debug(f"Setting bone {edit_bone.name} as shadow bone")
return FnBone.__set_edit_bone_to_special(edit_bone, BONE_COLLECTION_NAME_SHADOW)
@staticmethod
def __unassign_mmd_tools_bone_collections(edit_bone: EditBone) -> EditBone:
"""Unassign an edit bone from all MMD Tools collections"""
for bone_collection in edit_bone.collections:
if not FnBone.__is_mmd_tools_bone_collection(bone_collection):
continue
bone_collection.unassign(edit_bone)
return edit_bone
@staticmethod
def sync_bone_collections_from_display_item_frames(armature_object: Object) -> None:
"""Synchronize bone collections from display item frames"""
logger.info(f"Syncing bone collections from display item frames for {armature_object.name}")
armature = cast(Armature, armature_object.data)
bone_collections = armature.collections
from .model import FnModel
root_object = FnModel.find_root_object(armature_object)
if not root_object:
logger.error(f"No root object found for armature {armature_object.name}")
return
mmd_root = root_object.mmd_root
bones = armature.bones
used_groups: Set[str] = set()
unassigned_bone_names: Set[str] = {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)
logger.debug(f"Created new bone collection: {group_name}")
bone_collection.assign(bones[item.name])
for name in unassigned_bone_names:
for bc in bones[name].collections:
if not FnBone.__is_mmd_tools_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_bone_collection(bone_collection):
continue
if not FnBone.__is_normal_bone_collection(bone_collection):
continue
logger.debug(f"Removing unused bone collection: {bone_collection.name}")
bone_collections.remove(bone_collection)
@staticmethod
def sync_display_item_frames_from_bone_collections(armature_object: Object) -> None:
"""Synchronize display item frames from bone collections"""
logger.info(f"Syncing display item frames from bone collections for {armature_object.name}")
armature = cast(Armature, armature_object.data)
bone_collections = armature.collections
from .model import FnModel
root_object = FnModel.find_root_object(armature_object)
if not root_object:
logger.error(f"No root object found for armature {armature_object.name}")
return
mmd_root = root_object.mmd_root
display_item_frames = mmd_root.display_item_frames
used_frame_index: Set[int] = set()
bone_collection: 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 = 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
logger.debug(f"Created new display item frame: {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):
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()
logger.debug(f"Cleared special display item frame: {display_item_frame.name}")
else:
logger.debug(f"Removing unused display item frame: {display_item_frames[i].name}")
display_item_frames.remove(i)
mmd_root.active_display_item_frame = 0
@staticmethod
def apply_bone_fixed_axis(armature_object: Object) -> None:
"""Apply fixed axis to bones"""
logger.info(f"Applying bone fixed axis for {armature_object.name}")
bone_map: Dict[str, Tuple[Vector, bool, bool]] = {}
for b in armature_object.pose.bones:
if b.is_mmd_shadow_bone or not b.mmd_bone.enabled_fixed_axis:
continue
mmd_bone = 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: 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
logger.debug(f"Applied fixed axis to bone: {bone.name}")
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: Object, enable: bool = True) -> None:
"""Load local axes for selected bones"""
logger.debug(f"Loading bone local axes (enable={enable}) for {armature_object.name}")
for b in FnBone.__get_selected_pose_bones(armature_object):
mmd_bone = 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: Object) -> None:
"""Apply local axes to bones"""
logger.info(f"Applying bone local axes for {armature_object.name}")
bone_map: Dict[str, Tuple[Vector, Vector]] = {}
for b in armature_object.pose.bones:
if b.is_mmd_shadow_bone or not b.mmd_bone.enabled_local_axes:
continue
mmd_bone = 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: 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
logger.debug(f"Applied local axes to bone: {bone.name}")
@staticmethod
def update_bone_roll(edit_bone: EditBone, mmd_local_axis_x: Vector, mmd_local_axis_z: Vector) -> None:
"""Update bone roll based on local axes"""
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: Vector, mmd_local_axis_z: Vector) -> Tuple[Vector, Vector, Vector]:
"""Get axes from local axis vectors"""
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: Object) -> None:
"""Apply automatic bone roll to appropriate bones"""
logger.info(f"Applying auto bone roll for {armature.name}")
bone_names: List[str] = []
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):
bone_names.append(b.name)
with bpyutils.edit_object(armature) as data:
bone: EditBone
for bone in data.edit_bones:
if bone.name not in bone_names:
continue
FnBone.update_auto_bone_roll(bone)
bone.select = True
logger.debug(f"Applied auto bone roll to bone: {bone.name}")
@staticmethod
def update_auto_bone_roll(edit_bone: EditBone) -> None:
"""Update bone roll automatically"""
# 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: str) -> bool:
"""Check if a bone should have automatic local axis"""
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: Object) -> None:
"""Clean additional transformation constraints and bones"""
logger.info(f"Cleaning additional transformations for {armature_object.name}")
# clean constraints
p_bone: 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: PoseBone) -> bool:
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:
logger.debug(f"Removing {len(shadow_bone_names)} shadow bones")
with bpyutils.edit_object(armature_object) as data:
remove_edit_bones(data.edit_bones, shadow_bone_names)
@staticmethod
def apply_additional_transformation(armature_object: Object) -> None:
"""Apply additional transformation to bones"""
logger.info(f"Applying additional transformations for {armature_object.name}")
def __is_dirty_bone(b: PoseBone) -> bool:
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)]
logger.debug(f"Found {len(dirty_bones)} dirty bones to process")
# setup constraints
shadow_bone_pool: List[Union[_AT_ShadowBoneRemove, _AT_ShadowBoneCreate]] = []
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: PoseBone) -> Optional[Union['_AT_ShadowBoneRemove', '_AT_ShadowBoneCreate']]:
"""Set up constraints for additional transformation"""
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:
logger.debug(f"Removing additional transform constraints for bone: {bone_name}")
return _AT_ShadowBoneRemove(bone_name)
return None
logger.debug(f"Setting up additional transform for bone: {bone_name} targeting {target_bone}")
shadow_bone = _AT_ShadowBoneCreate(bone_name, target_bone)
def __config(name: str, mute: bool, map_type: str, value: float) -> None:
if mute:
remove_constraint(constraints, name)
return
c = TransformConstraintOp.create(constraints, name, map_type)
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: PoseBone) -> None:
"""Update the influence of additional transform constraints"""
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)
logger.debug(f"Updated additional transform influence for bone: {pose_bone.name} to {influence}")
class MigrationFnBone:
"""Migration Functions for old MMD models broken by bugs or issues"""
@staticmethod
def fix_mmd_ik_limit_override(armature_object: Object) -> None:
"""Fix IK limit override constraints in old MMD models"""
logger.info(f"Fixing MMD IK limit overrides for {armature_object.name}")
pose_bone: PoseBone
for pose_bone in armature_object.pose.bones:
constraint: Constraint
for constraint in pose_bone.constraints:
if constraint.type == "LIMIT_ROTATION" and "mmd_ik_limit_override" in constraint.name:
constraint.owner_space = "LOCAL"
logger.debug(f"Fixed IK limit override for bone: {pose_bone.name}")
class _AT_ShadowBoneRemove:
"""Handler for removing shadow bones"""
def __init__(self, bone_name: str) -> None:
"""Initialize with bone name"""
self.__shadow_bone_names = ("_dummy_" + bone_name, "_shadow_" + bone_name)
def update_edit_bones(self, edit_bones: bpy.types.ArmatureEditBones) -> None:
"""Update edit bones by removing shadow bones"""
remove_edit_bones(edit_bones, self.__shadow_bone_names)
logger.debug(f"Removed shadow bones: {self.__shadow_bone_names}")
def update_pose_bones(self, pose_bones: Any) -> None:
"""Update pose bones (no-op for removal)"""
pass
class _AT_ShadowBoneCreate:
"""Handler for creating shadow bones"""
def __init__(self, bone_name: str, target_bone_name: str) -> None:
"""Initialize with bone names"""
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: List[Constraint] = []
def __is_well_aligned(self, bone0: EditBone, bone1: EditBone) -> bool:
"""Check if two bones are well aligned"""
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: bool = True) -> None:
"""Update constraints to use shadow or target bone"""
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: Constraint) -> None:
"""Add a constraint to the pool"""
self.__constraint_pool.append(constraint)
def update_edit_bones(self, edit_bones: bpy.types.ArmatureEditBones) -> None:
"""Update edit bones by creating shadow 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):
logger.debug(f"Bones are well aligned, removing shadow bones for {self.__bone_name}")
_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
logger.debug(f"Created/updated dummy bone: {dummy_bone_name}")
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
logger.debug(f"Created/updated shadow bone: {shadow_bone_name}")
def update_pose_bones(self, pose_bones: Any) -> None:
"""Update pose bones by setting up shadow bone properties"""
if self.__shadow_bone_name not in pose_bones:
logger.debug(f"Shadow bone {self.__shadow_bone_name} not found, using target bone directly")
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"
logger.debug(f"Created copy transforms constraint for shadow bone: {self.__shadow_bone_name}")
self.__update_constraints()
logger.debug(f"Updated constraints for shadow bone: {self.__shadow_bone_name}")
+323
View File
@@ -0,0 +1,323 @@
# -*- 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 math
from typing import Optional, List, Tuple, Callable, Any, Union
import bpy
from bpy.types import Object, ID, Camera, Context
from mathutils import Vector, Matrix, Euler
import traceback
from ..bpyutils import FnContext, Props
from ....core.logging_setup import logger
class FnCamera:
@staticmethod
def find_root(obj: Optional[Object]) -> Optional[Object]:
"""Find the root object of an MMD camera setup."""
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: Object) -> bool:
"""Check if an object is an MMD camera."""
return obj.type == "CAMERA" and FnCamera.find_root(obj.parent) is not None
@staticmethod
def is_mmd_camera_root(obj: Object) -> bool:
"""Check if an object is an MMD camera root."""
return obj.type == "EMPTY" and obj.mmd_type == "CAMERA"
@staticmethod
def add_drivers(camera_object: Object) -> None:
"""Add drivers to the camera object for MMD camera functionality."""
logger.debug(f"Adding drivers to camera: {camera_object.name}")
def __add_driver(id_data: ID, data_path: str, expression: str, index: int = -1) -> None:
"""Add a driver to the specified ID data."""
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:
v = d.variables.new()
v.name = "sensor_height"
v.type = "SINGLE_PROP"
v.targets[0].id_type = "CAMERA"
v.targets[0].id = camera_object.data
v.targets[0].data_path = "sensor_height"
expression = expression.replace("$sensor_height", v.name)
d.expression = expression
try:
__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")
logger.debug(f"Successfully added drivers to camera: {camera_object.name}")
except Exception:
logger.error(f"Failed to add drivers to camera {camera_object.name}: {traceback.format_exc()}")
@staticmethod
def remove_drivers(camera_object: Object) -> None:
"""Remove drivers from the camera object."""
logger.debug(f"Removing drivers from camera: {camera_object.name}")
try:
camera_object.data.driver_remove("ortho_scale")
camera_object.driver_remove("rotation_euler")
camera_object.data.driver_remove("ortho_scale")
camera_object.data.driver_remove("lens")
logger.debug(f"Successfully removed drivers from camera: {camera_object.name}")
except Exception:
logger.error(f"Failed to remove drivers from camera {camera_object.name}: {traceback.format_exc()}")
class MigrationFnCamera:
@staticmethod
def update_mmd_camera() -> None:
"""Update all MMD cameras in the scene."""
logger.info("Updating all MMD cameras in the scene")
updated_count = 0
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
try:
FnCamera.remove_drivers(camera_object)
FnCamera.add_drivers(camera_object)
updated_count += 1
except Exception:
logger.error(f"Failed to update MMD camera {camera_object.name}: {traceback.format_exc()}")
logger.info(f"Updated {updated_count} MMD cameras")
class MMDCamera:
def __init__(self, obj: Object):
"""Initialize an MMD camera."""
root_object = FnCamera.find_root(obj)
if root_object is None:
logger.error(f"Object {obj.name} is not an MMD camera")
raise ValueError(f"{obj.name} is not an MMD camera")
self.__emptyObj = getattr(root_object, "original", obj)
logger.debug(f"Initialized MMD camera with root: {self.__emptyObj.name}")
@staticmethod
def isMMDCamera(obj: Object) -> bool:
"""Check if an object is an MMD camera."""
return FnCamera.find_root(obj) is not None
@staticmethod
def addDrivers(cameraObj: Object) -> None:
"""Add drivers to the camera object."""
FnCamera.add_drivers(cameraObj)
@staticmethod
def removeDrivers(cameraObj: Object) -> None:
"""Remove drivers from the camera object. """
if cameraObj.type != "CAMERA":
return
FnCamera.remove_drivers(cameraObj)
@staticmethod
def convertToMMDCamera(cameraObj: Object, scale: float = 1.0) -> 'MMDCamera':
"""Convert a camera to an MMD camera."""
logger.info(f"Converting camera {cameraObj.name} to MMD camera with scale {scale}")
if FnCamera.is_mmd_camera(cameraObj):
logger.debug(f"Camera {cameraObj.name} is already an MMD camera")
return MMDCamera(cameraObj)
try:
empty = bpy.data.objects.new(name="MMD_Camera", object_data=None)
context = FnContext.ensure_context()
FnContext.link_object(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
logger.info(f"Successfully converted {cameraObj.name} to MMD camera")
return MMDCamera(empty)
except Exception:
logger.error(f"Failed to convert camera {cameraObj.name} to MMD camera: {traceback.format_exc()}")
raise
@staticmethod
def newMMDCameraAnimation(
cameraObj: Optional[Object],
cameraTarget: Optional[Object] = None,
scale: float = 1.0,
min_distance: float = 0.1
) -> 'MMDCamera':
"""Create a new MMD camera animation."""
logger.info(f"Creating new MMD camera animation with scale {scale}")
try:
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: Optional[Callable[[], Object]] = None
if cameraObj is None:
if scene.camera is None:
scene.camera = mmd_cam
logger.debug("Set scene camera to new MMD camera")
return MMDCamera(mmd_cam_root)
_camera_override_func = lambda: scene.camera
_target_override_func: Optional[Callable[[Object], Object]] = None
if cameraTarget is None:
_target_override_func = lambda camObj: 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)
from math import atan
from mathutils import Matrix, Vector
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 = []
for i in range(3):
fcurves.append(parent_action.fcurves.new(data_path="location", index=i)) # x, y, z
for i in range(3):
fcurves.append(parent_action.fcurves.new(data_path="rotation_euler", index=i)) # 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)
logger.debug(f"Processing {frame_count} frames for camera animation")
for f, x, y, z, rx, ry, rz, fov, persp, dis in zip(frames, *(c.keyframe_points for c in fcurves)):
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 * 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)
logger.info(f"Successfully created MMD camera animation with {frame_count} frames")
return MMDCamera(mmd_cam_root)
except Exception:
logger.error(f"Failed to create MMD camera animation: {traceback.format_exc()}")
raise
def object(self) -> Object:
"""Get the root object of the MMD camera."""
return self.__emptyObj
def camera(self) -> Object:
"""Get the camera object of the MMD camera."""
for i in self.__emptyObj.children:
if i.type == "CAMERA":
return i
logger.error(f"No camera found for MMD camera root {self.__emptyObj.name}")
raise KeyError(f"No camera found for MMD camera root {self.__emptyObj.name}")
+14
View File
@@ -0,0 +1,14 @@
# -*- 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.
class MaterialNotFoundError(KeyError):
"""Exception raised when a material is not found in the scene"""
def __init__(self, *args: object) -> None:
"""Constructor for MaterialNotFoundError"""
super().__init__(*args)
+86
View File
@@ -0,0 +1,86 @@
# -*- 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, Union, Any, List, Tuple
from bpy.types import Object, Context
from ..bpyutils import FnContext, Props
from ....core.logging_setup import logger
class MMDLamp:
def __init__(self, obj: Object) -> None:
if MMDLamp.isLamp(obj):
obj = obj.parent
if obj and obj.type == "EMPTY" and obj.mmd_type == "LIGHT":
self.__emptyObj: Object = obj
else:
error_msg = f"{str(obj)} is not MMDLamp"
logger.error(error_msg)
raise ValueError(error_msg)
@staticmethod
def isLamp(obj: Optional[Object]) -> bool:
"""Check if the object is a lamp/light object"""
return obj is not None and obj.type in {"LIGHT", "LAMP"}
@staticmethod
def isMMDLamp(obj: Optional[Object]) -> bool:
"""Check if the object is an MMD lamp"""
if MMDLamp.isLamp(obj):
obj = obj.parent
return obj is not None and obj.type == "EMPTY" and obj.mmd_type == "LIGHT"
@staticmethod
def convertToMMDLamp(lampObj: Object, scale: float = 1.0) -> 'MMDLamp':
"""Convert a regular lamp to an MMD lamp"""
if MMDLamp.isMMDLamp(lampObj):
logger.debug(f"Object {lampObj.name} is already an MMD lamp")
return MMDLamp(lampObj)
logger.info(f"Converting {lampObj.name} to MMD lamp with scale {scale}")
empty: Object = bpy.data.objects.new(name="MMD_Light", object_data=None)
context = FnContext.ensure_context()
FnContext.link_object(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"
logger.debug(f"Successfully created MMD lamp from {lampObj.name}")
return MMDLamp(empty)
def object(self) -> Object:
"""Get the empty object that represents this MMD lamp"""
return self.__emptyObj
def lamp(self) -> Object:
"""Get the actual lamp/light object"""
for i in self.__emptyObj.children:
if MMDLamp.isLamp(i):
return i
error_msg = f"No lamp found in MMD lamp {self.__emptyObj.name}"
logger.error(error_msg)
raise KeyError(error_msg)
+778
View File
@@ -0,0 +1,778 @@
# -*- 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 logging
import os
from typing import TYPE_CHECKING, Callable, Iterable, Optional, Tuple, cast, Dict, List, Any, Union, Set
import bpy
from mathutils import Vector
from ..bpyutils import FnContext
from .exceptions import MaterialNotFoundError
from .shader import _NodeGroupUtils
from ....core.logging_setup import logger
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: bpy.types.Image):
self.type: str = "IMAGE"
self.image: bpy.types.Image = image
self.use_mipmap: bool = True
class _DummyTextureSlot:
def __init__(self, image: bpy.types.Image):
self.diffuse_color_factor: float = 1
self.uv_layer: str = ""
self.texture: _DummyTexture = _DummyTexture(image)
class FnMaterial:
__NODES_ARE_READONLY: bool = False
def __init__(self, material: bpy.types.Material):
self.__material: bpy.types.Material = material
self._nodes_are_readonly: bool = FnMaterial.__NODES_ARE_READONLY
@staticmethod
def set_nodes_are_readonly(nodes_are_readonly: bool) -> None:
FnMaterial.__NODES_ARE_READONLY = nodes_are_readonly
@classmethod
def from_material_id(cls, material_id: str) -> Optional['FnMaterial']:
for material in bpy.data.materials:
if material.mmd_material.material_id == material_id:
return cls(material)
return None
@staticmethod
def clean_materials(obj: bpy.types.Object, can_remove: Callable[[bpy.types.Material], bool]) -> None:
materials = obj.data.materials
materials_pop = materials.pop
removed_count = 0
for i in sorted((x for x, m in enumerate(materials) if can_remove(m)), reverse=True):
m = materials_pop(index=i)
removed_count += 1
if m.users < 1:
bpy.data.materials.remove(m)
if removed_count > 0:
logger.debug(f"Removed {removed_count} materials from {obj.name}")
@staticmethod
def swap_materials(mesh_object: bpy.types.Object, mat1_ref: Union[str, int], mat2_ref: Union[str, int], reverse: bool = False, swap_slots: bool = False) -> Tuple[bpy.types.Material, bpy.types.Material]:
"""
This method will 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)
logger.debug(f"Swapping materials: {mat1.name} (idx:{mat1_idx}) <-> {mat2.name} (idx:{mat2_idx}) in {mesh_object.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]) -> None:
"""
This method will fix the material order. Which is lost after joining meshes.
"""
materials = cast(bpy.types.Mesh, meshObj.data).materials
logger.debug(f"Fixing material order for {meshObj.name}")
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
logger.debug(f"Moving material {mat} to index {new_idx}")
FnMaterial.swap_materials(meshObj, mat, new_idx, reverse=True, swap_slots=True)
@property
def material_id(self) -> int:
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
logger.debug(f"Assigned new material ID {mmd_mat.material_id} to {self.__material.name}")
return mmd_mat.material_id
@property
def material(self) -> bpy.types.Material:
return self.__material
def __same_image_file(self, image: Optional[bpy.types.Image], filepath: str) -> bool:
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:
pass
return False
def _load_image(self, filepath: str) -> bpy.types.Image:
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:
logger.debug(f"Loading image: {filepath}")
img = bpy.data.images.load(filepath)
except:
logger.warning(f"Cannot create a texture for {filepath}. No such file.")
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) -> None:
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))
logger.debug(f"Using shared toon texture: {toon_path}")
self.create_toon_texture(bpy.path.resolve_ncase(path=toon_path))
elif mmd_mat.toon_texture != "":
logger.debug(f"Using custom toon texture: {mmd_mat.toon_texture}")
self.create_toon_texture(mmd_mat.toon_texture)
else:
logger.debug(f"Removing toon texture from {self.__material.name}")
self.remove_toon_texture()
def _mix_diffuse_and_ambient(self, mmd_mat: 'MMDMaterial') -> List[float]:
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) -> None:
pass
def update_enabled_toon_edge(self) -> None:
if self._nodes_are_readonly:
return
self.update_edge_color()
def update_edge_color(self) -> None:
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: Optional[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
logger.debug(f"Updated edge color for {mat.name}")
def update_edge_weight(self) -> None:
pass
def get_texture(self) -> Optional[_DummyTexture]:
return self.__get_texture_node("mmd_base_tex", use_dummy=True)
def create_texture(self, filepath: str) -> _DummyTextureSlot:
texture = self.__create_texture_node("mmd_base_tex", filepath, (-4, -1))
logger.debug(f"Created base texture for {self.__material.name}: {filepath}")
return _DummyTextureSlot(texture.image)
def remove_texture(self) -> None:
if self._nodes_are_readonly:
return
logger.debug(f"Removing base texture from {self.__material.name}")
self.__remove_texture_node("mmd_base_tex")
def get_sphere_texture(self) -> Optional[_DummyTexture]:
return self.__get_texture_node("mmd_sphere_tex", use_dummy=True)
def use_sphere_texture(self, use_sphere: bool, obj: Optional[bpy.types.Object] = None) -> None:
if self._nodes_are_readonly:
return
if use_sphere:
logger.debug(f"Enabling sphere texture for {self.__material.name}")
self.update_sphere_texture_type(obj)
else:
logger.debug(f"Disabling sphere texture for {self.__material.name}")
self.__update_shader_input("Sphere Tex Fac", 0)
def create_sphere_texture(self, filepath: str, obj: Optional[bpy.types.Object] = None) -> _DummyTextureSlot:
texture = self.__create_texture_node("mmd_sphere_tex", filepath, (-2, -2))
logger.debug(f"Created sphere texture for {self.__material.name}: {filepath}")
self.update_sphere_texture_type(obj)
return _DummyTextureSlot(texture.image)
def update_sphere_texture_type(self, obj: Optional[bpy.types.Object] = None) -> 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 = (l for l in obj.data.uv_layers if not l.name.startswith("_"))
next(uv_layers, None) # skip base UV
subtex_uv = getattr(next(uv_layers, None), "name", "")
if subtex_uv != "UV1":
logger.info(f'Material({mat.name}): object "{obj.name}" use UV "{subtex_uv}" for SubTex')
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"])
logger.debug(f"Updated sphere texture type for {self.material.name}: {sphere_texture_type}")
def remove_sphere_texture(self) -> None:
if self._nodes_are_readonly:
return
logger.debug(f"Removing sphere texture from {self.__material.name}")
self.__remove_texture_node("mmd_sphere_tex")
def get_toon_texture(self) -> Optional[_DummyTexture]:
return self.__get_texture_node("mmd_toon_tex", use_dummy=True)
def use_toon_texture(self, use_toon: bool) -> None:
if self._nodes_are_readonly:
return
logger.debug(f"{'Enabling' if use_toon else 'Disabling'} toon texture for {self.__material.name}")
self.__update_shader_input("Toon Tex Fac", use_toon)
def create_toon_texture(self, filepath: str) -> _DummyTextureSlot:
texture = self.__create_texture_node("mmd_toon_tex", filepath, (-3, -1.5))
logger.debug(f"Created toon texture for {self.__material.name}: {filepath}")
return _DummyTextureSlot(texture.image)
def remove_toon_texture(self) -> None:
if self._nodes_are_readonly:
return
logger.debug(f"Removing toon texture from {self.__material.name}")
self.__remove_texture_node("mmd_toon_tex")
def __get_texture_node(self, node_name: str, use_dummy: bool = False) -> Optional[Union[bpy.types.ShaderNodeTexImage, _DummyTexture]]:
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: str) -> None:
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: str, filepath: str, pos: Tuple[float, float]) -> bpy.types.ShaderNodeTexImage:
texture = self.__get_texture_node(node_name)
if texture is None:
from mathutils import Vector
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) -> None:
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,))
logger.debug(f"Updated ambient color for {mat.name}")
def update_diffuse_color(self) -> None:
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,))
logger.debug(f"Updated diffuse color for {mat.name}")
def update_alpha(self) -> None:
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()
logger.debug(f"Updated alpha for {mat.name}: {mmd_mat.alpha}")
def update_specular_color(self) -> None:
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,))
logger.debug(f"Updated specular color for {mat.name}")
def update_shininess(self) -> None:
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 = pow(1 - mat.roughness, 2.7)
if hasattr(mat, "specular_hardness"):
mat.specular_hardness = mmd_mat.shininess
self.__update_shader_input("Reflect", mmd_mat.shininess)
logger.debug(f"Updated shininess for {mat.name}: {mmd_mat.shininess}")
def update_is_double_sided(self) -> None:
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)
logger.debug(f"Updated double-sided setting for {mat.name}: {mmd_mat.is_double_sided}")
def update_self_shadow_map(self) -> None:
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"
logger.debug(f"Updated self shadow map for {mat.name}: {cast_shadows}")
def update_self_shadow(self) -> None:
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)
logger.debug(f"Updated self shadow for {mat.name}: {mmd_mat.enabled_self_shadow}")
@staticmethod
def convert_to_mmd_material(material: bpy.types.Material, context: bpy.types.Context = bpy.context) -> None:
m, mmd_material = material, material.mmd_material
logger.debug(f"Converting material to MMD material: {material.name}")
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) -> Optional[bpy.types.ShaderNodeTexImage]:
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_NEXT": "EEVEE",
}.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:
logger.debug(f"Found texture node for {material.name}: {tex_node.name}")
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:
logger.debug(f"Using BSDF base color for {material.name}")
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:
logger.debug(f"Removing BSDF node from {material.name}: {n.name}")
m.node_tree.nodes.remove(n)
def __update_shader_input(self, name: str, val: Any) -> None:
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) -> None:
mat = self.material
if mat.node_tree is None:
logger.debug(f"Creating node tree for {mat.name}")
mat.use_nodes = True
mat.node_tree.nodes.clear()
nodes, links = mat.node_tree.nodes, mat.node_tree.links
class _Dummy:
default_value: Any = None
is_linked: bool = True
node_shader = nodes.get("mmd_shader", None)
if node_shader is None:
logger.debug(f"Creating MMD shader node for {mat.name}")
node_shader: bpy.types.ShaderNodeGroup = nodes.new("ShaderNodeGroup")
node_shader.name = "mmd_shader"
node_shader.location = (0, 1500)
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:
logger.debug(f"Creating MMD UV node for {mat.name}")
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("mmd_%s_tex" % name_id.lower())
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) -> bpy.types.ShaderNodeTree:
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
logger.debug(f"Creating MMD UV shader node group")
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) -> bpy.types.ShaderNodeTree:
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
logger.debug(f"Creating MMD shader node group")
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() -> None:
mmd_shader_node_tree: Optional[bpy.types.NodeTree] = bpy.data.node_groups.get("MMDShaderDev")
if mmd_shader_node_tree is None:
logger.debug("No MMD shader node tree found, skipping update")
return
ng = _NodeGroupUtils(mmd_shader_node_tree)
if "Color" in ng.node_output.inputs:
logger.debug("MMD shader already has Color output, skipping update")
return
logger.info("Updating MMD shader node tree")
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"])
logger.info("MMD shader node tree updated successfully")
# Add Self Shadow input if it doesn't exist
if "Self Shadow" not in ng.node_input.outputs:
logger.info("Adding Self Shadow input to MMD shader")
# Find shader_base_mix node to connect Self Shadow
shader_base_mix = shader_alpha_mix.inputs[2].links[0].from_node
ng.new_input_socket("Self Shadow", shader_base_mix.inputs["Fac"], 0, min_max=(0, 1))
File diff suppressed because it is too large Load Diff
+800
View File
@@ -0,0 +1,800 @@
# -*- 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 re
from typing import TYPE_CHECKING, Tuple, cast, List, Dict, Optional, Set, Any, Union, Iterator
import bpy
import numpy as np
from bpy.types import Object, ShapeKey, Material, Mesh, Armature, PoseBone, Constraint
from .. import bpyutils, utils
from ..bpyutils import FnContext, FnObject, TransformConstraintOp
from ....core.logging_setup import logger
if TYPE_CHECKING:
from .model import Model
class FnMorph:
def __init__(self, morph: Any, model: "Model"):
self.__morph = morph
self.__rig = model
@classmethod
def storeShapeKeyOrder(cls, obj: Object, shape_key_names: List[str]) -> None:
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: bpy.types.bpy_prop_collection, name: str) -> None:
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: Object, shape_key_names: List[str]) -> None:
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: "Model") -> "_MorphSlider":
return _MorphSlider(rig)
@staticmethod
def category_guess(morph: Any) -> None:
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: "Model") -> None:
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: Object, shape_key_name: str) -> None:
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: Object, src_name: str, dest_name: str) -> None:
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: Object, morph_name: Optional[str] = None, offset_axes: str = "XYZW") -> Iterator[Tuple[bpy.types.VertexGroup, str, str]]:
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: Object, src_name: str, dest_name: str) -> None:
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: Object) -> None:
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('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.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: Object) -> None:
# remove empty vertex groups of uv morphs
vg_indices: Set[int] = {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: Object, morph: Any) -> Dict[int, List[float]]:
offset_map: Dict[int, List[float]] = {} # 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)]
else:
offset_map[i] = val.offset
return offset_map
@staticmethod
def store_uv_morph_data(obj: Object, morph: Any, offsets: Optional[List[Any]] = None, offset_axes: str = "XYZW") -> None:
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"):
if abs(val) > 1e-4:
vg_name = "UV_{0}{1}{2}".format(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: Optional[Object] = None) -> 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: Object) -> None:
"""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(l: Any, r: Any) -> bool:
return (
l.related_mesh_data == r.related_mesh_data
and l.offset_type == r.offset_type
and l.material == r.material
and all(a == b for a, b in zip(l.diffuse_color, r.diffuse_color))
and all(a == b for a, b in zip(l.specular_color, r.specular_color))
and l.shininess == r.shininess
and all(a == b for a, b in zip(l.ambient_color, r.ambient_color))
and all(a == b for a, b in zip(l.edge_color, r.edge_color))
and l.edge_weight == r.edge_weight
and all(a == b for a, b in zip(l.texture_factor, r.texture_factor))
and all(a == b for a, b in zip(l.sphere_texture_factor, r.sphere_texture_factor))
and all(a == b for a, b in zip(l.toon_texture_factor, r.toon_texture_factor))
)
def morph_equals(l: Any, r: Any) -> bool:
return len(l.data) == len(r.data) and all(morph_data_equals(a, b) for a, b in zip(l.data, r.data))
# 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: bool = False, binded: bool = False) -> Optional[Object]:
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) -> Optional[Object]:
obj = self.placeholder()
return self.__dummy_armature(obj) if obj else None
def __dummy_armature(self, obj: Object, create: bool = False) -> Optional[Object]:
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: str) -> Optional[ShapeKey]:
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) -> Object:
self.__rig.loadMorphs()
obj = self.placeholder(create=True)
self.__load(obj, self.__rig.rootObject().mmd_root)
return obj
def __load(self, obj: Object, mmd_root: Any) -> None:
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: Any, path: str, index: int = -1) -> Tuple[Any, Any]:
d = id_data.driver_add(path, index)
variables = d.driver.variables
for x in variables:
variables.remove(x)
return d.driver, variables
@staticmethod
def __add_single_prop(variables: Any, id_obj: Object, data_path: str, prefix: str) -> Any:
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: ShapeKey, resolve_path: bool = False) -> bool:
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: Optional[Dict[str, Any]] = None) -> None:
from math import ceil, floor
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[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, floor(ms.value)), max(ms.slider_max, 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, floor(kb.value)), max(ms.slider_max, ceil(kb.value))
for m in 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 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) -> None:
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) -> None:
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: Dict[Tuple[str, str], List[List[Any]]] = {}
shape_key_map: Dict[str, List[Tuple[ShapeKey, str, List[Any]]]] = {}
uv_morph_map: Dict[str, List[Tuple[str, str, str, List[Any]]]] = {}
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: List[Any] = []
shape_key_map.setdefault(name_bind, []).append((kb_bind, data_path, groups))
group_map.setdefault(("vertex_morphs", kb_name), []).append(groups)
uv_layers = [l.name for l in mesh_object.data.uv_layers if not l.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: Dict[str, Tuple[str, Any, str, str, List[Any]]] = {}
with bpyutils.edit_object(arm) as data:
from .bone import FnBone
edit_bones = data.edit_bones
def __get_bone(name: str, parent: Optional[bpy.types.EditBone]) -> bpy.types.EditBone:
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: List[Any] = []
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: List[Any] = []
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: Set[str] = set(bone_offset_map.keys()) | set(uv_morph_map.keys())
used_bone_names.add(ctrl_base.name)
for b in edit_bones: # cleanup
if b.name.startswith("mmd_bind") and b.name not in used_bone_names:
edit_bones.remove(b)
material_offset_map: Dict[str, Any] = {}
for m in mmd_root.material_morphs:
morph_name = m.name.replace('"', '\\"')
data_path = f'data.shape_keys.key_blocks["{morph_name}"].value'
groups: List[Any] = []
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: Any, expression: str, groups: List[Any]) -> str:
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 l in shape_key_map.values() for i in l):
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: bpy.types.ArmatureConstraints, map_type: str, attributes: Set[str], val: float, val_str: str) -> None:
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})"
from math import pi
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, 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 l in uv_morph_map.values() for i in l):
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: Material, morph_list: List[Tuple[str, Any, str]]) -> None:
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):
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 be 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() -> None:
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, 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
else:
# Compat case. The new version mmd_tools saved. And old version mmd_tools edit. Then new version mmd_tools 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 != -1:
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() -> None:
mat_ids_set: Set[int] = set()
# The reference library properties cannot be modified and bypassed in advance.
need_update_mat: List[Material] = []
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() -> None:
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
+313
View File
@@ -0,0 +1,313 @@
# -*- 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 typing import List, Optional, Tuple, Union, Dict, Any, Set, cast
import bpy
from mathutils import Euler, Vector, Matrix
from ..bpyutils import FnContext, Props
from ....core.logging_setup import logger
SHAPE_SPHERE = 0
SHAPE_BOX = 1
SHAPE_CAPSULE = 2
MODE_STATIC = 0
MODE_DYNAMIC = 1
MODE_DYNAMIC_BONE = 2
def shapeType(collision_shape: str) -> int:
"""Convert collision shape name to type index"""
return ("SPHERE", "BOX", "CAPSULE").index(collision_shape)
def collisionShape(shape_type: int) -> str:
"""Convert shape type index to collision shape name"""
return ("SPHERE", "BOX", "CAPSULE")[shape_type]
def setRigidBodyWorldEnabled(enable: bool) -> bool:
"""Enable or disable the rigid body world and return previous state"""
if bpy.ops.rigidbody.world_add.poll():
logger.debug("Creating rigid body world")
bpy.ops.rigidbody.world_add()
rigidbody_world = bpy.context.scene.rigidbody_world
enabled = rigidbody_world.enabled
rigidbody_world.enabled = enable
logger.debug(f"Rigid body world enabled: {enable} (was: {enabled})")
return enabled
class RigidBodyMaterial:
COLORS: List[int] = [
0x7FDDD4,
0xF0E68C,
0xEE82EE,
0xFFE4E1,
0x8FEEEE,
0xADFF2F,
0xFA8072,
0x9370DB,
0x40E0D0,
0x96514D,
0x5A964E,
0xE6BFAB,
0xD3381C,
0x165E83,
0x701682,
0x828216,
]
@classmethod
def getMaterial(cls, number: int) -> bpy.types.Material:
"""Get or create a material for rigid bodies with the specified number"""
number = int(number)
material_name = f"mmd_tools_rigid_{number}"
if material_name not in bpy.data.materials:
logger.debug(f"Creating rigid body material: {material_name}")
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]:
"""Create multiple rigid body objects parented to the specified object"""
if count < 1:
return []
logger.debug(f"Creating {count} rigid body objects parented to {parent_object.name}")
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:
"""Create a new rigid body object parented to the specified object"""
logger.debug(f"Creating new rigid body object parented to {parent_object.name}")
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: int,
location: Vector,
rotation: Euler,
size: Vector,
dynamics_type: int,
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:
"""Set up a rigid body object with the specified parameters"""
logger.debug(f"Setting up rigid body object: {obj.name}")
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) -> Tuple[float, float, float]:
"""Get the size of a rigid body object based on its shape type"""
assert obj.mmd_type == "RIGID_BODY"
x0, y0, z0 = obj.bound_box[0]
x1, y1, z1 = obj.bound_box[6]
assert x1 >= x0 and y1 >= y0 and z1 >= z0
shape = obj.mmd_rigid.shape
if shape == "SPHERE":
radius = (z1 - z0) / 2
return (radius, 0.0, 0.0)
elif shape == "BOX":
x, y, z = (x1 - x0) / 2, (y1 - y0) / 2, (z1 - z0) / 2
return (x, y, z)
elif shape == "CAPSULE":
diameter = x1 - x0
radius = diameter / 2
height = abs((z1 - z0) - diameter)
return (radius, height, 0.0)
else:
error_msg = f"Invalid shape type: {shape}"
logger.error(error_msg)
raise ValueError(error_msg)
@staticmethod
def new_joint_object(context: bpy.types.Context, parent_object: bpy.types.Object, empty_display_size: float) -> bpy.types.Object:
"""Create a new joint object parented to the specified object"""
logger.debug(f"Creating new joint object parented to {parent_object.name}")
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]:
"""Create multiple joint objects parented to the specified object"""
if count < 1:
return []
logger.debug(f"Creating {count} joint objects parented to {parent_object.name}")
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:
"""Set up a joint object with the specified parameters"""
logger.debug(f"Setting up joint object: {obj.name} with name {name}")
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
+360
View File
@@ -0,0 +1,360 @@
# -*- 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 logging
import time
from typing import Dict, List, Tuple, Set, Optional, Any, Union, cast, TypeVar, Callable
import bpy
import numpy as np
from mathutils import Matrix, Vector, Quaternion, Euler
from bpy.types import Object, PoseBone, Pose, ShapeKey, Modifier, VertexGroup
from ..bpyutils import FnObject
from ....core.logging_setup import logger
T = TypeVar('T')
def _hash(v: Union[Object, PoseBone, Pose]) -> int:
if isinstance(v, (bpy.types.Object, bpy.types.PoseBone)):
return hash(type(v).__name__ + v.name)
elif isinstance(v, bpy.types.Pose):
return hash(type(v).__name__ + v.id_data.name)
else:
raise NotImplementedError("hash")
class FnSDEF:
g_verts: Dict[int, Dict[Tuple[int, int], Tuple[PoseBone, PoseBone, List[Tuple[int, float, float, Vector, Vector, Vector]], List[int]]]] = {} # global cache
g_shapekey_data: Dict[int, Optional[np.ndarray]] = {}
g_bone_check: Dict[int, Dict[Union[Tuple[int, int], str], Union[Tuple[Matrix, Matrix], bool]]] = {}
__g_armature_check: Dict[int, Optional[int]] = {}
SHAPEKEY_NAME: str = "mmd_sdef_skinning"
MASK_NAME: str = "mmd_sdef_mask"
def __init__(self) -> None:
raise NotImplementedError("not allowed")
@classmethod
def __init_cache(cls, obj: Object, shapekey: ShapeKey) -> bool:
key = _hash(obj)
obj = getattr(obj, "original", obj)
mod = obj.modifiers.get("mmd_bone_order_override")
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:
logger.debug(f"Initializing SDEF cache for {obj.name}")
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: Object, bone0: PoseBone, bone1: PoseBone) -> bool:
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: Object, mute: bool) -> None:
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):
logger.debug(f"Setting SDEF mute state to {mute} for {obj.name}")
cls.__init_cache(obj, shapekey)
cls.__sdef_muted(obj, shapekey)
@classmethod
def __sdef_muted(cls, obj: Object, shapekey: ShapeKey) -> bool:
mute = shapekey.mute
if mute != cls.g_bone_check[_hash(obj)].get("sdef_mute"):
mod = obj.modifiers.get("mmd_bone_order_override")
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
logger.debug(f"SDEF mute state updated to {mute} for {obj.name}")
return mute
@staticmethod
def has_sdef_data(obj: Object) -> bool:
mod = obj.modifiers.get("mmd_bone_order_override")
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: Object) -> Dict[Tuple[int, int], Tuple[PoseBone, PoseBone, List[Tuple[int, float, float, Vector, Vector, Vector]], List[int]]]:
if not cls.has_sdef_data(obj):
return {}
vertices: Dict[Tuple[int, int], Tuple[PoseBone, PoseBone, List[Tuple[int, float, float, Vector, Vector, Vector]], List[int]]] = {}
pose_bones = obj.modifiers.get("mmd_bone_order_override").object.pose.bones
bone_map: Dict[int, PoseBone] = {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
logger.debug(f"Finding SDEF vertices for {obj.name}")
vertex_count = 0
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 / (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)
vertex_count += 1
logger.debug(f"Found {vertex_count} SDEF vertices in {obj.name}")
return vertices
@classmethod
def driver_function_wrap(cls, obj_name: str, bulk_update: bool, use_skip: bool, use_scale: bool) -> float:
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: ShapeKey, obj_name: str, bulk_update: bool, use_skip: bool, use_scale: bool) -> float:
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_bone_order_override").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:
import numpy as np
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: Matrix, w0: float, w1: float) -> Matrix:
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: Matrix, pos_c: Vector, vid: int) -> Vector:
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), 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) -> None:
"""Register driver functions in Blender's driver namespace."""
if "mmd_sdef_driver" not in bpy.app.driver_namespace:
logger.debug("Registering SDEF driver function")
bpy.app.driver_namespace["mmd_sdef_driver"] = cls.driver_function
if "mmd_sdef_driver_wrap" not in bpy.app.driver_namespace:
logger.debug("Registering SDEF driver wrapper function")
bpy.app.driver_namespace["mmd_sdef_driver_wrap"] = cls.driver_function_wrap
BENCH_LOOP: int = 10
@classmethod
def __get_benchmark_result(cls, obj: Object, shapkey: ShapeKey, use_scale: bool, use_skip: bool) -> bool:
# 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(f"SDEF benchmark for {obj.name}: default {default_time:.4f}s vs bulk_update {bulk_time:.4f}s => bulk_update={result}")
return result
@classmethod
def bind(cls, obj: Object, bulk_update: Optional[bool] = None, use_skip: bool = True, use_scale: bool = False) -> bool:
# Unbind first
cls.unbind(obj)
if not cls.has_sdef_data(obj):
logger.debug(f"Object {obj.name} does not have SDEF data")
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"
if not bulk_update and use_skip: # FIXME: force disable use_skip=True for bulk_update=False on 2.8
use_skip = False
mod = obj.modifiers.get("mmd_bone_order_override")
variables = f.driver.variables
for name in set(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
param = (bulk_update, use_skip, use_scale)
f.driver.expression = "mmd_sdef_driver(self, obj, bulk_update={}, use_skip={}, use_scale={})".format(*param)
logger.info(f"Successfully bound SDEF to {obj.name} with bulk_update={bulk_update}, use_skip={use_skip}, use_scale={use_scale}")
return True
@classmethod
def unbind(cls, obj: Object) -> None:
if obj.data.shape_keys:
if cls.SHAPEKEY_NAME in obj.data.shape_keys.key_blocks:
logger.debug(f"Removing SDEF shape key from {obj.name}")
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:
logger.debug(f"Clearing SDEF vertex group from modifier in {obj.name}")
mod.vertex_group = ""
mod.invert_vertex_group = False
break
if cls.MASK_NAME in obj.vertex_groups:
logger.debug(f"Removing SDEF vertex group from {obj.name}")
obj.vertex_groups.remove(obj.vertex_groups[cls.MASK_NAME])
cls.clear_cache(obj)
@classmethod
def clear_cache(cls, obj: Optional[Object] = None, unused_only: bool = False) -> None:
if unused_only:
valid_keys = set(_hash(i) for i in bpy.data.objects if i.type == "MESH" and i != obj)
removed_keys = cls.g_verts.keys() - valid_keys
for key in removed_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]
logger.debug(f"Cleared {len(removed_keys)} unused SDEF cache entries")
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]
logger.debug(f"Cleared SDEF cache for {obj.name}")
else:
logger.debug("Cleared all SDEF cache")
cls.g_verts = {}
cls.g_bone_check = {}
cls.g_shapekey_data = {}
+366
View File
@@ -0,0 +1,366 @@
# -*- 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 typing import Optional, Tuple, cast, List, Dict, Any, Union
import bpy
from bpy.types import (
ShaderNodeTree,
ShaderNode,
NodeGroupInput,
NodeGroupOutput,
Material
)
from ....core.logging_setup import logger
class _NodeTreeUtils:
def __init__(self, shader: ShaderNodeTree):
self.shader = shader
self.nodes: bpy.types.bpy_prop_collection[ShaderNode] = shader.nodes # type: ignore
self.links = shader.links
def _find_node(self, node_type: str) -> Optional[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]) -> ShaderNode:
node: ShaderNode = self.nodes.new(idname)
node.location = (pos[0] * 210, pos[1] * 220)
return node
def new_math_node(self, operation: str, pos: Tuple[int, int], value1: Optional[float] = None, value2: Optional[float] = None) -> ShaderNode:
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: str, pos: Tuple[int, int], vector1: Optional[Tuple[float, float, float, float]] = None, vector2: Optional[Tuple[float, float, float, float]] = None) -> ShaderNode:
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: str, pos: Tuple[int, int], fac: Optional[float] = None, color1: Optional[Tuple[float, float, float, float]] = None, color2: Optional[Tuple[float, float, float, float]] = None) -> ShaderNode:
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: Dict[str, str] = {"NodeSocketFloatFactor": "NodeSocketFloat"}
SOCKET_SUBTYPE_MAPPING: Dict[str, str] = {"NodeSocketFloatFactor": "FACTOR"}
class _NodeGroupUtils(_NodeTreeUtils):
def __init__(self, shader: ShaderNodeTree):
super().__init__(shader)
self.__node_input: Optional[NodeGroupInput] = None
self.__node_output: Optional[NodeGroupOutput] = None
@property
def node_input(self) -> NodeGroupInput:
if not self.__node_input:
self.__node_input = cast(NodeGroupInput, self._find_node("NodeGroupInput") or self.new_node("NodeGroupInput", (-2, 0)))
return self.__node_input
@property
def node_output(self) -> NodeGroupOutput:
if not self.__node_output:
self.__node_output = cast(NodeGroupOutput, self._find_node("NodeGroupOutput") or self.new_node("NodeGroupOutput", (2, 0)))
return self.__node_output
def hide_nodes(self, hide_sockets: bool = True) -> None:
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: str, socket: Optional[bpy.types.NodeSocket], default_val: Optional[Union[float, Tuple[float, float, float, float]]] = None, min_max: Optional[Tuple[float, float]] = None, socket_type: Optional[str] = None) -> 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: str, socket: Optional[bpy.types.NodeSocket], default_val: Optional[Union[float, Tuple[float, float, float, float]]] = None, min_max: Optional[Tuple[float, float]] = None, socket_type: Optional[str] = None) -> None:
self.__new_io("OUTPUT", self.node_output.inputs, io_name, socket, default_val, min_max, socket_type)
def __new_io(self, in_out: str, io_sockets: bpy.types.bpy_prop_collection, io_name: str, socket: Optional[bpy.types.NodeSocket], default_val: Optional[Union[float, Tuple[float, float, float, float]]] = None, min_max: Optional[Tuple[float, float]] = None, socket_type: Optional[str] = None) -> None:
if io_name not in io_sockets:
idname = socket_type or (socket.bl_idname if socket else "NodeSocketFloat")
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") or idname.endswith("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: Optional[Material], morph: Any) -> None:
"""Update material morph inputs based on morph data"""
if material and material.node_tree and morph.name in material.node_tree.nodes:
logger.debug(f"Updating morph inputs for {morph.name} in {material.name}")
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: Material, morphs: List[Any]) -> List[ShaderNode]:
"""Set up morph nodes for a material"""
node, nodes = None, []
logger.debug(f"Setting up {len(morphs)} morph nodes for {material.name}")
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: ShaderNode) -> None:
"""Reset morph links for a node"""
logger.debug(f"Resetting morph links for {node.name}")
cls.__update_morph_links(node, reset=True)
@classmethod
def __update_morph_links(cls, node: ShaderNode, reset: bool = False) -> None:
nodes, links = node.id_data.nodes, node.id_data.links
if reset:
if any(l.from_node.name.startswith("mmd_bind") for i in node.inputs for l in i.links):
return
def __init_link(socket_morph: bpy.types.NodeSocket, socket_shader: Optional[bpy.types.NodeSocket]) -> None:
if socket_shader and socket_morph.is_linked:
links.new(socket_morph.links[0].from_socket, socket_shader)
else:
def __init_link(socket_morph: bpy.types.NodeSocket, socket_shader: Optional[bpy.types.NodeSocket]) -> None:
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: ShaderNode, morph: Any) -> None:
"""Update node inputs based on morph data"""
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: Material, morph: Optional[Any], prev_node: Optional[ShaderNode]) -> Optional[ShaderNode]:
"""Add a morph node to a material"""
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:
logger.debug(f"Connecting last node to shader for {material.name}")
def __soft_link(socket_out: Optional[bpy.types.NodeSocket], socket_in: Optional[bpy.types.NodeSocket]) -> None:
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: str) -> ShaderNodeTree:
"""Get or create a shader node group for the specified 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
logger.info(f"Creating new shader node group: {group_name}")
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: str, pos: Tuple[int, int], tag: str = "") -> ShaderNode:
# 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("%s1" % id_name + tag, node_mix.inputs["Color1"])
ng.new_input_socket("%s2" % id_name + 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: str, pos: Tuple[int, int], node_tex_rgb: ShaderNode, node_tex_a_output: bpy.types.NodeSocket) -> None:
# 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: str, input1: bpy.types.NodeSocket, input2: bpy.types.NodeSocket, output: bpy.types.NodeSocket, tag: str = "") -> None:
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()
logger.debug(f"Shader node group {group_name} created successfully")
return ng.shader
+738
View File
@@ -0,0 +1,738 @@
# -*- 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 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):
@classmethod
@property
@abstractmethod
def type_name(cls) -> str:
pass
@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]:
"""Returns (name, name_j, name_e)"""
@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.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):
@classmethod
@property
def type_name(cls) -> str:
return 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.bone.select else "RESTRICT_SELECT_ON")
row.prop(pose_bone.bone, "hide", text="", emboss=False, icon_only=True, icon="HIDE_ON" if pose_bone.bone.hide else "HIDE_OFF")
@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 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.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):
@classmethod
@property
def type_name(cls) -> str:
return 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):
@classmethod
@property
def type_name(cls) -> str:
return 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, icon="HIDE_ON" if mesh_object.hide_get() else "HIDE_OFF")
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):
@classmethod
@property
def type_name(cls) -> str:
return 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, icon="HIDE_ON" if mmd_translation_element.object.hide_get() else "HIDE_OFF")
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):
@classmethod
@property
def type_name(cls) -> str:
return 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, icon="HIDE_ON" if obj.hide_get() else "HIDE_OFF")
@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):
@classmethod
@property
def type_name(cls) -> str:
return 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, icon="HIDE_ON" if info_object.hide_get() else "HIDE_OFF")
@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
+6
View File
@@ -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.
+673
View File
@@ -0,0 +1,673 @@
# -*- 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 logging
import math
import os
from typing import Union
import bpy
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)
logging.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 __keyframe_insert_inner(fcurves: bpy.types.ActionFCurves, path: str, index: int, frame: float, value: float):
fcurve = fcurves.find(path, index=index)
if fcurve is None:
fcurve = fcurves.new(path, index=index)
fcurve.keyframe_points.insert(frame, value, options={"FAST"})
@staticmethod
def __keyframe_insert(fcurves: bpy.types.ActionFCurves, path: str, frame: float, value: Union[int, float, Vector]):
if isinstance(value, (int, float)):
VMDImporter.__keyframe_insert_inner(fcurves, path, 0, frame, value)
elif isinstance(value, Vector):
VMDImporter.__keyframe_insert_inner(fcurves, path, 0, frame, value[0])
VMDImporter.__keyframe_insert_inner(fcurves, path, 1, frame, value[1])
VMDImporter.__keyframe_insert_inner(fcurves, path, 2, frame, value[2])
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
logging.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:
logging.warning("WARNING: not found bone %s (%d frames)", name, len(keyFrames))
continue
logging.info("(bone) frames:%5d name: %s", len(keyFrames), name)
assert bone_name_table.get(bone.name, name) == name
bone_name_table[bone.name] = name
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] = action.fcurves.new(data_path=data_path, index=axis_i, action_group=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] = action.fcurves.new(data_path=data_path, index=axis_i, action_group=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
for c in action.fcurves:
self.__fixFcurveHandles(c)
# property animation
propertyAnim = self.__vmdFile.propertyAnimation
if len(propertyAnim) > 0:
logging.info("---- IK animations:%5d target: %s", len(propertyAnim), armObj.name)
for keyFrame in propertyAnim:
logging.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
logging.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:
logging.warning("WARNING: not found shape key %s (%d frames)", name, len(keyFrames))
continue
logging.info("(mesh) frames:%5d name: %s", len(keyFrames), name)
shapeKey = shapeKeyDict[name]
fcurve = action.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
logging.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)
logging.debug("(Display) list(frame, show): %s", [(keyFrame.frame_number, bool(keyFrame.visible)) for keyFrame in propertyAnim])
for keyFrame in propertyAnim:
self.__keyframe_insert(action.fcurves, "mmd_root.show_meshes", keyFrame.frame_number + self.__frame_margin, float(keyFrame.visible))
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
logging.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
fcurves = []
for i in range(3):
fcurves.append(parent_action.fcurves.new(data_path="location", index=i)) # x, y, z
for i in range(3):
fcurves.append(parent_action.fcurves.new(data_path="rotation_euler", index=i)) # 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(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
logging.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.fcurves, "color", frame, Vector(keyFrame.color))
self.__keyframe_insert(location_action.fcurves, "location", frame, Vector(_loc(keyFrame.direction)).xzy * -1)
for fcurve in location_action.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
+260
View File
@@ -0,0 +1,260 @@
# -*- 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 typing import Iterable, Optional, Any, List, Tuple, Union
import bpy
from bpy.types import Material, NodeTree, Node, NodeSocket, ShaderNodeGroup, ShaderNodeOutputMaterial, NodeLink
from ..logging_setup import logger
from .core.shader import _NodeGroupUtils
from .core.material import FnMaterial
def __switchToCyclesRenderEngine() -> None:
if bpy.context.scene.render.engine != "CYCLES":
logger.debug("Switching render engine to Cycles")
bpy.context.scene.render.engine = "CYCLES"
def __exposeNodeTreeInput(in_socket: NodeSocket, name: str, default_value: Any, node_input: Node, shader: NodeTree) -> None:
_NodeGroupUtils(shader).new_input_socket(name, in_socket, default_value)
def __exposeNodeTreeOutput(out_socket: NodeSocket, name: str, node_output: Node, shader: NodeTree) -> None:
_NodeGroupUtils(shader).new_output_socket(name, out_socket)
def __getMaterialOutput(nodes: bpy.types.Nodes, bl_idname: str) -> Node:
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() -> NodeTree:
__switchToCyclesRenderEngine()
if "MMDAlphaShader" in bpy.data.node_groups:
logger.debug("Using existing MMDAlphaShader node group")
return bpy.data.node_groups["MMDAlphaShader"]
logger.info("Creating new MMDAlphaShader node group")
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() -> NodeTree:
__switchToCyclesRenderEngine()
if "MMDBasicShader" in bpy.data.node_groups:
logger.debug("Using existing MMDBasicShader node group")
return bpy.data.node_groups["MMDBasicShader"]
logger.info("Creating new MMDBasicShader node group")
shader: NodeTree = bpy.data.node_groups.new(name="MMDBasicShader", type="ShaderNodeTree")
node_input: Node = shader.nodes.new("NodeGroupInput")
node_output: Node = shader.nodes.new("NodeGroupOutput")
node_output.location.x += 250
node_input.location.x -= 500
dif: Node = shader.nodes.new("ShaderNodeBsdfDiffuse")
dif.location.x -= 250
dif.location.y += 150
glo: Node = shader.nodes.new("ShaderNodeBsdfAnisotropic")
glo.location.x -= 250
glo.location.y -= 150
mix: Node = 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: Node) -> Iterable[Node]:
yield node
if node.parent:
yield node.parent
for n in set(l.from_node for i in node.inputs for l in i.links):
yield from __enum_linked_nodes(n)
def __cleanNodeTree(material: Material) -> None:
logger.debug(f"Cleaning node tree for material: {material.name}")
nodes = material.node_tree.nodes
node_names = set(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 -= set(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: bool = False, clean_nodes: bool = False, subsurface: float = 0.001) -> None:
logger.info(f"Converting {obj.name} to Cycles shader (use_principled={use_principled}, clean_nodes={clean_nodes})")
__switchToCyclesRenderEngine()
convertToBlenderShader(obj, use_principled, clean_nodes, subsurface)
def convertToBlenderShader(obj: bpy.types.Object, use_principled: bool = False, clean_nodes: bool = False, subsurface: float = 0.001) -> None:
for i in obj.material_slots:
if not i.material:
continue
if not i.material.use_nodes:
logger.debug(f"Enabling nodes for material: {i.material.name}")
i.material.use_nodes = True
__convertToMMDBasicShader(i.material)
if use_principled:
logger.debug(f"Converting material to Principled BSDF: {i.material.name}")
__convertToPrincipledBsdf(i.material, subsurface)
if clean_nodes:
__cleanNodeTree(i.material)
def convertToMMDShader(obj: bpy.types.Object) -> None:
"""BSDF -> MMDShaderDev conversion."""
logger.info(f"Converting {obj.name} to MMD shader")
for i in obj.material_slots:
if not i.material:
continue
if not i.material.use_nodes:
logger.debug(f"Enabling nodes for material: {i.material.name}")
i.material.use_nodes = True
FnMaterial.convert_to_mmd_material(i.material)
def __convertToMMDBasicShader(material: Material) -> None:
logger.debug(f"Converting material to MMD Basic Shader: {material.name}")
# TODO: test me
mmd_basic_shader_grp = create_MMDBasicShader()
mmd_alpha_shader_grp = create_MMDAlphaShader()
if not any(filter(lambda x: isinstance(x, ShaderNodeGroup) and x.node_tree.name in {"MMDBasicShader", "MMDAlphaShader"}, material.node_tree.nodes)):
# Add nodes for Cycles Render
shader: 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:
logger.debug(f"Material has alpha: {material.name}, alpha={alpha_value}")
alpha_shader: 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: 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: Material, subsurface: float) -> None:
logger.debug(f"Converting material to Principled BSDF: {material.name}")
node_names = set()
for s in (n for n in material.node_tree.nodes if isinstance(n, ShaderNodeGroup)):
if s.node_tree.name == "MMDBasicShader":
l: NodeLink
for l in s.outputs[0].links:
to_node = l.to_node
# assuming there is no bpy.types.NodeReroute between MMDBasicShader and MMDAlphaShader
if isinstance(to_node, 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: NodeTree, node_basic: ShaderNodeGroup, subsurface: float, node_alpha: Optional[ShaderNodeGroup] = None) -> None:
logger.debug(f"Switching to Principled BSDF: {node_basic.name}")
shader: Node = 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_socket_name].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 l in output_links:
node_tree.links.new(shader.outputs[0], l.to_socket)
+6
View File
@@ -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.
+451
View File
@@ -0,0 +1,451 @@
# -*- 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 bpy.props import BoolProperty, StringProperty, FloatProperty
from bpy.types import Operator, Context, Object, Material
from typing import Set, Dict, Any, List, Tuple, Optional, Union, cast
from .. import cycles_converter
from ..core.exceptions import MaterialNotFoundError
from ..core.material import FnMaterial
from ..core.shader import _NodeGroupUtils
from ....core.logging_setup import logger
import traceback
class ConvertMaterialsForCycles(Operator):
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: 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: 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: Context) -> bool:
return next((x for x in context.selected_objects if x.type == "MESH"), None) is not None
def draw(self, context: Context) -> None:
layout = self.layout
layout.prop(self, "use_principled")
layout.prop(self, "clean_nodes")
def execute(self, context: Context) -> Set[str]:
try:
context.scene.render.engine = "CYCLES"
except Exception:
logger.error(f"Failed to change to Cycles render engine: {traceback.format_exc()}")
self.report({"ERROR"}, " * Failed to change to Cycles render engine.")
return {"CANCELLED"}
logger.info(f"Converting materials for Cycles with principled={self.use_principled}, clean_nodes={self.clean_nodes}")
for obj in (x for x in context.selected_objects if x.type == "MESH"):
logger.debug(f"Converting materials for object: {obj.name}")
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: 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: BoolProperty(
name="Clean Nodes",
description="Remove redundant nodes as well if enabled. Disable it to keep node data.",
default=True,
options={"SKIP_SAVE"},
)
subsurface: FloatProperty(
name="Subsurface",
default=0.001,
soft_min=0.000,
soft_max=1.000,
precision=3,
options={"SKIP_SAVE"},
)
@classmethod
def poll(cls, context: Context) -> bool:
return next((x for x in context.selected_objects if x.type == "MESH"), None) is not None
def execute(self, context: Context) -> Set[str]:
logger.info(f"Converting materials with principled={self.use_principled}, clean_nodes={self.clean_nodes}, subsurface={self.subsurface}")
for obj in context.selected_objects:
if obj.type != "MESH":
continue
logger.debug(f"Converting materials for object: {obj.name}")
cycles_converter.convertToBlenderShader(obj, use_principled=self.use_principled, clean_nodes=self.clean_nodes, subsurface=self.subsurface)
return {"FINISHED"}
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: Context) -> bool:
return next((x for x in context.selected_objects if x.type == 'MESH'), None) is not None
def execute(self, context: Context) -> Set[str]:
logger.info("Converting BSDF materials to MMD shader")
for obj in context.selected_objects:
if obj.type != 'MESH':
continue
logger.debug(f"Converting BSDF materials for object: {obj.name}")
cycles_converter.convertToMMDShader(obj)
return {'FINISHED'}
class _OpenTextureBase:
"""Create a texture for mmd model material."""
bl_options: Set[str] = {"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: Context, event: Any) -> Set[str]:
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: Context) -> Set[str]:
mat = context.active_object.active_material
if not mat:
logger.error("No active material found")
return {"CANCELLED"}
logger.info(f"Creating texture for material: {mat.name} from {self.filepath}")
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: Context) -> Set[str]:
mat = context.active_object.active_material
if not mat:
logger.error("No active material found")
return {"CANCELLED"}
logger.info(f"Removing texture from material: {mat.name}")
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: Context) -> Set[str]:
mat = context.active_object.active_material
if not mat:
logger.error("No active material found")
return {"CANCELLED"}
logger.info(f"Creating sphere texture for material: {mat.name} from {self.filepath}")
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: Context) -> Set[str]:
mat = context.active_object.active_material
if not mat:
logger.error("No active material found")
return {"CANCELLED"}
logger.info(f"Removing sphere texture from material: {mat.name}")
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: Context) -> bool:
obj = context.active_object
valid_mesh = obj and obj.type == "MESH" and obj.mmd_type == "NONE"
return bool(valid_mesh and obj.active_material_index > 0)
def execute(self, context: Context) -> Set[str]:
obj = context.active_object
current_idx = obj.active_material_index
prev_index = current_idx - 1
logger.debug(f"Moving material {current_idx} up to position {prev_index} for object {obj.name}")
try:
FnMaterial.swap_materials(obj, current_idx, prev_index, reverse=True, swap_slots=True)
except MaterialNotFoundError:
logger.error(f"Materials not found for indices {current_idx} and {prev_index}")
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: Context) -> bool:
obj = context.active_object
valid_mesh = obj and obj.type == "MESH" and obj.mmd_type == "NONE"
return bool(valid_mesh and obj.active_material_index < len(obj.material_slots) - 1)
def execute(self, context: Context) -> Set[str]:
obj = context.active_object
current_idx = obj.active_material_index
next_index = current_idx + 1
logger.debug(f"Moving material {current_idx} down to position {next_index} for object {obj.name}")
try:
FnMaterial.swap_materials(obj, current_idx, next_index, reverse=True, swap_slots=True)
except MaterialNotFoundError:
logger.error(f"Materials not found for indices {current_idx} and {next_index}")
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: Context) -> Set[str]:
from ..core.model import FnModel
root = FnModel.find_root_object(context.active_object)
if root is None:
logger.error("No MMD model root found")
self.report({"ERROR"}, "Select a MMD model")
return {"CANCELLED"}
if self.action == "CLEAN":
logger.info(f"Cleaning toon edge for model: {root.name}")
for obj in FnModel.iterate_mesh_objects(root):
self.__clean_toon_edge(obj)
else:
from ..bpyutils import Props
logger.info(f"Creating toon edge for model: {root.name}")
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))
logger.info(f"Created {counts} toon edge(s)")
self.report({"INFO"}, "Created %d toon edge(s)" % counts)
return {"FINISHED"}
def __clean_toon_edge(self, obj: Object) -> None:
logger.debug(f"Cleaning toon edge for object: {obj.name}")
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: Object, scale: float = 1.0) -> int:
logger.debug(f"Creating toon edge for object: {obj.name} with scale {scale}")
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: Object) -> None:
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: Dict[int, float] = {}
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: str, edge_color: Tuple[float, float, float, float], materials: List[Material]) -> Material:
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: Material) -> None:
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) -> bpy.types.NodeTree:
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)
node_input = ng.new_node("NodeGroupInput", (-5, 0))
node_output = 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
+339
View File
@@ -0,0 +1,339 @@
# -*- 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 re
from typing import List, Dict, Any, Set, Optional, Tuple, Union, Type
import bpy
from bpy.types import Context, Object, Operator, ShapeKey
from .. import utils
from ..bpyutils import FnContext, FnObject
from ..core.bone import FnBone
from ..core.model import FnModel, Model
from ..core.morph import FnMorph
from ....core.logging_setup import logger
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: Context) -> Set[str]:
logger.debug(f"Selecting object: {self.name}")
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: Object, index: int) -> None:
m = cls.__PREFIX_REGEXP.match(obj.name)
name = m.group("name") if m else obj.name
obj.name = "%s_%s" % (utils.int2base(index, 36, 3), name)
@classmethod
def get_name(cls, obj: Object, prefix: Optional[str] = None) -> str:
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: List[Object]) -> None:
for i, x in enumerate(objects):
cls.set_index(x, i)
@classmethod
def poll(cls, context: Context) -> bool:
return context.active_object is not None
def execute(self, context: Context) -> Set[str]:
obj = context.active_object
objects = self.__get_objects(obj)
if obj not in objects:
logger.error(f'Cannot move object "{obj.name}"')
self.report({"ERROR"}, f'Can not move object "{obj.name}"')
return {"CANCELLED"}
objects.sort(key=lambda x: x.name)
logger.debug(f"Moving object {obj.name} {self.type}")
self.move(objects, objects.index(obj), self.type)
self.normalize_indices(objects)
return {"FINISHED"}
def __get_objects(self, obj: Object) -> Any:
class __MovableList(list):
def move(self, index_old: int, index_new: int) -> None:
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: Context) -> bool:
return any(o.type == "MESH" for o in context.selected_objects)
@staticmethod
def __can_remove(key_block: ShapeKey) -> bool:
if key_block.relative_key == key_block:
return False # Basis
for v0, v1 in zip(key_block.relative_key.data, key_block.data):
if v0.co != v1.co:
return False
return True
def __shape_key_clean(self, obj: Object, key_blocks: List[ShapeKey]) -> None:
for kb in key_blocks:
if self.__can_remove(kb):
logger.debug(f"Removing unused shape key: {kb.name} from {obj.name}")
FnObject.mesh_remove_shape_key(obj, kb)
if len(key_blocks) == 1:
logger.debug(f"Removing single shape key: {key_blocks[0].name} from {obj.name}")
FnObject.mesh_remove_shape_key(obj, key_blocks[0])
def execute(self, context: Context) -> Set[str]:
logger.info("Cleaning shape keys for selected objects")
obj: 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
logger.debug(f"Processing shape keys for {obj.name}")
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 = "Separate By Materials"
bl_options = {"REGISTER", "UNDO"}
clean_shape_keys: bpy.props.BoolProperty(
name="Clean Shape Keys",
description="Remove unused shape keys of separated objects",
default=True,
)
@classmethod
def poll(cls, context: Context) -> bool:
obj = context.active_object
return obj and obj.type == "MESH"
def __separate_by_materials(self, obj: Object) -> None:
logger.info(f"Separating {obj.name} by materials")
utils.separateByMaterials(obj)
if self.clean_shape_keys:
logger.debug("Cleaning shape keys after separation")
bpy.ops.mmd_tools.clean_shape_keys()
def execute(self, context: Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
if root is None:
logger.debug("No root object found, separating single object")
self.__separate_by_materials(obj)
else:
logger.debug(f"Root object found: {root.name}, preparing for separation")
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))
logger.debug(f"Setting index {idx} for mesh {mesh.name}")
MoveObject.set_index(mesh, idx)
for morph in root.mmd_root.material_morphs:
logger.debug(f"Updating material morph: {morph.name}")
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: Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
if root is None:
logger.error("No MMD model found")
self.report({"ERROR"}, "Select a MMD model")
return {"CANCELLED"}
logger.info(f"Joining meshes for model: {root.name}")
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:
logger.error("No meshes found in the model")
self.report({"ERROR"}, "The model does not have any meshes")
return {"CANCELLED"}
active_mesh = meshes_list[0]
logger.debug(f"Found {len(meshes_list)} meshes, using {active_mesh.name} as active")
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[:]:
logger.debug(f"Adding material {mat.name} to active mesh")
active_mesh.data.materials.append(mat)
# Join selected meshes
logger.debug("Joining meshes")
bpy.ops.object.join()
if self.sort_shape_keys:
logger.debug("Sorting 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:
logger.debug(f"Updating material morph: {morph.name}")
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: Context) -> Set[str]:
root = FnModel.find_root_object(context.active_object)
if root is None:
logger.error("No MMD model found")
self.report({"ERROR"}, "Select a MMD model")
return {"CANCELLED"}
armObj = FnModel.find_armature_object(root)
if armObj is None:
logger.error("Model armature not found")
self.report({"ERROR"}, "Model Armature not found")
return {"CANCELLED"}
logger.info(f"Attaching meshes to model: {root.name}")
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: Context) -> bool:
return FnModel.find_root_object(context.active_object) is not None
def invoke(self, context: Context, event: Any) -> Set[str]:
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: Context) -> Set[str]:
root_object = FnModel.find_root_object(context.active_object)
logger.info(f"Changing IK loop factor to {self.mmd_ik_loop_factor} for model: {root_object.name}")
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: Context) -> bool:
obj = context.active_object
return obj and obj.type == "ARMATURE"
def invoke(self, context: Context, event: Any) -> Set[str]:
vm = context.window_manager
return vm.invoke_props_dialog(self)
def draw(self, context: Context) -> None:
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: Context) -> Set[str]:
arm = context.active_object
logger.info(f"Recalculating bone roll for armature: {arm.name}")
FnBone.apply_auto_bone_roll(arm)
return {"FINISHED"}
+523
View File
@@ -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"}
+354
View File
@@ -0,0 +1,354 @@
# -*- 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 itertools
from operator import itemgetter
from typing import Dict, List, Optional, Set, Tuple, Any
import bmesh
import bpy
import numpy as np
import numpy.typing as npt
from bpy.types import Context, Object, Operator, EditBone, Mesh, Armature
from ..bpyutils import FnContext
from ..core.model import FnModel, Model
from ....core.logging_setup import logger
class MessageException(Exception):
"""Class for error with message."""
class ModelJoinByBonesOperator(bpy.types.Operator):
bl_idname = "mmd_tools.model_join_by_bones"
bl_label = "Model Join by Bones"
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: Context) -> bool:
active_object: Optional[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: Context, event: Any) -> Set[str]:
return context.window_manager.invoke_props_dialog(self)
def execute(self, context: Context) -> Set[str]:
try:
logger.info("Starting model join by bones operation")
self.join(context)
logger.info("Model join by bones completed successfully")
except MessageException as ex:
logger.error(f"Model join by bones failed: {str(ex)}")
self.report(type={"ERROR"}, message=str(ex))
return {"CANCELLED"}
return {"FINISHED"}
def join(self, context: Context) -> None:
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 MessageException("No MMD Models selected")
logger.debug(f"Joining {len(child_root_objects)} models into parent model: {parent_root_object.name}")
with FnContext.temp_override_active_layer_collection(context, parent_root_object):
FnModel.join_models(parent_root_object, child_root_objects)
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: EditBone = context.active_bone
child_edit_bones: Set[EditBone] = set(context.selected_bones)
child_edit_bones.remove(parent_edit_bone)
logger.debug(f"Connecting {len(child_edit_bones)} child bones to parent bone: {parent_edit_bone.name}")
child_edit_bone: 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_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: Context) -> bool:
active_object: Optional[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: Context, event: Any) -> Set[str]:
return context.window_manager.invoke_props_dialog(self)
def execute(self, context: Context) -> Set[str]:
try:
logger.info("Starting model separate by bones operation")
self.separate(context)
logger.info("Model separate by bones completed successfully")
except MessageException as ex:
logger.error(f"Model separate by bones failed: {str(ex)}")
self.report(type={"ERROR"}, message=str(ex))
return {"CANCELLED"}
return {"FINISHED"}
def separate(self, context: Context) -> None:
weight_threshold: float = self.weight_threshold
mmd_scale = 0.08
target_armature_object: Object = context.active_object
logger.debug(f"Target armature: {target_armature_object.name}")
bpy.ops.object.mode_set(mode="EDIT")
root_bones: Set[EditBone] = set(context.selected_bones)
logger.debug(f"Selected root bones: {len(root_bones)}")
if self.include_descendant_bones:
logger.debug("Including descendant bones")
for edit_bone in root_bones:
with context.temp_override(active_bone=edit_bone):
bpy.ops.armature.select_similar(type="CHILDREN", threshold=0.1)
separate_bones: Dict[str, EditBone] = {b.name: b for b in context.selected_bones}
deform_bones: Dict[str, EditBone] = {b.name: b for b in target_armature_object.data.edit_bones if b.use_deform}
logger.debug(f"Total bones to separate: {len(separate_bones)}")
mmd_root_object: Object = FnModel.find_root_object(context.active_object)
mmd_model = Model(mmd_root_object)
mmd_model_mesh_objects: List[Object] = list(mmd_model.meshes())
logger.debug(f"Found {len(mmd_model_mesh_objects)} mesh objects in model")
mesh_selection_result = self.select_weighted_vertices(mmd_model_mesh_objects, separate_bones, deform_bones, weight_threshold)
mmd_model_mesh_objects = list(mesh_selection_result.keys())
logger.debug(f"Selected {len(mmd_model_mesh_objects)} mesh objects with weighted vertices")
# separate armature bones
separate_armature_object: Optional[Object]
if self.separate_armature:
logger.debug("Separating armature")
target_armature_object.select_set(True)
bpy.ops.armature.separate()
separate_armature_object = next(iter([a for a in context.selected_objects if a != target_armature_object]), None)
if separate_armature_object:
logger.debug(f"Created separate armature: {separate_armature_object.name}")
bpy.ops.object.mode_set(mode="OBJECT")
# collect separate rigid bodies
separate_rigid_bodies: Set[Object] = {rigid_body_object for rigid_body_object in mmd_model.rigidBodies() if rigid_body_object.mmd_rigid.bone in separate_bones}
logger.debug(f"Found {len(separate_rigid_bodies)} rigid bodies to separate")
boundary_joint_owner_condition = any if self.boundary_joint_owner == "DESTINATION" else all
# collect separate joints
separate_joints: Set[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,
]
)
}
logger.debug(f"Found {len(separate_joints)} joints to separate")
separate_mesh_objects: Set[Object]
model2separate_mesh_objects: Dict[Object, Object]
if len(mmd_model_mesh_objects) == 0:
logger.debug("No mesh objects to separate")
separate_mesh_objects = set()
model2separate_mesh_objects = dict()
else:
# select meshes
logger.debug("Selecting meshes for separation")
obj: 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
logger.debug("Separating meshes by selected vertices")
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.separate(type="SELECTED")
separate_mesh_objects: List[Object] = [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")
logger.debug(f"Created {len(separate_mesh_objects)} separate mesh objects")
model2separate_mesh_objects = dict(zip(mmd_model_mesh_objects, separate_mesh_objects))
logger.debug(f"Creating new model with scale {mmd_scale}")
separate_model: Model = Model.create(mmd_root_object.mmd_root.name, mmd_root_object.mmd_root.name_e, mmd_scale, 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()
logger.debug(f"Created separate model with root: {separate_root_object.name}")
if self.separate_armature:
logger.debug("Joining separate armature to new model")
with context.temp_override(
active_object=separate_model_armature_object,
selected_editable_objects=[separate_model_armature_object, separate_armature_object],
):
bpy.ops.object.join()
# add mesh
logger.debug("Parenting separate mesh objects to new model")
with context.temp_override(
object=separate_model_armature_object,
selected_editable_objects=[separate_model_armature_object, *separate_mesh_objects],
):
bpy.ops.object.parent_set(type="OBJECT", keep_transform=True)
# replace mesh armature modifier.object
logger.debug("Updating armature modifiers on separate meshes")
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:
logger.debug(f"Creating new armature modifier for {separate_mesh.name}")
armature_modifier: bpy.types.ArmatureModifier = separate_mesh.modifiers.new("mmd_bone_order_override", "ARMATURE")
armature_modifier.object = separate_model_armature_object
logger.debug("Parenting rigid bodies to new model")
with context.temp_override(
object=separate_model.rigidGroupObject(),
selected_editable_objects=[separate_model.rigidGroupObject(), *separate_rigid_bodies],
):
bpy.ops.object.parent_set(type="OBJECT", keep_transform=True)
logger.debug("Parenting joints to new model")
with context.temp_override(
object=separate_model.jointGroupObject(),
selected_editable_objects=[separate_model.jointGroupObject(), *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:
logger.debug(f"Moving objects from collection {mmd_layer_collection.name} to {separate_layer_collection.name}")
for separate_object in itertools.chain(separate_mesh_objects, separate_rigid_bodies, separate_joints):
separate_layer_collection.collection.objects.link(separate_object)
mmd_layer_collection.collection.objects.unlink(separate_object)
logger.debug("Copying MMD root properties")
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()}
},
)
def select_weighted_vertices(self, mmd_model_mesh_objects: List[Object], separate_bones: Dict[str, EditBone], deform_bones: Dict[str, EditBone], weight_threshold: float) -> Dict[Object, int]:
"""Select vertices weighted to the bones to be separated"""
logger.debug(f"Selecting vertices weighted to {len(separate_bones)} bones with threshold {weight_threshold}")
mesh2selected_vertex_count: Dict[Object, int] = dict()
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: 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:
logger.debug(f"Selected {selected_vertex_count} vertices in mesh {mesh_object.name}")
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
+803
View File
@@ -0,0 +1,803 @@
# -*- 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 typing import Optional, cast, List, Dict, Any, Set, Tuple, Union
import bpy
from mathutils import Quaternion, Vector
from ..core.model import FnModel
from .. import bpyutils, utils
from ..core.exceptions import MaterialNotFoundError
from ..core.material import FnMaterial
from ..core.morph import FnMorph
from ..utils import ItemMoveOp, ItemOp
from ....logging_setup import logger
# Util functions
def divide_vector_components(vec1: List[float], vec2: List[float]) -> List[float]:
if len(vec1) != len(vec2):
raise ValueError("Vectors should have the same number of components")
result = []
for v1, v2 in zip(vec1, vec2):
if v2 == 0:
if v1 == 0:
v2 = 1 # If we have a 0/0 case we change the divisor to 1
else:
raise ZeroDivisionError("Invalid Input: a non-zero value can't be divided by zero")
result.append(v1 / v2)
return result
def multiply_vector_components(vec1: List[float], vec2: List[float]) -> List[float]:
if len(vec1) != len(vec2):
raise ValueError("Vectors should have the same number of components")
result = []
for v1, v2 in zip(vec1, vec2):
result.append(v1 * v2)
return result
def special_division(n1: float, n2: float) -> float:
"""This function returns 0 in case of 0/0. If non-zero divided by zero case is found, an Exception is raised"""
if n2 == 0:
if n1 == 0:
n2 = 1
else:
raise ZeroDivisionError("Invalid Input: a non-zero value can't be divided by zero")
return n1 / n2
class AddMorph(bpy.types.Operator):
bl_idname = "mmd_tools.morph_add"
bl_label = "Add Morph"
bl_description = "Add a morph item to active morph list"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
morph_type = mmd_root.active_morph_type
morphs = getattr(mmd_root, morph_type)
morph, mmd_root.active_morph = ItemOp.add_after(morphs, mmd_root.active_morph)
morph.name = "New Morph"
if morph_type.startswith("uv"):
morph.data_type = "VERTEX_GROUP"
logger.debug(f"Added new morph of type {morph_type}")
return {"FINISHED"}
class RemoveMorph(bpy.types.Operator):
bl_idname = "mmd_tools.morph_remove"
bl_label = "Remove Morph"
bl_description = "Remove morph item(s) from the list"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
all: bpy.props.BoolProperty(
name="All",
description="Delete all morph items",
default=False,
options={"SKIP_SAVE"},
)
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
morph_type = mmd_root.active_morph_type
if morph_type.startswith("material"):
bpy.ops.mmd_tools.clear_temp_materials()
elif morph_type.startswith("uv"):
bpy.ops.mmd_tools.clear_uv_morph_view()
morphs = getattr(mmd_root, morph_type)
if self.all:
morphs.clear()
mmd_root.active_morph = 0
logger.debug(f"Removed all morphs of type {morph_type}")
else:
morphs.remove(mmd_root.active_morph)
mmd_root.active_morph = max(0, mmd_root.active_morph - 1)
logger.debug(f"Removed morph at index {mmd_root.active_morph} of type {morph_type}")
return {"FINISHED"}
class MoveMorph(bpy.types.Operator, ItemMoveOp):
bl_idname = "mmd_tools.morph_move"
bl_label = "Move Morph"
bl_description = "Move active morph item up/down in the list"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
mmd_root.active_morph = self.move(
getattr(mmd_root, mmd_root.active_morph_type),
mmd_root.active_morph,
self.type,
)
logger.debug(f"Moved morph to index {mmd_root.active_morph}")
return {"FINISHED"}
class CopyMorph(bpy.types.Operator):
bl_idname = "mmd_tools.morph_copy"
bl_label = "Copy Morph"
bl_description = "Make a copy of active morph in the list"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
mmd_root = root.mmd_root
morph_type = mmd_root.active_morph_type
morphs = getattr(mmd_root, morph_type)
morph = ItemOp.get_by_index(morphs, mmd_root.active_morph)
if morph is None:
return {"CANCELLED"}
name_orig, name_tmp = morph.name, "_tmp%s" % str(morph.as_pointer())
if morph_type.startswith("vertex"):
for obj in FnModel.iterate_mesh_objects(root):
FnMorph.copy_shape_key(obj, name_orig, name_tmp)
elif morph_type.startswith("uv"):
if morph.data_type == "VERTEX_GROUP":
for obj in FnModel.iterate_mesh_objects(root):
FnMorph.copy_uv_morph_vertex_groups(obj, name_orig, name_tmp)
morph_new, mmd_root.active_morph = ItemOp.add_after(morphs, mmd_root.active_morph)
for k, v in morph.items():
morph_new[k] = v if k != "name" else name_tmp
morph_new.name = name_orig + "_copy" # trigger name check
logger.debug(f"Copied morph {name_orig} to {morph_new.name}")
return {"FINISHED"}
class OverwriteBoneMorphsFromActionPose(bpy.types.Operator):
bl_idname = "mmd_tools.morph_overwrite_from_active_action_pose"
bl_label = "Overwrite Bone Morphs from active Action Pose"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
root = FnModel.find_root_object(context.active_object)
if root is None:
return False
return root.mmd_root.active_morph_type == "bone_morphs"
def execute(self, context: bpy.types.Context) -> Set[str]:
root = FnModel.find_root_object(context.active_object)
FnMorph.overwrite_bone_morphs_from_action_pose(FnModel.find_armature_object(root))
logger.info("Overwrote bone morphs from active action pose")
return {"FINISHED"}
class AddMorphOffset(bpy.types.Operator):
bl_idname = "mmd_tools.morph_offset_add"
bl_label = "Add Morph Offset"
bl_description = "Add a morph offset item to the list"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
morph_type = mmd_root.active_morph_type
morph = ItemOp.get_by_index(getattr(mmd_root, morph_type), mmd_root.active_morph)
if morph is None:
return {"CANCELLED"}
item, morph.active_data = ItemOp.add_after(morph.data, morph.active_data)
if morph_type.startswith("material"):
if obj.type == "MESH" and obj.mmd_type == "NONE":
item.related_mesh = obj.data.name
active_material = obj.active_material
if active_material and "_temp" not in active_material.name:
item.material = active_material.name
elif morph_type.startswith("bone"):
pose_bone = context.active_pose_bone
if pose_bone:
item.bone = pose_bone.name
item.location = pose_bone.location
item.rotation = pose_bone.rotation_quaternion
logger.debug(f"Added morph offset to {morph_type}")
return {"FINISHED"}
class RemoveMorphOffset(bpy.types.Operator):
bl_idname = "mmd_tools.morph_offset_remove"
bl_label = "Remove Morph Offset"
bl_description = "Remove morph offset item(s) from the list"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
all: bpy.props.BoolProperty(
name="All",
description="Delete all morph offset items",
default=False,
options={"SKIP_SAVE"},
)
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
mmd_root = root.mmd_root
morph_type = mmd_root.active_morph_type
morph = ItemOp.get_by_index(getattr(mmd_root, morph_type), mmd_root.active_morph)
if morph is None:
return {"CANCELLED"}
if morph_type.startswith("material"):
bpy.ops.mmd_tools.clear_temp_materials()
if self.all:
if morph_type.startswith("vertex"):
for obj in FnModel.iterate_mesh_objects(root):
FnMorph.remove_shape_key(obj, morph.name)
logger.debug(f"Removed all vertex morph offsets for {morph.name}")
return {"FINISHED"}
elif morph_type.startswith("uv"):
if morph.data_type == "VERTEX_GROUP":
for obj in FnModel.iterate_mesh_objects(root):
FnMorph.store_uv_morph_data(obj, morph)
logger.debug(f"Removed all UV morph offsets for {morph.name}")
return {"FINISHED"}
morph.data.clear()
morph.active_data = 0
logger.debug(f"Cleared all morph offsets for {morph.name}")
else:
morph.data.remove(morph.active_data)
morph.active_data = max(0, morph.active_data - 1)
logger.debug(f"Removed morph offset at index {morph.active_data}")
return {"FINISHED"}
class InitMaterialOffset(bpy.types.Operator):
bl_idname = "mmd_tools.material_morph_offset_init"
bl_label = "Init Material Offset"
bl_description = "Set all offset values to target value"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
target_value: bpy.props.FloatProperty(
name="Target Value",
description="Target value",
default=0,
)
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
morph = mmd_root.material_morphs[mmd_root.active_morph]
mat_data = morph.data[morph.active_data]
val = self.target_value
mat_data.diffuse_color = mat_data.edge_color = (val,) * 4
mat_data.specular_color = mat_data.ambient_color = (val,) * 3
mat_data.shininess = mat_data.edge_weight = val
mat_data.texture_factor = mat_data.toon_texture_factor = mat_data.sphere_texture_factor = (val,) * 4
logger.debug(f"Initialized material offset with value {val}")
return {"FINISHED"}
class ApplyMaterialOffset(bpy.types.Operator):
bl_idname = "mmd_tools.apply_material_morph_offset"
bl_label = "Apply Material Offset"
bl_description = "Calculates the offsets and apply them, then the temporary material is removed"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
morph = mmd_root.material_morphs[mmd_root.active_morph]
mat_data = morph.data[morph.active_data]
if not mat_data.related_mesh:
self.report({"ERROR"}, "You need to choose a Related Mesh first")
return {"CANCELLED"}
meshObj = FnModel.find_mesh_object_by_name(morph.id_data, mat_data.related_mesh)
if meshObj is None:
self.report({"ERROR"}, "The model mesh can't be found")
return {"CANCELLED"}
try:
work_mat_name = mat_data.material + "_temp"
work_mat, base_mat = FnMaterial.swap_materials(meshObj, work_mat_name, mat_data.material)
except MaterialNotFoundError:
self.report({"ERROR"}, "Material not found")
return {"CANCELLED"}
base_mmd_mat = base_mat.mmd_material
work_mmd_mat = work_mat.mmd_material
if mat_data.offset_type == "MULT":
try:
diffuse_offset = divide_vector_components(work_mmd_mat.diffuse_color, base_mmd_mat.diffuse_color) + [special_division(work_mmd_mat.alpha, base_mmd_mat.alpha)]
specular_offset = divide_vector_components(work_mmd_mat.specular_color, base_mmd_mat.specular_color)
edge_offset = divide_vector_components(work_mmd_mat.edge_color, base_mmd_mat.edge_color)
mat_data.diffuse_color = diffuse_offset
mat_data.specular_color = specular_offset
mat_data.shininess = special_division(work_mmd_mat.shininess, base_mmd_mat.shininess)
mat_data.ambient_color = divide_vector_components(work_mmd_mat.ambient_color, base_mmd_mat.ambient_color)
mat_data.edge_color = edge_offset
mat_data.edge_weight = special_division(work_mmd_mat.edge_weight, base_mmd_mat.edge_weight)
except ZeroDivisionError:
mat_data.offset_type = "ADD" # If there is any 0 division we automatically switch it to type ADD
logger.warning("Zero division detected, switching to ADD offset type")
except ValueError:
self.report({"ERROR"}, "An unexpected error happened")
# We should stop on our tracks and re-raise the exception
raise
if mat_data.offset_type == "ADD":
diffuse_offset = list(work_mmd_mat.diffuse_color - base_mmd_mat.diffuse_color) + [work_mmd_mat.alpha - base_mmd_mat.alpha]
specular_offset = list(work_mmd_mat.specular_color - base_mmd_mat.specular_color)
edge_offset = Vector(work_mmd_mat.edge_color) - Vector(base_mmd_mat.edge_color)
mat_data.diffuse_color = diffuse_offset
mat_data.specular_color = specular_offset
mat_data.shininess = work_mmd_mat.shininess - base_mmd_mat.shininess
mat_data.ambient_color = work_mmd_mat.ambient_color - base_mmd_mat.ambient_color
mat_data.edge_color = list(edge_offset)
mat_data.edge_weight = work_mmd_mat.edge_weight - base_mmd_mat.edge_weight
FnMaterial.clean_materials(meshObj, can_remove=lambda m: m == work_mat)
logger.info(f"Applied material offset for {mat_data.material}")
return {"FINISHED"}
class CreateWorkMaterial(bpy.types.Operator):
bl_idname = "mmd_tools.create_work_material"
bl_label = "Create Work Material"
bl_description = "Creates a temporary material to edit this offset"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
morph = mmd_root.material_morphs[mmd_root.active_morph]
mat_data = morph.data[morph.active_data]
if not mat_data.related_mesh:
self.report({"ERROR"}, "You need to choose a Related Mesh first")
return {"CANCELLED"}
meshObj = FnModel.find_mesh_object_by_name(morph.id_data, mat_data.related_mesh)
if meshObj is None:
self.report({"ERROR"}, "The model mesh can't be found")
return {"CANCELLED"}
base_mat = meshObj.data.materials.get(mat_data.material, None)
if base_mat is None:
self.report({"ERROR"}, 'Material "%s" not found' % mat_data.material)
return {"CANCELLED"}
work_mat_name = base_mat.name + "_temp"
if work_mat_name in bpy.data.materials:
self.report({"ERROR"}, 'Temporary material "%s" is in use' % work_mat_name)
return {"CANCELLED"}
work_mat = base_mat.copy()
work_mat.name = work_mat_name
meshObj.data.materials.append(work_mat)
FnMaterial.swap_materials(meshObj, base_mat.name, work_mat.name)
base_mmd_mat = base_mat.mmd_material
work_mmd_mat = work_mat.mmd_material
work_mmd_mat.material_id = -1
# Apply the offsets
if mat_data.offset_type == "MULT":
diffuse_offset = multiply_vector_components(base_mmd_mat.diffuse_color, mat_data.diffuse_color[0:3])
specular_offset = multiply_vector_components(base_mmd_mat.specular_color, mat_data.specular_color)
edge_offset = multiply_vector_components(base_mmd_mat.edge_color, mat_data.edge_color)
ambient_offset = multiply_vector_components(base_mmd_mat.ambient_color, mat_data.ambient_color)
work_mmd_mat.diffuse_color = diffuse_offset
work_mmd_mat.alpha *= mat_data.diffuse_color[3]
work_mmd_mat.specular_color = specular_offset
work_mmd_mat.shininess *= mat_data.shininess
work_mmd_mat.ambient_color = ambient_offset
work_mmd_mat.edge_color = edge_offset
work_mmd_mat.edge_weight *= mat_data.edge_weight
elif mat_data.offset_type == "ADD":
diffuse_offset = Vector(base_mmd_mat.diffuse_color) + Vector(mat_data.diffuse_color[0:3])
specular_offset = Vector(base_mmd_mat.specular_color) + Vector(mat_data.specular_color)
edge_offset = Vector(base_mmd_mat.edge_color) + Vector(mat_data.edge_color)
ambient_offset = Vector(base_mmd_mat.ambient_color) + Vector(mat_data.ambient_color)
work_mmd_mat.diffuse_color = list(diffuse_offset)
work_mmd_mat.alpha += mat_data.diffuse_color[3]
work_mmd_mat.specular_color = list(specular_offset)
work_mmd_mat.shininess += mat_data.shininess
work_mmd_mat.ambient_color = list(ambient_offset)
work_mmd_mat.edge_color = list(edge_offset)
work_mmd_mat.edge_weight += mat_data.edge_weight
logger.info(f"Created work material {work_mat_name}")
return {"FINISHED"}
class ClearTempMaterials(bpy.types.Operator):
bl_idname = "mmd_tools.clear_temp_materials"
bl_label = "Clear Temp Materials"
bl_description = "Clears all the temporary materials"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
for meshObj in FnModel.iterate_mesh_objects(root):
def __pre_remove(m: Optional[bpy.types.Material]) -> bool:
if m and "_temp" in m.name:
base_mat_name = m.name.split("_temp")[0]
try:
FnMaterial.swap_materials(meshObj, m.name, base_mat_name)
return True
except MaterialNotFoundError:
self.report({"WARNING"}, "Base material for %s was not found" % m.name)
return False
FnMaterial.clean_materials(meshObj, can_remove=__pre_remove)
logger.info("Cleared all temporary materials")
return {"FINISHED"}
class ViewBoneMorph(bpy.types.Operator):
bl_idname = "mmd_tools.view_bone_morph"
bl_label = "View Bone Morph"
bl_description = "View the result of active bone morph"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
mmd_root = root.mmd_root
armature = FnModel.find_armature_object(root)
utils.selectSingleBone(context, armature, None, True)
morph = mmd_root.bone_morphs[mmd_root.active_morph]
for morph_data in morph.data:
p_bone: Optional[bpy.types.PoseBone] = armature.pose.bones.get(morph_data.bone, None)
if p_bone:
p_bone.bone.select = True
mtx = (p_bone.matrix_basis.to_3x3() @ Quaternion(*morph_data.rotation.to_axis_angle()).to_matrix()).to_4x4()
mtx.translation = p_bone.location + morph_data.location
p_bone.matrix_basis = mtx
logger.info(f"Viewing bone morph: {morph.name}")
return {"FINISHED"}
class ClearBoneMorphView(bpy.types.Operator):
bl_idname = "mmd_tools.clear_bone_morph_view"
bl_label = "Clear Bone Morph View"
bl_description = "Reset transforms of all bones to their default values"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
armature = FnModel.find_armature_object(root)
for p_bone in armature.pose.bones:
p_bone.matrix_basis.identity()
logger.info("Cleared bone morph view")
return {"FINISHED"}
class ApplyBoneMorph(bpy.types.Operator):
bl_idname = "mmd_tools.apply_bone_morph"
bl_label = "Apply Bone Morph"
bl_description = "Apply current pose to active bone morph"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
armature = FnModel.find_armature_object(root)
mmd_root = root.mmd_root
morph = mmd_root.bone_morphs[mmd_root.active_morph]
morph.data.clear()
morph.active_data = 0
for p_bone in armature.pose.bones:
if p_bone.location.length > 0 or p_bone.matrix_basis.decompose()[1].angle > 0:
item = morph.data.add()
item.bone = p_bone.name
item.location = p_bone.location
item.rotation = p_bone.rotation_quaternion if p_bone.rotation_mode == "QUATERNION" else p_bone.matrix_basis.to_quaternion()
p_bone.bone.select = True
else:
p_bone.bone.select = False
logger.info(f"Applied current pose to bone morph: {morph.name}")
return {"FINISHED"}
class SelectRelatedBone(bpy.types.Operator):
bl_idname = "mmd_tools.select_bone_morph_offset_bone"
bl_label = "Select Related Bone"
bl_description = "Select the bone assigned to this offset in the armature"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
mmd_root = root.mmd_root
armature = FnModel.find_armature_object(root)
morph = mmd_root.bone_morphs[mmd_root.active_morph]
morph_data = morph.data[morph.active_data]
utils.selectSingleBone(context, armature, morph_data.bone)
logger.debug(f"Selected bone: {morph_data.bone}")
return {"FINISHED"}
class EditBoneOffset(bpy.types.Operator):
bl_idname = "mmd_tools.edit_bone_morph_offset"
bl_label = "Edit Related Bone"
bl_description = "Applies the location and rotation of this offset to the bone"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
mmd_root = root.mmd_root
armature = FnModel.find_armature_object(root)
morph = mmd_root.bone_morphs[mmd_root.active_morph]
morph_data = morph.data[morph.active_data]
p_bone = armature.pose.bones[morph_data.bone]
mtx = Quaternion(*morph_data.rotation.to_axis_angle()).to_matrix().to_4x4()
mtx.translation = morph_data.location
p_bone.matrix_basis = mtx
utils.selectSingleBone(context, armature, p_bone.name)
logger.debug(f"Edited bone offset for {p_bone.name}")
return {"FINISHED"}
class ApplyBoneOffset(bpy.types.Operator):
bl_idname = "mmd_tools.apply_bone_morph_offset"
bl_label = "Apply Bone Morph Offset"
bl_description = "Stores the current bone location and rotation into this offset"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
mmd_root = root.mmd_root
armature = FnModel.find_armature_object(root)
assert armature is not None
morph = mmd_root.bone_morphs[mmd_root.active_morph]
morph_data = morph.data[morph.active_data]
p_bone = armature.pose.bones[morph_data.bone]
morph_data.location = p_bone.location
morph_data.rotation = p_bone.rotation_quaternion if p_bone.rotation_mode == "QUATERNION" else p_bone.matrix_basis.to_quaternion()
logger.debug(f"Applied bone offset for {p_bone.name}")
return {"FINISHED"}
class ViewUVMorph(bpy.types.Operator):
bl_idname = "mmd_tools.view_uv_morph"
bl_label = "View UV Morph"
bl_description = "View the result of active UV morph on current mesh object"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
mmd_root = root.mmd_root
meshes = tuple(FnModel.iterate_mesh_objects(root))
if len(meshes) == 1:
obj = meshes[0]
elif obj not in meshes:
self.report({"ERROR"}, "Please select a mesh object")
return {"CANCELLED"}
meshObj = obj
bpy.ops.mmd_tools.clear_uv_morph_view()
selected = meshObj.select_get()
with bpyutils.select_object(meshObj):
mesh = cast(bpy.types.Mesh, meshObj.data)
morph = mmd_root.uv_morphs[mmd_root.active_morph]
uv_textures = mesh.uv_layers
base_uv_layers = [l for l in mesh.uv_layers if not l.name.startswith("_")]
if morph.uv_index >= len(base_uv_layers):
self.report({"ERROR"}, "Invalid uv index: %d" % morph.uv_index)
return {"CANCELLED"}
uv_layer_name = base_uv_layers[morph.uv_index].name
if morph.uv_index == 0 or uv_textures.active.name not in {uv_layer_name, "_" + uv_layer_name}:
uv_textures.active = uv_textures[uv_layer_name]
uv_layer_name = uv_textures.active.name
uv_tex = uv_textures.new(name="__uv.%s" % uv_layer_name)
if uv_tex is None:
self.report({"ERROR"}, "Failed to create a temporary uv layer")
return {"CANCELLED"}
offsets = FnMorph.get_uv_morph_offset_map(meshObj, morph).items()
offsets = {k: getattr(Vector(v), "zw" if uv_layer_name.startswith("_") else "xy") for k, v in offsets}
if len(offsets) > 0:
base_uv_data = mesh.uv_layers.active.data
temp_uv_data = mesh.uv_layers[uv_tex.name].data
for i, l in enumerate(mesh.loops):
select = temp_uv_data[i].select = l.vertex_index in offsets
if select:
temp_uv_data[i].uv = base_uv_data[i].uv + offsets[l.vertex_index]
uv_textures.active = uv_tex
uv_tex.active_render = True
meshObj.hide_set(False)
meshObj.select_set(selected)
logger.info(f"Viewing UV morph: {morph.name}")
return {"FINISHED"}
class ClearUVMorphView(bpy.types.Operator):
bl_idname = "mmd_tools.clear_uv_morph_view"
bl_label = "Clear UV Morph View"
bl_description = "Clear all temporary data of UV morphs"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
for m in FnModel.iterate_mesh_objects(root):
mesh = m.data
uv_textures = getattr(mesh, "uv_textures", mesh.uv_layers)
for t in uv_textures:
if t.name.startswith("__uv."):
uv_textures.remove(t)
if len(uv_textures) > 0:
uv_textures[0].active_render = True
uv_textures.active_index = 0
animation_data = mesh.animation_data
if animation_data:
nla_tracks = animation_data.nla_tracks
for t in nla_tracks:
if t.name.startswith("__uv."):
nla_tracks.remove(t)
if animation_data.action and animation_data.action.name.startswith("__uv."):
animation_data.action = None
if animation_data.action is None and len(nla_tracks) == 0:
mesh.animation_data_clear()
for act in bpy.data.actions:
if act.name.startswith("__uv.") and act.users < 1:
bpy.data.actions.remove(act)
logger.info("Cleared UV morph view")
return {"FINISHED"}
class EditUVMorph(bpy.types.Operator):
bl_idname = "mmd_tools.edit_uv_morph"
bl_label = "Edit UV Morph"
bl_description = "Edit UV morph on a temporary UV layer (use UV Editor to edit the result)"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
obj = context.active_object
if obj.type != "MESH":
return False
active_uv_layer = obj.data.uv_layers.active
return active_uv_layer and active_uv_layer.name.startswith("__uv.")
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
meshObj = obj
selected = meshObj.select_get()
with bpyutils.select_object(meshObj):
mesh = cast(bpy.types.Mesh, meshObj.data)
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_mode(type="VERT", action="ENABLE")
bpy.ops.mesh.reveal() # unhide all vertices
bpy.ops.mesh.select_all(action="DESELECT")
bpy.ops.object.mode_set(mode="OBJECT")
vertices = mesh.vertices
for l, d in zip(mesh.loops, mesh.uv_layers.active.data):
if d.select:
vertices[l.vertex_index].select = True
polygons = mesh.polygons
polygons.active = getattr(next((p for p in polygons if all(vertices[i].select for i in p.vertices)), None), "index", polygons.active)
bpy.ops.object.mode_set(mode="EDIT")
meshObj.select_set(selected)
logger.info("Editing UV morph")
return {"FINISHED"}
class ApplyUVMorph(bpy.types.Operator):
bl_idname = "mmd_tools.apply_uv_morph"
bl_label = "Apply UV Morph"
bl_description = "Calculate the UV offsets of selected vertices and apply to active UV morph"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
obj = context.active_object
if obj.type != "MESH":
return False
active_uv_layer = obj.data.uv_layers.active
return active_uv_layer and active_uv_layer.name.startswith("__uv.")
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
meshObj = obj
selected = meshObj.select_get()
with bpyutils.select_object(meshObj):
mesh = cast(bpy.types.Mesh, meshObj.data)
morph = mmd_root.uv_morphs[mmd_root.active_morph]
base_uv_name = mesh.uv_layers.active.name[5:]
if base_uv_name not in mesh.uv_layers:
self.report({"ERROR"}, ' * UV map "%s" not found' % base_uv_name)
return {"CANCELLED"}
base_uv_data = mesh.uv_layers[base_uv_name].data
temp_uv_data = mesh.uv_layers.active.data
axis_type = "ZW" if base_uv_name.startswith("_") else "XY"
from collections import namedtuple
__OffsetData = namedtuple("OffsetData", "index, offset")
offsets = {}
vertices = mesh.vertices
for l, i0, i1 in zip(mesh.loops, base_uv_data, temp_uv_data):
if vertices[l.vertex_index].select and l.vertex_index not in offsets:
dx, dy = i1.uv - i0.uv
if abs(dx) > 0.0001 or abs(dy) > 0.0001:
offsets[l.vertex_index] = __OffsetData(l.vertex_index, (dx, dy, dx, dy))
FnMorph.store_uv_morph_data(meshObj, morph, offsets.values(), axis_type)
morph.data_type = "VERTEX_GROUP"
meshObj.select_set(selected)
logger.info(f"Applied UV morph: {morph.name}")
return {"FINISHED"}
class CleanDuplicatedMaterialMorphs(bpy.types.Operator):
bl_idname = "mmd_tools.clean_duplicated_material_morphs"
bl_label = "Clean Duplicated Material Morphs"
bl_description = "Clean duplicated material morphs"
bl_options = {"REGISTER", "UNDO"}
@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]:
mmd_root_object = FnModel.find_root_object(context.active_object)
FnMorph.clean_duplicated_material_morphs(mmd_root_object)
logger.info("Cleaned duplicated material morphs")
return {"FINISHED"}
+593
View File
@@ -0,0 +1,593 @@
# -*- 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 math
from typing import Dict, Optional, Tuple, cast, Set, List, Any, Union, Generator
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
from ...logging_setup import logger
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: bpy.types.Context, event: Any) -> Set[str]:
vm = context.window_manager
return vm.invoke_props_dialog(self)
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return FnModel.is_rigid_body_object(context.active_object)
def execute(self, context: bpy.types.Context) -> Set[str]:
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) -> bpy.types.Object:
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: bpy.types.Context) -> bool:
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: bpy.types.Context) -> Set[str]:
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:
logger.info(f"Adding rigid bodies to {len(selected_pose_bones)} selected bones")
for pose_bone in selected_pose_bones:
rigid = self.__add_rigid_body(context, root_object, pose_bone)
rigid.select_set(True)
else:
logger.info("Adding a single rigid body without bone attachment")
rigid = self.__add_rigid_body(context, root_object)
rigid.select_set(True)
return {"FINISHED"}
def invoke(self, context: bpy.types.Context, event: Any) -> Set[str]:
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: bpy.types.Context) -> bool:
return FnModel.is_rigid_body_object(context.active_object)
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
logger.info(f"Removing rigid body: {obj.name}")
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) -> Set[str]:
logger.info("Baking rigid body simulation")
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) -> Set[str]:
logger.info("Deleting rigid body simulation bake")
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]]) -> Generator[Tuple[bpy.types.Object, bpy.types.Object], None, None]:
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: Dict[bpy.types.Object, Optional[bpy.types.Bone]]) -> bpy.types.Object:
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: bpy.types.Context) -> bool:
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: bpy.types.Context) -> Set[str]:
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:
logger.info("Creating rigid body world")
bpy.ops.rigidbody.world_add()
joint_count = 0
for pair in self.__enumerate_rigid_pair(bone_map):
joint = self.__add_joint(context, root_object, pair, bone_map)
joint.select_set(True)
joint_count += 1
logger.info(f"Added {joint_count} joints between rigid bodies")
return {"FINISHED"}
def invoke(self, context: bpy.types.Context, event: Any) -> Set[str]:
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: bpy.types.Context) -> bool:
return FnModel.is_joint_object(context.active_object)
def execute(self, context: bpy.types.Context) -> Set[str]:
obj = context.active_object
root = FnModel.find_root_object(obj)
logger.info(f"Removing joint: {obj.name}")
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() -> Tuple[bpy.types.Collection, bpy.types.Collection]:
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: bpy.types.Context) -> Set[str]:
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: bpy.types.Object, group: bpy.types.Collection) -> bool:
if obj in scene_objs:
if obj not in group.values():
group.link(obj)
return True
elif obj in group.values():
group.unlink(obj)
return False
def _references(obj: bpy.types.Object) -> Generator[bpy.types.Object, None, None]:
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.
logger.info("Updating rigid body world objects")
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
logger.info("Updating rigid body constraints")
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:
logger.info("Rebuilding physics for models")
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"}
+116
View File
@@ -0,0 +1,116 @@
# -*- 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 typing import Set, Tuple
import bpy
from bpy.types import Operator, Context, Object
from ..core.model import FnModel
from ..core.sdef import FnSDEF
from ....core.logging_setup import logger
def _get_target_objects(context: Context) -> Tuple[Set[Object], Set[Object]]:
root_objects: Set[Object] = set()
selected_objects: Set[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: Context) -> Set[str]:
target_meshes, _ = _get_target_objects(context)
logger.info(f"Resetting SDEF cache for {len(target_meshes)} objects")
for i in target_meshes:
FnSDEF.clear_cache(i)
FnSDEF.clear_cache(unused_only=True)
logger.debug("SDEF cache reset completed")
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: Context, event: bpy.types.Event) -> Set[str]:
vm = context.window_manager
return vm.invoke_props_dialog(self)
def execute(self, context: Context) -> Set[str]:
target_meshes, root_objects = _get_target_objects(context)
logger.info(f"Binding SDEF for {len(target_meshes)} objects with mode={self.mode}, skip={self.use_skip}, scale={self.use_scale}")
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)
logger.info(f"Successfully bound SDEF for {count} of {len(target_meshes)} 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"}
def execute(self, context: Context) -> Set[str]:
target_meshes, root_objects = _get_target_objects(context)
logger.info(f"Unbinding SDEF for {len(target_meshes)} objects")
for i in target_meshes:
FnSDEF.unbind(i)
for r in root_objects:
r.mmd_root.use_sdef = False
logger.debug("SDEF unbinding completed")
return {"FINISHED"}
+336
View File
@@ -0,0 +1,336 @@
# -*- 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 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
return obj in context.selected_objects and FnModel.find_root_object(obj)
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"}, "Failed to load dictionary: %s" % 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, "translate_%s" % 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):
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_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.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):
return FnModel.find_root_object(context.object) 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")
# row.operator(ExecuteTranslationScriptOperator.bl_idname, text='Write to .csv')
translation_box.separator()
row = translation_box.row()
row.prop(mmd_translation, "dictionary", text="replace")
def invoke(self, context: bpy.types.Context, _event):
root_object = FnModel.find_root_object(context.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.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.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"}
+158
View File
@@ -0,0 +1,158 @@
# -*- 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 re
from typing import Optional, Tuple, List, Set, Dict, Any, Generator, Callable, Union, Type, Iterator
from bpy.types import Operator, Context
from mathutils import Matrix, Vector, Quaternion
from ...logging_setup import logger
class _SetShadingBase:
bl_options: Set[str] = {"REGISTER", "UNDO"}
@staticmethod
def _get_view3d_spaces(context: Context) -> Iterator[Any]:
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: Context, use_display_device: bool = True) -> None:
try:
context.scene.display_settings.display_device = ("None", "sRGB")[use_display_device]
except TypeError:
pass
@staticmethod
def _reset_material_shading(context: Context, use_shadeless: bool = False) -> None:
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
s.material.use_nodes = False
s.material.use_shadeless = use_shadeless
def execute(self, context: Context) -> Dict[str, str]:
context.scene.render.engine = "BLENDER_EEVEE_NEXT"
logger.debug(f"Setting render engine to BLENDER_EEVEE_NEXT")
shading_mode: Optional[str] = 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
logger.debug(f"Applied shading mode: {shading_mode or 'DEFAULT'}")
return {"FINISHED"}
class SetGLSLShading(Operator, _SetShadingBase):
bl_idname: str = "mmd_tools.set_glsl_shading"
bl_label: str = "GLSL View"
bl_description: str = "Use GLSL shading with additional lighting"
_shading_mode: str = "GLSL"
class SetShadelessGLSLShading(Operator, _SetShadingBase):
bl_idname: str = "mmd_tools.set_shadeless_glsl_shading"
bl_label: str = "Shadeless GLSL View"
bl_description: str = "Use only toon shading"
_shading_mode: str = "SHADELESS"
class ResetShading(Operator, _SetShadingBase):
bl_idname: str = "mmd_tools.reset_shading"
bl_label: str = "Reset View"
bl_description: str = "Reset to default Blender shading"
class FlipPose(Operator):
bl_idname: str = "mmd_tools.flip_pose"
bl_label: str = "Flip Pose"
bl_description: str = "Apply the current pose of selected bones to matching bone on opposite side of X-Axis."
bl_options: Set[str] = {"REGISTER", "UNDO"}
# https://docs.blender.org/manual/en/dev/rigging/armatures/bones/editing/naming.html
__LR_REGEX: List[Dict[str, Any]] = [
{"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: Dict[str, str] = {
"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: str) -> str:
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: Union[Vector, Quaternion], vec2: Tuple[float, float, float, float]) -> Union[Vector, Quaternion]:
return type(vec1)([x * y for x, y in zip(vec1, vec2)])
@staticmethod
def __matrix_compose(loc: Vector, rot: Quaternion, scale: Vector) -> Matrix:
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: Matrix, bone_src: Any, bone_dest: Any) -> None:
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: Context) -> bool:
return context.active_object and context.active_object.type == "ARMATURE" and context.active_object.mode == "POSE"
def execute(self, context: Context) -> Dict[str, str]:
logger.info("Executing flip pose operation")
pose_bones = context.active_object.pose.bones
for b, mat in [(x, x.matrix_basis.copy()) for x in context.selected_pose_bones]:
flip_name = self.flip_name(b.name)
target_bone = pose_bones.get(flip_name, b)
logger.debug(f"Flipping pose from {b.name} to {target_bone.name}")
self.__flip_pose(mat, b, target_bone)
logger.info("Flip pose operation completed")
return {"FINISHED"}
+34
View File
@@ -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
+293
View File
@@ -0,0 +1,293 @@
# -*- 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, Type
from .. import utils
from ..core import material
from ..core.material import FnMaterial
from ..core.model import FnModel
from . import patch_library_overridable
from ....core.logging_setup import logger
def _mmd_material_update_ambient_color(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
FnMaterial(prop.id_data).update_ambient_color()
def _mmd_material_update_diffuse_color(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
FnMaterial(prop.id_data).update_diffuse_color()
def _mmd_material_update_alpha(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
FnMaterial(prop.id_data).update_alpha()
def _mmd_material_update_specular_color(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
FnMaterial(prop.id_data).update_specular_color()
def _mmd_material_update_shininess(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
FnMaterial(prop.id_data).update_shininess()
def _mmd_material_update_is_double_sided(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
FnMaterial(prop.id_data).update_is_double_sided()
def _mmd_material_update_sphere_texture_type(prop: "MMDMaterial", context: bpy.types.Context) -> None:
FnMaterial(prop.id_data).update_sphere_texture_type(context.active_object)
def _mmd_material_update_toon_texture(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
FnMaterial(prop.id_data).update_toon_texture()
def _mmd_material_update_enabled_drop_shadow(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
FnMaterial(prop.id_data).update_drop_shadow()
def _mmd_material_update_enabled_self_shadow_map(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
FnMaterial(prop.id_data).update_self_shadow_map()
def _mmd_material_update_enabled_self_shadow(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
FnMaterial(prop.id_data).update_self_shadow()
def _mmd_material_update_enabled_toon_edge(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
FnMaterial(prop.id_data).update_enabled_toon_edge()
def _mmd_material_update_edge_color(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
FnMaterial(prop.id_data).update_edge_color()
def _mmd_material_update_edge_weight(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
FnMaterial(prop.id_data).update_edge_weight()
def _mmd_material_get_name_j(prop: "MMDMaterial") -> str:
return prop.get("name_j", "")
def _mmd_material_set_name_j(prop: "MMDMaterial", value: str) -> None:
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:
logger.debug(f"No root object found, using unique name for material: {value}")
prop_value = utils.unique_name(value, {mat.mmd_material.name_j for mat in bpy.data.materials})
else:
logger.debug(f"Root object found, using unique name for material within model: {value}")
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) -> bool:
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() -> None:
logger.debug("Registering MMD material properties")
bpy.types.Material.mmd_material = patch_library_overridable(bpy.props.PointerProperty(type=MMDMaterial))
@staticmethod
def unregister() -> None:
logger.debug("Unregistering MMD material properties")
del bpy.types.Material.mmd_material
+494
View File
@@ -0,0 +1,494 @@
# -*- 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, List, Dict, Any, Set, Tuple, Union, TypeVar, Type
from bpy.types import PropertyGroup, Object, ShapeKey
from .. import utils
from ..core.bone import FnBone
from ..core.material import FnMaterial
from ..core.model import FnModel, Model
from ..core.morph import FnMorph
from ....core.logging_setup import logger
def _morph_base_get_name(prop: "_MorphBase") -> str:
return prop.get("name", "")
def _morph_base_set_name(prop: "_MorphBase", value: str) -> None:
mmd_root = prop.id_data.mmd_root
morph_type = "%s_morphs" % prop.bl_rna.identifier[:-5].lower()
prop_name = prop.get("name", None)
if prop_name == value:
return
used_names: Set[str] = {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: Dict[str, List[ShapeKey]] = {}
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: Dict[str, List[Any]] = {}
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
logger.debug(f"Renamed morph from '{prop_name}' to '{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_get_bone(prop: "BoneMorphData") -> str:
bone_id: int = prop.get("bone_id", -1)
if bone_id < 0:
return ""
root_object: Object = prop.id_data
armature_object: Optional[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) -> None:
root: Object = prop.id_data
arm: Optional[Object] = 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)
logger.debug(f"Set bone morph data bone to '{value}' with ID {prop['bone_id']}")
def _bone_morph_data_update_location_or_rotation(prop: "BoneMorphData", _context: bpy.types.Context) -> None:
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
logger.debug(f"Updated bone morph data location/rotation for '{prop.name}'")
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",
)
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") -> str:
mat_p = prop.get("material_data", None)
if mat_p is not None:
return mat_p.name
return ""
def _material_morph_data_set_material(prop: "MaterialMorphData", value: str) -> None:
if value not in bpy.data.materials:
prop["material_data"] = None
prop["material_id"] = -1
logger.debug(f"Material '{value}' not found, setting material_data to None")
else:
mat = bpy.data.materials[value]
fnMat = FnMaterial(mat)
prop["material_data"] = mat
prop["material_id"] = fnMat.material_id
logger.debug(f"Set material morph data material to '{value}' with ID {fnMat.material_id}")
def _material_morph_data_set_related_mesh(prop: "MaterialMorphData", value: str) -> None:
mesh = FnModel.find_mesh_object_by_name(prop.id_data, value)
if mesh is not None:
prop["related_mesh_data"] = mesh.data
logger.debug(f"Set material morph data related mesh to '{value}'")
else:
prop["related_mesh_data"] = None
logger.debug(f"Mesh '{value}' not found, setting related_mesh_data to None")
def _material_morph_data_get_related_mesh(prop: "MaterialMorphData") -> str:
mesh_p = prop.get("related_mesh_data", None)
if mesh_p is not None:
return mesh_p.name
return ""
def _material_morph_data_update_modifiable_values(prop: "MaterialMorphData", _context: bpy.types.Context) -> None:
if not prop.name.startswith("mmd_bind"):
return
from ..core.shader import _MaterialMorph
mat = prop["material_data"]
if mat is not None:
_MaterialMorph.update_morph_inputs(mat, prop)
logger.debug(f"Updated material morph modifiable values for '{prop.name}'")
else:
for mat in FnModel(prop.id_data).materials():
_MaterialMorph.update_morph_inputs(mat, prop)
logger.debug(f"Updated material morph modifiable values for all materials")
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,
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"""
+230
View File
@@ -0,0 +1,230 @@
# -*- 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 typing import cast, Optional, Any, Union
import bpy
from bpy.types import Context, PropertyGroup, PoseBone, Object, Armature
from ..core.bone import FnBone
from . import patch_library_overridable
from ....core.logging_setup import logger
def _mmd_bone_update_additional_transform(prop: "MMDBone", context: Context) -> None:
prop["is_additional_transform_dirty"] = True
p_bone = context.active_pose_bone
if p_bone and p_bone.mmd_bone.as_pointer() == prop.as_pointer():
logger.debug(f"Applying additional transformation for {p_bone.name}")
FnBone.apply_additional_transformation(prop.id_data)
def _mmd_bone_update_additional_transform_influence(prop: "MMDBone", context: Context) -> None:
pose_bone = context.active_pose_bone
if pose_bone and pose_bone.mmd_bone.as_pointer() == prop.as_pointer():
logger.debug(f"Updating additional transform influence for {pose_bone.name}")
FnBone.update_additional_transform_influence(pose_bone)
else:
prop["is_additional_transform_dirty"] = True
def _mmd_bone_get_additional_transform_bone(prop: "MMDBone") -> str:
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) -> None:
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]
prop["additional_transform_bone_id"] = FnBone.get_or_assign_bone_id(pose_bone)
class MMDBone(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)
def is_id_unique(self) -> bool:
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() -> None:
logger.debug("Registering MMDBone properties")
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() -> None:
logger.debug("Unregistering MMDBone properties")
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: PoseBone, _context: Any) -> None:
v = prop.mmd_ik_toggle
armature_object = cast(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:
logger.debug(f"Updating IK toggle for {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
+303
View File
@@ -0,0 +1,303 @@
# -*- 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.
"""Properties for rigid bodies and joints"""
import bpy
from typing import Optional, Any, Set, List, Dict, Tuple, Union
from bpy.types import Context, Object, PropertyGroup, Material
from .. import bpyutils
from ..core import rigid_body
from ..core.rigid_body import RigidBodyMaterial, FnRigidBody
from ..core.model import FnModel
from . import patch_library_overridable
from ....core.logging_setup import logger
def _updateCollisionGroup(prop: PropertyGroup, _context: Context) -> None:
obj: Object = prop.id_data
materials: List[Material] = 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: PropertyGroup, _context: Context) -> None:
obj: Object = prop.id_data
rb = obj.rigid_body
if rb:
rb.kinematic = int(prop.type) == rigid_body.MODE_STATIC
def _updateShape(prop: PropertyGroup, _context: Context) -> None:
obj: Object = 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: PropertyGroup) -> str:
obj: Object = 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: PropertyGroup, value: str) -> None:
bone_name: str = value
obj: Object = 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: PropertyGroup) -> Tuple[float, float, float]:
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: PropertyGroup, value: Tuple[float, float, float]) -> None:
obj: Object = prop.id_data
assert obj.mode == "OBJECT" # not support other mode yet
shape: str = prop.shape
mesh = obj.data
rb = obj.rigid_body
if len(mesh.vertices) == 0 or rb is None or rb.collision_shape != shape:
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: PropertyGroup) -> str:
return prop.get("name", "")
def _set_rigid_name(prop: PropertyGroup, value: str) -> None:
prop["name"] = value
class MMDRigidBody(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() -> None:
logger.debug("Registering MMDRigidBody property")
bpy.types.Object.mmd_rigid = patch_library_overridable(bpy.props.PointerProperty(type=MMDRigidBody))
@staticmethod
def unregister() -> None:
logger.debug("Unregistering MMDRigidBody property")
del bpy.types.Object.mmd_rigid
def _updateSpringLinear(prop: PropertyGroup, context: Context) -> None:
obj: Object = 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: PropertyGroup, context: Context) -> None:
obj: Object = 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(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() -> None:
logger.debug("Registering MMDJoint property")
bpy.types.Object.mmd_joint = patch_library_overridable(bpy.props.PointerProperty(type=MMDJoint))
@staticmethod
def unregister() -> None:
logger.debug("Unregistering MMDJoint property")
del bpy.types.Object.mmd_joint
+593
View File
@@ -0,0 +1,593 @@
# -*- 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.
"""Properties for MMD model root object"""
import bpy
from typing import Optional, List, Dict, Any, Set, Tuple, Union, Type, TypeVar, cast
from .. import utils
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
from ....core.logging_setup import logger
def __driver_variables(constraint: bpy.types.Constraint, path: str, index: int = -1) -> Tuple[bpy.types.Driver, Any]:
d = constraint.driver_add(path, index)
variables = d.driver.variables
for x in variables:
variables.remove(x)
return d.driver, variables
def __add_single_prop(variables: Any, id_obj: bpy.types.Object, data_path: str, prefix: str) -> Any:
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: bpy.types.Context) -> None:
root_object: bpy.types.Object = self.id_data
armature_object = FnModel.find_armature_object(root_object)
if armature_object is None:
ik_map: Dict[Any, Tuple[Any, Any]] = {}
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:
logger.debug("Enabling property drivers for %s", root_object.name)
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:
logger.debug("Disabling property drivers for %s", root_object.name)
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: bpy.types.Context) -> None:
use_toon = self.use_toon_texture
logger.debug("Toggling toon texture to %s for %s", use_toon, self.id_data.name)
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: bpy.types.Context) -> None:
use_sphere = self.use_sphere_texture
logger.debug("Toggling sphere texture to %s for %s", use_sphere, self.id_data.name)
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: bpy.types.Context) -> None:
mute_sdef = not self.use_sdef
logger.debug("Toggling SDEF to %s for %s", not mute_sdef, self.id_data.name)
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) -> None:
root = self.id_data
hide = not self.show_meshes
logger.debug("Toggling mesh visibility to %s for %s", not hide, root.name)
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) -> None:
root = self.id_data
hide = not self.show_rigid_bodies
logger.debug("Toggling rigid body visibility to %s for %s", not hide, root.name)
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: bpy.types.Context) -> None:
root_object = self.id_data
hide = not self.show_joints
logger.debug("Toggling joint visibility to %s for %s", not hide, root_object.name)
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) -> None:
root_object: bpy.types.Object = self.id_data
hide = not self.show_temporary_objects
logger.debug("Toggling temporary object visibility to %s for %s", not hide, root_object.name)
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: bpy.types.Context) -> None:
root = self.id_data
show_names = root.mmd_root.show_names_of_rigid_bodies
logger.debug("Toggling rigid body names to %s for %s", show_names, root.name)
for i in FnModel.iterate_rigid_body_objects(root):
i.show_name = show_names
def _toggleShowNamesOfJoints(self: "MMDRoot", _context: bpy.types.Context) -> None:
root = self.id_data
show_names = root.mmd_root.show_names_of_joints
logger.debug("Toggling joint names to %s for %s", show_names, root.name)
for i in FnModel.iterate_joint_objects(root):
i.show_name = show_names
def _setVisibilityOfMMDRigArmature(prop: "MMDRoot", v: bool) -> None:
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)
logger.debug("Setting armature visibility to %s for %s", v, root.name)
arm.hide_set(not v)
def _getVisibilityOfMMDRigArmature(prop: "MMDRoot") -> bool:
if prop.id_data.mmd_type != "ROOT":
return False
arm = FnModel.find_armature_object(prop.id_data)
return arm and not arm.hide_get()
def _setActiveRigidbodyObject(prop: "MMDRoot", v: int) -> None:
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") -> int:
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) -> None:
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") -> int:
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) -> None:
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") -> int:
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) -> None:
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") -> int:
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,
)
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,
)
# *************************
# 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:
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:
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:
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:
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 register() -> None:
logger.debug("Registering MMDRoot property group")
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",
},
)
)
@staticmethod
def unregister() -> None:
logger.debug("Unregistering MMDRoot property group")
del bpy.types.Object.hide
del bpy.types.Object.select
del bpy.types.Object.mmd_root
del bpy.types.Object.mmd_type
+127
View File
@@ -0,0 +1,127 @@
# -*- 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 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()
+516
View File
@@ -0,0 +1,516 @@
# -*- 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 csv
import time
from typing import List, Tuple, Dict, Optional, Any, Generator, Union, TextIO, Iterator, Set
import bpy
from bpy.types import Text, Context
from .bpyutils import FnContext
from ..logging_setup import logger
# Type definitions for translation tuples
TranslationTuple = Tuple[str, str]
TranslationList = List[TranslationTuple]
jp_half_to_full_tuples: TranslationList = (
("ヴ", ""),
("ガ", ""),
("ギ", ""),
("グ", ""),
("ゲ", ""),
("ゴ", ""),
("ザ", ""),
("ジ", ""),
("ズ", ""),
("ゼ", ""),
("ゾ", ""),
("ダ", ""),
("ヂ", ""),
("ヅ", ""),
("デ", ""),
("ド", ""),
("バ", ""),
("パ", ""),
("ビ", ""),
("ピ", ""),
("ブ", ""),
("プ", ""),
("ベ", ""),
("ペ", ""),
("ボ", ""),
("ポ", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("ソ", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
("", ""),
)
jp_to_en_tuples: TranslationList = [
("全ての親", "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"),
("", "1"),
("", "2"),
("", "3"),
("", "4"),
("", "5"),
("", "6"),
("", "7"),
("", "8"),
("", "9"),
("", "a"),
("", "b"),
("", "c"),
("", "d"),
("", "e"),
("", "f"),
("", "g"),
("", "h"),
("", "i"),
("", "j"),
("", "k"),
("", "l"),
("", "m"),
("", "n"),
("", "o"),
("", "p"),
("", "q"),
("", "r"),
("", "s"),
("", "t"),
("", "u"),
("", "v"),
("", "w"),
("", "x"),
("", "y"),
("", "z"),
("", "A"),
("", "B"),
("", "C"),
("", "D"),
("", "E"),
("", "F"),
("", "G"),
("", "H"),
("", "I"),
("", "J"),
("", "K"),
("", "L"),
("", "M"),
("", "N"),
("", "O"),
("", "P"),
("", "Q"),
("", "R"),
("", "S"),
("", "T"),
("", "U"),
("", "V"),
("", "W"),
("", "X"),
("", "Y"),
("", "Z"),
("", "+"),
("", "-"),
("_", "_"),
("", "/"),
(".", "_"), # probably should be combined with the global 'use underscore' option
]
def translateFromJp(name: str) -> str:
"""Translate a Japanese name to English using the translation tuples."""
logger.debug(f"Translating from Japanese: {name}")
for tuple in jp_to_en_tuples:
if tuple[0] in name:
name = name.replace(tuple[0], tuple[1])
logger.debug(f"Translation result: {name}")
return name
def getTranslator(csvfile: Union[str, Dict[str, str], Text] = "", keep_order: bool = False) -> 'MMDTranslator':
"""Get a translator instance with the specified CSV file."""
translator = MMDTranslator()
if isinstance(csvfile, bpy.types.Text):
logger.debug(f"Loading translator from Text object: {csvfile.name}")
translator.load_from_stream(csvfile)
elif isinstance(csvfile, dict):
logger.debug(f"Loading translator from dictionary with {len(csvfile)} entries")
translator.csv_tuples.extend(csvfile.items())
elif csvfile in bpy.data.texts.keys():
logger.debug(f"Loading translator from text data: {csvfile}")
translator.load_from_stream(bpy.data.texts[csvfile])
else:
logger.debug(f"Loading translator from file: {csvfile}")
translator.load(csvfile)
if not keep_order:
translator.sort()
translator.update()
return translator
class MMDTranslator:
"""Handles translation of Japanese text to English for MMD models."""
def __init__(self) -> None:
self.__csv_tuples: List[Tuple[str, str]] = []
self.__fails: Dict[str, str] = {}
@staticmethod
def default_csv_filepath() -> str:
"""Get the default CSV filepath for translations."""
return __file__[:-3] + ".csv"
@staticmethod
def get_csv_text(text_name: Optional[str] = None) -> Text:
"""Get or create a Text object for CSV data."""
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: str, tuples: List[Tuple[str, str]]) -> str:
"""Replace parts of a string based on translation tuples."""
for pair in tuples:
if pair[0] in name:
name = name.replace(pair[0], pair[1])
return name
@property
def csv_tuples(self) -> List[Tuple[str, str]]:
"""Get the CSV tuples."""
return self.__csv_tuples
@property
def fails(self) -> Dict[str, str]:
"""Get the failed translations."""
return self.__fails
def sort(self) -> None:
"""Sort the CSV tuples by length (longest first) and then alphabetically."""
logger.debug("Sorting translation tuples")
self.__csv_tuples.sort(key=lambda row: (-len(row[0]), row))
def update(self) -> None:
"""Update the CSV tuples, removing duplicates."""
from collections import OrderedDict
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("Translation update - removed items: %d (of %d)", count_old - len(self.__csv_tuples), count_old)
def half_to_full(self, name: str) -> str:
"""Convert half-width Japanese characters to full-width."""
return self.replace_from_tuples(name, jp_half_to_full_tuples)
def is_translated(self, name: str) -> bool:
"""Check if a string is already translated (contains only ASCII characters)."""
try:
name.encode("ascii", errors="strict")
except UnicodeEncodeError:
return False
return True
def translate(self, name: str, default: Optional[str] = None, from_full_width: bool = True) -> str:
"""Translate a string from Japanese to English."""
logger.debug(f"Translating: {name}")
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):
logger.warning(f"Translation failed for: {name}")
self.__fails[name] = name_new
return default
return name_new
def save_fails(self, text_name: Optional[str] = None) -> Text:
"""Save failed translations to a Text object."""
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))
logger.info(f"Saved {len(items)} failed translations to {text_name}")
return txt
def load_from_stream(self, csvfile: Union[Text, Iterator[str]] = None) -> None:
"""Load translations from a stream."""
csvfile = csvfile or self.get_csv_text()
if isinstance(csvfile, bpy.types.Text):
csvfile = (l.body + "\n" for l 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("Loaded %d translation items", len(self.__csv_tuples))
def save_to_stream(self, csvfile: Union[Text, TextIO] = None) -> None:
"""Save translations to a stream.
Args:
csvfile: The CSV file or stream to save to
"""
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("Saved %d translation items", len(self.__csv_tuples))
def load(self, filepath: Optional[str] = None) -> None:
"""Load translations from a file."""
filepath = filepath or self.default_csv_filepath()
logger.info("Loading CSV file: %s", filepath)
try:
with open(filepath, "rt", encoding="utf-8", newline="") as csvfile:
self.load_from_stream(csvfile)
except Exception as e:
logger.error(f"Failed to load CSV file: {e}")
def save(self, filepath: Optional[str] = None) -> None:
"""Save translations to a file."""
filepath = filepath or self.default_csv_filepath()
logger.info("Saving CSV file: %s", filepath)
try:
with open(filepath, "wt", encoding="utf-8", newline="") as csvfile:
self.save_to_stream(csvfile)
except Exception as e:
logger.error(f"Failed to save CSV file: {e}")
class DictionaryEnum:
"""Handles dictionary enumeration for UI."""
__items_ttl: float = 0.0
__items_cache: Optional[List[Tuple[str, str, str, int]]] = None
@staticmethod
def get_dictionary_items(prop: Any, context: Context) -> List[Tuple[str, str, str, Union[int, str], int]]:
"""Get dictionary items for UI enumeration."""
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)))
import os
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)
logger.debug(f"Found {len(items)} dictionary items")
return items
@staticmethod
def get_translator(dictionary: str) -> Optional[MMDTranslator]:
"""Get a translator for the specified dictionary."""
if dictionary == "DISABLED":
logger.debug("Translation disabled")
return None
if dictionary == "INTERNAL":
logger.debug("Using internal dictionary")
return getTranslator(dict(jp_to_en_tuples))
logger.debug(f"Using dictionary: {dictionary}")
return getTranslator(dictionary)
+332
View File
@@ -0,0 +1,332 @@
# -*- 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 re
from typing import Callable, Dict, List, Optional, Set, Tuple, Union, Any
import bpy
from bpy.types import Object, Bone, PoseBone, Mesh, VertexGroup
from ..logging_setup import logger
from .bpyutils import FnContext
## 指定したオブジェクトのみを選択状態かつアクティブにする
def selectAObject(obj: Object) -> None:
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: Object) -> None:
selectAObject(obj)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
def setParentToBone(obj: Object, parent: Object, bone_name: str) -> None:
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: bpy.types.Context, armature: Object, bone_name: str, reset_pose: bool = False) -> None:
try:
bpy.ops.object.mode_set(mode="OBJECT")
except:
pass
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()
armature_bones: bpy.types.ArmatureBones = armature.data.bones
i: Bone
for i in armature_bones:
i.select = i.name == bone_name
i.select_head = i.select_tail = i.select
if i.select:
armature_bones.active = i
i.hide = False
__CONVERT_NAME_TO_L_REGEXP = re.compile("^(.*)左(.*)$")
__CONVERT_NAME_TO_R_REGEXP = re.compile("^(.*)右(.*)$")
## 日本語で左右を命名されている名前をblender方式のL(R)に変更する
def convertNameToLR(name: str, use_underscore: bool = False) -> str:
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: str) -> str:
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: Object, src_vertex_group_name: str, dest_vertex_group_name: str) -> None:
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: Object) -> None:
if len(meshObj.data.materials) < 2:
selectAObject(meshObj)
return
matrix_parent_inverse = meshObj.matrix_parent_inverse.copy()
prev_parent = meshObj.parent
dummy_parent = bpy.data.objects.new(name="tmp", object_data=None)
meshObj.parent = dummy_parent
meshObj.active_shape_key_index = 0
try:
enterEditMode(meshObj)
bpy.ops.mesh.select_all(action="SELECT")
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
bpy.data.objects.remove(dummy_parent)
def clearUnusedMeshes() -> None:
meshes_to_delete = []
for mesh in bpy.data.meshes:
if mesh.users == 0:
meshes_to_delete.append(mesh)
for mesh in meshes_to_delete:
bpy.data.meshes.remove(mesh)
## Boneのカスタムプロパティにname_jが存在する場合、name_jの値を
# それ以外の場合は通常のbone名をキーとしたpose_boneへの辞書を作成
def makePmxBoneMap(armObj: Object) -> Dict[str, PoseBone]:
# Maintain backward compatibility with mmd_tools 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:
"""Helper function for storing unique names.
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: int, base: int, width: int = 0) -> str:
"""
Method to convert an int to a base
Source: http://stackoverflow.com/questions/2267362
"""
import string
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: str, start: str, strategy: str = "inside") -> str:
"""
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: bpy.types.bpy_prop_collection, index: int) -> Optional[Any]:
if 0 <= index < len(items):
return items[index]
return None
@staticmethod
def resize(items: bpy.types.bpy_prop_collection, length: int) -> None:
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: bpy.types.bpy_prop_collection, index: int) -> Tuple[Any, int]:
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: bpy.types.bpy_prop_collection, index: int, move_type: str,
index_min: int = 0, index_max: Optional[int] = None) -> int:
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
elif 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) -> Callable:
"""Decorator to 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) -> Callable:
def _inner_wrapper(*args: Any, **kwargs: Any) -> Any:
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:
"""Reports 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,
)
+255 -2
View File
@@ -43,6 +43,13 @@ 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"""
@@ -60,6 +67,74 @@ def get_mesh_objects(self, context):
return [('NONE', t("Visemes.no_meshes"), '')]
return meshes
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"""
@@ -190,6 +265,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(
@@ -458,13 +534,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(
@@ -588,12 +666,179 @@ class AvatarToolkitSceneProperties(PropertyGroup):
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
)
# 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:
logger.info(f"Active armature set to: {self.active_armature}")
# Deselect all objects first
bpy.ops.object.select_all(action='DESELECT')
# Select and make active the chosen armature
self.active_armature.select_set(True)
context.view_layer.objects.active = self.active_armature
logger.info(f"Selected and activated armature: {self.active_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.info("No armature selected")
def register() -> None:
"""Register the Avatar Toolkit property group"""
logger.info("Registering Avatar Toolkit properties")
# 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")
@@ -601,6 +846,14 @@ 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:
-1
View File
@@ -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
+10 -8
View File
@@ -1,14 +1,16 @@
import traceback
from types import FrameType
import bpy
import bpy_extras
from numpy import double
from typing import Set, Dict
import re
import traceback
from .common import get_active_armature, simplify_bonename, validate_armature, ProgressTracker, identify_bones
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
@@ -77,7 +79,7 @@ class AvatarToolkit_OT_ConvertResonite(Operator):
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,context).items():
for key_simple,bone_name in identify_bones(arm_data).items():
if key_simple in resonite_translations:
new_name = resonite_translations[key_simple]
@@ -91,16 +93,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")
+600
View File
@@ -0,0 +1,600 @@
# 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):
with open(self._cache_file, 'r', encoding='utf-8') 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"""
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"""
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
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"""
if not armature or armature.type != 'ARMATURE':
return []
jobs = []
for bone in armature.data.bones:
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"""
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:
jobs.append(TranslationJob(
name=shape_key.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"""
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:
jobs.append(TranslationJob(
name=material.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"""
if object_types is None:
object_types = {'MESH', 'ARMATURE', 'EMPTY'}
jobs = []
for obj in bpy.data.objects:
if obj.type in object_types:
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
+942
View File
@@ -0,0 +1,942 @@
# 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
@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"""
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 []
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"""
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 []
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"""
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 []
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"""
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
+1 -1
View File
@@ -20,7 +20,7 @@ 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 updates
ALLOWED_VERSION_SERIES = ["0.2"]
ALLOWED_VERSION_SERIES = ["0.4"]
is_checking_for_update: bool = False
update_needed: bool = False
+486
View File
@@ -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
+2 -1
View File
@@ -8,6 +8,7 @@ from ..core.common import SceneMatClass, MaterialListBool, ProgressTracker
from ..core.packer.rectangle_packer import MaterialImageList, BinPacker
from ..core.translations import t
from ..core.logging_setup import logger
import traceback
class MaterialImageList:
def __init__(self):
@@ -306,6 +307,6 @@ class AvatarToolKit_OT_AtlasMaterials(Operator):
return {"FINISHED"}
except Exception as e:
logger.error(f"Error creating material atlas: {str(e)}", exc_info=True)
logger.error(f"Error creating material atlas: {traceback.format_exc()}", exc_info=True)
self.report({'ERROR'}, t("TextureAtlas.atlas_error"))
raise e
View File
+85 -52
View File
@@ -1,16 +1,22 @@
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.dictionaries import bone_names
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,
identify_bones,
store_breaking_settings_armature,
restore_breaking_settings_armature,
)
from ...core.dictionaries import simplify_bonename
@@ -23,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)
@@ -40,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)
@@ -68,12 +102,41 @@ class AvatarToolkit_OT_MergeArmature(bpy.types.Operator):
wm.progress_update(100)
wm.progress_end()
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))
logger.error(f"Error merging armatures: {str(e)}\n{traceback.format_exc()}")
self.report({'ERROR'}, traceback.format_exc())
# 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:
@@ -148,6 +211,9 @@ def merge_armatures(
# 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):
@@ -169,50 +235,30 @@ 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]] = {}
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
#create reverse lookup
reverse_bone_lookup = {}
for preferred_name, name_list in bone_names.items():
for name in name_list:
reverse_bone_lookup[name] = preferred_name
# Get base bone names
base_bone_names: Set[str] = {bone.name for bone in base_armature.data.bones}
base_armature_standards: Dict[str,Optional[str]] = {}
for bone in base_bone_names:
if simplify_bonename(bone) in reverse_bone_lookup:
base_armature_standards[reverse_bone_lookup[simplify_bonename(bone)]] = bone
# 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/removing to target armature.
bone_names_source: list[str] = [bone.name for bone in merge_armature_data.edit_bones]
for bone in bone_names_source:
bone_name = bone
if bone_name not in base_bone_names: #not auto mergable to original
if simplify_bonename(bone_name) in reverse_bone_lookup: #if is a standard bone through standard translation.
if reverse_bone_lookup[simplify_bonename(bone_name)] in base_armature_standards: #if this bone equals for example, "hips", does a bone that should be "hips" exist on our target armature?
#if so, rename this bone to that one
merge_armature_data.edit_bones[bone_name].name = base_armature_standards[reverse_bone_lookup[simplify_bonename(bone_name)]]
bone_name = merge_armature_data.edit_bones[bone_name].name
#adjust original parents list to point to the new name.
for child_bone in merge_armature_data.edit_bones[bone_name]:
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])
continue
#if it really doesn't have a counter part, just don't bother.
else:
# 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
@@ -222,6 +268,7 @@ 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()
@@ -399,20 +446,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'}
+11 -6
View File
@@ -1,7 +1,9 @@
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
@@ -10,7 +12,9 @@ from ...core.common import (
get_all_meshes,
ProgressTracker,
calculate_bone_orientation,
add_armature_modifier
add_armature_modifier,
store_breaking_settings_armature,
restore_breaking_settings_armature,
)
from ...core.armature_validation import validate_armature
@@ -83,11 +87,11 @@ class AvatarToolkit_OT_AttachMesh(Operator):
attach_to_bone = armature.data.edit_bones.get(attach_bone_name)
if not attach_to_bone:
raise ValueError(t("AttachMesh.error.bone_not_found", bone=attach_bone_name))
data_breaking = store_breaking_settings_armature(armature)
mesh_bone = armature.data.edit_bones.new(mesh_name)
mesh_bone.parent = attach_to_bone
progress.step(t("AttachMesh.create_bone"))
# Calculate bone placement
verts_in_group: List[Any] = [v for v in mesh.data.vertices
for g in v.groups if g.group == vg.index]
@@ -104,6 +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]:
+8 -7
View File
@@ -10,6 +10,7 @@ from typing import Optional, Dict, Tuple, Set, List, Any, Union, ClassVar
from collections import OrderedDict
from random import random
from itertools import chain
import traceback
from ..core.logging_setup import logger
from ..core.translations import t
@@ -104,8 +105,8 @@ class CreateEyesAV3Button(bpy.types.Operator):
self.report({'INFO'}, t('EyeTracking.success'))
return {'FINISHED'}
except Exception as e:
logger.error(f"Eye tracking setup failed: {str(e)}")
except Exception:
logger.error(f"Eye tracking setup failed: {traceback.format_exc()}")
return {'CANCELLED'}
class CreateEyesSDK2Button(bpy.types.Operator):
@@ -197,7 +198,7 @@ class CreateEyesSDK2Button(bpy.types.Operator):
return {'FINISHED'}
except Exception as e:
logger.error(f"Eye tracking setup failed: {str(e)}")
logger.error(f"Eye tracking setup failed: {traceback.format_exc()}")
return {'CANCELLED'}
class EyeTrackingBackup:
@@ -222,8 +223,8 @@ class EyeTrackingBackup:
with open(self.backup_path, 'w') as f:
json.dump(self.bone_positions, f)
return True
except Exception as e:
logger.error(f"Backup failed: {str(e)}")
except Exception:
logger.error(f"Backup failed: {traceback.format_exc()}")
return False
def restore_bone_positions(self, armature) -> bool:
@@ -243,8 +244,8 @@ class EyeTrackingBackup:
bone.tail = positions['tail']
return True
except Exception as e:
logger.error(f"Restore failed: {str(e)}")
except Exception:
logger.error(f"Restore failed: {traceback.format_exc()}")
return False
class EyeTrackingValidator:
+12 -9
View File
@@ -1,3 +1,4 @@
import traceback
import bpy
import re
from typing import Set, Dict, List, Optional, Tuple
@@ -18,6 +19,7 @@ from ...core.common import (
ProgressTracker
)
from ...core.armature_validation import validate_armature
import traceback
def textures_match(tex1: ShaderNodeTexImage, tex2: ShaderNodeTexImage) -> bool:
"""Compare two texture nodes for matching properties and image data"""
@@ -112,24 +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:
+8 -6
View File
@@ -1,3 +1,4 @@
import traceback
import bpy
from typing import Set, List, Tuple, ClassVar
from bpy.types import Operator, Context, Object
@@ -11,6 +12,7 @@ from ...core.common import (
ProgressTracker
)
from ...core.armature_validation import validate_armature
import traceback
class AvatarToolkit_OT_JoinAllMeshes(Operator):
"""Operator to join all meshes in the scene"""
@@ -51,9 +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):
@@ -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'}
+88 -213
View File
@@ -1,3 +1,4 @@
import traceback
import bpy
import numpy as np
from typing import List, TypedDict, Any, Literal, TypeAlias, cast
@@ -9,6 +10,8 @@ from ...core.common import (
get_all_meshes,
)
from ...core.armature_validation import validate_armature
import bmesh
import mathutils
# Constants
MERGE_ITERATION_COUNT = 20
@@ -19,83 +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
duplicate.name = f"{shapekey_name}_object_is_{mesh.name}"
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
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
def vertex_moves(mesh_data: bpy.types.Mesh, vertex: int) -> bool:
for shapekey in mesh_data.shape_keys.key_blocks:
data: bpy.types.ShapeKey = shapekey
if data.points[vertex].co.xyz != mesh_data.vertices[vertex].co.xyz:
return True
return False
def merge_vertex_at_index(mesh_data: bpy.types.Mesh, index: int, distance: float):
select_target_vertex = [False]*len(mesh_data.vertices)
select_target_vertex[index] = True
bpy.ops.object.mode_set(mode='OBJECT')
mesh_data.vertices.foreach_set("select",select_target_vertex)
bpy.ops.object.mode_set(mode='EDIT')
for _ in range(0,20): #for some reason, if using merge to unselected on a vertex, the vertex will only merge to 1 other vertex. so we gotta spam it to fix it.
bpy.ops.mesh.remove_doubles(threshold=distance, use_unselected=True, use_sharp_edge_from_normals=False)
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)
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"
@@ -104,7 +60,7 @@ 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"""
@@ -117,27 +73,27 @@ class AvatarToolkit_OT_RemoveDoubles(Operator):
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]:
@@ -157,158 +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) -> int:
"""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)}
merge_vertex_at_index(duplicate.data, mesh_entry["cur_vertex_pass"], merge_distance) #merge the vertex at our pass to find vertices that would merge to our vertex at this shapekey.
# Process merging
merged_vertices = process_vertex_merging(duplicate.data, vertices_original, mesh_entry["cur_vertex_pass"]) # find what vertices actually merged.
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] # remove vertices that merged from the list if they didn't merge during this shapkey.
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) # merge all vertices that merged on every shapekey no matter the shapekey during the loop.
return len(final_merged_vertex_group)
except Exception as e:
logger.error(f"Error in modify_mesh_advanced: {str(e)}")
return 1
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")
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: MeshEntry = self.objects_to_do.pop(0)
merge_distance: float = self.merge_distance
#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.
if(final_merged_vertex_group == []): #populate list if it is empty
final_merged_vertex_group = merged_vertices
new_dict: dict[set[int],list[int]] = {}
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
#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()
if len(mesh['shapekeys']) > 0 and not advanced:
shapekeyname = mesh['shapekeys'].pop(0)
mesh["mesh"].active_shape_key_index = mesh_data.shape_keys.key_blocks.find(shapekeyname)
logger.debug(f"Processing shapekey {shapekeyname}")
self.modify_mesh(context, mesh)
elif not mesh_data.shape_keys:
self.process_simple_mesh(context, mesh, merge_distance)
self.objects_to_do.pop(0)
elif not (mesh["cur_vertex_pass"] > mesh["vertices"]) and advanced: #advanced merging vertex by vertex
if(mesh["cur_vertex_pass"] < 0): #make sure it doesn't go below 0 and explode when advancing backwards from a previous step
mesh["cur_vertex_pass"] = 0
if vertex_moves(mesh["mesh"].data, mesh["cur_vertex_pass"]): # do not do advanced merging for vertices that don't move
mesh["cur_vertex_pass"] -= self.modify_mesh_advanced(context, mesh)-2 #advance forward or backwards based on how many vertices actually got merged, changing the list size.
#if above returns 1 (no vertices other than this one being merged to ourselves), advance by 1. else don't advance or go backwards. Makes sure all vertices get merged in the end.
else:
mesh["cur_vertex_pass"] += 1
#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]]
elif (mesh["cur_vertex_pass"] > mesh["vertices"]) and advanced and len(mesh['shapekeys']) > 0: #after advanced merging has gone past all the moving vertices, now we need to merge non moving vertices.
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)
else:
self.finish_mesh_processing(context, mesh, advanced, merge_distance)
self.objects_to_do.pop(0)
#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'}
+14 -12
View File
@@ -1,3 +1,4 @@
import traceback
import bpy
from typing import Set, Dict, List, Tuple, Optional, Any
from bpy.props import StringProperty
@@ -14,6 +15,7 @@ from ..core.common import (
process_armature_modifiers,
ProgressTracker
)
import traceback
from ..core.armature_validation import validate_armature
class BatchPoseOperationMixin:
@@ -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,9 +87,9 @@ class AvatarToolkit_OT_StopPoseMode(Operator):
bpy.ops.pose.select_all(action="INVERT")
bpy.ops.object.mode_set(mode='OBJECT')
return {'FINISHED'}
except Exception as e:
logger.error(f"Failed to stop pose mode: {str(e)}")
self.report({'ERROR'}, t("PoseMode.error.stop", error=str(e)))
except Exception:
logger.error(f"Failed to stop pose mode: {traceback.format_exc()}")
self.report({'ERROR'}, t("PoseMode.error.stop", error=traceback.format_exc()))
return {'CANCELLED'}
class AvatarToolkit_OT_ApplyPoseAsRest(Operator, BatchPoseOperationMixin):
@@ -129,9 +131,9 @@ class AvatarToolkit_OT_ApplyPoseAsRest(Operator, BatchPoseOperationMixin):
progress.step(f"Processed {mesh_obj.name}")
return {'FINISHED'}
except Exception as e:
logger.error(f"Failed to apply pose as shape key: {str(e)}")
self.report({'ERROR'}, t("PoseMode.error.shapekey", error=str(e)))
except Exception:
logger.error(f"Failed to apply pose as shape key: {traceback.format_exc()}")
self.report({'ERROR'}, t("PoseMode.error.shapekey", error=traceback.format_exc()))
return {'CANCELLED'}
class AvatarToolkit_OT_ApplyPoseAsShapekey(Operator, BatchPoseOperationMixin):
@@ -160,7 +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'}
+8 -6
View File
@@ -1,3 +1,4 @@
import traceback
import bpy
import numpy as np
from bpy.types import Operator, Context
@@ -6,6 +7,7 @@ from ...core.translations import t
from ...core.logging_setup import logger
from ...core.common import get_active_armature, get_all_meshes, remove_unused_shapekeys
from ...core.armature_validation import validate_armature
import traceback
class AvatarToolkit_OT_ApplyTransforms(Operator):
"""Apply all transformations to armature and associated meshes"""
@@ -42,9 +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):
@@ -86,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'}
+451
View File
@@ -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")
+206 -82
View File
@@ -1,3 +1,4 @@
import traceback
import bpy
import re
from bpy.types import Operator, Context, EditBone, Object, Armature, Mesh
@@ -7,19 +8,16 @@ from ...core.common import (
get_active_armature,
get_all_meshes,
ProgressTracker,
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"""
@@ -36,31 +34,15 @@ class AvatarToolKit_OT_CreateDigitigradeLegs(Operator):
return False
valid, _, _ = validate_armature(armature)
return (valid and
context.mode == 'EDIT_ARMATURE' and
(context.mode == 'EDIT_ARMATURE' or context.mode == 'POSE') and
context.selected_editable_bones is not None and
len(context.selected_editable_bones) == 2)
def 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]
@@ -72,46 +54,51 @@ class AvatarToolKit_OT_CreateDigitigradeLegs(Operator):
bone.select = True
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'}
@@ -137,6 +124,8 @@ class AvatarToolKit_OT_DeleteBoneConstraints(Operator):
armature = get_active_armature(context)
bpy.ops.object.select_all(action='DESELECT')
armature.select_set(True)
data_breaking = store_breaking_settings_armature(armature)
context.view_layer.objects.active = armature
bpy.ops.object.mode_set(mode='POSE')
@@ -147,6 +136,7 @@ class AvatarToolKit_OT_DeleteBoneConstraints(Operator):
constraints_removed += 1
bpy.ops.object.mode_set(mode='OBJECT')
restore_breaking_settings_armature(armature, data_breaking)
self.report({'INFO'}, t("Tools.clean_constraints_success", count=constraints_removed))
return {'FINISHED'}
@@ -196,9 +186,10 @@ class AvatarToolKit_OT_RemoveZeroWeightBones(Operator):
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(),
@@ -208,60 +199,82 @@ 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)
zero_weight_bones: List[str] = []
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)):
if context.scene.avatar_toolkit.list_only_mode:
zero_weight_bones.append(bone.name)
continue
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)
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]
restore_bone_transforms(child, data)
if b.parent:
child.parent = b.parent
if b.name in armature_data.edit_bones:
armature_data.edit_bones.remove(b)
removed_count += 1
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"
@@ -271,6 +284,7 @@ class AvatarToolKit_OT_RemoveSelectedBones(Operator):
def execute(self, context: Context) -> set[str]:
armature = get_active_armature(context)
data_breaking = store_breaking_settings_armature(armature)
toolkit = context.scene.avatar_toolkit
selected_bones = [item.name for item in toolkit.zero_weight_bones
@@ -283,6 +297,116 @@ class AvatarToolKit_OT_RemoveSelectedBones(Operator):
bpy.ops.object.mode_set(mode='OBJECT')
toolkit.zero_weight_bones.clear()
restore_breaking_settings_armature(armature, data_breaking)
self.report({'INFO'}, t("Tools.bones_removed", count=len(selected_bones)))
return {'FINISHED'}
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))
#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 armature.animation_data.action.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'}
+102
View File
@@ -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'}
+196
View File
@@ -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'}
+33 -15
View File
@@ -1,11 +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
from ...core.common import get_active_armature, get_all_meshes, get_vertex_weights, transfer_vertex_weights, store_breaking_settings_armature, restore_breaking_settings_armature
from ...core.armature_validation import validate_armature
import traceback
class AvatarToolkit_OT_ConnectBones(Operator):
"""Connect disconnected bones in chain"""
@@ -23,8 +25,12 @@ class AvatarToolkit_OT_ConnectBones(Operator):
return valid
def execute(self, context: Context) -> Set[str]:
armature = get_active_armature(context)
data_breaking = store_breaking_settings_armature(armature)
try:
armature = get_active_armature(context)
logger.info("Starting bone connection operation")
bpy.ops.object.mode_set(mode='EDIT')
@@ -47,12 +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):
@@ -67,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]:
armature = get_active_armature(context)
data_breaking = store_breaking_settings_armature(armature)
try:
armature = get_active_armature(context)
bpy.ops.object.mode_set(mode='EDIT')
active_bone = context.active_bone
selected_bones = [b for b in context.selected_editable_bones if b != active_bone]
@@ -102,11 +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):
@@ -121,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]:
armature = get_active_armature(context)
data_breaking = store_breaking_settings_armature(armature)
try:
armature = get_active_armature(context)
bpy.ops.object.mode_set(mode='EDIT')
selected_bones = [b for b in context.selected_editable_bones if b.parent]
if not selected_bones:
@@ -153,10 +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'}
+6 -4
View File
@@ -1,8 +1,10 @@
import traceback
import bpy
from bpy.types import Operator, Context
from ...core.translations import t
from ...core.common import get_active_armature
from ...core.armature_validation import validate_armature
import traceback
class AvatarToolKit_OT_SeparateByMaterials(Operator):
"""Operator to separate mesh by materials"""
@@ -32,8 +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):
@@ -64,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'}
+68 -7
View File
@@ -1,11 +1,13 @@
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
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"""
@@ -56,9 +58,9 @@ class AvatarToolkit_OT_ConvertRigifyToUnity(Operator):
self.report({'INFO'}, t("Tools.rigify_converted"))
return {'FINISHED'}
except Exception as e:
logger.error(f"Failed to convert Rigify: {str(e)}", exc_info=True)
self.report({'ERROR'}, str(e))
except Exception:
logger.error(f"Failed to convert Rigify: {traceback.format_exc()}", exc_info=True)
self.report({'ERROR'}, traceback.format_exc())
return {'CANCELLED'}
def cleanup_extra_bones(self, armature: Object) -> None:
@@ -67,19 +69,50 @@ class AvatarToolkit_OT_ConvertRigifyToUnity(Operator):
# 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:
if any(pattern in bone.name.lower() for pattern in rigify_unnecessary_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])
if 'spine.004' in armature.data.edit_bones and 'spine.005' in armature.data.edit_bones:
# 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']
@@ -87,6 +120,7 @@ class AvatarToolkit_OT_ConvertRigifyToUnity(Operator):
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']
@@ -135,6 +169,22 @@ class AvatarToolkit_OT_ConvertRigifyToUnity(Operator):
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:
@@ -188,6 +238,17 @@ class AvatarToolkit_OT_ConvertRigifyToUnity(Operator):
("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:
+29 -37
View File
@@ -1,3 +1,4 @@
import traceback
import bpy
import math
from typing import Dict, List, Set, Tuple, Optional, Any, Union
@@ -12,7 +13,9 @@ from ...core.dictionaries import (
bone_hierarchy,
acceptable_bone_names,
acceptable_bone_hierarchy,
non_standard_mappings
non_standard_mappings,
reverse_bone_lookup,
simplify_bonename
)
class AvatarToolkit_OT_StandardizeArmature(Operator):
@@ -25,7 +28,7 @@ class AvatarToolkit_OT_StandardizeArmature(Operator):
@classmethod
def poll(cls, context: Context) -> bool:
armature: Optional[Object] = get_active_armature(context)
return armature is not None and context.mode in {'OBJECT', 'EDIT_ARMATURE'}
return armature is not None and context.mode in {'OBJECT', 'EDIT_ARMATURE', 'POSE'}
def invoke(self, context: Context, event: Any) -> Set[str]:
logger.debug("Invoking standardize armature dialog")
@@ -52,12 +55,6 @@ class AvatarToolkit_OT_StandardizeArmature(Operator):
logger.info(f"Starting armature standardization for {armature.name}")
is_valid, _, _ = validate_armature(armature)
if is_valid:
logger.info("Armature already meets standards, no changes needed")
self.report({'INFO'}, t("Tools.standardize_already_valid"))
return {'FINISHED'}
original_mode: str = context.mode
logger.debug(f"Original mode: {original_mode}")
bpy.ops.object.mode_set(mode='OBJECT')
@@ -87,7 +84,7 @@ class AvatarToolkit_OT_StandardizeArmature(Operator):
logger.info(f"Fixed {fixed_scale} scale issues")
bpy.ops.object.mode_set(mode='OBJECT')
is_valid, messages, _ = validate_armature(armature)
is_valid, messages, _ = validate_armature(armature, override_mode='STRICT')
if is_valid:
logger.info("Armature successfully standardized")
@@ -99,20 +96,24 @@ class AvatarToolkit_OT_StandardizeArmature(Operator):
if original_mode == 'EDIT_ARMATURE':
bpy.ops.object.mode_set(mode='EDIT')
if original_mode == 'POSE':
bpy.ops.object.mode_set(mode='POSE')
return {'FINISHED'}
except Exception as e:
logger.error(f"Failed to standardize armature: {str(e)}")
self.report({'ERROR'}, str(e))
except Exception:
logger.error(f"Failed to standardize armature: {traceback.format_exc()}")
self.report({'ERROR'}, traceback.format_exc())
try:
if original_mode == 'EDIT_ARMATURE':
bpy.ops.object.mode_set(mode='EDIT')
if original_mode == 'POSE':
bpy.ops.object.mode_set(mode='POSE')
else:
bpy.ops.object.mode_set(mode='OBJECT')
except Exception as restore_error:
logger.error(f"Failed to restore original mode: {str(restore_error)}")
except Exception:
logger.error(f"Failed to restore original mode: {traceback.format_exc()}")
return {'CANCELLED'}
@@ -129,17 +130,14 @@ class AvatarToolkit_OT_StandardizeArmature(Operator):
existing_standard_bones.add(bone.name)
logger.debug(f"Found existing standard bone: {bone.name}")
# Build a mapping of non-standard bone names to standard names
# Use the reverse bone lookup that's already built and simplified
name_mapping: Dict[str, str] = {}
for category, standard_name in standard_bones.items():
# Skip if this standard bone already exists
if standard_name in existing_standard_bones:
continue
# Get all variants for this category
if category in non_standard_mappings:
for variant in non_standard_mappings[category]:
name_mapping[variant.lower()] = standard_name
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] = {}
@@ -150,20 +148,14 @@ class AvatarToolkit_OT_StandardizeArmature(Operator):
if original_name in standard_bones.values():
continue
simplified_name: str = original_name.lower().replace(' ', '').replace('_', '').replace('.', '')
simplified_name: str = simplify_bonename(original_name)
# Check if this bone matches any known pattern
for variant, standard_name in name_mapping.items():
# More precise matching - exact match or with common separators
if (variant == simplified_name or
variant == original_name.lower() or
f"{variant}_" in simplified_name or
f"{variant}." in 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}")
break
# 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
+5 -2
View File
@@ -6,6 +6,7 @@ import numpy as np
import math
from ...core.translations import t
from ...core.logging_setup import logger
import traceback
class GenerateLoopTreeResult(TypedDict):
tree: Dict[str, Set[str]]
@@ -31,6 +32,8 @@ class AvatarToolkit_OT_AlignUVEdgesToTarget(Operator):
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:
@@ -245,8 +248,8 @@ class AvatarToolkit_OT_AlignUVEdgesToTarget(Operator):
logger.info(f"Finished mesh {source} for UV's")
except Exception as e:
logger.error(f"Error processing source {source}: {str(e)}")
except Exception:
logger.error(f"Error processing source {source}: {traceback.format_exc()}")
return {'CANCELLED'}
bpy.ops.object.mode_set(mode=prev_mode)
+88
View File
@@ -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'}
+11 -19
View File
@@ -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,
get_all_meshes,
validate_mesh_for_pose
)
from ..core.armature_validation import validate_armature
import traceback
class VisemeCache:
"""Manages caching of generated viseme shape data for performance optimization"""
@@ -124,7 +124,7 @@ class VisemePreview:
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")
@@ -140,12 +140,8 @@ class ATOOLKIT_OT_preview_visemes(Operator):
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'
# Validate mesh
return mesh_obj and mesh_obj.type == 'MESH'
def execute(self, context: Context) -> Set[str]:
props = context.scene.avatar_toolkit
@@ -181,7 +177,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")
@@ -198,12 +194,8 @@ class ATOOLKIT_OT_create_visemes(Operator):
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'
# Validate mesh
return mesh_obj and mesh_obj.type == 'MESH'
def execute(self, context: Context) -> Set[str]:
props = context.scene.avatar_toolkit
@@ -221,9 +213,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:
+138 -8
View File
@@ -1,7 +1,7 @@
{
"authors": ["Avatar Toolkit Team"],
"messages": {
"AvatarToolkit.label": "Avatar Toolkit (Alpha 0.2.1)",
"AvatarToolkit.label": "Avatar Toolkit (Alpha 0.4.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.",
@@ -63,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",
@@ -128,9 +135,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",
@@ -158,8 +163,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}",
@@ -167,6 +170,7 @@
"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",
@@ -191,6 +195,8 @@
"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",
@@ -203,7 +209,10 @@
"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",
@@ -211,6 +220,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",
@@ -301,6 +325,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",
@@ -493,6 +518,8 @@
"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",
@@ -514,12 +541,115 @@
"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": "Remove Colliders",
"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",
"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)"
}
}
+114 -2
View File
@@ -1,7 +1,7 @@
{
"authors": ["Avatar Toolkit Team"],
"messages": {
"AvatarToolkit.label": "アバターツールキット (アルファ 0.2.1)",
"AvatarToolkit.label": "アバターツールキット (アルファ 0.4.0)",
"AvatarToolkit.desc1": "アバターツールキットは早期アクセス中であり、",
"AvatarToolkit.desc2": "問題が発生する可能性があります。問題を見つけた場合は、",
"AvatarToolkit.desc3": "GitHubで報告してください。",
@@ -63,6 +63,13 @@
"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": "基本的なアーマチュア検証に失敗しました",
@@ -301,6 +308,7 @@
"Visemes.success": "口形素が正常に作成されました",
"Visemes.mesh_select": "メッシュを選択",
"Visemes.mesh_select_desc": "口形素を作成するメッシュを選択",
"Visemes.no_meshes": "メッシュが見つかりません",
"EyeTracking.label": "アイトラッキング",
"EyeTracking.setup": "アイトラッキング設定",
@@ -493,6 +501,8 @@
"TextureAtlas.save_file_instructions": "ファイル > 名前を付けて保存... を使用するか、下のボタンをクリックしてください:",
"TextureAtlas.save_file_button": "Blenderファイルを保存",
"TextureAtlas.save_file_required": "ファイルの保存が必要です",
"TextureAtlas.search_materials": "マテリアルを検索",
"TextureAtlas.search_materials_desc": "名前でマテリアルをフィルタリング",
"Settings.label": "設定",
"Settings.language": "言語",
@@ -514,12 +524,114 @@
"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_colliders": "コライダーを削除",
"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キー(一部のサーバーでは任意)"
}
}
+114 -2
View File
@@ -1,7 +1,7 @@
{
"authors": ["Avatar Toolkit Team"],
"messages": {
"AvatarToolkit.label": "아바타 툴킷 (알파 0.2.1)",
"AvatarToolkit.label": "아바타 툴킷 (알파 0.4.0)",
"AvatarToolkit.desc1": "아바타 툴킷은 초기 액세스 단계에 있으므로",
"AvatarToolkit.desc2": "문제가 있을 수 있습니다. 문제를 발견하시면",
"AvatarToolkit.desc3": "Github에 보고해 주세요.",
@@ -63,6 +63,13 @@
"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": "기본 아마추어 검증 실패",
@@ -301,6 +308,7 @@
"Visemes.success": "비셈 생성 성공",
"Visemes.mesh_select": "메시 선택",
"Visemes.mesh_select_desc": "비셈을 생성할 메시 선택",
"Visemes.no_meshes": "메시를 찾을 수 없음",
"EyeTracking.label": "시선 추적",
"EyeTracking.setup": "시선 추적 설정",
@@ -493,6 +501,8 @@
"TextureAtlas.save_file_instructions": "파일 > 다른 이름으로 저장... 을 사용하거나 아래 버튼을 클릭하세요:",
"TextureAtlas.save_file_button": "Blender 파일 저장",
"TextureAtlas.save_file_required": "파일 저장 필요",
"TextureAtlas.search_materials": "재질 검색",
"TextureAtlas.search_materials_desc": "이름으로 재질 필터링",
"Settings.label": "설정",
"Settings.language": "언어",
@@ -514,12 +524,114 @@
"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.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_colliders": "콜라이더 제거",
"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 키 (일부 서버는 선택사항)"
}
}
+7 -6
View File
@@ -6,6 +6,7 @@ from ..core.common import SceneMatClass, MaterialListBool, get_active_armature
from ..functions.atlas_materials import AvatarToolKit_OT_AtlasMaterials
from ..core.translations import t
from ..core.logging_setup import logger
import traceback
class AvatarToolKit_OT_SelectAllMaterials(Operator):
bl_idname = 'avatar_toolkit.select_all_materials'
@@ -80,8 +81,8 @@ class AvatarToolKit_OT_ExpandSectionMaterials(Operator):
logger.debug("Hiding material list")
return {'FINISHED'}
except Exception as e:
logger.error(f"Error loading materials: {str(e)}", exc_info=True)
except Exception:
logger.error(f"Error loading materials: {traceback.format_exc()}", exc_info=True)
self.report({'ERROR'}, t("TextureAtlas.load_error"))
return {'CANCELLED'}
@@ -97,14 +98,14 @@ class AvatarToolKit_UL_MaterialTextureAtlasProperties(UIList):
row = layout.row(align=True)
row.scale_y = 1.2
row.operator("avatar_toolkit.select_all_materials", text="", icon='CHECKBOX_HLT',
row.operator(AvatarToolKit_OT_SelectAllMaterials.bl_idname, text="", icon='CHECKBOX_HLT',
emboss=True).tooltip = t("TextureAtlas.select_all_tooltip")
row.operator("avatar_toolkit.select_none_materials", text="", icon='CHECKBOX_DEHLT',
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("avatar_toolkit.expand_all_materials", text="", icon='DISCLOSURE_TRI_DOWN',
row.operator(AvatarToolKit_OT_ExpandAllMaterials.bl_idname, text="", icon='DISCLOSURE_TRI_DOWN',
emboss=True).tooltip = t("TextureAtlas.expand_all_tooltip")
row.operator("avatar_toolkit.collapse_all_materials", text="", icon='DISCLOSURE_TRI_RIGHT',
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)
+7 -7
View File
@@ -175,12 +175,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
@@ -188,7 +188,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"""
@@ -213,17 +213,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
@@ -231,4 +231,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')
+7 -5
View File
@@ -3,6 +3,9 @@ from typing import Set
from bpy.types import Panel, Context, UILayout, Operator
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
from ..core.translations import t
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"""
@@ -26,7 +29,7 @@ class AvatarToolKit_PT_OptimizationPanel(Panel):
col.separator(factor=0.5)
# Material Operations
col.operator("avatar_toolkit.combine_materials", icon='MATERIAL')
col.operator(AvatarToolkit_OT_CombineMaterials.bl_idname, icon='MATERIAL')
# Mesh Cleanup Box
cleanup_box: UILayout = layout.box()
@@ -36,8 +39,7 @@ class AvatarToolKit_PT_OptimizationPanel(Panel):
# 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')
row.operator(AvatarToolkit_OT_RemoveDoubles.bl_idname, icon='MESH_DATA')
# Join Meshes Box
join_box: UILayout = layout.box()
@@ -47,5 +49,5 @@ class AvatarToolKit_PT_OptimizationPanel(Panel):
# 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')
row.operator(AvatarToolkit_OT_JoinAllMeshes.bl_idname, icon='OBJECT_DATA')
row.operator(AvatarToolkit_OT_JoinSelectedMeshes.bl_idname, icon='RESTRICT_SELECT_OFF')
+94 -21
View File
@@ -17,13 +17,27 @@ from ..core.common import (
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
from ..core.armature_validation import validate_armature, AvatarToolkit_OT_ValidateTPose
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 +55,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"""
@@ -81,21 +95,44 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel):
# Armature Selection
col.prop(context.scene.avatar_toolkit, "active_armature", text="")
# Armature Validation
# Armature Validation (cached to improve performance)
active_armature: Optional[Object] = get_active_armature(context)
if active_armature:
is_valid, messages, is_acceptable, hierarchy_messages, scale_messages, non_standard_messages = validate_armature(active_armature, detailed_messages=True)
# Cache validation results to avoid expensive recalculations on every draw
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
is_pmx_model = False
if hasattr(active_armature, 'mmd_type') or (hasattr(active_armature, 'parent') and active_armature.parent and hasattr(active_armature.parent, 'mmd_type')):
is_pmx_model = True
info_box = col.box()
# If it's a PMX model, display a prominent notice
if is_pmx_model:
pmx_box = info_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"))
if not is_valid:
# Display non-standard bones and hierarchy issues
if len(messages) > 1:
if messages and len(messages) > 0:
# Found Bones section
validation_box = info_box.box()
row = validation_box.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:
if props.show_found_bones and len(messages) > 0:
for line in messages[0].split('\n'):
validation_box.label(text=line)
@@ -124,15 +161,31 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel):
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:
if non_standard_messages and len(non_standard_messages) > 0:
for message in non_standard_messages:
for line in message.split('\n'):
sub_row = validation_box.row()
sub_row.alert = True
sub_row.label(text=line)
else:
sub_row = validation_box.row()
sub_row.label(text=t("Validation.no_non_standard_issues"))
# For PMX models, if no non-standard messages but it's a PMX model,
# we should still indicate there might be non-standard bones
if is_pmx_model:
sub_row = validation_box.row()
sub_row.alert = True
sub_row.label(text=t("Armature.validation.pmx_model_basic"))
sub_row = validation_box.row()
sub_row.alert = True
sub_row.label(text=t("Armature.validation.pmx_model_strict"))
sub_row = validation_box.row()
sub_row.alert = True
sub_row.label(text=t("Armature.validation.pmx_model_standardize"))
else:
sub_row = validation_box.row()
sub_row.label(text=t("Validation.no_non_standard_issues"))
# Hierarchy Issues section
validation_box = info_box.box()
@@ -170,7 +223,7 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel):
col = pose_box.column(align=True)
col.label(text=t("Validation.tpose.label"), icon='ARMATURE_DATA')
col.separator(factor=0.5)
col.operator("avatar_toolkit.validate_tpose", icon='CHECKMARK')
col.operator(AvatarToolkit_OT_ValidateTPose.bl_idname, icon='CHECKMARK')
if props.show_tpose_validation:
validation_box = col.box()
@@ -187,27 +240,46 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel):
row.label(text=msg.name)
else:
# If no specific issues, show acceptable message
info_box.label(text=messages[0], icon='INFO')
info_box.label(text=messages[1])
info_box.label(text=messages[2])
if messages and len(messages) > 0:
info_box.label(text=messages[0], icon='INFO')
if len(messages) > 1:
info_box.label(text=messages[1])
if len(messages) > 2:
info_box.label(text=messages[2])
else:
info_box.label(text=t("Validation.no_messages"), icon='INFO')
elif is_valid and not is_acceptable:
row = info_box.row()
split = row.split(factor=0.6)
split.label(text=t("QuickAccess.valid_armature"), icon='CHECKMARK')
stats = get_armature_stats(active_armature)
# Cache armature stats to avoid expensive recalculations
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]
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')
elif is_valid and is_acceptable:
# Show acceptable standard message
info_box.label(text=messages[0], icon='INFO')
info_box.label(text=messages[1])
info_box.label(text=messages[2])
if messages and len(messages) > 0:
info_box.label(text=messages[0], icon='INFO')
# Only try to access additional messages if they exist
if len(messages) > 1:
info_box.label(text=messages[1])
if len(messages) > 2:
info_box.label(text=messages[2])
else:
info_box.label(text=t("Validation.no_messages"), icon='INFO')
# Add standardize button
standardize_box = info_box.box()
standardize_box.operator("avatar_toolkit.standardize_armature",
standardize_box.operator(AvatarToolkit_OT_StandardizeArmature.bl_idname,
text=t("QuickAccess.standardize_armature"),
icon='MODIFIER')
@@ -247,5 +319,6 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel):
# 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')
button_row.operator(AvatarToolKit_OT_Import.bl_idname, text=t("QuickAccess.import"), icon='IMPORT')
button_row.operator(AvatarToolKit_OT_ExportMenu.bl_idname, text=t("QuickAccess.export"), icon='EXPORT')
+6 -2
View File
@@ -10,6 +10,7 @@ from bpy.types import (
)
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
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"""
@@ -71,9 +72,9 @@ class AvatarToolKit_PT_SettingsPanel(Panel):
col.separator()
col.prop(props, "highlight_problem_bones")
if props.highlight_problem_bones:
col.operator("avatar_toolkit.highlight_problem_bones", icon='COLOR')
col.operator(AvatarToolkit_OT_HighlightProblemBones.bl_idname, icon='COLOR')
else:
col.operator("avatar_toolkit.clear_bone_highlighting", icon='X')
col.operator(AvatarToolkit_OT_ClearBoneHighlighting.bl_idname, icon='X')
# Debug Settings
debug_box = layout.box()
@@ -88,3 +89,6 @@ class AvatarToolKit_PT_SettingsPanel(Panel):
if props.debug_expand:
col = debug_box.column(align=True)
col.prop(props, "enable_logging")
if props.enable_logging:
col.prop(props, "log_level")
+56 -25
View File
@@ -4,17 +4,22 @@ from bpy.types import Panel, Context, UILayout, Operator, UIList
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
from ..core.translations import t
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')
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"""
@@ -37,7 +42,7 @@ class AvatarToolKit_PT_ToolsPanel(Panel):
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.operator(AvatarToolkit_OT_ConvertResonite.bl_idname, text=t("Tools.convert_resonite"), icon='EXPORT')
# Separation Tools
sep_box: UILayout = layout.box()
@@ -45,22 +50,33 @@ class AvatarToolKit_PT_ToolsPanel(Panel):
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')
row.operator(AvatarToolKit_OT_SeparateByMaterials.bl_idname, text=t("Tools.separate_materials"), icon='MATERIAL')
row.operator(AvatarToolKit_OT_SeparateByLooseParts.bl_idname, text=t("Tools.separate_loose"), icon='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.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
mesh_box: UILayout = layout.box()
col = mesh_box.column(align=True)
col.label(text=t("Tools.mesh_title"), icon='MESH_DATA')
col.separator(factor=0.5)
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
standardize_box: UILayout = bone_box.box()
col = standardize_box.column(align=True)
col.label(text=t("Tools.standardize_title"), icon='OUTLINER_OB_ARMATURE')
col.separator(factor=0.5)
col.operator("avatar_toolkit.standardize_armature", icon='CHECKMARK')
col.operator(AvatarToolkit_OT_StandardizeArmature.bl_idname, icon='CHECKMARK')
# Weight Tools
weight_box: UILayout = bone_box.box()
@@ -78,12 +94,14 @@ class AvatarToolKit_PT_ToolsPanel(Panel):
toolkit, "zero_weight_bones_index")
col = box.column(align=True)
col.operator("avatar_toolkit.remove_selected_bones",
col.operator(AvatarToolKit_OT_RemoveSelectedBones.bl_idname,
text=t("Tools.remove_selected_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')
row.operator(AvatarToolKit_OT_RemoveZeroWeightBones.bl_idname, text=t("Tools.clean_weights"), icon='GROUP_BONE')
row.operator(AvatarToolKit_OT_DeleteBoneConstraints.bl_idname, text=t("Tools.clean_constraints"), icon='CONSTRAINT_BONE')
row = col.row(align=True)
row.operator(AvatarToolKit_OT_RemoveZeroWeightVertexGroups.bl_idname, text=t("Tools.clean_vertex_groups"), icon='CONSTRAINT_BONE')
# Merge Tools
merge_box: UILayout = layout.box()
@@ -91,22 +109,35 @@ class AvatarToolKit_PT_ToolsPanel(Panel):
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')
row.operator(AvatarToolkit_OT_MergeToActive.bl_idname, text=t("Tools.merge_to_active"), icon='BONE_DATA')
row.operator(AvatarToolkit_OT_MergeToParent.bl_idname, text=t("Tools.merge_to_parent"), icon='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.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
rigify_box: UILayout = layout.box()
col = rigify_box.column(align=True)
col.label(text=t("Tools.rigify_title"), icon='ARMATURE_DATA')
col.separator(factor=0.5)
col.operator("avatar_toolkit.convert_rigify_to_unity", 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')
+731
View File
@@ -0,0 +1,731 @@
# GPL License
import bpy
from typing import Set, Dict, List, Optional, Any
from bpy.types import (
Operator,
Panel,
Context,
UILayout,
WindowManager,
Event,
Object
)
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
from ..core.translations import t
from ..core.logging_setup import logger
from ..core.common import get_active_armature, ProgressTracker
# Module-level cache for UI performance (avoids Blender scene property write restrictions)
_ui_cache = {
'translation_status': {},
'deepl_config': {},
'libretranslate_config': {},
'last_refresh_frame': 0,
'cache_refresh_interval': 30
}
class AvatarToolkit_OT_TranslateNames(Operator):
"""Translate names using the translation system"""
bl_idname: str = "avatar_toolkit.translate_names"
bl_label: str = t("Translation.translate_names")
bl_description: str = t("Translation.translate_names_desc")
translation_type: bpy.props.EnumProperty(
items=[
('bones', t("Translation.type.bones"), t("Translation.type.bones_desc")),
('shapekeys', t("Translation.type.shapekeys"), t("Translation.type.shapekeys_desc")),
('materials', t("Translation.type.materials"), t("Translation.type.materials_desc")),
('objects', t("Translation.type.objects"), t("Translation.type.objects_desc")),
('all', t("Translation.type.all"), t("Translation.type.all_desc"))
],
default='bones'
)
def execute(self, context: Context) -> Set[str]:
logger.info(f"Starting translation operation: {self.translation_type}")
try:
from ..core.translation_manager import get_avatar_translation_manager
manager = get_avatar_translation_manager()
# Set up progress callback for detailed feedback
def progress_callback(current: int, total: int, message: str):
progress_percent = (current / max(total, 1)) * 100
logger.info(f"Translation progress: {current}/{total} ({progress_percent:.1f}%) - {message}")
context.area.header_text_set(f"Translating: {current}/{total} - {message}")
manager.set_progress_callback(progress_callback)
results = []
armature = get_active_armature(context)
total_steps = 0
if self.translation_type == 'bones' or self.translation_type == 'all':
if armature:
total_steps += len(armature.data.bones)
if self.translation_type == 'shapekeys' or self.translation_type == 'all':
meshes = [obj for obj in context.scene.objects if obj.type == 'MESH']
for mesh in meshes:
if mesh.data.shape_keys:
total_steps += len(mesh.data.shape_keys.key_blocks)
if self.translation_type == 'materials' or self.translation_type == 'all':
materials = set()
for obj in context.scene.objects:
if obj.type == 'MESH' and obj.data.materials:
for mat in obj.data.materials:
if mat:
materials.add(mat)
total_steps += len(materials)
if self.translation_type == 'objects' or self.translation_type == 'all':
objects = [obj for obj in context.scene.objects if obj.type in {'MESH', 'ARMATURE', 'EMPTY'}]
total_steps += len(objects)
logger.info(f"Translation operation will process approximately {total_steps} items")
with ProgressTracker(context, total_steps, "Translation") as progress:
if self.translation_type == 'bones' or self.translation_type == 'all':
if armature:
logger.info(f"Starting bone translation for armature: {armature.name}")
self.report({'INFO'}, f"Translating {len(armature.data.bones)} bones...")
bone_results = manager.translate_armature_bones(armature, apply_results=True)
results.extend(bone_results)
successful_bones = sum(1 for r in bone_results if r.method not in ['failed', 'skipped'])
progress.step(f"Bones: {successful_bones}/{len(bone_results)} translated")
logger.info(f"Bone translation complete: {successful_bones}/{len(bone_results)} successful")
else:
self.report({'WARNING'}, t("Translation.no_armature"))
logger.warning("No armature selected for bone translation")
if self.translation_type == 'shapekeys' or self.translation_type == 'all':
meshes = [obj for obj in context.scene.objects if obj.type == 'MESH']
logger.info(f"Starting shape key translation for {len(meshes)} mesh objects")
total_shapekeys = 0
for mesh in meshes:
if mesh.data.shape_keys:
shapekey_count = len(mesh.data.shape_keys.key_blocks)
self.report({'INFO'}, f"Translating {shapekey_count} shape keys in {mesh.name}...")
shapekey_results = manager.translate_object_shapekeys(mesh, apply_results=True)
results.extend(shapekey_results)
total_shapekeys += len(shapekey_results)
successful_shapekeys = sum(1 for r in results[-total_shapekeys:] if r.method not in ['failed', 'skipped'])
progress.step(f"Shape keys: {successful_shapekeys}/{total_shapekeys} translated")
logger.info(f"Shape key translation complete: {successful_shapekeys}/{total_shapekeys} successful")
if self.translation_type == 'materials' or self.translation_type == 'all':
logger.info("Starting material translation")
self.report({'INFO'}, "Translating materials...")
material_results = manager.translate_scene_materials(apply_results=True)
results.extend(material_results)
successful_materials = sum(1 for r in material_results if r.method not in ['failed', 'skipped'])
progress.step(f"Materials: {successful_materials}/{len(material_results)} translated")
logger.info(f"Material translation complete: {successful_materials}/{len(material_results)} successful")
if self.translation_type == 'objects' or self.translation_type == 'all':
logger.info("Starting object translation")
self.report({'INFO'}, "Translating objects...")
object_results = manager.translate_scene_objects(apply_results=True)
results.extend(object_results)
successful_objects = sum(1 for r in object_results if r.method not in ['failed', 'skipped'])
progress.step(f"Objects: {successful_objects}/{len(object_results)} translated")
logger.info(f"Object translation complete: {successful_objects}/{len(object_results)} successful")
manager.set_progress_callback(None)
context.area.header_text_set(None)
# Final results summary
successful = sum(1 for r in results if r.method not in ['failed', 'skipped'])
total = len(results)
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')
failed_count = sum(1 for r in results if r.method == 'failed')
logger.info(f"Translation summary: {successful}/{total} successful (Dictionary: {dictionary_count}, API: {api_count}, Cache: {cache_count}, Failed: {failed_count})")
if successful > 0:
success_msg = f"Successfully translated {successful}/{total} items"
if dictionary_count > 0:
success_msg += f" (Dictionary: {dictionary_count}"
if api_count > 0:
success_msg += f", API: {api_count}"
if cache_count > 0:
success_msg += f", Cache: {cache_count}"
if dictionary_count > 0 or api_count > 0 or cache_count > 0:
success_msg += ")"
self.report({'INFO'}, success_msg)
else:
if total > 0:
self.report({'WARNING'}, f"No translations were applied ({total} items checked)")
else:
self.report({'WARNING'}, "No items found to translate")
return {'FINISHED'}
except Exception as e:
try:
manager.set_progress_callback(None)
context.area.header_text_set(None)
except:
pass
logger.error(f"Translation operation failed: {e}", exc_info=True)
self.report({'ERROR'}, f"Translation failed: {str(e)}")
return {'CANCELLED'}
class AvatarToolkit_OT_TestTranslationService(Operator):
"""Test the currently selected translation service"""
bl_idname: str = "avatar_toolkit.test_translation_service"
bl_label: str = t("Translation.test_service")
bl_description: str = t("Translation.test_service_desc")
def execute(self, context: Context) -> Set[str]:
logger.info("Starting translation service test")
try:
from ..core.translation_manager import get_avatar_translation_manager
manager = get_avatar_translation_manager()
self.report({'INFO'}, "Testing translation service...")
context.area.header_text_set("Testing translation service...")
# Test translation with a simple word
test_word = "テスト" # "Test" in Japanese
logger.info(f"Testing translation of '{test_word}'")
result = manager.translate_single(test_word, "auto")
# Clear status
context.area.header_text_set(None)
if result.method == "failed":
logger.error(f"Translation test failed: {result}")
self.report({'ERROR'}, t("Translation.test_failed"))
else:
service_info = f" ({result.service})" if result.service else ""
success_msg = f"Translation test successful: '{test_word}''{result.translated}' via {result.method}{service_info}"
logger.info(f"Translation test successful: {result}")
self.report({'INFO'}, success_msg)
return {'FINISHED'}
except Exception as e:
try:
context.area.header_text_set(None)
except:
pass
logger.error(f"Translation service test failed: {e}", exc_info=True)
self.report({'ERROR'}, f"Service test failed: {str(e)}")
return {'CANCELLED'}
class AvatarToolkit_OT_ClearTranslationCache(Operator):
"""Clear all translation caches"""
bl_idname: str = "avatar_toolkit.clear_translation_cache"
bl_label: str = t("Translation.clear_cache")
bl_description: str = t("Translation.clear_cache_desc")
def execute(self, context: Context) -> Set[str]:
try:
from ..core.translation_manager import get_avatar_translation_manager
manager = get_avatar_translation_manager()
manager.clear_all_caches()
self.report({'INFO'}, t("Translation.cache_cleared"))
return {'FINISHED'}
except Exception as e:
logger.error(f"Failed to clear translation cache: {e}")
self.report({'ERROR'}, f"Failed to clear cache: {str(e)}")
return {'CANCELLED'}
class AvatarToolkit_OT_ConfigureDeepL(Operator):
"""Configure DeepL API settings"""
bl_idname: str = "avatar_toolkit.configure_deepl"
bl_label: str = t("Translation.configure_deepl")
bl_description: str = t("Translation.configure_deepl_desc")
api_key: bpy.props.StringProperty(
name=t("Translation.deepl_api_key"),
description=t("Translation.deepl_api_key_desc"),
default="",
subtype='PASSWORD'
)
def execute(self, context: Context) -> Set[str]:
try:
if not self.api_key.strip():
self.report({'ERROR'}, "API key cannot be empty")
return {'CANCELLED'}
from ..core.translation_manager import configure_translation_service
success = configure_translation_service("deepl", api_key=self.api_key.strip())
if success:
_ui_cache['deepl_config'].clear()
_ui_cache['translation_status'].clear()
if 'batch_info' in _ui_cache:
del _ui_cache['batch_info']
self.report({'INFO'}, "DeepL API configured successfully")
return {'FINISHED'}
else:
self.report({'ERROR'}, "Failed to configure DeepL API - check your API key")
return {'CANCELLED'}
except Exception as e:
logger.error(f"DeepL configuration failed: {e}")
self.report({'ERROR'}, f"Configuration failed: {str(e)}")
return {'CANCELLED'}
def invoke(self, context: Context, event: Event) -> Set[str]:
# Load existing API key if available
try:
from ..core.addon_preferences import get_preference
existing_key = get_preference("deepl_api_key", "")
if existing_key:
# Show only first/last few characters for security
if len(existing_key) > 8:
display_key = existing_key[:4] + "..." + existing_key[-4:]
self.api_key = existing_key # Keep full key for editing
else:
self.api_key = existing_key
except:
pass
wm: WindowManager = context.window_manager
return wm.invoke_props_dialog(self, width=400)
def draw(self, context: Context) -> None:
layout: UILayout = self.layout
info_box = layout.box()
info_col = info_box.column()
info_col.label(text="DeepL API Configuration", icon='SETTINGS')
info_col.separator()
info_col.label(text="1. Visit deepl.com/pro to get your free API key")
info_col.label(text="2. Free tier: 500,000 characters/month")
info_col.label(text="3. Higher quality than other services")
info_col.label(text="4. The Fastest Option due to native batching support")
layout.separator()
layout.prop(self, "api_key")
class AvatarToolkit_OT_ConfigureLibreTranslate(Operator):
"""Configure LibreTranslate server settings"""
bl_idname: str = "avatar_toolkit.configure_libretranslate"
bl_label: str = t("Translation.configure_libretranslate")
bl_description: str = t("Translation.configure_libretranslate_desc")
server_url: bpy.props.StringProperty(
name=t("Translation.server_url"),
description=t("Translation.server_url_desc"),
default="https://libretranslate.com"
)
api_key: bpy.props.StringProperty(
name=t("Translation.api_key"),
description=t("Translation.api_key_desc"),
default="",
subtype='PASSWORD'
)
def execute(self, context: Context) -> Set[str]:
try:
if not self.server_url.strip():
self.report({'ERROR'}, "Server URL cannot be empty")
return {'CANCELLED'}
from ..core.translation_manager import configure_translation_service
success = configure_translation_service("libretranslate",
server_url=self.server_url.strip(),
api_key=self.api_key.strip() if self.api_key.strip() else None)
if success:
_ui_cache['libretranslate_config'].clear()
_ui_cache['translation_status'].clear()
if 'batch_info' in _ui_cache:
del _ui_cache['batch_info']
self.report({'INFO'}, f"LibreTranslate server configured: {self.server_url}")
return {'FINISHED'}
else:
self.report({'ERROR'}, "Failed to connect to LibreTranslate server")
return {'CANCELLED'}
except Exception as e:
logger.error(f"LibreTranslate configuration failed: {e}")
self.report({'ERROR'}, f"Configuration failed: {str(e)}")
return {'CANCELLED'}
def invoke(self, context: Context, event: Event) -> Set[str]:
# Load existing server URL and API key if available
try:
from ..core.addon_preferences import get_preference
existing_url = get_preference("libretranslate_url", "https://libretranslate.com")
existing_api_key = get_preference("libretranslate_api_key", "")
self.server_url = existing_url
self.api_key = existing_api_key
except:
pass
wm: WindowManager = context.window_manager
return wm.invoke_props_dialog(self, width=500)
def draw(self, context: Context) -> None:
layout: UILayout = self.layout
info_box = layout.box()
info_col = info_box.column()
info_col.label(text="LibreTranslate Server Configuration", icon='SETTINGS')
info_col.separator()
info_col.label(text="⚠ libretranslate.com requires payment for API access")
info_col.label(text="✓ You can run your own LibreTranslate server")
info_col.label(text="✓ Or find community-hosted instances")
info_col.separator()
info_col.label(text="Examples:")
info_col.label(text=" • Your server: https://translate.yoursite.com")
info_col.label(text=" • Docker local: http://localhost:5000")
layout.separator()
layout.prop(self, "server_url")
layout.prop(self, "api_key")
class AvatarToolkit_OT_TranslationStats(Operator):
"""Show translation statistics"""
bl_idname: str = "avatar_toolkit.translation_stats"
bl_label: str = t("Translation.show_stats")
bl_description: str = t("Translation.show_stats_desc")
def execute(self, context: Context) -> Set[str]:
return {'FINISHED'}
def invoke(self, context: Context, event: Event) -> Set[str]:
wm: WindowManager = context.window_manager
return wm.invoke_props_dialog(self, width=400)
def draw(self, context: Context) -> None:
layout: UILayout = self.layout
try:
from ..core.translation_manager import get_avatar_translation_manager
manager = get_avatar_translation_manager()
stats = manager.get_translation_stats()
dict_box = layout.box()
dict_box.label(text="Dictionary Translations", icon='BOOKMARKS')
dict_stats = stats['dictionary_translations']
for category, count in dict_stats.items():
if count > 0:
dict_box.label(text=f"{category.title()}: {count}")
cache_box = layout.box()
cache_box.label(text="Translation Cache", icon='FILE_CACHE')
cache_stats = stats['cache_stats']
cache_box.label(text=f"Language pairs: {cache_stats['language_pairs']}")
cache_box.label(text=f"Total cached: {cache_stats['total_entries']}")
service_box = layout.box()
service_box.label(text="Translation Services", icon='WORLD')
service_box.label(text=f"Current mode: {stats['current_mode']}")
service_box.label(text=f"Primary service: {stats['primary_service']}")
available_services = stats['available_services']
if available_services:
service_box.label(text="Available services:")
for service_id, service_name in available_services:
service_box.label(text=f"{service_name}")
else:
service_box.label(text="No services available", icon='ERROR')
except Exception as e:
layout.label(text=f"Error loading stats: {str(e)}", icon='ERROR')
class AvatarToolKit_PT_TranslationPanel(Panel):
"""Translation panel for Avatar Toolkit"""
bl_label: str = t("Translation.label")
bl_idname: str = "OBJECT_PT_avatar_toolkit_translation"
bl_space_type: str = 'VIEW_3D'
bl_region_type: str = 'UI'
bl_category: str = CATEGORY_NAME
bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
bl_order: int = 9
bl_options = {'DEFAULT_CLOSED'}
def draw(self, context: Context) -> None:
"""Draw the translation panel layout"""
layout: UILayout = self.layout
props = context.scene.avatar_toolkit
# Translation Service Settings
service_box: UILayout = layout.box()
col: UILayout = service_box.column(align=True)
row: UILayout = col.row()
row.scale_y = 1.2
row.label(text=t("Translation.service_settings"), icon='WORLD')
col.separator()
col.prop(props, "translation_service", text="")
col.prop(props, "translation_mode", text="")
row = col.row(align=True)
row.prop(props, "translation_expand",
icon="TRIA_DOWN" if props.translation_expand else "TRIA_RIGHT",
icon_only=True, emboss=False)
row.label(text=t("Translation.advanced_settings"))
if props.translation_expand:
config_col = service_box.column(align=True)
# MyMemory settings (no configuration needed)
if props.translation_service == 'mymemory':
config_col.separator()
config_col.label(text="MyMemory Configuration:", icon='CHECKMARK')
success_col = config_col.column()
success_col.alert = False
success_col.label(text="✓ No API key required!", icon='CHECKMARK')
success_col.label(text="✓ Completely free service")
success_col.label(text="✓ 1000 translations per day")
success_col.label(text="✓ Slowest Option due to no native batching")
success_col.label(text="✓ Ready to use!")
elif props.translation_service == 'libretranslate':
config_col.separator()
config_col.label(text="LibreTranslate Configuration:", icon='SETTINGS')
# Check current server configuration (cached to avoid performance issues)
try:
if 'libretranslate_url' not in _ui_cache['libretranslate_config']:
from ..core.addon_preferences import get_preference
_ui_cache['libretranslate_config']['libretranslate_url'] = get_preference("libretranslate_url", "https://libretranslate.com")
server_url = _ui_cache['libretranslate_config']['libretranslate_url']
info_col = config_col.column()
info_col.alert = False
info_col.label(text=f"Server: {server_url}", icon='URL')
if "libretranslate.com" in server_url.lower():
warning_col = config_col.column()
warning_col.alert = True
warning_col.label(text="⚠ Default server requires payment", icon='ERROR')
warning_col.label(text="Configure your own LibreTranslate server")
else:
success_col = config_col.column()
success_col.alert = False
success_col.label(text="✓ Custom server configured", icon='CHECKMARK')
config_row = config_col.row()
config_row.operator("avatar_toolkit.configure_libretranslate", text="Configure Server", icon='SETTINGS')
except Exception as e:
config_col.label(text="LibreTranslate configuration error", icon='ERROR')
elif props.translation_service == 'deepl':
config_col.separator()
config_col.label(text="DeepL Configuration:", icon='SETTINGS')
# Check if API key is configured (cached to avoid performance issues)
try:
if 'deepl_api_key' not in _ui_cache['deepl_config']:
from ..core.addon_preferences import get_preference
_ui_cache['deepl_config']['deepl_api_key'] = get_preference("deepl_api_key", "")
deepl_api_key = _ui_cache['deepl_config']['deepl_api_key']
if deepl_api_key and deepl_api_key.strip():
success_col = config_col.column()
success_col.alert = False
success_col.label(text="✓ API key configured", icon='CHECKMARK')
success_col.label(text="✓ High quality translations")
success_col.label(text="✓ 500,000 chars/month free")
success_col.label(text="✓ Ready to use!")
reconfig_row = config_col.row()
reconfig_row.operator("avatar_toolkit.configure_deepl", text="Reconfigure API Key", icon='SETTINGS')
else:
warning_col = config_col.column()
warning_col.alert = True
warning_col.label(text="⚠ API key required!", icon='ERROR')
warning_col.label(text="Get free key at deepl.com/pro")
warning_col.label(text="500,000 characters/month free")
config_row = config_col.row()
config_row.operator("avatar_toolkit.configure_deepl", text="Configure API Key", icon='PLUS')
except Exception as e:
config_col.label(text="DeepL configuration error", icon='ERROR')
# Language Settings
lang_box: UILayout = layout.box()
col = lang_box.column(align=True)
row = col.row()
row.scale_y = 1.2
row.label(text=t("Translation.language_settings"), icon='SYNTAX_ON')
col.separator()
col.prop(props, "translation_source_language", text="From")
col.prop(props, "translation_target_language", text="To")
# Quick Actions
action_box: UILayout = layout.box()
col = action_box.column(align=True)
row = col.row()
row.scale_y = 1.2
row.label(text=t("Translation.quick_actions"), icon='PLAY')
col.separator()
# Translate buttons
row = col.row(align=True)
op_bones = row.operator(AvatarToolkit_OT_TranslateNames.bl_idname, text="Bones", icon='BONE_DATA')
op_bones.translation_type = 'bones'
op_shapes = row.operator(AvatarToolkit_OT_TranslateNames.bl_idname, text="Shape Keys", icon='SHAPEKEY_DATA')
op_shapes.translation_type = 'shapekeys'
row = col.row(align=True)
op_mats = row.operator(AvatarToolkit_OT_TranslateNames.bl_idname, text="Materials", icon='MATERIAL_DATA')
op_mats.translation_type = 'materials'
op_objs = row.operator(AvatarToolkit_OT_TranslateNames.bl_idname, text="Objects", icon='OBJECT_DATA')
op_objs.translation_type = 'objects'
col.separator()
op_all = col.operator(AvatarToolkit_OT_TranslateNames.bl_idname, text="Translate All", icon='WORLD')
op_all.translation_type = 'all'
# Utility buttons
util_box: UILayout = layout.box()
col = util_box.column(align=True)
row = col.row()
row.scale_y = 1.2
row.label(text=t("Translation.utilities"), icon='TOOL_SETTINGS')
col.separator()
row = col.row(align=True)
row.operator(AvatarToolkit_OT_TestTranslationService.bl_idname, icon='PLAY')
row.operator(AvatarToolkit_OT_TranslationStats.bl_idname, icon='INFO')
col.operator(AvatarToolkit_OT_ClearTranslationCache.bl_idname, icon='TRASH')
status_box = layout.box()
status_col = status_box.column()
try:
status_cache_key = f"translation_status_{props.translation_service}_{props.translation_mode}"
# Refresh cache periodically
frame = context.scene.frame_current
cache_expired = (frame - _ui_cache['last_refresh_frame'] >= _ui_cache['cache_refresh_interval']) or status_cache_key not in _ui_cache['translation_status']
if cache_expired:
from ..core.translation_manager import get_available_translation_services, get_avatar_translation_manager
manager = get_avatar_translation_manager()
available_services = get_available_translation_services()
_ui_cache['translation_status'][status_cache_key] = {
'available_services': available_services,
'manager': manager,
'cache_stats': None
}
_ui_cache['last_refresh_frame'] = frame
try:
stats = manager.get_translation_stats()
_ui_cache['translation_status'][status_cache_key]['cache_stats'] = stats['cache_stats']
except:
pass
# Use cached data
cached_data = _ui_cache['translation_status'].get(status_cache_key, {})
available_services = cached_data.get('available_services', [])
cache_stats = cached_data.get('cache_stats')
if available_services:
status_col.label(text="Translation services ready", icon='CHECKMARK')
# Show current service status
current_service = props.translation_service
service_available = any(service_id == current_service for service_id, _ in available_services)
if service_available:
service_name = next((name for sid, name in available_services if sid == current_service), current_service)
status_col.label(text=f"Active: {service_name}", icon='WORLD')
# Show translation mode
mode_display = {
'hybrid': 'Dictionary + API',
'dictionary_only': 'Dictionary Only',
'api_only': 'API Only'
}.get(props.translation_mode, props.translation_mode)
status_col.label(text=f"Mode: {mode_display}", icon='SETTINGS')
# Show cache status
if cache_stats and cache_stats['total_entries'] > 0:
status_col.label(text=f"Cache: {cache_stats['total_entries']} translations", icon='FILE_CACHE')
# Show batch translation capability
try:
if 'batch_info' not in _ui_cache:
from ..core.translation_manager import get_batch_translation_info
_ui_cache['batch_info'] = get_batch_translation_info()
batch_info = _ui_cache['batch_info'].get(current_service, {})
if batch_info.get('supports_batch', False):
batch_type = batch_info.get('batch_type', 'individual')
if batch_type == 'native':
status_col.label(text="⚡ DeepL Native batch translation (up to 50x faster)", icon='LIGHT')
elif batch_type == 'concurrent':
if current_service == 'mymemory':
status_col.label(text="⚡ Slowest Option, no native Batching", icon='LIGHT')
else:
status_col.label(text="⚡ Slightly Faster then MyMemory processing (3x faster)", icon='LIGHT')
except:
pass
else:
warning_col = status_col.column()
warning_col.alert = True
warning_col.label(text=f"Service unavailable: {props.translation_service}", icon='ERROR')
else:
warning_col = status_col.column()
warning_col.alert = True
warning_col.label(text="No translation services available", icon='ERROR')
if props.translation_service == 'mymemory':
warning_col.label(text="Internet connection required")
except Exception as e:
error_col = status_col.column()
error_col.alert = True
error_col.label(text="Translation system error", icon='ERROR')
logger.error(f"Status display error: {e}")
try:
if hasattr(context.area, 'header_text') and context.area.header_text:
progress_col = status_col.column()
progress_col.alert = False
progress_col.label(text=context.area.header_text, icon='TIME')
except:
pass
+2 -1
View File
@@ -1,6 +1,7 @@
import bpy
from bpy.types import Panel, Context, UILayout
from ..core.translations import t
from .main_panel import CATEGORY_NAME
class AvatarToolKit_PT_UVPanel(Panel):
"""Main UV Tools panel for Avatar Toolkit"""
@@ -8,7 +9,7 @@ class AvatarToolKit_PT_UVPanel(Panel):
bl_idname = "OBJECT_PT_avatar_toolkit_uv_main"
bl_space_type = 'IMAGE_EDITOR'
bl_region_type = 'UI'
bl_category = "Avatar Toolkit"
bl_category = CATEGORY_NAME
def draw(self, context: Context) -> None:
layout: UILayout = self.layout
+6 -4
View File
@@ -1,6 +1,8 @@
import bpy
from bpy.types import Panel, Context, UILayout
from ..core.translations import t
from ..functions.tools.uv_tools import AvatarToolkit_OT_AlignUVEdgesToTarget
from .uv_panel import AvatarToolKit_PT_UVPanel
class AvatarToolKit_PT_UVTools(Panel):
"""UV Tools panel containing UV manipulation operators"""
@@ -8,9 +10,9 @@ class AvatarToolKit_PT_UVTools(Panel):
bl_idname = "OBJECT_PT_avatar_toolkit_uv_tools"
bl_space_type = 'IMAGE_EDITOR'
bl_region_type = 'UI'
bl_category = "Avatar Toolkit"
bl_parent_id = "OBJECT_PT_avatar_toolkit_uv_main"
bl_order = 3
bl_category = "UV Tools"
bl_parent_id = AvatarToolKit_PT_UVPanel.bl_idname
bl_order = 0
bl_options = {'DEFAULT_CLOSED'}
def draw(self, context: Context) -> None:
@@ -22,6 +24,6 @@ class AvatarToolKit_PT_UVTools(Panel):
col.separator(factor=0.5)
row: UILayout = col.row(align=True)
row.operator("avatar_toolkit.align_uv_edges_to_target",
row.operator(AvatarToolkit_OT_AlignUVEdgesToTarget.bl_idname,
text=t("UVTools.align_edges"),
icon='GP_MULTIFRAME_EDITING')
+3 -2
View File
@@ -3,6 +3,7 @@ from bpy.types import Panel, Context, UILayout, Object, ShapeKey
from ..core.translations import t
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
from ..core.common import get_active_armature
from ..functions.visemes import AvatarToolkit_OT_PreviewVisemes, AvatarToolkit_OT_CreateVisemes
class AvatarToolKit_PT_VisemesPanel(Panel):
"""Panel containing viseme creation and preview tools"""
@@ -65,11 +66,11 @@ class AvatarToolKit_PT_VisemesPanel(Panel):
col.separator()
preview_text: str = t("Visemes.stop_preview") if props.viseme_preview_mode else t("Visemes.start_preview")
col.operator("avatar_toolkit.preview_visemes", text=preview_text, icon='HIDE_OFF')
col.operator(AvatarToolkit_OT_PreviewVisemes.bl_idname, text=preview_text, icon='HIDE_OFF')
# Create Box
create_box: UILayout = layout.box()
col: UILayout = create_box.column(align=True)
col.label(text=t("Visemes.create_label"), icon='ADD')
col.separator(factor=0.5)
col.operator("avatar_toolkit.create_visemes", icon='ADD')
col.operator(AvatarToolkit_OT_CreateVisemes.bl_idname, icon='ADD')
+87
View File
@@ -0,0 +1,87 @@
import bpy
from bpy.types import Panel, Context, UILayout
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
from ..core.translations import t
from ..core.common import get_active_armature
from ..core.vrm_unity_converter import detect_vrm_armature
from ..functions.tools.vrm_unity_conversion import AvatarToolkit_OT_ConvertVRMToUnity
class AvatarToolKit_PT_VRMUnityPanel(Panel):
"""Panel for VRM to Unity conversion tools"""
bl_label = t("VRM.panel.label")
bl_idname = "OBJECT_PT_avatar_toolkit_vrm_unity"
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:
"""Draw the VRM to Unity conversion panel interface"""
layout: UILayout = self.layout
# VRM Conversion Tools
vrm_box: UILayout = layout.box()
col: UILayout = vrm_box.column(align=True)
col.label(text=t("VRM.converter.title"), icon='ARMATURE_DATA')
col.separator(factor=0.5)
# Check if we have an active armature
armature = get_active_armature(context)
if not armature:
col.label(text=t("VRM.no_armature_selected"), icon='ERROR')
col.label(text=t("VRM.select_armature_to_convert"))
return
# Check if the armature appears to be VRM
is_vrm = detect_vrm_armature(armature)
if is_vrm:
col.label(text=t("VRM.armature_name", name=armature.name), icon='CHECKMARK')
col.label(text=t("VRM.armature_detected"), icon='INFO')
col.separator(factor=0.3)
toolkit = context.scene.avatar_toolkit
col.prop(toolkit, 'vrm_remove_colliders', text=t("VRM.remove_colliders"))
col.prop(toolkit, 'vrm_remove_root', text=t("VRM.remove_root_bone"))
col.separator(factor=0.2)
col.operator(
AvatarToolkit_OT_ConvertVRMToUnity.bl_idname,
text=t("VRM.convert_to_unity_format"),
icon='EXPORT'
)
info_box = vrm_box.box()
info_col = info_box.column(align=True)
info_col.label(text=t("VRM.conversion_info.title"), icon='INFO')
info_col.label(text=t("VRM.conversion_info.renames_bones"))
info_col.label(text=t("VRM.conversion_info.removes_colliders"))
info_col.label(text=t("VRM.conversion_info.removes_root"))
info_col.label(text=t("VRM.conversion_info.maintains_hierarchy"))
info_col.label(text=t("VRM.conversion_info.validates_results"))
info_col.label(text=t("VRM.conversion_info.preserves_animations"))
else:
col.label(text=t("VRM.armature_name", name=armature.name), icon='ERROR')
col.label(text=t("VRM.no_vrm_bones_detected"), icon='CANCEL')
col.separator(factor=0.3)
row = col.row()
row.enabled = False
row.operator(
AvatarToolkit_OT_ConvertVRMToUnity.bl_idname,
text=t("VRM.convert_to_unity_format"),
icon='CANCEL'
)
help_box = vrm_box.box()
help_col = help_box.column(align=True)
help_col.label(text=t("VRM.detection_failed.title"), icon='QUESTION')
help_col.label(text=t("VRM.detection_failed.not_vrm_format"))
help_col.label(text=t("VRM.detection_failed.bones_start_with"))
help_col.label(text=t("VRM.detection_failed.need_five_bones"))
help_col.label(text=t("VRM.detection_failed.check_bone_names"))