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:
2026-01-01 14:21:56 +08:00
commit 091ad6a49a
243 changed files with 60636 additions and 0 deletions

405
editor/ops.py Normal file
View 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]