feat: Add VRM Blender addon with complete import/export functionality
- Add core VRM addon infrastructure with manifest and registration - Add common utilities module with file system, logging, and conversion helpers - Add human bone mapper with support for multiple rigging standards (Mixamo, MMD, Unreal, Rigify, etc.) - Add VRM 0.x and 1.x format support with property groups and handlers - Add editor UI panels for VRM metadata, spring bones, and MToon materials - Add exporter with glTF2 extension support for VRM format serialization - Add importer with scene reconstruction and armature generation - Add MToon shader support with auto-setup and material migration - Add spring bone physics simulation with constraint handling - Add node constraint editor for advanced rigging control - Add comprehensive validation and error handling with user dialogs - Add scene watcher for real-time property synchronization - Add workspace management and preference system - Include Python cache files and Blender manifest configuration - This is the initial commit establishing the complete VRM addon ecosystem for Blender
This commit is contained in:
405
editor/ops.py
Normal file
405
editor/ops.py
Normal file
@@ -0,0 +1,405 @@
|
||||
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
|
||||
# SPDX-FileCopyrightText: 2018 iCyP
|
||||
# SPDX-FileCopyrightText: 2024 saturday06
|
||||
import json
|
||||
import warnings
|
||||
import webbrowser
|
||||
from collections.abc import Set as AbstractSet
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional, TypeVar, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from bpy.app.translations import pgettext
|
||||
from bpy.props import StringProperty
|
||||
from bpy.types import (
|
||||
Armature,
|
||||
Context,
|
||||
Event,
|
||||
Operator,
|
||||
UILayout,
|
||||
)
|
||||
from bpy_extras.io_utils import ExportHelper, ImportHelper
|
||||
|
||||
from ..common.deep import make_json
|
||||
from ..common.human_bone_mapper.vroid_mapping import (
|
||||
FULL_PATTERN,
|
||||
symmetrise_vroid_bone_name,
|
||||
)
|
||||
from ..common.logger import get_logger
|
||||
from ..common.vrm0.human_bone import HumanBoneSpecifications
|
||||
from ..common.workspace import save_workspace
|
||||
from . import search
|
||||
from .extension import get_armature_extension
|
||||
from .t_pose import set_estimated_humanoid_t_pose
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class VRM_OT_simplify_vroid_bones(Operator):
|
||||
bl_idname = "vrm.bones_rename"
|
||||
bl_label = "Symmetrize VRoid Bone Names on X-Axis"
|
||||
bl_description = "Make VRoid bone names editable for X-axis mirroring."
|
||||
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
|
||||
|
||||
armature_object_name: StringProperty( # type: ignore[valid-type]
|
||||
options={"HIDDEN"},
|
||||
)
|
||||
|
||||
def update_armature_name(self, _context: Context) -> None:
|
||||
message = (
|
||||
f"`{type(self).__qualname__}.armature_name` is deprecated"
|
||||
+ " and will be removed in the next major release."
|
||||
+ f" `Please use {type(self).__qualname__}.armature_object_name` instead."
|
||||
)
|
||||
logger.warning(message)
|
||||
warnings.warn(message, DeprecationWarning, stacklevel=5)
|
||||
|
||||
armature_name: StringProperty( # type: ignore[valid-type]
|
||||
options={"HIDDEN"},
|
||||
update=update_armature_name,
|
||||
)
|
||||
"""`armature_name` is deprecated and will be removed in the next major release.
|
||||
Please use `armature_object_name` instead.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def vroid_bones_exist(armature: Armature) -> bool:
|
||||
return any(map(FULL_PATTERN.match, armature.bones.keys()))
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
if not self.armature_object_name and self.armature_name:
|
||||
self.armature_object_name = self.armature_name
|
||||
armature = context.blend_data.objects.get(self.armature_object_name)
|
||||
if armature is None or armature.type != "ARMATURE":
|
||||
return {"CANCELLED"}
|
||||
armature_data = armature.data
|
||||
if not isinstance(armature_data, Armature):
|
||||
return {"CANCELLED"}
|
||||
|
||||
with save_workspace(context):
|
||||
for bone_name, bone in armature_data.bones.items():
|
||||
bone.name = symmetrise_vroid_bone_name(bone_name)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This code is auto generated.
|
||||
# To regenerate, run the `uv run tools/property_typing.py` command.
|
||||
armature_object_name: str # type: ignore[no-redef]
|
||||
armature_name: str # type: ignore[no-redef]
|
||||
|
||||
|
||||
class VRM_OT_save_human_bone_mappings(Operator, ExportHelper):
|
||||
bl_idname = "vrm.save_human_bone_mappings"
|
||||
bl_label = "Save Bone Mappings"
|
||||
bl_description = "Save bone mappings."
|
||||
bl_options: AbstractSet[str] = {"REGISTER"}
|
||||
|
||||
filename_ext = ".json"
|
||||
filter_glob: StringProperty( # type: ignore[valid-type]
|
||||
default="*.json",
|
||||
options={"HIDDEN"},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
return search.armature_exists(context)
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
armature = search.current_armature(context)
|
||||
if not armature:
|
||||
return {"CANCELLED"}
|
||||
armature_data = armature.data
|
||||
if not isinstance(armature_data, Armature):
|
||||
return {"CANCELLED"}
|
||||
|
||||
mappings = {}
|
||||
for human_bone in get_armature_extension(
|
||||
armature_data
|
||||
).vrm0.humanoid.human_bones:
|
||||
if human_bone.bone not in HumanBoneSpecifications.all_names:
|
||||
continue
|
||||
if not human_bone.node.bone_name:
|
||||
continue
|
||||
mappings[human_bone.bone] = human_bone.node.bone_name
|
||||
|
||||
Path(self.filepath).write_bytes(
|
||||
json.dumps(mappings, sort_keys=True, indent=4)
|
||||
.replace("\r\n", "\n")
|
||||
.encode()
|
||||
)
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||
return ExportHelper.invoke(self, context, event)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This code is auto generated.
|
||||
# To regenerate, run the `uv run tools/property_typing.py` command.
|
||||
filter_glob: str # type: ignore[no-redef]
|
||||
|
||||
|
||||
class VRM_OT_load_human_bone_mappings(Operator, ImportHelper):
|
||||
bl_idname = "vrm.load_human_bone_mappings"
|
||||
bl_label = "Load Bone Mappings"
|
||||
bl_description = "Load bone mappings."
|
||||
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
|
||||
|
||||
filename_ext = ".json"
|
||||
filter_glob: StringProperty( # type: ignore[valid-type]
|
||||
default="*.json",
|
||||
options={"HIDDEN"},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
return search.armature_exists(context)
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
armature = search.current_armature(context)
|
||||
if not armature:
|
||||
return {"CANCELLED"}
|
||||
armature_data = armature.data
|
||||
if not isinstance(armature_data, Armature):
|
||||
return {"CANCELLED"}
|
||||
|
||||
obj = make_json(json.loads(Path(self.filepath).read_text(encoding="UTF-8")))
|
||||
if not isinstance(obj, dict):
|
||||
return {"CANCELLED"}
|
||||
|
||||
for human_bone_name, bpy_bone_name in obj.items():
|
||||
if human_bone_name not in HumanBoneSpecifications.all_names:
|
||||
continue
|
||||
if not isinstance(bpy_bone_name, str):
|
||||
continue
|
||||
# INFO@MICROSOFT.COM
|
||||
found = False
|
||||
for human_bone in get_armature_extension(
|
||||
armature_data
|
||||
).vrm0.humanoid.human_bones:
|
||||
if human_bone.bone == human_bone_name:
|
||||
human_bone.node.bone_name = bpy_bone_name
|
||||
found = True
|
||||
break
|
||||
if found:
|
||||
continue
|
||||
|
||||
human_bone = get_armature_extension(
|
||||
armature_data
|
||||
).vrm0.humanoid.human_bones.add()
|
||||
human_bone.bone = human_bone_name
|
||||
human_bone.node.bone_name = bpy_bone_name
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||
return ImportHelper.invoke(self, context, event)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This code is auto generated.
|
||||
# To regenerate, run the `uv run tools/property_typing.py` command.
|
||||
filter_glob: str # type: ignore[no-redef]
|
||||
|
||||
|
||||
class VRM_OT_open_url_in_web_browser(Operator):
|
||||
bl_idname = "vrm.open_url_in_web_browser"
|
||||
bl_label = "Open"
|
||||
bl_description = "Open the URL in the default web browser."
|
||||
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
|
||||
|
||||
url: StringProperty( # type: ignore[valid-type]
|
||||
options={"HIDDEN"}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def supported(url_str: str) -> bool:
|
||||
try:
|
||||
url = urlparse(url_str)
|
||||
except ValueError:
|
||||
return False
|
||||
return url.scheme in ["http", "https"]
|
||||
|
||||
def execute(self, _context: Context) -> set[str]:
|
||||
url = self.url
|
||||
if not self.supported(url):
|
||||
return {"CANCELLED"}
|
||||
webbrowser.open(self.url)
|
||||
return {"FINISHED"}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This code is auto generated.
|
||||
# To regenerate, run the `uv run tools/property_typing.py` command.
|
||||
url: str # type: ignore[no-redef]
|
||||
|
||||
|
||||
class VRM_OT_show_blend_file_compatibility_warning(Operator):
|
||||
bl_idname = "vrm.show_blend_file_compatibility_warning"
|
||||
bl_label = "File Compatibility Warning"
|
||||
bl_description = "Show blend file compatibility warning."
|
||||
bl_options: AbstractSet[str] = {"REGISTER"}
|
||||
|
||||
file_version: StringProperty(options={"HIDDEN"}) # type: ignore[valid-type]
|
||||
app_version: StringProperty(options={"HIDDEN"}) # type: ignore[valid-type]
|
||||
|
||||
def execute(self, _context: Context) -> set[str]:
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context: Context, _event: Event) -> set[str]:
|
||||
return context.window_manager.invoke_props_dialog(self, width=500)
|
||||
|
||||
def draw(self, _context: Context) -> None:
|
||||
column = self.layout.row(align=True).column()
|
||||
text = pgettext(
|
||||
"The current file is not compatible with the running Blender.\n"
|
||||
+ "The current file was created in Blender {file_version}, but the running"
|
||||
+ " Blender version is {app_version}.\n"
|
||||
+ "This incompatibility may result in data loss or corruption."
|
||||
).format(
|
||||
app_version=self.app_version,
|
||||
file_version=self.file_version,
|
||||
)
|
||||
description_outer_column = column.column()
|
||||
description_outer_column.emboss = "NONE"
|
||||
description_column = description_outer_column.box().column(align=True)
|
||||
for i, line in enumerate(text.splitlines()):
|
||||
icon = "ERROR" if i == 0 else "NONE"
|
||||
description_column.label(text=line, translate=False, icon=icon)
|
||||
open_url = layout_operator(
|
||||
self.layout,
|
||||
VRM_OT_open_url_in_web_browser,
|
||||
text="Open Documentation",
|
||||
icon="URL",
|
||||
)
|
||||
open_url.url = "https://developer.blender.org/docs/handbook/guidelines/compatibility_handling_for_blend_files/#forward-compatibility"
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This code is auto generated.
|
||||
# To regenerate, run the `uv run tools/property_typing.py` command.
|
||||
file_version: str # type: ignore[no-redef]
|
||||
app_version: str # type: ignore[no-redef]
|
||||
|
||||
|
||||
class VRM_OT_show_blend_file_addon_compatibility_warning(Operator):
|
||||
bl_idname = "vrm.show_blend_file_addon_compatibility_warning"
|
||||
bl_label = "VRM Add-on Compatibility Warning"
|
||||
bl_description = "Show blend file and VRM add-on compatibility warning."
|
||||
bl_options: AbstractSet[str] = {"REGISTER"}
|
||||
|
||||
file_addon_version: StringProperty(options={"HIDDEN"}) # type: ignore[valid-type]
|
||||
installed_addon_version: StringProperty(options={"HIDDEN"}) # type: ignore[valid-type]
|
||||
|
||||
def execute(self, _context: Context) -> set[str]:
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context: Context, _event: Event) -> set[str]:
|
||||
return context.window_manager.invoke_props_dialog(self, width=500)
|
||||
|
||||
def draw(self, _context: Context) -> None:
|
||||
column = self.layout.row(align=True).column()
|
||||
text = pgettext(
|
||||
"The current file is not compatible with the installed VRM Add-on.\n"
|
||||
+ "The current file was created in VRM Add-on {file_addon_version}, but the"
|
||||
+ " installed\n"
|
||||
+ "VRM Add-on version is {installed_addon_version}. This incompatibility\n"
|
||||
+ "may result in data loss or corruption."
|
||||
).format(
|
||||
file_addon_version=self.file_addon_version,
|
||||
installed_addon_version=self.installed_addon_version,
|
||||
)
|
||||
description_outer_column = column.column()
|
||||
description_outer_column.emboss = "NONE"
|
||||
description_column = description_outer_column.box().column(align=True)
|
||||
for i, line in enumerate(text.splitlines()):
|
||||
icon = "ERROR" if i == 0 else "NONE"
|
||||
description_column.label(text=line, translate=False, icon=icon)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This code is auto generated.
|
||||
# To regenerate, run the `uv run tools/property_typing.py` command.
|
||||
file_addon_version: str # type: ignore[no-redef]
|
||||
installed_addon_version: str # type: ignore[no-redef]
|
||||
|
||||
|
||||
__Operator = TypeVar("__Operator", bound=Operator)
|
||||
|
||||
|
||||
def layout_operator(
|
||||
layout: UILayout,
|
||||
operator_type: type[__Operator],
|
||||
*,
|
||||
text: Optional[str] = None,
|
||||
text_ctxt: str = "",
|
||||
translate: bool = True,
|
||||
icon: str = "NONE",
|
||||
emboss: bool = True,
|
||||
depress: bool = False,
|
||||
icon_value: int = 0,
|
||||
) -> __Operator:
|
||||
if text is None:
|
||||
text = operator_type.bl_label
|
||||
operator = layout.operator(
|
||||
operator_type.bl_idname,
|
||||
text=text,
|
||||
text_ctxt=text_ctxt,
|
||||
translate=translate,
|
||||
icon=icon,
|
||||
emboss=emboss,
|
||||
depress=depress,
|
||||
icon_value=icon_value,
|
||||
)
|
||||
|
||||
split = operator_type.bl_idname.split(".")
|
||||
if len(split) != 2:
|
||||
message = f"Unexpected bl_idname: {operator_type.bl_idname}"
|
||||
raise AssertionError(message)
|
||||
name = f"{split[0].encode().upper().decode()}_OT_{split[1]}"
|
||||
if type(operator).__qualname__ != name:
|
||||
raise AssertionError(
|
||||
f"{type(operator)} is not compatible with {operator_type}."
|
||||
+ f"the expected name is {name}"
|
||||
)
|
||||
return cast("__Operator", operator)
|
||||
|
||||
|
||||
class VRM_OT_make_estimated_humanoid_t_pose(Operator):
|
||||
bl_idname = "vrm.make_estimated_humanoid_t_pose"
|
||||
bl_label = "Make Estimated T-Pose"
|
||||
bl_description = "Create VRM estimated humanoid T-pose."
|
||||
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
|
||||
|
||||
armature_object_name: StringProperty( # type: ignore[valid-type]
|
||||
options={"HIDDEN"},
|
||||
)
|
||||
|
||||
def update_armature_name(self, _context: Context) -> None:
|
||||
message = (
|
||||
f"`{type(self).__qualname__}.armature_name` is deprecated"
|
||||
+ " and will be removed in the next major release."
|
||||
+ f" `Please use {type(self).__qualname__}.armature_object_name` instead."
|
||||
)
|
||||
logger.warning(message)
|
||||
warnings.warn(message, DeprecationWarning, stacklevel=5)
|
||||
|
||||
armature_name: StringProperty( # type: ignore[valid-type]
|
||||
options={"HIDDEN"},
|
||||
update=update_armature_name,
|
||||
)
|
||||
"""`armature_name` is deprecated and will be removed in the next major release.
|
||||
Please use `armature_object_name` instead.
|
||||
"""
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
if not self.armature_object_name and self.armature_name:
|
||||
self.armature_object_name = self.armature_name
|
||||
armature = context.blend_data.objects.get(self.armature_object_name)
|
||||
if armature is None or armature.type != "ARMATURE":
|
||||
return {"CANCELLED"}
|
||||
if not set_estimated_humanoid_t_pose(context, armature):
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This code is auto generated.
|
||||
# To regenerate, run the `uv run tools/property_typing.py` command.
|
||||
armature_object_name: str # type: ignore[no-redef]
|
||||
armature_name: str # type: ignore[no-redef]
|
||||
Reference in New Issue
Block a user