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

559
importer/import_scene.py Normal file
View File

@@ -0,0 +1,559 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import traceback
from collections.abc import Set as AbstractSet
from os import environ
from pathlib import Path
from typing import TYPE_CHECKING
import bpy
from bpy.app.translations import pgettext
from bpy.props import BoolProperty, CollectionProperty, StringProperty
from bpy.types import (
Armature,
Context,
Event,
Operator,
Panel,
PropertyGroup,
SpaceFileBrowser,
)
from bpy_extras.io_utils import ImportHelper
from ..common import ops, version
from ..common.error_dialog import show_error_dialog
from ..common.logger import get_logger
from ..common.preferences import (
ImportPreferencesProtocol,
copy_import_preferences,
create_import_preferences_dict,
draw_import_preferences_layout,
get_preferences,
)
from ..editor import search
from ..editor.extension import get_armature_extension
from ..editor.ops import VRM_OT_open_url_in_web_browser, layout_operator
from ..editor.property_group import CollectionPropertyProtocol, StringPropertyGroup
from .abstract_base_vrm_importer import AbstractBaseVrmImporter, parse_vrm_json
from .license_validation import LicenseConfirmationRequiredError
from .uni_vrm_vrm_animation_importer import UniVrmVrmAnimationImporter
from .vrm0_importer import Vrm0Importer
from .vrm1_importer import Vrm1Importer
logger = get_logger(__name__)
class LicenseConfirmation(PropertyGroup):
message: StringProperty() # type: ignore[valid-type]
url: StringProperty() # type: ignore[valid-type]
json_key: StringProperty() # type: ignore[valid-type]
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
message: str # type: ignore[no-redef]
url: str # type: ignore[no-redef]
json_key: str # type: ignore[no-redef]
def import_vrm_update_addon_preferences(
import_op: "IMPORT_SCENE_OT_vrm", context: Context
) -> None:
if import_op.use_addon_preferences:
copy_import_preferences(source=import_op, destination=get_preferences(context))
class IMPORT_SCENE_OT_vrm(Operator, ImportHelper):
bl_idname = "import_scene.vrm"
bl_label = "Open"
bl_description = "import VRM"
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
filename_ext = ".vrm"
filter_glob: StringProperty( # type: ignore[valid-type]
default="*.vrm",
options={"HIDDEN"},
)
use_addon_preferences: BoolProperty( # type: ignore[valid-type]
name="Import using add-on preferences",
description="Import using add-on preferences instead of operator arguments",
)
extract_textures_into_folder: BoolProperty( # type: ignore[valid-type]
name="Extract texture images into the folder",
update=import_vrm_update_addon_preferences,
default=False,
)
make_new_texture_folder: BoolProperty( # type: ignore[valid-type]
name="Don't overwrite existing texture folder",
update=import_vrm_update_addon_preferences,
default=True,
)
set_shading_type_to_material_on_import: BoolProperty( # type: ignore[valid-type]
name='Set shading type to "Material"',
update=import_vrm_update_addon_preferences,
default=True,
)
set_view_transform_to_standard_on_import: BoolProperty( # type: ignore[valid-type]
name='Set view transform to "Standard"',
update=import_vrm_update_addon_preferences,
default=True,
)
set_armature_display_to_wire: BoolProperty( # type: ignore[valid-type]
name='Set an imported armature display to "Wire"',
update=import_vrm_update_addon_preferences,
default=True,
)
set_armature_display_to_show_in_front: BoolProperty( # type: ignore[valid-type]
name='Set an imported armature display to show "In-Front"',
update=import_vrm_update_addon_preferences,
default=True,
)
set_armature_bone_shape_to_default: BoolProperty( # type: ignore[valid-type]
name="Set an imported bone shape to default",
update=import_vrm_update_addon_preferences,
default=True,
)
enable_mtoon_outline_preview: BoolProperty( # type: ignore[valid-type]
name="Enable MToon Outline Preview",
default=True,
)
def execute(self, context: Context) -> set[str]:
try:
filepath = Path(self.filepath)
if not filepath.is_file():
return {"CANCELLED"}
if self.use_addon_preferences:
copy_import_preferences(
source=get_preferences(context), destination=self
)
license_error = None
try:
return import_vrm(
filepath,
self,
context,
license_validation=True,
)
except LicenseConfirmationRequiredError as e:
license_error = e # Prevent traceback dump on another exception
except Exception:
show_error_dialog(
pgettext("Failed to import VRM."),
traceback.format_exc(),
)
raise
logger.warning(license_error.description())
execution_context = "INVOKE_DEFAULT"
import_anyway = False
if environ.get("BLENDER_VRM_AUTOMATIC_LICENSE_CONFIRMATION") == "true":
execution_context = "EXEC_DEFAULT"
import_anyway = True
return ops.wm.vrm_license_warning(
execution_context,
import_anyway=import_anyway,
license_confirmations=license_error.license_confirmations(),
filepath=str(filepath),
**create_import_preferences_dict(self),
)
def invoke(self, context: Context, event: Event) -> set[str]:
self.use_addon_preferences = True
copy_import_preferences(source=get_preferences(context), destination=self)
if "gltf" not in dir(bpy.ops.import_scene):
return ops.wm.vrm_gltf2_addon_disabled_warning("INVOKE_DEFAULT")
return ImportHelper.invoke(self, context, event)
def draw(self, _context: Context) -> None:
pass # Is needed to get panels available
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]
use_addon_preferences: bool # type: ignore[no-redef]
extract_textures_into_folder: bool # type: ignore[no-redef]
make_new_texture_folder: bool # type: ignore[no-redef]
set_shading_type_to_material_on_import: bool # type: ignore[no-redef]
set_view_transform_to_standard_on_import: bool # type: ignore[no-redef]
set_armature_display_to_wire: bool # type: ignore[no-redef]
set_armature_display_to_show_in_front: bool # type: ignore[no-redef]
set_armature_bone_shape_to_default: bool # type: ignore[no-redef]
enable_mtoon_outline_preview: bool # type: ignore[no-redef]
class VRM_PT_import_file_browser_tool_props(Panel):
bl_idname = "VRM_PT_import_file_browser_tool_props"
bl_space_type = "FILE_BROWSER"
bl_region_type = "TOOL_PROPS"
bl_parent_id = "FILE_PT_operator"
bl_label = ""
bl_options: AbstractSet[str] = {"HIDE_HEADER"}
@classmethod
def poll(cls, context: Context) -> bool:
space_data = context.space_data
if not isinstance(space_data, SpaceFileBrowser):
return False
return space_data.active_operator.bl_idname == "IMPORT_SCENE_OT_vrm"
def draw(self, context: Context) -> None:
space_data = context.space_data
if not isinstance(space_data, SpaceFileBrowser):
return
operator = space_data.active_operator
if not isinstance(operator, IMPORT_SCENE_OT_vrm):
return
layout = self.layout
draw_import_preferences_layout(operator, layout)
class VRM_PT_import_unsupported_blender_version_warning(Panel):
bl_idname = "VRM_PT_import_unsupported_blender_version_warning"
bl_space_type = "FILE_BROWSER"
bl_region_type = "TOOL_PROPS"
bl_parent_id = "FILE_PT_operator"
bl_label = ""
bl_options: AbstractSet[str] = {"HIDE_HEADER"}
@classmethod
def poll(cls, context: Context) -> bool:
space_data = context.space_data
if not isinstance(space_data, SpaceFileBrowser):
return False
if space_data.active_operator.bl_idname != "IMPORT_SCENE_OT_vrm":
return False
return bool(version.panel_warning_message())
def draw(self, _context: Context) -> None:
warning_message = version.panel_warning_message()
if warning_message is None:
return
box = self.layout.box()
warning_column = box.column(align=True)
for index, warning_line in enumerate(warning_message.splitlines()):
warning_column.label(
text=warning_line,
translate=False,
icon="NONE" if index else "ERROR",
)
class WM_OT_vrm_license_confirmation(Operator):
bl_label = "VRM License Confirmation"
bl_idname = "wm.vrm_license_warning"
bl_options: AbstractSet[str] = {"REGISTER"}
filepath: StringProperty() # type: ignore[valid-type]
license_confirmations: CollectionProperty(type=LicenseConfirmation) # type: ignore[valid-type]
import_anyway: BoolProperty( # type: ignore[valid-type]
name="Import Anyway",
)
extract_textures_into_folder: BoolProperty() # type: ignore[valid-type]
make_new_texture_folder: BoolProperty() # type: ignore[valid-type]
set_shading_type_to_material_on_import: BoolProperty() # type: ignore[valid-type]
set_view_transform_to_standard_on_import: BoolProperty() # type: ignore[valid-type]
set_armature_display_to_wire: BoolProperty() # type: ignore[valid-type]
set_armature_display_to_show_in_front: BoolProperty() # type: ignore[valid-type]
set_armature_bone_shape_to_default: BoolProperty() # type: ignore[valid-type]
enable_mtoon_outline_preview: BoolProperty() # type: ignore[valid-type]
def execute(self, context: Context) -> set[str]:
try:
filepath = Path(self.filepath)
if not filepath.is_file():
return {"CANCELLED"}
if not self.import_anyway:
return {"CANCELLED"}
return import_vrm(
filepath,
self,
context,
license_validation=False,
)
except Exception:
show_error_dialog(
pgettext("Failed to import VRM."),
traceback.format_exc(),
)
raise
def invoke(self, context: Context, _event: Event) -> set[str]:
return context.window_manager.invoke_props_dialog(self, width=600)
def draw(self, _context: Context) -> None:
layout = self.layout
layout.label(text=self.filepath, translate=False)
for license_confirmation in self.license_confirmations:
box = layout.box()
for line in license_confirmation.message.split("\n"):
box.label(text=line, translate=False, icon="INFO")
if license_confirmation.json_key:
box.label(
text=pgettext("For more information please check following URL.")
)
if VRM_OT_open_url_in_web_browser.supported(license_confirmation.url):
split = box.split(factor=0.85, align=True)
split.prop(
license_confirmation,
"url",
text=license_confirmation.json_key,
translate=False,
)
op = layout_operator(split, VRM_OT_open_url_in_web_browser)
op.url = license_confirmation.url
else:
box.prop(
license_confirmation,
"url",
text=license_confirmation.json_key,
translate=False,
)
layout.prop(self, "import_anyway")
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
filepath: str # type: ignore[no-redef]
license_confirmations: CollectionPropertyProtocol[ # type: ignore[no-redef]
LicenseConfirmation
]
import_anyway: bool # type: ignore[no-redef]
extract_textures_into_folder: bool # type: ignore[no-redef]
make_new_texture_folder: bool # type: ignore[no-redef]
set_shading_type_to_material_on_import: bool # type: ignore[no-redef]
set_view_transform_to_standard_on_import: bool # type: ignore[no-redef]
set_armature_display_to_wire: bool # type: ignore[no-redef]
set_armature_display_to_show_in_front: bool # type: ignore[no-redef]
set_armature_bone_shape_to_default: bool # type: ignore[no-redef]
enable_mtoon_outline_preview: bool # type: ignore[no-redef]
def import_vrm(
filepath: Path,
preferences: ImportPreferencesProtocol,
context: Context,
*,
license_validation: bool,
) -> set[str]:
parse_result = parse_vrm_json(filepath, license_validation=license_validation)
if parse_result.spec_version_number >= (1,):
vrm_importer: AbstractBaseVrmImporter = Vrm1Importer(
context,
parse_result,
preferences,
)
else:
vrm_importer = Vrm0Importer(
context,
parse_result,
preferences,
)
vrm_importer.import_vrm()
return {"FINISHED"}
def menu_import(
menu_op: Operator, _context: Context
) -> None: # Same as test/blender_io.py for now
vrm_import_op = layout_operator(
menu_op.layout, IMPORT_SCENE_OT_vrm, text="VRM (.vrm)"
)
vrm_import_op.use_addon_preferences = True
vrma_import_op = layout_operator(
menu_op.layout, IMPORT_SCENE_OT_vrma, text="VRM Animation (.vrma)"
)
vrma_import_op.armature_object_name = ""
class IMPORT_SCENE_OT_vrma(Operator, ImportHelper):
bl_idname = "import_scene.vrma"
bl_label = "Open"
bl_description = "Import VRM Animation"
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
filename_ext = ".vrma"
filter_glob: StringProperty( # type: ignore[valid-type]
default="*.vrma",
options={"HIDDEN"},
)
armature_object_name: StringProperty( # type: ignore[valid-type]
options={"HIDDEN"},
)
def execute(self, context: Context) -> set[str]:
try:
errors = WM_OT_vrma_import_prerequisite.detect_errors(
context, self.armature_object_name
)
if errors:
for error in errors:
logger.error(error)
return {"CANCELLED"}
filepath = Path(self.filepath)
if not filepath.is_file():
return {"CANCELLED"}
armature = None
if self.armature_object_name:
armature = context.blend_data.objects.get(self.armature_object_name)
if not armature:
return {"CANCELLED"}
if not armature:
armature = search.current_armature(context)
if not armature:
added = ops.icyp.make_basic_armature()
if added != {"FINISHED"}:
return added
armature = search.current_armature(context)
if not armature:
return {"CANCELLED"}
armature_data = armature.data
if not isinstance(armature_data, Armature):
return {"CANCELLED"}
ext = get_armature_extension(armature_data)
ext.spec_version = ext.SPEC_VERSION_VRM1
return UniVrmVrmAnimationImporter.execute(context, filepath, armature)
except Exception:
show_error_dialog(
pgettext("Failed to import VRM Animation."),
traceback.format_exc(),
)
raise
def invoke(self, context: Context, event: Event) -> set[str]:
if WM_OT_vrma_import_prerequisite.detect_errors(
context, self.armature_object_name
):
return ops.wm.vrma_import_prerequisite(
"INVOKE_DEFAULT",
armature_object_name=self.armature_object_name,
)
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]
armature_object_name: str # type: ignore[no-redef]
class WM_OT_vrma_import_prerequisite(Operator):
bl_label = "VRM Animation Import Prerequisite"
bl_idname = "wm.vrma_import_prerequisite"
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
armature_object_name: StringProperty( # type: ignore[valid-type]
options={"HIDDEN"},
)
armature_object_name_candidates: CollectionProperty( # type: ignore[valid-type]
type=StringPropertyGroup,
options={"HIDDEN"},
)
@staticmethod
def detect_errors(context: Context, armature_object_name: str) -> list[str]:
error_messages: list[str] = []
if not armature_object_name:
armature = search.current_armature(context)
else:
armature = context.blend_data.objects.get(armature_object_name)
if not armature:
return error_messages
armature_data = armature.data
if not isinstance(armature_data, Armature):
return error_messages
ext = get_armature_extension(armature_data)
if get_armature_extension(armature_data).is_vrm1():
humanoid = ext.vrm1.humanoid
if not humanoid.human_bones.all_required_bones_are_assigned():
error_messages.append(pgettext("Please assign required human bones"))
else:
error_messages.append(pgettext("Please set the version of VRM to 1.0"))
return error_messages
def execute(self, _context: Context) -> set[str]:
return ops.import_scene.vrma(
"INVOKE_DEFAULT", armature_object_name=self.armature_object_name
)
def invoke(self, context: Context, _event: Event) -> set[str]:
if not self.armature_object_name:
armature_object = search.current_armature(context)
if armature_object:
self.armature_object_name = armature_object.name
self.armature_object_name_candidates.clear()
for obj in context.blend_data.objects:
if obj.type != "ARMATURE":
continue
candidate = self.armature_object_name_candidates.add()
candidate.value = obj.name
return context.window_manager.invoke_props_dialog(self, width=800)
def draw(self, context: Context) -> None:
layout = self.layout
layout.label(
text="VRM Animation import requires a VRM 1.0 armature",
icon="INFO",
)
error_messages = WM_OT_vrma_import_prerequisite.detect_errors(
context, self.armature_object_name
)
layout.prop_search(
self,
"armature_object_name",
self,
"armature_object_name_candidates",
icon="OUTLINER_OB_ARMATURE",
text="Armature to be animated",
)
if error_messages:
error_column = layout.box().column(align=True)
for error_message in error_messages:
error_column.label(text=error_message, icon="ERROR", translate=False)
open_op = layout_operator(
layout,
VRM_OT_open_url_in_web_browser,
icon="URL",
text="Open help in a Web Browser",
)
open_op.url = pgettext("https://vrm-addon-for-blender.info/en-us/animation/")
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_object_name_candidates: CollectionPropertyProtocol[ # type: ignore[no-redef]
StringPropertyGroup
]