Compare commits

...

84 Commits

Author SHA1 Message Date
Yusarina 37b92ded6d Change allowed version series to 0.5 2025-11-29 22:52:13 +00:00
Yusarina fb470f19da Merge pull request #217 from Yusarina/Current
Fix garbled Japanese/Unicode text in armature and mesh dropdowns
2025-11-29 22:45:11 +00:00
Yusarina 843147db69 Fix garbled Japanese/Unicode text in armature and mesh dropdowns
- Add proper caching to EnumProperty callbacks to prevent encoding corruption
- Use ASCII-safe identifiers (ARM_/MESH_ + pointer) with Unicode display names
- Add get_mesh_from_identifier() helper for safe mesh retrieval
- Update visemes panel to use new mesh identifier system
- Ensure stable string objects prevent Blender RNA encoding issues
2025-11-29 22:44:26 +00:00
Yusarina fe2b0d50cb Merge pull request #216 from Yusarina/Current
Fix swapped operator IDs for Apply Pose as Rest/Shapekey buttons #211
2025-11-29 22:26:40 +00:00
Yusarina c4dca2455d Fix swapped operator IDs for Apply Pose as Rest/Shapekey buttons #211 2025-11-29 22:22:58 +00:00
Yusarina 659f3eb91e Update version label in Korean translation 2025-11-22 15:26:19 +00:00
Yusarina ff19a895dc Update AvatarToolkit label version to 0.5.2 2025-11-22 15:26:07 +00:00
Yusarina e6e5a98e58 Update Avatar Toolkit version to Alpha 0.5.2 2025-11-22 15:25:55 +00:00
Yusarina 3fe00da569 Bump version from 0.5.1 to 0.5.2 2025-11-22 15:25:17 +00:00
Yusarina 108f9d3bc8 Merge pull request #214 from Yusarina/Current
Fix to translation service
2025-11-22 14:11:16 +00:00
Yusarina 1847628dc8 Fix to translation service 2025-11-22 13:12:48 +00:00
Yusarina 25a43afdbc Merge pull request #213 from Yusarina/atk-next
Logging Fix
2025-11-20 03:22:12 +00:00
Yusarina baaf4049f6 Logging Fix 2025-11-20 03:21:31 +00:00
Yusarina 7ef86b68fa Update AvatarToolkit label version in Korean translation 2025-11-19 06:48:11 +00:00
Yusarina 27e18b5656 Update AvatarToolkit label version in Japanese translation 2025-11-19 06:47:59 +00:00
Yusarina b61283b9d5 Update Avatar Toolkit version in translations 2025-11-19 06:47:49 +00:00
Yusarina fbcf709ffc Downgrade version from 0.6.0 to 0.5.1 2025-11-19 06:47:25 +00:00
Yusarina 299800e5c2 Update allowed version series to 0.6 2025-11-19 06:41:43 +00:00
Yusarina f6197ccbbf Merge pull request #210 from teamneoneko/Current
Bring Next Up To Speed
2025-11-19 06:41:05 +00:00
Yusarina fd01c39cf9 Merge branch 'atk-next' into Current 2025-11-19 06:40:58 +00:00
Yusarina 117ce4f41d Merge pull request #209 from Yusarina/Current
Fixed Updater
2025-11-19 06:39:06 +00:00
Yusarina f11e9d35fb Fixed Updater 2025-11-19 06:38:44 +00:00
Yusarina 7f1decc644 Merge pull request #208 from Yusarina/Current
Fixed to PMX Import
2025-11-19 06:36:25 +00:00
Yusarina a929f68ad4 Holy shit this was a pain
- Truly fixes PMX Import lol, i messed up completely
- Updated MMD Tools to use Cats One
2025-11-19 06:35:06 +00:00
Yusarina f0bda259d3 Fix PMX import for Blender 5.0 - remove deprecated UV texture APIs
- Replace mesh.uv_textures with mesh.uv_layers
- Remove deprecated UV selection properties
- Add compatibility helpers for UV vertex selection
- Fix morph operators UV handling
2025-11-19 05:06:13 +00:00
Yusarina f4d93a8180 Merge pull request #207 from Yusarina/atk-next
Translation Strings Fixes
2025-11-19 04:36:38 +00:00
Yusarina 303707adf7 Translation String Fix 2025-11-19 04:34:32 +00:00
Yusarina ef84478af7 Translation Strings Fix 2025-11-19 04:32:34 +00:00
Yusarina 56005c5d37 Translations Strings Fixes 2025-11-19 04:30:57 +00:00
Yusarina fe122f9f13 Merge pull request #206 from Yusarina/atk-next
panel order and default open logic
2025-11-16 18:52:13 +00:00
Yusarina 17fb0fcadd panel order and default open logic
Replaced hardcoded panel order and default open/closed options with dynamic values using get_panel_order and should_open_by_default from panel_layout.
2025-11-16 18:50:50 +00:00
Yusarina 1d9c186613 How? 2025-11-16 18:35:55 +00:00
Yusarina 49f5bf7063 Merge pull request #205 from Yusarina/atk-next
improve UI consistency and reduce code duplication
2025-11-16 18:33:31 +00:00
Yusarina daef1298d4 improve UI consistency and reduce code duplication
- Add ui_utils.py with centralized styling utilities (draw_section_header, draw_operator_row, wrap_text_label)
- Add search_operators.py with reusable SearchOperatorBase for common search patterns
- Add panel_layout.py for centralized panel ordering configuration
- Refactor 6 panels to use new utilities (optimization, tools, settings, eye_tracking, main, quick_access)
- Consolidate multi-label warnings into single wrapped text (eye tracking panel)
- Combine single-button rows into compact operator rows
- Standardize button scaling with UIStyle constants
- Add help text to validation settings
- Reduce duplicate code by ~200 lines
- Improve information density by 25-40% through better layout organization
2025-11-16 18:31:54 +00:00
Yusarina 86406efc6b Merge pull request #204 from Yusarina/atk-next
Update lz4 wheels
2025-11-16 01:58:05 +00:00
Yusarina 734d5fe401 Updae lz4 wheels 2025-11-16 01:57:00 +00:00
Yusarina 5029ba8724 Merge pull request #203 from Yusarina/atk-next
overhaul armature validation system to be opt-in by default
2025-11-16 01:49:44 +00:00
Yusarina 3545951fae refactor: overhaul armature validation system to be opt-in by default
- Change default validation mode from STRICT to NONE (disabled)
- Move validation from automatic panel draw to explicit "Validate Now" button
- Hide validation results when mode is changed to NONE
- Fix PMX/MMD model detection to check mmd_type value, not just attribute existence
- Add new validation result collapsible sections
- Improve UI presentation with better visual hierarchy
- Add translation strings for new validation UI elements
2025-11-16 01:47:21 +00:00
Yusarina 0b5bff9222 Update ko_KR.json 2025-11-15 17:21:04 +00:00
Yusarina 862849c032 Update AvatarToolkit label to version 0.5.0 2025-11-15 17:20:56 +00:00
Yusarina e060186716 Update Avatar Toolkit version to Alpha 0.5.0 2025-11-15 17:20:44 +00:00
Onan Chew 07c4dd501f Merge pull request #202 from Yusarina/Current
Migrate to Blender 5.0 API
2025-11-14 22:47:30 -05:00
Yusarina e80c0c034d Version Change
- Min Blender version is 5.0
- ATK version is 0.5.0
2025-11-15 02:52:04 +00:00
Yusarina f40b2faacb Migrate to Blender 5.0 API
- Replaced action.fcurves with channelbag system
- Updated EEVEE_NEXT to EEVEE render engine
- Removed deprecated material.use_nodes and use_shadeless
- Fixed bone selection/hide API for Pose mode
2025-11-15 02:45:37 +00:00
Onan Chew d2b98716ff Merge pull request #201 from Yusarina/Current
Fix texture atlas crash caused by premature image removal
2025-11-12 15:20:01 -05:00
Yusarina e4f3cdbf17 Fix texture atlas crash caused by premature image removal
- Changed image replacement logic to reuse existing placeholder images instead of deleting and recreating them. This should prevents ReferenceError when multiple materials reference the same replacement image
2025-11-12 16:44:07 +00:00
Onan Chew 1d34ac2dd8 Merge pull request #200 from teamneoneko/Alpha-4
Alpha 4
Unbreak things
2025-10-29 12:15:05 -04:00
Onan Chew 3bb533ff64 Merge branch 'Current' into Alpha-4 2025-10-29 12:14:38 -04:00
Onan Chew 69cae02160 Merge pull request #199 from Yusarina/patch-1
Fix updater for Alpha 4 releases
2025-10-29 12:03:39 -04:00
Onan Chew 5496078a39 Merge pull request #197 from Yusarina/Current
Revise README for Blender support and wiki notice
2025-10-29 12:03:18 -04:00
Yusarina dbf2fb77f9 Fix updater for Alpha 4 releases
The tag never for updated for Alpha 4, but also the first release of alpha 4 tag was incorrect so this allows for Alpha 3 and 4 tags.
2025-10-29 15:29:43 +00:00
Yusarina 3de600cf64 Revise README for Blender support and wiki notice
Updated README to reflect changes in Blender version support and added a notice about wiki status and Offical blender version support only.
2025-10-14 17:21:33 +01:00
Onan Chew ba9d579176 Merge pull request #195 from hanzcvr/feat/morebones
dictionaries: add mappings for upper legs and toes observed on Komado's Rusk
2025-10-13 00:22:02 -04:00
Hanz 35458f9aed dictionaries: add mappings for upper legs and toes observed on Komado's Rusk 2025-10-12 19:30:30 -05:00
Onan Chew d2c30caef5 Merge pull request #194 from teamneoneko/Alpha-3
Alpha 3 fix
2025-10-06 19:28:29 -04:00
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
Onan Chew b9f7a4acd0 Merge pull request #192 from Yusarina/Current
- bug fixes to merge armature button
2025-08-24 16:09:00 -04:00
Yusarina e626bdc5c5 Loggin Fix 2025-08-23 22:36:59 +01:00
Yusarina da2bfeb2fc Version Bump 2025-08-22 23:06:27 +01:00
Yusarina 2b53146e83 Armature Meging Fixes
- Fixes issue with Armature Merging giving a error.
- Fixes logger error
2025-08-22 23:05:05 +01: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
Onan Chew 4ba594d712 Merge pull request #187 from Yusarina/Current
Alpha 3: Avatar Toolkit 0.3.2
2025-08-09 02:47:57 -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
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 2f3b8ab0ee Version Bump 2025-08-03 15:00:25 +01: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 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
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
88 changed files with 9831 additions and 3210 deletions
+79
View File
@@ -0,0 +1,79 @@
# Fix for Garbled Japanese/Non-ASCII Text in Dropdowns
## Problem
Japanese, Korean, Chinese, and other non-ASCII characters were displaying as garbled/corrupted text in dropdown menus for:
- Armature selection in Quick Access panel
- Mesh selection in Visemes panel
This is a known issue with Blender's EnumProperty system when using dynamic callbacks that return Unicode strings.
## Root Cause
Blender's EnumProperty RNA system can have encoding issues when:
1. The enum items function is called multiple times with changing data
2. Unicode strings in display names aren't properly cached
3. The internal C API receives the same Python string object in different states
## Solution
Implemented proper caching with invalidation for EnumProperty items:
### Changes Made
1. **core/common.py** - Enhanced `get_armature_list()` function
- Added cache key based on (name, pointer) tuples
- Cache is invalidated only when actual objects change
- Prevents Blender from re-encoding strings on every access
- Added `clear_enum_caches()` helper function
2. **core/properties.py** - Enhanced `get_mesh_objects()` function
- Added same caching mechanism as armature list
- Cache key based on mesh objects (name, pointer)
- Stable cache prevents encoding corruption
3. **core/common.py** - `get_mesh_from_identifier()` helper
- Converts safe identifier back to mesh object
- Handles both new format (`MESH_{pointer}`) and legacy format
- Returns None if mesh not found
4. **ui/visemes_panel.py** - Updated mesh retrieval
- Uses `get_mesh_from_identifier()` instead of direct lookup
5. **functions/visemes.py** - Updated all mesh access points
- All operators now use the helper function consistently
## Technical Details
### ASCII-Safe Identifiers
- Dropdown identifier: `ARM_{memory_pointer}` or `MESH_{memory_pointer}` (ASCII-safe, unique)
- Dropdown display: Original object name (preserves Unicode characters)
- Backwards compatibility: Falls back to direct name lookup
### Caching Strategy
The cache uses function attributes to store:
- `_cache_key`: Tuple of (name, pointer) for all relevant objects
- `_cached_items`: The actual list of enum items
Cache is invalidated when:
- Objects are added/removed
- Objects are renamed
- Object pointers change (object recreated)
This ensures Blender's RNA system receives the exact same Python string objects on subsequent calls, preventing encoding corruption.
## Testing
To verify the fix works:
1. Create armature/mesh objects with Japanese/Korean/Chinese names (e.g., "アバター", "아바타", "化身")
2. Open Quick Access panel - armature dropdown should display correctly
3. Open Visemes panel - mesh dropdown should display correctly
4. Select items - operations should work with the selected objects
5. Rename objects - dropdowns should update and still display correctly
## Related Files
- `core/properties.py` - Property definitions and mesh enumeration
- `core/common.py` - Common utility functions and armature enumeration
- `ui/visemes_panel.py` - Visemes UI panel
- `ui/quick_access_panel.py` - Quick Access UI panel
- `functions/visemes.py` - Viseme operators
## Note on prop_search
The `prop_search` widget used for shape key/bone selection inherently handles non-ASCII characters correctly since it searches Blender's internal data structures directly, not custom enum properties.
+14 -4
View File
@@ -1,4 +1,5 @@
# Avatar Toolkit
We are aware the wiki is down and are working on a new one, please don't report this.
## Avatar Toolkit is in Alpha, There will be issues, please ensure you report them!. If using a Alpha plugin isn't your fancy you can find Cats Blender Plugin [HERE](https://github.com/unofficalcats/Cats-Blender-Plugin-Unofficial-)!
#### Avatar Toolkit is in Alpha and will contain issues, please ensure you report them!
@@ -32,9 +33,8 @@ See everything Avatar Toolkit has ot offer [here](https://avatartoolkit.xyz/lega
## 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
- If using a custom Python installation with Blender, ensure NumPy is installed
@@ -42,7 +42,17 @@ See everything Avatar Toolkit has ot offer [here](https://avatartoolkit.xyz/lega
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
#### Unfortunately, due to the increased number of people complaining to me (yes, we get DMs about this) that AT or CATS is broken when it's not, we are going to have to be a bit more strict about which Blender releases we will provide support for.
#### We only support the following Blender releases:
- Steam release
- The Blender website releases (there are downloads for Linux, Mac, and Windows)
#### We do not support the following what so ever and we will not give help if your running the following.
- We do not support the Windows Store due to it causing issues, and we also don't support the Snap Store for Linux.
- We do not support package manager releases on Linux. This is because package managers are normally run by the distro, and a lot of the time the distro will build Blender themselves and make their own changes which are not sanctioned by Blender (for example, bundling a newer version of Python which tends to break plugins). If you report a bug from anything apart from the Blender versions we support, you will be told we can't help you from now on.
#### Additional Plugins Requirements.
Currently None.
+6 -6
View File
@@ -3,23 +3,23 @@
schema_version = "1.0.0"
id = "avatar_toolkit"
version = "0.3.1"
version = "0.5.2"
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 = "5.0.0"
license = [
"SPDX:GPL-3.0-or-later",
]
wheels = [
"./wheels/lz4-4.4.3-cp311-cp311-macosx_11_0_arm64.whl",
"./wheels/lz4-4.3.3-cp311-cp311-macosx_10_9_x86_64.whl",
"./wheels/lz4-4.4.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
"./wheels/lz4-4.4.3-cp311-cp311-win_amd64.whl"
"./wheels/lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl",
"./wheels/lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl",
"./wheels/lz4-4.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
"./wheels/lz4-4.4.5-cp311-cp311-win_amd64.whl"
]
[permissions]
+1 -1
View File
@@ -63,6 +63,6 @@ def get_addon_preferences(context):
# Initialize preferences if the file doesn't exist
if not os.path.exists(PREFERENCES_FILE):
save_preference("language", 0) # Set default language to 0 (auto)
save_preference("validation_mode", "STRICT") # Set default validation mode
save_preference("validation_mode", "NONE") # Set default validation mode to NONE (off by default)
save_preference("enable_logging", False) # Set default logging mode
save_preference("highlight_problem_bones", True) # Set default bone highlighting
+66 -6
View File
@@ -15,21 +15,40 @@ from ..core.dictionaries import (
)
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 is_pmx_model(armature: Object) -> bool:
"""
Check if the armature is a PMX/MMD model.
PMX models have an mmd_type attribute set to 'ROOT' on the root object.
"""
if not armature:
return False
# Check if armature itself has mmd_type set to ROOT
if hasattr(armature, 'mmd_type') and armature.mmd_type == 'ROOT':
return True
# Check if parent has mmd_type set to ROOT (parent container model)
if hasattr(armature, 'parent') and armature.parent:
parent = armature.parent
if hasattr(parent, 'mmd_type') and parent.mmd_type == 'ROOT':
return True
return False
def validate_armature(armature: Object, detailed_messages: bool = False, override_mode: Optional[str] = None) -> Union[Tuple[bool, List[str], bool], Tuple[bool, List[str], bool, List[str], List[str], List[str]]]:
"""
Validates armature and returns validation results
"""
logger.debug(f"Validating armature: {armature.name if armature else 'None'}")
validation_mode = 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
pmx_model = is_pmx_model(armature)
if pmx_model:
logger.debug("Detected PMX model, using specialized validation")
if validation_mode == 'NONE':
@@ -157,7 +176,7 @@ def validate_armature(armature: Object, detailed_messages: bool = False) -> Unio
non_standard_messages.append(t("Armature.validation.standardize_note.line3"))
# Special handling for PMX models
if is_pmx_model:
if pmx_model:
logger.info("PMX model detected, applying specialized validation")
# For PMX models, we'll be more lenient with validation
# and provide specific guidance for these models
@@ -783,3 +802,44 @@ class AvatarToolkit_OT_ClearBoneHighlighting(Operator):
logger.info("Bone highlighting cleared")
self.report({'INFO'}, t("Validation.highlighting_cleared"))
return {'FINISHED'}
class AvatarToolkit_OT_ValidateArmatureManual(Operator):
"""Manually validate armature and show results"""
bl_idname = "avatar_toolkit.validate_armature_manual"
bl_label = t("Validation.validate_now", "Validate Armature Now")
bl_description = t("Validation.validate_now_desc", "Run armature validation and display detailed results")
@classmethod
def poll(cls, context):
return get_active_armature(context) is not None
def execute(self, context):
armature = get_active_armature(context)
if not armature:
logger.warning("No active armature found for validation")
self.report({'ERROR'}, t("Validation.no_armature"))
return {'CANCELLED'}
logger.info(f"Running manual validation for armature: {armature.name}")
# Clear the validation cache to force a refresh
from ..ui.quick_access_panel import clear_armature_caches
clear_armature_caches()
# Toggle the show_validation_results flag to display results
props = context.scene.avatar_toolkit
props.show_validation_results = True
# Run validation
is_valid, messages, is_acceptable = validate_armature(armature, detailed_messages=False)
if is_valid:
if is_acceptable:
self.report({'INFO'}, t("Armature.validation.acceptable_standard.success"))
else:
self.report({'INFO'}, t("QuickAccess.valid_armature"))
else:
self.report({'WARNING'}, t("Validation.status.failed"))
logger.info("Manual validation complete")
return {'FINISHED'}
+127 -10
View File
@@ -92,23 +92,132 @@ class ProgressTracker:
def get_active_armature(context: Context) -> Optional[Object]:
"""Get the currently selected armature from Avatar Toolkit properties"""
armature_name = str(context.scene.avatar_toolkit.active_armature)
if armature_name and armature_name != 'NONE':
return bpy.data.objects.get(armature_name)
try:
# Get the safe identifier from the enum property
armature_id = context.scene.avatar_toolkit.active_armature
if not armature_id or armature_id == 'NONE':
return None
# The identifier format is "ARM_{pointer_value}"
if armature_id.startswith('ARM_'):
try:
pointer_str = armature_id[4:]
pointer_value = int(pointer_str)
# Find the armature with this pointer value
for obj in context.scene.objects:
if obj.type == 'ARMATURE' and obj.as_pointer() == pointer_value:
return obj
logger.warning(f"Armature with pointer {pointer_value} not found")
except (ValueError, AttributeError) as e:
logger.error(f"Failed to parse armature identifier: {e}")
# Fallback for old-style identifiers (direct name)
# This handles backward compatibility
return bpy.data.objects.get(armature_id)
except (UnicodeDecodeError, UnicodeEncodeError, AttributeError) as e:
# Handle encoding issues as a last resort
logger.warning(f"Encoding issue with active_armature property: {e}")
# Final fallback: return active object if it's an armature, or first armature found
if context.view_layer.objects.active and context.view_layer.objects.active.type == 'ARMATURE':
return context.view_layer.objects.active
for obj in context.scene.objects:
if obj.type == 'ARMATURE':
logger.info(f"Falling back to first armature found: {obj.name}")
return obj
return None
def set_active_armature(context: Context, armature: Object) -> None:
"""Set the active armature for Avatar Toolkit operations"""
context.scene.avatar_toolkit.active_armature = armature
"""Set the active armature for Avatar Toolkit operations using safe identifier"""
if armature and armature.type == 'ARMATURE':
# Use the same safe identifier format as get_armature_list
safe_id = f"ARM_{armature.as_pointer()}"
context.scene.avatar_toolkit.active_armature = safe_id
else:
context.scene.avatar_toolkit.active_armature = 'NONE'
def get_mesh_from_identifier(mesh_id: str) -> Optional[Object]:
"""Get mesh object from safe identifier
Args:
mesh_id: Safe identifier in format "MESH_{pointer}" or direct object name
Returns:
Mesh object or None if not found
"""
if not mesh_id or mesh_id == 'NONE':
return None
# Handle new-style identifiers (MESH_{pointer})
if mesh_id.startswith('MESH_'):
try:
pointer_str = mesh_id[5:] # Remove "MESH_" prefix
target_pointer = int(pointer_str)
# Search for object with matching pointer
for obj in bpy.data.objects:
if obj.type == 'MESH' and obj.as_pointer() == target_pointer:
return obj
except (ValueError, AttributeError):
pass
# Fallback for old-style identifiers (direct name)
return bpy.data.objects.get(mesh_id)
def clear_enum_caches() -> None:
"""Clear all enum property caches to force refresh of dropdown lists"""
if hasattr(get_armature_list, '_cache_key'):
delattr(get_armature_list, '_cache_key')
if hasattr(get_armature_list, '_cached_items'):
delattr(get_armature_list, '_cached_items')
def get_armature_list(self: Optional[Any] = None, context: Optional[Context] = None) -> List[Tuple[str, str, str]]:
"""Get list of all armature objects in the scene"""
"""Get list of all armature objects in the scene
Returns tuples of (identifier, display_name, description) where:
- identifier: ASCII-safe unique ID (uses object's memory address)
- display_name: The actual object name (can contain Japanese characters)
- description: Empty string
Uses caching to prevent encoding issues with Blender's EnumProperty system
"""
if context is None:
context = bpy.context
armatures = [(obj.name, obj.name, "") for obj in context.scene.objects if obj.type == 'ARMATURE']
# Create a cache key based on armature objects in scene
armature_objects = [obj for obj in context.scene.objects if obj.type == 'ARMATURE']
cache_key = tuple((obj.name, obj.as_pointer()) for obj in armature_objects)
# Check if we have a cached result
if hasattr(get_armature_list, '_cache_key') and get_armature_list._cache_key == cache_key:
if hasattr(get_armature_list, '_cached_items'):
return get_armature_list._cached_items
# Build the list
armatures = []
for obj in armature_objects:
# Create a safe ASCII identifier using the object pointer
safe_id = f"ARM_{obj.as_pointer()}"
# Use the name directly - Blender should handle Unicode in display names
display_name = obj.name
armatures.append((safe_id, display_name, ""))
if not armatures:
return [('NONE', t("Armature.validation.no_armature"), '')]
return armatures
result = [('NONE', t("Armature.validation.no_armature"), '')]
else:
result = armatures
# Cache the result
get_armature_list._cache_key = cache_key
get_armature_list._cached_items = result
return result
def auto_select_single_armature(context: Context) -> None:
"""Automatically select armature if only one exists in scene"""
@@ -140,6 +249,12 @@ def get_all_meshes(context: Context) -> List[Object]:
return [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == armature]
return []
def get_meshes_for_armature(armature: Object) -> List[Object]:
"""Get all mesh objects parented to a specific armature"""
if armature and armature.type == 'ARMATURE':
return [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == armature]
return []
def validate_mesh_for_pose(mesh_obj: Object) -> Tuple[bool, str]:
"""Validate mesh object for pose operations"""
if not mesh_obj.data:
@@ -650,7 +765,9 @@ class ArmatureData(Tuple[bool,bool]):
def store_breaking_settings_armature(armature: bpy.types.Object) -> ArmatureData:
armature_data: bpy.types.Armature = armature.data
return (armature_data.use_mirror_x, armature.pose.use_mirror_x)
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)
+125 -30
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
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,7 +452,7 @@ standard_bones = {
'hips': 'Hips',
'spine': 'Spine',
'chest': 'Chest',
'upper_chest': 'Chest.Up',
'upper_chest': 'UpperChest',
'neck': 'Neck',
'head': 'Head',
@@ -427,26 +512,34 @@ standard_bones = {
# Eyes
'left_eye': 'Eye_L',
'right_eye': 'Eye_R'
'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'),
# Left Arm Chain
('Chest.Up', 'Shoulder_L'),
('UpperChest', 'Shoulder_L'),
('Shoulder_L', 'UpperArm_L'),
('UpperArm_L', 'LowerArm_L'),
('LowerArm_L', 'Hand_L'),
# Right Arm Chain
('Chest.Up', 'Shoulder_R'),
('UpperChest', 'Shoulder_R'),
('Shoulder_R', 'UpperArm_R'),
('UpperArm_R', 'LowerArm_R'),
('LowerArm_R', 'Hand_R'),
@@ -523,10 +616,10 @@ acceptable_bone_hierarchy = [
('Head', 'RightEye'),
# Old standard bone hierarchy patterns
('Chest.Up', 'UpperArm.L'),
('UpperChest', 'UpperArm.L'),
('UpperArm.L', 'LowerArm.L'),
('LowerArm.L', 'Hand.L'),
('Chest.Up', 'UpperArm.R'),
('UpperChest', 'UpperArm.R'),
('UpperArm.R', 'LowerArm.R'),
('LowerArm.R', 'Hand.R'),
('Hips', 'UpperLeg.L'),
@@ -539,11 +632,11 @@ acceptable_bone_hierarchy = [
('Foot.R', 'Toes.R'),
# New standard bone hierarchy patterns (with shoulders)
('Chest.Up', 'Shoulder_L'),
('UpperChest', 'Shoulder_L'),
('Shoulder_L', 'UpperArm_L'),
('UpperArm_L', 'LowerArm_L'),
('LowerArm_L', 'Hand_L'),
('Chest.Up', 'Shoulder_R'),
('UpperChest', 'Shoulder_R'),
('Shoulder_R', 'UpperArm_R'),
('UpperArm_R', 'LowerArm_R'),
('LowerArm_R', 'Hand_R'),
@@ -788,7 +881,8 @@ non_standard_mappings = {
'left_leg': [
'mixamorig:LeftUpLeg', 'mixamorig_LeftUpLeg',
'ORG-thigh.L', 'thigh.L',
'lThighBend', 'lThigh', 'UpperLeg.L'
'lThighBend', 'lThigh', 'UpperLeg.L',
'LeftUpperLeg'
],
'left_knee': [
'mixamorig:LeftLeg', 'mixamorig_LeftLeg',
@@ -803,13 +897,14 @@ non_standard_mappings = {
'left_toe': [
'mixamorig:LeftToeBase', 'mixamorig_LeftToeBase',
'ORG-toe.L', 'toe.L',
'lToe', 'Toes.L'
'lToe', 'Toes.L', 'LeftToeBase'
],
'right_leg': [
'mixamorig:RightUpLeg', 'mixamorig_RightUpLeg',
'ORG-thigh.R', 'thigh.R',
'rThighBend', 'rThigh', 'UpperLeg.R'
'rThighBend', 'rThigh', 'UpperLeg.R',
'RightUpperLeg'
],
'right_knee': [
'mixamorig:RightLeg', 'mixamorig_RightLeg',
@@ -824,7 +919,7 @@ non_standard_mappings = {
'right_toe': [
'mixamorig:RightToeBase', 'mixamorig_RightToeBase',
'ORG-toe.R', 'toe.R',
'rToe', 'Toes.R'
'rToe', 'Toes.R', 'RightToeBase'
],
'thumb_1_l': [
+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
+20 -24
View File
@@ -8,7 +8,6 @@ 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
@@ -203,34 +202,31 @@ class AvatarToolKit_OT_Import(Operator, ImportHelper):
def import_pmx_file(filepath: str) -> None:
"""
Import a PMX file using the MMD Tools PMXImporter
Import a PMX file using the MMD Tools import operator
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()
# Use the MMD Tools operator to import PMX files (CATS-compatible)
# Must pass files + directory like CATS does, not just filepath
try:
importer.execute(**import_settings)
directory = os.path.dirname(filepath)
filename = os.path.basename(filepath)
bpy.ops.mmd_tools.import_model('EXEC_DEFAULT',
files=[{'name': filename}],
directory=directory,
scale=0.08,
types={'MESH', 'ARMATURE', 'MORPHS', 'DISPLAY'},
clean_model=False, # Disable cleaning to preserve morph indices
remove_doubles=False,
fix_ik_links=False,
ik_loop_factor=5,
apply_bone_fixed_axis=False,
rename_bones=False,
use_underscore=False)
logger.info(f"Successfully imported PMX file: {filepath}")
except Exception:
logger.error(f"Failed to import PMX file: {traceback.format_exc()}", exc_info=True)
except (AttributeError, TypeError, ValueError) as e:
logger.error(f"Failed to import PMX file: {e}", exc_info=True)
raise
+3 -2
View File
@@ -33,9 +33,10 @@ def configure_logging(enabled: bool = False, level: str = "WARNING") -> None:
logger.addHandler(handler)
def error_with_traceback(msg, *args, **kwargs):
if isinstance(kwargs.get('exception', None), Exception):
# If exc_info is True, include traceback in the message
if kwargs.get('exc_info', False):
full_msg = f"{msg}\n{traceback.format_exc()}"
_original_error(full_msg, *args, **{**kwargs, 'exc_info': False})
_original_error(full_msg, *args, **{k: v for k, v in kwargs.items() if k != 'exc_info'})
else:
_original_error(msg, *args, **kwargs)
+102 -106
View File
@@ -1,18 +1,13 @@
# -*- 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.
# Copyright 2013 MMD Tools authors
# This file is part of MMD Tools.
import contextlib
from typing import Generator, List, Optional, TypeVar, Any, Set, Tuple, Dict, Union
import math
from typing import Generator, List, Optional, TypeVar
import bmesh
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
from mathutils import Matrix
class Props: # For API changes of only name changed properties
@@ -24,7 +19,7 @@ class Props: # For API changes of only name changed properties
class __EditMode:
def __init__(self, obj: Object):
def __init__(self, obj):
if not isinstance(obj, bpy.types.Object):
raise ValueError
self.__prevMode = obj.mode
@@ -34,10 +29,10 @@ class __EditMode:
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
def __enter__(self) -> Any:
def __enter__(self):
return self.__obj.data
def __exit__(self, type: Any, value: Any, traceback: Any) -> None:
def __exit__(self, exc_type, exc_value, traceback):
if self.__prevMode == "EDIT":
bpy.ops.object.mode_set(mode="OBJECT") # update edited data
bpy.ops.object.mode_set(mode=self.__prevMode)
@@ -45,43 +40,46 @@ class __EditMode:
class __SelectObjects:
def __init__(self, active_object: Object, selected_objects: Optional[List[Object]] = None):
def __init__(self, active_object: bpy.types.Object, selected_objects: Optional[List[bpy.types.Object]] = None):
if not isinstance(active_object, bpy.types.Object):
raise ValueError
try:
bpy.ops.object.mode_set(mode="OBJECT")
except Exception:
logger.debug("Failed to set object mode")
pass
context = FnContext.ensure_context()
contenxt = FnContext.ensure_context()
for i in context.selected_objects:
for i in contenxt.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.__selected_objects = tuple(set(selected_objects) | {active_object}) if selected_objects else (active_object,)
self.__hides: List[bool] = []
for i in self.__selected_objects:
self.__hides.append(i.hide_get())
FnContext.select_object(context, i)
FnContext.set_active_object(context, active_object)
FnContext.select_object(contenxt, i)
FnContext.set_active_object(contenxt, active_object)
def __enter__(self) -> Object:
def __enter__(self) -> bpy.types.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 __exit__(self, exc_type, exc_value, traceback):
for i, j in zip(self.__selected_objects, self.__hides, strict=False):
try:
i.hide_set(j)
except ReferenceError:
# Object may no longer exist, so skip restoring hidden state.
pass
def setParent(obj: Object, parent: Object) -> None:
def setParent(obj, parent):
with select_object(parent, objects=[parent, obj]):
bpy.ops.object.parent_set(type="OBJECT", xmirror=False, keep_transform=False)
def setParentToBone(obj: Object, parent: Object, bone_name: str) -> None:
def setParentToBone(obj, parent, bone_name):
with select_object(parent, objects=[parent, obj]):
bpy.ops.object.mode_set(mode="POSE")
parent.data.bones.active = parent.data.bones[bone_name]
@@ -89,7 +87,7 @@ def setParentToBone(obj: Object, parent: Object, bone_name: str) -> None:
bpy.ops.object.mode_set(mode="OBJECT")
def edit_object(obj: Object) -> __EditMode:
def edit_object(obj):
"""Set the object interaction mode to 'EDIT'
It is recommended to use 'edit_object' with 'with' statement like the following code.
@@ -100,7 +98,7 @@ def edit_object(obj: Object) -> __EditMode:
return __EditMode(obj)
def select_object(obj: Object, objects: Optional[List[Object]] = None) -> __SelectObjects:
def select_object(obj: bpy.types.Object, objects: Optional[List[bpy.types.Object]] = None):
"""Select objects.
It is recommended to use 'select_object' with 'with' statement like the following code.
@@ -109,27 +107,26 @@ def select_object(obj: Object, objects: Optional[List[Object]] = None) -> __Sele
with select_object(obj):
some functions...
"""
# TODO: Reimplement with bpy.context.temp_override (If it ain't broke, don't fix it.)
# TODO: Consider reimplementing with bpy.context.temp_override,
# but note that Blender's new API has stability issues.
# temp_override is prone to crashes, making the current approach safer.
# If it ain't broke, don't fix it.
return __SelectObjects(obj, objects)
def duplicateObject(obj: Object, total_len: int) -> List[Object]:
def duplicateObject(obj, total_len):
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:
def createObject(name="Object", object_data=None, target_scene=None):
context = FnContext.ensure_context(target_scene)
return FnContext.set_active_object(context, FnContext.new_and_link_object(context, name, object_data))
def makeSphere(segment: int = 8, ring_count: int = 5, radius: float = 1.0, target_object: Optional[Object] = None) -> Object:
import bmesh
def makeSphere(segment=8, ring_count=5, radius=1.0, target_object=None):
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_data = bpy.data.meshes.new("Sphere")
target_object = createObject(name="Sphere", object_data=mesh_data)
mesh = target_object.data
bm = bmesh.new()
@@ -146,15 +143,10 @@ def makeSphere(segment: int = 8, ring_count: int = 5, radius: float = 1.0, targe
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
def makeBox(size=(1, 1, 1), target_object=None):
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_data = bpy.data.meshes.new("Box")
target_object = createObject(name="Box", object_data=mesh_data)
mesh = target_object.data
bm = bmesh.new()
@@ -170,16 +162,10 @@ def makeBox(size: Tuple[float, float, float] = (1, 1, 1), target_object: Optiona
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
def makeCapsule(segment=8, ring_count=2, radius=1.0, height=1.0, target_object=None):
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}")
mesh_data = bpy.data.meshes.new("Capsule")
target_object = createObject(name="Capsule", object_data=mesh_data)
height = max(height, 1e-3)
mesh = target_object.data
@@ -188,8 +174,11 @@ def makeCapsule(segment: int = 8, ring_count: int = 2, radius: float = 1.0, heig
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)
# def f(i):
# return radius * i / ring_count
def f(i):
return radius * math.sin(0.5 * math.pi * i / ring_count)
for i in range(ring_count, 0, -1):
z = f(i - 1)
t = math.sqrt(radius**2 - z**2)
@@ -238,10 +227,10 @@ def makeCapsule(segment: int = 8, ring_count: int = 2, radius: float = 1.0, heig
class TransformConstraintOp:
__MIN_MAX_MAP: Dict[Union[str, Tuple[str, str]], Union[str, Tuple[str, ...]]] = {"ROTATION": "_rot", "SCALE": "_scale"}
__MIN_MAX_MAP = {"ROTATION": "_rot", "SCALE": "_scale"}
@staticmethod
def create(constraints: bpy.types.ObjectConstraints, name: str, map_type: str) -> bpy.types.TransformConstraint:
def create(constraints, name, map_type):
c = constraints.get(name, None)
if c and c.type != "TRANSFORM":
constraints.remove(c)
@@ -259,7 +248,7 @@ class TransformConstraintOp:
return c
@classmethod
def min_max_attributes(cls, map_type: str, name_id: str = "") -> Tuple[str, ...]:
def min_max_attributes(cls, map_type, name_id=""):
key = (map_type, name_id)
ret = cls.__MIN_MAX_MAP.get(key, None)
if ret is None:
@@ -269,7 +258,7 @@ class TransformConstraintOp:
return ret
@classmethod
def update_min_max(cls, constraint: bpy.types.TransformConstraint, value: float, influence: Optional[float] = 1) -> None:
def update_min_max(cls, constraint, value, influence=1):
c = constraint
if not c or c.type != "TRANSFORM":
return
@@ -293,14 +282,14 @@ class FnObject:
raise NotImplementedError("This class is not expected to be instantiated.")
@staticmethod
def mesh_remove_shape_key(mesh_object: Object, shape_key: ShapeKey) -> None:
def mesh_remove_shape_key(mesh_object: bpy.types.Object, shape_key: bpy.types.ShapeKey):
assert isinstance(mesh_object.data, bpy.types.Mesh)
key: Key = shape_key.id_data
key: bpy.types.Key = shape_key.id_data
assert key == mesh_object.data.shape_keys
if mesh_object.animation_data is not None:
fc_curve: FCurve
fc_curve: bpy.types.FCurve
for fc_curve in mesh_object.animation_data.drivers:
if not fc_curve.data_path.startswith(shape_key.path_from_id()):
continue
@@ -324,35 +313,43 @@ class FnContext:
raise NotImplementedError("This class is not expected to be instantiated.")
@staticmethod
def ensure_context(context: Optional[Context] = None) -> Context:
def ensure_context(context: Optional[bpy.types.Context] = None) -> bpy.types.Context:
return context or bpy.context
@staticmethod
def get_active_object(context: Context) -> Optional[Object]:
def get_active_object(context: bpy.types.Context) -> Optional[bpy.types.Object]:
# Added defensive programming for get methods
# Related to: https://github.com/MMD-Blender/blender_mmd_tools_local/issues/176
if context is None or not hasattr(context, "active_object"):
return None
return context.active_object
@staticmethod
def set_active_object(context: Context, obj: Object) -> Object:
def set_active_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object:
context.view_layer.objects.active = obj
return obj
@staticmethod
def set_active_and_select_single_object(context: Context, obj: Object) -> Object:
def set_active_and_select_single_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object:
return FnContext.set_active_object(context, FnContext.select_single_object(context, obj))
@staticmethod
def get_scene_objects(context: Context) -> bpy.types.SceneObjects:
def get_scene_objects(context: bpy.types.Context) -> bpy.types.SceneObjects:
# Added defensive programming for get methods
# Added for consistency with get_active_object
if context is None or not hasattr(context, "scene") or not hasattr(context.scene, "objects"):
return []
return context.scene.objects
@staticmethod
def ensure_selectable(context: Context, obj: Object) -> Object:
def ensure_selectable(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object:
obj.hide_viewport = False
obj.hide_select = False
obj.hide_set(False)
if obj not in context.selectable_objects:
def __layer_check(layer_collection: LayerCollection) -> bool:
def __layer_check(layer_collection: bpy.types.LayerCollection) -> bool:
for lc in layer_collection.children:
if __layer_check(lc):
lc.hide_viewport = False
@@ -374,44 +371,44 @@ class FnContext:
return obj
@staticmethod
def select_object(context: Context, obj: Object) -> Object:
def select_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object:
FnContext.ensure_selectable(context, obj).select_set(True)
return obj
@staticmethod
def select_objects(context: Context, *objects: Object) -> List[Object]:
def select_objects(context: bpy.types.Context, *objects: bpy.types.Object) -> List[bpy.types.Object]:
return [FnContext.select_object(context, obj) for obj in objects]
@staticmethod
def select_single_object(context: Context, obj: Object) -> Object:
def select_single_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object:
for i in context.selected_objects:
if i != obj:
i.select_set(False)
return FnContext.select_object(context, obj)
@staticmethod
def link_object(context: Context, obj: Object) -> Object:
def link_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object:
context.collection.objects.link(obj)
return obj
@staticmethod
def new_and_link_object(context: Context, name: str, object_data: Optional[ID]) -> Object:
def new_and_link_object(context: bpy.types.Context, name: str, object_data: Optional[bpy.types.ID]) -> bpy.types.Object:
return FnContext.link_object(context, bpy.data.objects.new(name=name, object_data=object_data))
@staticmethod
def duplicate_object(context: Context, object_to_duplicate: Object, target_count: int) -> List[Object]:
def duplicate_object(context: bpy.types.Context, object_to_duplicate: bpy.types.Object, target_count: int) -> List[bpy.types.Object]:
"""
Duplicate object.
This function duplicates the given object and returns a list of duplicated objects.
Args:
context (Context): The context in which the duplication is performed.
object_to_duplicate (Object): The object to be duplicated.
context (bpy.types.Context): The context in which the duplication is performed.
object_to_duplicate (bpy.types.Object): The object to be duplicated.
target_count (int): The desired count of duplicated objects.
Returns:
List[Object]: A list of duplicated objects.
List[bpy.types.Object]: A list of duplicated objects.
Raises:
AssertionError: If the number of selected objects in the context is not equal to 1 or if the selected object is not the same as the object to be duplicated.
@@ -435,28 +432,27 @@ class FnContext:
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]:
def find_user_layer_collection_by_object(context: bpy.types.Context, target_object: bpy.types.Object) -> Optional[bpy.types.LayerCollection]:
"""
Finds the layer collection that contains the given target_object in the user's collections.
Find 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.
context (bpy.types.Context): The Blender context.
target_object (bpy.types.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.
Optional[bpy.types.LayerCollection]: The layer collection that contains the target_object, or None if not found.
"""
scene_layer_collection: LayerCollection = context.view_layer.layer_collection
scene_layer_collection: bpy.types.LayerCollection = context.view_layer.layer_collection
def find_layer_collection_by_name(layer_collection: LayerCollection, name: str) -> Optional[LayerCollection]:
def find_layer_collection_by_name(layer_collection: bpy.types.LayerCollection, name: str) -> Optional[bpy.types.LayerCollection]:
if layer_collection.name == name:
return layer_collection
child_layer_collection: LayerCollection
child_layer_collection: bpy.types.LayerCollection
for child_layer_collection in layer_collection.children:
found = find_layer_collection_by_name(child_layer_collection, name)
if found is not None:
@@ -464,7 +460,7 @@ class FnContext:
return None
user_collection: Collection
user_collection: bpy.types.Collection
for user_collection in target_object.users_collection:
found = find_layer_collection_by_name(scene_layer_collection, user_collection.name)
if found is not None:
@@ -474,7 +470,7 @@ class FnContext:
@staticmethod
@contextlib.contextmanager
def temp_override_active_layer_collection(context: Context, target_object: Object) -> Generator[Context, None, None]:
def temp_override_active_layer_collection(context: bpy.types.Context, target_object: bpy.types.Object) -> Generator[bpy.types.Context, None, None]:
"""
Context manager to temporarily override the active_layer_collection that contains the target object.
@@ -482,11 +478,11 @@ class FnContext:
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.
context (bpy.types.Context): The context in which the active_layer_collection will be overridden.
target_object (bpy.types.Object): The target object whose layer collection will be set as the active_layer_collection.
Yields:
Context: The modified context with the active_layer_collection overridden.
bpy.types.Context: The modified context with the active_layer_collection overridden.
Example:
with FnContext.temp_override_active_layer_collection(context, target_object):
@@ -507,24 +503,24 @@ class FnContext:
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)
def __get_addon_preferences(context: bpy.types.Context) -> Optional[bpy.types.AddonPreferences]:
addon: bpy.types.Addon = context.preferences.addons.get(__package__, None)
return addon.preferences if addon else None
@staticmethod
def get_addon_preferences_attribute(context: Context, attribute_name: str, default_value: ADDON_PREFERENCE_ATTRIBUTE_VALUE_TYPE = None) -> ADDON_PREFERENCE_ATTRIBUTE_VALUE_TYPE:
def get_addon_preferences_attribute(context: bpy.types.Context, attribute_name: str, default_value: ADDON_PREFERENCE_ATTRIBUTE_VALUE_TYPE = None) -> ADDON_PREFERENCE_ATTRIBUTE_VALUE_TYPE:
return getattr(FnContext.__get_addon_preferences(context), attribute_name, default_value)
@staticmethod
def temp_override_objects(
context: 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]:
context: bpy.types.Context,
window: Optional[bpy.types.Window] = None,
area: Optional[bpy.types.Area] = None,
region: Optional[bpy.types.Region] = None,
active_object: Optional[bpy.types.Object] = None,
selected_objects: Optional[List[bpy.types.Object]] = None,
**keywords,
) -> Generator[bpy.types.Context, None, None]:
if active_object is not None:
keywords["active_object"] = active_object
keywords["object"] = active_object
+96 -186
View File
@@ -1,44 +1,37 @@
# -*- 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.
# Copyright 2015 MMD Tools authors
# This file is part of MMD Tools.
import math
from typing import TYPE_CHECKING, Iterable, Optional, Set, List, Dict, Tuple, Any, Union, cast
from typing import TYPE_CHECKING, Iterable, Optional, Set
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
from ..properties.root import MMDDisplayItemFrame, MMDRoot
def remove_constraint(constraints: Any, name: str) -> bool:
"""Remove a constraint by name if it exists"""
def remove_constraint(constraints, name):
c = constraints.get(name, None)
if c:
constraints.remove(c)
return True
return False
def remove_edit_bones(edit_bones: bpy.types.ArmatureEditBones, bone_names: List[str]) -> None:
"""Remove edit bones by name"""
def remove_edit_bones(edit_bones, bone_names):
for name in bone_names:
b = edit_bones.get(name, None)
if b:
edit_bones.remove(b)
BONE_COLLECTION_CUSTOM_PROPERTY_NAME = "mmd_tools"
BONE_COLLECTION_CUSTOM_PROPERTY_NAME = "mmd_tools_local"
BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_SPECIAL = "special collection"
BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_NORMAL = "normal collection"
BONE_COLLECTION_NAME_SHADOW = "mmd_shadow"
@@ -48,52 +41,44 @@ SPECIAL_BONE_COLLECTION_NAMES = [BONE_COLLECTION_NAME_SHADOW, BONE_COLLECTION_NA
class FnBone:
AUTO_LOCAL_AXIS_ARMS: Tuple[str, ...] = ("左肩", "左腕", "左ひじ", "左手首", "右腕", "右肩", "右ひじ", "右手首")
AUTO_LOCAL_AXIS_FINGERS: Tuple[str, ...] = ("親指", "人指", "中指", "薬指", "小指")
AUTO_LOCAL_AXIS_SEMI_STANDARD_ARMS: Tuple[str, ...] = ("左腕捩", "左手捩", "左肩P", "左ダミー", "右腕捩", "右手捩", "右肩P", "右ダミー")
AUTO_LOCAL_AXIS_ARMS = ("左肩", "左腕", "左ひじ", "左手首", "右腕", "右肩", "右ひじ", "右手首")
AUTO_LOCAL_AXIS_FINGERS = ("親指", "人指", "中指", "薬指", "小指")
AUTO_LOCAL_AXIS_SEMI_STANDARD_ARMS = ("左腕捩", "左手捩", "左肩P", "左ダミー", "右腕捩", "右手捩", "右肩P", "右ダミー")
def __init__(self) -> None:
def __init__(self):
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"""
def find_pose_bone_by_bone_id(armature_object: bpy.types.Object, bone_id: int) -> Optional[bpy.types.PoseBone]:
for bone in armature_object.pose.bones:
if bone.mmd_bone.bone_id != bone_id:
continue
return bone
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"""
def __new_bone_id(armature_object: bpy.types.Object) -> int:
return max(b.mmd_bone.bone_id for b in armature_object.pose.bones) + 1
@staticmethod
def get_or_assign_bone_id(pose_bone: PoseBone) -> int:
"""Get the bone ID or assign a new one if not set"""
def get_or_assign_bone_id(pose_bone: bpy.types.PoseBone) -> int:
if pose_bone.mmd_bone.bone_id < 0:
pose_bone.mmd_bone.bone_id = FnBone.__new_bone_id(pose_bone.id_data)
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"""
def __get_selected_pose_bones(armature_object: bpy.types.Object) -> Iterable[bpy.types.PoseBone]:
if armature_object.mode == "EDIT":
bpy.ops.object.mode_set(mode="OBJECT") # update selected bones
bpy.ops.object.mode_set(mode="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}")
def load_bone_fixed_axis(armature_object: bpy.types.Object, enable=True):
for b in FnBone.__get_selected_pose_bones(armature_object):
mmd_bone = b.mmd_bone
mmd_bone: MMDBone = b.mmd_bone
mmd_bone.enabled_fixed_axis = enable
lock_rotation = b.lock_rotation[:]
if enable:
@@ -108,91 +93,72 @@ class FnBone:
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)
def setup_special_bone_collections(armature_object: bpy.types.Object) -> bpy.types.Object:
armature: bpy.types.Armature = armature_object.data
bone_collections = armature.collections
for bone_collection_name in SPECIAL_BONE_COLLECTION_NAMES:
if bone_collection_name in bone_collections:
continue
bone_collection = bone_collections.new(bone_collection_name)
FnBone.__set_bone_collection_to_special(bone_collection, is_visible=False)
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"""
def __is_mmd_tools_local_bone_collection(bone_collection: bpy.types.BoneCollection) -> bool:
return BONE_COLLECTION_CUSTOM_PROPERTY_NAME in bone_collection
@staticmethod
def __is_special_bone_collection(bone_collection: 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)
def __is_special_bone_collection(bone_collection: bpy.types.BoneCollection) -> bool:
return bone_collection.get(BONE_COLLECTION_CUSTOM_PROPERTY_NAME) == BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_SPECIAL
@staticmethod
def __set_bone_collection_to_special(bone_collection: BoneCollection, is_visible: bool) -> None:
"""Mark a bone collection as special"""
def __set_bone_collection_to_special(bone_collection: bpy.types.BoneCollection, is_visible: bool):
bone_collection[BONE_COLLECTION_CUSTOM_PROPERTY_NAME] = BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_SPECIAL
bone_collection.is_visible = is_visible
@staticmethod
def __is_normal_bone_collection(bone_collection: 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)
def __is_normal_bone_collection(bone_collection: bpy.types.BoneCollection) -> bool:
return bone_collection.get(BONE_COLLECTION_CUSTOM_PROPERTY_NAME) == BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_NORMAL
@staticmethod
def __set_bone_collection_to_normal(bone_collection: BoneCollection) -> None:
"""Mark a bone collection as normal"""
def __set_bone_collection_to_normal(bone_collection: bpy.types.BoneCollection):
bone_collection[BONE_COLLECTION_CUSTOM_PROPERTY_NAME] = BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_NORMAL
@staticmethod
def __set_edit_bone_to_special(edit_bone: EditBone, bone_collection_name: str) -> EditBone:
"""Set an edit bone to a special collection"""
def __set_edit_bone_to_special(edit_bone: bpy.types.EditBone, bone_collection_name: str) -> bpy.types.EditBone:
edit_bone.id_data.collections[bone_collection_name].assign(edit_bone)
edit_bone.use_deform = False
return edit_bone
@staticmethod
def set_edit_bone_to_dummy(edit_bone: EditBone) -> EditBone:
"""Set an edit bone as a dummy bone"""
logger.debug(f"Setting bone {edit_bone.name} as dummy bone")
def set_edit_bone_to_dummy(edit_bone: bpy.types.EditBone) -> bpy.types.EditBone:
return FnBone.__set_edit_bone_to_special(edit_bone, BONE_COLLECTION_NAME_DUMMY)
@staticmethod
def set_edit_bone_to_shadow(edit_bone: EditBone) -> EditBone:
"""Set an edit bone as a shadow bone"""
logger.debug(f"Setting bone {edit_bone.name} as shadow bone")
def set_edit_bone_to_shadow(edit_bone: bpy.types.EditBone) -> bpy.types.EditBone:
return FnBone.__set_edit_bone_to_special(edit_bone, BONE_COLLECTION_NAME_SHADOW)
@staticmethod
def __unassign_mmd_tools_bone_collections(edit_bone: EditBone) -> EditBone:
"""Unassign an edit bone from all MMD Tools collections"""
def __unassign_mmd_tools_local_bone_collections(edit_bone: bpy.types.EditBone) -> bpy.types.EditBone:
for bone_collection in edit_bone.collections:
if not FnBone.__is_mmd_tools_bone_collection(bone_collection):
if not FnBone.__is_mmd_tools_local_bone_collection(bone_collection):
continue
bone_collection.unassign(edit_bone)
return edit_bone
@staticmethod
def sync_bone_collections_from_display_item_frames(armature_object: 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)
def sync_bone_collections_from_display_item_frames(armature_object: bpy.types.Object):
armature: bpy.types.Armature = armature_object.data
bone_collections = armature.collections
from .model import FnModel
root_object = 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
root_object: bpy.types.Object = FnModel.find_root_object(armature_object)
mmd_root: MMDRoot = root_object.mmd_root
bones = armature.bones
used_groups: Set[str] = set()
unassigned_bone_names: Set[str] = {b.name for b in bones}
used_groups = set()
unassigned_bone_names = {b.name for b in bones}
for frame in mmd_root.display_item_frames:
for item in frame.data:
@@ -204,12 +170,11 @@ class FnBone:
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):
if not FnBone.__is_mmd_tools_local_bone_collection(bc):
continue
if not FnBone.__is_normal_bone_collection(bc):
continue
@@ -219,48 +184,40 @@ class FnBone:
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):
if not FnBone.__is_mmd_tools_local_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
def sync_display_item_frames_from_bone_collections(armature_object: bpy.types.Object):
armature: bpy.types.Armature = armature_object.data
bone_collections: bpy.types.BoneCollections = armature.collections
from .model import FnModel
root_object = 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
root_object: bpy.types.Object = FnModel.find_root_object(armature_object)
mmd_root: MMDRoot = root_object.mmd_root
display_item_frames = mmd_root.display_item_frames
used_frame_index: Set[int] = set()
bone_collection: BoneCollection
bone_collection: bpy.types.BoneCollection
for bone_collection in bone_collections:
if len(bone_collection.bones) == 0 or FnBone.__is_special_bone_collection(bone_collection):
continue
bone_collection_name = bone_collection.name
display_item_frame = display_item_frames.get(bone_collection_name)
display_item_frame: Optional[MMDDisplayItemFrame] = display_item_frames.get(bone_collection_name)
if display_item_frame is None:
display_item_frame = display_item_frames.add()
display_item_frame.name = bone_collection_name
display_item_frame.name_e = bone_collection_name
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):
for display_item, bone in zip(display_item_frame.data, bone_collection.bones, strict=False):
display_item.type = "BONE"
display_item.name = bone.name
@@ -271,27 +228,23 @@ class FnBone:
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]] = {}
def apply_bone_fixed_axis(armature_object: bpy.types.Object):
bone_map = {}
for b in armature_object.pose.bones:
if b.is_mmd_shadow_bone or not b.mmd_bone.enabled_fixed_axis:
continue
mmd_bone = b.mmd_bone
mmd_bone: MMDBone = b.mmd_bone
parent_tip = b.parent and not b.parent.is_mmd_shadow_bone and b.parent.mmd_bone.is_tip
bone_map[b.name] = (mmd_bone.fixed_axis.normalized(), mmd_bone.is_tip, parent_tip)
force_align = True
with bpyutils.edit_object(armature_object) as data:
bone: EditBone
bone: bpy.types.EditBone
for bone in data.edit_bones:
if bone.name not in bone_map:
bone.select = False
@@ -322,7 +275,6 @@ class FnBone:
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]
@@ -330,11 +282,9 @@ class FnBone:
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}")
def load_bone_local_axes(armature_object: bpy.types.Object, enable=True):
for b in FnBone.__get_selected_pose_bones(armature_object):
mmd_bone = b.mmd_bone
mmd_bone: MMDBone = b.mmd_bone
mmd_bone.enabled_local_axes = enable
if enable:
axes = b.bone.matrix_local.to_3x3().transposed()
@@ -342,18 +292,16 @@ class FnBone:
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]] = {}
def apply_bone_local_axes(armature_object: bpy.types.Object):
bone_map = {}
for b in armature_object.pose.bones:
if b.is_mmd_shadow_bone or not b.mmd_bone.enabled_local_axes:
continue
mmd_bone = b.mmd_bone
mmd_bone: MMDBone = b.mmd_bone
bone_map[b.name] = (mmd_bone.local_axis_x, mmd_bone.local_axis_z)
with bpyutils.edit_object(armature_object) as data:
bone: EditBone
bone: bpy.types.EditBone
for bone in data.edit_bones:
if bone.name not in bone_map:
bone.select = False
@@ -361,18 +309,15 @@ class FnBone:
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"""
def update_bone_roll(edit_bone: bpy.types.EditBone, mmd_local_axis_x, mmd_local_axis_z):
axes = FnBone.get_axes(mmd_local_axis_x, mmd_local_axis_z)
idx, val = max([(i, edit_bone.vector.dot(v)) for i, v in enumerate(axes)], key=lambda x: abs(x[1]))
edit_bone.align_roll(axes[(idx - 1) % 3 if val < 0 else (idx + 1) % 3])
@staticmethod
def get_axes(mmd_local_axis_x: Vector, mmd_local_axis_z: Vector) -> Tuple[Vector, Vector, Vector]:
"""Get axes from local axis vectors"""
def get_axes(mmd_local_axis_x, mmd_local_axis_z):
x_axis = Vector(mmd_local_axis_x).normalized().xzy
z_axis = Vector(mmd_local_axis_z).normalized().xzy
y_axis = z_axis.cross(x_axis).normalized()
@@ -380,25 +325,18 @@ class FnBone:
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)
def apply_auto_bone_roll(armature):
bone_names = [b.name for b in armature.pose.bones if not b.is_mmd_shadow_bone and not b.mmd_bone.enabled_local_axes and FnBone.has_auto_local_axis(b.mmd_bone.name_j)]
with bpyutils.edit_object(armature) as data:
bone: EditBone
bone: bpy.types.EditBone
for bone in data.edit_bones:
if bone.name not in bone_names:
continue
FnBone.update_auto_bone_roll(bone)
bone.select = True
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"""
def update_auto_bone_roll(edit_bone):
# make a triangle face (p1,p2,p3)
p1 = edit_bone.head.copy()
p2 = edit_bone.tail.copy()
@@ -419,8 +357,7 @@ class FnBone:
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"""
def has_auto_local_axis(name_j):
if name_j:
if name_j in FnBone.AUTO_LOCAL_AXIS_ARMS or name_j in FnBone.AUTO_LOCAL_AXIS_SEMI_STANDARD_ARMS:
return True
@@ -430,11 +367,12 @@ class FnBone:
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}")
def clean_additional_transformation(armature_object: bpy.types.Object):
if armature_object.type != "ARMATURE" or armature_object.pose is None:
return
# clean constraints
p_bone: PoseBone
p_bone: bpy.types.PoseBone
for p_bone in armature_object.pose.bones:
p_bone.mmd_bone.is_additional_transform_dirty = True
constraints = p_bone.constraints
@@ -450,21 +388,17 @@ class FnBone:
"ADDITIONAL_TRANSFORM_INVERT",
}
def __is_at_shadow_bone(b: PoseBone) -> bool:
def __is_at_shadow_bone(b):
return b.is_mmd_shadow_bone and b.mmd_shadow_bone_type in shadow_bone_types
shadow_bone_names = [b.name for b in armature_object.pose.bones if __is_at_shadow_bone(b)]
if len(shadow_bone_names) > 0:
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:
def apply_additional_transformation(armature_object: bpy.types.Object):
def __is_dirty_bone(b):
if b.is_mmd_shadow_bone:
return False
mmd_bone = b.mmd_bone
@@ -473,10 +407,9 @@ class FnBone:
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]] = []
shadow_bone_pool = []
for p_bone in dirty_bones:
sb = FnBone.__setup_constraints(p_bone)
if sb:
@@ -497,8 +430,7 @@ class FnBone:
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"""
def __setup_constraints(p_bone):
bone_name = p_bone.name
mmd_bone = p_bone.mmd_bone
influence = mmd_bone.additional_transform_influence
@@ -511,18 +443,21 @@ class FnBone:
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:
def __config(name, mute, map_type, value):
if mute:
remove_constraint(constraints, name)
return
c = TransformConstraintOp.create(constraints, name, map_type)
# FIXME: Some bones require specific rotation modes to match MMD behavior.
# Currently using hardcoded bone names as a temporary solution.
# See https://github.com/MMD-Blender/blender_mmd_tools_local/issues/242
if bone_name in {"左肩C", "右肩C", "肩C.L", "肩C.R", "肩C_L", "肩C_R"}:
c.from_rotation_mode = "ZYX" # Best matches MMD behavior for shoulder bones
c.target = p_bone.id_data
shadow_bone.add_constraint(c)
TransformConstraintOp.update_min_max(c, value, influence)
@@ -533,81 +468,62 @@ class FnBone:
return shadow_bone
@staticmethod
def update_additional_transform_influence(pose_bone: PoseBone) -> None:
"""Update the influence of additional transform constraints"""
def update_additional_transform_influence(pose_bone: bpy.types.PoseBone):
influence = pose_bone.mmd_bone.additional_transform_influence
constraints = pose_bone.constraints
c = constraints.get("mmd_additional_rotation", None)
TransformConstraintOp.update_min_max(c, math.pi, influence)
c = constraints.get("mmd_additional_location", None)
TransformConstraintOp.update_min_max(c, 100, influence)
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
def fix_mmd_ik_limit_override(armature_object: bpy.types.Object):
pose_bone: bpy.types.PoseBone
for pose_bone in armature_object.pose.bones:
constraint: Constraint
constraint: bpy.types.Constraint
for constraint in pose_bone.constraints:
if constraint.type == "LIMIT_ROTATION" and "mmd_ik_limit_override" in constraint.name:
constraint.owner_space = "LOCAL"
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"""
def __init__(self, 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"""
def update_edit_bones(self, edit_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)"""
def update_pose_bones(self, pose_bones):
pass
class _AT_ShadowBoneCreate:
"""Handler for creating shadow bones"""
def __init__(self, bone_name: str, target_bone_name: str) -> None:
"""Initialize with bone names"""
def __init__(self, bone_name, target_bone_name):
self.__dummy_bone_name = "_dummy_" + bone_name
self.__shadow_bone_name = "_shadow_" + bone_name
self.__bone_name = bone_name
self.__target_bone_name = target_bone_name
self.__constraint_pool: List[Constraint] = []
self.__constraint_pool = []
def __is_well_aligned(self, bone0: EditBone, bone1: EditBone) -> bool:
"""Check if two bones are well aligned"""
def __is_well_aligned(self, bone0, bone1):
return bone0.x_axis.dot(bone1.x_axis) > 0.99 and bone0.y_axis.dot(bone1.y_axis) > 0.99
def __update_constraints(self, use_shadow: bool = True) -> None:
"""Update constraints to use shadow or target bone"""
def __update_constraints(self, use_shadow=True):
subtarget = self.__shadow_bone_name if use_shadow else self.__target_bone_name
for c in self.__constraint_pool:
c.subtarget = subtarget
def add_constraint(self, constraint: Constraint) -> None:
"""Add a constraint to the pool"""
def add_constraint(self, constraint):
self.__constraint_pool.append(constraint)
def update_edit_bones(self, edit_bones: bpy.types.ArmatureEditBones) -> None:
"""Update edit bones by creating shadow bones"""
def update_edit_bones(self, edit_bones):
bone = edit_bones[self.__bone_name]
target_bone = edit_bones[self.__target_bone_name]
if bone != target_bone and self.__is_well_aligned(bone, target_bone):
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
@@ -617,7 +533,6 @@ class _AT_ShadowBoneCreate:
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))
@@ -625,12 +540,9 @@ class _AT_ShadowBoneCreate:
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"""
def update_pose_bones(self, pose_bones):
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
@@ -649,7 +561,5 @@ class _AT_ShadowBoneCreate:
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}")
+134 -209
View File
@@ -1,25 +1,18 @@
# -*- 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.
# This file is part of MMD Tools.
import math
from typing import Optional, List, Tuple, Callable, Any, Union
from typing import Optional
import bpy
from bpy.types import Object, ID, Camera, Context
from mathutils import Vector, Matrix, Euler
import traceback
from mathutils import Matrix, Vector
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."""
def find_root(obj: bpy.types.Object) -> Optional[bpy.types.Object]:
if obj is None:
return None
if FnCamera.is_mmd_camera_root(obj):
@@ -29,22 +22,16 @@ class FnCamera:
return None
@staticmethod
def is_mmd_camera(obj: Object) -> bool:
"""Check if an object is an MMD camera."""
def is_mmd_camera(obj: bpy.types.Object) -> bool:
return obj.type == "CAMERA" and FnCamera.find_root(obj.parent) is not None
@staticmethod
def is_mmd_camera_root(obj: Object) -> bool:
"""Check if an object is an MMD camera root."""
def is_mmd_camera_root(obj: bpy.types.Object) -> bool:
return obj.type == "EMPTY" and obj.mmd_type == "CAMERA"
@staticmethod
def add_drivers(camera_object: 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."""
def add_drivers(camera_object: bpy.types.Object):
def __add_driver(id_data: bpy.types.ID, data_path: str, expression: str, index: int = -1):
d = id_data.driver_add(data_path, index).driver
d.type = "SCRIPTED"
if "$empty_distance" in expression:
@@ -72,46 +59,31 @@ class FnCamera:
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)
# Use fixed sensor_height instead of dynamic reference.
# When controlled by MMD angle, sensor_height shouldn't change.
# This avoids unnecessary dependency cycles.
# Reference: https://github.com/MMD-Blender/blender_mmd_tools_local/issues/227
current_sensor_height = camera_object.data.sensor_height
expression = expression.replace("$sensor_height", str(current_sensor_height))
d.expression = expression
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()}")
__add_driver(camera_object.data, "ortho_scale", "25*abs($empty_distance)/45")
__add_driver(camera_object, "rotation_euler", "pi if $is_perspective == False and $empty_distance > 1e-5 else 0", index=1)
__add_driver(camera_object.data, "type", "not $is_perspective")
__add_driver(camera_object.data, "lens", "$sensor_height/tan($angle/2)/2")
@staticmethod
def remove_drivers(camera_object: 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()}")
def remove_drivers(camera_object: bpy.types.Object):
camera_object.data.driver_remove("ortho_scale")
camera_object.driver_remove("rotation_euler")
camera_object.data.driver_remove("type")
camera_object.data.driver_remove("lens")
class MigrationFnCamera:
@staticmethod
def update_mmd_camera() -> None:
"""Update all MMD cameras in the scene."""
logger.info("Updating all MMD cameras in the scene")
updated_count = 0
def update_mmd_camera():
for camera_object in bpy.data.objects:
if camera_object.type != "CAMERA":
continue
@@ -121,203 +93,156 @@ class MigrationFnCamera:
# 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")
FnCamera.remove_drivers(camera_object)
FnCamera.add_drivers(camera_object)
class MMDCamera:
def __init__(self, obj: Object):
"""Initialize an MMD camera."""
def __init__(self, obj):
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")
raise ValueError(f"{str(obj)} is not MMDCamera")
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."""
def isMMDCamera(obj: bpy.types.Object) -> bool:
return FnCamera.find_root(obj) is not None
@staticmethod
def addDrivers(cameraObj: Object) -> None:
"""Add drivers to the camera object."""
def addDrivers(cameraObj: bpy.types.Object):
FnCamera.add_drivers(cameraObj)
@staticmethod
def removeDrivers(cameraObj: Object) -> None:
"""Remove drivers from the camera object. """
def removeDrivers(cameraObj: bpy.types.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}")
def convertToMMDCamera(cameraObj: bpy.types.Object, scale=1.0):
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)
empty = bpy.data.objects.new(name="MMD_Camera", object_data=None)
FnContext.link_object(FnContext.ensure_context(), empty)
cameraObj.parent = empty
cameraObj.data.sensor_fit = "VERTICAL"
cameraObj.data.lens_unit = "MILLIMETERS" # MILLIMETERS, FOV
cameraObj.data.ortho_scale = 25 * scale
cameraObj.data.clip_end = 500 * scale
setattr(cameraObj.data, Props.display_size, 5 * scale)
cameraObj.location = (0, -45 * scale, 0)
cameraObj.rotation_mode = "XYZ"
cameraObj.rotation_euler = (math.radians(90), 0, 0)
cameraObj.lock_location = (True, False, True)
cameraObj.lock_rotation = (True, True, True)
cameraObj.lock_scale = (True, True, True)
cameraObj.data.dof.focus_object = empty
FnCamera.add_drivers(cameraObj)
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
empty.location = (0, 0, 10 * scale)
empty.rotation_mode = "YXZ"
setattr(empty, Props.empty_display_size, 5 * scale)
empty.lock_scale = (True, True, True)
empty.mmd_type = "CAMERA"
empty.mmd_camera.angle = math.radians(30)
empty.mmd_camera.persp = True
return MMDCamera(empty)
@staticmethod
def newMMDCameraAnimation(
cameraObj: 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}")
def newMMDCameraAnimation(cameraObj, cameraTarget=None, scale=1.0, min_distance=0.1):
scene = bpy.context.scene
mmd_cam = bpy.data.objects.new(name="Camera", object_data=bpy.data.cameras.new("Camera"))
FnContext.link_object(FnContext.ensure_context(), mmd_cam)
MMDCamera.convertToMMDCamera(mmd_cam, scale=scale)
mmd_cam_root = mmd_cam.parent
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 = None
if cameraObj is None:
if scene.camera is None:
scene.camera = mmd_cam
return MMDCamera(mmd_cam_root)
def _camera_override_func():
return scene.camera
_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 = None
if cameraTarget is None:
def _target_override_func(camObj):
return camObj.data.dof.focus_object or camObj
_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)
action_name = mmd_cam_root.name
parent_action = bpy.data.actions.new(name=action_name)
distance_action = bpy.data.actions.new(name=action_name + "_dis")
FnCamera.remove_drivers(mmd_cam)
render = scene.render
factor = (render.resolution_y * render.pixel_aspect_y) / (render.resolution_x * render.pixel_aspect_x)
matrix_rotation = Matrix(([1, 0, 0, 0], [0, 0, 1, 0], [0, -1, 0, 0], [0, 0, 0, 1]))
neg_z_vector = Vector((0, 0, -1))
frame_start, frame_end, frame_current = scene.frame_start, scene.frame_end + 1, scene.frame_current
frame_count = frame_end - frame_start
frames = range(frame_start, frame_end)
from math import atan
from mathutils import Matrix, Vector
fcurves = [parent_action.fcurves.new(data_path="location", index=i) for i in range(3)] # x, y, z
fcurves.extend(parent_action.fcurves.new(data_path="rotation_euler", index=i) for i in range(3)) # rx, ry, rz
fcurves.append(parent_action.fcurves.new(data_path="mmd_camera.angle")) # fov
fcurves.append(parent_action.fcurves.new(data_path="mmd_camera.is_perspective")) # persp
fcurves.append(distance_action.fcurves.new(data_path="location", index=1)) # dis
for c in fcurves:
c.keyframe_points.add(frame_count)
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
for f, x, y, z, rx, ry, rz, fov, persp, dis in zip(frames, *(c.keyframe_points for c in fcurves), strict=False):
scene.frame_set(f)
if _camera_override_func:
cameraObj = _camera_override_func()
if _target_override_func:
cameraTarget = _target_override_func(cameraObj)
cam_matrix_world = cameraObj.matrix_world
cam_target_loc = cameraTarget.matrix_world.translation
cam_rotation = (cam_matrix_world @ matrix_rotation).to_euler(mmd_cam_root.rotation_mode)
cam_vec = cam_matrix_world.to_3x3() @ neg_z_vector
if cameraObj.data.type == "ORTHO":
cam_dis = -(9 / 5) * cameraObj.data.ortho_scale
if cameraObj.data.sensor_fit != "VERTICAL":
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)
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
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"
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)
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)
x.co, y.co, z.co = ((f, i) for i in cam_target_loc)
rx.co, ry.co, rz.co = ((f, i) for i in cam_rotation)
dis.co = (f, cam_dis)
fov.co = (f, 2 * math.atan(tan_val))
persp.co = (f, cameraObj.data.type != "ORTHO")
persp.interpolation = "CONSTANT"
for kp in (x, y, z, rx, ry, rz, fov, dis):
kp.interpolation = "LINEAR"
logger.info(f"Successfully created MMD camera animation with {frame_count} frames")
return MMDCamera(mmd_cam_root)
FnCamera.add_drivers(mmd_cam)
mmd_cam_root.animation_data_create().action = parent_action
mmd_cam.animation_data_create().action = distance_action
scene.frame_set(frame_current)
return MMDCamera(mmd_cam_root)
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."""
def object(self):
return self.__emptyObj
def camera(self) -> Object:
"""Get the camera object of the MMD camera."""
def camera(self):
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}")
raise KeyError
+5 -7
View File
@@ -1,14 +1,12 @@
# -*- 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.
# Copyright 2016 MMD Tools authors
# This file is part of MMD Tools.
# Module for custom exceptions
class MaterialNotFoundError(KeyError):
"""Exception raised when a material is not found in the scene"""
def __init__(self, *args: object) -> None:
"""Constructor for MaterialNotFoundError"""
"""Initialize MaterialNotFoundError"""
super().__init__(*args)
+14 -35
View File
@@ -1,53 +1,37 @@
# -*- 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.
# This file is part of MMD Tools.
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:
def __init__(self, obj):
if MMDLamp.isLamp(obj):
obj = obj.parent
if obj and obj.type == "EMPTY" and obj.mmd_type == "LIGHT":
self.__emptyObj: Object = obj
self.__emptyObj = obj
else:
error_msg = f"{str(obj)} is not MMDLamp"
logger.error(error_msg)
raise ValueError(error_msg)
raise ValueError(f"{str(obj)} is not MMDLamp")
@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"}
def isLamp(obj):
return obj and obj.type in {"LIGHT", "LAMP"}
@staticmethod
def isMMDLamp(obj: Optional[Object]) -> bool:
"""Check if the object is an MMD lamp"""
def isMMDLamp(obj):
if MMDLamp.isLamp(obj):
obj = obj.parent
return obj is not None and obj.type == "EMPTY" and obj.mmd_type == "LIGHT"
return obj 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"""
def convertToMMDLamp(lampObj, scale=1.0):
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 = bpy.data.objects.new(name="MMD_Light", object_data=None)
FnContext.link_object(FnContext.ensure_context(), empty)
empty.rotation_mode = "XYZ"
empty.lock_rotation = (True, True, True)
@@ -69,18 +53,13 @@ class MMDLamp:
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"""
def object(self):
return self.__emptyObj
def lamp(self) -> Object:
"""Get the actual lamp/light object"""
def lamp(self):
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)
raise KeyError
+89 -154
View File
@@ -1,13 +1,10 @@
# -*- 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.
# This file is part of MMD Tools.
import logging
from ....core.logging_setup import logger
import os
from typing import TYPE_CHECKING, Callable, Iterable, Optional, Tuple, cast, Dict, List, Any, Union, Set
from pathlib import Path
from typing import TYPE_CHECKING, Callable, Iterable, Optional, Tuple, cast
import bpy
from mathutils import Vector
@@ -15,7 +12,6 @@ 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
@@ -28,55 +24,51 @@ 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
def __init__(self, image):
self.type = "IMAGE"
self.image = image
self.use_mipmap = 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)
def __init__(self, image):
self.diffuse_color_factor = 1
self.uv_layer = ""
self.texture = _DummyTexture(image)
class FnMaterial:
__NODES_ARE_READONLY: bool = False
def __init__(self, material: bpy.types.Material):
self.__material: bpy.types.Material = material
self._nodes_are_readonly: bool = FnMaterial.__NODES_ARE_READONLY
self.__material = material
self._nodes_are_readonly = FnMaterial.__NODES_ARE_READONLY
@staticmethod
def set_nodes_are_readonly(nodes_are_readonly: bool) -> None:
def set_nodes_are_readonly(nodes_are_readonly: bool):
FnMaterial.__NODES_ARE_READONLY = nodes_are_readonly
@classmethod
def from_material_id(cls, material_id: str) -> Optional['FnMaterial']:
def from_material_id(cls, material_id: int):
for material in bpy.data.materials:
if material.mmd_material.material_id == material_id:
return cls(material)
return None
@staticmethod
def clean_materials(obj: bpy.types.Object, can_remove: Callable[[bpy.types.Material], bool]) -> None:
def clean_materials(obj, can_remove: Callable[[bpy.types.Material], bool]):
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]:
def swap_materials(mesh_object: bpy.types.Object, mat1_ref: str | int, mat2_ref: str | int, reverse=False, swap_slots=False) -> Tuple[bpy.types.Material, bpy.types.Material]:
"""
This method will assign the polygons of mat1 to mat2.
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.
@@ -94,22 +86,18 @@ class FnMaterial:
Raises:
MaterialNotFoundError: If one of the materials is not found
"""
mesh = cast(bpy.types.Mesh, mesh_object.data)
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()
if None in {mat1, mat2}:
raise MaterialNotFoundError
except (KeyError, IndexError) as exc:
# Wrap exceptions within our custom ones
raise MaterialNotFoundError() from exc
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:
@@ -123,37 +111,31 @@ class FnMaterial:
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}")
def fixMaterialOrder(meshObj: bpy.types.Object, material_names: Iterable[str]):
"""Fix the material order which is lost after joining meshes."""
materials = cast("bpy.types.Mesh", meshObj.data).materials
for new_idx, mat in enumerate(material_names):
# Get the material that is currently on this index
other_mat = materials[new_idx]
if other_mat.name == mat:
continue # This is already in place
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
def material_id(self):
mmd_mat: MMDMaterial = self.__material.mmd_material
if mmd_mat.material_id < 0:
max_id = -1
for mat in bpy.data.materials:
max_id = max(max_id, mat.mmd_material.material_id)
mmd_mat.material_id = max_id + 1
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:
def material(self):
return self.__material
def __same_image_file(self, image: Optional[bpy.types.Image], filepath: str) -> bool:
def __same_image_file(self, image, filepath):
if image and image.source == "FILE":
# pylint: disable=assignment-from-no-return
img_filepath = bpy.path.abspath(image.filepath) # image.filepath_from_user()
@@ -162,19 +144,18 @@ class FnMaterial:
# pylint: disable=bare-except
try:
return os.path.samefile(img_filepath, filepath)
except:
pass
except Exception as e:
logger.warning(f"Failed to compare files '{img_filepath}' and '{filepath}': {e}")
return False
def _load_image(self, filepath: str) -> bpy.types.Image:
def _load_image(self, filepath):
img = next((i for i in bpy.data.images if self.__same_image_file(i, filepath)), None)
if img is None:
# pylint: disable=bare-except
try:
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.")
except Exception:
logger.warning("Cannot create a texture for %s. No such file.", filepath)
img = bpy.data.images.new(os.path.basename(filepath), 1, 1)
img.source = "FILE"
img.filepath = filepath
@@ -185,46 +166,43 @@ class FnMaterial:
img.alpha_mode = "NONE"
return img
def update_toon_texture(self) -> None:
def update_toon_texture(self):
if self._nodes_are_readonly:
return
mmd_mat: 'MMDMaterial' = self.__material.mmd_material
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))
self.create_toon_texture(str(Path(toon_path).resolve()))
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]:
def _mix_diffuse_and_ambient(self, mmd_mat):
r, g, b = mmd_mat.diffuse_color
ar, ag, ab = mmd_mat.ambient_color
return [min(1.0, 0.5 * r + ar), min(1.0, 0.5 * g + ag), min(1.0, 0.5 * b + ab)]
def update_drop_shadow(self) -> None:
def update_drop_shadow(self):
pass
def update_enabled_toon_edge(self) -> None:
def update_enabled_toon_edge(self):
if self._nodes_are_readonly:
return
self.update_edge_color()
def update_edge_color(self) -> None:
def update_edge_color(self):
if self._nodes_are_readonly:
return
mat = self.__material
mmd_mat: 'MMDMaterial' = mat.mmd_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)
mat_edge: bpy.types.Material = bpy.data.materials.get("mmd_edge." + mat.name, None)
if mat_edge:
mat_edge.mmd_material.edge_color = line_color
@@ -236,51 +214,44 @@ class FnMaterial:
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:
def update_edge_weight(self):
pass
def get_texture(self) -> Optional[_DummyTexture]:
def get_texture(self):
return self.__get_texture_node("mmd_base_tex", use_dummy=True)
def create_texture(self, filepath: str) -> _DummyTextureSlot:
def create_texture(self, filepath):
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:
def remove_texture(self):
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]:
def get_sphere_texture(self):
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:
def use_sphere_texture(self, use_sphere, obj=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:
def create_sphere_texture(self, filepath, obj=None):
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:
def update_sphere_texture_type(self, obj=None):
if self._nodes_are_readonly:
return
sphere_texture_type = int(self.material.mmd_material.sphere_texture_type)
is_sph_add = sphere_texture_type == 2
if sphere_texture_type not in (1, 2, 3):
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)
@@ -298,62 +269,54 @@ class FnMaterial:
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("_"))
uv_layers = (layer for layer in obj.data.uv_layers if not layer.name.startswith("_"))
next(uv_layers, None) # skip base UV
subtex_uv = getattr(next(uv_layers, None), "name", "")
if subtex_uv != "UV1":
logger.info(f'Material({mat.name}): object "{obj.name}" use UV "{subtex_uv}" for SubTex')
logger.info(' * material(%s): object "%s" use UV "%s" for SubTex', mat.name, obj.name, subtex_uv)
links.new(nodes["mmd_tex_uv"].outputs["SubTex UV"], texture.inputs["Vector"])
else:
links.new(nodes["mmd_tex_uv"].outputs["Sphere UV"], texture.inputs["Vector"])
logger.debug(f"Updated sphere texture type for {self.material.name}: {sphere_texture_type}")
def remove_sphere_texture(self) -> None:
def remove_sphere_texture(self):
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]:
def get_toon_texture(self):
return self.__get_texture_node("mmd_toon_tex", use_dummy=True)
def use_toon_texture(self, use_toon: bool) -> None:
def use_toon_texture(self, use_toon):
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:
def create_toon_texture(self, filepath):
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:
def remove_toon_texture(self):
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]]:
def __get_texture_node(self, node_name, use_dummy=False):
mat = self.material
texture = getattr(mat.node_tree, "nodes", {}).get(node_name, None)
if isinstance(texture, bpy.types.ShaderNodeTexImage):
return _DummyTexture(texture.image) if use_dummy else texture
return None
def __remove_texture_node(self, node_name: str) -> None:
def __remove_texture_node(self, node_name):
mat = self.material
texture = getattr(mat.node_tree, "nodes", {}).get(node_name, None)
if isinstance(texture, bpy.types.ShaderNodeTexImage):
mat.node_tree.nodes.remove(texture)
mat.update_tag()
def __create_texture_node(self, node_name: str, filepath: str, pos: Tuple[float, float]) -> bpy.types.ShaderNodeTexImage:
def __create_texture_node(self, node_name, filepath, pos):
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")
@@ -365,25 +328,23 @@ class FnMaterial:
self.__update_shader_nodes()
return texture
def update_ambient_color(self) -> None:
def update_ambient_color(self):
if self._nodes_are_readonly:
return
mat = self.material
mmd_mat = mat.mmd_material
mat.diffuse_color[:3] = self._mix_diffuse_and_ambient(mmd_mat)
self.__update_shader_input("Ambient Color", mmd_mat.ambient_color[:] + (1,))
logger.debug(f"Updated ambient color for {mat.name}")
def update_diffuse_color(self) -> None:
def update_diffuse_color(self):
if self._nodes_are_readonly:
return
mat = self.material
mmd_mat = mat.mmd_material
mat.diffuse_color[:3] = self._mix_diffuse_and_ambient(mmd_mat)
self.__update_shader_input("Diffuse Color", mmd_mat.diffuse_color[:] + (1,))
logger.debug(f"Updated diffuse color for {mat.name}")
def update_alpha(self) -> None:
def update_alpha(self):
if self._nodes_are_readonly:
return
mat = self.material
@@ -401,31 +362,28 @@ class FnMaterial:
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:
def update_specular_color(self):
if self._nodes_are_readonly:
return
mat = self.material
mmd_mat = mat.mmd_material
mat.specular_color = mmd_mat.specular_color
self.__update_shader_input("Specular Color", mmd_mat.specular_color[:] + (1,))
logger.debug(f"Updated specular color for {mat.name}")
def update_shininess(self) -> None:
def update_shininess(self):
if self._nodes_are_readonly:
return
mat = self.material
mmd_mat = mat.mmd_material
mat.roughness = 1 / pow(max(mmd_mat.shininess, 1), 0.37)
if hasattr(mat, "metallic"):
mat.metallic = pow(1 - mat.roughness, 2.7)
mat.metallic = 0.0
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:
def update_is_double_sided(self):
if self._nodes_are_readonly:
return
mat = self.material
@@ -435,9 +393,8 @@ class FnMaterial:
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:
def update_self_shadow_map(self):
if self._nodes_are_readonly:
return
mat = self.material
@@ -445,24 +402,21 @@ class FnMaterial:
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:
def update_self_shadow(self):
if self._nodes_are_readonly:
return
mat = self.material
mmd_mat = mat.mmd_material
self.__update_shader_input("Self Shadow", mmd_mat.enabled_self_shadow)
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:
def convert_to_mmd_material(material, context=bpy.context):
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]:
def search_tex_image_node(node: bpy.types.ShaderNode):
if node.type == "TEX_IMAGE":
return node
for node_input in node.inputs:
@@ -481,7 +435,8 @@ class FnMaterial:
preferred_output_node_target = {
"CYCLES": "CYCLES",
"BLENDER_EEVEE_NEXT": "EEVEE",
"BLENDER_EEVEE": "EEVEE",
"BLENDER_EEVEE_NEXT": "EEVEE", # Keep for backwards compatibility with 4.x
}.get(active_render_engine, "ALL")
tex_node = None
@@ -499,15 +454,13 @@ class FnMaterial:
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)
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')
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]
@@ -538,12 +491,11 @@ class FnMaterial:
# 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_')]
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:
def __update_shader_input(self, name, val):
mat = self.material
if mat.name.startswith("mmd_"): # skip mmd_edge.*
return
@@ -555,29 +507,26 @@ class FnMaterial:
val = min(max(val, interface_socket.min_value), interface_socket.max_value)
shader.inputs[name].default_value = val
def __update_shader_nodes(self) -> None:
def __update_shader_nodes(self):
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
default_value, is_linked = None, 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.location = (0, 300)
node_shader.width = 200
node_shader.node_tree = self.__get_shader()
mmd_mat: 'MMDMaterial' = mat.mmd_material
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,)
@@ -589,7 +538,6 @@ class FnMaterial:
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))
@@ -604,7 +552,7 @@ class FnMaterial:
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())
texture = self.__get_texture_node(f"mmd_{name_id.lower()}_tex")
if texture:
name_tex_in, name_alpha_in, name_uv_out = (name_id + x for x in (" Tex", " Alpha", " UV"))
if not node_shader.inputs.get(name_tex_in, _Dummy).is_linked:
@@ -614,13 +562,12 @@ class FnMaterial:
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:
def __get_shader_uv(self):
group_name = "MMDTexUV"
shader: bpy.types.ShaderNodeTree = bpy.data.node_groups.get(group_name, None) or bpy.data.node_groups.new(name=group_name, type="ShaderNodeTree")
if len(shader.nodes):
return shader
logger.debug(f"Creating MMD UV shader node group")
ng = _NodeGroupUtils(shader)
############################################################################
@@ -652,13 +599,12 @@ class FnMaterial:
return shader
def __get_shader(self) -> bpy.types.ShaderNodeTree:
def __get_shader(self):
group_name = "MMDShaderDev"
shader: bpy.types.ShaderNodeTree = bpy.data.node_groups.get(group_name, None) or bpy.data.node_groups.new(name=group_name, type="ShaderNodeTree")
if len(shader.nodes):
return shader
logger.debug(f"Creating MMD shader node group")
ng = _NodeGroupUtils(shader)
############################################################################
@@ -748,18 +694,15 @@ class FnMaterial:
class MigrationFnMaterial:
@staticmethod
def update_mmd_shader() -> None:
def update_mmd_shader():
mmd_shader_node_tree: Optional[bpy.types.NodeTree] = bpy.data.node_groups.get("MMDShaderDev")
if mmd_shader_node_tree is None:
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
@@ -768,11 +711,3 @@ class MigrationFnMaterial:
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))
+608 -315
View File
File diff suppressed because it is too large Load Diff
+95 -105
View File
@@ -1,39 +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.
# Copyright 2016 MMD Tools authors
# This file is part of MMD Tools.
from ....core.logging_setup import logger
import math
import re
from typing import TYPE_CHECKING, Tuple, cast, List, Dict, Optional, Set, Any, Union, Iterator
from typing import TYPE_CHECKING, Tuple, cast
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"):
def __init__(self, morph, model: "Model"):
self.__morph = morph
self.__rig = model
@classmethod
def storeShapeKeyOrder(cls, obj: Object, shape_key_names: List[str]) -> None:
def storeShapeKeyOrder(cls, obj, shape_key_names):
if len(shape_key_names) < 1:
return
assert FnContext.get_active_object(FnContext.ensure_context()) == obj
if obj.data.shape_keys is None:
bpy.ops.object.shape_key_add()
def __move_to_bottom(key_blocks: bpy.types.bpy_prop_collection, name: str) -> None:
def __move_to_bottom(key_blocks, name):
obj.active_shape_key_index = key_blocks.find(name)
bpy.ops.object.shape_key_move(type="BOTTOM")
@@ -45,7 +40,7 @@ class FnMorph:
__move_to_bottom(key_blocks, name)
@classmethod
def fixShapeKeyOrder(cls, obj: Object, shape_key_names: List[str]) -> None:
def fixShapeKeyOrder(cls, obj, shape_key_names):
if len(shape_key_names) < 1:
return
assert FnContext.get_active_object(FnContext.ensure_context()) == obj
@@ -60,11 +55,11 @@ class FnMorph:
bpy.ops.object.shape_key_move(type="BOTTOM")
@staticmethod
def get_morph_slider(rig: "Model") -> "_MorphSlider":
def get_morph_slider(rig):
return _MorphSlider(rig)
@staticmethod
def category_guess(morph: Any) -> None:
def category_guess(morph):
name_lower = morph.name.lower()
if "mouth" in name_lower:
morph.category = "MOUTH"
@@ -75,7 +70,7 @@ class FnMorph:
morph.category = "EYE"
@classmethod
def load_morphs(cls, rig: "Model") -> None:
def load_morphs(cls, rig):
mmd_root = rig.rootObject().mmd_root
vertex_morphs = mmd_root.vertex_morphs
uv_morphs = mmd_root.uv_morphs
@@ -94,7 +89,7 @@ class FnMorph:
cls.category_guess(item)
@staticmethod
def remove_shape_key(mesh_object: Object, shape_key_name: str) -> None:
def remove_shape_key(mesh_object: bpy.types.Object, shape_key_name: str):
assert isinstance(mesh_object.data, bpy.types.Mesh)
shape_keys = mesh_object.data.shape_keys
@@ -106,7 +101,7 @@ class FnMorph:
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:
def copy_shape_key(mesh_object: bpy.types.Object, src_name: str, dest_name: str):
assert isinstance(mesh_object.data, bpy.types.Mesh)
shape_keys = mesh_object.data.shape_keys
@@ -128,13 +123,13 @@ class FnMorph:
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]]:
def get_uv_morph_vertex_groups(obj, morph_name=None, offset_axes="XYZW"):
pattern = "UV_%s[+-][%s]$" % (morph_name or ".{1,}", offset_axes or "XYZW")
# yield (vertex_group, morph_name, axis),...
return ((g, g.name[3:-2], g.name[-2:]) for g in obj.vertex_groups if re.match(pattern, g.name))
@staticmethod
def copy_uv_morph_vertex_groups(obj: Object, src_name: str, dest_name: str) -> None:
def copy_uv_morph_vertex_groups(obj, src_name, dest_name):
for vg, n, x in FnMorph.get_uv_morph_vertex_groups(obj, dest_name):
obj.vertex_groups.remove(vg)
@@ -145,12 +140,12 @@ class FnMorph:
obj.vertex_groups.active.name = vg_name.replace(src_name, dest_name)
@staticmethod
def overwrite_bone_morphs_from_action_pose(armature_object: Object) -> None:
def overwrite_bone_morphs_from_action_pose(armature_object):
armature = armature_object.id_data
# Use animation_data and action instead of action_pose
if armature.animation_data is None or armature.animation_data.action is None:
logger.warning('Armature "%s" has no animation data or action', armature_object.name)
logger.warning('[WARNING] armature "%s" has no animation data or action', armature_object.name)
return
action = armature.animation_data.action
@@ -164,7 +159,7 @@ class FnMorph:
bone_morphs = mmd_root.bone_morphs
utils.selectAObject(armature_object)
original_mode = bpy.context.object.mode
original_mode = bpy.context.active_object.mode
bpy.ops.object.mode_set(mode="POSE")
try:
for index, pose_marker in enumerate(pose_markers):
@@ -189,9 +184,9 @@ class FnMorph:
utils.selectAObject(root)
@staticmethod
def clean_uv_morph_vertex_groups(obj: Object) -> None:
def clean_uv_morph_vertex_groups(obj):
# 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)}
vg_indices = {g.index for g, n, x in FnMorph.get_uv_morph_vertex_groups(obj)}
vertex_groups = obj.vertex_groups
for v in obj.data.vertices:
for x in v.groups:
@@ -205,8 +200,8 @@ class FnMorph:
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
def get_uv_morph_offset_map(obj, morph):
offset_map = {} # offset_map[vertex_index] = offset_xyzw
if morph.data_type == "VERTEX_GROUP":
scale = morph.vertex_group_scale
axis_map = {g.index: x for g, n, x in FnMorph.get_uv_morph_vertex_groups(obj, morph.name)}
@@ -221,13 +216,13 @@ class FnMorph:
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)]
offset_map[i] = [a + b for a, b in zip(offset_map[i], val.offset, strict=False)]
else:
offset_map[i] = val.offset
return offset_map
@staticmethod
def store_uv_morph_data(obj: Object, morph: Any, offsets: Optional[List[Any]] = None, offset_axes: str = "XYZW") -> None:
def store_uv_morph_data(obj, morph, offsets=None, offset_axes="XYZW"):
vertex_groups = obj.vertex_groups
morph_name = getattr(morph, "name", None)
if offset_axes:
@@ -246,13 +241,13 @@ class FnMorph:
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"):
for val, axis in zip(offset, "XYZW", strict=False):
if abs(val) > 1e-4:
vg_name = "UV_{0}{1}{2}".format(morph_name, "-" if val < 0 else "+", axis)
vg_name = f"UV_{morph_name}{'-' if val < 0 else '+'}{axis}"
vg = vertex_groups.get(vg_name, None) or vertex_groups.new(name=vg_name)
vg.add(index=[idx], weight=abs(val) / scale, type="REPLACE")
def update_mat_related_mesh(self, new_mesh: Optional[Object] = None) -> None:
def update_mat_related_mesh(self, new_mesh=None):
for offset in self.__morph.data:
# Use the new_mesh if provided
meshObj = new_mesh
@@ -272,28 +267,28 @@ class FnMorph:
offset.related_mesh = meshObj.data.name
@staticmethod
def clean_duplicated_material_morphs(mmd_root_object: Object) -> None:
def clean_duplicated_material_morphs(mmd_root_object: bpy.types.Object):
"""Clean duplicated material_morphs and data from mmd_root_object.mmd_root.material_morphs[].data[]"""
mmd_root = mmd_root_object.mmd_root
def morph_data_equals(l: Any, r: Any) -> bool:
def morph_data_equals(left, right) -> 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))
left.related_mesh_data == right.related_mesh_data
and left.offset_type == right.offset_type
and left.material == right.material
and all(a == b for a, b in zip(left.diffuse_color, right.diffuse_color, strict=False))
and all(a == b for a, b in zip(left.specular_color, right.specular_color, strict=False))
and left.shininess == right.shininess
and all(a == b for a, b in zip(left.ambient_color, right.ambient_color, strict=False))
and all(a == b for a, b in zip(left.edge_color, right.edge_color, strict=False))
and left.edge_weight == right.edge_weight
and all(a == b for a, b in zip(left.texture_factor, right.texture_factor, strict=False))
and all(a == b for a, b in zip(left.sphere_texture_factor, right.sphere_texture_factor, strict=False))
and all(a == b for a, b in zip(left.toon_texture_factor, right.toon_texture_factor, strict=False))
)
def morph_equals(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))
def morph_equals(left, right) -> bool:
return len(left.data) == len(right.data) and all(morph_data_equals(a, b) for a, b in zip(left.data, right.data, strict=False))
# Remove duplicated mmd_root.material_morphs.data[]
for material_morph in mmd_root.material_morphs:
@@ -327,7 +322,7 @@ class _MorphSlider:
def __init__(self, model: "Model"):
self.__rig = model
def placeholder(self, create: bool = False, binded: bool = False) -> Optional[Object]:
def placeholder(self, create=False, binded=False):
rig = self.__rig
root = rig.rootObject()
obj = next((x for x in root.children if x.mmd_type == "PLACEHOLDER" and x.type == "MESH"), None)
@@ -345,11 +340,11 @@ class _MorphSlider:
return obj
@property
def dummy_armature(self) -> Optional[Object]:
def dummy_armature(self):
obj = self.placeholder()
return self.__dummy_armature(obj) if obj else None
def __dummy_armature(self, obj: Object, create: bool = False) -> Optional[Object]:
def __dummy_armature(self, obj, create=False):
arm = next((x for x in obj.children if x.mmd_type == "PLACEHOLDER" and x.type == "ARMATURE"), None)
if create and arm is None:
arm = bpy.data.objects.new(name=".dummy_armature", object_data=bpy.data.armatures.new(name=".dummy_armature"))
@@ -362,7 +357,7 @@ class _MorphSlider:
FnBone.setup_special_bone_collections(arm)
return arm
def get(self, morph_name: str) -> Optional[ShapeKey]:
def get(self, morph_name):
obj = self.placeholder()
if obj is None:
return None
@@ -371,13 +366,13 @@ class _MorphSlider:
return None
return key_blocks.get(morph_name, None)
def create(self) -> Object:
def create(self):
self.__rig.loadMorphs()
obj = self.placeholder(create=True)
self.__load(obj, self.__rig.rootObject().mmd_root)
return obj
def __load(self, obj: Object, mmd_root: Any) -> None:
def __load(self, obj, mmd_root):
attr_list = ("group", "vertex", "bone", "uv", "material")
morph_sliders = obj.data.shape_keys.key_blocks
for m in (x for attr in attr_list for x in getattr(mmd_root, attr + "_morphs", ())):
@@ -388,15 +383,15 @@ class _MorphSlider:
obj.shape_key_add(name=name, from_mix=False)
@staticmethod
def __driver_variables(id_data: Any, path: str, index: int = -1) -> Tuple[Any, Any]:
def __driver_variables(id_data, path, index=-1):
d = id_data.driver_add(path, index)
variables = d.driver.variables
for x in variables:
for x in reversed(variables):
variables.remove(x)
return d.driver, variables
@staticmethod
def __add_single_prop(variables: Any, id_obj: Object, data_path: str, prefix: str) -> Any:
def __add_single_prop(variables, id_obj, data_path, prefix):
var = variables.new()
var.name = f"{prefix}{len(variables)}"
var.type = "SINGLE_PROP"
@@ -407,7 +402,7 @@ class _MorphSlider:
return var
@staticmethod
def __shape_key_driver_check(key_block: ShapeKey, resolve_path: bool = False) -> bool:
def __shape_key_driver_check(key_block, resolve_path=False):
if resolve_path:
try:
key_block.id_data.path_resolve(key_block.path_from_id())
@@ -421,22 +416,20 @@ class _MorphSlider:
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
def __cleanup(self, names_in_use=None):
names_in_use = names_in_use or {}
rig = self.__rig
morph_sliders = self.placeholder()
morph_sliders = morph_sliders.data.shape_keys.key_blocks if morph_sliders else {}
for mesh_object in rig.meshes():
for kb in getattr(mesh_object.data.shape_keys, "key_blocks", cast(Tuple[ShapeKey], ())):
for kb in getattr(mesh_object.data.shape_keys, "key_blocks", cast("Tuple[bpy.types.ShapeKey]", ())):
if kb.name in names_in_use:
continue
if kb.name.startswith("mmd_bind"):
kb.driver_remove("value")
ms = morph_sliders[kb.relative_key.name]
kb.relative_key.slider_min, kb.relative_key.slider_max = min(ms.slider_min, floor(ms.value)), max(ms.slider_max, ceil(ms.value))
kb.relative_key.slider_min, kb.relative_key.slider_max = min(ms.slider_min, math.floor(ms.value)), max(ms.slider_max, math.ceil(ms.value))
kb.relative_key.value = ms.value
kb.relative_key.mute = False
FnObject.mesh_remove_shape_key(mesh_object, kb)
@@ -444,9 +437,9 @@ class _MorphSlider:
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))
kb.slider_min, kb.slider_max = min(ms.slider_min, math.floor(kb.value)), max(ms.slider_max, math.ceil(kb.value))
for m in mesh_object.modifiers: # uv morph
for m in reversed(mesh_object.modifiers): # uv morph
if m.name.startswith("mmd_bind") and m.name not in names_in_use:
mesh_object.modifiers.remove(m)
@@ -461,13 +454,13 @@ class _MorphSlider:
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:
for c in reversed(b.constraints):
if c.name.startswith("mmd_bind") and c.name[:-4] not in names_in_use:
for attr in attributes:
c.driver_remove(attr)
b.constraints.remove(c)
def unbind(self) -> None:
def unbind(self):
mmd_root = self.__rig.rootObject().mmd_root
# after unbind, the weird lag problem will disappear.
@@ -490,7 +483,7 @@ class _MorphSlider:
b.driver_remove("rotation_quaternion")
self.__cleanup()
def bind(self) -> None:
def bind(self):
rig = self.__rig
root = rig.rootObject()
armObj = rig.armature()
@@ -504,10 +497,10 @@ class _MorphSlider:
morph_sliders = obj.data.shape_keys.key_blocks
# data gathering
group_map: Dict[Tuple[str, str], List[List[Any]]] = {}
group_map = {}
shape_key_map: Dict[str, List[Tuple[ShapeKey, str, List[Any]]]] = {}
uv_morph_map: Dict[str, List[Tuple[str, str, str, List[Any]]]] = {}
shape_key_map = {}
uv_morph_map = {}
for mesh_object in rig.meshes():
mesh_object.show_only_shape_key = False
key_blocks = getattr(mesh_object.data.shape_keys, "key_blocks", ())
@@ -528,11 +521,11 @@ class _MorphSlider:
kb_bind.slider_max = 10
data_path = 'data.shape_keys.key_blocks["%s"].value' % kb_name.replace('"', '\\"')
groups: List[Any] = []
groups = []
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 = [layer.name for layer in mesh_object.data.uv_layers if not layer.name.startswith("_")]
uv_layers += [""] * (5 - len(uv_layers))
for vg, morph_name, axis in FnMorph.get_uv_morph_vertex_groups(mesh_object):
morph = mmd_root.uv_morphs.get(morph_name, None)
@@ -544,7 +537,7 @@ class _MorphSlider:
continue
name_bind = "mmd_bind%s" % hash(vg.name)
uv_morph_map.setdefault(name_bind, [])
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
@@ -557,13 +550,13 @@ class _MorphSlider:
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]]] = {}
bone_offset_map = {}
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:
def __get_bone(name, parent):
b = edit_bones.get(name, None) or edit_bones.new(name=name)
b.head = (0, 0, 0)
b.tail = (0, 0, 1)
@@ -580,7 +573,7 @@ class _MorphSlider:
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] = []
groups = []
bone_offset_map[name_bind] = (m.name, d, b.name, data_path, groups)
group_map.setdefault(("bone_morphs", m.name), []).append(groups)
@@ -591,21 +584,21 @@ class _MorphSlider:
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] = []
groups = []
uv_morph_map.setdefault(name_bind, []).append((b.name, data_path, scale_path, groups))
group_map.setdefault(("uv_morphs", m.name), []).append(groups)
used_bone_names: Set[str] = set(bone_offset_map.keys()) | set(uv_morph_map.keys())
used_bone_names = bone_offset_map.keys() | uv_morph_map.keys()
used_bone_names.add(ctrl_base.name)
for b in edit_bones: # cleanup
for b in reversed(edit_bones): # cleanup
if b.name.startswith("mmd_bind") and b.name not in used_bone_names:
edit_bones.remove(b)
material_offset_map: Dict[str, Any] = {}
material_offset_map = {}
for m in mmd_root.material_morphs:
morph_name = m.name.replace('"', '\\"')
data_path = f'data.shape_keys.key_blocks["{morph_name}"].value'
groups: List[Any] = []
groups = []
group_map.setdefault(("material_morphs", m.name), []).append(groups)
material_offset_map.setdefault("group_dict", {})[m.name] = (data_path, groups)
for d in m.data:
@@ -616,7 +609,7 @@ class _MorphSlider:
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)
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:
@@ -627,7 +620,7 @@ class _MorphSlider:
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:
def __config_groups(variables, expression, groups):
for g_name, morph_path, factor_path in groups:
var = self.__add_single_prop(variables, obj, morph_path, "g")
fvar = self.__add_single_prop(variables, root, factor_path, "w")
@@ -635,7 +628,7 @@ class _MorphSlider:
return expression
# vertex morphs
for kb_bind, morph_data_path, groups in (i for l in shape_key_map.values() for i in l):
for kb_bind, morph_data_path, groups in (i for value_list in shape_key_map.values() for i in value_list):
driver, variables = self.__driver_variables(kb_bind, "value")
var = self.__add_single_prop(variables, obj, morph_data_path, "v")
if kb_bind.name.startswith("mmd_bind"):
@@ -646,7 +639,7 @@ class _MorphSlider:
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:
def __config_bone_morph(constraints, map_type, attributes, val, val_str):
c_name = f"mmd_bind{hash(data)}.{map_type[:3]}"
c = TransformConstraintOp.create(constraints, c_name, map_type)
TransformConstraintOp.update_min_max(c, val, None)
@@ -660,8 +653,6 @@ class _MorphSlider:
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():
@@ -671,7 +662,7 @@ class _MorphSlider:
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, "ROTATION", attributes_rot, math.pi, "pi")
__config_bone_morph(pb.constraints, "LOCATION", attributes_loc, 100, "100")
# uv morphs
@@ -680,7 +671,7 @@ class _MorphSlider:
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):
for bname, data_path, scale_path, groups in (i for value_list in uv_morph_map.values() for i in value_list):
b = arm.pose.bones[bname]
b.is_mmd_shadow_bone = True
b.mmd_shadow_bone_type = "BIND"
@@ -694,9 +685,9 @@ class _MorphSlider:
group_dict = material_offset_map.get("group_dict", {})
def __config_material_morph(mat: Material, morph_list: List[Tuple[str, Any, str]]) -> None:
def __config_material_morph(mat, morph_list):
nodes = _MaterialMorph.setup_morph_nodes(mat, tuple(x[1] for x in morph_list))
for (morph_name, data, name_bind), node in zip(morph_list, nodes):
for (morph_name, data, name_bind), node in zip(morph_list, nodes, strict=False):
node.label, node.name = morph_name, name_bind
data_path, groups = group_dict[morph_name]
driver, variables = self.__driver_variables(mat.node_tree, node.inputs[0].path_from_id("default_value"))
@@ -706,7 +697,7 @@ class _MorphSlider:
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.")
logger.warning("Oh no. The material name should never empty.")
mul_list, add_list = [], []
else:
mat_name = "#" + mat.name
@@ -722,7 +713,7 @@ class _MorphSlider:
class MigrationFnMorph:
@staticmethod
def update_mmd_morph() -> None:
def update_mmd_morph():
from .material import FnMaterial
for root in bpy.data.objects:
@@ -733,7 +724,7 @@ class MigrationFnMorph:
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.
# The material_id is also no longer used, but for compatibility with older version mmd_tools_local, keep it.
if "material_id" not in morph_data.material_data.mmd_material or "material_id" not in morph_data or morph_data.material_data.mmd_material["material_id"] == morph_data["material_id"]:
# In the new version, the related_mesh property is no longer used.
# Explicitly remove this property to avoid misuse.
@@ -741,15 +732,14 @@ class MigrationFnMorph:
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
# Compat case. The new version mmd_tools_local saved. And old version mmd_tools_local edit. Then new version mmd_tools_local load again.
# Go update path.
pass
morph_data.material_data = None
if "material_id" in morph_data:
mat_id = morph_data["material_id"]
if mat_id != -1:
if mat_id >= 0:
fnMat = FnMaterial.from_material_id(mat_id)
if fnMat:
morph_data.material_data = fnMat.material
@@ -764,11 +754,11 @@ class MigrationFnMorph:
morph_data.related_mesh_data = bpy.data.meshes[related_mesh]
@staticmethod
def ensure_material_id_not_conflict() -> None:
mat_ids_set: Set[int] = set()
def ensure_material_id_not_conflict():
mat_ids_set = set()
# The reference library properties cannot be modified and bypassed in advance.
need_update_mat: List[Material] = []
need_update_mat = []
for mat in bpy.data.materials:
if mat.mmd_material.material_id < 0:
continue
@@ -783,7 +773,7 @@ class MigrationFnMorph:
mat_ids_set.add(mat.mmd_material.material_id)
@staticmethod
def compatible_with_old_version_mmd_tools() -> None:
def compatible_with_old_version_mmd_tools_local():
MigrationFnMorph.ensure_material_id_not_conflict()
for root in bpy.data.objects:
+177 -177
View File
@@ -5,7 +5,7 @@
# 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
from .....core.logging_setup import logger
import os
import struct
@@ -40,7 +40,7 @@ class FileStream:
def close(self):
if self.__file_obj is not None:
logging.debug('close the file("%s")', self.__path)
logger.debug('close the file("%s")', self.__path)
self.__file_obj.close()
self.__file_obj = None
@@ -260,20 +260,20 @@ class Header:
return 4
def load(self, fs):
logging.info('loading pmx header information...')
logger.info('loading pmx header information...')
self.sign = fs.readBytes(4)
logging.debug('File signature is %s', self.sign)
logger.debug('File signature is %s', self.sign)
if self.sign[:3] != self.PMX_SIGN[:3]:
logging.info('File signature is invalid')
logging.error('This file is unsupported format, or corrupt file.')
logger.info('File signature is invalid')
logger.error('This file is unsupported format, or corrupt file.')
raise InvalidFileError('File signature is invalid.')
self.version = fs.readFloat()
logging.info('pmx format version: %f', self.version)
logger.info('pmx format version: %f', self.version)
if self.version != self.VERSION:
logging.error('PMX version %.1f is unsupported', self.version)
logger.error('PMX version %.1f is unsupported', self.version)
raise UnsupportedVersionError('unsupported PMX version: %.1f'%self.version)
if fs.readByte() != 8 or self.sign[3] != self.PMX_SIGN[3]:
logging.warning(' * This file might be corrupted.')
logger.warning(' * This file might be corrupted.')
self.encoding = Encoding(fs.readByte())
self.additional_uvs = fs.readByte()
self.vertex_index_size = fs.readByte()
@@ -283,19 +283,19 @@ class Header:
self.morph_index_size = fs.readByte()
self.rigid_index_size = fs.readByte()
logging.info('----------------------------')
logging.info('pmx header information')
logging.info('----------------------------')
logging.info('pmx version: %.1f', self.version)
logging.info('encoding: %s', str(self.encoding))
logging.info('number of uvs: %d', self.additional_uvs)
logging.info('vertex index size: %d byte(s)', self.vertex_index_size)
logging.info('texture index: %d byte(s)', self.texture_index_size)
logging.info('material index: %d byte(s)', self.material_index_size)
logging.info('bone index: %d byte(s)', self.bone_index_size)
logging.info('morph index: %d byte(s)', self.morph_index_size)
logging.info('rigid index: %d byte(s)', self.rigid_index_size)
logging.info('----------------------------')
logger.info('----------------------------')
logger.info('pmx header information')
logger.info('----------------------------')
logger.info('pmx version: %.1f', self.version)
logger.info('encoding: %s', str(self.encoding))
logger.info('number of uvs: %d', self.additional_uvs)
logger.info('vertex index size: %d byte(s)', self.vertex_index_size)
logger.info('texture index: %d byte(s)', self.texture_index_size)
logger.info('material index: %d byte(s)', self.material_index_size)
logger.info('bone index: %d byte(s)', self.bone_index_size)
logger.info('morph index: %d byte(s)', self.morph_index_size)
logger.info('rigid index: %d byte(s)', self.rigid_index_size)
logger.info('----------------------------')
def save(self, fs):
fs.writeBytes(self.PMX_SIGN)
@@ -364,27 +364,27 @@ class Model:
self.comment = fs.readStr()
self.comment_e = fs.readStr()
logging.info('Model name: %s', self.name)
logging.info('Model name(english): %s', self.name_e)
logging.info('Comment:%s', self.comment)
logging.info('Comment(english):%s', self.comment_e)
logger.info('Model name: %s', self.name)
logger.info('Model name(english): %s', self.name_e)
logger.info('Comment:%s', self.comment)
logger.info('Comment(english):%s', self.comment_e)
logging.info('')
logging.info('------------------------------')
logging.info('Load Vertices')
logging.info('------------------------------')
logger.info('')
logger.info('------------------------------')
logger.info('Load Vertices')
logger.info('------------------------------')
num_vertices = fs.readInt()
self.vertices = []
for i in range(num_vertices):
v = Vertex()
v.load(fs)
self.vertices.append(v)
logging.info('----- Loaded %d vertices', len(self.vertices))
logger.info('----- Loaded %d vertices', len(self.vertices))
logging.info('')
logging.info('------------------------------')
logging.info(' Load Faces')
logging.info('------------------------------')
logger.info('')
logger.info('------------------------------')
logger.info(' Load Faces')
logger.info('------------------------------')
num_faces = fs.readInt()
self.faces = []
for i in range(int(num_faces/3)):
@@ -392,25 +392,25 @@ class Model:
f2 = fs.readVertexIndex()
f3 = fs.readVertexIndex()
self.faces.append((f3, f2, f1))
logging.info(' Load %d faces', len(self.faces))
logger.info(' Load %d faces', len(self.faces))
logging.info('')
logging.info('------------------------------')
logging.info(' Load Textures')
logging.info('------------------------------')
logger.info('')
logger.info('------------------------------')
logger.info(' Load Textures')
logger.info('------------------------------')
num_textures = fs.readInt()
self.textures = []
for i in range(num_textures):
t = Texture()
t.load(fs)
self.textures.append(t)
logging.info('Texture %d: %s', i, t.path)
logging.info(' ----- Loaded %d textures', len(self.textures))
logger.info('Texture %d: %s', i, t.path)
logger.info(' ----- Loaded %d textures', len(self.textures))
logging.info('')
logging.info('------------------------------')
logging.info(' Load Materials')
logging.info('------------------------------')
logger.info('')
logger.info('------------------------------')
logger.info(' Load Materials')
logger.info('------------------------------')
num_materials = fs.readInt()
self.materials = []
for i in range(num_materials):
@@ -418,38 +418,38 @@ class Model:
m.load(fs, num_textures)
self.materials.append(m)
logging.info('Material %d: %s', i, m.name)
logging.debug(' Name(english): %s', m.name_e)
logging.debug(' Comment: %s', m.comment)
logging.debug(' Vertex Count: %d', m.vertex_count)
logging.debug(' Diffuse: (%.2f, %.2f, %.2f, %.2f)', *m.diffuse)
logging.debug(' Specular: (%.2f, %.2f, %.2f)', *m.specular)
logging.debug(' Shininess: %f', m.shininess)
logging.debug(' Ambient: (%.2f, %.2f, %.2f)', *m.ambient)
logging.debug(' Double Sided: %s', str(m.is_double_sided))
logging.debug(' Drop Shadow: %s', str(m.enabled_drop_shadow))
logging.debug(' Self Shadow: %s', str(m.enabled_self_shadow))
logging.debug(' Self Shadow Map: %s', str(m.enabled_self_shadow_map))
logging.debug(' Edge: %s', str(m.enabled_toon_edge))
logging.debug(' Edge Color: (%.2f, %.2f, %.2f, %.2f)', *m.edge_color)
logging.debug(' Edge Size: %.2f', m.edge_size)
logger.info('Material %d: %s', i, m.name)
logger.debug(' Name(english): %s', m.name_e)
logger.debug(' Comment: %s', m.comment)
logger.debug(' Vertex Count: %d', m.vertex_count)
logger.debug(' Diffuse: (%.2f, %.2f, %.2f, %.2f)', *m.diffuse)
logger.debug(' Specular: (%.2f, %.2f, %.2f)', *m.specular)
logger.debug(' Shininess: %f', m.shininess)
logger.debug(' Ambient: (%.2f, %.2f, %.2f)', *m.ambient)
logger.debug(' Double Sided: %s', str(m.is_double_sided))
logger.debug(' Drop Shadow: %s', str(m.enabled_drop_shadow))
logger.debug(' Self Shadow: %s', str(m.enabled_self_shadow))
logger.debug(' Self Shadow Map: %s', str(m.enabled_self_shadow_map))
logger.debug(' Edge: %s', str(m.enabled_toon_edge))
logger.debug(' Edge Color: (%.2f, %.2f, %.2f, %.2f)', *m.edge_color)
logger.debug(' Edge Size: %.2f', m.edge_size)
if m.texture != -1:
logging.debug(' Texture Index: %d', m.texture)
logger.debug(' Texture Index: %d', m.texture)
else:
logging.debug(' Texture: None')
logger.debug(' Texture: None')
if m.sphere_texture != -1:
logging.debug(' Sphere Texture Index: %d', m.sphere_texture)
logging.debug(' Sphere Texture Mode: %d', m.sphere_texture_mode)
logger.debug(' Sphere Texture Index: %d', m.sphere_texture)
logger.debug(' Sphere Texture Mode: %d', m.sphere_texture_mode)
else:
logging.debug(' Sphere Texture: None')
logging.debug('')
logger.debug(' Sphere Texture: None')
logger.debug('')
logging.info('----- Loaded %d materials.', len(self.materials))
logger.info('----- Loaded %d materials.', len(self.materials))
logging.info('')
logging.info('------------------------------')
logging.info(' Load Bones')
logging.info('------------------------------')
logger.info('')
logger.info('------------------------------')
logger.info(' Load Bones')
logger.info('------------------------------')
num_bones = fs.readInt()
self.bones = []
for i in range(num_bones):
@@ -457,33 +457,33 @@ class Model:
b.load(fs)
self.bones.append(b)
logging.info('Bone %d: %s', i, b.name)
logging.debug(' Name(english): %s', b.name_e)
logging.debug(' Location: (%f, %f, %f)', *b.location)
logging.debug(' displayConnection: %s', str(b.displayConnection))
logging.debug(' Parent: %s', str(b.parent))
logging.debug(' Transform Order: %s', str(b.transform_order))
logging.debug(' Rotatable: %s', str(b.isRotatable))
logging.debug(' Movable: %s', str(b.isMovable))
logging.debug(' Visible: %s', str(b.visible))
logging.debug(' Controllable: %s', str(b.isControllable))
logging.debug(' Additional Location: %s', str(b.hasAdditionalLocation))
logging.debug(' Additional Rotation: %s', str(b.hasAdditionalRotate))
logger.info('Bone %d: %s', i, b.name)
logger.debug(' Name(english): %s', b.name_e)
logger.debug(' Location: (%f, %f, %f)', *b.location)
logger.debug(' displayConnection: %s', str(b.displayConnection))
logger.debug(' Parent: %s', str(b.parent))
logger.debug(' Transform Order: %s', str(b.transform_order))
logger.debug(' Rotatable: %s', str(b.isRotatable))
logger.debug(' Movable: %s', str(b.isMovable))
logger.debug(' Visible: %s', str(b.visible))
logger.debug(' Controllable: %s', str(b.isControllable))
logger.debug(' Additional Location: %s', str(b.hasAdditionalLocation))
logger.debug(' Additional Rotation: %s', str(b.hasAdditionalRotate))
if b.additionalTransform is not None:
logging.debug(' Additional Transform: Bone:%d, influence: %f', *b.additionalTransform)
logging.debug(' IK: %s', str(b.isIK))
logger.debug(' Additional Transform: Bone:%d, influence: %f', *b.additionalTransform)
logger.debug(' IK: %s', str(b.isIK))
if b.isIK:
logging.debug(' Unit Angle: %f', b.rotationConstraint)
logging.debug(' Target: %d', b.target)
logger.debug(' Unit Angle: %f', b.rotationConstraint)
logger.debug(' Target: %d', b.target)
for j, link in enumerate(b.ik_links):
logging.debug(' IK Link %d: %d, %s - %s', j, link.target, str(link.minimumAngle), str(link.maximumAngle))
logging.debug('')
logging.info('----- Loaded %d bones.', len(self.bones))
logger.debug(' IK Link %d: %d, %s - %s', j, link.target, str(link.minimumAngle), str(link.maximumAngle))
logger.debug('')
logger.info('----- Loaded %d bones.', len(self.bones))
logging.info('')
logging.info('------------------------------')
logging.info(' Load Morphs')
logging.info('------------------------------')
logger.info('')
logger.info('------------------------------')
logger.info(' Load Morphs')
logger.info('------------------------------')
num_morph = fs.readInt()
self.morphs = []
display_categories = {0: 'System', 1: 'Eyebrow', 2: 'Eye', 3: 'Mouth', 4: 'Other'}
@@ -491,16 +491,16 @@ class Model:
m = Morph.create(fs)
self.morphs.append(m)
logging.info('%s %d: %s', m.__class__.__name__, i, m.name)
logging.debug(' Name(english): %s', m.name_e)
logging.debug(' Category: %s (%d)', display_categories.get(m.category, '#Invalid'), m.category)
logging.debug('')
logging.info('----- Loaded %d morphs.', len(self.morphs))
logger.info('%s %d: %s', m.__class__.__name__, i, m.name)
logger.debug(' Name(english): %s', m.name_e)
logger.debug(' Category: %s (%d)', display_categories.get(m.category, '#Invalid'), m.category)
logger.debug('')
logger.info('----- Loaded %d morphs.', len(self.morphs))
logging.info('')
logging.info('------------------------------')
logging.info(' Load Display Items')
logging.info('------------------------------')
logger.info('')
logger.info('------------------------------')
logger.info(' Load Display Items')
logger.info('------------------------------')
num_disp = fs.readInt()
self.display = []
for i in range(num_disp):
@@ -508,15 +508,15 @@ class Model:
d.load(fs)
self.display.append(d)
logging.info('Display Item %d: %s', i, d.name)
logging.debug(' Name(english): %s', d.name_e)
logging.debug('')
logging.info('----- Loaded %d display items.', len(self.display))
logger.info('Display Item %d: %s', i, d.name)
logger.debug(' Name(english): %s', d.name_e)
logger.debug('')
logger.info('----- Loaded %d display items.', len(self.display))
logging.info('')
logging.info('------------------------------')
logging.info(' Load Rigid Bodies')
logging.info('------------------------------')
logger.info('')
logger.info('------------------------------')
logger.info(' Load Rigid Bodies')
logger.info('------------------------------')
num_rigid = fs.readInt()
self.rigids = []
rigid_types = {0: 'Sphere', 1: 'Box', 2: 'Capsule'}
@@ -525,27 +525,27 @@ class Model:
r = Rigid()
r.load(fs)
self.rigids.append(r)
logging.info('Rigid Body %d: %s', i, r.name)
logging.debug(' Name(english): %s', r.name_e)
logging.debug(' Type: %s', rigid_types[r.type])
logging.debug(' Mode: %s (%d)', rigid_modes.get(r.mode, '#Invalid'), r.mode)
logging.debug(' Related bone: %s', r.bone)
logging.debug(' Collision group: %d', r.collision_group_number)
logging.debug(' Collision group mask: 0x%x', r.collision_group_mask)
logging.debug(' Size: (%f, %f, %f)', *r.size)
logging.debug(' Location: (%f, %f, %f)', *r.location)
logging.debug(' Rotation: (%f, %f, %f)', *r.rotation)
logging.debug(' Mass: %f', r.mass)
logging.debug(' Bounce: %f', r.bounce)
logging.debug(' Friction: %f', r.friction)
logging.debug('')
logger.info('Rigid Body %d: %s', i, r.name)
logger.debug(' Name(english): %s', r.name_e)
logger.debug(' Type: %s', rigid_types[r.type])
logger.debug(' Mode: %s (%d)', rigid_modes.get(r.mode, '#Invalid'), r.mode)
logger.debug(' Related bone: %s', r.bone)
logger.debug(' Collision group: %d', r.collision_group_number)
logger.debug(' Collision group mask: 0x%x', r.collision_group_mask)
logger.debug(' Size: (%f, %f, %f)', *r.size)
logger.debug(' Location: (%f, %f, %f)', *r.location)
logger.debug(' Rotation: (%f, %f, %f)', *r.rotation)
logger.debug(' Mass: %f', r.mass)
logger.debug(' Bounce: %f', r.bounce)
logger.debug(' Friction: %f', r.friction)
logger.debug('')
logging.info('----- Loaded %d rigid bodies.', len(self.rigids))
logger.info('----- Loaded %d rigid bodies.', len(self.rigids))
logging.info('')
logging.info('------------------------------')
logging.info(' Load Joints')
logging.info('------------------------------')
logger.info('')
logger.info('------------------------------')
logger.info(' Load Joints')
logger.info('------------------------------')
num_joints = fs.readInt()
self.joints = []
for i in range(num_joints):
@@ -553,19 +553,19 @@ class Model:
j.load(fs)
self.joints.append(j)
logging.info('Joint %d: %s', i, j.name)
logging.debug(' Name(english): %s', j.name_e)
logging.debug(' Rigid A: %s', j.src_rigid)
logging.debug(' Rigid B: %s', j.dest_rigid)
logging.debug(' Location: (%f, %f, %f)', *j.location)
logging.debug(' Rotation: (%f, %f, %f)', *j.rotation)
logging.debug(' Location Limit: (%f, %f, %f) - (%f, %f, %f)', *(j.minimum_location + j.maximum_location))
logging.debug(' Rotation Limit: (%f, %f, %f) - (%f, %f, %f)', *(j.minimum_rotation + j.maximum_rotation))
logging.debug(' Spring: (%f, %f, %f)', *j.spring_constant)
logging.debug(' Spring(rotation): (%f, %f, %f)', *j.spring_rotation_constant)
logging.debug('')
logger.info('Joint %d: %s', i, j.name)
logger.debug(' Name(english): %s', j.name_e)
logger.debug(' Rigid A: %s', j.src_rigid)
logger.debug(' Rigid B: %s', j.dest_rigid)
logger.debug(' Location: (%f, %f, %f)', *j.location)
logger.debug(' Rotation: (%f, %f, %f)', *j.rotation)
logger.debug(' Location Limit: (%f, %f, %f) - (%f, %f, %f)', *(j.minimum_location + j.maximum_location))
logger.debug(' Rotation Limit: (%f, %f, %f) - (%f, %f, %f)', *(j.minimum_rotation + j.maximum_rotation))
logger.debug(' Spring: (%f, %f, %f)', *j.spring_constant)
logger.debug(' Spring(rotation): (%f, %f, %f)', *j.spring_rotation_constant)
logger.debug('')
logging.info('----- Loaded %d joints.', len(self.joints))
logger.info('----- Loaded %d joints.', len(self.joints))
def save(self, fs):
fs.writeStr(self.name)
@@ -574,7 +574,7 @@ class Model:
fs.writeStr(self.comment)
fs.writeStr(self.comment_e)
logging.info('''exportings pmx model data...
logger.info('''exportings pmx model data...
name: %s
name(english): %s
comment:
@@ -583,62 +583,62 @@ comment(english):
%s
''', self.name, self.name_e, self.comment, self.comment_e)
logging.info('exporting vertices... %d', len(self.vertices))
logger.info('exporting vertices... %d', len(self.vertices))
fs.writeInt(len(self.vertices))
for i in self.vertices:
i.save(fs)
logging.info('finished exporting vertices.')
logger.info('finished exporting vertices.')
logging.info('exporting faces... %d', len(self.faces))
logger.info('exporting faces... %d', len(self.faces))
fs.writeInt(len(self.faces)*3)
for f3, f2, f1 in self.faces:
fs.writeVertexIndex(f1)
fs.writeVertexIndex(f2)
fs.writeVertexIndex(f3)
logging.info('finished exporting faces.')
logger.info('finished exporting faces.')
logging.info('exporting textures... %d', len(self.textures))
logger.info('exporting textures... %d', len(self.textures))
fs.writeInt(len(self.textures))
for i in self.textures:
i.save(fs)
logging.info('finished exporting textures.')
logger.info('finished exporting textures.')
logging.info('exporting materials... %d', len(self.materials))
logger.info('exporting materials... %d', len(self.materials))
fs.writeInt(len(self.materials))
for i in self.materials:
i.save(fs)
logging.info('finished exporting materials.')
logger.info('finished exporting materials.')
logging.info('exporting bones... %d', len(self.bones))
logger.info('exporting bones... %d', len(self.bones))
fs.writeInt(len(self.bones))
for i in self.bones:
i.save(fs)
logging.info('finished exporting bones.')
logger.info('finished exporting bones.')
logging.info('exporting morphs... %d', len(self.morphs))
logger.info('exporting morphs... %d', len(self.morphs))
fs.writeInt(len(self.morphs))
for i in self.morphs:
i.save(fs)
logging.info('finished exporting morphs.')
logger.info('finished exporting morphs.')
logging.info('exporting display items... %d', len(self.display))
logger.info('exporting display items... %d', len(self.display))
fs.writeInt(len(self.display))
for i in self.display:
i.save(fs)
logging.info('finished exporting display items.')
logger.info('finished exporting display items.')
logging.info('exporting rigid bodies... %d', len(self.rigids))
logger.info('exporting rigid bodies... %d', len(self.rigids))
fs.writeInt(len(self.rigids))
for i in self.rigids:
i.save(fs)
logging.info('finished exporting rigid bodies.')
logger.info('finished exporting rigid bodies.')
logging.info('exporting joints... %d', len(self.joints))
logger.info('exporting joints... %d', len(self.joints))
fs.writeInt(len(self.joints))
for i in self.joints:
i.save(fs)
logging.info('finished exporting joints.')
logging.info('finished exporting the model.')
logger.info('finished exporting joints.')
logger.info('finished exporting the model.')
def __repr__(self):
@@ -803,7 +803,7 @@ class Texture:
except ValueError:
relPath = self.path
relPath = relPath.replace(os.path.sep, '\\') # always save using windows path conventions
logging.info('writing to pmx file the relative texture path: %s', relPath)
logger.info('writing to pmx file the relative texture path: %s', relPath)
fs.writeStr(relPath)
class SharedTexture(Texture):
@@ -1170,7 +1170,7 @@ class Morph:
name = fs.readStr()
name_e = fs.readStr()
logging.debug('morph: %s', name)
logger.debug('morph: %s', name)
category = fs.readSignedByte()
typeIndex = fs.readSignedByte()
ret = _CLASSES[typeIndex](name, name_e, category, type_index = typeIndex)
@@ -1399,7 +1399,7 @@ class Display:
else:
raise Exception('invalid value.')
self.data.append((disp_type, index))
logging.debug('the number of display elements: %d', len(self.data))
logger.debug('the number of display elements: %d', len(self.data))
def save(self, fs):
fs.writeStr(self.name)
@@ -1595,12 +1595,12 @@ class Joint:
def load(path):
with FileReadStream(path) as fs:
logging.info('****************************************')
logging.info(' mmd_tools.pmx module')
logging.info('----------------------------------------')
logging.info(' Start to load model data form a pmx file')
logging.info(' by the mmd_tools.pmx modlue.')
logging.info('')
logger.info('****************************************')
logger.info(' mmd_tools.pmx module')
logger.info('----------------------------------------')
logger.info(' Start to load model data form a pmx file')
logger.info(' by the mmd_tools.pmx modlue.')
logger.info('')
header = Header()
header.load(fs)
fs.setHeader(header)
@@ -1608,12 +1608,12 @@ def load(path):
try:
model.load(fs)
except struct.error as e:
logging.error(' * Corrupted file: %s', e)
logger.error(' * Corrupted file: %s', e)
#raise
logging.info(' Finished loading.')
logging.info('----------------------------------------')
logging.info(' mmd_tools.pmx module')
logging.info('****************************************')
logger.info(' Finished loading.')
logger.info('----------------------------------------')
logger.info(' mmd_tools.pmx module')
logger.info('****************************************')
return model
def save(path, model, add_uv_count=0):
+69 -32
View File
@@ -6,6 +6,7 @@
# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit.
import collections
import math
import os
import time
from typing import TYPE_CHECKING, List, Optional, Dict, Tuple, Set, Callable, Any, Union, FrozenSet, Iterator
@@ -103,7 +104,7 @@ class PMXImporter:
obj_name = self.__safe_name(bpy.path.display_name(pmxModel.filepath), max_length=54)
logger.info(f"Creating objects for model: {obj_name}")
self.__rig = Model.create(pmxModel.name, pmxModel.name_e, self.__scale or 1.0, obj_name)
self.__rig = Model.create(pmxModel.name, pmxModel.name_e, self.__scale, obj_name)
root = self.__rig.rootObject()
mmd_root: 'MMDRoot' = root.mmd_root
self.__root = root
@@ -192,7 +193,7 @@ class PMXImporter:
mesh: Mesh = self.__meshObj.data
mesh.vertices.add(count=vertex_count)
mesh.vertices.foreach_set("co", tuple(i for pv in pmx_vertices for i in (Vector(pv.co).xzy * (self.__scale or 1.0))))
mesh.vertices.foreach_set("co", tuple(i for pv in pmx_vertices for i in (Vector(pv.co).xzy * self.__scale)))
vertex_group_table = self.__vertexGroupTable
if not vertex_group_table:
@@ -249,9 +250,9 @@ class PMXImporter:
for i, pv in self.__sdefVertices.items():
w = pv.weight.weights
sdefC.data[i].co = Vector(w.c).xzy * (self.__scale or 1.0)
sdefR0.data[i].co = Vector(w.r0).xzy * (self.__scale or 1.0)
sdefR1.data[i].co = Vector(w.r1).xzy * (self.__scale or 1.0)
sdefC.data[i].co = Vector(w.c).xzy * self.__scale
sdefR0.data[i].co = Vector(w.r0).xzy * self.__scale
sdefR1.data[i].co = Vector(w.r1).xzy * self.__scale
logger.debug(f"Stored {len(self.__sdefVertices)} SDEF vertices in shape keys")
@@ -290,13 +291,13 @@ class PMXImporter:
# Create bones
for i in pmx_bones:
bone = data.edit_bones.new(name=i.name)
loc = _VectorXZY(i.location) * (self.__scale or 1.0)
loc = _VectorXZY(i.location) * self.__scale
bone.head = loc
editBoneTable.append(bone)
nameTable.append(bone.name)
# Set parent relationships
for i, (b_bone, m_bone) in enumerate(zip(editBoneTable, pmx_bones)):
for i, (b_bone, m_bone) in enumerate(zip(editBoneTable, pmx_bones, strict=False)):
if m_bone.parent != -1:
if i not in dependency_cycle_ik_bones:
b_bone.parent = editBoneTable[m_bone.parent]
@@ -304,18 +305,18 @@ class PMXImporter:
b_bone.parent = editBoneTable[m_bone.parent].parent
# Set tail positions
for b_bone, m_bone in zip(editBoneTable, pmx_bones):
for b_bone, m_bone in zip(editBoneTable, pmx_bones, strict=False):
if isinstance(m_bone.displayConnection, int):
if m_bone.displayConnection != -1:
b_bone.tail = editBoneTable[m_bone.displayConnection].head
else:
b_bone.tail = b_bone.head
else:
loc = _VectorXZY(m_bone.displayConnection) * (self.__scale or 1.0)
loc = _VectorXZY(m_bone.displayConnection) * self.__scale
b_bone.tail = b_bone.head + loc
# Check and fix IK links
for b_bone, m_bone in zip(editBoneTable, pmx_bones):
for b_bone, m_bone in zip(editBoneTable, pmx_bones, strict=False):
if m_bone.isIK and m_bone.target != -1:
logger.debug(f"Checking IK links of {b_bone.name}")
b_target = editBoneTable[m_bone.target]
@@ -333,30 +334,30 @@ class PMXImporter:
b_bone_link.tail = b_bone_link.head + loc
# Fix too short bones
for b_bone, m_bone in zip(editBoneTable, pmx_bones):
for b_bone, m_bone in zip(editBoneTable, pmx_bones, strict=False):
# Set the length of too short bones to 1 because Blender delete them.
if b_bone.length < 0.001:
if not self.__apply_bone_fixed_axis and m_bone.axis is not None:
fixed_axis = Vector(m_bone.axis)
if fixed_axis.length:
b_bone.tail = b_bone.head + fixed_axis.xzy.normalized() * (self.__scale or 1.0)
b_bone.tail = b_bone.head + fixed_axis.xzy.normalized() * self.__scale
else:
b_bone.tail = b_bone.head + Vector((0, 0, 1)) * (self.__scale or 1.0)
b_bone.tail = b_bone.head + Vector((0, 0, 1)) * self.__scale
else:
b_bone.tail = b_bone.head + Vector((0, 0, 1)) * (self.__scale or 1.0)
b_bone.tail = b_bone.head + Vector((0, 0, 1)) * self.__scale
if m_bone.displayConnection != -1 and m_bone.displayConnection != [0.0, 0.0, 0.0]:
logger.debug(f"Special tip bone {b_bone.name}, display {str(m_bone.displayConnection)}")
specialTipBones.append(b_bone.name)
# Update bone roll
for b_bone, m_bone in zip(editBoneTable, pmx_bones):
for b_bone, m_bone in zip(editBoneTable, pmx_bones, strict=False):
if m_bone.localCoordinate is not None:
FnBone.update_bone_roll(b_bone, m_bone.localCoordinate.x_axis, m_bone.localCoordinate.z_axis)
elif FnBone.has_auto_local_axis(m_bone.name):
FnBone.update_auto_bone_roll(b_bone)
# Set bone connections
for b_bone, m_bone in zip(editBoneTable, pmx_bones):
for b_bone, m_bone in zip(editBoneTable, pmx_bones, strict=False):
if isinstance(m_bone.displayConnection, int) and m_bone.displayConnection >= 0:
t = editBoneTable[m_bone.displayConnection]
if t.parent is None or t.parent != b_bone:
@@ -534,7 +535,8 @@ class PMXImporter:
elif b_bone.name in specialTipBones:
mmd_bone.is_tip = True
b_bone.bone.hide = not pmx_bone.visible # or mmd_bone.is_tip
# Blender 5.0: use pose bone hide for Pose/Object mode visibility
b_bone.hide = not pmx_bone.visible # or mmd_bone.is_tip
if not pmx_bone.isRotatable:
b_bone.lock_rotation = [True, True, True]
@@ -589,7 +591,7 @@ class PMXImporter:
)
for i, (rigid, rigid_obj) in enumerate(zip(self.__model.rigids, rigid_pool)):
loc = Vector(rigid.location).xzy * (self.__scale or 1.0)
loc = Vector(rigid.location).xzy * self.__scale
rot = Vector(rigid.rotation).xzy * -1
size = Vector(rigid.size).xzy if rigid.type == pmx.Rigid.TYPE_BOX else Vector(rigid.size)
@@ -598,7 +600,7 @@ class PMXImporter:
shape_type=rigid.type,
location=loc,
rotation=rot,
size=size * (self.__scale or 1.0),
size=size * self.__scale,
dynamics_type=rigid.mode,
name=rigid.name,
name_e=rigid.name_e,
@@ -636,7 +638,7 @@ class PMXImporter:
)
for i, (joint, joint_obj) in enumerate(zip(self.__model.joints, joint_pool)):
loc = Vector(joint.location).xzy * (self.__scale or 1.0)
loc = Vector(joint.location).xzy * self.__scale
rot = Vector(joint.rotation).xzy * -1
obj = FnRigidBody.setup_joint_object(
@@ -647,8 +649,8 @@ class PMXImporter:
rotation=rot,
rigid_a=self.__rigidTable.get(joint.src_rigid, None),
rigid_b=self.__rigidTable.get(joint.dest_rigid, None),
maximum_location=Vector(joint.maximum_location).xzy * (self.__scale or 1.0),
minimum_location=Vector(joint.minimum_location).xzy * (self.__scale or 1.0),
maximum_location=Vector(joint.maximum_location).xzy * self.__scale,
minimum_location=Vector(joint.minimum_location).xzy * self.__scale,
maximum_rotation=Vector(joint.minimum_rotation).xzy * -1,
minimum_rotation=Vector(joint.maximum_rotation).xzy * -1,
spring_linear=Vector(joint.spring_constant).xzy,
@@ -752,7 +754,7 @@ class PMXImporter:
uv_layer.data.foreach_set("uv", tuple(v for i in loop_indices_orig for v in uv_table[i]))
if hasattr(mesh, "uv_textures"):
for bf, mi in zip(uv_tex.data, material_indices):
for bf, mi in zip(uv_tex.data, material_indices, strict=False):
bf.image = self.__imageTable.get(mi, None)
if pmxModel.header and pmxModel.header.additional_uvs:
@@ -829,14 +831,18 @@ class PMXImporter:
logger.debug(f"Found {len(vertex_morphs)} vertex morphs")
for morph in vertex_morphs:
shapeKey = self.__meshObj.shape_key_add(name=morph.name)
shapeKey = self.__meshObj.shape_key_add(name=morph.name, from_mix=False)
shapeKey.value = 0.0 # Set shape key value to 0 (inactive) on import
vtx_morph = mmd_root.vertex_morphs.add()
vtx_morph.name = morph.name
vtx_morph.name_e = morph.name_e
vtx_morph.category = categories.get(morph.category, "OTHER")
for md in morph.offsets:
shapeKeyPoint = shapeKey.data[md.index]
shapeKeyPoint.co += Vector(md.offset).xzy * (self.__scale or 1.0)
if md.index < len(shapeKey.data):
shapeKeyPoint = shapeKey.data[md.index]
shapeKeyPoint.co += Vector(md.offset).xzy * self.__scale
else:
logger.warning(f"Morph {morph.name} has out-of-range vertex index: {md.index}")
logger.debug(f"Imported vertex morph: {morph.name} with {len(morph.offsets)} offsets")
def __importMaterialMorphs(self) -> None:
@@ -897,7 +903,7 @@ class PMXImporter:
data = bone_morph.data.add()
bl_bone = self.__boneTable[morph_data.index]
data.bone = bl_bone.name
converter = BoneConverter(bl_bone, self.__scale or 1.0)
converter = BoneConverter(bl_bone, self.__scale)
data.location = converter.convert_location(morph_data.location_offset)
data.rotation = converter.convert_rotation(morph_data.rotation_offset)
valid_offsets += 1
@@ -1000,12 +1006,19 @@ class PMXImporter:
armModifier = meshObj.modifiers.new(name="Armature", type="ARMATURE")
armModifier.object = armObj
armModifier.use_vertex_groups = True
armModifier.name = "mmd_bone_order_override"
armModifier.show_render = armModifier.show_viewport = len(meshObj.data.vertices) > 0
armModifier.name = "mmd_armature"
logger.debug("Armature modifier added")
def __assignCustomNormals(self) -> None:
"""Assign custom normals to the mesh"""
# NOTE: This uses the older Blender API instead of the newer mesh.attributes approach
# because it requires "INT16_2D" format for proper functionality.
# Manual calculation of normals in INT16_2D format is overly complex.
# The newer implementation was removed in commit [ad47b9a] due to these issues.
# The current implementation uses normals_split_custom_set() with 179-degree sharp edge
# marking as a workaround. While not ideal, this remains the most practical solution
# for preserving custom normals in most cases.
if not self.__meshObj or not self.__model:
logger.error("Mesh object or model not created")
return
@@ -1013,17 +1026,41 @@ class PMXImporter:
mesh: Mesh = self.__meshObj.data
logger.info("Setting custom normals...")
# CRITICAL: Mark sharp edges (based on angle) BEFORE setting custom normals
# For mesh.normals_split_custom_set() to work as expected, two conditions must be met:
# 1. The normal vectors must be non-zero (mentioned in Blender documentation)
# 2. Some edges must be marked as sharp (NOT mentioned in Blender documentation)
# An angle of 179 degrees is confirmed to be sufficient to preserve all custom normals.
# 180 degrees does not work because it misses some sharp edges required for normals_split_custom_set to work 100% correctly.
current_mode = bpy.context.active_object.mode if bpy.context.active_object else 'OBJECT'
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.object.select_all(action="DESELECT")
bpy.context.view_layer.objects.active = self.__meshObj
# Mark sharp edges
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="DESELECT")
bpy.ops.mesh.edges_select_sharp(sharpness=math.radians(179))
bpy.ops.mesh.mark_sharp()
bpy.ops.object.mode_set(mode="OBJECT")
# Logging
total_edges = len(mesh.edges)
sharp_edges = sum(1 for edge in mesh.edges if edge.use_edge_sharp)
percentage = (sharp_edges / total_edges) * 100 if total_edges > 0 else 0
logger.info(f" - Marked {sharp_edges}/{total_edges} ({percentage:.2f}%) sharp edges with angle: 179 degrees")
if self.__vertex_map:
verts, faces = self.__model.vertices, self.__model.faces
custom_normals = [(Vector(verts[i].normal).xzy).normalized() for f in faces for i in f]
mesh.normals_split_custom_set(custom_normals)
logger.debug(f"Set {len(custom_normals)} custom normals using face data")
else:
custom_normals = [(Vector(v.normal).xzy).normalized() for v in self.__model.vertices]
mesh.normals_split_custom_set_from_vertices(custom_normals)
logger.debug(f"Set {len(custom_normals)} custom normals from vertices")
logger.info("Custom normals set successfully")
bpy.ops.object.mode_set(mode=current_mode)
logger.info(" - Done!!")
# Continue without custom normals - mesh will use auto-calculated normals
def __renameLRBones(self, use_underscore: bool) -> None:
"""Rename bones with left/right naming convention"""
+19 -44
View File
@@ -1,17 +1,13 @@
# -*- 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.
# This file is part of MMD Tools.
from typing import List, Optional, Tuple, Union, Dict, Any, Set, cast
from ....core.logging_setup import logger
from typing import List, Optional
import bpy
from mathutils import Euler, Vector, Matrix
from mathutils import Euler, Vector
from ..bpyutils import FnContext, Props
from ....core.logging_setup import logger
SHAPE_SPHERE = 0
SHAPE_BOX = 1
@@ -22,30 +18,25 @@ MODE_DYNAMIC = 1
MODE_DYNAMIC_BONE = 2
def shapeType(collision_shape: str) -> int:
"""Convert collision shape name to type index"""
def shapeType(collision_shape):
return ("SPHERE", "BOX", "CAPSULE").index(collision_shape)
def collisionShape(shape_type: int) -> str:
"""Convert shape type index to collision shape name"""
def collisionShape(shape_type):
return ("SPHERE", "BOX", "CAPSULE")[shape_type]
def setRigidBodyWorldEnabled(enable: bool) -> bool:
"""Enable or disable the rigid body world and return previous state"""
def setRigidBodyWorldEnabled(enable):
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] = [
COLORS = [
0x7FDDD4,
0xF0E68C,
0xEE82EE,
@@ -65,12 +56,10 @@ class RigidBodyMaterial:
]
@classmethod
def getMaterial(cls, number: int) -> bpy.types.Material:
"""Get or create a material for rigid bodies with the specified number"""
def getMaterial(cls, number):
number = int(number)
material_name = f"mmd_tools_rigid_{number}"
material_name = "mmd_tools_rigid_%d" % (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)]
@@ -97,11 +86,9 @@ class RigidBodyMaterial:
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:
@@ -111,8 +98,6 @@ class FnRigidBody:
@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"
@@ -130,11 +115,11 @@ class FnRigidBody:
@staticmethod
def setup_rigid_body_object(
obj: bpy.types.Object,
shape_type: int,
shape_type: str,
location: Vector,
rotation: Euler,
size: Vector,
dynamics_type: int,
dynamics_type: str,
collision_group_number: Optional[int] = None,
collision_group_mask: Optional[List[bool]] = None,
name: Optional[str] = None,
@@ -146,8 +131,6 @@ class FnRigidBody:
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
@@ -189,35 +172,31 @@ class FnRigidBody:
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"""
def get_rigid_body_size(obj: bpy.types.Object):
assert obj.mmd_type == "RIGID_BODY"
x0, y0, z0 = obj.bound_box[0]
x1, y1, z1 = obj.bound_box[6]
assert x1 >= x0 and y1 >= y0 and z1 >= z0
if not (x1 >= x0 and y1 >= y0 and z1 >= z0):
logger.warning(f"Rigid body '{obj.name}' has invalid bounding box coordinates, using default size")
return (1.0, 1.0, 1.0)
shape = obj.mmd_rigid.shape
if shape == "SPHERE":
radius = (z1 - z0) / 2
return (radius, 0.0, 0.0)
elif shape == "BOX":
if shape == "BOX":
x, y, z = (x1 - x0) / 2, (y1 - y0) / 2, (z1 - z0) / 2
return (x, y, z)
elif shape == "CAPSULE":
if 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)
raise ValueError(f"Invalid shape type: {shape}")
@staticmethod
def new_joint_object(context: bpy.types.Context, parent_object: bpy.types.Object, empty_display_size: float) -> bpy.types.Object:
"""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"
@@ -249,11 +228,9 @@ class FnRigidBody:
@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:
@@ -277,8 +254,6 @@ class FnRigidBody:
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
+55 -80
View File
@@ -1,52 +1,42 @@
# -*- 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.
# Copyright 2018 MMD Tools authors
# This file is part of MMD Tools.
import logging
from ....core.logging_setup import logger
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 mathutils import Matrix, Vector
from ..bpyutils import FnObject
from ....core.logging_setup import logger
T = TypeVar('T')
def _hash(v: Union[Object, PoseBone, Pose]) -> int:
def _hash(v):
if isinstance(v, (bpy.types.Object, bpy.types.PoseBone)):
return hash(type(v).__name__ + v.name)
elif isinstance(v, bpy.types.Pose):
if isinstance(v, bpy.types.Pose):
return hash(type(v).__name__ + v.id_data.name)
else:
raise NotImplementedError("hash")
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"
g_verts = {} # global cache
g_shapekey_data = {}
g_bone_check = {}
__g_armature_check = {}
SHAPEKEY_NAME = "mmd_sdef_skinning"
MASK_NAME = "mmd_sdef_mask"
def __init__(self) -> None:
def __init__(self):
raise NotImplementedError("not allowed")
@classmethod
def __init_cache(cls, obj: Object, shapekey: ShapeKey) -> bool:
def __init_cache(cls, obj, shapekey):
key = _hash(obj)
obj = getattr(obj, "original", obj)
mod = obj.modifiers.get("mmd_bone_order_override")
mod = obj.modifiers.get("mmd_armature")
key_armature = _hash(mod.object.pose) if mod and mod.type == "ARMATURE" and mod.object else None
if key not in cls.g_verts or cls.__g_armature_check.get(key) != key_armature:
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
@@ -55,7 +45,7 @@ class FnSDEF:
return False
@classmethod
def __check_bone_update(cls, obj: Object, bone0: PoseBone, bone1: PoseBone) -> bool:
def __check_bone_update(cls, obj, bone0, bone1):
check = cls.g_bone_check[_hash(obj)]
key = (_hash(bone0), _hash(bone1))
if key not in check or (bone0.matrix, bone1.matrix) != check[key]:
@@ -64,21 +54,20 @@ class FnSDEF:
return False
@classmethod
def mute_sdef_set(cls, obj: Object, mute: bool) -> None:
def mute_sdef_set(cls, obj, mute):
key_blocks = getattr(obj.data.shape_keys, "key_blocks", ())
if cls.SHAPEKEY_NAME in key_blocks:
shapekey = key_blocks[cls.SHAPEKEY_NAME]
shapekey.mute = mute
if cls.has_sdef_data(obj):
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:
def __sdef_muted(cls, obj, shapekey):
mute = shapekey.mute
if mute != cls.g_bone_check[_hash(obj)].get("sdef_mute"):
mod = obj.modifiers.get("mmd_bone_order_override")
mod = obj.modifiers.get("mmd_armature")
if mod and mod.type == "ARMATURE":
if not mute and cls.MASK_NAME not in obj.vertex_groups and obj.mode != "EDIT":
mask = tuple(i for v in cls.g_verts[_hash(obj)].values() for i in v[3])
@@ -87,33 +76,32 @@ class FnSDEF:
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")
def has_sdef_data(obj):
if obj is None or not hasattr(obj, "modifiers") or not hasattr(obj, "data") or obj.data is None:
return False
mod = obj.modifiers.get("mmd_armature")
if mod and mod.type == "ARMATURE" and mod.object:
kb = getattr(obj.data.shape_keys, "key_blocks", None)
return kb and "mmd_sdef_c" in kb and "mmd_sdef_r0" in kb and "mmd_sdef_r1" in kb
return False
@classmethod
def __find_vertices(cls, obj: Object) -> Dict[Tuple[int, int], Tuple[PoseBone, PoseBone, List[Tuple[int, float, float, Vector, Vector, Vector]], List[int]]]:
def __find_vertices(cls, obj):
if not cls.has_sdef_data(obj):
logger.debug(f"SDEF vertex search skipped for '{obj.name}': No SDEF data found")
return {}
vertices: 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}
vertices = {}
pose_bones = obj.modifiers.get("mmd_armature").object.pose.bones
bone_map = {g.index: pose_bones[g.name] for g in obj.vertex_groups if g.name in pose_bones}
sdef_c = obj.data.shape_keys.key_blocks["mmd_sdef_c"].data
sdef_r0 = obj.data.shape_keys.key_blocks["mmd_sdef_r0"].data
sdef_r1 = obj.data.shape_keys.key_blocks["mmd_sdef_r1"].data
vd = obj.data.vertices
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
@@ -122,7 +110,7 @@ class FnSDEF:
# preprocessing
w0, w1 = bgs[0].weight, bgs[1].weight
# w0 + w1 == 1
w0 = w0 / (w0 + w1)
w0 /= (w0 + w1)
w1 = 1 - w0
c, r0, r1 = sdef_c[i].co, sdef_r0[i].co, sdef_r1[i].co
@@ -136,19 +124,22 @@ class FnSDEF:
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:
def driver_function_wrap(cls, obj_name, bulk_update, use_skip, use_scale):
if obj_name not in bpy.data.objects:
logger.warning(f"SDEF driver wrap: Object '{obj_name}' not found")
return 0.0
obj = bpy.data.objects[obj_name]
shapekey = obj.data.shape_keys.key_blocks[cls.SHAPEKEY_NAME]
return cls.driver_function(shapekey, obj_name, bulk_update, use_skip, use_scale)
@classmethod
def driver_function(cls, shapekey: ShapeKey, obj_name: str, bulk_update: bool, use_skip: bool, use_scale: bool) -> float:
def driver_function(cls, shapekey, obj_name, bulk_update, use_skip, use_scale):
if obj_name not in bpy.data.objects:
logger.warning(f"SDEF driver: Object '{obj_name}' not found, driver will be inactive")
return 0.0
obj = bpy.data.objects[obj_name]
if getattr(shapekey.id_data, "is_evaluated", False):
# For Blender 2.8x, we should use evaluated object, and the only reference is the "obj" variable of SDEF driver
@@ -159,7 +150,7 @@ class FnSDEF:
if cls.__sdef_muted(obj, shapekey):
return 0.0
pose_bones = obj.modifiers.get("mmd_bone_order_override").object.pose.bones
pose_bones = obj.modifiers.get("mmd_armature").object.pose.bones
if not bulk_update:
shapekey_data = shapekey.data
if use_scale:
@@ -200,8 +191,6 @@ class FnSDEF:
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)
@@ -220,15 +209,15 @@ class FnSDEF:
rot1 = -rot1
s0, s1 = mat0.to_scale(), mat1.to_scale()
def scale(mat_rot: Matrix, w0: float, w1: float) -> Matrix:
def scale(mat_rot, w0, w1, s0, s1):
s = s0 * w0 + s1 * w1
return mat_rot @ Matrix([(s[0], 0, 0), (0, s[1], 0), (0, 0, s[2])])
def offset(mat_rot: Matrix, pos_c: Vector, vid: int) -> Vector:
def offset(mat_rot, pos_c, vid):
delta = sum(((key.data[vid].co - key.relative_key.data[vid].co) * key.value for key in key_blocks), Vector()) # assuming key.vertex_group = ''
return (mat_rot @ (pos_c + delta)) - delta
shapekey_data[vids] = [offset(scale((rot0 * w0 + rot1 * w1).normalized().to_matrix(), w0, w1), pos_c, vid) + (mat0 @ cr0) * w0 + (mat1 @ cr1) * w1 for vid, w0, w1, pos_c, cr0, cr1 in sdef_data]
shapekey_data[vids] = [offset(scale((rot0 * w0 + rot1 * w1).normalized().to_matrix(), w0, w1, s0, s1), pos_c, vid) + (mat0 @ cr0) * w0 + (mat1 @ cr1) * w1 for vid, w0, w1, pos_c, cr0, cr1 in sdef_data]
else:
# bulk update
for bone0, bone1, sdef_data, vids in cls.g_verts[_hash(obj)].values():
@@ -247,19 +236,16 @@ class FnSDEF:
return 1.0 # shapkey value
@classmethod
def register_driver_function(cls) -> None:
"""Register driver functions in Blender's driver namespace."""
def register_driver_function(cls):
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
BENCH_LOOP = 10
@classmethod
def __get_benchmark_result(cls, obj: Object, shapkey: ShapeKey, use_scale: bool, use_skip: bool) -> bool:
def __get_benchmark_result(cls, obj, shapkey, use_scale, use_skip):
# warmed up
cls.driver_function(shapkey, obj.name, bulk_update=True, use_skip=False, use_scale=use_scale)
cls.driver_function(shapkey, obj.name, bulk_update=False, use_skip=False, use_scale=use_scale)
@@ -273,15 +259,15 @@ class FnSDEF:
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}")
logger.info("FnSDEF:benchmark: default %.4f vs bulk_update %.4f => bulk_update=%s", default_time, bulk_time, result)
return result
@classmethod
def bind(cls, obj: Object, bulk_update: Optional[bool] = None, use_skip: bool = True, use_scale: bool = False) -> bool:
def bind(cls, obj, bulk_update=None, use_skip=True, use_scale=False):
# Unbind first
cls.unbind(obj)
if not cls.has_sdef_data(obj):
logger.debug(f"Object {obj.name} does not have SDEF data")
logger.debug(f"SDEF bind skipped for '{obj.name}': No SDEF data found")
return False
# Create the shapekey for the driver
shapekey = obj.shape_key_add(name=cls.SHAPEKEY_NAME, from_mix=False)
@@ -300,50 +286,41 @@ class FnSDEF:
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")
mod = obj.modifiers.get("mmd_armature")
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
for name in {data[i].name for data in cls.g_verts[_hash(obj)].values() for i in range(2)}: # add required bones for dependency graph
var = variables.new()
var.type = "TRANSFORMS"
var.targets[0].id = mod.object
var.targets[0].bone_target = name
f.driver.use_self = True
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}")
f.driver.expression = f"mmd_sdef_driver(self, obj, bulk_update={bulk_update}, use_skip={use_skip}, use_scale={use_scale})"
return True
@classmethod
def unbind(cls, obj: Object) -> None:
def unbind(cls, obj):
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:
def clear_cache(cls, obj=None, unused_only=False):
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:
valid_keys = {_hash(i) for i in bpy.data.objects if i.type == "MESH" and i != obj}
for key in cls.g_verts.keys() - valid_keys:
del cls.g_verts[key]
for key in cls.g_shapekey_data.keys() - cls.g_verts.keys():
del cls.g_shapekey_data[key]
for key in cls.g_bone_check.keys() - cls.g_verts.keys():
del cls.g_bone_check[key]
logger.debug(f"Cleared {len(removed_keys)} unused SDEF cache entries")
elif obj:
key = _hash(obj)
if key in cls.g_verts:
@@ -352,9 +329,7 @@ class FnSDEF:
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 = {}
+43 -66
View File
@@ -1,37 +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.
# Copyright 2019 MMD Tools authors
# This file is part of MMD Tools.
from typing import Optional, Tuple, cast
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):
def __init__(self, shader: bpy.types.ShaderNodeTree):
self.shader = shader
self.nodes: bpy.types.bpy_prop_collection[ShaderNode] = shader.nodes # type: ignore
self.nodes: bpy.types.bpy_prop_collection[bpy.types.ShaderNode] = shader.nodes # type: ignore[assignment]
self.links = shader.links
def _find_node(self, node_type: str) -> Optional[ShaderNode]:
def _find_node(self, node_type: str) -> Optional[bpy.types.ShaderNode]:
return next((n for n in self.nodes if n.bl_idname == node_type), None)
def new_node(self, idname: str, pos: Tuple[int, int]) -> ShaderNode:
node: ShaderNode = self.nodes.new(idname)
def new_node(self, idname: str, pos: Tuple[int, int]) -> bpy.types.ShaderNode:
node: bpy.types.ShaderNode = self.nodes.new(idname)
node.location = (pos[0] * 210, pos[1] * 220)
return node
def new_math_node(self, operation: str, pos: Tuple[int, int], value1: Optional[float] = None, value2: Optional[float] = None) -> ShaderNode:
def new_math_node(self, operation, pos, value1=None, value2=None):
node = self.new_node("ShaderNodeMath", pos)
node.operation = operation
if value1 is not None:
@@ -40,7 +29,7 @@ class _NodeTreeUtils:
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:
def new_vector_math_node(self, operation, pos, vector1=None, vector2=None):
node = self.new_node("ShaderNodeVectorMath", pos)
node.operation = operation
if vector1 is not None:
@@ -49,7 +38,7 @@ class _NodeTreeUtils:
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:
def new_mix_node(self, blend_type, pos, fac=None, color1=None, color2=None):
node = self.new_node("ShaderNodeMixRGB", pos)
node.blend_type = blend_type
if fac is not None:
@@ -61,30 +50,30 @@ class _NodeTreeUtils:
return node
SOCKET_TYPE_MAPPING: Dict[str, str] = {"NodeSocketFloatFactor": "NodeSocketFloat"}
SOCKET_TYPE_MAPPING = {"NodeSocketFloatFactor": "NodeSocketFloat"}
SOCKET_SUBTYPE_MAPPING: Dict[str, str] = {"NodeSocketFloatFactor": "FACTOR"}
SOCKET_SUBTYPE_MAPPING = {"NodeSocketFloatFactor": "FACTOR"}
class _NodeGroupUtils(_NodeTreeUtils):
def __init__(self, shader: ShaderNodeTree):
def __init__(self, shader: bpy.types.ShaderNodeTree):
super().__init__(shader)
self.__node_input: Optional[NodeGroupInput] = None
self.__node_output: Optional[NodeGroupOutput] = None
self.__node_input: Optional[bpy.types.NodeGroupInput] = None
self.__node_output: Optional[bpy.types.NodeGroupOutput] = None
@property
def node_input(self) -> NodeGroupInput:
def node_input(self) -> bpy.types.NodeGroupInput:
if not self.__node_input:
self.__node_input = cast(NodeGroupInput, self._find_node("NodeGroupInput") or self.new_node("NodeGroupInput", (-2, 0)))
self.__node_input = cast("bpy.types.NodeGroupInput", self._find_node("NodeGroupInput") or self.new_node("NodeGroupInput", (-2, 0)))
return self.__node_input
@property
def node_output(self) -> NodeGroupOutput:
def node_output(self) -> bpy.types.NodeGroupOutput:
if not self.__node_output:
self.__node_output = cast(NodeGroupOutput, self._find_node("NodeGroupOutput") or self.new_node("NodeGroupOutput", (2, 0)))
self.__node_output = cast("bpy.types.NodeGroupOutput", self._find_node("NodeGroupOutput") or self.new_node("NodeGroupOutput", (2, 0)))
return self.__node_output
def hide_nodes(self, hide_sockets: bool = True) -> None:
def hide_nodes(self, hide_sockets=True):
skip_nodes = {self.__node_input, self.__node_output}
for n in (x for x in self.nodes if x not in skip_nodes):
n.hide = True
@@ -95,22 +84,22 @@ class _NodeGroupUtils(_NodeTreeUtils):
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:
def new_input_socket(self, io_name, socket, default_val=None, min_max=None, socket_type=None):
self.__new_io("INPUT", self.node_input.outputs, io_name, socket, default_val, min_max, socket_type)
def new_output_socket(self, io_name: 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:
def new_output_socket(self, io_name, socket, default_val=None, min_max=None, socket_type=None):
self.__new_io("OUTPUT", self.node_output.inputs, io_name, socket, default_val, min_max, socket_type)
def __new_io(self, in_out: 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:
def __new_io(self, in_out, io_sockets, io_name, socket, default_val=None, min_max=None, socket_type=None):
if io_name not in io_sockets:
idname = socket_type or (socket.bl_idname if socket else "NodeSocketFloat")
idname = socket_type or socket.bl_idname
interface_socket = self.shader.interface.new_socket(name=io_name, in_out=in_out, socket_type=SOCKET_TYPE_MAPPING.get(idname, idname))
if idname in SOCKET_SUBTYPE_MAPPING:
interface_socket.subtype = SOCKET_SUBTYPE_MAPPING.get(idname, "")
if not min_max:
if idname.endswith("Factor") or io_name.endswith("Alpha"):
interface_socket.min_value, interface_socket.max_value = 0, 1
elif idname.endswith("Float") or idname.endswith("Vector"):
elif idname.endswith(("Float", "Vector")):
interface_socket.min_value, interface_socket.max_value = -10, 10
if socket is not None:
self.links.new(io_sockets[io_name], socket)
@@ -122,18 +111,14 @@ class _NodeGroupUtils(_NodeTreeUtils):
class _MaterialMorph:
@classmethod
def update_morph_inputs(cls, material: Optional[Material], morph: Any) -> None:
"""Update material morph inputs based on morph data"""
def update_morph_inputs(cls, material, morph):
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"""
def setup_morph_nodes(cls, material, morphs):
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)
@@ -149,25 +134,23 @@ class _MaterialMorph:
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}")
def reset_morph_links(cls, node):
cls.__update_morph_links(node, reset=True)
@classmethod
def __update_morph_links(cls, node: ShaderNode, reset: bool = False) -> None:
def __update_morph_links(cls, node, reset=False):
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):
if any(link.from_node.name.startswith("mmd_bind") for i in node.inputs for link in i.links):
return
def __init_link(socket_morph: bpy.types.NodeSocket, socket_shader: Optional[bpy.types.NodeSocket]) -> None:
def __init_link(socket_morph, socket_shader):
if socket_shader and socket_morph.is_linked:
links.new(socket_morph.links[0].from_socket, socket_shader)
else:
def __init_link(socket_morph: bpy.types.NodeSocket, socket_shader: Optional[bpy.types.NodeSocket]) -> None:
def __init_link(socket_morph, socket_shader):
if socket_shader:
if socket_shader.is_linked:
links.new(socket_shader.links[0].from_socket, socket_morph)
@@ -192,8 +175,7 @@ class _MaterialMorph:
__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"""
def __update_node_inputs(cls, node, morph):
node.inputs["Ambient2"].default_value[:3] = morph.ambient_color[:3]
node.inputs["Diffuse2"].default_value[:3] = morph.diffuse_color[:3]
node.inputs["Specular2"].default_value[:3] = morph.specular_color[:3]
@@ -211,8 +193,7 @@ class _MaterialMorph:
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"""
def __morph_node_add(cls, material, morph, prev_node):
nodes, links = material.node_tree.nodes, material.node_tree.links
shader = nodes.get("mmd_shader", None)
@@ -237,9 +218,8 @@ class _MaterialMorph:
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:
def __soft_link(socket_out, socket_in):
if socket_out and socket_in:
links.new(socket_out, socket_in)
@@ -261,14 +241,12 @@ class _MaterialMorph:
return shader
@classmethod
def __get_shader(cls, morph_type: str) -> ShaderNodeTree:
"""Get or create a shader node group for the specified morph type"""
def __get_shader(cls, morph_type):
group_name = "MMDMorph" + morph_type
shader = bpy.data.node_groups.get(group_name, None) or bpy.data.node_groups.new(name=group_name, type="ShaderNodeTree")
if len(shader.nodes):
return shader
logger.info(f"Creating new shader node group: {group_name}")
ng = _NodeGroupUtils(shader)
links = ng.links
@@ -279,18 +257,18 @@ class _MaterialMorph:
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:
def __blend_color_add(id_name, pos, tag=""):
# MA_RAMP_MULT: ColorMul = Color1 * (Fac * Color2 + (1 - Fac))
# MA_RAMP_ADD: ColorAdd = Color1 + Fac * Color2
# https://github.com/blender/blender/blob/594f47ecd2d5367ca936cf6fc6ec8168c2b360d0/source/blender/blenkernel/intern/material.c#L1400
node_mix = ng.new_mix_node("MULTIPLY" if use_mul else "ADD", (pos[0] + 1, pos[1]))
links.new(node_input.outputs["Fac"], node_mix.inputs["Fac"])
ng.new_input_socket("%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_input_socket(f"{id_name}1" + tag, node_mix.inputs["Color1"])
ng.new_input_socket(f"{id_name}2" + tag, node_mix.inputs["Color2"], socket_type="NodeSocketVector")
ng.new_output_socket(id_name + tag, node_mix.outputs["Color"])
return node_mix
def __blend_tex_color(id_name: str, pos: Tuple[int, int], node_tex_rgb: ShaderNode, node_tex_a_output: bpy.types.NodeSocket) -> None:
def __blend_tex_color(id_name, pos, node_tex_rgb, node_tex_a_output):
# Tex Color = tex_rgb * tex_a + (1 - tex_a)
# : tex_rgb = TexRGB * ColorMul + ColorAdd
# : tex_a = TexA * ValueMul + ValueAdd
@@ -313,7 +291,7 @@ class _MaterialMorph:
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:
def __add_sockets(id_name, input1, input2, output, tag=""):
ng.new_input_socket(f"{id_name}1{tag}", input1, use_mul)
ng.new_input_socket(f"{id_name}2{tag}", input2, use_mul)
ng.new_output_socket(f"{id_name}{tag}", output)
@@ -362,5 +340,4 @@ class _MaterialMorph:
__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
+56 -81
View File
@@ -1,9 +1,5 @@
# -*- 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.
# Copyright 2021 MMD Tools authors
# This file is part of MMD Tools.
import itertools
import re
@@ -33,11 +29,7 @@ class MMDTranslationElementType(Enum):
class MMDDataHandlerABC(ABC):
@classmethod
@property
@abstractmethod
def type_name(cls) -> str:
pass
type_name: str
@classmethod
@abstractmethod
@@ -67,7 +59,8 @@ class MMDDataHandlerABC(ABC):
@classmethod
@abstractmethod
def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]:
"""Returns (name, name_j, name_e)"""
"""Return (name, name_j, name_e)"""
pass
@classmethod
def is_restorable(cls, mmd_translation_element: "MMDTranslationElement") -> bool:
@@ -75,7 +68,7 @@ class MMDDataHandlerABC(ABC):
@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
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):
@@ -86,7 +79,7 @@ class MMDDataHandlerABC(ABC):
row.label(text="", icon="BLANK1")
return
op = row.operator("mmd_tools.restore_mmd_translation_element_name", text="", icon="FILE_REFRESH")
op = row.operator("mmd_tools_local.restore_mmd_translation_element_name", text="", icon="FILE_REFRESH")
op.index = index
op.prop_name = prop_name
op.restore_value = original_value
@@ -100,10 +93,7 @@ class MMDDataHandlerABC(ABC):
class MMDBoneHandler(MMDDataHandlerABC):
@classmethod
@property
def type_name(cls) -> str:
return MMDTranslationElementType.BONE.name
type_name = MMDTranslationElementType.BONE.name
@classmethod
def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int):
@@ -114,18 +104,18 @@ class MMDBoneHandler(MMDDataHandlerABC):
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")
row.prop(pose_bone.bone, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if pose_bone.select else "RESTRICT_SELECT_ON")
row.prop(pose_bone.bone, "hide", text="", emboss=False, icon_only=True)
@classmethod
def collect_data(cls, mmd_translation: "MMDTranslation"):
armature_object: bpy.types.Object = FnModel.find_armature_object(mmd_translation.id_data)
pose_bone: bpy.types.PoseBone
for index, pose_bone in enumerate(armature_object.pose.bones):
if not any(c.is_visible for c in pose_bone.bone.collections):
if pose_bone.bone.hide or (pose_bone.bone.collections and not any(c.is_visible for c in pose_bone.bone.collections)):
continue
mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add()
mmd_translation_element: 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}]"
@@ -140,14 +130,14 @@ class MMDBoneHandler(MMDDataHandlerABC):
@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"
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):
if cls.check_data_visible(filter_selected, filter_visible, pose_bone.select, pose_bone.bone.hide):
continue
if check_blank_name(mmd_translation_element.name_j, mmd_translation_element.name_e):
@@ -156,7 +146,7 @@ class MMDBoneHandler(MMDDataHandlerABC):
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: MMDTranslationElementIndex = mmd_translation.filtered_translation_element_indices.add()
mmd_translation_element_index.value = index
@classmethod
@@ -176,14 +166,11 @@ class MMDBoneHandler(MMDDataHandlerABC):
class MMDMorphHandler(MMDDataHandlerABC):
@classmethod
@property
def type_name(cls) -> str:
return MMDTranslationElementType.MORPH.name
type_name = MMDTranslationElementType.MORPH.name
@classmethod
def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int):
morph: "_MorphBase" = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path)
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()
@@ -198,7 +185,7 @@ class MMDMorphHandler(MMDDataHandlerABC):
@classmethod
def collect_data(cls, mmd_translation: "MMDTranslation"):
root_object: bpy.types.Object = mmd_translation.id_data
mmd_root: "MMDRoot" = root_object.mmd_root
mmd_root: MMDRoot = root_object.mmd_root
for morphs_name, morphs in {
"material_morphs": mmd_root.material_morphs,
@@ -207,9 +194,9 @@ class MMDMorphHandler(MMDDataHandlerABC):
"vertex_morphs": mmd_root.vertex_morphs,
"group_morphs": mmd_root.group_morphs,
}.items():
morph: "_MorphBase"
morph: _MorphBase
for index, morph in enumerate(morphs):
mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add()
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}]"
@@ -228,24 +215,24 @@ class MMDMorphHandler(MMDDataHandlerABC):
@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"
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)
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: 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)
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:
@@ -253,15 +240,12 @@ class MMDMorphHandler(MMDDataHandlerABC):
@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)
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
type_name = MMDTranslationElementType.MATERIAL.name
@classmethod
def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int):
@@ -274,7 +258,7 @@ class MMDMaterialHandler(MMDDataHandlerABC):
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")
row.prop(mesh_object, "hide", text="", emboss=False, icon_only=True)
MATERIAL_DATA_PATH_EXTRACT = re.compile(r"data\.materials\[(?P<index>\d*)\]")
@@ -293,7 +277,7 @@ class MMDMaterialHandler(MMDDataHandlerABC):
if not hasattr(material, "mmd_material"):
continue
mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add()
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}]"
@@ -314,7 +298,7 @@ class MMDMaterialHandler(MMDDataHandlerABC):
@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"
mmd_translation_element: MMDTranslationElement
for index, mmd_translation_element in enumerate(mmd_translation.translation_elements):
if mmd_translation_element.type != MMDTranslationElementType.MATERIAL.name:
continue
@@ -330,7 +314,7 @@ class MMDMaterialHandler(MMDDataHandlerABC):
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: MMDTranslationElementIndex = mmd_translation.filtered_translation_element_indices.add()
mmd_translation_element_index.value = index
@classmethod
@@ -350,10 +334,7 @@ class MMDMaterialHandler(MMDDataHandlerABC):
class MMDDisplayHandler(MMDDataHandlerABC):
@classmethod
@property
def type_name(cls) -> str:
return MMDTranslationElementType.DISPLAY.name
type_name = MMDTranslationElementType.DISPLAY.name
@classmethod
def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int):
@@ -366,7 +347,7 @@ class MMDDisplayHandler(MMDDataHandlerABC):
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")
row.prop(mmd_translation_element.object, "hide", text="", emboss=False, icon_only=True)
DISPLAY_DATA_PATH_EXTRACT = re.compile(r"data\.collections\[(?P<index>\d*)\]")
@@ -375,7 +356,7 @@ class MMDDisplayHandler(MMDDataHandlerABC):
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: 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}]"
@@ -396,7 +377,7 @@ class MMDDisplayHandler(MMDDataHandlerABC):
@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"
mmd_translation_element: MMDTranslationElement
for index, mmd_translation_element in enumerate(mmd_translation.translation_elements):
if mmd_translation_element.type != MMDTranslationElementType.DISPLAY.name:
continue
@@ -412,7 +393,7 @@ class MMDDisplayHandler(MMDDataHandlerABC):
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: MMDTranslationElementIndex = mmd_translation.filtered_translation_element_indices.add()
mmd_translation_element_index.value = index
@classmethod
@@ -428,10 +409,7 @@ class MMDDisplayHandler(MMDDataHandlerABC):
class MMDPhysicsHandler(MMDDataHandlerABC):
@classmethod
@property
def type_name(cls) -> str:
return MMDTranslationElementType.PHYSICS.name
type_name = MMDTranslationElementType.PHYSICS.name
@classmethod
def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int):
@@ -451,7 +429,7 @@ class MMDPhysicsHandler(MMDDataHandlerABC):
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")
row.prop(obj, "hide", text="", emboss=False, icon_only=True)
@classmethod
def collect_data(cls, mmd_translation: "MMDTranslation"):
@@ -460,7 +438,7 @@ class MMDPhysicsHandler(MMDDataHandlerABC):
obj: bpy.types.Object
for obj in model.rigidBodies():
mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add()
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"
@@ -470,7 +448,7 @@ class MMDPhysicsHandler(MMDDataHandlerABC):
obj: bpy.types.Object
for obj in model.joints():
mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add()
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"
@@ -484,7 +462,7 @@ class MMDPhysicsHandler(MMDDataHandlerABC):
@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"
mmd_translation_element: MMDTranslationElement
for index, mmd_translation_element in enumerate(mmd_translation.translation_elements):
if mmd_translation_element.type != MMDTranslationElementType.PHYSICS.name:
continue
@@ -504,7 +482,7 @@ class MMDPhysicsHandler(MMDDataHandlerABC):
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: MMDTranslationElementIndex = mmd_translation.filtered_translation_element_indices.add()
mmd_translation_element_index.value = index
@classmethod
@@ -536,10 +514,7 @@ class MMDPhysicsHandler(MMDDataHandlerABC):
class MMDInfoHandler(MMDDataHandlerABC):
@classmethod
@property
def type_name(cls) -> str:
return MMDTranslationElementType.INFO.name
type_name = MMDTranslationElementType.INFO.name
TYPE_TO_ICONS = {
"EMPTY": "EMPTY_DATA",
@@ -557,7 +532,7 @@ class MMDInfoHandler(MMDDataHandlerABC):
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")
row.prop(info_object, "hide", text="", emboss=False, icon_only=True)
@classmethod
def collect_data(cls, mmd_translation: "MMDTranslation"):
@@ -568,7 +543,7 @@ class MMDInfoHandler(MMDDataHandlerABC):
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: MMDTranslationElement = mmd_translation.translation_elements.add()
mmd_translation_element.type = MMDTranslationElementType.INFO.name
mmd_translation_element.object = info_object
mmd_translation_element.data_path = ""
@@ -582,7 +557,7 @@ class MMDInfoHandler(MMDDataHandlerABC):
@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"
mmd_translation_element: MMDTranslationElement
for index, mmd_translation_element in enumerate(mmd_translation.translation_elements):
if mmd_translation_element.type != MMDTranslationElementType.INFO.name:
continue
@@ -597,7 +572,7 @@ class MMDInfoHandler(MMDDataHandlerABC):
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: MMDTranslationElementIndex = mmd_translation.filtered_translation_element_indices.add()
mmd_translation_element_index.value = index
@classmethod
@@ -627,10 +602,10 @@ MMD_DATA_TYPE_TO_HANDLERS: Dict[str, MMDDataHandlerABC] = {h.type_name: h for h
class FnTranslations:
@staticmethod
def apply_translations(root_object: bpy.types.Object):
mmd_translation: "MMDTranslation" = root_object.mmd_root.translation
mmd_translation_element_index: "MMDTranslationElementIndex"
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]
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(
@@ -642,7 +617,7 @@ class FnTranslations:
@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
mmd_translation: MMDTranslation = root_object.mmd_root.translation
batch_operation_script = mmd_translation.batch_operation_script
if not batch_operation_script:
return ({}, None)
@@ -657,9 +632,9 @@ class FnTranslations:
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"
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]
mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements[mmd_translation_element_index.value]
handler: MMDDataHandlerABC = MMD_DATA_TYPE_TO_HANDLERS[mmd_translation_element.type]
@@ -684,7 +659,7 @@ class FnTranslations:
"org_name_j": org_name_j,
"org_name_e": org_name_e,
},
)
),
)
if batch_operation_target == "BLENDER":
@@ -701,8 +676,8 @@ class FnTranslations:
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_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)
@@ -724,7 +699,7 @@ class FnTranslations:
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
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:
+57 -35
View File
@@ -5,12 +5,13 @@
# 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
from .....core.logging_setup import logger
import math
import os
from typing import Union
import bpy
from bpy_extras import anim_utils
from mathutils import Quaternion, Vector
from ... import utils
@@ -260,7 +261,7 @@ 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))
logger.debug(str(self.__vmdFile.header))
self.__scale = scale
self.__convert_mmd_camera = convert_mmd_camera
self.__convert_mmd_lamp = convert_mmd_lamp
@@ -300,21 +301,31 @@ class VMDImporter:
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)
def __get_channelbag(action: bpy.types.Action, target_id=None):
"""Get or create channelbag for action using Blender 5.0 API."""
if not action.slots:
slot = action.slots.new(for_id=target_id)
else:
slot = action.slots[0]
return anim_utils.action_ensure_channelbag_for_slot(action, slot)
@staticmethod
def __keyframe_insert_inner(action: bpy.types.Action, path: str, index: int, frame: float, value: float, target_id=None, group_name=None):
channelbag = VMDImporter.__get_channelbag(action, target_id)
fcurve = channelbag.fcurves.find(path, index=index)
if fcurve is None:
fcurve = fcurves.new(path, index=index)
fcurve = channelbag.fcurves.new(path, index=index, group_name=group_name)
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]):
def __keyframe_insert(action: bpy.types.Action, path: str, frame: float, value: Union[int, float, Vector], target_id=None, group_name=None):
if isinstance(value, (int, float)):
VMDImporter.__keyframe_insert_inner(fcurves, path, 0, frame, value)
VMDImporter.__keyframe_insert_inner(action, path, 0, frame, value, target_id, group_name)
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])
VMDImporter.__keyframe_insert_inner(action, path, 0, frame, value[0], target_id, group_name)
VMDImporter.__keyframe_insert_inner(action, path, 1, frame, value[1], target_id, group_name)
VMDImporter.__keyframe_insert_inner(action, path, 2, frame, value[2], target_id, group_name)
else:
raise TypeError("Unsupported type: {0}".format(type(value)))
@@ -370,7 +381,7 @@ class VMDImporter:
def __assignToArmature(self, armObj, action_name=None):
boneAnim = self.__vmdFile.boneAnimation
logging.info("---- bone animations:%5d target: %s", len(boneAnim), armObj.name)
logger.info("---- bone animations:%5d target: %s", len(boneAnim), armObj.name)
if len(boneAnim) < 1:
return
@@ -401,22 +412,25 @@ class VMDImporter:
continue
bone = pose_bones.get(name, None)
if bone is None:
logging.warning("WARNING: not found bone %s (%d frames)", name, len(keyFrames))
logger.warning("WARNING: not found bone %s (%d frames)", name, len(keyFrames))
continue
logging.info("(bone) frames:%5d name: %s", len(keyFrames), name)
logger.info("(bone) frames:%5d name: %s", len(keyFrames), name)
assert bone_name_table.get(bone.name, name) == name
bone_name_table[bone.name] = name
# Get channelbag for this action
channelbag = self.__get_channelbag(action, armObj.data)
fcurves = [dummy_keyframe_points] * 7 # x, y, z, r0, r1, r2, (r3)
data_path_rot = prop_rot_map.get(bone.rotation_mode, "rotation_euler")
bone_rotation = getattr(bone, data_path_rot)
default_values = list(bone.location) + list(bone_rotation)
data_path = 'pose.bones["%s"].location' % bone.name
for axis_i in range(3):
fcurves[axis_i] = action.fcurves.new(data_path=data_path, index=axis_i, action_group=bone.name)
fcurves[axis_i] = channelbag.fcurves.new(data_path=data_path, index=axis_i, group_name=bone.name)
data_path = 'pose.bones["%s"].%s' % (bone.name, data_path_rot)
for axis_i in range(len(bone_rotation)):
fcurves[3 + axis_i] = action.fcurves.new(data_path=data_path, index=axis_i, action_group=bone.name)
fcurves[3 + axis_i] = channelbag.fcurves.new(data_path=data_path, index=axis_i, group_name=bone.name)
for i in range(len(default_values)):
c = fcurves[i]
@@ -458,15 +472,17 @@ class VMDImporter:
self.__setInterpolation(interp[idx : idx + 16 : 4], prev_kp, kp)
prev_kps = curr_kps
for c in action.fcurves:
# Get channelbag to iterate fcurves
channelbag = self.__get_channelbag(action, armObj.data)
for c in channelbag.fcurves:
self.__fixFcurveHandles(c)
# property animation
propertyAnim = self.__vmdFile.propertyAnimation
if len(propertyAnim) > 0:
logging.info("---- IK animations:%5d target: %s", len(propertyAnim), armObj.name)
logger.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)
logger.debug("(IK) frame:%5d list: %s", keyFrame.frame_number, keyFrame.ik_states)
frame = keyFrame.frame_number + self.__frame_margin
for ikName, enable in keyFrame.ik_states:
bone = pose_bones.get(ikName, None)
@@ -500,7 +516,7 @@ class VMDImporter:
def __assignToMesh(self, meshObj, action_name=None):
shapeKeyAnim = self.__vmdFile.shapeKeyAnimation
logging.info("---- morph animations:%5d target: %s", len(shapeKeyAnim), meshObj.name)
logger.info("---- morph animations:%5d target: %s", len(shapeKeyAnim), meshObj.name)
if len(shapeKeyAnim) < 1:
return
@@ -514,11 +530,12 @@ class VMDImporter:
for name, keyFrames in shapeKeyAnim.items():
if name not in shapeKeyDict:
logging.warning("WARNING: not found shape key %s (%d frames)", name, len(keyFrames))
logger.warning("WARNING: not found shape key %s (%d frames)", name, len(keyFrames))
continue
logging.info("(mesh) frames:%5d name: %s", len(keyFrames), name)
logger.info("(mesh) frames:%5d name: %s", len(keyFrames), name)
shapeKey = shapeKeyDict[name]
fcurve = action.fcurves.new(data_path='key_blocks["%s"].value' % shapeKey.name)
channelbag = self.__get_channelbag(action, meshObj.data.shape_keys)
fcurve = channelbag.fcurves.new(data_path='key_blocks["%s"].value' % shapeKey.name)
fcurve.keyframe_points.add(len(keyFrames))
keyFrames.sort(key=lambda x: x.frame_number)
for k, v in zip(keyFrames, fcurve.keyframe_points):
@@ -532,16 +549,16 @@ class VMDImporter:
def __assignToRoot(self, rootObj, action_name=None):
propertyAnim = self.__vmdFile.propertyAnimation
logging.info("---- display animations:%5d target: %s", len(propertyAnim), rootObj.name)
logger.info("---- display animations:%5d target: %s", len(propertyAnim), rootObj.name)
if len(propertyAnim) < 1:
return
action_name = action_name or rootObj.name
action = bpy.data.actions.new(name=action_name)
logging.debug("(Display) list(frame, show): %s", [(keyFrame.frame_number, bool(keyFrame.visible)) for keyFrame in propertyAnim])
logger.debug("(Display) list(frame, show): %s", [(keyFrame.frame_number, bool(keyFrame.visible)) for keyFrame in propertyAnim])
for keyFrame in propertyAnim:
self.__keyframe_insert(action.fcurves, "mmd_root.show_meshes", keyFrame.frame_number + self.__frame_margin, float(keyFrame.visible))
self.__keyframe_insert(action, "mmd_root.show_meshes", keyFrame.frame_number + self.__frame_margin, float(keyFrame.visible), rootObj)
self.__assign_action(rootObj, action)
@@ -562,7 +579,7 @@ class VMDImporter:
cameraObj = mmdCameraInstance.camera()
cameraAnim = self.__vmdFile.cameraAnimation
logging.info("(camera) frames:%5d name: %s", len(cameraAnim), mmdCamera.name)
logger.info("(camera) frames:%5d name: %s", len(cameraAnim), mmdCamera.name)
if len(cameraAnim) < 1:
return
@@ -574,14 +591,18 @@ class VMDImporter:
if self.__mirror:
_loc, _rot = _MirrorMapper.get_location, _MirrorMapper.get_rotation3
# Get channelbags for camera actions
parent_channelbag = self.__get_channelbag(parent_action, mmdCamera.parent)
distance_channelbag = self.__get_channelbag(distance_action, mmdCamera.distance)
fcurves = []
for i in range(3):
fcurves.append(parent_action.fcurves.new(data_path="location", index=i)) # x, y, z
fcurves.append(parent_channelbag.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
fcurves.append(parent_channelbag.fcurves.new(data_path="rotation_euler", index=i)) # rx, ry, rz
fcurves.append(parent_channelbag.fcurves.new(data_path="mmd_camera.angle")) # fov
fcurves.append(parent_channelbag.fcurves.new(data_path="mmd_camera.is_perspective")) # persp
fcurves.append(distance_channelbag.fcurves.new(data_path="location", index=1)) # dis
for c in fcurves:
c.keyframe_points.add(len(cameraAnim))
@@ -629,7 +650,7 @@ class VMDImporter:
lampObj = mmdLampInstance.lamp()
lampAnim = self.__vmdFile.lampAnimation
logging.info("(lamp) frames:%5d name: %s", len(lampAnim), mmdLamp.name)
logger.info("(lamp) frames:%5d name: %s", len(lampAnim), mmdLamp.name)
if len(lampAnim) < 1:
return
@@ -640,10 +661,11 @@ class VMDImporter:
_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)
self.__keyframe_insert(color_action, "color", frame, Vector(keyFrame.color), lampObj)
self.__keyframe_insert(location_action, "location", frame, Vector(_loc(keyFrame.direction)).xzy * -1, mmdLamp)
for fcurve in location_action.fcurves:
location_channelbag = self.__get_channelbag(location_action, mmdLamp)
for fcurve in location_channelbag.fcurves:
self.detectLampChange(fcurve)
self.__assign_action(lampObj.data, color_action)
+44 -61
View File
@@ -1,48 +1,39 @@
# -*- 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.
# Copyright 2012 MMD Tools authors
# This file is part of MMD Tools.
from typing import Iterable, Optional, Any, List, Tuple, Union
from typing import Iterable, Optional
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
from .core.shader import _NodeGroupUtils
def __switchToCyclesRenderEngine() -> None:
def __switchToCyclesRenderEngine():
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:
def __exposeNodeTreeInput(in_socket, name, default_value, node_input, shader):
_NodeGroupUtils(shader).new_input_socket(name, in_socket, default_value)
def __exposeNodeTreeOutput(out_socket: NodeSocket, name: str, node_output: Node, shader: NodeTree) -> None:
def __exposeNodeTreeOutput(out_socket, name, node_output, shader):
_NodeGroupUtils(shader).new_output_socket(name, out_socket)
def __getMaterialOutput(nodes: bpy.types.Nodes, bl_idname: str) -> Node:
def __getMaterialOutput(nodes, bl_idname):
o = next((n for n in nodes if n.bl_idname == bl_idname and n.is_active_output), None) or nodes.new(bl_idname)
o.is_active_output = True
return o
def create_MMDAlphaShader() -> NodeTree:
def create_MMDAlphaShader():
__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")
@@ -64,28 +55,26 @@ def create_MMDAlphaShader() -> NodeTree:
return shader
def create_MMDBasicShader() -> NodeTree:
def create_MMDBasicShader():
__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")
shader: bpy.types.ShaderNodeTree = bpy.data.node_groups.new(name="MMDBasicShader", type="ShaderNodeTree")
node_input: Node = shader.nodes.new("NodeGroupInput")
node_output: Node = shader.nodes.new("NodeGroupOutput")
node_input: bpy.types.NodeGroupInput = shader.nodes.new("NodeGroupInput")
node_output: bpy.types.NodeGroupOutput = shader.nodes.new("NodeGroupOutput")
node_output.location.x += 250
node_input.location.x -= 500
dif: Node = shader.nodes.new("ShaderNodeBsdfDiffuse")
dif: bpy.types.ShaderNodeBsdfDiffuse = shader.nodes.new("ShaderNodeBsdfDiffuse")
dif.location.x -= 250
dif.location.y += 150
glo: Node = shader.nodes.new("ShaderNodeBsdfAnisotropic")
glo: bpy.types.ShaderNodeBsdfAnisotropic = shader.nodes.new("ShaderNodeBsdfAnisotropic")
glo.location.x -= 250
glo.location.y -= 150
mix: Node = shader.nodes.new("ShaderNodeMixShader")
mix: bpy.types.ShaderNodeMixShader = shader.nodes.new("ShaderNodeMixShader")
shader.links.new(mix.inputs[1], dif.outputs["BSDF"])
shader.links.new(mix.inputs[2], glo.outputs["BSDF"])
@@ -98,65 +87,62 @@ def create_MMDBasicShader() -> NodeTree:
return shader
def __enum_linked_nodes(node: Node) -> Iterable[Node]:
def __enum_linked_nodes(node: bpy.types.Node) -> Iterable[bpy.types.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):
for n in {link.from_node for i in node.inputs for link in i.links}:
yield from __enum_linked_nodes(n)
def __cleanNodeTree(material: Material) -> None:
logger.debug(f"Cleaning node tree for material: {material.name}")
def __cleanNodeTree(material: bpy.types.Material):
nodes = material.node_tree.nodes
node_names = set(n.name for n in nodes)
node_names = {n.name for n in nodes}
for o in (n for n in nodes if n.bl_idname in {"ShaderNodeOutput", "ShaderNodeOutputMaterial"}):
if any(i.is_linked for i in o.inputs):
node_names -= set(linked.name for linked in __enum_linked_nodes(o))
node_names -= {linked.name for linked in __enum_linked_nodes(o)}
for name in node_names:
nodes.remove(nodes[name])
def convertToCyclesShader(obj: bpy.types.Object, use_principled: 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})")
def convertToCyclesShader(obj: bpy.types.Object, use_principled=False, clean_nodes=False, subsurface=0.001):
__switchToCyclesRenderEngine()
convertToBlenderShader(obj, use_principled, clean_nodes, subsurface)
def convertToBlenderShader(obj: bpy.types.Object, use_principled: bool = False, clean_nodes: bool = False, subsurface: float = 0.001) -> None:
def convertToBlenderShader(obj: bpy.types.Object, use_principled=False, clean_nodes=False, subsurface=0.001):
for i in obj.material_slots:
if not i.material:
continue
# use_nodes is deprecated in 5.0 but always returns True and setting it is safe
if not i.material.use_nodes:
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:
def convertToMMDShader(obj):
"""BSDF -> MMDShaderDev conversion."""
logger.info(f"Converting {obj.name} to MMD shader")
for i in obj.material_slots:
if not i.material:
continue
# use_nodes is deprecated in 5.0 but always returns True and setting it is safe
if not i.material.use_nodes:
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}")
def __convertToMMDBasicShader(material: bpy.types.Material):
# TODO: test me
mmd_basic_shader_grp = create_MMDBasicShader()
mmd_alpha_shader_grp = create_MMDAlphaShader()
if not any(filter(lambda x: isinstance(x, ShaderNodeGroup) and x.node_tree.name in {"MMDBasicShader", "MMDAlphaShader"}, material.node_tree.nodes)):
if not any(filter(lambda x: isinstance(x, bpy.types.ShaderNodeGroup) and x.node_tree.name in {"MMDBasicShader", "MMDAlphaShader"}, material.node_tree.nodes)):
# Add nodes for Cycles Render
shader: ShaderNodeGroup = material.node_tree.nodes.new("ShaderNodeGroup")
shader: bpy.types.ShaderNodeGroup = material.node_tree.nodes.new("ShaderNodeGroup")
shader.node_tree = mmd_basic_shader_grp
shader.inputs[0].default_value[:3] = material.diffuse_color[:3]
shader.inputs[1].default_value[:3] = material.specular_color[:3]
@@ -171,8 +157,7 @@ def __convertToMMDBasicShader(material: Material) -> None:
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: bpy.types.ShaderNodeGroup = material.node_tree.nodes.new("ShaderNodeGroup")
alpha_shader.location.x = shader.location.x + 250
alpha_shader.location.y = shader.location.y - 150
alpha_shader.node_tree = mmd_alpha_shader_grp
@@ -180,22 +165,21 @@ def __convertToMMDBasicShader(material: Material) -> None:
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_output: bpy.types.ShaderNodeOutputMaterial = __getMaterialOutput(material.node_tree.nodes, "ShaderNodeOutputMaterial")
material.node_tree.links.new(material_output.inputs["Surface"], outplug)
material_output.location.x = shader.location.x + 500
material_output.location.y = shader.location.y - 150
def __convertToPrincipledBsdf(material: Material, subsurface: float) -> None:
logger.debug(f"Converting material to Principled BSDF: {material.name}")
def __convertToPrincipledBsdf(material: bpy.types.Material, subsurface: float):
node_names = set()
for s in (n for n in material.node_tree.nodes if isinstance(n, ShaderNodeGroup)):
for s in (n for n in material.node_tree.nodes if isinstance(n, bpy.types.ShaderNodeGroup)):
if s.node_tree.name == "MMDBasicShader":
l: NodeLink
for l in s.outputs[0].links:
to_node = l.to_node
link: bpy.types.NodeLink
for link in s.outputs[0].links:
to_node = link.to_node
# assuming there is no bpy.types.NodeReroute between MMDBasicShader and MMDAlphaShader
if isinstance(to_node, ShaderNodeGroup) and to_node.node_tree.name == "MMDAlphaShader":
if isinstance(to_node, bpy.types.ShaderNodeGroup) and to_node.node_tree.name == "MMDAlphaShader":
__switchToPrincipledBsdf(material.node_tree, s, subsurface, node_alpha=to_node)
node_names.add(to_node.name)
else:
@@ -210,9 +194,8 @@ def __convertToPrincipledBsdf(material: Material, subsurface: float) -> None:
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")
def __switchToPrincipledBsdf(node_tree: bpy.types.NodeTree, node_basic: bpy.types.ShaderNodeGroup, subsurface: float, node_alpha: Optional[bpy.types.ShaderNodeGroup] = None):
shader: bpy.types.ShaderNodeBsdfPrincipled = node_tree.nodes.new("ShaderNodeBsdfPrincipled")
shader.parent = node_basic.parent
shader.location.x = node_basic.location.x
shader.location.y = node_basic.location.y
@@ -240,7 +223,7 @@ def __switchToPrincipledBsdf(node_tree: NodeTree, node_basic: ShaderNodeGroup, s
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
shader.inputs["Alpha"].default_value = node_alpha.inputs["Alpha"].default_value
if node_alpha.inputs[alpha_socket_name].is_linked:
node_tree.links.new(node_alpha.inputs[alpha_socket_name].links[0].from_socket, shader.inputs["Alpha"])
else:
@@ -256,5 +239,5 @@ def __switchToPrincipledBsdf(node_tree: NodeTree, node_basic: ShaderNodeGroup, s
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)
for link in output_links:
node_tree.links.new(shader.outputs[0], link.to_socket)
File diff suppressed because it is too large Load Diff
+142 -98
View File
@@ -1,22 +1,16 @@
# -*- 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.
# This file is part of MMD Tools.
from collections import defaultdict
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 bpy.props import BoolProperty, StringProperty
from bpy.types import Operator
from .. import cycles_converter
from ..core.exceptions import MaterialNotFoundError
from ..core.material import FnMaterial
from ..core.shader import _NodeGroupUtils
from ....core.logging_setup import logger
import traceback
class ConvertMaterialsForCycles(Operator):
@@ -25,14 +19,14 @@ class ConvertMaterialsForCycles(Operator):
bl_description = "Convert materials of selected objects for Cycles."
bl_options = {"REGISTER", "UNDO"}
use_principled: BoolProperty(
use_principled: bpy.props.BoolProperty(
name="Convert to Principled BSDF",
description="Convert MMD shader nodes to Principled BSDF as well if enabled",
default=False,
options={"SKIP_SAVE"},
)
clean_nodes: BoolProperty(
clean_nodes: bpy.props.BoolProperty(
name="Clean Nodes",
description="Remove redundant nodes as well if enabled. Disable it to keep node data.",
default=False,
@@ -40,27 +34,22 @@ class ConvertMaterialsForCycles(Operator):
)
@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 poll(cls, context):
return any(x.type == "MESH" for x in context.selected_objects)
def draw(self, context: Context) -> None:
def draw(self, context):
layout = self.layout
layout.prop(self, "use_principled")
layout.prop(self, "clean_nodes")
def execute(self, context: Context) -> Set[str]:
def execute(self, context):
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"}
@@ -70,21 +59,21 @@ class ConvertMaterials(Operator):
bl_description = "Convert materials of selected objects."
bl_options = {"REGISTER", "UNDO"}
use_principled: BoolProperty(
use_principled: bpy.props.BoolProperty(
name="Convert to Principled BSDF",
description="Convert MMD shader nodes to Principled BSDF as well if enabled",
default=True,
options={"SKIP_SAVE"},
)
clean_nodes: BoolProperty(
clean_nodes: bpy.props.BoolProperty(
name="Clean Nodes",
description="Remove redundant nodes as well if enabled. Disable it to keep node data.",
default=True,
options={"SKIP_SAVE"},
)
subsurface: FloatProperty(
subsurface: bpy.props.FloatProperty(
name="Subsurface",
default=0.001,
soft_min=0.000,
@@ -94,41 +83,130 @@ class ConvertMaterials(Operator):
)
@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 poll(cls, context):
return any(x.type == "MESH" for x in context.selected_objects)
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}")
def execute(self, context):
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'}
class MergeMaterials(Operator):
bl_idname = "mmd_tools.merge_materials"
bl_label = "Merge Materials"
bl_description = "Merge materials with the same texture in selected objects. Only merges materials with exactly one texture node. Materials with no texture or with multiple textures are not merged. Please convert to Blender materials first."
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context: Context) -> bool:
return next((x for x in context.selected_objects if x.type == 'MESH'), None) is not None
def poll(cls, context):
return any(x.type == "MESH" for x in context.selected_objects)
def execute(self, context: Context) -> Set[str]:
logger.info("Converting BSDF materials to MMD shader")
def execute(self, context):
# Process all selected mesh objects
for obj in context.selected_objects:
if obj.type != 'MESH':
if obj.type != "MESH":
continue
self.merge_materials_for_object(context, obj)
return {"FINISHED"}
def merge_materials_for_object(self, context, obj):
"""Merge materials with same texture for a single object"""
if not obj.data.materials:
self.report({"INFO"}, f"Object '{obj.name}' has no materials")
return
# Map texture paths to material indices and names
texture_to_materials = defaultdict(list)
# Check each material
for i, material in enumerate(obj.data.materials):
# use_nodes is deprecated in 5.0 but always returns True, so check is safe
if not material or not material.use_nodes:
continue
# 1. Check texture node count (must be exactly 1)
texture_nodes = [node for node in material.node_tree.nodes if node.type == "TEX_IMAGE"]
if len(texture_nodes) != 1:
continue
# 2. Record texture path and material info
texture_node = texture_nodes[0]
if texture_node.image:
texture_path = bpy.path.abspath(texture_node.image.filepath)
texture_to_materials[texture_path].append({"index": i, "name": material.name})
# Find material groups that need merging
materials_to_merge = {path: materials for path, materials in texture_to_materials.items() if len(materials) > 1}
if not materials_to_merge:
self.report({"INFO"}, f"No materials to merge in object '{obj.name}'")
return
# Process each texture group
context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode="EDIT")
merge_details = []
for texture_path, materials in materials_to_merge.items():
# Use first material as target
target_material = materials[0]
target_index = target_material["index"]
target_name = target_material["name"]
source_materials = []
# Reassign faces from other materials to target material
for source_material in materials[1:]:
source_index = source_material["index"]
source_name = source_material["name"]
source_materials.append(source_name)
bpy.ops.mesh.select_all(action="DESELECT")
obj.active_material_index = source_index
bpy.ops.object.material_slot_select()
obj.active_material_index = target_index
bpy.ops.object.material_slot_assign()
# Record merge details
texture_name = bpy.path.basename(texture_path)
merge_details.append({"texture": texture_name, "target": target_name, "sources": source_materials})
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.object.material_slot_remove_unused()
merged_count = sum(len(details["sources"]) for details in merge_details)
self.report({"INFO"}, f"Object '{obj.name}': Merged {merged_count} materials")
for details in merge_details:
sources_text = ", ".join(details["sources"])
self.report({"INFO"}, f"Same Texture '{details['texture']}': Merged materials [{sources_text}] into '{details['target']}'")
class ConvertBSDFMaterials(Operator):
bl_idname = "mmd_tools.convert_bsdf_materials"
bl_label = "Convert Blender Materials"
bl_description = "Convert materials of selected objects."
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return any(x.type == "MESH" for x in context.selected_objects)
def execute(self, context):
for obj in context.selected_objects:
if obj.type != "MESH":
continue
logger.debug(f"Converting BSDF materials for object: {obj.name}")
cycles_converter.convertToMMDShader(obj)
return {'FINISHED'}
return {"FINISHED"}
class _OpenTextureBase:
"""Create a texture for mmd model material."""
bl_options: Set[str] = {"REGISTER", "UNDO", "INTERNAL"}
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
filepath: StringProperty(
name="File Path",
@@ -142,7 +220,7 @@ class _OpenTextureBase:
options={"HIDDEN"},
)
def invoke(self, context: Context, event: Any) -> Set[str]:
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}
@@ -152,13 +230,8 @@ class OpenTexture(Operator, _OpenTextureBase):
bl_label = "Open Texture"
bl_description = "Create main texture of active material"
def execute(self, context: Context) -> Set[str]:
def execute(self, context):
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"}
@@ -172,13 +245,8 @@ class RemoveTexture(Operator):
bl_description = "Remove main texture of active material"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: Context) -> Set[str]:
def execute(self, context):
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"}
@@ -191,13 +259,8 @@ class OpenSphereTextureSlot(Operator, _OpenTextureBase):
bl_label = "Open Sphere Texture"
bl_description = "Create sphere texture of active material"
def execute(self, context: Context) -> Set[str]:
def execute(self, context):
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"}
@@ -211,13 +274,8 @@ class RemoveSphereTexture(Operator):
bl_description = "Remove sphere texture of active material"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: Context) -> Set[str]:
def execute(self, context):
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"}
@@ -230,21 +288,17 @@ class MoveMaterialUp(Operator):
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context: Context) -> bool:
def poll(cls, context):
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)
return obj is not None and obj.type == "MESH" and obj.mmd_type == "NONE" and obj.active_material_index > 0
def execute(self, context: Context) -> Set[str]:
def execute(self, context):
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
@@ -259,21 +313,17 @@ class MoveMaterialDown(Operator):
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context: Context) -> bool:
def poll(cls, context):
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)
return obj is not None and obj.type == "MESH" and obj.mmd_type == "NONE" and obj.active_material_index < len(obj.material_slots) - 1
def execute(self, context: Context) -> Set[str]:
def execute(self, context):
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
@@ -296,31 +346,26 @@ class EdgePreviewSetup(Operator):
default="CREATE",
)
def execute(self, context: Context) -> Set[str]:
def execute(self, context):
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}")
def __clean_toon_edge(self, obj):
if "mmd_edge_preview" in obj.modifiers:
obj.modifiers.remove(obj.modifiers["mmd_edge_preview"])
@@ -329,8 +374,7 @@ class EdgePreviewSetup(Operator):
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}")
def __create_toon_edge(self, obj, scale=1.0):
self.__clean_toon_edge(obj)
materials = obj.data.materials
material_offset = len(materials)
@@ -355,10 +399,10 @@ class EdgePreviewSetup(Operator):
mod.vertex_group = "mmd_edge_preview"
return len(materials) - material_offset
def __create_edge_preview_group(self, obj: Object) -> None:
def __create_edge_preview_group(self, obj):
vertices, materials = obj.data.vertices, obj.data.materials
weight_map = {i: m.mmd_material.edge_weight for i, m in enumerate(materials) if m}
scale_map: Dict[int, float] = {}
scale_map = {}
vg_scale_index = obj.vertex_groups.find("mmd_edge_scale")
if vg_scale_index >= 0:
scale_map = {v.index: g.weight for v in vertices for g in v.groups if g.group == vg_scale_index}
@@ -367,7 +411,7 @@ class EdgePreviewSetup(Operator):
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:
def __get_edge_material(self, mat_name, edge_color, materials):
if mat_name in materials:
return materials[mat_name]
mat = bpy.data.materials.get(mat_name, None)
@@ -385,7 +429,7 @@ class EdgePreviewSetup(Operator):
self.__make_shader(mat)
return mat
def __make_shader(self, m: Material) -> None:
def __make_shader(self, m):
m.use_nodes = True
nodes, links = m.node_tree.nodes, m.node_tree.links
@@ -406,7 +450,7 @@ class EdgePreviewSetup(Operator):
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:
def __get_edge_preview_shader(self):
group_name = "MMDEdgePreview"
shader = bpy.data.node_groups.get(group_name, None) or bpy.data.node_groups.new(name=group_name, type="ShaderNodeTree")
if len(shader.nodes):
@@ -414,8 +458,8 @@ class EdgePreviewSetup(Operator):
ng = _NodeGroupUtils(shader)
node_input = ng.new_node("NodeGroupInput", (-5, 0))
node_output = ng.new_node("NodeGroupOutput", (3, 0))
ng.new_node("NodeGroupInput", (-5, 0))
ng.new_node("NodeGroupOutput", (3, 0))
############################################################################
node_color = ng.new_node("ShaderNodeMixRGB", (-1, -1.5))
+47 -68
View File
@@ -1,22 +1,15 @@
# -*- 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.
# This file is part of MMD Tools.
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):
@@ -32,8 +25,7 @@ class SelectObject(bpy.types.Operator):
options={"HIDDEN", "SKIP_SAVE"},
)
def execute(self, context: Context) -> Set[str]:
logger.debug(f"Selecting object: {self.name}")
def execute(self, context):
utils.selectAObject(context.scene.objects[self.name])
return {"FINISHED"}
@@ -47,43 +39,41 @@ class MoveObject(bpy.types.Operator, utils.ItemMoveOp):
__PREFIX_REGEXP = re.compile(r"(?P<prefix>[0-9A-Z]{3}_)(?P<name>.*)")
@classmethod
def set_index(cls, obj: Object, index: int) -> None:
def set_index(cls, obj, index):
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)
obj.name = f"{utils.int2base(index, 36, 3)}_{name}"
@classmethod
def get_name(cls, obj: Object, prefix: Optional[str] = None) -> str:
def get_name(cls, obj, prefix=None):
m = cls.__PREFIX_REGEXP.match(obj.name)
name = m.group("name") if m else obj.name
return name[len(prefix) :] if prefix and name.startswith(prefix) else name
@classmethod
def normalize_indices(cls, objects: List[Object]) -> None:
def normalize_indices(cls, objects):
for i, x in enumerate(objects):
cls.set_index(x, i)
@classmethod
def poll(cls, context: Context) -> bool:
def poll(cls, context):
return context.active_object is not None
def execute(self, context: Context) -> Set[str]:
def execute(self, context):
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:
def __get_objects(self, obj):
class __MovableList(list):
def move(self, index_old: int, index_new: int) -> None:
def move(self, index_old, index_new):
item = self[index_old]
self.remove(item)
self.insert(index_new, item)
@@ -108,43 +98,40 @@ class CleanShapeKeys(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context: Context) -> bool:
def poll(cls, context):
return any(o.type == "MESH" for o in context.selected_objects)
@staticmethod
def __can_remove(key_block: ShapeKey) -> bool:
def __can_remove(key_block):
if key_block.relative_key == key_block:
return False # Basis
for v0, v1 in zip(key_block.relative_key.data, key_block.data):
for v0, v1 in zip(key_block.relative_key.data, key_block.data, strict=False):
if v0.co != v1.co:
return False
return True
def __shape_key_clean(self, obj: Object, key_blocks: List[ShapeKey]) -> None:
def __shape_key_clean(self, obj, key_blocks):
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
def execute(self, context):
obj: bpy.types.Object
for obj in context.selected_objects:
if obj.type != "MESH" or obj.data.shape_keys is None:
continue
if not obj.data.shape_keys.use_relative:
continue # not be considered yet
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_label = "Sep by Mat(High Risk)"
bl_description = "Separate by Materials (High Risk)\nSeparate the mesh into multiple objects based on materials.\nHIGH RISK & BUGGY: This operation is not reversible and may cause various issues. It splits adjacent geometry by material, and merging later will not reconnect shared edges.\nKnown issues include potential mesh corruption, UV mapping problems, and other unpredictable behaviors. Use with extreme caution and backup your work first."
bl_options = {"REGISTER", "UNDO"}
clean_shape_keys: bpy.props.BoolProperty(
@@ -153,26 +140,32 @@ class SeparateByMaterials(bpy.types.Operator):
default=True,
)
@classmethod
def poll(cls, context: Context) -> bool:
obj = context.active_object
return obj and obj.type == "MESH"
keep_normals: bpy.props.BoolProperty(
name="Keep Normals",
default=True,
)
def __separate_by_materials(self, obj: Object) -> None:
logger.info(f"Separating {obj.name} by materials")
utils.separateByMaterials(obj)
@classmethod
def poll(cls, context):
obj = context.active_object
return obj is not None and obj.type == "MESH"
def __separate_by_materials(self, obj):
utils.separateByMaterials(obj, self.keep_normals)
if self.clean_shape_keys:
logger.debug("Cleaning shape keys after separation")
bpy.ops.mmd_tools.clean_shape_keys()
def execute(self, context: Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
# Sep by Mat crashes Blender if used after morph assembly
rig = Model(root)
rig.morph_slider.unbind()
if root is None:
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()
@@ -185,11 +178,9 @@ class SeparateByMaterials(bpy.types.Operator):
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"}
@@ -207,15 +198,13 @@ class JoinMeshes(bpy.types.Operator):
default=True,
)
def execute(self, context: Context) -> Set[str]:
def execute(self, context):
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()
@@ -223,11 +212,9 @@ class JoinMeshes(bpy.types.Operator):
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)
@@ -236,19 +223,15 @@ class JoinMeshes(bpy.types.Operator):
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"}
@@ -262,20 +245,17 @@ class AttachMeshesToMMD(bpy.types.Operator):
add_armature_modifier: bpy.props.BoolProperty(default=True)
def execute(self, context: Context) -> Set[str]:
def execute(self, context: bpy.types.Context):
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"}
@@ -295,18 +275,18 @@ class ChangeMMDIKLoopFactor(bpy.types.Operator):
)
@classmethod
def poll(cls, context: Context) -> bool:
return FnModel.find_root_object(context.active_object) is not None
def poll(cls, context):
root = FnModel.find_root_object(context.active_object)
return root is not None
def invoke(self, context: Context, event: Any) -> Set[str]:
def invoke(self, context, event):
root_object = FnModel.find_root_object(context.active_object)
self.mmd_ik_loop_factor = root_object.mmd_root.ik_loop_factor
vm = context.window_manager
return vm.invoke_props_dialog(self)
def execute(self, context: Context) -> Set[str]:
def execute(self, context):
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"}
@@ -318,22 +298,21 @@ class RecalculateBoneRoll(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context: Context) -> bool:
def poll(cls, context):
obj = context.active_object
return obj and obj.type == "ARMATURE"
return obj is not None and obj.type == "ARMATURE"
def invoke(self, context: Context, event: Any) -> Set[str]:
def invoke(self, context, event):
vm = context.window_manager
return vm.invoke_props_dialog(self)
def draw(self, context: Context) -> None:
def draw(self, context):
layout = self.layout
c = layout.column()
c.label(text="This operation will break existing f-curve/action.", icon="QUESTION")
c.label(text="Click [OK] to run the operation.")
def execute(self, context: Context) -> Set[str]:
def execute(self, context):
arm = context.active_object
logger.info(f"Recalculating bone roll for armature: {arm.name}")
FnBone.apply_auto_bone_roll(arm)
return {"FINISHED"}
+240 -139
View File
@@ -1,32 +1,27 @@
# -*- 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.
# Copyright 2022 MMD Tools authors
# This file is part of MMD Tools.
import itertools
from operator import itemgetter
from typing import Dict, List, Optional, Set, Tuple, Any
from typing import Dict, List, Optional, Set
import bmesh
import bpy
import numpy as np
import numpy.typing as npt
from bpy.types import Context, Object, Operator, EditBone, Mesh, Armature
from mathutils import Matrix
from ..bpyutils import FnContext
from ..bpyutils import FnContext, select_object
from ..core.model import FnModel, Model
from ....core.logging_setup import logger
class MessageException(Exception):
"""Class for error with message."""
class NoModelSelectedError(Exception):
"""Raised when no MMD model is selected."""
class ModelJoinByBonesOperator(bpy.types.Operator):
bl_idname = "mmd_tools.model_join_by_bones"
bl_label = "Model Join by Bones"
bl_description = "Join multiple MMD models into one.\n\nWARNING: To align models before joining, only adjust the root (cross under the model) transformation. Do not move armatures, meshes, rigid bodies, or joints directly as they will not move together.\n\nIMPORTANT: Don't use any of the 'Assembly' functions before using this function. This function requires the models to be in a clean state."
bl_options = {"REGISTER", "UNDO"}
join_type: bpy.props.EnumProperty(
@@ -39,8 +34,8 @@ class ModelJoinByBonesOperator(bpy.types.Operator):
)
@classmethod
def poll(cls, context: Context) -> bool:
active_object: Optional[Object] = context.active_object
def poll(cls, context: bpy.types.Context):
active_object: Optional[bpy.types.Object] = context.active_object
if context.mode != "POSE":
return False
@@ -56,22 +51,19 @@ class ModelJoinByBonesOperator(bpy.types.Operator):
return len(context.selected_pose_bones) > 0
def invoke(self, context: Context, event: Any) -> Set[str]:
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
def execute(self, context: Context) -> Set[str]:
def execute(self, context: bpy.types.Context):
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)}")
except NoModelSelectedError as ex:
self.report(type={"ERROR"}, message=str(ex))
return {"CANCELLED"}
return {"FINISHED"}
def join(self, context: Context) -> None:
def join(self, context: bpy.types.Context):
bpy.ops.object.mode_set(mode="OBJECT")
parent_root_object = FnModel.find_root_object(context.active_object)
@@ -79,23 +71,35 @@ class ModelJoinByBonesOperator(bpy.types.Operator):
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")
raise NoModelSelectedError("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)
# Save original active_layer_collection
orig_active_layer_collection = context.view_layer.active_layer_collection
# Find layer collection containing parent_root_object and set it as active
layer_collection = FnContext.find_user_layer_collection_by_object(context, parent_root_object)
if layer_collection:
context.view_layer.active_layer_collection = layer_collection
# Execute the join operation
FnModel.join_models(parent_root_object, child_root_objects)
# Restore original active_layer_collection
context.view_layer.active_layer_collection = orig_active_layer_collection
bpy.ops.object.mode_set(mode="OBJECT")
parent_armature_object = FnModel.find_armature_object(parent_root_object)
FnContext.set_active_and_select_single_object(context, parent_armature_object)
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.armature.parent_set(type="OFFSET")
# Connect child bones
if self.join_type == "CONNECTED":
parent_edit_bone: EditBone = context.active_bone
child_edit_bones: Set[EditBone] = set(context.selected_bones)
parent_edit_bone: bpy.types.EditBone = context.active_bone
child_edit_bones: Set[bpy.types.EditBone] = set(context.selected_bones)
child_edit_bones.remove(parent_edit_bone)
logger.debug(f"Connecting {len(child_edit_bones)} child bones to parent bone: {parent_edit_bone.name}")
child_edit_bone: EditBone
child_edit_bone: bpy.types.EditBone
for child_edit_bone in child_edit_bones:
child_edit_bone.use_connect = True
@@ -105,6 +109,7 @@ class ModelJoinByBonesOperator(bpy.types.Operator):
class ModelSeparateByBonesOperator(bpy.types.Operator):
bl_idname = "mmd_tools.model_separate_by_bones"
bl_label = "Model Separate by Bones"
bl_description = "Separate MMD model into multiple models based on selected bones.\n\nWARNING: This operation will split meshes, armatures, rigid bodies and joints. To move models before separating, only adjust the root (cross under the model) transformation. Do not move armatures, meshes, rigid bodies, or joints directly before separating as they will not move together.\n\nIMPORTANT: Don't use any of the 'Assembly' functions before using this function. This function requires the model to be in a clean state."
bl_options = {"REGISTER", "UNDO"}
separate_armature: bpy.props.BoolProperty(name="Separate Armature", default=True)
@@ -120,8 +125,8 @@ class ModelSeparateByBonesOperator(bpy.types.Operator):
)
@classmethod
def poll(cls, context: Context) -> bool:
active_object: Optional[Object] = context.active_object
def poll(cls, context: bpy.types.Context):
active_object: Optional[bpy.types.Object] = context.active_object
if context.mode != "POSE":
return False
@@ -137,155 +142,183 @@ class ModelSeparateByBonesOperator(bpy.types.Operator):
return len(context.selected_pose_bones) > 0
def invoke(self, context: Context, event: Any) -> Set[str]:
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
def execute(self, context: Context) -> Set[str]:
def execute(self, context: bpy.types.Context):
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)}")
except NoModelSelectedError as ex:
self.report(type={"ERROR"}, message=str(ex))
return {"CANCELLED"}
return {"FINISHED"}
def separate(self, context: Context) -> None:
def separate(self, context: bpy.types.Context):
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}")
target_armature_object: bpy.types.Object = context.active_object
bpy.ops.object.mode_set(mode="EDIT")
root_bones: Set[EditBone] = set(context.selected_bones)
logger.debug(f"Selected root bones: {len(root_bones)}")
root_bones: Set[bpy.types.EditBone] = set(context.selected_bones)
if self.include_descendant_bones:
logger.debug("Including descendant bones")
original_active_bone = context.active_bone
for edit_bone in root_bones:
with context.temp_override(active_bone=edit_bone):
bpy.ops.armature.select_similar(type="CHILDREN", threshold=0.1)
context.active_object.data.edit_bones.active = edit_bone
bpy.ops.armature.select_similar(type="CHILDREN", threshold=0.1)
self._select_related_ik_bones(target_armature_object)
if original_active_bone:
context.active_object.data.edit_bones.active = original_active_bone
separate_bones: Dict[str, 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)
separate_bones: Dict[str, bpy.types.EditBone] = {b.name: b for b in context.selected_bones}
deform_bones: Dict[str, bpy.types.EditBone] = {b.name: b for b in target_armature_object.data.edit_bones if b.use_deform}
mmd_root_object: bpy.types.Object = FnModel.find_root_object(context.active_object)
mmd_model = Model(mmd_root_object)
mmd_model_mesh_objects: List[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}")
mmd_model_mesh_objects: List[bpy.types.Object] = list(mmd_model.meshes())
mmd_model_mesh_objects = list(self._select_weighted_vertices(mmd_model_mesh_objects, separate_bones, deform_bones, weight_threshold).keys())
bpy.ops.object.mode_set(mode="OBJECT")
# 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")
# Store original transform matrix for root object
original_matrix_world = mmd_root_object.matrix_world.copy()
mmd_root_object.matrix_world = Matrix.Identity(4)
# Reset object visibility
FnContext.set_active_and_select_single_object(context, mmd_root_object)
bpy.ops.mmd_tools.reset_object_visibility()
# Clean additional transform
FnContext.set_active_and_select_single_object(context, mmd_root_object)
bpy.ops.mmd_tools.clean_additional_transform()
# Create new separate model first
separate_model: Model = Model.create(mmd_root_object.mmd_root.name, mmd_root_object.mmd_root.name_e, mmd_scale, obj_name=mmd_root_object.name, add_root_bone=False)
separate_model.initialDisplayFrames()
separate_root_object = separate_model.rootObject()
separate_root_object.matrix_world = mmd_root_object.matrix_world
separate_model_armature_object = separate_model.armature()
# Now separate armature bones from original model
separate_armature_object: Optional[bpy.types.Object] = None
if self.separate_armature:
FnContext.set_active_and_select_single_object(context, target_armature_object)
bpy.ops.object.mode_set(mode="EDIT")
# Re-select the bones that should be separated (they might have been deselected)
for bone_name in separate_bones.keys():
if bone_name in target_armature_object.data.edit_bones:
target_armature_object.data.edit_bones[bone_name].select = True
bpy.ops.armature.separate()
separate_armature_object = next(iter([a for a in context.selected_objects if a != target_armature_object and a.type == "ARMATURE"]), None)
bpy.ops.object.mode_set(mode="OBJECT")
# Collect separate rigid bodies
separate_rigid_bodies: Set[bpy.types.Object] = {rigid_body_object for rigid_body_object in mmd_model.rigidBodies() if rigid_body_object.mmd_rigid.bone in separate_bones}
boundary_joint_owner_condition = any if self.boundary_joint_owner == "DESTINATION" else all
# collect separate joints
separate_joints: Set[Object] = {
# Collect separate joints
separate_joints: Set[bpy.types.Object] = {
joint_object
for joint_object in mmd_model.joints()
if boundary_joint_owner_condition(
[
joint_object.rigid_body_constraint.object1 in separate_rigid_bodies,
joint_object.rigid_body_constraint.object2 in separate_rigid_bodies,
]
],
)
}
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
separate_mesh_objects: List[bpy.types.Object] = []
model2separate_mesh_objects: Dict[bpy.types.Object, bpy.types.Object] = {}
if len(mmd_model_mesh_objects) > 0:
# Find a single unique attribute name that doesn't conflict with any existing attributes.
all_attribute_names = {attr.name for obj in mmd_model_mesh_objects for attr in obj.data.attributes}
temp_normal_name = "mmd_temp_normal"
i = 0
while temp_normal_name in all_attribute_names:
temp_normal_name = f"mmd_temp_normal.{i:03d}"
i += 1
# Backup custom normals to the unique temporary attribute.
for mesh_obj in mmd_model_mesh_objects:
mesh_data = mesh_obj.data
existing_custom_normal = mesh_data.attributes.get("custom_normal")
if not existing_custom_normal:
continue
if existing_custom_normal.data_type == "INT16_2D":
normals_data = np.empty(len(mesh_data.loops) * 2, dtype=np.int16)
existing_custom_normal.data.foreach_get("value", normals_data)
temp_normal_attr = mesh_data.attributes.new(temp_normal_name, "INT16_2D", "CORNER")
temp_normal_attr.data.foreach_set("value", normals_data)
else:
raise TypeError(f"Unsupported custom_normal data type: '{existing_custom_normal.data_type}'. Supported types: 'INT16_2D'")
# Select meshes
obj: bpy.types.Object
for obj in context.view_layer.objects:
obj.select_set(obj in mmd_model_mesh_objects)
context.view_layer.objects.active = mmd_model_mesh_objects[0]
# separate mesh by selected vertices
logger.debug("Separating meshes by selected vertices")
# Separate mesh 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]
separate_mesh_objects = [m for m in context.selected_objects if m.type == "MESH" and m not in mmd_model_mesh_objects]
bpy.ops.object.mode_set(mode="OBJECT")
logger.debug(f"Created {len(separate_mesh_objects)} separate mesh objects")
model2separate_mesh_objects = dict(zip(mmd_model_mesh_objects, separate_mesh_objects))
model2separate_mesh_objects = dict(zip(mmd_model_mesh_objects, separate_mesh_objects, strict=False))
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)
# Restore normal data for all meshes (original and separated)
all_mesh_objects = list(mmd_model_mesh_objects) + list(separate_mesh_objects)
for mesh_obj in all_mesh_objects:
mesh_data = mesh_obj.data
temp_normal_attr = mesh_data.attributes.get(temp_normal_name)
if not temp_normal_attr:
continue
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}")
try:
if temp_normal_attr.data_type == "INT16_2D":
normals_data = np.empty(len(mesh_data.loops) * 2, dtype=np.int16)
temp_normal_attr.data.foreach_get("value", normals_data)
custom_normal_attr = mesh_data.attributes.get("custom_normal")
if not custom_normal_attr:
custom_normal_attr = mesh_data.attributes.new("custom_normal", "INT16_2D", "CORNER")
custom_normal_attr.data.foreach_set("value", normals_data)
else:
raise TypeError(f"Unsupported custom_normal data type: '{temp_normal_attr.data_type}'. Supported types: 'INT16_2D'")
finally:
mesh_data.attributes.remove(temp_normal_attr)
if self.separate_armature:
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],
):
if self.separate_armature and separate_armature_object:
separate_armature_data = separate_armature_object.data
with select_object(separate_model_armature_object, objects=[separate_model_armature_object, separate_armature_object]):
bpy.ops.object.join()
if separate_armature_data.users == 0:
bpy.data.armatures.remove(separate_armature_data)
# 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)
if separate_mesh_objects:
with select_object(separate_model_armature_object, objects=[separate_model_armature_object] + separate_mesh_objects):
bpy.ops.object.parent_set(type="OBJECT", keep_transform=True)
# replace mesh armature modifier.object
logger.debug("Updating armature modifiers on separate meshes")
# Replace mesh armature modifier.object
for separate_mesh in separate_mesh_objects:
armature_modifier: Optional[bpy.types.ArmatureModifier] = next(iter([m for m in separate_mesh.modifiers if m.type == "ARMATURE"]), None)
if armature_modifier is None:
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: bpy.types.ArmatureModifier = separate_mesh.modifiers.new("mmd_armature", "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)
if separate_rigid_bodies:
with select_object(separate_model.rigidGroupObject(), objects=[separate_model.rigidGroupObject()] + list(separate_rigid_bodies)):
bpy.ops.object.parent_set(type="OBJECT", keep_transform=True)
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)
if separate_joints:
with select_object(separate_model.jointGroupObject(), objects=[separate_model.jointGroupObject()] + list(separate_joints)):
bpy.ops.object.parent_set(type="OBJECT", keep_transform=True)
# move separate objects to new collection
# 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
@@ -293,31 +326,42 @@ class ModelSeparateByBonesOperator(bpy.types.Operator):
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)
if separate_object.name not in separate_layer_collection.collection.objects:
separate_layer_collection.collection.objects.link(separate_object)
if separate_object.name in mmd_layer_collection.collection.objects:
mmd_layer_collection.collection.objects.unlink(separate_object)
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()}
# 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()
# Apply additional transform
FnContext.set_active_and_select_single_object(context, mmd_root_object)
bpy.ops.mmd_tools.apply_additional_transform()
FnContext.set_active_and_select_single_object(context, separate_root_object)
bpy.ops.mmd_tools.apply_additional_transform()
# Restore original transform matrix for root object
mmd_root_object.matrix_world = original_matrix_world
separate_root_object.matrix_world = original_matrix_world
# End state
FnContext.set_active_and_select_single_object(context, separate_root_object)
def _select_weighted_vertices(self, mmd_model_mesh_objects: List[bpy.types.Object], separate_bones: Dict[str, bpy.types.EditBone], deform_bones: Dict[str, bpy.types.EditBone], weight_threshold: float) -> Dict[bpy.types.Object, int]:
mesh2selected_vertex_count: Dict[bpy.types.Object, int] = {}
target_bmesh: bmesh.types.BMesh = bmesh.new()
for mesh_object in mmd_model_mesh_objects:
vertex_groups: bpy.types.VertexGroups = mesh_object.vertex_groups
mesh: Mesh = mesh_object.data
mesh: bpy.types.Mesh = mesh_object.data
target_bmesh.from_mesh(mesh, face_normals=False)
target_bmesh.select_mode |= {"VERT"}
deform_layer = target_bmesh.verts.layers.deform.verify()
@@ -344,7 +388,6 @@ class ModelSeparateByBonesOperator(bpy.types.Operator):
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)
@@ -352,3 +395,61 @@ class ModelSeparateByBonesOperator(bpy.types.Operator):
target_bmesh.clear()
return mesh2selected_vertex_count
def _select_related_ik_bones(self, armature_object: bpy.types.Object) -> None:
"""
Expand the current selection to include any full IK systems that are
partially selected. An IK system includes the chain bones, the IK
target bone, and the pole target bone.
NOTE: This method operates entirely in EDIT mode and avoids mode switching
to prevent segmentation faults.
"""
edit_bones = armature_object.data.edit_bones
initial_selection_names = {b.name for b in edit_bones if b.select}
# Access pose bones constraints directly without mode switching
pose_bones = armature_object.pose.bones
# Find all complete IK systems
ik_systems = []
for pose_bone in pose_bones:
for constraint in pose_bone.constraints:
if constraint.type == "IK":
# Build the set of bones in this IK system
system_bones = {pose_bone.name}
# Add the main IK Target bone
if constraint.target and constraint.subtarget:
system_bones.add(constraint.subtarget)
# Add the Pole Target bone
if constraint.pole_target and constraint.pole_subtarget:
system_bones.add(constraint.pole_subtarget)
# Add all other bones in the IK chain
current_bone_name = pose_bone.name
chain_count = constraint.chain_count
# Walk up the parent chain
for _ in range(chain_count - 1):
if current_bone_name not in edit_bones:
break
current_bone = edit_bones[current_bone_name]
if not current_bone.parent:
break
current_bone_name = current_bone.parent.name
system_bones.add(current_bone_name)
ik_systems.append(system_bones)
# Expand selection to include any related, full IK systems
final_selection_names = set(initial_selection_names)
for system in ik_systems:
if not system.isdisjoint(initial_selection_names):
final_selection_names.update(system)
# Apply the final selection
for bone in edit_bones:
bone.select = bone.name in final_selection_names
+399 -106
View File
@@ -1,30 +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.
# Copyright 2015 MMD Tools authors
# This file is part of MMD Tools.
from typing import Optional, cast, List, Dict, Any, Set, Tuple, Union
from collections import namedtuple
from typing import Optional, cast
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.model import FnModel
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]:
def divide_vector_components(vec1, vec2):
if len(vec1) != len(vec2):
raise ValueError("Vectors should have the same number of components")
result = []
for v1, v2 in zip(vec1, vec2):
for v1, v2 in zip(vec1, vec2, strict=False):
if v2 == 0:
if v1 == 0:
v2 = 1 # If we have a 0/0 case we change the divisor to 1
@@ -34,17 +30,17 @@ def divide_vector_components(vec1: List[float], vec2: List[float]) -> List[float
return result
def multiply_vector_components(vec1: List[float], vec2: List[float]) -> List[float]:
def multiply_vector_components(vec1, vec2):
if len(vec1) != len(vec2):
raise ValueError("Vectors should have the same number of components")
result = []
for v1, v2 in zip(vec1, vec2):
for v1, v2 in zip(vec1, vec2, strict=False):
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"""
def special_division(n1, n2):
"""Return 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
@@ -59,7 +55,7 @@ class AddMorph(bpy.types.Operator):
bl_description = "Add a morph item to active morph list"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
@@ -69,7 +65,6 @@ class AddMorph(bpy.types.Operator):
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"}
@@ -86,7 +81,7 @@ class RemoveMorph(bpy.types.Operator):
options={"SKIP_SAVE"},
)
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
@@ -101,21 +96,19 @@ class RemoveMorph(bpy.types.Operator):
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_description = "Move active morph item up/down in the list. This will not affect the morph order in exported PMX files (use Display Panel order instead)."
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
@@ -124,7 +117,6 @@ class MoveMorph(bpy.types.Operator, ItemMoveOp):
mmd_root.active_morph,
self.type,
)
logger.debug(f"Moved morph to index {mmd_root.active_morph}")
return {"FINISHED"}
@@ -134,7 +126,7 @@ class CopyMorph(bpy.types.Operator):
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]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
@@ -146,7 +138,7 @@ class CopyMorph(bpy.types.Operator):
if morph is None:
return {"CANCELLED"}
name_orig, name_tmp = morph.name, "_tmp%s" % str(morph.as_pointer())
name_orig, name_tmp = morph.name, f"_tmp{str(morph.as_pointer())}"
if morph_type.startswith("vertex"):
for obj in FnModel.iterate_mesh_objects(root):
@@ -161,7 +153,6 @@ class CopyMorph(bpy.types.Operator):
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"}
@@ -171,17 +162,14 @@ class OverwriteBoneMorphsFromActionPose(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
def poll(cls, context):
root = FnModel.find_root_object(context.active_object)
if root is None:
return False
return root is not None and root.mmd_root.active_morph_type == "bone_morphs"
return root.mmd_root.active_morph_type == "bone_morphs"
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
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"}
@@ -191,7 +179,7 @@ class AddMorphOffset(bpy.types.Operator):
bl_description = "Add a morph offset item to the list"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
@@ -216,7 +204,6 @@ class AddMorphOffset(bpy.types.Operator):
item.location = pose_bone.location
item.rotation = pose_bone.rotation_quaternion
logger.debug(f"Added morph offset to {morph_type}")
return {"FINISHED"}
@@ -233,7 +220,7 @@ class RemoveMorphOffset(bpy.types.Operator):
options={"SKIP_SAVE"},
)
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
@@ -250,21 +237,17 @@ class RemoveMorphOffset(bpy.types.Operator):
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_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"}
@@ -280,7 +263,7 @@ class InitMaterialOffset(bpy.types.Operator):
default=0,
)
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
@@ -292,7 +275,6 @@ class InitMaterialOffset(bpy.types.Operator):
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"}
@@ -302,7 +284,7 @@ class ApplyMaterialOffset(bpy.types.Operator):
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]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
@@ -340,7 +322,6 @@ class ApplyMaterialOffset(bpy.types.Operator):
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
@@ -358,7 +339,6 @@ class ApplyMaterialOffset(bpy.types.Operator):
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"}
@@ -368,7 +348,7 @@ class CreateWorkMaterial(bpy.types.Operator):
bl_description = "Creates a temporary material to edit this offset"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
@@ -385,12 +365,12 @@ class CreateWorkMaterial(bpy.types.Operator):
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)
self.report({"ERROR"}, f'Material "{mat_data.material}" not found')
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)
self.report({"ERROR"}, f'Temporary material "{work_mat_name}" is in use')
return {"CANCELLED"}
work_mat = base_mat.copy()
@@ -427,7 +407,6 @@ class CreateWorkMaterial(bpy.types.Operator):
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"}
@@ -437,24 +416,23 @@ class ClearTempMaterials(bpy.types.Operator):
bl_description = "Clears all the temporary materials"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
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:
def __pre_remove(m, meshObj=meshObj):
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)
self.report({"WARNING"}, f"Base material for {m.name} was not found")
return False
FnMaterial.clean_materials(meshObj, can_remove=__pre_remove)
logger.info("Cleared all temporary materials")
return {"FINISHED"}
@@ -464,7 +442,7 @@ class ViewBoneMorph(bpy.types.Operator):
bl_description = "View the result of active bone morph"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
@@ -475,11 +453,10 @@ class ViewBoneMorph(bpy.types.Operator):
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
p_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"}
@@ -489,14 +466,13 @@ class ClearBoneMorphView(bpy.types.Operator):
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]:
def execute(self, context):
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"}
@@ -506,7 +482,7 @@ class ApplyBoneMorph(bpy.types.Operator):
bl_description = "Apply current pose to active bone morph"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
@@ -521,10 +497,9 @@ class ApplyBoneMorph(bpy.types.Operator):
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
p_bone.select = True
else:
p_bone.bone.select = False
logger.info(f"Applied current pose to bone morph: {morph.name}")
p_bone.select = False
return {"FINISHED"}
@@ -534,7 +509,7 @@ class SelectRelatedBone(bpy.types.Operator):
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]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
@@ -543,7 +518,6 @@ class SelectRelatedBone(bpy.types.Operator):
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"}
@@ -553,7 +527,7 @@ class EditBoneOffset(bpy.types.Operator):
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]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
@@ -566,7 +540,6 @@ class EditBoneOffset(bpy.types.Operator):
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"}
@@ -576,7 +549,7 @@ class ApplyBoneOffset(bpy.types.Operator):
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]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
@@ -588,7 +561,6 @@ class ApplyBoneOffset(bpy.types.Operator):
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"}
@@ -598,7 +570,7 @@ class ViewUVMorph(bpy.types.Operator):
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]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
@@ -616,11 +588,11 @@ class ViewUVMorph(bpy.types.Operator):
selected = meshObj.select_get()
with bpyutils.select_object(meshObj):
mesh = cast(bpy.types.Mesh, meshObj.data)
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("_")]
base_uv_layers = [layer for layer in mesh.uv_layers if not layer.name.startswith("_")]
if morph.uv_index >= len(base_uv_layers):
self.report({"ERROR"}, "Invalid uv index: %d" % morph.uv_index)
return {"CANCELLED"}
@@ -630,7 +602,7 @@ class ViewUVMorph(bpy.types.Operator):
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)
uv_tex = uv_textures.new(name=f"__uv.{uv_layer_name}")
if uv_tex is None:
self.report({"ERROR"}, "Failed to create a temporary uv layer")
return {"CANCELLED"}
@@ -640,16 +612,15 @@ class ViewUVMorph(bpy.types.Operator):
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
for i, loop in enumerate(mesh.loops):
select = temp_uv_data[i].select = loop.vertex_index in offsets
if select:
temp_uv_data[i].uv = base_uv_data[i].uv + offsets[l.vertex_index]
temp_uv_data[i].uv = base_uv_data[i].uv + offsets[loop.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"}
@@ -659,14 +630,14 @@ class ClearUVMorphView(bpy.types.Operator):
bl_description = "Clear all temporary data of UV morphs"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
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:
for t in reversed(uv_textures):
if t.name.startswith("__uv."):
uv_textures.remove(t)
if len(uv_textures) > 0:
@@ -676,7 +647,7 @@ class ClearUVMorphView(bpy.types.Operator):
animation_data = mesh.animation_data
if animation_data:
nla_tracks = animation_data.nla_tracks
for t in nla_tracks:
for t in reversed(nla_tracks):
if t.name.startswith("__uv."):
nla_tracks.remove(t)
if animation_data.action and animation_data.action.name.startswith("__uv."):
@@ -684,10 +655,9 @@ class ClearUVMorphView(bpy.types.Operator):
if animation_data.action is None and len(nla_tracks) == 0:
mesh.animation_data_clear()
for act in bpy.data.actions:
for act in reversed(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"}
@@ -698,20 +668,20 @@ class EditUVMorph(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
def poll(cls, context):
obj = context.active_object
if obj.type != "MESH":
if obj is None or obj.type != "MESH":
return False
active_uv_layer = obj.data.uv_layers.active
return active_uv_layer and active_uv_layer.name.startswith("__uv.")
return active_uv_layer is not None and active_uv_layer.name.startswith("__uv.")
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
meshObj = obj
selected = meshObj.select_get()
with bpyutils.select_object(meshObj):
mesh = cast(bpy.types.Mesh, meshObj.data)
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
@@ -719,16 +689,15 @@ class EditUVMorph(bpy.types.Operator):
bpy.ops.object.mode_set(mode="OBJECT")
vertices = mesh.vertices
for l, d in zip(mesh.loops, mesh.uv_layers.active.data):
for loop, d in zip(mesh.loops, mesh.uv_layers.active.data, strict=False):
if d.select:
vertices[l.vertex_index].select = True
vertices[loop.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"}
@@ -739,14 +708,14 @@ class ApplyUVMorph(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
def poll(cls, context):
obj = context.active_object
if obj.type != "MESH":
if obj is None or obj.type != "MESH":
return False
active_uv_layer = obj.data.uv_layers.active
return active_uv_layer and active_uv_layer.name.startswith("__uv.")
return active_uv_layer is not None and active_uv_layer.name.startswith("__uv.")
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
@@ -754,34 +723,31 @@ class ApplyUVMorph(bpy.types.Operator):
selected = meshObj.select_get()
with bpyutils.select_object(meshObj):
mesh = cast(bpy.types.Mesh, meshObj.data)
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)
self.report({"ERROR"}, f' * UV map "{base_uv_name}" not found')
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:
for loop, i0, i1 in zip(mesh.loops, base_uv_data, temp_uv_data, strict=False):
if vertices[loop.vertex_index].select and loop.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))
offsets[loop.vertex_index] = __OffsetData(loop.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"}
@@ -792,12 +758,339 @@ class CleanDuplicatedMaterialMorphs(bpy.types.Operator):
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 poll(cls, context):
root = FnModel.find_root_object(context.active_object)
return root is not None
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context: bpy.types.Context):
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"}
class ConvertBoneMorphToVertexMorph(bpy.types.Operator):
bl_idname = "mmd_tools.convert_bone_morph_to_vertex_morph"
bl_label = "Convert To Vertex Morph"
bl_description = "Convert a bone morph into a single vertex morph by applying the bone transformations.\nIf a corresponding vertex morph already exists, it will be updated."
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context):
root = FnModel.find_root_object(context.active_object)
if root is None:
return False
mmd_root = root.mmd_root
if mmd_root.active_morph_type != "bone_morphs":
return False
morph = ItemOp.get_by_index(mmd_root.bone_morphs, mmd_root.active_morph)
return morph is not None and len(morph.data) > 0
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
# Get the active bone morph
bone_morph = ItemOp.get_by_index(mmd_root.bone_morphs, mmd_root.active_morph)
if bone_morph is None:
self.report({"ERROR"}, "No active bone morph")
return {"CANCELLED"}
original_name = bone_morph.name
target_name = original_name
# Add 'B' suffix if necessary
if not original_name.endswith("B"):
bone_morph.name = original_name + "B"
target_name = original_name
else:
# If already has B suffix, use name without B
target_name = original_name[:-1]
try:
# Step 1: import
from ..core.model import Model
rig = Model(root)
# Ensure morph slider is bound
bpy.ops.mmd_tools.morph_slider_setup(type="BIND")
# Re-obtain placeholder object
placeholder_obj = rig.morph_slider.placeholder()
if placeholder_obj is None or placeholder_obj.data.shape_keys is None:
self.report({"ERROR"}, "Failed to create morph slider system")
return {"CANCELLED"}
shape_keys = placeholder_obj.data.shape_keys
key_blocks = shape_keys.key_blocks
# Step 2: Check if target bone morph exists
current_morph_name = bone_morph.name
if current_morph_name not in key_blocks:
self.report({"ERROR"}, f"Bone morph '{current_morph_name}' not found in morph sliders")
return {"CANCELLED"}
# Step 3: Save all current morph values
original_values = {}
for key_block in key_blocks:
if key_block.name != "--- morph sliders ---":
original_values[key_block.name] = key_block.value
# Step 4: Set all morphs to 0
for key_block in key_blocks:
if key_block.name != "--- morph sliders ---":
key_block.value = 0
# Step 5: Set target bone morph to 1.0
key_blocks[current_morph_name].value = 1.0
# Step 6: Use Armature Modifier's "Apply as Shape Key" functionality
created_shape_keys = []
for mesh_obj in FnModel.iterate_mesh_objects(root):
# Switch to this mesh object
context.view_layer.objects.active = mesh_obj
# Ensure mesh object has shape keys
if mesh_obj.data.shape_keys is None:
mesh_obj.shape_key_add(name="Basis", from_mix=False)
# Delete existing shape key with same name
if target_name in mesh_obj.data.shape_keys.key_blocks:
idx = mesh_obj.data.shape_keys.key_blocks.find(target_name)
if idx >= 0:
mesh_obj.active_shape_key_index = idx
bpy.ops.object.shape_key_remove()
# Find armature modifier
armature_modifier = None
for modifier in mesh_obj.modifiers:
if modifier.type == "ARMATURE":
armature_modifier = modifier
break
if armature_modifier is None:
self.report({"WARNING"}, f"No armature modifier found on mesh '{mesh_obj.name}'")
continue
# Use Apply as Shape Key functionality, keeping the modifier
bpy.ops.object.modifier_apply_as_shapekey(modifier=armature_modifier.name, keep_modifier=True)
# Rename the newly created shape key to target name
shape_key_blocks = mesh_obj.data.shape_keys.key_blocks
new_shape_key = shape_key_blocks[-1] # Latest created shape key
new_shape_key.name = target_name
new_shape_key.value = 0.0 # Set to 0 to avoid double effect
created_shape_keys.append((mesh_obj.name, target_name))
self.report({"INFO"}, f"Created shape key '{target_name}' on mesh '{mesh_obj.name}'")
# Step 7: Restore all original morph values
for key_name, original_value in original_values.items():
if key_name in key_blocks:
key_blocks[key_name].value = original_value
# Step 8: Create or update vertex morph entry
vertex_morph_exists = False
for i, morph in enumerate(mmd_root.vertex_morphs):
if morph.name == target_name:
vertex_morph_exists = True
mmd_root.active_morph_type = "vertex_morphs"
mmd_root.active_morph = i
break
if not vertex_morph_exists:
mmd_root.active_morph_type = "vertex_morphs"
morph, mmd_root.active_morph = ItemOp.add_after(mmd_root.vertex_morphs, mmd_root.active_morph)
morph.name = target_name
# Step 9: Add to facial expression display frame
facial_frame = None
for frame in mmd_root.display_item_frames:
if frame.name == "表情":
facial_frame = frame
break
if facial_frame:
morph_exists_in_frame = False
for item in facial_frame.data:
if item.type == "MORPH" and item.name == target_name and item.morph_type == "vertex_morphs":
morph_exists_in_frame = True
break
if not morph_exists_in_frame:
new_item = facial_frame.data.add()
new_item.type = "MORPH"
new_item.morph_type = "vertex_morphs"
new_item.name = target_name
facial_frame.active_item = len(facial_frame.data) - 1
for i, frame in enumerate(mmd_root.display_item_frames):
if frame.name == "表情":
mmd_root.active_display_item_frame = i
break
# UNBIND
bpy.ops.mmd_tools.morph_slider_setup(type="UNBIND")
# Success message
shape_key_info = ", ".join([f"{mesh}:{key}" for mesh, key in created_shape_keys])
self.report({"INFO"}, f"Successfully converted bone morph '{original_name}' to vertex morph '{target_name}'. Created shape keys: {shape_key_info}")
except Exception as e:
self.report({"ERROR"}, f"Error during conversion: {str(e)}")
return {"CANCELLED"}
return {"FINISHED"}
class ConvertGroupMorphToVertexMorph(bpy.types.Operator):
bl_idname = "mmd_tools.convert_group_morph_to_vertex_morph"
bl_label = "Convert To Vertex Morph"
bl_description = "Convert a group morph into a single vertex morph by merging only the vertex morphs within the group.\nIf a corresponding vertex morph already exists, it will be updated."
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context):
root = FnModel.find_root_object(context.active_object)
if root is None:
return False
mmd_root = root.mmd_root
if mmd_root.active_morph_type != "group_morphs":
return False
morph = ItemOp.get_by_index(mmd_root.group_morphs, mmd_root.active_morph)
return morph is not None and len(morph.data) > 0
def execute(self, context):
bpy.ops.mmd_tools.morph_slider_setup(type="UNBIND")
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
# Get the active group morph
group_morph = ItemOp.get_by_index(mmd_root.group_morphs, mmd_root.active_morph)
if group_morph is None:
self.report({"ERROR"}, "No active group morph")
return {"CANCELLED"}
# Check if the group morph contains any vertex morphs to convert
has_vertex_morphs = False
for offset in group_morph.data:
if offset.morph_type == "vertex_morphs":
has_vertex_morphs = True
break
if not has_vertex_morphs:
self.report({"ERROR"}, "The group morph does not contain any vertex morphs to convert")
return {"CANCELLED"}
original_name = group_morph.name
target_name = original_name
# Add 'G' suffix if necessary
if not original_name.endswith("G"):
group_morph.name = original_name + "G"
target_name = original_name
else:
# If already has G suffix, use name without G
target_name = original_name[:-1]
# First, reset all shape keys to zero
for obj in FnModel.iterate_mesh_objects(root):
if obj.data.shape_keys:
for kb in obj.data.shape_keys.key_blocks:
kb.value = 0
# Apply only the vertex morphs from the group morph
for offset in group_morph.data:
if offset.morph_type == "vertex_morphs":
# Find the vertex morph by name
vertex_morph = getattr(root.mmd_root, offset.morph_type).get(offset.name)
if vertex_morph:
# Apply this morph at the specified factor
for obj in FnModel.iterate_mesh_objects(root):
if obj.data.shape_keys:
kb = obj.data.shape_keys.key_blocks.get(offset.name)
if kb:
kb.value = offset.factor
# Now add a new shape key from mix for each mesh
for obj in FnModel.iterate_mesh_objects(root):
if obj.data.shape_keys:
# Make this the active object
context.view_layer.objects.active = obj
# Remove existing shape key if it exists
if target_name in obj.data.shape_keys.key_blocks:
idx = obj.data.shape_keys.key_blocks.find(target_name)
if idx >= 0:
obj.active_shape_key_index = idx
bpy.ops.object.shape_key_remove()
# Add shape key from mix
bpy.ops.object.shape_key_add(from_mix=True)
# Rename the newly created shape key
new_key = obj.data.shape_keys.key_blocks[-1]
new_key.name = target_name
# Check if a vertex morph with the target name already exists
vertex_morph_exists = False
for i, morph in enumerate(mmd_root.vertex_morphs):
if morph.name == target_name:
vertex_morph_exists = True
mmd_root.active_morph_type = "vertex_morphs"
mmd_root.active_morph = i
break
# If not, create a new vertex morph
if not vertex_morph_exists:
# Switch to vertex morphs panel
mmd_root.active_morph_type = "vertex_morphs"
# Add new vertex morph
morph, mmd_root.active_morph = ItemOp.add_after(mmd_root.vertex_morphs, mmd_root.active_morph)
morph.name = target_name
# Add the new vertex morph to the facial display frame
facial_frame = None
for frame in mmd_root.display_item_frames:
if frame.name == "表情": # This is the facial display frame
facial_frame = frame
break
if facial_frame:
# Check if this morph is already in the facial frame
morph_exists_in_frame = False
for item in facial_frame.data:
if item.type == "MORPH" and item.name == target_name and item.morph_type == "vertex_morphs":
morph_exists_in_frame = True
break
# If not, add it
if not morph_exists_in_frame:
new_item = facial_frame.data.add()
new_item.type = "MORPH"
new_item.morph_type = "vertex_morphs"
new_item.name = target_name
# Make this the active item in the facial frame
facial_frame.active_item = len(facial_frame.data) - 1
# Set the facial frame as active
for i, frame in enumerate(mmd_root.display_item_frames):
if frame.name == "表情":
mmd_root.active_display_item_frame = i
break
# Reset all shape keys
for obj in FnModel.iterate_mesh_objects(root):
if obj.data.shape_keys:
for kb in obj.data.shape_keys.key_blocks:
kb.value = 0
self.report({"INFO"}, f"Successfully converted vertex morphs in group to vertex morph '{target_name}' and added to facial display frame")
return {"FINISHED"}
+31 -49
View File
@@ -1,12 +1,8 @@
# -*- 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.
# Copyright 2015 MMD Tools authors
# This file is part of MMD Tools.
import math
from typing import Dict, Optional, Tuple, cast, Set, List, Any, Union, Generator
from typing import Dict, Optional, Tuple, cast
import bpy
from mathutils import Euler, Vector
@@ -16,7 +12,6 @@ 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):
@@ -44,15 +39,15 @@ class SelectRigidBody(bpy.types.Operator):
default=False,
)
def invoke(self, context: bpy.types.Context, event: Any) -> Set[str]:
def invoke(self, context, event):
vm = context.window_manager
return vm.invoke_props_dialog(self)
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
def poll(cls, context):
return FnModel.is_rigid_body_object(context.active_object)
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
if root is None:
@@ -174,7 +169,7 @@ class AddRigidBody(bpy.types.Operator):
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:
def __add_rigid_body(self, context: bpy.types.Context, root_object: bpy.types.Object, pose_bone: Optional[bpy.types.PoseBone] = None):
name_j: str = self.name_j
name_e: str = self.name_e
size = self.size.copy()
@@ -227,7 +222,7 @@ class AddRigidBody(bpy.types.Operator):
)
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
def poll(cls, context):
root_object = FnModel.find_root_object(context.active_object)
if root_object is None:
return False
@@ -238,11 +233,11 @@ class AddRigidBody(bpy.types.Operator):
return True
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
active_object = context.active_object
root_object = cast(bpy.types.Object, FnModel.find_root_object(active_object))
armature_object = cast(bpy.types.Object, FnModel.find_armature_object(root_object))
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)
@@ -255,17 +250,15 @@ class AddRigidBody(bpy.types.Operator):
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]:
def invoke(self, context, event):
no_bone = True
if context.selected_bones and len(context.selected_bones) > 0:
no_bone = False
@@ -291,13 +284,12 @@ class RemoveRigidBody(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
def poll(cls, context):
return FnModel.is_rigid_body_object(context.active_object)
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
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:
@@ -310,8 +302,7 @@ class RigidBodyBake(bpy.types.Operator):
bl_label = "Bake"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
logger.info("Baking rigid body simulation")
def execute(self, context: bpy.types.Context):
with context.temp_override(scene=context.scene, point_cache=context.scene.rigidbody_world.point_cache):
bpy.ops.ptcache.bake("INVOKE_DEFAULT", bake=True)
@@ -323,8 +314,7 @@ class RigidBodyDeleteBake(bpy.types.Operator):
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")
def execute(self, context: bpy.types.Context):
with context.temp_override(scene=context.scene, point_cache=context.scene.rigidbody_world.point_cache):
bpy.ops.ptcache.free_bake("INVOKE_DEFAULT")
@@ -387,7 +377,7 @@ class AddJoint(bpy.types.Operator):
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]:
def __enumerate_rigid_pair(self, bone_map: Dict[bpy.types.Object, Optional[bpy.types.Bone]]):
obj_seq = tuple(bone_map.keys())
for rigid_a, bone_a in bone_map.items():
for rigid_b, bone_b in bone_map.items():
@@ -400,7 +390,7 @@ class AddJoint(bpy.types.Operator):
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:
def __add_joint(self, context: bpy.types.Context, root_object: bpy.types.Object, rigid_pair: Tuple[bpy.types.Object, bpy.types.Object], bone_map):
loc: Optional[Vector] = None
rot = Euler((0.0, 0.0, 0.0))
rigid_a, rigid_b = rigid_pair
@@ -438,7 +428,7 @@ class AddJoint(bpy.types.Operator):
)
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
def poll(cls, context):
root_object = FnModel.find_root_object(context.active_object)
if root_object is None:
return False
@@ -449,11 +439,11 @@ class AddJoint(bpy.types.Operator):
return True
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
active_object = context.active_object
root_object = cast(bpy.types.Object, FnModel.find_root_object(active_object))
armature_object = cast(bpy.types.Object, FnModel.find_armature_object(root_object))
bones = cast(bpy.types.Armature, armature_object.data).bones
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:
@@ -462,19 +452,15 @@ class AddJoint(bpy.types.Operator):
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]:
def invoke(self, context, event):
vm = context.window_manager
return vm.invoke_props_dialog(self)
@@ -486,13 +472,12 @@ class RemoveJoint(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
def poll(cls, context):
return FnModel.is_joint_object(context.active_object)
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
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:
@@ -507,7 +492,7 @@ class UpdateRigidBodyWorld(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
@staticmethod
def __get_rigid_body_world_objects() -> Tuple[bpy.types.Collection, bpy.types.Collection]:
def __get_rigid_body_world_objects():
rigid_body.setRigidBodyWorldEnabled(True)
rbw = bpy.context.scene.rigidbody_world
if not rbw.collection:
@@ -522,21 +507,21 @@ class UpdateRigidBodyWorld(bpy.types.Operator):
return rbw.collection.objects, rbw.constraints.objects
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
scene = context.scene
scene_objs = set(scene.objects)
scene_objs.union(o for x in scene.objects if x.instance_type == "COLLECTION" and x.instance_collection for o in x.instance_collection.objects)
def _update_group(obj: bpy.types.Object, group: bpy.types.Collection) -> bool:
def _update_group(obj, group):
if obj in scene_objs:
if obj not in group.values():
group.link(obj)
return True
elif obj in group.values():
if obj in group.values():
group.unlink(obj)
return False
def _references(obj: bpy.types.Object) -> Generator[bpy.types.Object, None, None]:
def _references(obj):
yield obj
if getattr(obj, "proxy", None):
yield from _references(obj.proxy)
@@ -553,7 +538,6 @@ class UpdateRigidBodyWorld(bpy.types.Operator):
# 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
@@ -568,7 +552,6 @@ class UpdateRigidBodyWorld(bpy.types.Operator):
# 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
@@ -579,7 +562,6 @@ class UpdateRigidBodyWorld(bpy.types.Operator):
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
+13 -23
View File
@@ -1,23 +1,18 @@
# -*- 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.
# Copyright 2018 MMD Tools authors
# This file is part of MMD Tools.
from typing import Set, Tuple
from typing import Set
import bpy
from bpy.types import Operator, Context, Object
from bpy.types import Operator
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()
def _get_target_objects(context):
root_objects: Set[bpy.types.Object] = set()
selected_objects: Set[bpy.types.Object] = set()
for i in context.selected_objects:
if i.type == "MESH":
selected_objects.add(i)
@@ -41,13 +36,11 @@ class ResetSDEFCache(Operator):
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]:
def execute(self, context):
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"}
@@ -78,20 +71,19 @@ class BindSDEF(Operator):
default=False,
)
def invoke(self, context: Context, event: bpy.types.Event) -> Set[str]:
def invoke(self, context, event):
vm = context.window_manager
return vm.invoke_props_dialog(self)
def execute(self, context: Context) -> Set[str]:
# TODO: Utility Functionalize
def execute(self, context):
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"}
@@ -102,15 +94,13 @@ class UnbindSDEF(Operator):
bl_description = "Unbind MMD SDEF data of selected objects"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: Context) -> Set[str]:
# TODO: Utility Functionalize
def execute(self, context):
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"}
+266 -36
View File
@@ -1,10 +1,8 @@
# -*- 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.
# Copyright 2021 MMD Tools authors
# This file is part of MMD Tools.
import csv
import os
from typing import TYPE_CHECKING, cast
import bpy
@@ -14,7 +12,11 @@ from ..core.translations import MMD_DATA_TYPE_TO_HANDLERS, FnTranslations
from ..translations import DictionaryEnum
if TYPE_CHECKING:
from ..properties.translations import MMDTranslation, MMDTranslationElement, MMDTranslationElementIndex
from ..properties.translations import (
MMDTranslation,
MMDTranslationElement,
MMDTranslationElementIndex,
)
class TranslateMMDModel(bpy.types.Operator):
@@ -77,7 +79,8 @@ class TranslateMMDModel(bpy.types.Operator):
@classmethod
def poll(cls, context):
obj = context.active_object
return obj in context.selected_objects and FnModel.find_root_object(obj)
root = FnModel.find_root_object(obj)
return obj is not None and obj in context.selected_objects and root is not None
def invoke(self, context, event):
vm = context.window_manager
@@ -87,7 +90,7 @@ class TranslateMMDModel(bpy.types.Operator):
try:
self.__translator = DictionaryEnum.get_translator(self.dictionary)
except Exception as e:
self.report({"ERROR"}, "Failed to load dictionary: %s" % e)
self.report({"ERROR"}, f"Failed to load dictionary: {e}")
return {"CANCELLED"}
obj = context.active_object
@@ -96,7 +99,7 @@ class TranslateMMDModel(bpy.types.Operator):
if "MMD" in self.modes:
for i in self.types:
getattr(self, "translate_%s" % i.lower())(rig)
getattr(self, f"translate_{i.lower()}")(rig)
if "BLENDER" in self.modes:
self.translate_blender_names(rig)
@@ -104,7 +107,11 @@ class TranslateMMDModel(bpy.types.Operator):
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))
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):
@@ -130,7 +137,7 @@ class TranslateMMDModel(bpy.types.Operator):
if "DISPLAY" in self.types:
g: bpy.types.BoneCollection
for g in cast(bpy.types.Armature, rig.armature().data).collections:
for g in cast("bpy.types.Armature", rig.armature().data).collections:
g.name = self.translate(g.name, g.name)
if "PHYSICS" in self.types:
@@ -153,7 +160,9 @@ class TranslateMMDModel(bpy.types.Operator):
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 = self.translate(
comment_text.as_string(), comment_e_text.as_string(),
)
comment_e_text.from_string(comment_e)
def translate_bone(self, rig):
@@ -167,7 +176,7 @@ class TranslateMMDModel(bpy.types.Operator):
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 attr, prefix in zip(attr_list, prefix_list, strict=False):
for m in getattr(mmd_root, attr + "_morphs", []):
m.name_e = self.translate(m.name, m.name_e)
if not prefix:
@@ -182,7 +191,9 @@ class TranslateMMDModel(bpy.types.Operator):
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)
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
@@ -200,10 +211,24 @@ class TranslateMMDModel(bpy.types.Operator):
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 MMD_TOOLS_LOCAL_UL_MMDTranslationElementIndex(bpy.types.UIList):
def draw_item(
self,
context,
layout: bpy.types.UILayout,
data,
mmd_translation_element_index: "MMDTranslationElementIndex",
icon,
active_data,
active_propname,
index: int,
):
mmd_translation_element: MMDTranslationElement = data.translation_elements[
mmd_translation_element_index.value
]
MMD_DATA_TYPE_TO_HANDLERS[mmd_translation_element.type].draw_item(
layout, mmd_translation_element, index,
)
class RestoreMMDDataReferenceOperator(bpy.types.Operator):
@@ -216,9 +241,15 @@ class RestoreMMDDataReferenceOperator(bpy.types.Operator):
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]
root_object = FnModel.find_root_object(context.active_object)
mmd_translation_element_index = (
root_object.mmd_root.translation.filtered_translation_element_indices[
self.index
].value
)
mmd_translation_element = root_object.mmd_root.translation.translation_elements[
mmd_translation_element_index
]
setattr(mmd_translation_element, self.prop_name, self.restore_value)
return {"FINISHED"}
@@ -231,7 +262,8 @@ class GlobalTranslationPopup(bpy.types.Operator):
@classmethod
def poll(cls, context):
return FnModel.find_root_object(context.object) is not None
root = FnModel.find_root_object(context.active_object)
return root is not None
def draw(self, _context):
layout = self.layout
@@ -244,13 +276,33 @@ class GlobalTranslationPopup(bpy.types.Operator):
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_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)
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)
@@ -262,11 +314,14 @@ class GlobalTranslationPopup(bpy.types.Operator):
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:
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_tools_UL_MMDTranslationElementIndex",
"",
mmd_translation,
"filtered_translation_element_indices",
@@ -281,7 +336,12 @@ class GlobalTranslationPopup(bpy.types.Operator):
box.separator()
row = box.row()
row.prop(mmd_translation, "batch_operation_script_preset", text="Preset", icon="CON_TRANSFORM_CACHE")
row.prop(
mmd_translation,
"batch_operation_script_preset",
text="Preset",
icon="CON_TRANSFORM_CACHE",
)
row.operator(ExecuteTranslationBatchOperator.bl_idname, text="Execute")
box.separator()
@@ -289,18 +349,25 @@ class GlobalTranslationPopup(bpy.types.Operator):
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")
# CSV import/export
box.separator()
translation_box = box.box().column(align=True)
translation_box.label(text="CSV:", icon="FILE_TEXT")
row = translation_box.row()
row.operator(ImportTranslationCSVOperator.bl_idname, text="Import CSV")
row.operator(ExportTranslationCSVOperator.bl_idname, text="Export CSV")
def invoke(self, context: bpy.types.Context, _event):
root_object = FnModel.find_root_object(context.object)
root_object = FnModel.find_root_object(context.active_object)
if root_object is None:
return {"CANCELLED"}
mmd_translation: "MMDTranslation" = root_object.mmd_root.translation
mmd_translation: MMDTranslation = root_object.mmd_root.translation
self._mmd_translation = mmd_translation
FnTranslations.clear_data(mmd_translation)
FnTranslations.collect_data(mmd_translation)
@@ -309,7 +376,7 @@ class GlobalTranslationPopup(bpy.types.Operator):
return context.window_manager.invoke_props_dialog(self, width=800)
def execute(self, context):
root_object = FnModel.find_root_object(context.object)
root_object = FnModel.find_root_object(context.active_object)
if root_object is None:
return {"CANCELLED"}
@@ -325,12 +392,175 @@ class ExecuteTranslationBatchOperator(bpy.types.Operator):
bl_options = {"INTERNAL"}
def execute(self, context: bpy.types.Context):
root = FnModel.find_root_object(context.object)
root = FnModel.find_root_object(context.active_object)
if root is None:
return {"CANCELLED"}
fails, text = FnTranslations.execute_translation_batch(root)
if fails:
self.report({"WARNING"}, "Failed to translate %d names, see '%s' in text editor" % (len(fails), text.name))
self.report(
{"WARNING"},
"Failed to translate %d names, see '%s' in text editor"
% (len(fails), text.name),
)
return {"FINISHED"}
class ExportTranslationCSVOperator(bpy.types.Operator):
bl_idname = "mmd_tools.export_translation_csv"
bl_description = "Export CSV for external translation."
bl_label = "Export Translation CSV"
filter_glob: bpy.props.StringProperty(default="*.csv", options={"HIDDEN"})
filename_ext = ".csv"
filepath: bpy.props.StringProperty(
name="File Path",
description="Path to save the translation CSV",
subtype="FILE_PATH",
default="mmd_translation.csv",
)
def _ensure_csv_extension(self):
"""Ensure the file path ends with a .csv extension (case-insensitive)."""
if not self.filepath.lower().endswith(".csv"):
self.filepath = bpy.path.ensure_ext(self.filepath, ".csv")
def invoke(self, context, event):
self._ensure_csv_extension()
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}
def execute(self, context):
self._ensure_csv_extension()
root_object = FnModel.find_root_object(context.active_object)
if root_object is None:
self.report({"ERROR"}, "Root object not found")
return {"CANCELLED"}
mmd_translation = root_object.mmd_root.translation
try:
with open(self.filepath, "w", newline="", encoding="utf-8") as csvfile:
writer = csv.writer(csvfile)
writer.writerow(["type", "blender", "japanese", "english"])
for idx in mmd_translation.filtered_translation_element_indices:
element = mmd_translation.translation_elements[idx.value]
writer.writerow(
[element.type, element.name, element.name_j, element.name_e],
)
except Exception as e:
self.report({"ERROR"}, f"Failed to write CSV: {e}")
return {"CANCELLED"}
self.report({"INFO"}, f"Exported to {os.path.basename(self.filepath)}")
return {"FINISHED"}
class ImportTranslationCSVOperator(bpy.types.Operator):
bl_idname = "mmd_tools.import_translation_csv"
bl_description = "Import translated CSV."
bl_label = "Import Translation CSV"
only_update_english_name: bpy.props.BoolProperty(
name="Only Update English Name",
description="(Enabled by default) Only update English name (name_e). otherwise, update all names when different",
default=True,
)
filter_glob: bpy.props.StringProperty(default="*.csv", options={"HIDDEN"})
filepath: bpy.props.StringProperty(
name="File Path",
description="Path to import the translation CSV",
subtype="FILE_PATH",
default="*.csv",
)
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}
def execute(self, context):
root_object = FnModel.find_root_object(context.active_object)
if root_object is None:
self.report({"ERROR"}, "Root object not found")
return {"CANCELLED"}
mmd_translation = root_object.mmd_root.translation
updated_count = 0
warnings = []
try:
with open(self.filepath, encoding="utf-8") as csvfile:
reader = csv.DictReader(csvfile)
required_headers = {"blender", "japanese", "english"}
if not required_headers.issubset(set(reader.fieldnames or [])):
missing = required_headers - set(reader.fieldnames or [])
self.report(
{"ERROR"},
f"Missing required headers in CSV: {', '.join(missing)}",
)
return {"CANCELLED"}
visible_indices = [
i.value
for i in mmd_translation.filtered_translation_element_indices
]
translation_elements_list = list(mmd_translation.translation_elements)
row_count = 0
for row in reader:
if row_count >= len(visible_indices):
row_count += 1
continue
element = translation_elements_list[visible_indices[row_count]]
b_name = row.get("blender", "").strip()
j_name = row.get("japanese", "").strip()
e_name = row.get("english", "").strip()
updated = False
if self.only_update_english_name:
if element.name_e != e_name:
element.name_e = e_name
updated = True
else:
if element.name != b_name:
element.name = b_name
updated = True
if element.name_j != j_name:
element.name_j = j_name
updated = True
if element.name_e != e_name:
element.name_e = e_name
updated = True
if updated:
updated_count += 1
row_count += 1
# Output warnings
if row_count > len(visible_indices):
warnings.append(
f"{row_count - len(visible_indices)} extra lines in CSV! (ignored)",
)
elif row_count < len(visible_indices):
warnings.append(
f"{len(visible_indices) - row_count} missing lines in CSV! (aborted translation)",
)
except Exception as e:
self.report({"ERROR"}, f"Failed to read CSV: {e}")
return {"CANCELLED"}
FnTranslations.update_query(mmd_translation)
msg = f"Imported {updated_count} entries from CSV"
if warnings:
for w in warnings:
self.report({"WARNING"}, w)
msg += " with warnings"
self.report({"INFO"}, msg)
return {"FINISHED"}
+39 -50
View File
@@ -1,49 +1,43 @@
# -*- 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.
# This file is part of MMD Tools.
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
from bpy.types import Operator
from mathutils import Matrix, Quaternion
class _SetShadingBase:
bl_options: Set[str] = {"REGISTER", "UNDO"}
bl_options = {"REGISTER", "UNDO"}
@staticmethod
def _get_view3d_spaces(context: Context) -> Iterator[Any]:
def _get_view3d_spaces(context):
if getattr(context.area, "type", None) == "VIEW_3D":
return (context.area.spaces[0],)
return (area.spaces[0] for area in getattr(context.screen, "areas", ()) if area.type == "VIEW_3D")
@staticmethod
def _reset_color_management(context: Context, use_display_device: bool = True) -> None:
def _reset_color_management(context, use_display_device=True):
try:
context.scene.display_settings.display_device = ("None", "sRGB")[use_display_device]
except TypeError:
pass
@staticmethod
def _reset_material_shading(context: Context, use_shadeless: bool = False) -> None:
def _reset_material_shading(context, use_shadeless=False):
for i in (x for x in context.scene.objects if x.type == "MESH" and x.mmd_type == "NONE"):
for s in i.material_slots:
if s.material is None:
continue
# use_nodes is deprecated in 5.0 but harmless to set
s.material.use_nodes = False
s.material.use_shadeless = use_shadeless
def execute(self, context: Context) -> Dict[str, str]:
context.scene.render.engine = "BLENDER_EEVEE_NEXT"
logger.debug(f"Setting render engine to BLENDER_EEVEE_NEXT")
def execute(self, context):
# Changed from BLENDER_EEVEE_NEXT to BLENDER_EEVEE for Blender 5.0
context.scene.render.engine = "BLENDER_EEVEE"
shading_mode: Optional[str] = getattr(self, "_shading_mode", None)
shading_mode = getattr(self, "_shading_mode", None)
for space in self._get_view3d_spaces(context):
shading = space.shading
shading.type = "SOLID"
@@ -51,40 +45,39 @@ class _SetShadingBase:
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"
bl_idname = "mmd_tools.set_glsl_shading"
bl_label = "GLSL View"
bl_description = "Use GLSL shading with additional lighting"
_shading_mode: str = "GLSL"
_shading_mode = "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"
bl_idname = "mmd_tools.set_shadeless_glsl_shading"
bl_label = "Shadeless GLSL View"
bl_description = "Use only toon shading"
_shading_mode: str = "SHADELESS"
_shading_mode = "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"
bl_idname = "mmd_tools.reset_shading"
bl_label = "Reset View"
bl_description = "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"}
bl_idname = "mmd_tools.flip_pose"
bl_label = "Flip Pose"
bl_description = "Apply the current pose of selected bones to matching bone on opposite side of X-Axis."
bl_options = {"REGISTER", "UNDO"}
# https://docs.blender.org/manual/en/dev/rigging/armatures/bones/editing/naming.html
__LR_REGEX: List[Dict[str, Any]] = [
__LR_REGEX = [
{"re": re.compile(r"^(.+)(RIGHT|LEFT)(\.\d+)?$", re.IGNORECASE), "lr": 1},
{"re": re.compile(r"^(.+)([\.\- _])(L|R)(\.\d+)?$", re.IGNORECASE), "lr": 2},
{"re": re.compile(r"^(LEFT|RIGHT)(.+)$", re.IGNORECASE), "lr": 0},
@@ -92,7 +85,7 @@ class FlipPose(Operator):
{"re": re.compile(r"^(.+)(左|右)(\.\d+)?$"), "lr": 1},
{"re": re.compile(r"^(左|右)(.+)$"), "lr": 0},
]
__LR_MAP: Dict[str, str] = {
__LR_MAP = {
"RIGHT": "LEFT",
"Right": "Left",
"right": "left",
@@ -108,7 +101,7 @@ class FlipPose(Operator):
}
@classmethod
def flip_name(cls, name: str) -> str:
def flip_name(cls, name):
for regex in cls.__LR_REGEX:
match = regex["re"].match(name)
if match:
@@ -126,15 +119,15 @@ class FlipPose(Operator):
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)])
def __cmul(vec1, vec2):
return type(vec1)([x * y for x, y in zip(vec1, vec2, strict=False)])
@staticmethod
def __matrix_compose(loc: Vector, rot: Quaternion, scale: Vector) -> Matrix:
def __matrix_compose(loc, rot, scale):
return (Matrix.Translation(loc) @ rot.to_matrix().to_4x4()) @ Matrix([(scale[0], 0, 0, 0), (0, scale[1], 0, 0), (0, 0, scale[2], 0), (0, 0, 0, 1)])
@classmethod
def __flip_pose(cls, matrix_basis: Matrix, bone_src: Any, bone_dest: Any) -> None:
def __flip_pose(cls, matrix_basis, bone_src, bone_dest):
m = bone_dest.bone.matrix_local.to_3x3().transposed()
mi = bone_src.bone.matrix_local.to_3x3().transposed().inverted() if bone_src != bone_dest else m.inverted()
loc, rot, scale = matrix_basis.decompose()
@@ -143,16 +136,12 @@ class FlipPose(Operator):
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 poll(cls, context):
obj = context.active_object
return obj is not None and obj.type == "ARMATURE" and obj.mode == "POSE"
def execute(self, context: Context) -> Dict[str, str]:
logger.info("Executing flip pose operation")
def execute(self, context):
pose_bones = context.active_object.pose.bones
for b, mat in [(x, x.matrix_basis.copy()) for x in context.selected_pose_bones]:
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")
self.__flip_pose(mat, b, pose_bones.get(self.flip_name(b.name), b))
return {"FINISHED"}
+20 -30
View File
@@ -1,90 +1,82 @@
# -*- 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.
# This file is part of MMD Tools.
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:
def _mmd_material_update_ambient_color(prop: "MMDMaterial", _context):
FnMaterial(prop.id_data).update_ambient_color()
def _mmd_material_update_diffuse_color(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
def _mmd_material_update_diffuse_color(prop: "MMDMaterial", _context):
FnMaterial(prop.id_data).update_diffuse_color()
def _mmd_material_update_alpha(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
def _mmd_material_update_alpha(prop: "MMDMaterial", _context):
FnMaterial(prop.id_data).update_alpha()
def _mmd_material_update_specular_color(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
def _mmd_material_update_specular_color(prop: "MMDMaterial", _context):
FnMaterial(prop.id_data).update_specular_color()
def _mmd_material_update_shininess(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
def _mmd_material_update_shininess(prop: "MMDMaterial", _context):
FnMaterial(prop.id_data).update_shininess()
def _mmd_material_update_is_double_sided(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
def _mmd_material_update_is_double_sided(prop: "MMDMaterial", _context):
FnMaterial(prop.id_data).update_is_double_sided()
def _mmd_material_update_sphere_texture_type(prop: "MMDMaterial", context: bpy.types.Context) -> None:
def _mmd_material_update_sphere_texture_type(prop: "MMDMaterial", context):
FnMaterial(prop.id_data).update_sphere_texture_type(context.active_object)
def _mmd_material_update_toon_texture(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
def _mmd_material_update_toon_texture(prop: "MMDMaterial", _context):
FnMaterial(prop.id_data).update_toon_texture()
def _mmd_material_update_enabled_drop_shadow(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
def _mmd_material_update_enabled_drop_shadow(prop: "MMDMaterial", _context):
FnMaterial(prop.id_data).update_drop_shadow()
def _mmd_material_update_enabled_self_shadow_map(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
def _mmd_material_update_enabled_self_shadow_map(prop: "MMDMaterial", _context):
FnMaterial(prop.id_data).update_self_shadow_map()
def _mmd_material_update_enabled_self_shadow(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
def _mmd_material_update_enabled_self_shadow(prop: "MMDMaterial", _context):
FnMaterial(prop.id_data).update_self_shadow()
def _mmd_material_update_enabled_toon_edge(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
def _mmd_material_update_enabled_toon_edge(prop: "MMDMaterial", _context):
FnMaterial(prop.id_data).update_enabled_toon_edge()
def _mmd_material_update_edge_color(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
def _mmd_material_update_edge_color(prop: "MMDMaterial", _context):
FnMaterial(prop.id_data).update_edge_color()
def _mmd_material_update_edge_weight(prop: "MMDMaterial", _context: bpy.types.Context) -> None:
def _mmd_material_update_edge_weight(prop: "MMDMaterial", _context):
FnMaterial(prop.id_data).update_edge_weight()
def _mmd_material_get_name_j(prop: "MMDMaterial") -> str:
def _mmd_material_get_name_j(prop: "MMDMaterial"):
return prop.get("name_j", "")
def _mmd_material_set_name_j(prop: "MMDMaterial", value: str) -> None:
def _mmd_material_set_name_j(prop: "MMDMaterial", value: str):
prop_value = value
if prop_value and prop_value != prop.get("name_j"):
root = FnModel.find_root_object(bpy.context.active_object)
if root is None:
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
@@ -279,15 +271,13 @@ class MMDMaterial(bpy.types.PropertyGroup):
description="Comment",
)
def is_id_unique(self) -> bool:
def is_id_unique(self):
return self.material_id < 0 or not next((m for m in bpy.data.materials if m.mmd_material != self and m.mmd_material.material_id == self.material_id), None)
@staticmethod
def register() -> None:
logger.debug("Registering MMD material properties")
def register():
bpy.types.Material.mmd_material = patch_library_overridable(bpy.props.PointerProperty(type=MMDMaterial))
@staticmethod
def unregister() -> None:
logger.debug("Unregistering MMD material properties")
def unregister():
del bpy.types.Material.mmd_material
+49 -58
View File
@@ -1,38 +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.
# Copyright 2015 MMD Tools authors
# This file is part of MMD Tools.
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:
def _morph_base_set_name(prop: "_MorphBase", value: str):
mmd_root = prop.id_data.mmd_root
morph_type = "%s_morphs" % prop.bl_rna.identifier[:-5].lower()
# morph_type = mmd_root.active_morph_type
morph_type = f"{prop.bl_rna.identifier[:-5].lower()}_morphs"
# assert(prop.bl_rna.identifier.endswith('Morph'))
# logging.debug('_set_name: %s %s %s', prop, value, morph_type)
prop_name = prop.get("name", None)
if prop_name == value:
return
used_names: Set[str] = {x.name for x in getattr(mmd_root, morph_type) if x != prop}
used_names = {x.name for x in getattr(mmd_root, morph_type) if x != prop}
value = utils.unique_name(value, used_names)
if prop_name is not None:
if morph_type == "vertex_morphs":
kb_list: Dict[str, List[ShapeKey]] = {}
kb_list = {}
for mesh in FnModel.iterate_mesh_objects(prop.id_data):
for kb in getattr(mesh.data.shape_keys, "key_blocks", ()):
kb_list.setdefault(kb.name, []).append(kb)
@@ -43,7 +39,7 @@ def _morph_base_set_name(prop: "_MorphBase", value: str) -> None:
kb.name = value
elif morph_type == "uv_morphs":
vg_list: Dict[str, List[Any]] = {}
vg_list = {}
for mesh in FnModel.iterate_mesh_objects(prop.id_data):
for vg, n, x in FnMorph.get_uv_morph_vertex_groups(mesh):
vg_list.setdefault(n, []).append(vg)
@@ -72,7 +68,6 @@ def _morph_base_set_name(prop: "_MorphBase", value: str) -> None:
kb.name = value
prop["name"] = value
logger.debug(f"Renamed morph from '{prop_name}' to '{value}'")
class _MorphBase:
@@ -101,12 +96,16 @@ class _MorphBase:
)
def _bone_morph_data_update_bone_id(prop: "BoneMorphData", context: bpy.types.Context):
pass # Empty function is sufficient to trigger UI update
def _bone_morph_data_get_bone(prop: "BoneMorphData") -> str:
bone_id: int = prop.get("bone_id", -1)
bone_id = 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)
root_object = prop.id_data
armature_object = FnModel.find_armature_object(root_object)
if armature_object is None:
return ""
pose_bone = FnBone.find_pose_bone_by_bone_id(armature_object, bone_id)
@@ -115,9 +114,9 @@ def _bone_morph_data_get_bone(prop: "BoneMorphData") -> str:
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)
def _bone_morph_data_set_bone(prop: "BoneMorphData", value: str):
root = prop.id_data
arm = FnModel.find_armature_object(root)
# Load the library_override file. This function is triggered when loading, but the arm obj cannot be found.
# The arm obj is exist, but the relative relationship has not yet been established.
@@ -125,14 +124,13 @@ def _bone_morph_data_set_bone(prop: "BoneMorphData", value: str) -> None:
return
if value not in arm.pose.bones.keys():
prop["bone_id"] = -1
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']}")
prop.bone_id = FnBone.get_or_assign_bone_id(pose_bone)
def _bone_morph_data_update_location_or_rotation(prop: "BoneMorphData", _context: bpy.types.Context) -> None:
def _bone_morph_data_update_location_or_rotation(prop: "BoneMorphData", _context):
if not prop.name.startswith("mmd_bind"):
return
arm = FnModel(prop.id_data).morph_slider.dummy_armature
@@ -141,12 +139,9 @@ def _bone_morph_data_update_location_or_rotation(prop: "BoneMorphData", _context
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",
@@ -156,6 +151,7 @@ class BoneMorphData(bpy.types.PropertyGroup):
bone_id: bpy.props.IntProperty(
name="Bone ID",
update=_bone_morph_data_update_bone_id,
)
location: bpy.props.FloatVectorProperty(
@@ -191,61 +187,53 @@ class BoneMorph(_MorphBase, bpy.types.PropertyGroup):
)
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
def _material_morph_data_get_material(prop: "MaterialMorphData"):
mat_data = prop.get("material_data", None)
if mat_data is not None:
return mat_data.name
return ""
def _material_morph_data_set_material(prop: "MaterialMorphData", value: str) -> None:
def _material_morph_data_set_material(prop: "MaterialMorphData", value: str):
if value not in bpy.data.materials:
prop["material_data"] = None
prop["material_id"] = -1
logger.debug(f"Material '{value}' not found, setting material_data to None")
prop.material_data = None
prop.material_id = -1
else:
mat = bpy.data.materials[value]
fnMat = FnMaterial(mat)
prop["material_data"] = mat
prop["material_id"] = fnMat.material_id
logger.debug(f"Set material morph data material to '{value}' with ID {fnMat.material_id}")
prop.material_data = mat
prop.material_id = fnMat.material_id
def _material_morph_data_set_related_mesh(prop: "MaterialMorphData", value: str) -> None:
def _material_morph_data_set_related_mesh(prop: "MaterialMorphData", value: str):
mesh = FnModel.find_mesh_object_by_name(prop.id_data, value)
if mesh is not None:
prop["related_mesh_data"] = mesh.data
logger.debug(f"Set material morph data related mesh to '{value}'")
prop.related_mesh_data = mesh.data
else:
prop["related_mesh_data"] = None
logger.debug(f"Mesh '{value}' not found, setting related_mesh_data to None")
prop.related_mesh_data = 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
def _material_morph_data_get_related_mesh(prop):
mesh_data = prop.get("related_mesh_data", None)
if mesh_data is not None:
return mesh_data.name
return ""
def _material_morph_data_update_modifiable_values(prop: "MaterialMorphData", _context: bpy.types.Context) -> None:
def _material_morph_data_update_modifiable_values(prop: "MaterialMorphData", _context):
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}'")
mat_data = prop.get("material_data", None)
if mat_data is not None:
_MaterialMorph.update_morph_inputs(mat_data, prop)
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")
for mat_data in FnModel(prop.id_data).materials():
_MaterialMorph.update_morph_inputs(mat_data, prop)
class MaterialMorphData(bpy.types.PropertyGroup):
""" """
related_mesh: bpy.props.StringProperty(
name="Related Mesh",
description="Stores a reference to the mesh where this morph data belongs to",
@@ -416,6 +404,9 @@ class UVMorphOffset(bpy.types.PropertyGroup):
name="UV Offset",
description="UV offset",
size=4,
# min=-1,
# max=1,
# precision=3,
step=0.1,
default=[0, 0, 0, 0],
)
+85 -29
View File
@@ -1,37 +1,31 @@
# -*- 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.
# This file is part of MMD Tools.
from typing import cast
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
def _mmd_bone_update_additional_transform(prop: "MMDBone", context: bpy.types.Context):
prop.is_additional_transform_dirty = True
# Apply additional transform (Assembly -> Bone button) (Very Slow)
p_bone = context.active_pose_bone
if p_bone and p_bone.mmd_bone.as_pointer() == prop.as_pointer():
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:
def _mmd_bone_update_additional_transform_influence(prop: "MMDBone", context: bpy.types.Context):
pose_bone = context.active_pose_bone
if pose_bone and pose_bone.mmd_bone.as_pointer() == prop.as_pointer():
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
prop.is_additional_transform_dirty = True
def _mmd_bone_get_additional_transform_bone(prop: "MMDBone") -> str:
def _mmd_bone_get_additional_transform_bone(prop: "MMDBone"):
arm = prop.id_data
bone_id = prop.get("additional_transform_bone_id", -1)
if bone_id < 0:
@@ -42,17 +36,57 @@ def _mmd_bone_get_additional_transform_bone(prop: "MMDBone") -> str:
return pose_bone.name
def _mmd_bone_set_additional_transform_bone(prop: "MMDBone", value: str) -> None:
def _mmd_bone_set_additional_transform_bone(prop: "MMDBone", value: str):
arm = prop.id_data
prop["is_additional_transform_dirty"] = True
prop.is_additional_transform_dirty = True
if value not in arm.pose.bones.keys():
prop["additional_transform_bone_id"] = -1
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)
target_bone_id = FnBone.get_or_assign_bone_id(pose_bone)
if prop.bone_id == target_bone_id:
prop.additional_transform_bone_id = -1
return
prop.additional_transform_bone_id = target_bone_id
class MMDBone(PropertyGroup):
def _mmd_bone_update_display_connection(prop: "MMDBone", context: bpy.types.Context):
pass # Empty function is sufficient to trigger UI update
def _mmd_bone_get_display_connection_bone(prop: "MMDBone"):
arm = prop.id_data
bone_id = prop.get("display_connection_bone_id", -1)
if bone_id < 0:
return ""
pose_bone = FnBone.find_pose_bone_by_bone_id(arm, bone_id)
if pose_bone is None:
return ""
return pose_bone.name
def _mmd_bone_set_display_connection_bone(prop: "MMDBone", value: str):
arm = prop.id_data
if value not in arm.pose.bones.keys():
prop.display_connection_bone_id = -1
return
pose_bone = arm.pose.bones[value]
target_bone_id = FnBone.get_or_assign_bone_id(pose_bone)
if prop.bone_id == target_bone_id:
prop.display_connection_bone_id = -1
return
prop.display_connection_bone_id = target_bone_id
class MMDBone(bpy.types.PropertyGroup):
name_j: bpy.props.StringProperty(
name="Name",
description="Japanese Name",
@@ -188,12 +222,35 @@ class MMDBone(PropertyGroup):
is_additional_transform_dirty: bpy.props.BoolProperty(name="", default=True)
def is_id_unique(self) -> bool:
display_connection_bone: bpy.props.StringProperty(
name="Display Connection Bone",
description="Target bone for display connection",
set=_mmd_bone_set_display_connection_bone,
get=_mmd_bone_get_display_connection_bone,
)
display_connection_bone_id: bpy.props.IntProperty(
name="Display Connection Bone ID",
description="Bone ID for display connection (PMX displayConnection)",
default=-1,
update=_mmd_bone_update_display_connection,
)
display_connection_type: bpy.props.EnumProperty(
name="Display Connection Type",
description="Type of display connection",
items=[
("BONE", "Bone", "Connected to a bone"),
("OFFSET", "Offset", "Connected to an offset position"),
],
default="OFFSET",
)
def is_id_unique(self):
return self.bone_id < 0 or not next((b for b in self.id_data.pose.bones if b.mmd_bone != self and b.mmd_bone.bone_id == self.bone_id), None)
@staticmethod
def register() -> None:
logger.debug("Registering MMDBone properties")
def register():
bpy.types.PoseBone.mmd_bone = patch_library_overridable(bpy.props.PointerProperty(type=MMDBone))
bpy.types.PoseBone.is_mmd_shadow_bone = patch_library_overridable(bpy.props.BoolProperty(name="is_mmd_shadow_bone", default=False))
bpy.types.PoseBone.mmd_shadow_bone_type = patch_library_overridable(bpy.props.StringProperty(name="mmd_shadow_bone_type"))
@@ -203,25 +260,24 @@ class MMDBone(PropertyGroup):
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")
def unregister():
del bpy.types.PoseBone.mmd_ik_toggle
del bpy.types.PoseBone.mmd_shadow_bone_type
del bpy.types.PoseBone.is_mmd_shadow_bone
del bpy.types.PoseBone.mmd_bone
def _pose_bone_update_mmd_ik_toggle(prop: PoseBone, _context: Any) -> None:
def _pose_bone_update_mmd_ik_toggle(prop: bpy.types.PoseBone, _context):
v = prop.mmd_ik_toggle
armature_object = cast(Object, prop.id_data)
armature_object = cast("bpy.types.Object", prop.id_data)
for b in armature_object.pose.bones:
for c in b.constraints:
if c.type == "IK" and c.subtarget == prop.name:
logger.debug(f"Updating IK toggle for {b.name} {c.name}")
# logging.debug(' %s %s', b.name, c.name)
c.influence = v
b = b if c.use_tail else b.parent
for b in ([b] + b.parent_recursive)[: c.chain_count]:
+34 -43
View File
@@ -1,42 +1,35 @@
# -*- 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.
# This file is part of MMD Tools.
"""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 ..core.rigid_body import FnRigidBody, RigidBodyMaterial
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
def _updateCollisionGroup(prop, _context):
obj = prop.id_data
materials = obj.data.materials
if len(materials) == 0:
materials.append(RigidBodyMaterial.getMaterial(prop.collision_group_number))
else:
obj.material_slots[0].material = RigidBodyMaterial.getMaterial(prop.collision_group_number)
def _updateType(prop: PropertyGroup, _context: Context) -> None:
obj: Object = prop.id_data
def _updateType(prop, _context):
obj = prop.id_data
rb = obj.rigid_body
if rb:
rb.kinematic = int(prop.type) == rigid_body.MODE_STATIC
def _updateShape(prop: PropertyGroup, _context: Context) -> None:
obj: Object = prop.id_data
def _updateShape(prop, _context):
obj = prop.id_data
if len(obj.data.vertices) > 0:
size = prop.size
@@ -47,8 +40,8 @@ def _updateShape(prop: PropertyGroup, _context: Context) -> None:
rb.collision_shape = prop.shape
def _get_bone(prop: PropertyGroup) -> str:
obj: Object = prop.id_data
def _get_bone(prop):
obj = prop.id_data
relation = obj.constraints.get("mmd_tools_rigid_parent", None)
if relation:
arm = relation.target
@@ -58,9 +51,9 @@ def _get_bone(prop: PropertyGroup) -> str:
return prop.get("bone", "")
def _set_bone(prop: PropertyGroup, value: str) -> None:
bone_name: str = value
obj: Object = prop.id_data
def _set_bone(prop, value):
bone_name = value
obj = prop.id_data
relation = obj.constraints.get("mmd_tools_rigid_parent", None)
if relation is None:
relation = obj.constraints.new("CHILD_OF")
@@ -81,21 +74,24 @@ def _set_bone(prop: PropertyGroup, value: str) -> None:
prop["bone"] = bone_name
def _get_size(prop: PropertyGroup) -> Tuple[float, float, float]:
def _get_size(prop):
if prop.id_data.mmd_type != "RIGID_BODY":
return (0, 0, 0)
return FnRigidBody.get_rigid_body_size(prop.id_data)
def _set_size(prop: PropertyGroup, value: Tuple[float, float, float]) -> None:
obj: Object = prop.id_data
def _set_size(prop, value):
obj = prop.id_data
assert obj.mode == "OBJECT" # not support other mode yet
shape: str = prop.shape
shape = prop.shape
mesh = obj.data
rb = obj.rigid_body
if len(mesh.vertices) == 0 or rb is None or rb.collision_shape != shape:
current_size = FnRigidBody.get_rigid_body_size(obj)
is_zero_size = all(abs(s) < 1e-6 for s in current_size)
if len(mesh.vertices) == 0 or rb is None or rb.collision_shape != shape or is_zero_size:
if shape == "SPHERE":
bpyutils.makeSphere(
radius=value[0],
@@ -149,15 +145,15 @@ def _set_size(prop: PropertyGroup, value: Tuple[float, float, float]) -> None:
mesh.update()
def _get_rigid_name(prop: PropertyGroup) -> str:
def _get_rigid_name(prop):
return prop.get("name", "")
def _set_rigid_name(prop: PropertyGroup, value: str) -> None:
def _set_rigid_name(prop, value):
prop["name"] = value
class MMDRigidBody(PropertyGroup):
class MMDRigidBody(bpy.types.PropertyGroup):
name_j: bpy.props.StringProperty(
name="Name",
description="Japanese Name",
@@ -230,18 +226,16 @@ class MMDRigidBody(PropertyGroup):
)
@staticmethod
def register() -> None:
logger.debug("Registering MMDRigidBody property")
def register():
bpy.types.Object.mmd_rigid = patch_library_overridable(bpy.props.PointerProperty(type=MMDRigidBody))
@staticmethod
def unregister() -> None:
logger.debug("Unregistering MMDRigidBody property")
def unregister():
del bpy.types.Object.mmd_rigid
def _updateSpringLinear(prop: PropertyGroup, context: Context) -> None:
obj: Object = prop.id_data
def _updateSpringLinear(prop, context):
obj = prop.id_data
rbc = obj.rigid_body_constraint
if rbc:
rbc.spring_stiffness_x = prop.spring_linear[0]
@@ -249,8 +243,8 @@ def _updateSpringLinear(prop: PropertyGroup, context: Context) -> None:
rbc.spring_stiffness_z = prop.spring_linear[2]
def _updateSpringAngular(prop: PropertyGroup, context: Context) -> None:
obj: Object = prop.id_data
def _updateSpringAngular(prop, context):
obj = prop.id_data
rbc = obj.rigid_body_constraint
if rbc and hasattr(rbc, "use_spring_ang_x"):
rbc.spring_stiffness_ang_x = prop.spring_angular[0]
@@ -258,7 +252,7 @@ def _updateSpringAngular(prop: PropertyGroup, context: Context) -> None:
rbc.spring_stiffness_ang_z = prop.spring_angular[2]
class MMDJoint(PropertyGroup):
class MMDJoint(bpy.types.PropertyGroup):
name_j: bpy.props.StringProperty(
name="Name",
description="Japanese Name",
@@ -292,12 +286,9 @@ class MMDJoint(PropertyGroup):
)
@staticmethod
def register() -> None:
logger.debug("Registering MMDJoint property")
def register():
bpy.types.Object.mmd_joint = patch_library_overridable(bpy.props.PointerProperty(type=MMDJoint))
@staticmethod
def unregister() -> None:
logger.debug("Unregistering MMDJoint property")
def unregister():
del bpy.types.Object.mmd_joint
+87 -56
View File
@@ -1,16 +1,10 @@
# -*- 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.
# This file is part of MMD Tools.
"""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
@@ -18,18 +12,19 @@ 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
IS_BLENDER_50_UP = bpy.app.version >= (5, 0)
def __driver_variables(constraint: bpy.types.Constraint, path: str, index: int = -1) -> Tuple[bpy.types.Driver, Any]:
def __driver_variables(constraint: bpy.types.Constraint, path: str, index=-1):
d = constraint.driver_add(path, index)
variables = d.driver.variables
for x in variables:
for x in reversed(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:
def __add_single_prop(variables, id_obj, data_path, prefix):
var = variables.new()
var.name = prefix + str(len(variables))
var.type = "SINGLE_PROP"
@@ -40,18 +35,17 @@ def __add_single_prop(variables: Any, id_obj: bpy.types.Object, data_path: str,
return var
def _toggleUsePropertyDriver(self: "MMDRoot", _context: bpy.types.Context) -> None:
def _toggleUsePropertyDriver(self: "MMDRoot", _context):
root_object: bpy.types.Object = self.id_data
armature_object = FnModel.find_armature_object(root_object)
if armature_object is None:
ik_map: Dict[Any, Tuple[Any, Any]] = {}
ik_map = {}
else:
bones = armature_object.pose.bones
ik_map = {bones[c.subtarget]: (b, c) for b in bones for c in b.constraints if c.type == "IK" and c.is_valid and c.subtarget in bones}
if self.use_property_driver:
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
@@ -66,7 +60,6 @@ def _toggleUsePropertyDriver(self: "MMDRoot", _context: bpy.types.Context) -> No
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
@@ -84,35 +77,31 @@ def _toggleUsePropertyDriver(self: "MMDRoot", _context: bpy.types.Context) -> No
# ===========================================
def _toggleUseToonTexture(self: "MMDRoot", _context: bpy.types.Context) -> None:
def _toggleUseToonTexture(self: "MMDRoot", _context):
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:
def _toggleUseSphereTexture(self: "MMDRoot", _context):
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:
def _toggleUseSDEF(self: "MMDRoot", _context):
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:
def _toggleVisibilityOfMeshes(self: "MMDRoot", context: bpy.types.Context):
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
@@ -120,30 +109,27 @@ def _toggleVisibilityOfMeshes(self: "MMDRoot", context: bpy.types.Context) -> No
FnContext.set_active_object(context, root)
def _toggleVisibilityOfRigidBodies(self: "MMDRoot", context: bpy.types.Context) -> None:
def _toggleVisibilityOfRigidBodies(self: "MMDRoot", context: bpy.types.Context):
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:
def _toggleVisibilityOfJoints(self: "MMDRoot", context):
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:
def _toggleVisibilityOfTemporaryObjects(self: "MMDRoot", context: bpy.types.Context):
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)
@@ -151,48 +137,45 @@ def _toggleVisibilityOfTemporaryObjects(self: "MMDRoot", context: bpy.types.Cont
FnContext.set_active_object(context, root_object)
def _toggleShowNamesOfRigidBodies(self: "MMDRoot", _context: bpy.types.Context) -> None:
def _toggleShowNamesOfRigidBodies(self: "MMDRoot", _context):
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:
def _toggleShowNamesOfJoints(self: "MMDRoot", _context):
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:
def _setVisibilityOfMMDRigArmature(prop: "MMDRoot", v: bool):
root = prop.id_data
arm = FnModel.find_armature_object(root)
if arm is None:
return
if not v and bpy.context.active_object == arm:
FnContext.set_active_object(bpy.context, root)
logger.debug("Setting armature visibility to %s for %s", v, root.name)
arm.hide_set(not v)
def _getVisibilityOfMMDRigArmature(prop: "MMDRoot") -> bool:
def _getVisibilityOfMMDRigArmature(prop: "MMDRoot"):
if prop.id_data.mmd_type != "ROOT":
return False
arm = FnModel.find_armature_object(prop.id_data)
return arm and not arm.hide_get()
return arm is not None and not arm.hide_get()
def _setActiveRigidbodyObject(prop: "MMDRoot", v: int) -> None:
def _setActiveRigidbodyObject(prop: "MMDRoot", v: int):
obj = FnContext.get_scene_objects(bpy.context)[v]
if FnModel.is_rigid_body_object(obj):
FnContext.set_active_and_select_single_object(bpy.context, obj)
prop["active_rigidbody_object_index"] = v
def _getActiveRigidbodyObject(prop: "MMDRoot") -> int:
def _getActiveRigidbodyObject(prop: "MMDRoot"):
context = bpy.context
active_obj = FnContext.get_active_object(context)
if FnModel.is_rigid_body_object(active_obj):
@@ -200,14 +183,14 @@ def _getActiveRigidbodyObject(prop: "MMDRoot") -> int:
return prop.get("active_rigidbody_object_index", 0)
def _setActiveJointObject(prop: "MMDRoot", v: int) -> None:
def _setActiveJointObject(prop: "MMDRoot", v: int):
obj = FnContext.get_scene_objects(bpy.context)[v]
if FnModel.is_joint_object(obj):
FnContext.set_active_and_select_single_object(bpy.context, obj)
prop["active_joint_object_index"] = v
def _getActiveJointObject(prop: "MMDRoot") -> int:
def _getActiveJointObject(prop: "MMDRoot"):
context = bpy.context
active_obj = FnContext.get_active_object(context)
if FnModel.is_joint_object(active_obj):
@@ -215,26 +198,26 @@ def _getActiveJointObject(prop: "MMDRoot") -> int:
return prop.get("active_joint_object_index", 0)
def _setActiveMorph(prop: "MMDRoot", v: bool) -> None:
def _setActiveMorph(prop: "MMDRoot", v: bool):
if "active_morph_indices" not in prop:
prop["active_morph_indices"] = [0] * 5
prop["active_morph_indices"][prop.get("active_morph_type", 3)] = v
def _getActiveMorph(prop: "MMDRoot") -> int:
def _getActiveMorph(prop: "MMDRoot"):
if "active_morph_indices" in prop:
return prop["active_morph_indices"][prop.get("active_morph_type", 3)]
return 0
def _setActiveMeshObject(prop: "MMDRoot", v: int) -> None:
def _setActiveMeshObject(prop: "MMDRoot", v: int):
obj = FnContext.get_scene_objects(bpy.context)[v]
if FnModel.is_mesh_object(obj):
FnContext.set_active_and_select_single_object(bpy.context, obj)
prop["active_mesh_index"] = v
def _getActiveMeshObject(prop: "MMDRoot") -> int:
def _getActiveMeshObject(prop: "MMDRoot"):
context = bpy.context
active_obj = FnContext.get_active_object(context)
if FnModel.is_mesh_object(active_obj):
@@ -393,6 +376,18 @@ class MMDRoot(bpy.types.PropertyGroup):
update=_toggleShowNamesOfJoints,
)
show_japanese_name: bpy.props.BoolProperty(
name="Japanese name",
description="Toggle Japanese name display",
default=True,
)
show_english_name: bpy.props.BoolProperty(
name="English name",
description="Toggle English name display",
default=True,
)
use_toon_texture: bpy.props.BoolProperty(
name="Use Toon Texture",
description="Use toon texture",
@@ -453,6 +448,15 @@ class MMDRoot(bpy.types.PropertyGroup):
default=0,
)
# *************************
# Bone
# *************************
active_bone_index: bpy.props.IntProperty(
name="Active Bone Index",
description="Index of the active bone in the armature",
default=0,
)
# *************************
# Morph
# *************************
@@ -513,29 +517,40 @@ class MMDRoot(bpy.types.PropertyGroup):
@staticmethod
def __get_select(prop: bpy.types.Object) -> bool:
utils.warn_deprecation("Object.select", "v4.0.0", "Use Object.select_get() method instead")
# TODO: Object.select is deprecated since v4.0.0, use Object.select_get() method instead
# utils.warn_deprecation("Object.select", "v4.0.0", "Use Object.select_get() method instead")
return prop.select_get()
@staticmethod
def __set_select(prop: bpy.types.Object, value: bool) -> None:
utils.warn_deprecation("Object.select", "v4.0.0", "Use Object.select_set() method instead")
# TODO: Object.select is deprecated since v4.0.0, use Object.select_set() method instead
# utils.warn_deprecation("Object.select", "v4.0.0", "Use Object.select_set() method instead")
prop.select_set(value)
@staticmethod
def __get_hide(prop: bpy.types.Object) -> bool:
utils.warn_deprecation("Object.hide", "v4.0.0", "Use Object.hide_get() method instead")
# TODO: Object.hide is deprecated since v4.0.0, use Object.hide_get() method instead
# utils.warn_deprecation("Object.hide", "v4.0.0", "Use Object.hide_get() method instead")
return prop.hide_get()
@staticmethod
def __set_hide(prop: bpy.types.Object, value: bool) -> None:
utils.warn_deprecation("Object.hide", "v4.0.0", "Use Object.hide_set() method instead")
# TODO: Object.hide is deprecated since v4.0.0, use Object.hide_set() method instead
# utils.warn_deprecation("Object.hide", "v4.0.0", "Use Object.hide_set() method instead")
prop.hide_set(value)
if prop.hide_viewport != value:
prop.hide_viewport = value
@staticmethod
def register() -> None:
logger.debug("Registering MMDRoot property group")
def __get_pose_bone_select(prop: bpy.types.PoseBone) -> bool:
return prop.bone.select
@staticmethod
def __set_pose_bone_select(prop: bpy.types.PoseBone, value: bool) -> None:
prop.bone.select = value
@staticmethod
def register():
bpy.types.Object.mmd_type = patch_library_overridable(
bpy.props.EnumProperty(
name="Type",
@@ -557,7 +572,7 @@ class MMDRoot(bpy.types.PropertyGroup):
("SPRING_CONSTRAINT", "Spring Constraint", "", 53),
("SPRING_GOAL", "Spring Goal", "", 54),
],
)
),
)
bpy.types.Object.mmd_root = patch_library_overridable(bpy.props.PointerProperty(type=MMDRoot))
@@ -570,7 +585,7 @@ class MMDRoot(bpy.types.PropertyGroup):
"ANIMATABLE",
"LIBRARY_EDITABLE",
},
)
),
)
bpy.types.Object.hide = patch_library_overridable(
bpy.props.BoolProperty(
@@ -581,13 +596,29 @@ class MMDRoot(bpy.types.PropertyGroup):
"ANIMATABLE",
"LIBRARY_EDITABLE",
},
)
),
)
if not IS_BLENDER_50_UP:
bpy.types.PoseBone.select = patch_library_overridable(
bpy.props.BoolProperty(
name="Select",
description="Pose bone selection state (compatibility layer for Blender 4.x, forwards to bone.select)",
get=MMDRoot.__get_pose_bone_select,
set=MMDRoot.__set_pose_bone_select,
options={
"SKIP_SAVE",
"ANIMATABLE",
"LIBRARY_EDITABLE",
},
),
)
@staticmethod
def unregister() -> None:
logger.debug("Unregistering MMDRoot property group")
def unregister():
del bpy.types.Object.hide
del bpy.types.Object.select
del bpy.types.Object.mmd_root
del bpy.types.Object.mmd_type
if not IS_BLENDER_50_UP:
del bpy.types.PoseBone.select
+2 -6
View File
@@ -1,9 +1,5 @@
# -*- 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.
# Copyright 2021 MMD Tools authors
# This file is part of MMD Tools.
from typing import Dict, List, Optional, Tuple
+44 -105
View File
@@ -1,25 +1,17 @@
# -*- 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.
# Copyright 2016 MMD Tools authors
# This file is part of MMD Tools.
import csv
from ...core.logging_setup import logger
import os
import time
from typing import List, Tuple, Dict, Optional, Any, Generator, Union, TextIO, Iterator, Set
from collections import OrderedDict
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_half_to_full_tuples = (
("ヴ", ""),
("ガ", ""),
("ギ", ""),
@@ -109,7 +101,7 @@ jp_half_to_full_tuples: TranslationList = (
("", ""),
)
jp_to_en_tuples: TranslationList = [
jp_to_en_tuples = [
("全ての親", "ParentNode"),
("操作中心", "ControlNode"),
("センター", "Center"),
@@ -299,30 +291,22 @@ jp_to_en_tuples: TranslationList = [
]
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}")
def translateFromJp(name):
for t in jp_to_en_tuples:
if t[0] in name:
name = name.replace(t[0], t[1])
return name
def getTranslator(csvfile: Union[str, Dict[str, str], Text] = "", keep_order: bool = False) -> 'MMDTranslator':
"""Get a translator instance with the specified CSV file."""
def getTranslator(csvfile="", keep_order=False):
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:
@@ -332,20 +316,16 @@ def getTranslator(csvfile: Union[str, Dict[str, str], Text] = "", keep_order: bo
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] = {}
def __init__(self):
self.__csv_tuples = []
self.__fails = {}
@staticmethod
def default_csv_filepath() -> str:
"""Get the default CSV filepath for translations."""
def default_csv_filepath():
return __file__[:-3] + ".csv"
@staticmethod
def get_csv_text(text_name: Optional[str] = None) -> Text:
"""Get or create a Text object for CSV data."""
def get_csv_text(text_name=None):
text_name = text_name or bpy.path.basename(MMDTranslator.default_csv_filepath())
csv_text = bpy.data.texts.get(text_name, None)
if csv_text is None:
@@ -353,88 +333,67 @@ class MMDTranslator:
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."""
def replace_from_tuples(name, tuples):
for pair in tuples:
if pair[0] in name:
name = name.replace(pair[0], pair[1])
return name
@property
def csv_tuples(self) -> List[Tuple[str, str]]:
"""Get the CSV tuples."""
def csv_tuples(self):
return self.__csv_tuples
@property
def fails(self) -> Dict[str, str]:
"""Get the failed translations."""
def fails(self):
return self.__fails
def sort(self) -> None:
"""Sort the CSV tuples by length (longest first) and then alphabetically."""
logger.debug("Sorting translation tuples")
def sort(self):
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
def update(self):
count_old = len(self.__csv_tuples)
tuples_dict = OrderedDict((row[0], row) for row in self.__csv_tuples if len(row) >= 2 and row[0])
self.__csv_tuples.clear()
self.__csv_tuples.extend(tuples_dict.values())
logger.info("Translation update - removed items: %d (of %d)", count_old - len(self.__csv_tuples), count_old)
logger.info(" - removed items:\t%d\t(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."""
def half_to_full(self, name):
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)."""
def is_translated(self, name):
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}")
def translate(self, name, default=None, from_full_width=True):
if from_full_width:
name = self.half_to_full(name)
name_new = self.replace_from_tuples(name, self.__csv_tuples)
if default is not None and not self.is_translated(name_new):
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."""
def save_fails(self, text_name=None):
text_name = text_name or (__name__ + ".fails")
txt = self.get_csv_text(text_name)
fmt = '"%s","%s"'
items = sorted(self.__fails.items(), key=lambda row: (-len(row[0]), row))
txt.from_string("\n".join(fmt % (k, v) for k, v in items))
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."""
def load_from_stream(self, csvfile=None):
csvfile = csvfile or self.get_csv_text()
if isinstance(csvfile, bpy.types.Text):
csvfile = (l.body + "\n" for l in csvfile.lines)
csvfile = (line.body + "\n" for line in csvfile.lines)
spamreader = csv.reader(csvfile, delimiter=",", skipinitialspace=True)
csv_tuples = [tuple(row) for row in spamreader if len(row) >= 2]
self.__csv_tuples = csv_tuples
logger.info("Loaded %d translation items", len(self.__csv_tuples))
logger.info(" - load items:\t%d", 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
"""
def save_to_stream(self, csvfile=None):
csvfile = csvfile or self.get_csv_text()
lineterminator = "\r\n"
if isinstance(csvfile, bpy.types.Text):
@@ -442,38 +401,27 @@ class MMDTranslator:
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))
logger.info(" - save items:\t%d", len(self.__csv_tuples))
def load(self, filepath: Optional[str] = None) -> None:
"""Load translations from a file."""
def load(self, filepath=None):
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}")
logger.info("Loading csv file:\t%s", filepath)
with open(filepath, encoding="utf-8", newline="") as csvfile:
self.load_from_stream(csvfile)
def save(self, filepath: Optional[str] = None) -> None:
"""Save translations to a file."""
def save(self, filepath=None):
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}")
logger.info("Saving csv file:\t%s", filepath)
with open(filepath, "w", encoding="utf-8", newline="") as csvfile:
self.save_to_stream(csvfile)
class DictionaryEnum:
"""Handles dictionary enumeration for UI."""
__items_ttl: float = 0.0
__items_cache: Optional[List[Tuple[str, str, str, int]]] = None
__items_ttl = 0.0
__items_cache = 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."""
def get_dictionary_items(prop, context):
if DictionaryEnum.__items_ttl > time.time():
return DictionaryEnum.__items_cache
@@ -487,8 +435,6 @@ class DictionaryEnum:
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")):
@@ -498,19 +444,12 @@ class DictionaryEnum:
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."""
def get_translator(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)
+102 -74
View File
@@ -1,23 +1,20 @@
# -*- 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.
# Copyright 2012 MMD Tools authors
# This file is part of MMD Tools.
from ...core.logging_setup import logger
import os
import re
from typing import Callable, Dict, List, Optional, Set, Tuple, Union, Any
import string
from typing import Callable, Optional, Set
import bpy
from bpy.types import Object, Bone, PoseBone, Mesh, VertexGroup
import numpy as np
from ..logging_setup import logger
from .bpyutils import FnContext
## 指定したオブジェクトのみを選択状態かつアクティブにする
def selectAObject(obj: Object) -> None:
# 指定したオブジェクトのみを選択状態かつアクティブにする
def selectAObject(obj):
try:
bpy.ops.object.mode_set(mode="OBJECT")
except Exception:
@@ -27,14 +24,14 @@ def selectAObject(obj: Object) -> None:
FnContext.set_active_object(FnContext.ensure_context(), obj)
## 現在のモードを指定したオブジェクトのEdit Modeに変更する
def enterEditMode(obj: Object) -> None:
# 現在のモードを指定したオブジェクトのEdit Modeに変更する
def enterEditMode(obj):
selectAObject(obj)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
def setParentToBone(obj: Object, parent: Object, bone_name: str) -> None:
def setParentToBone(obj, parent, bone_name):
selectAObject(obj)
FnContext.set_active_object(FnContext.ensure_context(), parent)
bpy.ops.object.mode_set(mode="POSE")
@@ -43,11 +40,11 @@ def setParentToBone(obj: Object, parent: Object, bone_name: str) -> None:
bpy.ops.object.mode_set(mode="OBJECT")
def selectSingleBone(context: bpy.types.Context, armature: Object, bone_name: str, reset_pose: bool = False) -> None:
def selectSingleBone(context, armature, bone_name, reset_pose=False):
try:
bpy.ops.object.mode_set(mode="OBJECT")
except:
pass
except Exception as e:
logger.warning(f"Failed to set object mode: {e}")
for i in context.selected_objects:
i.select_set(False)
FnContext.set_active_object(context, armature)
@@ -55,22 +52,21 @@ def selectSingleBone(context: bpy.types.Context, armature: Object, bone_name: st
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
for p_bone in armature.pose.bones:
is_target = p_bone.name == bone_name
p_bone.select = is_target
if is_target:
armature.data.bones.active = p_bone.bone
p_bone.bone.hide = False
__CONVERT_NAME_TO_L_REGEXP = re.compile("^(.*)左(.*)$")
__CONVERT_NAME_TO_R_REGEXP = re.compile("^(.*)右(.*)$")
__CONVERT_NAME_TO_L_REGEXP = re.compile(r"^(.*)左(.*)$")
__CONVERT_NAME_TO_R_REGEXP = re.compile(r"^(.*)右(.*)$")
## 日本語で左右を命名されている名前をblender方式のL(R)に変更する
def convertNameToLR(name: str, use_underscore: bool = False) -> str:
# 日本語で左右を命名されている名前をblender方式のL(R)に変更する
def convertNameToLR(name, use_underscore=False):
m = __CONVERT_NAME_TO_L_REGEXP.match(name)
delimiter = "_" if use_underscore else "."
if m:
@@ -85,7 +81,7 @@ __CONVERT_L_TO_NAME_REGEXP = re.compile(r"(?P<lr>(?P<separator>[._])[lL])(?P<aft
__CONVERT_R_TO_NAME_REGEXP = re.compile(r"(?P<lr>(?P<separator>[._])[rR])(?P<after>($|(?P=separator)))")
def convertLRToName(name: str) -> str:
def convertLRToName(name):
match = __CONVERT_L_TO_NAME_REGEXP.search(name)
if match:
return f"{name[0:match.start()]}{match['after']}{name[match.end():]}"
@@ -97,8 +93,8 @@ def convertLRToName(name: str) -> str:
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:
# src_vertex_groupのWeightをdest_vertex_groupにaddする
def mergeVertexGroup(meshObj, src_vertex_group_name, dest_vertex_group_name):
mesh = meshObj.data
src_vertex_group = meshObj.vertex_groups[src_vertex_group_name]
dest_vertex_group = meshObj.vertex_groups[dest_vertex_group_name]
@@ -112,43 +108,73 @@ def mergeVertexGroup(meshObj: Object, src_vertex_group_name: str, dest_vertex_gr
pass
def separateByMaterials(meshObj: Object) -> None:
if len(meshObj.data.materials) < 2:
def separateByMaterials(meshObj: bpy.types.Object, keep_normals: bool = False):
meshData = meshObj.data
if len(meshData.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
dummy_parent = None
try:
enterEditMode(meshObj)
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.mesh.separate(type="MATERIAL")
dummy_parent = bpy.data.objects.new(name="tmp", object_data=None)
matrix_parent_inverse = meshObj.matrix_parent_inverse.copy()
prev_parent = meshObj.parent
meshObj.parent = dummy_parent
meshObj.active_shape_key_index = 0
mmd_normal_name = None # To avoid conflict ("mmd_normal.001", etc.)
if keep_normals:
existing_custom_normal = meshData.attributes.get("custom_normal")
if existing_custom_normal:
if existing_custom_normal.data_type == "INT16_2D":
normals_data = np.empty(len(meshData.loops) * 2, dtype=np.int16)
existing_custom_normal.data.foreach_get("value", normals_data)
mmd_normal = meshData.attributes.new("mmd_normal", "INT16_2D", "CORNER")
mmd_normal_name = mmd_normal.name
mmd_normal.data.foreach_set("value", normals_data)
else:
raise TypeError(f"Unsupported custom_normal data type: '{existing_custom_normal.data_type}'. Supported types: 'INT16_2D'")
try:
enterEditMode(meshObj)
bpy.ops.mesh.separate(type="MATERIAL")
finally:
bpy.ops.object.mode_set(mode="OBJECT")
for i in dummy_parent.children:
materials = i.data.materials
i.name = getattr(materials[0], "name", "None") if len(materials) else "None"
i.parent = prev_parent
i.matrix_parent_inverse = matrix_parent_inverse
if keep_normals and mmd_normal_name:
mmd_normal = i.data.attributes.get(mmd_normal_name)
if mmd_normal:
if mmd_normal.data_type == "INT16_2D":
normals_data = np.empty(len(i.data.loops) * 2, dtype=np.int16)
mmd_normal.data.foreach_get("value", normals_data)
custom_normal_attr = i.data.attributes.get("custom_normal")
if not custom_normal_attr:
custom_normal_attr = i.data.attributes.new("custom_normal", "INT16_2D", "CORNER")
custom_normal_attr.data.foreach_set("value", normals_data)
else:
raise TypeError(f"Unsupported custom_normal data type: '{mmd_normal.data_type}'. Supported types: 'INT16_2D'")
i.data.attributes.remove(mmd_normal)
finally:
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)
if dummy_parent and dummy_parent.name in bpy.data.objects:
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)
def clearUnusedMeshes():
meshes_to_delete = [mesh for mesh in bpy.data.meshes if mesh.users == 0]
for mesh in meshes_to_delete:
bpy.data.meshes.remove(mesh)
## Boneのカスタムプロパティにname_jが存在する場合、name_jの値を
# Boneのカスタムプロパティに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.
def makePmxBoneMap(armObj):
# Maintain backward compatibility with mmd_tools_local v0.4.x or older.
return {(i.mmd_bone.name_j or i.get("mmd_bone_name_j", i.get("name_j", i.name))): i for i in armObj.pose.bones}
@@ -156,7 +182,7 @@ __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.
"""Generate a unique name from the given name.
This function is a limited and simplified version of bpy_extras.io_utils.unique_name.
Args:
@@ -176,13 +202,11 @@ def unique_name(name: str, used_names: Set[str]) -> str:
return new_name
def int2base(x: int, base: int, width: int = 0) -> str:
def int2base(x, base, width=0):
"""
Method to convert an int to a base
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
@@ -199,7 +223,7 @@ def int2base(x: int, base: int, width: int = 0) -> str:
return digits
def saferelpath(path: str, start: str, strategy: str = "inside") -> str:
def saferelpath(path, start, strategy="inside"):
"""
On Windows relpath will raise a ValueError
when trying to calculate the relative path to a
@@ -226,15 +250,16 @@ def saferelpath(path: str, start: str, strategy: str = "inside") -> str:
return os.path.relpath(path, start)
class ItemOp:
@staticmethod
def get_by_index(items: bpy.types.bpy_prop_collection, index: int) -> Optional[Any]:
def get_by_index(items, index):
if 0 <= index < len(items):
return items[index]
return None
@staticmethod
def resize(items: bpy.types.bpy_prop_collection, length: int) -> None:
def resize(items: bpy.types.bpy_prop_collection, length: int):
count = length - len(items)
if count > 0:
for i in range(count):
@@ -244,7 +269,7 @@ class ItemOp:
items.remove(length)
@staticmethod
def add_after(items: bpy.types.bpy_prop_collection, index: int) -> Tuple[Any, int]:
def add_after(items, index):
index_end = len(items)
index = max(0, min(index_end, index + 1))
items.add()
@@ -266,8 +291,7 @@ class ItemMoveOp:
)
@staticmethod
def move(items: bpy.types.bpy_prop_collection, index: int, move_type: str,
index_min: int = 0, index_max: Optional[int] = None) -> int:
def move(items, index, move_type, index_min=0, index_max=None):
if index_max is None:
index_max = len(items) - 1
else:
@@ -277,7 +301,7 @@ class ItemMoveOp:
if index < index_min:
items.move(index, index_min)
return index_min
elif index > index_max:
if index > index_max:
items.move(index, index_max)
return index_max
@@ -296,8 +320,8 @@ class ItemMoveOp:
return index_new
def deprecated(deprecated_in: Optional[str] = None, details: Optional[str] = None) -> Callable:
"""Decorator to mark a function as deprecated.
def deprecated(deprecated_in: Optional[str] = None, details: Optional[str] = None):
"""Mark a function as deprecated.
Args:
deprecated_in (Optional[str]): Version in which the function was deprecated.
details (Optional[str]): Additional details about the deprecation.
@@ -305,8 +329,8 @@ def deprecated(deprecated_in: Optional[str] = None, details: Optional[str] = Non
Callable: The decorated function.
"""
def _function_wrapper(function: Callable) -> Callable:
def _inner_wrapper(*args: Any, **kwargs: Any) -> Any:
def _function_wrapper(function: Callable):
def _inner_wrapper(*args, **kwargs):
warn_deprecation(function.__name__, deprecated_in, details)
return function(*args, **kwargs)
@@ -316,7 +340,7 @@ def deprecated(deprecated_in: Optional[str] = None, details: Optional[str] = Non
def warn_deprecation(function_name: str, deprecated_in: Optional[str] = None, details: Optional[str] = None) -> None:
"""Reports a deprecation warning.
"""Report a deprecation warning.
Args:
function_name (str): Name of the deprecated function.
deprecated_in (Optional[str]): Version in which the function was deprecated.
@@ -330,3 +354,7 @@ def warn_deprecation(function_name: str, deprecated_in: Optional[str] = None, de
stack_info=True,
stacklevel=4,
)
# import warnings # pylint: disable=import-outside-toplevel
# warnings.warn(f"""{function_name}is deprecated{f" since {deprecated_in}" if deprecated_in else ""}{f": {details}" if details else ""}""", category=DeprecationWarning, stacklevel=2)
+288 -6
View File
@@ -35,6 +35,11 @@ def update_validation_mode(self: PropertyGroup, context: Context) -> None:
logger.info(f"Updating validation mode to: {self.validation_mode}")
save_preference("validation_mode", self.validation_mode)
# Hide validation results if mode is set to NONE
if self.validation_mode == 'NONE':
self.show_validation_results = False
logger.debug("Validation mode set to NONE, hiding validation results")
def update_logging_state(self: PropertyGroup, context: Context) -> None:
"""Updates logging state and configures logging"""
@@ -62,10 +67,110 @@ def highlight_problem_bones(self: PropertyGroup, context: Context) -> None:
save_preference("highlight_problem_bones", self.highlight_problem_bones)
def get_mesh_objects(self, context):
meshes = [(obj.name, obj.name, "") for obj in bpy.data.objects if obj.type == 'MESH']
"""Get list of all mesh objects with ASCII-safe identifiers
Returns tuples of (identifier, display_name, description) where:
- identifier: ASCII-safe unique ID (uses object's memory address)
- display_name: The actual object name (can contain Japanese/non-ASCII characters)
- description: Empty string
Uses caching to prevent encoding issues with Blender's EnumProperty system
"""
# Create a cache key based on mesh objects
mesh_objects = [obj for obj in bpy.data.objects if obj.type == 'MESH']
cache_key = tuple((obj.name, obj.as_pointer()) for obj in mesh_objects)
# Check if we have a cached result
if hasattr(get_mesh_objects, '_cache_key') and get_mesh_objects._cache_key == cache_key:
if hasattr(get_mesh_objects, '_cached_items'):
return get_mesh_objects._cached_items
# Build the list
meshes = []
for obj in mesh_objects:
safe_id = f"MESH_{obj.as_pointer()}"
# Use the name directly - Blender should handle Unicode in display names
display_name = obj.name
meshes.append((safe_id, display_name, ""))
if not meshes:
return [('NONE', t("Visemes.no_meshes"), '')]
return meshes
result = [('NONE', t("Visemes.no_meshes"), '')]
else:
result = meshes
# Cache the result
get_mesh_objects._cache_key = cache_key
get_mesh_objects._cached_items = result
return result
def auto_populate_merge_armatures(context: Context) -> None:
"""Auto-populate merge armature fields when there are 2+ armatures"""
armatures = [obj for obj in bpy.data.objects if obj.type == 'ARMATURE']
if len(armatures) >= 2:
toolkit = context.scene.avatar_toolkit
if not toolkit.merge_armature_into and not toolkit.merge_armature:
toolkit.merge_armature_into = armatures[0].name
toolkit.merge_armature = armatures[1].name
logger.debug(f"Auto-populated merge armatures: {armatures[0].name} <- {armatures[1].name}")
elif toolkit.merge_armature_into and not toolkit.merge_armature:
for armature in armatures:
if armature.name != toolkit.merge_armature_into:
toolkit.merge_armature = armature.name
logger.debug(f"Auto-populated merge_armature: {armature.name}")
break
elif not toolkit.merge_armature_into and toolkit.merge_armature:
for armature in armatures:
if armature.name != toolkit.merge_armature:
toolkit.merge_armature_into = armature.name
logger.debug(f"Auto-populated merge_armature_into: {armature.name}")
break
def update_merge_armature_into(self: PropertyGroup, context: Context) -> None:
"""Update function for merge_armature_into property"""
auto_populate_merge_armatures(context)
def update_merge_armature(self: PropertyGroup, context: Context) -> None:
"""Update function for merge_armature property"""
auto_populate_merge_armatures(context)
@bpy.app.handlers.persistent
def depsgraph_update_handler(scene: Scene, depsgraph) -> None:
"""Handler to auto-populate merge armatures when objects change"""
# Check for any armature-related updates
armature_updated = False
for update in depsgraph.updates:
if hasattr(update, 'id') and update.id and hasattr(update.id, 'type'):
if update.id.type == 'ARMATURE':
armature_updated = True
break
if armature_updated:
# Use a timer to defer the update to avoid context issues
bpy.app.timers.register(lambda: auto_populate_safe(), first_interval=0.1)
def auto_populate_safe() -> None:
"""Safe auto-populate function that can be called from timer"""
try:
if bpy.context and hasattr(bpy.context, 'scene') and hasattr(bpy.context.scene, 'avatar_toolkit'):
auto_populate_merge_armatures(bpy.context)
except (AttributeError, ReferenceError):
pass
return None # Don't repeat the timer
@bpy.app.handlers.persistent
def undo_post_handler(scene: Scene) -> None:
"""Handler for undo operations that might add/remove armatures"""
bpy.app.timers.register(lambda: auto_populate_safe(), first_interval=0.1)
@bpy.app.handlers.persistent
def redo_post_handler(scene: Scene) -> None:
"""Handler for redo operations that might add/remove armatures"""
bpy.app.timers.register(lambda: auto_populate_safe(), first_interval=0.1)
class AvatarToolkitSceneProperties(PropertyGroup):
"""Property group containing Avatar Toolkit scene-level settings and properties"""
@@ -85,6 +190,12 @@ class AvatarToolkitSceneProperties(PropertyGroup):
default=False
)
show_validation_results: BoolProperty(
name="Show Validation Results",
default=False,
description="Show the validation results section"
)
material_search_filter: StringProperty(
name=t("TextureAtlas.search_materials"),
description=t("TextureAtlas.search_materials_desc"),
@@ -197,6 +308,7 @@ class AvatarToolkitSceneProperties(PropertyGroup):
items=get_armature_list,
name=t("QuickAccess.select_armature"),
description=t("QuickAccess.select_armature"),
update=lambda self, context: update_active_armature(self, context)
)
language: EnumProperty(
@@ -214,7 +326,7 @@ class AvatarToolkitSceneProperties(PropertyGroup):
('BASIC', t("Settings.validation_mode.basic"), t("Settings.validation_mode.basic_desc")),
('NONE', t("Settings.validation_mode.none"), t("Settings.validation_mode.none_desc"))
],
default=get_preference("validation_mode", "STRICT"),
default=get_preference("validation_mode", "NONE"),
update=update_validation_mode
)
@@ -465,13 +577,15 @@ class AvatarToolkitSceneProperties(PropertyGroup):
merge_armature_into: StringProperty(
name=t('MergeArmature.into'),
description=t('MergeArmature.into_desc'),
default=""
default="",
update=update_merge_armature_into
)
merge_armature: StringProperty(
name=t('MergeArmature.from'),
description=t('MergeArmature.from_desc'),
default=""
default="",
update=update_merge_armature
)
attach_mesh: StringProperty(
@@ -608,12 +722,172 @@ class AvatarToolkitSceneProperties(PropertyGroup):
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 and self.active_armature != 'NONE':
# Get the actual armature object from the identifier
armature = get_active_armature(context)
if armature:
logger.info(f"Active armature set to: {armature.name}")
# Deselect all objects first
bpy.ops.object.select_all(action='DESELECT')
# Select and make active the chosen armature
armature.select_set(True)
context.view_layer.objects.active = armature
logger.info(f"Selected and activated armature: {armature.name}")
# Clear armature caches when armature changes to ensure fresh validation
try:
from ..ui.quick_access_panel import clear_armature_caches
clear_armature_caches()
except ImportError:
pass # UI module might not be loaded yet
else:
logger.warning("Failed to get armature object from identifier")
else:
logger.info("No armature selected")
def register() -> None:
"""Register the Avatar Toolkit property group"""
logger.info("Registering Avatar Toolkit properties")
# 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")
@@ -621,6 +895,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:
+27 -5
View File
@@ -2,6 +2,7 @@ import traceback
from types import FrameType
import bpy
import bpy_extras
from bpy_extras import anim_utils
from numpy import double
from typing import Set, Dict
import re
@@ -115,12 +116,33 @@ class AvatarToolkit_OT_ConvertResonite(Operator):
return {'FINISHED'}
def makeorexistingfcurve(action: bpy.types.Action,data_path: str,action_group: str, index=0) -> bpy.types.FCurve:
fcurve = action.fcurves.find(data_path=data_path,index=index)
if fcurve == None:
return action.fcurves.new(data_path,action_group=action_group,index=index)
def makeorexistingfcurve(action: bpy.types.Action, data_path: str, action_group: str, index=0) -> bpy.types.FCurve:
"""Get or create an F-Curve using Blender 5.0 channelbag system.
Blender 5.0 Breaking Change: The legacy action.fcurves API has been removed.
F-Curves are now accessed through channelbags. Each slot of an Action can have a channelbag.
This function has been migrated to use bpy_extras.anim_utils.action_ensure_channelbag_for_slot().
"""
# Get the action slot (assumes single slot for now - armature actions typically use first slot)
if not action.slots:
slot = action.slots.new(for_id=bpy.context.object.data if bpy.context.object and bpy.context.object.type == 'ARMATURE' else None)
else:
print("fcurve with data \""+data_path+"\" already exists")
slot = action.slots[0]
# Get or create channelbag for this slot
channelbag = anim_utils.action_ensure_channelbag_for_slot(action, slot)
# Use ensure() to get existing or create new F-Curve
fcurve = channelbag.fcurves.ensure(data_path, index=index, group_name=action_group)
if fcurve:
return fcurve
else:
print(f"fcurve with data \"{data_path}\" creation failed")
# Fallback: try to find or create manually
fcurve = channelbag.fcurves.find(data_path, index=index)
if fcurve is None:
fcurve = channelbag.fcurves.new(data_path, index=index, group_name=action_group)
return fcurve
class AvatarToolKit_OT_AnimX_Importer(Operator,bpy_extras.io_utils.ImportHelper):
+657
View File
@@ -0,0 +1,657 @@
# GPL License
import json
import os
import time
import threading
from typing import Dict, List, Optional, Tuple, Set, Any, Callable
from dataclasses import dataclass, asdict
from enum import Enum
import bpy
from bpy.types import Object, Material, ShapeKey
from .translation_service import get_translation_manager, TranslationServiceManager
from .enhanced_dictionaries import get_enhanced_translator, EnhancedDictionaryTranslator
from .logging_setup import logger
from .addon_preferences import get_preference, save_preference
from .translations import t
class TranslationMode(Enum):
"""Translation modes for different approaches"""
DICTIONARY_ONLY = "dictionary_only"
API_ONLY = "api_only"
HYBRID = "hybrid" # Default: Dictionary first, then API fallback
@dataclass
class TranslationJob:
"""Represents a translation job for batch processing"""
name: str
category: str
source_lang: str = "ja"
target_lang: str = "en"
object_ref: Optional[Any] = None
property_name: Optional[str] = None
@dataclass
class TranslationResult:
"""Result of a translation operation"""
original: str
translated: str
method: str # "dictionary", "api", "failed"
service: Optional[str] = None
category: str = "unknown"
confidence: float = 1.0
class TranslationCache:
"""Persistent translation cache with file storage"""
def __init__(self):
self._cache: Dict[str, Dict[str, str]] = {}
self._cache_file = self._get_cache_file_path()
self._cache_lock = threading.Lock()
self._load_cache()
def _get_cache_file_path(self) -> str:
"""Get the cache file path in user preferences directory"""
user_path = bpy.utils.resource_path('USER')
cache_dir = os.path.join(user_path, "config", "avatar_toolkit_prefs")
os.makedirs(cache_dir, exist_ok=True)
return os.path.join(cache_dir, "translation_cache.json")
def _load_cache(self) -> None:
"""Load cache from file"""
try:
if os.path.exists(self._cache_file):
# Try UTF-8 first, fallback to other encodings
try:
with open(self._cache_file, 'r', encoding='utf-8') as f:
self._cache = json.load(f)
except UnicodeDecodeError:
# Try with UTF-8 error handling
with open(self._cache_file, 'r', encoding='utf-8', errors='replace') as f:
self._cache = json.load(f)
logger.debug(f"Loaded translation cache with {len(self._cache)} entries")
else:
self._cache = {}
except Exception as e:
logger.warning(f"Failed to load translation cache: {e}")
self._cache = {}
def _save_cache(self) -> None:
"""Save cache to file"""
try:
with open(self._cache_file, 'w', encoding='utf-8') as f:
json.dump(self._cache, f, indent=2, ensure_ascii=False)
logger.debug(f"Saved translation cache with {len(self._cache)} entries")
except Exception as e:
logger.error(f"Failed to save translation cache: {e}")
def get(self, text: str, source_lang: str, target_lang: str) -> Optional[str]:
"""Get cached translation"""
cache_key = f"{source_lang}_{target_lang}"
with self._cache_lock:
if cache_key in self._cache and text in self._cache[cache_key]:
return self._cache[cache_key][text]
return None
def put(self, text: str, translation: str, source_lang: str, target_lang: str) -> None:
"""Store translation in cache"""
cache_key = f"{source_lang}_{target_lang}"
with self._cache_lock:
if cache_key not in self._cache:
self._cache[cache_key] = {}
self._cache[cache_key][text] = translation
# Save cache periodically (every 10 new entries)
if len(self._cache.get(cache_key, {})) % 10 == 0:
self._save_cache()
def clear(self) -> None:
"""Clear all cached translations"""
with self._cache_lock:
self._cache.clear()
self._save_cache()
logger.info("Translation cache cleared")
def get_stats(self) -> Dict[str, int]:
"""Get cache statistics"""
with self._cache_lock:
total_entries = sum(len(lang_cache) for lang_cache in self._cache.values())
return {
"language_pairs": len(self._cache),
"total_entries": total_entries
}
class AvatarToolkitTranslationManager:
"""Main translation manager for Avatar Toolkit"""
def __init__(self):
self.service_manager: TranslationServiceManager = get_translation_manager()
self.dictionary_translator: EnhancedDictionaryTranslator = get_enhanced_translator()
self.cache: TranslationCache = TranslationCache()
self.translation_mode: TranslationMode = TranslationMode(
get_preference("translation_mode", "hybrid")
)
self._progress_callback: Optional[Callable[[int, int, str], None]] = None
def set_translation_mode(self, mode: TranslationMode) -> None:
"""Set the translation mode"""
self.translation_mode = mode
save_preference("translation_mode", mode.value)
logger.info(f"Translation mode set to: {mode.value}")
def set_progress_callback(self, callback: Optional[Callable[[int, int, str], None]]) -> None:
"""Set progress callback for batch operations"""
self._progress_callback = callback
def translate_single(self, name: str, category: str = "auto",
source_lang: str = "ja", target_lang: str = "en") -> TranslationResult:
"""Translate a single name with comprehensive fallback logic"""
# Import safe_decode_text from translation_service
from .translation_service import safe_decode_text
# Ensure name is properly encoded
try:
name = safe_decode_text(name)
except Exception as e:
logger.warning(f"Failed to decode name: {e}")
if not name or not name.strip():
return TranslationResult(name, name, "skipped")
original_name = name.strip()
# Check cache first
cached_result = self.cache.get(original_name, source_lang, target_lang)
if cached_result:
return TranslationResult(original_name, cached_result, "cache", category=category)
# Dictionary translation (always try first in hybrid mode)
if self.translation_mode in [TranslationMode.DICTIONARY_ONLY, TranslationMode.HYBRID]:
dict_result, detected_category = self.dictionary_translator.translate_name(original_name, category)
if dict_result:
self.cache.put(original_name, dict_result, source_lang, target_lang)
return TranslationResult(original_name, dict_result, "dictionary",
category=detected_category, confidence=1.0)
if self.translation_mode in [TranslationMode.API_ONLY, TranslationMode.HYBRID]:
try:
api_result, service_name = self.service_manager.translate_with_fallback(
original_name, source_lang, target_lang
)
if api_result != original_name: # Translation succeeded
self.cache.put(original_name, api_result, source_lang, target_lang)
return TranslationResult(original_name, api_result, "api",
service=service_name, category=category, confidence=0.8)
except Exception as e:
logger.warning(f"API translation failed for '{original_name}': {e}")
# No translation available
return TranslationResult(original_name, original_name, "failed", category=category)
def translate_batch(self, jobs: List[TranslationJob],
apply_results: bool = True) -> List[TranslationResult]:
"""Translate multiple items in batch with progress reporting and interruption handling"""
results = []
total_jobs = len(jobs)
logger.info(f"Starting batch translation of {total_jobs} items")
# Group jobs by category for more efficient processing
jobs_by_category: Dict[str, List[TranslationJob]] = {}
for job in jobs:
if job.category not in jobs_by_category:
jobs_by_category[job.category] = []
jobs_by_category[job.category].append(job)
completed = 0
start_time = time.time()
for category, category_jobs in jobs_by_category.items():
logger.debug(f"Processing {len(category_jobs)} {category} translations")
# Check if we can use optimized batch translation for API calls
can_use_api_batch = (self.translation_mode in [TranslationMode.API_ONLY, TranslationMode.HYBRID] and
len(category_jobs) > 3)
if can_use_api_batch:
# Try optimized batch translation with API
batch_results = self._process_category_batch_optimized(category_jobs, completed, total_jobs, start_time)
if batch_results:
# Apply results to Blender objects if requested
for i, (job, result) in enumerate(zip(category_jobs, batch_results)):
if apply_results and result.method != "failed" and job.object_ref:
try:
self._apply_translation_to_object(job, result)
logger.debug(f"Successfully applied translation: {job.name} -> {result.translated}")
except Exception as e:
logger.error(f"Failed to apply translation to object {job.name}: {e}")
result.method = "apply_failed"
result.translated = job.name
results.extend(batch_results)
completed += len(category_jobs)
progress_percent = (completed / total_jobs) * 100
logger.info(f"Batch translation progress: {completed}/{total_jobs} ({progress_percent:.1f}%) - completed {category} batch")
continue
# Fallback to individual processing
for job in category_jobs:
# Check if we should continue (for potential cancellation support)
current_time = time.time()
elapsed_time = current_time - start_time
# Progress callback with detailed status
if self._progress_callback:
avg_time_per_item = elapsed_time / max(completed, 1)
remaining_items = total_jobs - completed
estimated_remaining = avg_time_per_item * remaining_items
status_msg = f"Translating {job.name}"
if completed > 0:
status_msg += f" (ETA: {estimated_remaining:.1f}s)"
self._progress_callback(completed, total_jobs, status_msg)
try:
logger.debug(f"Translating job {completed + 1}/{total_jobs}: {job.name} ({job.category})")
result = self.translate_single(job.name, job.category,
job.source_lang, job.target_lang)
if apply_results and result.method != "failed" and job.object_ref:
try:
self._apply_translation_to_object(job, result)
logger.debug(f"Successfully applied translation: {job.name} -> {result.translated}")
except Exception as e:
logger.error(f"Failed to apply translation to object {job.name}: {e}")
result.method = "apply_failed"
result.translated = job.name
results.append(result)
except Exception as e:
logger.error(f"Translation failed for job {job.name}: {e}")
# Create a failed result
failed_result = TranslationResult(
original=job.name,
translated=job.name,
method="failed",
category=job.category
)
results.append(failed_result)
completed += 1
# Log progress periodically
if completed % 10 == 0 or completed == total_jobs:
progress_percent = (completed / total_jobs) * 100
logger.info(f"Batch translation progress: {completed}/{total_jobs} ({progress_percent:.1f}%)")
if self._progress_callback:
total_time = time.time() - start_time
self._progress_callback(total_jobs, total_jobs, f"Translation complete ({total_time:.1f}s)")
successful = sum(1 for r in results if r.method not in ["failed", "skipped", "apply_failed"])
failed = sum(1 for r in results if r.method in ["failed", "apply_failed"])
skipped = sum(1 for r in results if r.method == "skipped")
dictionary_count = sum(1 for r in results if r.method == "dictionary")
api_count = sum(1 for r in results if r.method == "api")
cache_count = sum(1 for r in results if r.method == "cache")
logger.info(f"Batch translation complete: {successful}/{total_jobs} successful, {failed} failed, {skipped} skipped")
logger.info(f"Translation methods used: Dictionary: {dictionary_count}, API: {api_count}, Cache: {cache_count}")
return results
def _process_category_batch_optimized(self, category_jobs: List[TranslationJob],
completed: int, total_jobs: int, start_time: float) -> Optional[List[TranslationResult]]:
"""Process a batch of jobs from the same category using optimized API batch translation"""
from .translation_service import safe_decode_text
if not category_jobs:
return []
logger.info(f"Starting optimized batch translation for {len(category_jobs)} {category_jobs[0].category} items")
api_batch_jobs = []
api_batch_texts = []
results = [None] * len(category_jobs)
# First pass: try dictionary translations and collect API candidates
for i, job in enumerate(category_jobs):
if not job.name or not job.name.strip():
results[i] = TranslationResult(job.name, job.name, "skipped", category=job.category)
continue
# Ensure name is properly encoded
try:
original_name = safe_decode_text(job.name.strip())
except Exception as e:
logger.warning(f"Failed to decode job name: {e}")
original_name = job.name.strip()
continue
original_name = job.name.strip()
# Check cache first
cached_result = self.cache.get(original_name, job.source_lang, job.target_lang)
if cached_result:
results[i] = TranslationResult(original_name, cached_result, "cache", category=job.category)
continue
# Try dictionary translation first (if in hybrid mode)
if self.translation_mode == TranslationMode.HYBRID:
dict_result, detected_category = self.dictionary_translator.translate_name(original_name, job.category)
if dict_result:
self.cache.put(original_name, dict_result, job.source_lang, job.target_lang)
results[i] = TranslationResult(original_name, dict_result, "dictionary",
category=detected_category, confidence=1.0)
continue
# Add to API batch candidates
api_batch_jobs.append((i, job))
api_batch_texts.append(original_name)
# Process API batch if we have candidates
if api_batch_texts:
logger.info(f"Sending {len(api_batch_texts)} items to API batch translation")
if self._progress_callback:
elapsed_time = time.time() - start_time
avg_time_per_item = elapsed_time / max(completed, 1) if completed > 0 else 1.0
remaining_items = total_jobs - completed
estimated_remaining = avg_time_per_item * remaining_items
status_msg = f"Batch translating {len(api_batch_texts)} {category_jobs[0].category} items"
if completed > 0:
status_msg += f" (ETA: {estimated_remaining:.1f}s)"
self._progress_callback(completed, total_jobs, status_msg)
try:
# Use the service manager's optimized batch translation
if len(set(job.source_lang for _, job in api_batch_jobs)) == 1 and len(set(job.target_lang for _, job in api_batch_jobs)) == 1:
source_lang = api_batch_jobs[0][1].source_lang
target_lang = api_batch_jobs[0][1].target_lang
batch_results = self.service_manager.batch_translate_with_fallback(
api_batch_texts, source_lang, target_lang
)
for j, (result_idx, job) in enumerate(api_batch_jobs):
if j < len(batch_results):
translated_text, service_name = batch_results[j]
# Cache successful translations
if translated_text != job.name:
self.cache.put(job.name.strip(), translated_text, job.source_lang, job.target_lang)
results[result_idx] = TranslationResult(
original=job.name.strip(),
translated=translated_text,
method="api" if translated_text != job.name else "failed",
service=service_name,
category=job.category,
confidence=0.8
)
else:
# Fallback for missing results
results[result_idx] = TranslationResult(job.name, job.name, "failed", category=job.category)
else:
# Mixed language pairs - fallback to individual translations
logger.info("Mixed language pairs detected, falling back to individual API translations")
for result_idx, job in api_batch_jobs:
try:
result = self.translate_single(job.name, job.category, job.source_lang, job.target_lang)
results[result_idx] = result
except Exception as e:
logger.error(f"Individual API translation failed for {job.name}: {e}")
results[result_idx] = TranslationResult(job.name, job.name, "failed", category=job.category)
except Exception as e:
logger.error(f"Batch API translation failed: {e}")
# Fallback to individual translations
for result_idx, job in api_batch_jobs:
try:
result = self.translate_single(job.name, job.category, job.source_lang, job.target_lang)
results[result_idx] = result
except Exception as individual_e:
logger.error(f"Individual fallback translation failed for {job.name}: {individual_e}")
results[result_idx] = TranslationResult(job.name, job.name, "failed", category=job.category)
for i, result in enumerate(results):
if result is None:
results[i] = TranslationResult(category_jobs[i].name, category_jobs[i].name, "failed", category=category_jobs[i].category)
successful_batch = sum(1 for r in results if r.method not in ["failed", "skipped"])
logger.info(f"Optimized batch complete: {successful_batch}/{len(category_jobs)} successful")
return results
def _apply_translation_to_object(self, job: TranslationJob, result: TranslationResult) -> None:
"""Apply translation result to a Blender object"""
if not job.object_ref or not job.property_name:
return
try:
setattr(job.object_ref, job.property_name, result.translated)
logger.debug(f"Applied translation: {job.object_ref.name}.{job.property_name} = '{result.translated}'")
except Exception as e:
logger.error(f"Failed to set property {job.property_name}: {e}")
raise
def translate_armature_bones(self, armature: Object, apply_results: bool = True) -> List[TranslationResult]:
"""Translate all bone names in an armature"""
from .translation_service import safe_decode_text
if not armature or armature.type != 'ARMATURE':
return []
jobs = []
for bone in armature.data.bones:
try:
bone_name = safe_decode_text(bone.name)
except Exception as e:
logger.warning(f"Failed to decode bone name, using as-is: {e}")
bone_name = bone.name
jobs.append(TranslationJob(
name=bone_name,
category="bones",
object_ref=bone,
property_name="name"
))
return self.translate_batch(jobs, apply_results)
def translate_object_shapekeys(self, mesh_obj: Object, apply_results: bool = True) -> List[TranslationResult]:
"""Translate all shape key names in a mesh object"""
from .translation_service import safe_decode_text
if not mesh_obj or mesh_obj.type != 'MESH' or not mesh_obj.data.shape_keys:
return []
jobs = []
for shape_key in mesh_obj.data.shape_keys.key_blocks:
try:
sk_name = safe_decode_text(shape_key.name)
except Exception as e:
logger.warning(f"Failed to decode shape key name, using as-is: {e}")
sk_name = shape_key.name
jobs.append(TranslationJob(
name=sk_name,
category="shapekeys",
object_ref=shape_key,
property_name="name"
))
return self.translate_batch(jobs, apply_results)
def translate_scene_materials(self, apply_results: bool = True) -> List[TranslationResult]:
"""Translate all material names in the scene"""
from .translation_service import safe_decode_text
jobs = []
processed_materials: Set[str] = set()
for obj in bpy.data.objects:
if obj.type == 'MESH' and obj.data.materials:
for material in obj.data.materials:
if material and material.name not in processed_materials:
try:
mat_name = safe_decode_text(material.name)
except Exception as e:
logger.warning(f"Failed to decode material name, using as-is: {e}")
mat_name = material.name
jobs.append(TranslationJob(
name=mat_name,
category="materials",
object_ref=material,
property_name="name"
))
processed_materials.add(material.name)
return self.translate_batch(jobs, apply_results)
def translate_scene_objects(self, object_types: Optional[Set[str]] = None,
apply_results: bool = True) -> List[TranslationResult]:
"""Translate all object names in the scene"""
from .translation_service import safe_decode_text
if object_types is None:
object_types = {'MESH', 'ARMATURE', 'EMPTY'}
jobs = []
for obj in bpy.data.objects:
if obj.type in object_types:
try:
obj_name = safe_decode_text(obj.name)
except Exception as e:
logger.warning(f"Failed to decode object name, using as-is: {e}")
obj_name = obj.name
jobs.append(TranslationJob(
name=obj_name,
category="objects",
object_ref=obj,
property_name="name"
))
return self.translate_batch(jobs, apply_results)
def get_translation_stats(self) -> Dict[str, Any]:
"""Get comprehensive translation statistics"""
dict_stats = self.dictionary_translator.get_statistics()
cache_stats = self.cache.get_stats()
available_services = self.service_manager.get_available_services()
return {
"dictionary_translations": dict_stats,
"cache_stats": cache_stats,
"available_services": available_services,
"current_mode": self.translation_mode.value,
"primary_service": get_preference("translation_service", "microsoft")
}
def clear_all_caches(self) -> None:
"""Clear all translation caches"""
self.cache.clear()
for service_id, service in self.service_manager._services.items():
service.clear_cache()
logger.info("All translation caches cleared")
_translation_manager: Optional[AvatarToolkitTranslationManager] = None
def get_avatar_translation_manager() -> AvatarToolkitTranslationManager:
"""Get the global Avatar Toolkit translation manager"""
global _translation_manager
if _translation_manager is None:
_translation_manager = AvatarToolkitTranslationManager()
return _translation_manager
def translate_name_simple(name: str, category: str = "auto") -> str:
"""Simple translation function for quick use"""
manager = get_avatar_translation_manager()
result = manager.translate_single(name, category)
return result.translated
def is_translation_service_available(service_name: str) -> bool:
"""Check if a specific translation service is available"""
manager = get_avatar_translation_manager()
available_services = manager.service_manager.get_available_services()
return any(service_id == service_name for service_id, _ in available_services)
def get_available_translation_services() -> List[Tuple[str, str]]:
"""Get list of available translation services"""
manager = get_avatar_translation_manager()
return manager.service_manager.get_available_services()
def get_batch_translation_info() -> Dict[str, Dict[str, Any]]:
"""Get information about batch translation capabilities of available services"""
manager = get_avatar_translation_manager()
batch_info = {}
for service_id, service_name in manager.service_manager.get_available_services():
service = manager.service_manager.get_service(service_id)
if service:
batch_info[service_id] = {
'name': service_name,
'supports_batch': service.supports_batch_translation(),
'batch_type': 'native' if service_id == 'deepl' else 'concurrent' if service_id in ['libretranslate', 'mymemory'] else 'individual'
}
return batch_info
def configure_translation_service(service_id: str, **config) -> bool:
"""Configure a translation service with the provided settings (now with batch support)"""
try:
success = False
if service_id == "deepl":
from .translation_service import configure_deepl_translator
success = configure_deepl_translator(
config.get("api_key", ""),
config.get("use_free_api", True)
)
if success:
logger.info("DeepL configured with native batch translation support (up to 50 texts per request)")
elif service_id == "libretranslate":
from .translation_service import configure_libretranslate_server
success = configure_libretranslate_server(
config.get("server_url", "https://libretranslate.com"),
config.get("api_key", None)
)
if success:
logger.info("LibreTranslate configured with concurrent batch processing (3x faster)")
elif service_id == "microsoft":
from .translation_service import configure_microsoft_translator
success = configure_microsoft_translator(
config.get("api_key", ""),
config.get("region", "global")
)
else:
logger.error(f"Unknown translation service: {service_id}")
success = False
return success
except Exception as e:
logger.error(f"Failed to configure translation service {service_id}: {e}")
return False
+993
View File
@@ -0,0 +1,993 @@
# GPL License
import json
import time
import requests
import threading
from abc import ABC, abstractmethod
from typing import Dict, List, Optional, Tuple, Any, Set
from dataclasses import dataclass
from urllib.parse import urlencode
import uuid
from .logging_setup import logger
from .addon_preferences import save_preference, get_preference
def safe_decode_text(text: str) -> str:
"""Safely decode text that might be in various encodings (UTF-8, Shift-JIS, etc.)"""
if not text:
return text
# If it's already a proper string, return it
if isinstance(text, str):
try:
# Test if it's valid UTF-8
text.encode('utf-8')
return text
except (UnicodeDecodeError, UnicodeEncodeError):
pass
# Try common encodings for Japanese text
encodings = ['utf-8', 'shift-jis', 'cp932', 'euc-jp', 'iso-2022-jp']
for encoding in encodings:
try:
if isinstance(text, bytes):
return text.decode(encoding)
else:
# Try to re-encode and decode
return text.encode('latin-1', errors='ignore').decode(encoding, errors='ignore')
except (UnicodeDecodeError, UnicodeEncodeError, AttributeError):
continue
# Fallback: replace problematic characters
try:
if isinstance(text, bytes):
return text.decode('utf-8', errors='replace')
else:
return str(text).encode('utf-8', errors='replace').decode('utf-8')
except:
return str(text)
@dataclass
class TranslationRequest:
"""Represents a translation request"""
text: str
source_lang: str = "ja"
target_lang: str = "en"
category: str = "general"
@dataclass
class TranslationResult:
"""Represents a translation result"""
original: str
translated: str
service: str
confidence: float = 1.0
cached: bool = False
class TranslationError(Exception):
"""Custom exception for translation errors"""
pass
class TranslationService(ABC):
"""Abstract base class for translation services"""
def __init__(self, name: str):
self.name = name
self._cache: Dict[str, str] = {}
self._rate_limit_lock = threading.Lock()
self._last_request_time = 0.0
self._request_count = 0
self._rate_limit_per_second = 10 # Default rate limit
@abstractmethod
def translate_text(self, text: str, source_lang: str = "ja", target_lang: str = "en") -> str:
"""Translate a single text string"""
pass
@abstractmethod
def is_available(self) -> bool:
"""Check if the service is available"""
pass
@abstractmethod
def get_supported_languages(self) -> List[Tuple[str, str]]:
"""Get list of supported language pairs (code, name)"""
pass
def batch_translate(self, texts: List[str], source_lang: str = "ja", target_lang: str = "en") -> List[str]:
"""Translate multiple texts with rate limiting - base implementation for services without native batch support"""
results = []
for text in texts:
# Check cache first
cache_key = f"{source_lang}_{target_lang}_{text}"
if cache_key in self._cache:
results.append(self._cache[cache_key])
continue
# Rate limiting
with self._rate_limit_lock:
current_time = time.time()
if current_time - self._last_request_time < (1.0 / self._rate_limit_per_second):
time.sleep((1.0 / self._rate_limit_per_second) - (current_time - self._last_request_time))
try:
translated = self.translate_text(text, source_lang, target_lang)
self._cache[cache_key] = translated
results.append(translated)
self._last_request_time = time.time()
except Exception as e:
logger.warning(f"Translation failed for '{text}': {e}")
results.append(text)
return results
def supports_batch_translation(self) -> bool:
"""Check if service supports native batch translation"""
return False
def clear_cache(self) -> None:
"""Clear the translation cache"""
self._cache.clear()
logger.info(f"Cleared cache for {self.name}")
class DeepLService(TranslationService):
"""DeepL translation service - requires API key"""
def __init__(self, api_key: str = "", use_free_api: bool = True):
super().__init__("DeepL" + (" (Free)" if use_free_api else " (Pro)"))
self.api_key = api_key
self.use_free_api = use_free_api
self._rate_limit_per_second = 5 # DeepL allows more requests
self._base_url = "https://api-free.deepl.com" if use_free_api else "https://api.deepl.com"
def translate_text(self, text: str, source_lang: str = "ja", target_lang: str = "en") -> str:
"""Translate text using DeepL API"""
# Ensure text is properly encoded
text = safe_decode_text(text)
logger.info(f"DeepL: Starting translation of '{text}' from {source_lang} to {target_lang}")
if not text or not text.strip():
logger.debug("Empty text provided, returning as-is")
return text
if not self.api_key:
raise TranslationError("DeepL API key is required")
# DeepL language codes mapping
lang_map = {
"ja": "JA", "en": "EN", "ko": "KO", "zh": "ZH",
"es": "ES", "fr": "FR", "de": "DE", "it": "IT",
"pt": "PT", "ru": "RU", "nl": "NL", "pl": "PL"
}
source_lang = lang_map.get(source_lang, source_lang.upper())
target_lang = lang_map.get(target_lang, target_lang.upper())
endpoint = f"{self._base_url}/v2/translate"
headers = {
"Authorization": f"DeepL-Auth-Key {self.api_key}",
"Content-Type": "application/x-www-form-urlencoded"
}
data = {
"text": text,
"source_lang": source_lang,
"target_lang": target_lang
}
try:
logger.debug(f"Making request to DeepL API: {endpoint}")
response = requests.post(endpoint, headers=headers, data=data, timeout=15)
logger.debug(f"DeepL response status: {response.status_code}")
response.raise_for_status()
result = response.json()
logger.debug(f"DeepL response: {result}")
if "translations" in result and len(result["translations"]) > 0:
translated_text = result["translations"][0]["text"]
logger.info(f"DeepL SUCCESS: '{text}' -> '{translated_text}'")
return translated_text
else:
raise TranslationError("DeepL API returned no translations")
except requests.HTTPError as e:
if e.response.status_code == 401:
raise TranslationError("DeepL API key is invalid")
elif e.response.status_code == 403:
raise TranslationError("DeepL API key access denied or quota exceeded")
elif e.response.status_code == 456:
raise TranslationError("DeepL quota exceeded")
else:
logger.error(f"DeepL HTTP error: {e}")
raise TranslationError(f"DeepL API error: {e}")
except requests.Timeout:
logger.error("DeepL request timed out")
raise TranslationError("DeepL request timed out after 15 seconds")
except requests.RequestException as e:
logger.error(f"DeepL API request failed: {e}")
raise TranslationError(f"DeepL API request failed: {e}")
except Exception as e:
logger.error(f"Unexpected error in DeepL: {e}")
raise TranslationError(f"Unexpected error: {e}")
def is_available(self) -> bool:
"""Check if DeepL service is available"""
if not self.api_key:
return False
try:
headers = {"Authorization": f"DeepL-Auth-Key {self.api_key}"}
response = requests.get(f"{self._base_url}/v2/usage", headers=headers, timeout=5)
return response.status_code == 200
except:
return False
def get_supported_languages(self) -> List[Tuple[str, str]]:
"""Get supported languages for DeepL"""
return [
("ja", "Japanese"),
("en", "English"),
("ko", "Korean"),
("zh", "Chinese"),
("es", "Spanish"),
("fr", "French"),
("de", "German"),
("it", "Italian"),
("pt", "Portuguese"),
("ru", "Russian"),
("nl", "Dutch"),
("pl", "Polish")
]
def supports_batch_translation(self) -> bool:
"""DeepL supports native batch translation"""
return True
def batch_translate(self, texts: List[str], source_lang: str = "ja", target_lang: str = "en") -> List[str]:
"""Translate multiple texts using DeepL batch API"""
if not texts:
return []
# Ensure all texts are properly encoded
texts = [safe_decode_text(text) for text in texts]
logger.info(f"DeepL: Starting batch translation of {len(texts)} texts from {source_lang} to {target_lang}")
results = [None] * len(texts)
uncached_indices = []
uncached_texts = []
for i, text in enumerate(texts):
if not text or not text.strip():
results[i] = text
continue
cache_key = f"{source_lang}_{target_lang}_{text}"
if cache_key in self._cache:
results[i] = self._cache[cache_key]
continue
uncached_indices.append(i)
uncached_texts.append(text)
if not uncached_texts:
logger.info(f"DeepL: All {len(texts)} texts found in cache")
return results
logger.info(f"DeepL: Translating {len(uncached_texts)} uncached texts")
if not self.api_key:
logger.error("DeepL API key is required for batch translation")
for i, idx in enumerate(uncached_indices):
results[idx] = texts[idx]
return results
# DeepL language codes mapping
lang_map = {
"ja": "JA", "en": "EN", "ko": "KO", "zh": "ZH",
"es": "ES", "fr": "FR", "de": "DE", "it": "IT",
"pt": "PT", "ru": "RU", "nl": "NL", "pl": "PL"
}
source_lang_code = lang_map.get(source_lang, source_lang.upper())
target_lang_code = lang_map.get(target_lang, target_lang.upper())
# Batch size limit for DeepL
batch_size = 50
for batch_start in range(0, len(uncached_texts), batch_size):
batch_end = min(batch_start + batch_size, len(uncached_texts))
batch_texts = uncached_texts[batch_start:batch_end]
batch_indices = uncached_indices[batch_start:batch_end]
logger.debug(f"DeepL batch {batch_start//batch_size + 1}: Processing {len(batch_texts)} texts")
endpoint = f"{self._base_url}/v2/translate"
headers = {
"Authorization": f"DeepL-Auth-Key {self.api_key}",
"Content-Type": "application/x-www-form-urlencoded"
}
# Build form data with multiple text parameters (DeepL supports multiple 'text' params)
form_data = [
('source_lang', source_lang_code),
('target_lang', target_lang_code)
]
for text in batch_texts:
form_data.append(('text', text))
try:
logger.debug(f"Making batch request to DeepL API: {endpoint}")
import requests
response = requests.post(endpoint, headers=headers, data=form_data, timeout=30)
logger.debug(f"DeepL batch response status: {response.status_code}")
response.raise_for_status()
result = response.json()
logger.debug(f"DeepL batch response: {result}")
if "translations" in result and len(result["translations"]) == len(batch_texts):
for i, translation_data in enumerate(result["translations"]):
original_text = batch_texts[i]
translated_text = translation_data["text"]
original_idx = batch_indices[i]
cache_key = f"{source_lang}_{target_lang}_{original_text}"
self._cache[cache_key] = translated_text
results[original_idx] = translated_text
logger.debug(f"DeepL batch SUCCESS: '{original_text}' -> '{translated_text}'")
else:
logger.error(f"DeepL batch API returned unexpected response: {result}")
for i, idx in enumerate(batch_indices):
results[idx] = batch_texts[i]
# Rate limiting between batches
if batch_end < len(uncached_texts):
time.sleep(1.0 / self._rate_limit_per_second)
except Exception as e:
logger.error(f"DeepL batch translation failed: {e}")
for i, idx in enumerate(batch_indices):
results[idx] = batch_texts[i]
# Ensure all results are filled
for i, result in enumerate(results):
if result is None:
results[i] = texts[i]
successful_translations = sum(1 for i, result in enumerate(results) if result != texts[i])
logger.info(f"DeepL batch translation complete: {successful_translations}/{len(texts)} successfully translated")
return results
class MyMemoryService(TranslationService):
"""MyMemory free translation service - no API key required"""
def __init__(self):
super().__init__("MyMemory (Free)")
self._rate_limit_per_second = 1 # Conservative rate limiting for free service
self._base_url = "https://api.mymemory.translated.net"
def translate_text(self, text: str, source_lang: str = "ja", target_lang: str = "en") -> str:
"""Translate text using MyMemory free API"""
# Ensure text is properly encoded
text = safe_decode_text(text)
logger.info(f"MyMemory: Starting translation of '{text}' from {source_lang} to {target_lang}")
if not text or not text.strip():
logger.debug("Empty text provided, returning as-is")
return text
# MyMemory uses different language codes
lang_map = {"ja": "ja", "en": "en", "ko": "ko", "zh": "zh", "es": "es", "fr": "fr", "de": "de"}
source_lang = lang_map.get(source_lang, source_lang)
target_lang = lang_map.get(target_lang, target_lang)
endpoint = f"{self._base_url}/get"
params = {
'q': text,
'langpair': f"{source_lang}|{target_lang}",
'de': 'neoneko@avatartoolkit.com' # Optional email for higher quotas
}
try:
logger.debug(f"Making request to MyMemory API: {endpoint} with params: {params}")
response = requests.get(endpoint, params=params, timeout=15) # Increased timeout
logger.debug(f"MyMemory response status: {response.status_code}")
response.raise_for_status()
result = response.json()
logger.debug(f"MyMemory response: {result}")
if result.get('responseStatus') == 200 and 'responseData' in result:
translated_text = result['responseData']['translatedText']
matches = result.get('matches', [])
if matches and len(matches) > 0:
match_quality = matches[0].get('quality', '0')
logger.debug(f"MyMemory translation quality: {match_quality}")
logger.info(f"MyMemory SUCCESS: '{text}' -> '{translated_text}'")
return translated_text
else:
error_msg = result.get('responseDetails', 'Unknown error')
logger.error(f"MyMemory API error: {error_msg}")
if 'QUOTA_EXCEEDED' in error_msg:
raise TranslationError(f"MyMemory daily quota (1000 requests) exceeded. Try again tomorrow or switch to another service.")
else:
raise TranslationError(f"MyMemory API error: {error_msg}")
except requests.Timeout as e:
logger.error(f"MyMemory request timed out: {e}")
raise TranslationError(f"MyMemory request timed out after 15 seconds")
except requests.RequestException as e:
logger.error(f"MyMemory API request failed: {e}")
raise TranslationError(f"MyMemory API request failed: {e}")
except Exception as e:
logger.error(f"Unexpected error in MyMemory: {e}")
raise TranslationError(f"Unexpected error: {e}")
def is_available(self) -> bool:
"""Check if MyMemory service is available"""
try:
response = requests.get(f"{self._base_url}/get",
params={'q': 'test', 'langpair': 'en|en'},
timeout=5)
return response.status_code == 200
except:
return False
def get_supported_languages(self) -> List[Tuple[str, str]]:
"""Get supported languages for MyMemory"""
return [
("ja", "Japanese"),
("en", "English"),
("ko", "Korean"),
("zh", "Chinese"),
("es", "Spanish"),
("fr", "French"),
("de", "German"),
("it", "Italian"),
("pt", "Portuguese"),
("ru", "Russian")
]
def supports_batch_translation(self) -> bool:
"""MyMemory optimized batch processing"""
return True
def batch_translate(self, texts: List[str], source_lang: str = "ja", target_lang: str = "en") -> List[str]:
"""Translate multiple texts using MyMemory with optimized batching and caching"""
if not texts:
return []
# Ensure all texts are properly encoded
texts = [safe_decode_text(text) for text in texts]
logger.info(f"MyMemory: Starting batch translation of {len(texts)} texts from {source_lang} to {target_lang}")
results = [None] * len(texts)
uncached_indices = []
uncached_texts = []
for i, text in enumerate(texts):
if not text or not text.strip():
results[i] = text
continue
cache_key = f"{source_lang}_{target_lang}_{text}"
if cache_key in self._cache:
results[i] = self._cache[cache_key]
continue
uncached_indices.append(i)
uncached_texts.append(text)
if not uncached_texts:
logger.info(f"MyMemory: All {len(texts)} texts found in cache")
return results
logger.info(f"MyMemory: Translating {len(uncached_texts)} uncached texts using concurrent processing")
# Use concurrent processing for MyMemory to speed up translations
import concurrent.futures
from concurrent.futures import ThreadPoolExecutor
import threading
def translate_single_text(text_info):
idx, text = text_info
try:
with self._rate_limit_lock:
current_time = time.time()
if current_time - self._last_request_time < (1.0 / self._rate_limit_per_second):
sleep_time = (1.0 / self._rate_limit_per_second) - (current_time - self._last_request_time)
time.sleep(sleep_time)
self._last_request_time = time.time()
translated = self.translate_text(text, source_lang, target_lang)
cache_key = f"{source_lang}_{target_lang}_{text}"
self._cache[cache_key] = translated
return idx, translated, None
except Exception as e:
logger.warning(f"MyMemory concurrent translation failed for '{text}': {e}")
return idx, text, e
# Use conservative concurrent processing (2 workers max for free service)
max_workers = min(len(uncached_texts), 2)
batch_size = 8
for batch_start in range(0, len(uncached_texts), batch_size):
batch_end = min(batch_start + batch_size, len(uncached_texts))
batch_texts = uncached_texts[batch_start:batch_end]
batch_indices = uncached_indices[batch_start:batch_end]
text_info_batch = [(batch_indices[i], text) for i, text in enumerate(batch_texts)]
logger.debug(f"MyMemory concurrent batch {batch_start//batch_size + 1}: Processing {len(batch_texts)} texts with {max_workers} workers")
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_text = {executor.submit(translate_single_text, text_info): text_info for text_info in text_info_batch}
for future in concurrent.futures.as_completed(future_to_text):
try:
original_idx, translated_text, error = future.result(timeout=25)
results[original_idx] = translated_text
if error is None:
logger.debug(f"MyMemory concurrent SUCCESS: -> '{translated_text}'")
else:
logger.debug(f"MyMemory concurrent FAILED: {error}")
except concurrent.futures.TimeoutError:
text_info = future_to_text[future]
original_idx, original_text = text_info
results[original_idx] = original_text
logger.warning(f"MyMemory concurrent timeout for text: '{original_text}'")
except Exception as e:
text_info = future_to_text[future]
original_idx, original_text = text_info
results[original_idx] = original_text
logger.error(f"MyMemory concurrent thread error for '{original_text}': {e}")
# Shorter pause between batches since we're not hammering the API
if batch_end < len(uncached_texts):
time.sleep(0.5)
for i, result in enumerate(results):
if result is None:
results[i] = texts[i]
successful_translations = sum(1 for i, result in enumerate(results) if result != texts[i])
logger.info(f"MyMemory concurrent batch translation complete: {successful_translations}/{len(texts)} successfully translated")
return results
class LibreTranslateService(TranslationService):
"""LibreTranslate translation service with configurable server"""
def __init__(self, api_url: str = "https://libretranslate.com", api_key: str = None):
super().__init__("LibreTranslate")
# Ensure URL has trailing slash like official implementation
self.api_url = api_url.rstrip('/') + '/'
self.api_key = api_key
self._rate_limit_per_second = 2 # Conservative rate limiting
self._is_paid_service = "libretranslate.com" in api_url.lower()
def translate_text(self, text: str, source_lang: str = "ja", target_lang: str = "en") -> str:
"""Translate text using LibreTranslate API"""
# Ensure text is properly encoded
text = safe_decode_text(text)
logger.info(f"LibreTranslate: Starting translation of '{text}' from {source_lang} to {target_lang}")
if not text or not text.strip():
logger.debug("Empty text provided, returning as-is")
return text
lang_map = {"ja": "ja", "en": "en", "ko": "ko", "zh": "zh", "es": "es", "fr": "fr", "de": "de", "it": "it", "pt": "pt", "ru": "ru"}
source_lang = lang_map.get(source_lang, source_lang)
target_lang = lang_map.get(target_lang, target_lang)
endpoint = f"{self.api_url}translate"
data = {
"q": text,
"source": source_lang,
"target": target_lang
}
# Add API key if available (required for libretranslate.com, optional for self-hosted)
if self.api_key:
data["api_key"] = self.api_key
headers = {
"Content-Type": "application/json"
}
try:
logger.debug(f"Making request to LibreTranslate API: {endpoint}")
# Use JSON format like official API documentation
response = requests.post(endpoint, json=data, headers=headers, timeout=15)
logger.debug(f"LibreTranslate response status: {response.status_code}")
response.raise_for_status()
result = response.json()
logger.debug(f"LibreTranslate response: {result}")
if "translatedText" in result:
translated_text = result["translatedText"]
logger.info(f"LibreTranslate SUCCESS: '{text}' -> '{translated_text}'")
return translated_text
else:
raise TranslationError("LibreTranslate API returned no translation")
except requests.HTTPError as e:
if e.response.status_code == 429:
raise TranslationError("LibreTranslate rate limit exceeded")
elif e.response.status_code == 400:
raise TranslationError("LibreTranslate: Invalid language pair or text")
else:
logger.error(f"LibreTranslate HTTP error: {e}")
raise TranslationError(f"LibreTranslate API error: {e}")
except requests.Timeout:
logger.error("LibreTranslate request timed out")
raise TranslationError("LibreTranslate request timed out after 15 seconds")
except requests.RequestException as e:
logger.error(f"LibreTranslate API request failed: {e}")
raise TranslationError(f"LibreTranslate API request failed: {e}")
except Exception as e:
logger.error(f"Unexpected error in LibreTranslate: {e}")
raise TranslationError(f"Unexpected error: {e}")
def is_available(self) -> bool:
"""Check if LibreTranslate service is available"""
try:
endpoint = f"{self.api_url}languages"
params = {}
if self.api_key:
params["api_key"] = self.api_key
response = requests.get(endpoint, params=params if params else None, timeout=5)
return response.status_code == 200
except:
return False
def get_supported_languages(self) -> List[Tuple[str, str]]:
"""Get supported languages for LibreTranslate"""
try:
endpoint = f"{self.api_url}languages"
params = {}
if self.api_key:
params["api_key"] = self.api_key
response = requests.get(endpoint, params=params if params else None, timeout=5)
if response.status_code == 200:
languages = response.json()
return [(lang["code"], lang["name"]) for lang in languages]
except:
pass
# Fallback to common languages
return [
("ja", "Japanese"),
("en", "English"),
("ko", "Korean"),
("zh", "Chinese"),
("es", "Spanish"),
("fr", "French"),
("de", "German"),
("it", "Italian"),
("pt", "Portuguese"),
("ru", "Russian")
]
def supports_batch_translation(self) -> bool:
"""LibreTranslate optimized batch processing (concurrent requests)"""
return True
def batch_translate(self, texts: List[str], source_lang: str = "ja", target_lang: str = "en") -> List[str]:
"""Translate multiple texts using LibreTranslate with optimized concurrent requests"""
if not texts:
return []
# Ensure all texts are properly encoded
texts = [safe_decode_text(text) for text in texts]
logger.info(f"LibreTranslate: Starting batch translation of {len(texts)} texts from {source_lang} to {target_lang}")
# Check cache and separate cached vs uncached texts
results = [None] * len(texts)
uncached_indices = []
uncached_texts = []
for i, text in enumerate(texts):
if not text or not text.strip():
results[i] = text
continue
cache_key = f"{source_lang}_{target_lang}_{text}"
if cache_key in self._cache:
results[i] = self._cache[cache_key]
continue
uncached_indices.append(i)
uncached_texts.append(text)
if not uncached_texts:
logger.info(f"LibreTranslate: All {len(texts)} texts found in cache")
return results
logger.info(f"LibreTranslate: Translating {len(uncached_texts)} uncached texts")
# LibreTranslate language mapping
lang_map = {"ja": "ja", "en": "en", "ko": "ko", "zh": "zh", "es": "es", "fr": "fr", "de": "de", "it": "it", "pt": "pt", "ru": "ru"}
source_lang_code = lang_map.get(source_lang, source_lang)
target_lang_code = lang_map.get(target_lang, target_lang)
# Batch process in groups to avoid overwhelming the server
import concurrent.futures
from concurrent.futures import ThreadPoolExecutor
def translate_single_text(text_info):
idx, text = text_info
try:
translated = self.translate_text(text, source_lang, target_lang)
cache_key = f"{source_lang}_{target_lang}_{text}"
self._cache[cache_key] = translated
return idx, translated, None
except Exception as e:
logger.warning(f"LibreTranslate translation failed for '{text}': {e}")
return idx, text, e
# Use thread pool for concurrent requests (limited to avoid server overload)
max_workers = min(len(uncached_texts), 3)
batch_size = 10 # Process in smaller batches
for batch_start in range(0, len(uncached_texts), batch_size):
batch_end = min(batch_start + batch_size, len(uncached_texts))
batch_texts = uncached_texts[batch_start:batch_end]
batch_indices = uncached_indices[batch_start:batch_end]
text_info_batch = [(batch_indices[i], text) for i, text in enumerate(batch_texts)]
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_text = {executor.submit(translate_single_text, text_info): text_info for text_info in text_info_batch}
for future in concurrent.futures.as_completed(future_to_text):
try:
original_idx, translated_text, error = future.result(timeout=30)
results[original_idx] = translated_text
if error is None:
logger.debug(f"LibreTranslate SUCCESS: -> '{translated_text}'")
else:
logger.debug(f"LibreTranslate FAILED: {error}")
except concurrent.futures.TimeoutError:
text_info = future_to_text[future]
original_idx, original_text = text_info
results[original_idx] = original_text
logger.warning(f"LibreTranslate timeout for text: '{original_text}'")
except Exception as e:
text_info = future_to_text[future]
original_idx, original_text = text_info
results[original_idx] = original_text
logger.error(f"LibreTranslate thread error for '{original_text}': {e}")
if batch_end < len(uncached_texts):
time.sleep(0.5)
for i, result in enumerate(results):
if result is None:
results[i] = texts[i]
successful_translations = sum(1 for i, result in enumerate(results) if result != texts[i])
logger.info(f"LibreTranslate batch translation complete: {successful_translations}/{len(texts)} successfully translated")
return results
class TranslationServiceManager:
"""Manages multiple translation services with fallback logic"""
def __init__(self):
self._services: Dict[str, TranslationService] = {}
self._primary_service: Optional[str] = None
self._initialize_services()
def _initialize_services(self):
"""Initialize available translation services"""
mymemory = MyMemoryService()
self._services["mymemory"] = mymemory
libretranslate_url = get_preference("libretranslate_url", "https://libretranslate.com")
libretranslate_api_key = get_preference("libretranslate_api_key", "")
libretranslate = LibreTranslateService(api_url=libretranslate_url, api_key=libretranslate_api_key if libretranslate_api_key else None)
self._services["libretranslate"] = libretranslate
deepl_api_key = get_preference("deepl_api_key", "")
if deepl_api_key:
deepl = DeepLService(api_key=deepl_api_key, use_free_api=True)
self._services["deepl"] = deepl
# Set primary service from preferences (default to free service)
self._primary_service = get_preference("translation_service", "mymemory")
logger.info(f"Initialized translation services: {list(self._services.keys())}")
logger.info(f"Primary service: {self._primary_service}")
def get_available_services(self) -> List[Tuple[str, str]]:
"""Get list of available translation services"""
available = []
for service_id, service in self._services.items():
if service.is_available():
available.append((service_id, service.name))
else:
logger.debug(f"Service {service.name} is not available")
return available
def set_primary_service(self, service_id: str) -> bool:
"""Set the primary translation service"""
if service_id in self._services:
self._primary_service = service_id
save_preference("translation_service", service_id)
logger.info(f"Set primary translation service to: {service_id}")
return True
return False
def get_service(self, service_id: Optional[str] = None) -> Optional[TranslationService]:
"""Get a translation service by ID"""
if service_id is None:
service_id = self._primary_service
if service_id and service_id in self._services:
service = self._services[service_id]
if service.is_available():
return service
return None
def translate_with_fallback(self, text: str, source_lang: str = "ja", target_lang: str = "en") -> Tuple[str, str]:
"""Translate text with automatic fallback to other services"""
# Ensure text is properly encoded
text = safe_decode_text(text)
if not text or not text.strip():
return text, "none"
# Try primary service first
primary_service = self.get_service()
if primary_service:
try:
result = primary_service.translate_text(text, source_lang, target_lang)
return result, primary_service.name
except Exception as e:
logger.warning(f"Primary service {primary_service.name} failed: {e}")
for service_id, service in self._services.items():
if service_id == self._primary_service:
continue
if service.is_available():
try:
result = service.translate_text(text, source_lang, target_lang)
logger.info(f"Fallback to {service.name} successful")
return result, service.name
except Exception as e:
logger.warning(f"Fallback service {service.name} failed: {e}")
logger.error(f"All translation services failed for: {text}")
return text, "failed"
def batch_translate_with_fallback(self, texts: List[str], source_lang: str = "ja", target_lang: str = "en") -> List[Tuple[str, str]]:
"""Batch translate with fallback - uses optimized batch processing when available"""
if not texts:
return []
logger.info(f"Starting batch translation of {len(texts)} texts using service manager")
primary_service = self.get_service()
if primary_service:
try:
if primary_service.supports_batch_translation():
logger.info(f"Using native batch translation with {primary_service.name}")
translations = primary_service.batch_translate(texts, source_lang, target_lang)
return [(translation, primary_service.name) for translation in translations]
else:
logger.info(f"Service {primary_service.name} does not support batch translation, using individual requests")
# Use the base implementation for services without batch support
translations = []
for text in texts:
translated = primary_service.translate_text(text, source_lang, target_lang)
translations.append(translated)
return [(translation, primary_service.name) for translation in translations]
except Exception as e:
logger.warning(f"Batch translation failed with {primary_service.name}: {e}")
results = []
for text in texts:
translation, service_name = self.translate_with_fallback(text, source_lang, target_lang)
results.append((translation, service_name))
return results
# Global translation service manager instance
_translation_manager: Optional[TranslationServiceManager] = None
def get_translation_manager() -> TranslationServiceManager:
"""Get the global translation service manager"""
global _translation_manager
if _translation_manager is None:
_translation_manager = TranslationServiceManager()
return _translation_manager
def configure_deepl_translator(api_key: str, use_free_api: bool = True) -> bool:
"""Configure DeepL translation service"""
try:
save_preference("deepl_api_key", api_key)
save_preference("deepl_use_free_api", use_free_api)
# Test the API key
deepl = DeepLService(api_key=api_key, use_free_api=use_free_api)
if deepl.is_available():
# Re-initialize the global manager to pick up new service
global _translation_manager
_translation_manager = None
logger.info("DeepL translator configured successfully")
return True
else:
logger.error("DeepL API key test failed")
return False
except Exception as e:
logger.error(f"Failed to configure DeepL translator: {e}")
return False
def configure_libretranslate_server(server_url: str, api_key: str = None) -> bool:
"""Configure LibreTranslate server URL and optional API key"""
try:
if not server_url.strip():
server_url = "https://libretranslate.com"
# Ensure proper URL format
if not server_url.startswith(('http://', 'https://')):
server_url = 'https://' + server_url
save_preference("libretranslate_url", server_url)
save_preference("libretranslate_api_key", api_key if api_key else "")
# Test the server
libretranslate = LibreTranslateService(api_url=server_url, api_key=api_key)
if libretranslate.is_available():
# Re-initialize the global manager to pick up new service
global _translation_manager
_translation_manager = None
logger.info(f"LibreTranslate server configured successfully: {server_url}")
return True
else:
logger.error(f"LibreTranslate server test failed: {server_url}")
return False
except Exception as e:
logger.error(f"Failed to configure LibreTranslate server: {e}")
return False
+2 -2
View File
@@ -19,8 +19,8 @@ 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.3"]
# ["0.2", "0.3"] would look for both 0.2.x and 0.3.x
ALLOWED_VERSION_SERIES = ["0.5"]
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
+40 -26
View File
@@ -28,7 +28,15 @@ def scale_images_to_largest(images: List[Image]) -> tuple[int, int]:
x: int = 0
y: int = 0
valid_images = [img for img in images if img and img.has_data]
valid_images = []
for img in images:
if img:
try:
if img.has_data:
valid_images.append(img)
except ReferenceError:
# Image has been removed from Blender's memory
pass
if not valid_images:
return 0, 0
@@ -66,50 +74,56 @@ def get_material_images_from_scene(context: Context) -> list[MaterialImageList]:
new_mat_image_item.albedo = bpy.data.images[mat_slot.material.texture_atlas_albedo]
except Exception:
name = mat_slot.material.name + "_albedo_replacement"
if name in bpy.data.images:
bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True)
new_mat_image_item.albedo = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
new_mat_image_item.albedo.pixels[:] = numpy.tile(numpy.array([0.0,0.0,0.0,1.0]), 32*32)
if name not in bpy.data.images:
new_mat_image_item.albedo = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
new_mat_image_item.albedo.pixels[:] = numpy.tile(numpy.array([0.0,0.0,0.0,1.0]), 32*32)
else:
new_mat_image_item.albedo = bpy.data.images[name]
try:
new_mat_image_item.normal = bpy.data.images[mat_slot.material.texture_atlas_normal]
except Exception:
name = mat_slot.material.name + "_normal_replacement"
if name in bpy.data.images:
bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True)
new_mat_image_item.normal = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
new_mat_image_item.normal.pixels[:] = numpy.tile(numpy.array([0.5,0.5,1.0,1.0]), 32*32)
if name not in bpy.data.images:
new_mat_image_item.normal = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
new_mat_image_item.normal.pixels[:] = numpy.tile(numpy.array([0.5,0.5,1.0,1.0]), 32*32)
else:
new_mat_image_item.normal = bpy.data.images[name]
try:
new_mat_image_item.emission = bpy.data.images[mat_slot.material.texture_atlas_emission]
except Exception:
name = mat_slot.material.name + "_emission_replacement"
if name in bpy.data.images:
bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True)
new_mat_image_item.emission = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
new_mat_image_item.emission.pixels[:] = numpy.tile(numpy.array([0.0,0.0,0.0,1.0]), 32*32)
if name not in bpy.data.images:
new_mat_image_item.emission = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
new_mat_image_item.emission.pixels[:] = numpy.tile(numpy.array([0.0,0.0,0.0,1.0]), 32*32)
else:
new_mat_image_item.emission = bpy.data.images[name]
try:
new_mat_image_item.ambient_occlusion = bpy.data.images[mat_slot.material.texture_atlas_ambient_occlusion]
except Exception:
name = mat_slot.material.name + "_ambient_occlusion_replacement"
if name in bpy.data.images:
bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True)
new_mat_image_item.ambient_occlusion = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
new_mat_image_item.ambient_occlusion.pixels[:] = numpy.tile(numpy.array([1.0,1.0,1.0,1.0]), 32*32)
if name not in bpy.data.images:
new_mat_image_item.ambient_occlusion = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
new_mat_image_item.ambient_occlusion.pixels[:] = numpy.tile(numpy.array([1.0,1.0,1.0,1.0]), 32*32)
else:
new_mat_image_item.ambient_occlusion = bpy.data.images[name]
try:
new_mat_image_item.height = bpy.data.images[mat_slot.material.texture_atlas_height]
except Exception:
name = mat_slot.material.name + "_height_replacement"
if name in bpy.data.images:
bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True)
new_mat_image_item.height = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
new_mat_image_item.height.pixels[:] = numpy.tile(numpy.array([0.5,0.5,0.5,1.0]), 32*32)
if name not in bpy.data.images:
new_mat_image_item.height = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
new_mat_image_item.height.pixels[:] = numpy.tile(numpy.array([0.5,0.5,0.5,1.0]), 32*32)
else:
new_mat_image_item.height = bpy.data.images[name]
try:
new_mat_image_item.roughness = bpy.data.images[mat_slot.material.texture_atlas_roughness]
except Exception:
name = mat_slot.material.name + "_roughness_replacement"
if name in bpy.data.images:
bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True)
new_mat_image_item.roughness = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
new_mat_image_item.roughness.pixels[:] = numpy.tile(numpy.array([1.0,1.0,1.0,0.0]), 32*32)
if name not in bpy.data.images:
new_mat_image_item.roughness = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
new_mat_image_item.roughness.pixels[:] = numpy.tile(numpy.array([1.0,1.0,1.0,0.0]), 32*32)
else:
new_mat_image_item.roughness = bpy.data.images[name]
new_mat_image_item.material = mat_slot.material
new_mat_image_item.parent_mesh = obj
@@ -227,7 +241,7 @@ class AvatarToolKit_OT_AtlasMaterials(Operator):
# Create material nodes
atlased_mat.material = bpy.data.materials.new(
name=f"Atlas_Final_{context.scene.name}_{Path(bpy.data.filepath).stem}")
atlased_mat.material.use_nodes = True
# Note: material.use_nodes is deprecated in Blender 5.0 - materials always use nodes
atlased_mat.material.node_tree.nodes.clear()
principled_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeBsdfPrincipled")
+51 -4
View File
@@ -8,6 +8,7 @@ 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,
@@ -28,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)
@@ -79,19 +102,43 @@ class AvatarToolkit_OT_MergeArmature(bpy.types.Operator):
wm.progress_update(100)
wm.progress_end()
# Restore settings only for the base armature since merge_armature is removed during join
restore_breaking_settings_armature(base_armature, data_breaking_base)
if merge_armature_name_stored in bpy.data.objects:
merge_armature_obj = bpy.data.objects[merge_armature_name_stored]
restore_breaking_settings_armature(merge_armature_obj, data_breaking_merge)
# Restore original mode if it wasn't OBJECT
try:
if original_mode == 'EDIT_ARMATURE':
bpy.ops.object.mode_set(mode='EDIT')
elif original_mode == 'POSE':
bpy.ops.object.mode_set(mode='POSE')
elif original_mode != 'OBJECT':
logger.debug(f"Restoring to original mode: {original_mode}")
# For other modes, stay in object mode as it's safest
except Exception:
logger.warning(f"Could not restore original mode: {original_mode}")
self.report({'INFO'}, t('MergeArmature.success'))
return {'FINISHED'}
except Exception as e:
logger.error(f"Error merging armatures: {str(e)}\n{traceback.format_exc()}")
self.report({'ERROR'}, traceback.format_exc())
errormessage: str = traceback.format_exc()
logger.error(f"Error merging armatures: {str(e)}\n{errormessage}")
self.report({'ERROR'}, f"Error merging armatures: {errormessage}")
# Try to restore original mode even on error
try:
if 'original_mode' in locals() and original_mode != 'OBJECT':
if original_mode == 'EDIT_ARMATURE':
bpy.ops.object.mode_set(mode='EDIT')
elif original_mode == 'POSE':
bpy.ops.object.mode_set(mode='POSE')
except Exception:
logger.warning("Could not restore mode after error")
return {'CANCELLED'}
def delete_rigidbodies_and_joints(armature: Object) -> None:
+2 -2
View File
@@ -92,7 +92,7 @@ class AvatarToolkit_OT_StopPoseMode(Operator):
self.report({'ERROR'}, t("PoseMode.error.stop", error=traceback.format_exc()))
return {'CANCELLED'}
class AvatarToolkit_OT_ApplyPoseAsRest(Operator, BatchPoseOperationMixin):
class AvatarToolkit_OT_ApplyPoseAsShapekey(Operator, BatchPoseOperationMixin):
bl_idname = 'avatar_toolkit.apply_pose_as_shapekey'
bl_label = t("QuickAccess.apply_pose_as_shapekey.label")
bl_description = t("QuickAccess.apply_pose_as_shapekey.desc")
@@ -136,7 +136,7 @@ class AvatarToolkit_OT_ApplyPoseAsRest(Operator, BatchPoseOperationMixin):
self.report({'ERROR'}, t("PoseMode.error.shapekey", error=traceback.format_exc()))
return {'CANCELLED'}
class AvatarToolkit_OT_ApplyPoseAsShapekey(Operator, BatchPoseOperationMixin):
class AvatarToolkit_OT_ApplyPoseAsRest(Operator, BatchPoseOperationMixin):
bl_idname = 'avatar_toolkit.apply_pose_as_rest'
bl_label = t("QuickAccess.apply_pose_as_rest.label")
bl_description = t("QuickAccess.apply_pose_as_rest.desc")
+10 -1
View File
@@ -1,5 +1,7 @@
import traceback
import bpy
import bpy_extras
from bpy_extras import anim_utils
import re
from bpy.types import Operator, Context, EditBone, Object, Armature, Mesh
from typing import Optional, Dict, Any, List, Tuple
@@ -347,10 +349,17 @@ class AvatarToolKit_OT_FlipCurrentKeyFrames(Operator):
armature_data.bones.foreach_set("select", [False] * len(armature_data.bones))
# Get channelbag for the action using Blender 5.0 API
action = armature.animation_data.action
if not action.slots:
slot = action.slots.new(for_id=armature.data)
else:
slot = action.slots[0]
channelbag = anim_utils.action_ensure_channelbag_for_slot(action, slot)
#create a set for every frame time where we need to key a keyframe for the flipped pose
times: Dict[float,list[bpy.types.FCurve]] = {}
for curve in armature.animation_data.action.fcurves:
for curve in channelbag.fcurves:
if not curve.data_path.startswith("pose"):
continue
for point in curve.keyframe_points:
+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'}
+4 -2
View File
@@ -119,8 +119,10 @@ class AvatarToolkit_OT_ExplodeMesh(Operator):
@classmethod
def poll(cls, context: Context) -> bool:
return context.view_layer.objects.active.type == "MESH" and len(context.view_layer.objects.selected) == 1
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)
+1 -7
View File
@@ -55,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')
@@ -90,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")
+24 -3
View File
@@ -8,6 +8,26 @@ from ...core.translations import t
from ...core.logging_setup import logger
import traceback
def get_uv_vertex_selection(mesh: Mesh) -> List[bool]:
"""
Get UV vertex selection state for Blender 5.0.
UV selection is stored in mesh attributes (.uv_select_vert).
"""
uv_select_attr = mesh.attributes['.uv_select_vert']
selection = [False] * len(mesh.loops)
uv_select_attr.data.foreach_get('value', selection)
return selection
def set_uv_vertex_selection(mesh: Mesh, loop_index: int, value: bool) -> None:
"""
Set UV vertex selection state for Blender 5.0.
UV selection is stored in mesh attributes (.uv_select_vert).
"""
uv_select_attr = mesh.attributes['.uv_select_vert']
uv_select_attr.data[loop_index].value = value
class GenerateLoopTreeResult(TypedDict):
tree: Dict[str, Set[str]]
selected_loops: Dict[str, List[int]]
@@ -78,8 +98,9 @@ class AvatarToolkit_OT_AlignUVEdgesToTarget(Operator):
# that two vertices share the same face loop, and therefore are connected.
#hmmm real stupid grimlin hours with this one. Using a string as the index of a dictionary of loop corners that end up on the same coordinate
for k,i in enumerate(uv_lay.vertex_selection):
if (i.value == True) and (bm.verts[me.loops[k].vertex_index].select == True) and (bm.verts[me.loops[k].vertex_index].hide == False):
uv_selection = get_uv_vertex_selection(me)
for k, is_selected in enumerate(uv_selection):
if (is_selected == True) and (bm.verts[me.loops[k].vertex_index].select == True) and (bm.verts[me.loops[k].vertex_index].hide == False):
key = np.array(uv_lay.uv[k].vector[:])
key = key.round(decimals=5)
@@ -140,7 +161,7 @@ class AvatarToolkit_OT_AlignUVEdgesToTarget(Operator):
uv_lay = me.uv_layers.active
for uvcoordstr in vert_target_loops:
for loop in vert_target_loops[uvcoordstr]:
uv_lay.vertex_selection[loop].value = True
set_uv_vertex_selection(me, loop, True)
bm.free()
me.validate()
+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'}
+8 -4
View File
@@ -137,15 +137,17 @@ class AvatarToolkit_OT_PreviewVisemes(Operator):
return False
# Get mesh from UI selection
from ..core.common import get_mesh_from_identifier
props = context.scene.avatar_toolkit
mesh_obj = bpy.data.objects.get(props.viseme_mesh)
mesh_obj = get_mesh_from_identifier(props.viseme_mesh)
# Validate mesh
return mesh_obj and mesh_obj.type == 'MESH'
def execute(self, context: Context) -> Set[str]:
from ..core.common import get_mesh_from_identifier
props = context.scene.avatar_toolkit
mesh = bpy.data.objects.get(props.viseme_mesh)
mesh = get_mesh_from_identifier(props.viseme_mesh)
if props.viseme_preview_mode:
VisemePreview.end_preview(mesh)
@@ -191,15 +193,17 @@ class AvatarToolkit_OT_CreateVisemes(Operator):
return False
# Get mesh from UI selection
from ..core.common import get_mesh_from_identifier
props = context.scene.avatar_toolkit
mesh_obj = bpy.data.objects.get(props.viseme_mesh)
mesh_obj = get_mesh_from_identifier(props.viseme_mesh)
# Validate mesh
return mesh_obj and mesh_obj.type == 'MESH'
def execute(self, context: Context) -> Set[str]:
from ..core.common import get_mesh_from_identifier
props = context.scene.avatar_toolkit
mesh = bpy.data.objects.get(props.viseme_mesh) # Changed from context.active_object
mesh = get_mesh_from_identifier(props.viseme_mesh)
if not mesh or not mesh.data.shape_keys:
self.report({'ERROR'}, t("Visemes.error.no_shapekeys"))
+108 -3
View File
@@ -1,7 +1,7 @@
{
"authors": ["Avatar Toolkit Team"],
"messages": {
"AvatarToolkit.label": "Avatar Toolkit (Alpha 0.3.1)",
"AvatarToolkit.label": "Avatar Toolkit (Alpha 0.5.2)",
"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.",
@@ -117,6 +117,15 @@
"Validation.clear_bone_highlighting": "Clear Bone Highlighting",
"Validation.clear_bone_highlighting_desc": "Remove bone highlighting and reset bone colors to default",
"Validation.highlighting_cleared": "Bone highlighting cleared successfully",
"Validation.label": "Armature Validation",
"Validation.validate_now": "Validate Armature Now",
"Validation.validate_now_desc": "Run armature validation and display detailed results",
"Validation.results": "Validation Results",
"Validation.tpose.validate_now": "Validate T-Pose Now",
"Armature.validation.acceptable_standard.success": "Armature meets acceptable standards",
"Armature.validation.acceptable_standard.note": "This is a valid armature format that is compatible with most avatar systems",
"Armature.validation.acceptable_standard.option": "You can standardize the armature if desired",
"Mesh.validation.no_data": "No mesh data",
"Mesh.validation.no_vertex_groups": "No vertex groups found",
@@ -191,6 +200,7 @@
"Tools.digitigrade_error": "Failed to create digitigrade legs: {error}",
"Tools.digitigrade_success": "Successfully created digitigrade leg setup",
"Tools.processing_leg": "Processing leg bone: {bone}",
"Tools.weight_title": "Weight Tools",
"Tools.merge_twist_bones": "Keep Twist Bones",
"Tools.merge_twist_bones_desc": "When checked, twist bones will be kept, even if there are zero-weight",
"Tools.clean_weights": "Remove Zero Weight Bones",
@@ -325,6 +335,7 @@
"Visemes.success": "Visemes created successfully",
"Visemes.mesh_select": "Select Mesh",
"Visemes.mesh_select_desc": "Select the mesh to create visemes on",
"Visemes.no_meshes": "No meshes found",
"EyeTracking.label": "Eye Tracking",
"EyeTracking.setup": "Eye Tracking Setup",
@@ -517,6 +528,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",
@@ -554,6 +567,98 @@
"Language.ko_KR": "Korean",
"Language.changed.title": "Language Changed",
"Language.changed.success": "Language changed successfully!",
"Language.changed.restart": "Some UI elements may require restarting Blender"
}
"Language.changed.restart": "Some UI elements may require restarting Blender",
"VRM.panel.label": "VRM to Unity",
"VRM.converter.title": "VRM Converter",
"VRM.no_armature_selected": "No armature selected",
"VRM.select_armature_to_convert": "Select an armature to convert",
"VRM.armature_name": "Armature: {name}",
"VRM.armature_detected": "VRM armature detected",
"VRM.no_vrm_bones_detected": "No VRM bones detected",
"VRM.remove_colliders": "Remove Colliders",
"VRM.remove_root_bone": "Remove Root Bone",
"VRM.convert_to_unity_format": "Convert to Unity Format",
"VRM.convert_to_unity.label": "Convert VRM to Unity",
"VRM.convert_to_unity.desc": "Convert VRM armature bone names to Unity humanoid naming convention",
"VRM.conversion_info.title": "Conversion Info:",
"VRM.conversion_info.renames_bones": "• Renames VRM bones to Unity format",
"VRM.conversion_info.removes_colliders": "• Removes collider bones (optional)",
"VRM.conversion_info.removes_root": "• Removes root bone, makes Hips root (optional)",
"VRM.conversion_info.maintains_hierarchy": "• Maintains bone hierarchy",
"VRM.conversion_info.validates_results": "• Validates conversion results",
"VRM.conversion_info.preserves_animations": "• Preserves all animations",
"VRM.detection_failed.title": "VRM Detection Failed:",
"VRM.detection_failed.not_vrm_format": "• Selected armature is not VRM format",
"VRM.detection_failed.bones_start_with": "• VRM bones start with 'J_Bip_C_'",
"VRM.detection_failed.need_five_bones": "• Need at least 5 VRM bones detected",
"VRM.detection_failed.check_bone_names": "• Check armature bone names",
"VRM.validation.hierarchy_passed": "Unity hierarchy validation passed",
"VRM.validation.hierarchy_issues": "Conversion completed but hierarchy validation found issues:",
"VRM.validation.armature_passed": "Armature passes standard validation",
"VRM.validation.failed": "Conversion completed but validation failed: {error}",
"VRM.remove_colliders_desc": "Remove VRM collider bones during conversion",
"VRM.remove_root": "Remove Root Bone",
"VRM.remove_root_desc": "Remove unnecessary VRM root bone and make Hips the root bone",
"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)"
}
}
+106 -2
View File
@@ -1,7 +1,7 @@
{
"authors": ["Avatar Toolkit Team"],
"messages": {
"AvatarToolkit.label": "アバターツールキット (アルファ 0.3.1)",
"AvatarToolkit.label": "アバターツールキット (アルファ 0.5.2)",
"AvatarToolkit.desc1": "アバターツールキットは早期アクセス中であり、",
"AvatarToolkit.desc2": "問題が発生する可能性があります。問題を見つけた場合は、",
"AvatarToolkit.desc3": "GitHubで報告してください。",
@@ -117,6 +117,15 @@
"Validation.clear_bone_highlighting": "ボーンの強調表示をクリア",
"Validation.clear_bone_highlighting_desc": "ボーンの強調表示を削除し、ボーンの色をデフォルトにリセット",
"Validation.highlighting_cleared": "ボーンの強調表示が正常にクリアされました",
"Validation.label": "アーマチュア検証",
"Validation.validate_now": "アーマチュアを検証する",
"Validation.validate_now_desc": "アーマチュア検証を実行し、詳細な結果を表示",
"Validation.results": "検証結果",
"Validation.tpose.validate_now": "T-ポーズを検証する",
"Armature.validation.acceptable_standard.success": "アーマチュアが許容可能な標準を満たしています",
"Armature.validation.acceptable_standard.note": "これは、ほとんどのアバターシステムと互換性のある有効なアーマチュア形式です",
"Armature.validation.acceptable_standard.option": "必要に応じてアーマチュアを標準化できます",
"Mesh.validation.no_data": "メッシュデータがありません",
"Mesh.validation.no_vertex_groups": "頂点グループが見つかりません",
@@ -194,6 +203,7 @@
"Tools.digitigrade_error": "デジティグレード脚の作成に失敗: {error}",
"Tools.digitigrade_success": "デジティグレード脚の設定が正常に作成されました",
"Tools.processing_leg": "脚のボーンを処理中: {bone}",
"Tools.weight_title": "ウェイトツール",
"Tools.merge_twist_bones": "ツイストボーンを保持",
"Tools.merge_twist_bones_desc": "チェックすると、ウェイトがゼロでもツイストボーンが保持されます",
"Tools.clean_weights": "ゼロウェイトボーンを削除",
@@ -308,6 +318,7 @@
"Visemes.success": "口形素が正常に作成されました",
"Visemes.mesh_select": "メッシュを選択",
"Visemes.mesh_select_desc": "口形素を作成するメッシュを選択",
"Visemes.no_meshes": "メッシュが見つかりません",
"EyeTracking.label": "アイトラッキング",
"EyeTracking.setup": "アイトラッキング設定",
@@ -500,6 +511,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": "言語",
@@ -537,6 +550,97 @@
"Language.ko_KR": "韓国語",
"Language.changed.title": "言語が変更されました",
"Language.changed.success": "言語が正常に変更されました!",
"Language.changed.restart": "一部のUI要素はBlenderの再起動が必要な場合があります"
"Language.changed.restart": "一部のUI要素はBlenderの再起動が必要な場合があります",
"VRM.panel.label": "VRMからUnityへ",
"VRM.converter.title": "VRMコンバーター",
"VRM.no_armature_selected": "アーマチュアが選択されていません",
"VRM.select_armature_to_convert": "変換するアーマチュアを選択してください",
"VRM.armature_name": "アーマチュア: {name}",
"VRM.armature_detected": "VRMアーマチュアが検出されました",
"VRM.no_vrm_bones_detected": "VRMボーンが検出されませんでした",
"VRM.remove_root_bone": "ルートボーンを削除",
"VRM.convert_to_unity_format": "Unity形式に変換",
"VRM.convert_to_unity.label": "VRMをUnityに変換",
"VRM.convert_to_unity.desc": "VRMアーマチュアのボーン名をUnityヒューマノイド命名規則に変換",
"VRM.conversion_info.title": "変換情報:",
"VRM.conversion_info.renames_bones": "• VRMボーンをUnity形式にリネーム",
"VRM.conversion_info.removes_colliders": "• コライダーボーンを削除(オプション)",
"VRM.conversion_info.removes_root": "• ルートボーンを削除し、Hipsをルートにする(オプション)",
"VRM.conversion_info.maintains_hierarchy": "• ボーン階層を維持",
"VRM.conversion_info.validates_results": "• 変換結果を検証",
"VRM.conversion_info.preserves_animations": "• すべてのアニメーションを保持",
"VRM.detection_failed.title": "VRM検出失敗:",
"VRM.detection_failed.not_vrm_format": "• 選択されたアーマチュアはVRM形式ではありません",
"VRM.detection_failed.bones_start_with": "• VRMボーンは'J_Bip_C_'で始まります",
"VRM.detection_failed.need_five_bones": "• 少なくとも5つのVRMボーンが検出される必要があります",
"VRM.detection_failed.check_bone_names": "• アーマチュアのボーン名を確認してください",
"VRM.validation.hierarchy_passed": "Unity階層検証に合格しました",
"VRM.validation.hierarchy_issues": "変換は完了しましたが、階層検証で問題が見つかりました:",
"VRM.validation.armature_passed": "アーマチュアは標準検証に合格しました",
"VRM.validation.failed": "変換は完了しましたが、検証に失敗しました: {error}",
"VRM.remove_colliders": "コライダーを削除",
"VRM.remove_colliders_desc": "変換中にVRMコライダーボーンを削除",
"VRM.remove_root": "ルートボーンを削除",
"VRM.remove_root_desc": "不要なVRMルートボーンを削除し、ヒップをルートボーンにする",
"Translation.label": "翻訳",
"Translation.service": "翻訳サービス",
"Translation.service_desc": "使用する翻訳サービスを選択",
"Translation.mode": "翻訳モード",
"Translation.mode_desc": "翻訳の動作方法を選択",
"Translation.mode.hybrid": "ハイブリッド(辞書 + API",
"Translation.mode.hybrid_desc": "まず辞書を試し、その後APIサービスをフォールバックとして使用",
"Translation.mode.dictionary_only": "辞書のみ",
"Translation.mode.dictionary_only_desc": "翻訳には組み込み辞書のみを使用",
"Translation.mode.api_only": "APIのみ",
"Translation.mode.api_only_desc": "オンライン翻訳サービスのみを使用",
"Translation.service_settings": "翻訳サービス",
"Translation.language_settings": "言語設定",
"Translation.quick_actions": "クイックアクション",
"Translation.utilities": "ユーティリティ",
"Translation.advanced_settings": "詳細設定",
"Translation.source_language": "ソース言語",
"Translation.source_language_desc": "翻訳元の言語",
"Translation.target_language": "ターゲット言語",
"Translation.target_language_desc": "翻訳先の言語",
"Translation.translate_names": "名前を翻訳",
"Translation.translate_names_desc": "選択したサービスと設定を使用して名前を翻訳",
"Translation.test_service": "サービスをテスト",
"Translation.test_service_desc": "現在選択されている翻訳サービスをテスト",
"Translation.clear_cache": "キャッシュをクリア",
"Translation.clear_cache_desc": "すべてのキャッシュされた翻訳をクリア",
"Translation.show_stats": "統計を表示",
"Translation.show_stats_desc": "翻訳統計と情報を表示",
"Translation.no_armature": "アーマチュアが選択されていません",
"Translation.test_failed": "翻訳サービステストが失敗しました - 設定を確認してください",
"Translation.cache_cleared": "翻訳キャッシュが正常にクリアされました",
"Translation.mymemory_info": "MyMemoryは完全に無料でAPIキー不要です。1日1000回の翻訳を提供します。",
"Translation.service.mymemory": "MyMemory(無料)",
"Translation.service.mymemory_desc": "完全に無料のサービス - APIキー不要!",
"Translation.service.libretranslate": "LibreTranslate",
"Translation.service.libretranslate_desc": "設定可能なサーバー - セルフホスト可能",
"Translation.service.deepl": "DeepL",
"Translation.service.deepl_desc": "高品質な翻訳 - APIキーが必要",
"Translation.type.bones": "ボーン",
"Translation.type.bones_desc": "ボーン名を翻訳",
"Translation.type.shapekeys": "シェイプキー",
"Translation.type.shapekeys_desc": "シェイプキー名を翻訳",
"Translation.type.materials": "マテリアル",
"Translation.type.materials_desc": "マテリアル名を翻訳",
"Translation.type.objects": "オブジェクト",
"Translation.type.objects_desc": "オブジェクト名を翻訳",
"Translation.type.all": "すべて",
"Translation.type.all_desc": "サポートされているすべてのタイプを翻訳",
"Translation.configure_deepl": "DeepL APIを設定",
"Translation.configure_deepl_desc": "DeepL翻訳サービスAPIキーを設定",
"Translation.deepl_api_key": "DeepL APIキー",
"Translation.deepl_api_key_desc": "あなたのDeepL APIキー(deepl.com/proで無料キーを取得)",
"Translation.configure_libretranslate": "LibreTranslateサーバーを設定",
"Translation.configure_libretranslate_desc": "LibreTranslate翻訳サービスサーバーURLを設定",
"Translation.server_url": "サーバーURL",
"Translation.server_url_desc": "LibreTranslateサーバーURL(例:https://your-server.com",
"Translation.api_key": "APIキー",
"Translation.api_key_desc": "LibreTranslateサーバー用のAPIキー(一部のサーバーでは任意)"
}
}
+106 -2
View File
@@ -1,7 +1,7 @@
{
"authors": ["Avatar Toolkit Team"],
"messages": {
"AvatarToolkit.label": "아바타 툴킷 (알파 0.3.1)",
"AvatarToolkit.label": "아바타 툴킷 (알파 0.5.2)",
"AvatarToolkit.desc1": "아바타 툴킷은 초기 액세스 단계에 있으므로",
"AvatarToolkit.desc2": "문제가 있을 수 있습니다. 문제를 발견하시면",
"AvatarToolkit.desc3": "Github에 보고해 주세요.",
@@ -117,6 +117,15 @@
"Validation.clear_bone_highlighting": "본 강조 표시 지우기",
"Validation.clear_bone_highlighting_desc": "본 강조 표시를 제거하고 본 색상을 기본값으로 재설정",
"Validation.highlighting_cleared": "본 강조 표시 지우기 성공",
"Validation.label": "아마추어 검증",
"Validation.validate_now": "지금 아마추어 검증",
"Validation.validate_now_desc": "아마추어 검증을 실행하고 자세한 결과 표시",
"Validation.results": "검증 결과",
"Validation.tpose.validate_now": "지금 T-포즈 검증",
"Armature.validation.acceptable_standard.success": "아마추어가 허용 가능한 표준을 충족합니다",
"Armature.validation.acceptable_standard.note": "이것은 대부분의 아바타 시스템과 호환되는 유효한 아마추어 형식입니다",
"Armature.validation.acceptable_standard.option": "필요한 경우 아마추어를 표준화할 수 있습니다",
"Mesh.validation.no_data": "메시 데이터 없음",
"Mesh.validation.no_vertex_groups": "버텍스 그룹을 찾을 수 없음",
@@ -194,6 +203,7 @@
"Tools.digitigrade_error": "디지티그레이드 다리 생성 실패: {error}",
"Tools.digitigrade_success": "디지티그레이드 다리 설정 생성 성공",
"Tools.processing_leg": "다리 본 처리 중: {bone}",
"Tools.weight_title": "가중치 도구",
"Tools.merge_twist_bones": "트위스트 본 유지",
"Tools.merge_twist_bones_desc": "체크하면 가중치가 0이더라도 트위스트 본이 유지됩니다",
"Tools.clean_weights": "가중치 0인 본 제거",
@@ -308,6 +318,7 @@
"Visemes.success": "비셈 생성 성공",
"Visemes.mesh_select": "메시 선택",
"Visemes.mesh_select_desc": "비셈을 생성할 메시 선택",
"Visemes.no_meshes": "메시를 찾을 수 없음",
"EyeTracking.label": "시선 추적",
"EyeTracking.setup": "시선 추적 설정",
@@ -500,6 +511,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": "언어",
@@ -537,6 +550,97 @@
"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_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 키 (일부 서버는 선택사항)"
}
}
+3 -1
View File
@@ -2,6 +2,7 @@ from bpy.types import UIList, Panel, UILayout, Object, Context, Material, Operat
import bpy
from math import sqrt
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
from .panel_layout import get_panel_order, should_open_by_default
from ..core.common import SceneMatClass, MaterialListBool, get_active_armature
from ..functions.atlas_materials import AvatarToolKit_OT_AtlasMaterials
from ..core.translations import t
@@ -214,7 +215,8 @@ class AvatarToolKit_PT_TextureAtlasPanel(Panel):
bl_region_type = 'UI'
bl_category = CATEGORY_NAME
bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
bl_order = 7
bl_order = get_panel_order('texture_atlas')
bl_options = set() if not should_open_by_default('TEXTURE_ATLAS') else {'DEFAULT_CLOSED'}
def draw(self, context: Context):
layout = self.layout
+3 -2
View File
@@ -2,6 +2,7 @@ import bpy
from typing import Set, List, Tuple, Any
from bpy.types import Panel, Context, UILayout, Operator, Event, WindowManager
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
from .panel_layout import get_panel_order, should_open_by_default
from ..functions.custom_tools.mesh_attachment import AvatarToolkit_OT_AttachMesh
from ..functions.custom_tools.armature_merging import AvatarToolkit_OT_MergeArmature
from ..core.translations import t
@@ -112,8 +113,8 @@ class AvatarToolKit_PT_CustomPanel(Panel):
bl_region_type: str = 'UI'
bl_category: str = CATEGORY_NAME
bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
bl_order: int = 4
bl_options: Set[str] = {'DEFAULT_CLOSED'}
bl_order: int = get_panel_order('custom_avatar')
bl_options: Set[str] = set() if not should_open_by_default('CUSTOM_AVATAR') else {'DEFAULT_CLOSED'}
def draw(self, context: Context) -> None:
"""Draw the custom avatar panel UI"""
+43 -72
View File
@@ -2,6 +2,8 @@ import bpy
from typing import Set
from bpy.types import Panel, Context, UILayout, Operator, Event, WindowManager
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
from .ui_utils import UIStyle, draw_section_header, wrap_text_label
from .panel_layout import get_panel_order, should_open_by_default
from ..core.translations import t
from ..core.common import get_active_armature, get_all_meshes
from ..functions.eye_tracking import (
@@ -26,38 +28,37 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel):
bl_region_type: str = 'UI'
bl_category: str = CATEGORY_NAME
bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
bl_order: int = 6
bl_options: Set[str] = {'DEFAULT_CLOSED'}
bl_order: int = get_panel_order('eye_tracking')
bl_options: Set[str] = set() if not should_open_by_default('EYE_TRACKING') else {'DEFAULT_CLOSED'}
def draw(self, context: Context) -> None:
"""Draw the eye tracking panel interface"""
layout: UILayout = self.layout
toolkit = context.scene.avatar_toolkit
# SDK Version Selection Box
sdk_box: UILayout = layout.box()
col: UILayout = sdk_box.column(align=True)
col.label(text=t("EyeTracking.sdk_version"), icon='PRESET')
col.separator(factor=0.5)
# SDK Version Selection
col = draw_section_header(layout, t("EyeTracking.sdk_version"), icon='PRESET')
row: UILayout = col.row(align=True)
row.prop(toolkit, "eye_tracking_type", expand=True)
if toolkit.eye_tracking_type == 'SDK2':
# SDK2 Warning Box
# SDK2 Warning
warning_box: UILayout = layout.box()
col: UILayout = warning_box.column(align=True)
col.label(text=t("EyeTracking.sdk2_warning"), icon='INFO')
col.separator(factor=0.5)
col.label(text=t("EyeTracking.sdk2_warning_detail1"))
col.label(text=t("EyeTracking.sdk2_warning_detail2"))
col.label(text=t("EyeTracking.sdk2_warning_detail3"))
col.label(text=t("EyeTracking.sdk2_warning_detail4"))
col.alert = True
col.label(text=t("EyeTracking.sdk2_warning"), icon='ERROR')
col.separator(factor=UIStyle.SUBSECTION_SEPARATOR_FACTOR)
# Mode Selection Box
mode_box: UILayout = layout.box()
col: UILayout = mode_box.column(align=True)
col.label(text=t("EyeTracking.setup"), icon='TOOL_SETTINGS')
col.separator(factor=0.5)
warning_text = "\n".join([
t("EyeTracking.sdk2_warning_detail1"),
t("EyeTracking.sdk2_warning_detail2"),
t("EyeTracking.sdk2_warning_detail3"),
t("EyeTracking.sdk2_warning_detail4")
])
wrap_text_label(col, warning_text, max_length=45)
# Mode Selection
col = draw_section_header(layout, t("EyeTracking.setup"), icon='TOOL_SETTINGS')
col.prop(toolkit, "eye_mode", expand=True)
if toolkit.eye_mode == 'CREATION':
@@ -72,11 +73,9 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel):
"""Draw the AV3 eye tracking setup interface"""
toolkit = context.scene.avatar_toolkit
# Bone Setup Box
bone_box: UILayout = layout.box()
col: UILayout = bone_box.column(align=True)
col.label(text=t("EyeTracking.bone_setup"), icon='BONE_DATA')
col.separator(factor=0.5)
# Bone Setup
col = draw_section_header(layout, t("EyeTracking.bone_setup"), icon='BONE_DATA')
armature = get_active_armature(context)
if armature:
@@ -86,21 +85,16 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel):
else:
col.label(text=t("EyeTracking.no_armature"), icon='ERROR')
# Create Button
row: UILayout = layout.row(align=True)
row.scale_y = 1.5
row.scale_y = UIStyle.PRIMARY_BUTTON_SCALE
row.operator(CreateEyesAV3Button.bl_idname, icon='PLAY')
def draw_creation_mode(self, context: Context, layout: UILayout) -> None:
"""Draw the eye tracking creation mode interface"""
toolkit = context.scene.avatar_toolkit
# Bone Setup Box
bone_box: UILayout = layout.box()
col: UILayout = bone_box.column(align=True)
col.label(text=t("EyeTracking.bone_setup"), icon='BONE_DATA')
col.separator(factor=0.5)
# Bone Setup
col = draw_section_header(layout, t("EyeTracking.bone_setup"), icon='BONE_DATA')
armature = get_active_armature(context)
if armature:
col.prop_search(toolkit, "head", armature.data, "bones", text=t("EyeTracking.head_bone"))
@@ -109,19 +103,12 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel):
else:
col.label(text=t("EyeTracking.no_armature"), icon='ERROR')
# Mesh Setup Box
mesh_box: UILayout = layout.box()
col: UILayout = mesh_box.column(align=True)
col.label(text=t("EyeTracking.mesh_setup"), icon='MESH_DATA')
col.separator(factor=0.5)
# Mesh Setup
col = draw_section_header(layout, t("EyeTracking.mesh_setup"), icon='MESH_DATA')
col.prop_search(toolkit, "mesh_name_eye", bpy.data, "objects", text="")
# Shape Key Setup Box
shape_box: UILayout = layout.box()
col: UILayout = shape_box.column(align=True)
col.label(text=t("EyeTracking.shapekey_setup"), icon='SHAPEKEY_DATA')
col.separator(factor=0.5)
# Shape Key Setup
col = draw_section_header(layout, t("EyeTracking.shapekey_setup"), icon='SHAPEKEY_DATA')
mesh = bpy.data.objects.get(toolkit.mesh_name_eye)
if mesh and mesh.data.shape_keys:
col.prop_search(toolkit, "wink_left", mesh.data.shape_keys, "key_blocks", text=t("EyeTracking.wink_left"))
@@ -131,19 +118,15 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel):
else:
col.label(text=t("EyeTracking.no_shapekeys"), icon='ERROR')
# Options Box
options_box: UILayout = layout.box()
col: UILayout = options_box.column(align=True)
col.label(text=t("EyeTracking.options"), icon='SETTINGS')
col.separator(factor=0.5)
# Options
col = draw_section_header(layout, t("EyeTracking.options"), icon='SETTINGS')
col.prop(toolkit, "disable_eye_blinking")
col.prop(toolkit, "disable_eye_movement")
if not toolkit.disable_eye_movement:
col.prop(toolkit, "eye_distance")
# Create Button
row: UILayout = layout.row(align=True)
row.scale_y = 1.5
row.scale_y = UIStyle.PRIMARY_BUTTON_SCALE
row.operator(CreateEyesSDK2Button.bl_idname, icon='PLAY')
def draw_testing_mode(self, context: Context, layout: UILayout) -> None:
@@ -151,37 +134,25 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel):
toolkit = context.scene.avatar_toolkit
if context.mode != 'POSE':
# Testing Start Box
test_box: UILayout = layout.box()
col: UILayout = test_box.column(align=True)
col.label(text=t("EyeTracking.testing"), icon='PLAY')
col.separator(factor=0.5)
# Testing Start
col = draw_section_header(layout, t("EyeTracking.testing"), icon='PLAY')
row: UILayout = col.row(align=True)
row.scale_y = 1.5
row.scale_y = UIStyle.PRIMARY_BUTTON_SCALE
row.operator(StartTestingButton.bl_idname, icon='PLAY')
else:
# Eye Rotation Box
rotation_box: UILayout = layout.box()
col: UILayout = rotation_box.column(align=True)
col.label(text=t("EyeTracking.rotation_controls"), icon='DRIVER_ROTATIONAL_DIFFERENCE')
col.separator(factor=0.5)
# Eye Rotation
col = draw_section_header(layout, t("EyeTracking.rotation_controls"), icon='DRIVER_ROTATIONAL_DIFFERENCE')
col.prop(toolkit, "eye_rotation_x", text=t("EyeTracking.rotation.x"))
col.prop(toolkit, "eye_rotation_y", text=t("EyeTracking.rotation.y"))
col.operator(ResetRotationButton.bl_idname, icon='LOOP_BACK')
# Eye Adjustment Box
adjust_box: UILayout = layout.box()
col: UILayout = adjust_box.column(align=True)
col.label(text=t("EyeTracking.adjustments"), icon='MODIFIER')
col.separator(factor=0.5)
# Eye Adjustment
col = draw_section_header(layout, t("EyeTracking.adjustments"), icon='MODIFIER')
col.prop(toolkit, "eye_distance")
col.operator(AdjustEyesButton.bl_idname, icon='CON_TRACKTO')
# Blinking Test Box
blink_box: UILayout = layout.box()
col: UILayout = blink_box.column(align=True)
col.label(text=t("EyeTracking.blink_testing"), icon='HIDE_OFF')
col.separator(factor=0.5)
# Blinking Test
col = draw_section_header(layout, t("EyeTracking.blink_testing"), icon='HIDE_OFF')
row: UILayout = col.row(align=True)
row.prop(toolkit, "eye_blink_shape")
row.operator(TestBlinking.bl_idname, icon='RESTRICT_VIEW_OFF')
@@ -192,7 +163,7 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel):
# Stop Testing Button
row: UILayout = layout.row(align=True)
row.scale_y = 1.5
row.scale_y = UIStyle.PRIMARY_BUTTON_SCALE
row.operator(StopTestingButton.bl_idname, icon='PAUSE')
# Reset Button
+9 -7
View File
@@ -1,6 +1,7 @@
import bpy
from typing import Optional, Set
from bpy.types import Panel, Context, UILayout
from .ui_utils import UIStyle, wrap_text_label
from ..core.translations import t
CATEGORY_NAME: str = "Avatar Toolkit"
@@ -16,13 +17,14 @@ def draw_title(self: Panel) -> None:
row.scale_y: float = 1.2
row.label(text=t("AvatarToolkit.label"), icon='ARMATURE_DATA')
# Description as a flowing paragraph
desc_col: UILayout = col.column()
desc_col.scale_y: float = 0.6
desc_col.label(text=t("AvatarToolkit.desc1"))
desc_col.label(text=t("AvatarToolkit.desc2"))
desc_col.label(text=t("AvatarToolkit.desc3"))
col.separator()
# Description
col.separator(factor=UIStyle.SECTION_SEPARATOR_FACTOR)
description = " ".join([
t("AvatarToolkit.desc1"),
t("AvatarToolkit.desc2"),
t("AvatarToolkit.desc3")
])
wrap_text_label(col, description, max_length=50)
class AvatarToolKit_PT_AvatarToolkitPanel(Panel):
"""Main panel for Avatar Toolkit containing general information and settings"""
+15 -28
View File
@@ -2,6 +2,8 @@ import bpy
from typing import Set
from bpy.types import Panel, Context, UILayout, Operator
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
from .ui_utils import UIStyle, draw_section_header, draw_operator_row
from .panel_layout import get_panel_order, should_open_by_default
from ..core.translations import t
from ..functions.optimization.materials_tools import AvatarToolkit_OT_CombineMaterials
from ..functions.optimization.remove_doubles import AvatarToolkit_OT_RemoveDoubles
@@ -15,39 +17,24 @@ class AvatarToolKit_PT_OptimizationPanel(Panel):
bl_region_type: str = 'UI'
bl_category: str = CATEGORY_NAME
bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
bl_order: int = 1
bl_options = {'DEFAULT_CLOSED'}
bl_order: int = get_panel_order('optimization')
bl_options = set() if not should_open_by_default('OPTIMIZATION') else {'DEFAULT_CLOSED'}
def draw(self, context: Context) -> None:
"""Draws the optimization panel interface with material, mesh cleanup and join mesh tools"""
layout: UILayout = self.layout
# Materials Box
materials_box: UILayout = layout.box()
col: UILayout = materials_box.column(align=True)
col.label(text=t("Optimization.materials_title"), icon='MATERIAL')
col.separator(factor=0.5)
# Material Operations
# Materials section
col = draw_section_header(layout, t("Optimization.materials_title"), icon='MATERIAL')
col.operator(AvatarToolkit_OT_CombineMaterials.bl_idname, icon='MATERIAL')
# Mesh Cleanup Box
cleanup_box: UILayout = layout.box()
col: UILayout = cleanup_box.column(align=True)
col.label(text=t("Optimization.cleanup_title"), icon='MESH_DATA')
col.separator(factor=0.5)
# Mesh Cleanup section
col = draw_section_header(layout, t("Optimization.cleanup_title"), icon='MESH_DATA')
col.operator(AvatarToolkit_OT_RemoveDoubles.bl_idname, icon='MESH_DATA')
# Remove Doubles Row
row: UILayout = col.row(align=True)
row.operator(AvatarToolkit_OT_RemoveDoubles.bl_idname, icon='MESH_DATA')
# Join Meshes Box
join_box: UILayout = layout.box()
col: UILayout = join_box.column(align=True)
col.label(text=t("Optimization.join_meshes_title"), icon='OBJECT_DATA')
col.separator(factor=0.5)
# Join Meshes Row
row: UILayout = col.row(align=True)
row.operator(AvatarToolkit_OT_JoinAllMeshes.bl_idname, icon='OBJECT_DATA')
row.operator(AvatarToolkit_OT_JoinSelectedMeshes.bl_idname, icon='RESTRICT_SELECT_OFF')
# Join Meshes section
col = draw_section_header(layout, t("Optimization.join_meshes_title"), icon='OBJECT_DATA')
draw_operator_row(col, [
(AvatarToolkit_OT_JoinAllMeshes.bl_idname, t("Optimization.join_all_meshes"), 'OBJECT_DATA'),
(AvatarToolkit_OT_JoinSelectedMeshes.bl_idname, t("Optimization.join_selected_meshes"), 'RESTRICT_SELECT_OFF')
])
+53
View File
@@ -0,0 +1,53 @@
"""Panel ordering and organization guide for Avatar Toolkit UI
This module defines the standard panel order and grouping for the Avatar Toolkit.
"""
# Main Panel
MAIN_PANEL_ORDER = -1 # Always first (parent panel)
QUICK_ACCESS_ORDER = 0
OPTIMIZATION_ORDER = 1
TOOLS_ORDER = 2
CUSTOM_TOOLS_ORDER = 3
CUSTOM_AVATAR_ORDER = 4
TRANSLATION_ORDER = 5
VISEMES_ORDER = 6
EYE_TRACKING_ORDER = 7
TEXTURE_ATLAS_ORDER = 8
VRM_UNITY_ORDER = 9
SETTINGS_ORDER = 10
# Panel open/closed by default
PANELS_OPEN_BY_DEFAULT = {
'QUICK_ACCESS': False,
'OPTIMIZATION': True,
'TOOLS': True,
'CUSTOM_TOOLS': True,
'CUSTOM_AVATAR': True,
'VISEMES': True,
'EYE_TRACKING': True,
'TEXTURE_ATLAS': True,
'VRM_UNITY': True,
'SETTINGS': True,
'TRANSLATION': True,
}
def get_panel_order(panel_name: str) -> int:
"""Get the recommended bl_order value for a panel"""
order_map = {
'quick_access': QUICK_ACCESS_ORDER,
'optimization': OPTIMIZATION_ORDER,
'tools': TOOLS_ORDER,
'custom_tools': CUSTOM_TOOLS_ORDER,
'custom_avatar': CUSTOM_AVATAR_ORDER,
'translation': TRANSLATION_ORDER,
'visemes': VISEMES_ORDER,
'eye_tracking': EYE_TRACKING_ORDER,
'texture_atlas': TEXTURE_ATLAS_ORDER,
'vrm_unity': VRM_UNITY_ORDER,
'settings': SETTINGS_ORDER,
}
return order_map.get(panel_name.lower(), 99)
def should_open_by_default(panel_name: str) -> bool:
"""Check if a panel should be open by default"""
return PANELS_OPEN_BY_DEFAULT.get(panel_name.upper(), True)
+165 -183
View File
@@ -10,6 +10,8 @@ from bpy.types import (
Object
)
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
from .ui_utils import UIStyle, draw_section_header, draw_operator_row
from .panel_layout import get_panel_order, should_open_by_default
from ..core.translations import t
from ..core.common import (
get_active_armature,
@@ -17,13 +19,24 @@ 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, AvatarToolkit_OT_ValidateTPose
from ..core.armature_validation import validate_armature, AvatarToolkit_OT_ValidateTPose, is_pmx_model
from ..core.importers.importer import AvatarToolKit_OT_Import
from ..core.resonite_utils import AvatarToolKit_OT_ExportResonite
from ..functions.tools.standardize_armature import AvatarToolkit_OT_StandardizeArmature
@@ -68,233 +81,202 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel):
bl_region_type: str = 'UI'
bl_category: str = CATEGORY_NAME
bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
bl_order: int = 0
bl_order: int = get_panel_order('quick_access')
bl_options = {'DEFAULT_CLOSED'} if should_open_by_default('QUICK_ACCESS') else set()
def draw(self, context: Context) -> None:
"""Draw the panel layout"""
layout: UILayout = self.layout
props = context.scene.avatar_toolkit
# Armature Selection Box
armature_box: UILayout = layout.box()
col: UILayout = armature_box.column(align=True)
col.label(text=t("QuickAccess.select_armature"), icon='ARMATURE_DATA')
col.separator(factor=0.5)
# Armature Selection
col = draw_section_header(layout, t("QuickAccess.select_armature"), icon='ARMATURE_DATA')
col.prop(context.scene.avatar_toolkit, "active_armature", text="")
# Armature Validation
# Get active armature
active_armature: Optional[Object] = get_active_armature(context)
if active_armature:
is_valid, messages, is_acceptable, hierarchy_messages, scale_messages, non_standard_messages = validate_armature(active_armature, detailed_messages=True)
# Validation Section
col = draw_section_header(layout, t("Validation.label", "Armature Validation"), icon='CHECKMARK')
# 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
# Main validate button with prominent styling
validate_row = col.row(align=True)
validate_row.scale_y = UIStyle.PRIMARY_BUTTON_SCALE
validate_row.operator("avatar_toolkit.validate_armature_manual",
text=t("Validation.validate_now", "Validate Armature Now"),
icon='CHECKMARK')
info_box = col.box()
# Validation mode selector
col.prop(props, "validation_mode", text=t("Settings.validation_mode", "Mode"))
# 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')
# Show validation results if flag is set
if props.show_validation_results:
# Cache validation results
cache_key = f"validation_{active_armature.name}_{active_armature.data.name}_{len(active_armature.data.bones)}"
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 cache_key not in _validation_cache:
_validation_cache[cache_key] = validate_armature(active_armature, detailed_messages=True)
if not is_valid:
# Display non-standard bones and hierarchy issues
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 and len(messages) > 0:
for line in messages[0].split('\n'):
validation_box.label(text=line)
is_valid, messages, is_acceptable, hierarchy_messages, scale_messages, non_standard_messages = _validation_cache[cache_key]
# Main validation status
validation_box = info_box.box()
row = validation_box.row()
# Check if this is a PMX model
pmx_detected = is_pmx_model(active_armature)
results_box = col.box()
row = results_box.row()
row.prop(props, "show_validation_results", text=t("Validation.results", "Validation Results"),
icon='TRIA_DOWN' if props.show_validation_results else 'TRIA_RIGHT', emboss=False)
# PMX Model Notice
if pmx_detected:
pmx_box = results_box.box()
pmx_box.label(text=t("Armature.validation.pmx_model_detected"), icon='INFO')
validation_mode = context.scene.avatar_toolkit.validation_mode
if validation_mode == 'STRICT':
pmx_box.label(text=t("Armature.validation.pmx_model_strict"))
pmx_box.label(text=t("Armature.validation.pmx_model_standardize"))
else:
pmx_box.label(text=t("Armature.validation.pmx_model_basic"))
# Validation Results
if not is_valid:
# Display found bones
if messages and len(messages) > 0:
bones_section = results_box.box()
row = bones_section.row()
row.prop(props, "show_found_bones", text=t("Validation.section.found_bones"),
icon='TRIA_DOWN' if props.show_found_bones else 'TRIA_RIGHT', emboss=False)
if props.show_found_bones:
for line in messages[0].split('\n'):
bones_section.label(text=line)
# Status message
status_box = results_box.box()
row = status_box.row()
row.alert = True
row.label(text=t("Validation.status.failed"))
row.label(text=t("Validation.status.failed"), icon='ERROR')
# Detailed validation message
validation_box = info_box.box()
row = validation_box.row()
row.alert = True
row.label(text=t("Validation.message.failed.line1"))
row = validation_box.row()
row.alert = True
row.label(text=t("Validation.message.failed.line2"))
row = validation_box.row()
row.alert = True
row.label(text=t("Validation.message.failed.line3"))
# Error explanation
error_box = results_box.box()
error_box.alert = True
error_box.label(text=t("Validation.message.failed.line1"))
error_box.label(text=t("Validation.message.failed.line2"))
error_box.label(text=t("Validation.message.failed.line3"))
# Non-Standard Bones section
validation_box = info_box.box()
row = validation_box.row()
row.alert = True
row.prop(props, "show_non_standard", text=t("Validation.section.non_standard"),
icon='TRIA_DOWN' if props.show_non_standard else 'TRIA_RIGHT', emboss=False)
if props.show_non_standard:
if non_standard_messages and len(non_standard_messages) > 0:
for message in non_standard_messages:
for line in message.split('\n'):
sub_row = validation_box.row()
sub_row.alert = True
sub_row.label(text=line)
else:
# 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"))
if non_standard_messages or pmx_detected:
ns_section = results_box.box()
row = ns_section.row()
row.alert = True
row.prop(props, "show_non_standard", text=t("Validation.section.non_standard"),
icon='TRIA_DOWN' if props.show_non_standard else 'TRIA_RIGHT', emboss=False)
if props.show_non_standard:
if non_standard_messages and len(non_standard_messages) > 0:
for message in non_standard_messages:
for line in message.split('\n'):
sub_row = ns_section.row()
sub_row.alert = True
sub_row.label(text=line)
elif pmx_detected:
ns_section.alert = True
ns_section.label(text=t("Armature.validation.pmx_model_basic"))
ns_section.label(text=t("Armature.validation.pmx_model_strict"))
ns_section.label(text=t("Armature.validation.pmx_model_standardize"))
else:
sub_row = validation_box.row()
sub_row.label(text=t("Validation.no_non_standard_issues"))
ns_section.label(text=t("Validation.no_non_standard_issues"))
# Hierarchy Issues section
validation_box = info_box.box()
row = validation_box.row()
row.alert = True
row.prop(props, "show_hierarchy", text=t("Validation.section.hierarchy"),
icon='TRIA_DOWN' if props.show_hierarchy else 'TRIA_RIGHT', emboss=False)
if props.show_hierarchy:
if hierarchy_messages:
if hierarchy_messages:
hier_section = results_box.box()
row = hier_section.row()
row.alert = True
row.prop(props, "show_hierarchy", text=t("Validation.section.hierarchy"),
icon='TRIA_DOWN' if props.show_hierarchy else 'TRIA_RIGHT', emboss=False)
if props.show_hierarchy:
for message in hierarchy_messages:
sub_row = validation_box.row()
sub_row = hier_section.row()
sub_row.alert = True
sub_row.label(text=message)
else:
sub_row = validation_box.row()
sub_row.label(text=t("Validation.no_hierarchy_issues"))
# Scale Issues section
validation_box = info_box.box()
row = validation_box.row()
row.alert = True
row.prop(props, "show_scale_issues", text=t("Validation.section.scale_issues"),
icon='TRIA_DOWN' if props.show_scale_issues else 'TRIA_RIGHT', emboss=False)
if props.show_scale_issues:
if scale_messages:
if scale_messages:
scale_section = results_box.box()
row = scale_section.row()
row.alert = True
row.prop(props, "show_scale_issues", text=t("Validation.section.scale_issues"),
icon='TRIA_DOWN' if props.show_scale_issues else 'TRIA_RIGHT', emboss=False)
if props.show_scale_issues:
for scale_msg in scale_messages:
sub_row = validation_box.row()
sub_row = scale_section.row()
sub_row.alert = True
sub_row.label(text=scale_msg)
else:
sub_row = validation_box.row()
sub_row.label(text=t("Validation.no_scale_issues"))
pose_box = layout.box()
col = pose_box.column(align=True)
col.label(text=t("Validation.tpose.label"), icon='ARMATURE_DATA')
col.separator(factor=0.5)
col.operator(AvatarToolkit_OT_ValidateTPose.bl_idname, icon='CHECKMARK')
elif is_valid and not is_acceptable:
# Valid armature - show stats
stats_cache_key = f"stats_{active_armature.name}_{active_armature.data.name}_{len(active_armature.data.bones)}"
if props.show_tpose_validation:
validation_box = col.box()
if props.tpose_validation_result:
validation_box.label(text=t("Validation.tpose.valid"), icon='CHECKMARK')
else:
row = validation_box.row()
row.alert = True
row.label(text=t("Validation.tpose.warning"), icon='ERROR')
if stats_cache_key not in _stats_cache:
_stats_cache[stats_cache_key] = get_armature_stats(active_armature)
for msg in props.tpose_validation_messages:
row = validation_box.row()
row.alert = True
row.label(text=msg.name)
stats = _stats_cache[stats_cache_key]
status_box = results_box.box()
row = status_box.row()
row.label(text=t("QuickAccess.valid_armature"), icon='CHECKMARK')
split = row.split(factor=0.4)
split.label(text=t("QuickAccess.bones_count", count=stats['bone_count']))
if stats['has_pose']:
results_box.label(text=t("QuickAccess.pose_bones_available"), icon='POSE_HLT')
elif is_valid and is_acceptable:
# Acceptable standard
status_box = results_box.box()
status_box.label(text=t("Armature.validation.acceptable_standard.success"), icon='INFO')
status_box.label(text=t("Armature.validation.acceptable_standard.note"))
status_box.label(text=t("Armature.validation.acceptable_standard.option"))
# Add standardize button
standardize_box = results_box.box()
standardize_box.operator(AvatarToolkit_OT_StandardizeArmature.bl_idname,
text=t("QuickAccess.standardize_armature"),
icon='MODIFIER')
# T-Pose Validation
col = draw_section_header(layout, t("Validation.tpose.label"), icon='ARMATURE_DATA')
col.operator(AvatarToolkit_OT_ValidateTPose.bl_idname, text=t("Validation.tpose.validate_now"), icon='CHECKMARK')
if props.show_tpose_validation:
validation_result_col = col.column(align=True)
if props.tpose_validation_result:
validation_result_col.label(text=t("Validation.tpose.valid"), icon='CHECKMARK')
else:
# If no specific issues, show acceptable message
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)
split.label(text=t("QuickAccess.bones_count", count=stats['bone_count']))
validation_result_col.alert = True
validation_result_col.label(text=t("Validation.tpose.warning"), icon='ERROR')
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
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(AvatarToolkit_OT_StandardizeArmature.bl_idname,
text=t("QuickAccess.standardize_armature"),
icon='MODIFIER')
# Validation Mode Warnings
validation_mode = context.scene.avatar_toolkit.validation_mode
if validation_mode == 'BASIC':
warning_row = info_box.box()
warning_row.alert = True
warning_row.label(text=t("QuickAccess.validation_basic_warning"), icon='INFO')
warning_row.label(text=t("QuickAccess.validation_basic_details"))
elif validation_mode == 'NONE':
warning_row = info_box.box()
warning_row.alert = True
warning_row.label(text=t("QuickAccess.validation_none_warning"), icon='ERROR')
warning_row.label(text=t("QuickAccess.validation_none_details"))
for msg in props.tpose_validation_messages:
validation_result_col.label(text=msg.name)
# Pose Mode Controls
pose_box: UILayout = layout.box()
col = pose_box.column(align=True)
col.label(text=t("QuickAccess.pose_controls"), icon='ARMATURE_DATA')
col.separator(factor=0.5)
col = draw_section_header(layout, t("QuickAccess.pose_controls"), icon='ARMATURE_DATA')
if context.mode == "POSE":
col.operator(AvatarToolkit_OT_StopPoseMode.bl_idname, icon='POSE_HLT')
col.separator(factor=0.5)
col.operator(AvatarToolkit_OT_ApplyPoseAsRest.bl_idname, icon='MOD_ARMATURE')
col.operator(AvatarToolkit_OT_ApplyPoseAsShapekey.bl_idname, icon='MOD_ARMATURE')
col.separator(factor=UIStyle.SUBSECTION_SEPARATOR_FACTOR)
draw_operator_row(col, [
(AvatarToolkit_OT_ApplyPoseAsRest.bl_idname, t("QuickAccess.apply_pose_as_rest.label"), 'MOD_ARMATURE'),
(AvatarToolkit_OT_ApplyPoseAsShapekey.bl_idname, t("QuickAccess.apply_pose_as_shapekey.label"), 'MOD_ARMATURE')
])
else:
col.operator(AvatarToolkit_OT_StartPoseMode.bl_idname, icon='POSE_HLT')
# Import/Export Box
import_box: UILayout = layout.box()
col = import_box.column(align=True)
col.label(text=t("QuickAccess.import_export"), icon='IMPORT')
col.separator(factor=0.5)
# Import/Export Section
col = draw_section_header(layout, t("QuickAccess.import_export"), icon='IMPORT')
# Import/Export Buttons
button_row: UILayout = col.row(align=True)
button_row.scale_y = 1.5
button_row.operator(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')
draw_operator_row(col, [
(AvatarToolKit_OT_Import.bl_idname, t("QuickAccess.import"), 'IMPORT'),
(AvatarToolKit_OT_ExportMenu.bl_idname, t("QuickAccess.export"), 'EXPORT')
], scale_y=UIStyle.PRIMARY_BUTTON_SCALE)
+121
View File
@@ -0,0 +1,121 @@
"""Base classes for reusable search operators"""
from typing import Set, Callable, Optional
from bpy.types import Operator, Context, Event, WindowManager
class SearchOperatorBase(Operator):
"""
Reusable base class for search/selection operators.
This is an abstract base class - do not use directly.
Subclass and implement your specific search operator instead.
Subclasses should:
1. Define bl_idname, bl_label, bl_description
2. Define search_property_name (name of EnumProperty)
3. Define target_property_name (name of property to set on scene)
4. Define get_items_func (function to get enum items)
5. Optionally override get_enum_property() to customize the enum
This was created because search in ATK was all over the place and inconsistent, this way we have a standard way to do it.
"""
# Mark this as abstract by setting a non-Blender-compatible idname
bl_idname = "wm.search_operator_base" # Will be overridden in subclasses
bl_label = "Search and Select"
bl_options = {'REGISTER', 'INTERNAL'}
# These should be overridden in subclasses
search_property_name: str = "search_enum"
target_property_name: str = "target_property"
@staticmethod
def get_items_func(scene, context) -> list:
"""Override this to provide enum items. Return list of (id, name, description) tuples"""
return []
def get_enum_property(self) -> None:
"""
Create the enum property dynamically. Override if you need custom behavior.
This is called during class creation.
"""
import bpy
setattr(
type(self),
self.search_property_name,
bpy.props.EnumProperty(
name="Search",
description="Select item",
items=self.get_items_func
)
)
def execute(self, context: Context) -> Set[str]:
"""Set the target property from the search selection"""
search_value = getattr(self, self.search_property_name, None)
if search_value:
setattr(context.scene.avatar_toolkit, self.target_property_name, search_value)
return {'FINISHED'}
def invoke(self, context: Context, event: Event) -> Set[str]:
"""Open search popup"""
wm: WindowManager = context.window_manager
wm.invoke_search_popup(self)
return {'FINISHED'}
class ArmatureSearchOperator(SearchOperatorBase):
"""Specialized search operator for selecting armatures"""
bl_label = "Search Armatures"
search_property_name: str = "search_armature_enum"
@staticmethod
def get_items_func(scene, context) -> list:
"""Get list of all armature objects in scene"""
import bpy
return [
(obj.name, obj.name, "")
for obj in bpy.data.objects
if obj.type == 'ARMATURE'
]
class MeshSearchOperator(SearchOperatorBase):
"""Specialized search operator for selecting meshes"""
bl_label = "Search Meshes"
search_property_name: str = "search_mesh_enum"
@staticmethod
def get_items_func(scene, context) -> list:
"""Get list of all mesh objects without armature modifiers"""
import bpy
return [
(obj.name, obj.name, "")
for obj in bpy.data.objects
if obj.type == 'MESH'
and not any(mod.type == 'ARMATURE' for mod in obj.modifiers)
]
class BoneSearchOperator(SearchOperatorBase):
"""Specialized search operator for selecting bones from active armature"""
bl_label = "Search Bones"
search_property_name: str = "search_bone_enum"
@staticmethod
def get_items_func(scene, context) -> list:
"""Get list of all bones from active armature"""
from ..core.common import get_active_armature
armature = get_active_armature(context)
if not armature:
return []
return [
(bone.name, bone.name, "")
for bone in armature.data.bones
]
+15 -23
View File
@@ -9,6 +9,8 @@ from bpy.types import (
Event
)
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
from .ui_utils import UIStyle, draw_section_header, wrap_text_label
from .panel_layout import get_panel_order, should_open_by_default
from ..core.translations import t, get_languages_list
from ..core.armature_validation import AvatarToolkit_OT_HighlightProblemBones, AvatarToolkit_OT_ClearBoneHighlighting
@@ -26,8 +28,10 @@ class AvatarToolkit_OT_TranslationRestartPopup(Operator):
def draw(self, context: Context) -> None:
layout: UILayout = self.layout
layout.label(text=t("Language.changed.success"))
layout.label(text=t("Language.changed.restart"))
col = layout.column(align=True)
col.label(text=t("Language.changed.success"))
col.separator(factor=UIStyle.SUBSECTION_SEPARATOR_FACTOR)
wrap_text_label(col, t("Language.changed.restart"), max_length=50)
class AvatarToolKit_PT_SettingsPanel(Panel):
"""Settings panel for Avatar Toolkit containing language preferences"""
@@ -37,8 +41,8 @@ class AvatarToolKit_PT_SettingsPanel(Panel):
bl_region_type: str = 'UI'
bl_category: str = CATEGORY_NAME
bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
bl_order: int = 8
bl_options = {'DEFAULT_CLOSED'}
bl_order: int = get_panel_order('settings')
bl_options = set() if not should_open_by_default('SETTINGS') else {'DEFAULT_CLOSED'}
def draw(self, context: Context) -> None:
"""Draw the settings panel layout with language selection"""
@@ -46,30 +50,18 @@ class AvatarToolKit_PT_SettingsPanel(Panel):
props = context.scene.avatar_toolkit
# Language Settings
lang_box: UILayout = layout.box()
col: UILayout = lang_box.column(align=True)
row: UILayout = col.row()
row.scale_y = 1.2
row.label(text=t("Settings.language"), icon='WORLD')
col.separator()
col = draw_section_header(layout, t("Settings.language"), icon='WORLD')
col.prop(props, "language", text="")
# Validation Settings
val_box: UILayout = layout.box()
col = val_box.column(align=True)
row = col.row()
row.scale_y = 1.2
row.label(text=t("Settings.validation_mode"), icon='CHECKMARK')
col.separator()
# Validation Settings with help text
col = draw_section_header(layout, t("Settings.validation_mode"), icon='CHECKMARK')
col.prop(props, "validation_mode", text="")
# Help text for validation mode
col.separator(factor=UIStyle.SUBSECTION_SEPARATOR_FACTOR)
wrap_text_label(col, "Select how strictly to validate armature bone structure and naming conventions.", max_length=40)
# Bone Highlighting Settings
bone_box: UILayout = layout.box()
col = bone_box.column(align=True)
row = col.row()
row.scale_y = 1.2
row.label(text=t("Settings.bone_highlighting"), icon='BONE_DATA')
col.separator()
col = draw_section_header(layout, t("Settings.bone_highlighting"), icon='BONE_DATA')
col.prop(props, "highlight_problem_bones")
if props.highlight_problem_bones:
col.operator(AvatarToolkit_OT_HighlightProblemBones.bl_idname, icon='COLOR')
+35 -57
View File
@@ -2,6 +2,8 @@ import bpy
from typing import Set
from bpy.types import Panel, Context, UILayout, Operator, UIList
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
from .ui_utils import UIStyle, draw_section_header, draw_operator_row
from .panel_layout import get_panel_order, should_open_by_default
from ..core.translations import t
from ..core.resonite_utils import AvatarToolkit_OT_ConvertResonite
@@ -29,8 +31,8 @@ class AvatarToolKit_PT_ToolsPanel(Panel):
bl_region_type: str = 'UI'
bl_category: str = CATEGORY_NAME
bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
bl_order: int = 2
bl_options = {'DEFAULT_CLOSED'}
bl_order: int = get_panel_order('tools')
bl_options = set() if not should_open_by_default('TOOLS') else {'DEFAULT_CLOSED'}
def draw(self, context: Context) -> None:
"""Draw the tools panel interface"""
@@ -38,94 +40,70 @@ class AvatarToolKit_PT_ToolsPanel(Panel):
toolkit = context.scene.avatar_toolkit
# General Tools
tools_box: UILayout = layout.box()
col: UILayout = tools_box.column(align=True)
col.label(text=t("Tools.general_title"), icon='TOOL_SETTINGS')
col.separator(factor=0.5)
col = draw_section_header(layout, t("Tools.general_title"), icon='TOOL_SETTINGS')
col.operator(AvatarToolkit_OT_ConvertResonite.bl_idname, text=t("Tools.convert_resonite"), icon='EXPORT')
# Separation Tools
sep_box: UILayout = layout.box()
col = sep_box.column(align=True)
col.label(text=t("Tools.separate_title"), icon='MOD_EXPLODE')
col.separator(factor=0.5)
row: UILayout = col.row(align=True)
row.operator(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')
col = draw_section_header(layout, t("Tools.separate_title"), icon='MOD_EXPLODE')
draw_operator_row(col, [
(AvatarToolKit_OT_SeparateByMaterials.bl_idname, t("Tools.separate_materials"), 'MATERIAL'),
(AvatarToolKit_OT_SeparateByLooseParts.bl_idname, t("Tools.separate_loose"), 'MESH_DATA')
])
# Bone Tools
bone_box: UILayout = layout.box()
col = bone_box.column(align=True)
col.label(text=t("Tools.bone_title"), icon='BONE_DATA')
col.separator(factor=0.5)
col = draw_section_header(layout, t("Tools.bone_title"), icon='BONE_DATA')
col.operator(AvatarToolKit_OT_CreateDigitigradeLegs.bl_idname, text=t("Tools.create_digitigrade"), icon='BONE_DATA')
col.operator(AvatarToolKit_OT_FlipCurrentKeyFrames.bl_idname,text=t("Tools.flip_pose_frames"),icon="ACTION")
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")
col = draw_section_header(layout, t("Tools.mesh_title"), icon='MESH_DATA')
col.operator(AvatarToolkit_OT_SelectShortestSeamPath.bl_idname, text=t("Tools.find_shortest_seam_path"), icon="MESH_DATA")
col.operator(AvatarToolkit_OT_ApplyModifierForShapkeyObj.bl_idname, text=t("Tools.apply_modifier_on_shapekey_obj"), icon="SHAPEKEY_DATA")
col.operator(AvatarToolkit_OT_ExplodeMesh.bl_idname, text=t("Tools.explode_mesh"), icon="MOD_EXPLODE")
# Standardization Tools
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 = draw_section_header(layout, t("Tools.standardize_title"), icon='OUTLINER_OB_ARMATURE')
col.operator(AvatarToolkit_OT_StandardizeArmature.bl_idname, icon='CHECKMARK')
# Weight Tools
weight_box: UILayout = bone_box.box()
col = weight_box.column(align=True)
col = draw_section_header(layout, t("Tools.weight_title"), icon='GROUP_BONE')
col.prop(toolkit, "merge_twist_bones", text=t("Tools.merge_twist_bones"))
col.prop(toolkit, "preserve_parent_bones")
col.prop(toolkit, "target_bone_type")
col.prop(toolkit, "list_only_mode")
if toolkit.list_only_mode and len(toolkit.zero_weight_bones) > 0:
box = weight_box.box()
row = box.row()
sub_col = col.box()
row = sub_col.row()
row.template_list("AVATAR_TOOLKIT_UL_ZeroWeightBones", "",
toolkit, "zero_weight_bones",
toolkit, "zero_weight_bones_index")
col = box.column(align=True)
col.operator(AvatarToolKit_OT_RemoveSelectedBones.bl_idname,
text=t("Tools.remove_selected_bones"))
sub_col.operator(AvatarToolKit_OT_RemoveSelectedBones.bl_idname,
text=t("Tools.remove_selected_bones"))
row = col.row(align=True)
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')
# Combine weight
draw_operator_row(col, [
(AvatarToolKit_OT_RemoveZeroWeightBones.bl_idname, t("Tools.clean_weights"), 'GROUP_BONE'),
(AvatarToolKit_OT_DeleteBoneConstraints.bl_idname, t("Tools.clean_constraints"), 'CONSTRAINT_BONE')
])
col.operator(AvatarToolKit_OT_RemoveZeroWeightVertexGroups.bl_idname, text=t("Tools.clean_vertex_groups"), icon='CONSTRAINT_BONE')
# Merge Tools
merge_box: UILayout = layout.box()
col = merge_box.column(align=True)
col.label(text=t("Tools.merge_title"), icon='AUTOMERGE_ON')
col.separator(factor=0.5)
row = col.row(align=True)
row.operator(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 = draw_section_header(layout, t("Tools.merge_title"), icon='AUTOMERGE_ON')
draw_operator_row(col, [
(AvatarToolkit_OT_MergeToActive.bl_idname, t("Tools.merge_to_active"), 'BONE_DATA'),
(AvatarToolkit_OT_MergeToParent.bl_idname, t("Tools.merge_to_parent"), 'BONE_DATA')
])
col.operator(AvatarToolkit_OT_ConnectBones.bl_idname, text=t("Tools.connect_bones"), icon='BONE_DATA')
# Additional Tools
extra_box: UILayout = layout.box()
col = extra_box.column(align=True)
col.label(text=t("Tools.additional_title"), icon='TOOL_SETTINGS')
col.separator(factor=0.5)
col = draw_section_header(layout, t("Tools.additional_title"), icon='TOOL_SETTINGS')
col.operator(AvatarToolkit_OT_ApplyTransforms.bl_idname, text=t("Tools.apply_transforms"), icon='OBJECT_DATA')
col.operator(AvatarToolkit_OT_CleanShapekeys.bl_idname, text=t("Tools.clean_shapekeys"), icon='SHAPEKEY_DATA')
# Rigify Tools
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 = draw_section_header(layout, t("Tools.rigify_title"), icon='ARMATURE_DATA')
col.operator(AvatarToolkit_OT_ConvertRigifyToUnity.bl_idname, icon='ARMATURE_DATA')
col.prop(context.scene.avatar_toolkit, "merge_twist_bones")
+732
View File
@@ -0,0 +1,732 @@
# 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 .panel_layout import get_panel_order, should_open_by_default
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 = get_panel_order('translation')
bl_options = set() if not should_open_by_default('TRANSLATION') else {'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
+137
View File
@@ -0,0 +1,137 @@
"""UI utilities and styling helpers for consistent Avatar Toolkit panel design"""
from typing import Callable, Optional
from bpy.types import UILayout, Context, Operator
class UIStyle:
"""Centralized UI styling constants for consistent appearance"""
SECTION_SEPARATOR_FACTOR: float = 0.5
SUBSECTION_SEPARATOR_FACTOR: float = 0.3
PRIMARY_BUTTON_SCALE: float = 1.5
STANDARD_BUTTON_SCALE: float = 1.0
COMPACT_BUTTON_SCALE: float = 0.9
DEFAULT_PADDING: float = 1.0
COMPACT_PADDING: float = 0.5
CATEGORY_ICONS = {
'optimization': 'MOD_SMOOTH',
'tools': 'TOOL_SETTINGS',
'custom': 'TOOL_OPTIONS',
'eye_tracking': 'OBJECT_CAMERA',
'settings': 'PREFERENCES',
'import_export': 'EXPORT',
'pose': 'POSE_HLT',
'materials': 'MATERIAL',
'mesh': 'MESH_DATA',
'bones': 'BONE_DATA',
'vfx': 'MOD_DISPLACE'
}
def draw_section_header(layout: UILayout, title: str, icon: str = 'NONE', separator: bool = True) -> UILayout:
"""Draw a consistent section header with optional icon and separator"""
header_box = layout.box()
col = header_box.column(align=True)
row = col.row()
row.scale_y = 1.2
row.label(text=title, icon=icon)
if separator:
col.separator(factor=UIStyle.SECTION_SEPARATOR_FACTOR)
return col
def draw_subsection(layout: UILayout, title: str, icon: str = 'NONE') -> UILayout:
"""Draw a subsection with reduced visual weight (no box)"""
col = layout.column(align=True)
row = col.row()
row.label(text=title, icon=icon)
col.separator(factor=UIStyle.SUBSECTION_SEPARATOR_FACTOR)
return col
def draw_info_text(layout: UILayout, text: str, icon: str = 'INFO') -> None:
"""Draw informational text that can wrap (replaces multiple labels)"""
col = layout.column()
col.alert = False
# Split long text for wrapping
row = col.row()
row.label(text=text, icon=icon)
def draw_warning_text(layout: UILayout, text: str) -> None:
"""Draw warning-styled text"""
col = layout.column()
col.alert = True
row = col.row()
row.label(text=text, icon='ERROR')
def draw_primary_button(layout: UILayout, operator_idname: str, text: str = "",
icon: str = 'NONE', **kwargs) -> None:
"""Draw a primary action button with standard scaling"""
row = layout.row(align=True)
row.scale_y = UIStyle.PRIMARY_BUTTON_SCALE
row.operator(operator_idname, text=text, icon=icon, **kwargs)
def draw_operator_row(layout: UILayout, operators: list[tuple[str, str, str]],
scale_y: float = 1.0, equal_width: bool = True) -> None:
"""Draw multiple operators in a single row with consistent sizing"""
if not operators:
return
row = layout.row(align=equal_width)
row.scale_y = scale_y
for op_id, text, icon in operators:
row.operator(op_id, text=text, icon=icon)
def draw_collapsible_section(layout: UILayout, title: str, icon: str,
draw_func: Callable[[UILayout], None],
context: Context, storage_attr: str) -> None:
"""Draw a collapsible section (using context scene properties for state)"""
col = layout.column(align=True)
row = col.row()
scene = context.scene
attr_name = f"_ui_expand_{storage_attr}"
is_expanded = getattr(scene, attr_name, False)
icon_name = 'DISCLOSURE_TRI_DOWN' if is_expanded else 'DISCLOSURE_TRI_RIGHT'
row.prop(scene, attr_name, text="", icon=icon_name, emboss=False)
row.label(text=title, icon=icon)
if is_expanded:
col.separator(factor=UIStyle.SUBSECTION_SEPARATOR_FACTOR)
draw_func(col)
def apply_operator_disable_feedback(operator: Operator, layout: UILayout,
is_disabled: bool, reason: str = "") -> UILayout:
"""Prepare layout for disabled operator with visual feedback"""
if is_disabled:
layout.enabled = False
return layout
def wrap_text_label(layout: UILayout, text: str, max_length: int = 50) -> None:
"""Draw a label that wraps long text across multiple lines"""
words = text.split()
current_line = ""
col = layout.column()
for word in words:
test_line = (current_line + " " + word).strip()
if len(test_line) > max_length and current_line:
col.label(text=current_line)
current_line = word
else:
current_line = test_line
if current_line:
col.label(text=current_line)
+6 -4
View File
@@ -2,6 +2,7 @@ import bpy
from bpy.types import Panel, Context, UILayout, Object, ShapeKey
from ..core.translations import t
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
from .panel_layout import get_panel_order, should_open_by_default
from ..core.common import get_active_armature
from ..functions.visemes import AvatarToolkit_OT_PreviewVisemes, AvatarToolkit_OT_CreateVisemes
@@ -13,8 +14,8 @@ class AvatarToolKit_PT_VisemesPanel(Panel):
bl_region_type: str = 'UI'
bl_category: str = CATEGORY_NAME
bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
bl_order: int = 5
bl_options: set[str] = {'DEFAULT_CLOSED'}
bl_order: int = get_panel_order('visemes')
bl_options: set[str] = set() if not should_open_by_default('VISEMES') else {'DEFAULT_CLOSED'}
def draw(self, context: Context) -> None:
"""Draw the visemes panel interface with shape key selection and preview controls"""
@@ -33,8 +34,9 @@ class AvatarToolKit_PT_VisemesPanel(Panel):
else:
col.label(text=t("Visemes.no_armature"), icon='ERROR')
# Get selected mesh
mesh_obj = bpy.data.objects.get(props.viseme_mesh)
# Get selected mesh using safe identifier
from ..core.common import get_mesh_from_identifier
mesh_obj = get_mesh_from_identifier(props.viseme_mesh)
if not mesh_obj or not mesh_obj.data or not mesh_obj.data.shape_keys:
layout.label(text=t("Visemes.no_shapekeys"))
return
+88
View File
@@ -0,0 +1,88 @@
import bpy
from bpy.types import Panel, Context, UILayout
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
from .panel_layout import get_panel_order, should_open_by_default
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 = get_panel_order('vrm_unity')
bl_options = set() if not should_open_by_default('VRM_UNITY') else {'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"))
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.