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

1
exporter/__init__.py Normal file
View File

@@ -0,0 +1 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,384 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import secrets
import string
from abc import ABC, abstractmethod
from collections.abc import Iterator, Mapping, Sequence
from contextlib import contextmanager
from typing import Optional, Union
import bmesh
from bpy.types import Armature, Context, Mesh, NodesModifier, Object
from mathutils import Matrix, Vector
from ..common import shader
from ..common.convert import Json
from ..common.deep import make_json
from ..common.logger import get_logger
from ..editor.extension import get_armature_extension, get_material_extension
from ..editor.property_group import BonePropertyGroup, BonePropertyGroupType
from ..editor.search import MESH_CONVERTIBLE_OBJECT_TYPES
from ..external import io_scene_gltf2_support
logger = get_logger(__name__)
class AbstractBaseVrmExporter(ABC):
def __init__(
self,
context: Context,
export_objects: Sequence[Object],
armature: Object,
) -> None:
self.context = context
self.export_objects = export_objects
self.armature = armature
self.export_id = "BlenderVrmAddonExport" + (
"".join(secrets.choice(string.digits) for _ in range(10))
)
self.gltf2_addon_export_settings = (
io_scene_gltf2_support.create_export_settings()
)
armature_data = self.armature.data
if not isinstance(armature_data, Armature):
message = f"{type(armature_data)} is not an Armature"
raise TypeError(message)
@abstractmethod
def export_vrm(self) -> Optional[bytes]:
pass
@staticmethod
def enter_clear_blend_shape_proxy_previews(
armature_data: Armature,
) -> tuple[Sequence[float], Mapping[str, float]]:
ext = get_armature_extension(armature_data)
saved_vrm0_previews: list[float] = []
for blend_shape_group in ext.vrm0.blend_shape_master.blend_shape_groups:
saved_vrm0_previews.append(blend_shape_group.preview)
blend_shape_group.preview = 0
saved_vrm1_previews: dict[str, float] = {}
for (
name,
expression,
) in ext.vrm1.expressions.all_name_to_expression_dict().items():
saved_vrm1_previews[name] = expression.preview
expression.preview = 0
return saved_vrm0_previews, saved_vrm1_previews
@staticmethod
def leave_clear_blend_shape_proxy_previews(
armature_data: Armature,
saved_vrm0_previews: Sequence[float],
saved_vrm1_previews: Mapping[str, float],
) -> None:
ext = get_armature_extension(armature_data)
for blend_shape_group, blend_shape_preview in zip(
ext.vrm0.blend_shape_master.blend_shape_groups, saved_vrm0_previews
):
blend_shape_group.preview = blend_shape_preview
for (
name,
expression,
) in ext.vrm1.expressions.all_name_to_expression_dict().items():
expression_preview = saved_vrm1_previews.get(name)
if expression_preview is not None:
expression.preview = expression_preview
@contextmanager
def clear_blend_shape_proxy_previews(
self, armature_data: Armature
) -> Iterator[None]:
saved_vrm0_previews, saved_vrm1_previews = (
self.enter_clear_blend_shape_proxy_previews(armature_data)
)
try:
yield
# After yield, native bpy objects may be deleted or frames may advance,
# becoming invalid. Accessing them in this state causes crashes, so
# be careful not to access potentially invalid native objects after yield
finally:
self.leave_clear_blend_shape_proxy_previews(
armature_data, saved_vrm0_previews, saved_vrm1_previews
)
def enter_enable_deform_for_all_referenced_bones(
self, armature_data: Armature
) -> list[str]:
ext = get_armature_extension(armature_data)
modified_non_deform_bone_names = list[str]()
for (
bone_property_group,
bone_property_group_type,
) in BonePropertyGroup.get_all_bone_property_groups(armature_data):
bone = armature_data.bones.get(bone_property_group.bone_name)
if not bone or bone.use_deform:
continue
if (
ext.is_vrm0()
and BonePropertyGroupType.is_vrm0(bone_property_group_type)
) or (
ext.is_vrm1()
and BonePropertyGroupType.is_vrm1(bone_property_group_type)
):
bone.use_deform = True
modified_non_deform_bone_names.append(bone.name)
return modified_non_deform_bone_names
def leave_enable_deform_for_all_referenced_bones(
self, armature_data: Armature, modified_non_deform_bone_names: list[str]
) -> None:
for modified_non_deform_bone_name in modified_non_deform_bone_names:
bone = armature_data.bones.get(modified_non_deform_bone_name)
if bone and bone.use_deform:
bone.use_deform = False
@contextmanager
def enable_deform_for_all_referenced_bones(
self, armature_data: Armature
) -> Iterator[None]:
modified_non_deform_bone_names = (
self.enter_enable_deform_for_all_referenced_bones(armature_data)
)
try:
yield
finally:
self.leave_enable_deform_for_all_referenced_bones(
armature_data, modified_non_deform_bone_names
)
@staticmethod
def enter_hide_mtoon1_outline_geometry_nodes(
context: Context,
) -> dict[str, list[tuple[str, bool, bool]]]:
object_name_to_modifiers: dict[str, list[tuple[str, bool, bool]]] = {}
for obj in context.blend_data.objects:
for modifier in obj.modifiers:
if not modifier.show_viewport:
continue
if modifier.type != "NODES":
continue
if not isinstance(modifier, NodesModifier):
continue
node_group = modifier.node_group
if (
not node_group
or node_group.name != shader.OUTLINE_GEOMETRY_GROUP_NAME
):
continue
modifiers = object_name_to_modifiers.get(obj.name)
if modifiers is None:
modifiers = []
object_name_to_modifiers[obj.name] = modifiers
modifiers = object_name_to_modifiers[obj.name]
modifiers.append(
(
modifier.name,
modifier.show_render,
modifier.show_viewport,
)
)
if modifier.show_render:
modifier.show_render = False
if modifier.show_viewport:
modifier.show_viewport = False
return object_name_to_modifiers
@staticmethod
def exit_hide_mtoon1_outline_geometry_nodes(
context: Context,
object_name_to_modifiers: dict[str, list[tuple[str, bool, bool]]],
) -> None:
for object_name, modifiers in object_name_to_modifiers.items():
for modifier_name, render, viewport in modifiers:
obj = context.blend_data.objects.get(object_name)
if not obj:
continue
modifier = obj.modifiers.get(modifier_name)
if (
not modifier
or modifier.type != "NODES"
or not isinstance(modifier, NodesModifier)
):
continue
node_group = modifier.node_group
if (
not node_group
or node_group.name != shader.OUTLINE_GEOMETRY_GROUP_NAME
):
continue
if modifier.show_render != render:
modifier.show_render = render
if modifier.show_viewport != viewport:
modifier.show_viewport = viewport
@staticmethod
@contextmanager
def hide_mtoon1_outline_geometry_nodes(context: Context) -> Iterator[None]:
object_name_to_modifier_names = (
AbstractBaseVrmExporter.enter_hide_mtoon1_outline_geometry_nodes(context)
)
try:
yield
# After yield, native bpy objects may be deleted or frames may advance,
# becoming invalid. Accessing them in this state causes crashes, so
# be careful not to access potentially invalid native objects after yield
finally:
AbstractBaseVrmExporter.exit_hide_mtoon1_outline_geometry_nodes(
context, object_name_to_modifier_names
)
@staticmethod
def setup_mtoon_gltf_fallback_nodes(context: Context, *, is_vrm0: bool) -> None:
"""Reflect MToon node values to nodes used for glTF fallback values.
When MToon nodes are directly edited, glTF fallback values are not
automatically set. Therefore, we explicitly set values during export.
"""
for material in context.blend_data.materials:
mtoon1 = get_material_extension(material).mtoon1
if not mtoon1.enabled:
continue
mtoon1.pbr_metallic_roughness.base_color_factor = (
mtoon1.pbr_metallic_roughness.base_color_factor
)
mtoon1.emissive_factor = mtoon1.emissive_factor
mtoon1.normal_texture.scale = mtoon1.normal_texture.scale
mtoon1.extensions.khr_materials_emissive_strength.emissive_strength = (
mtoon1.extensions.khr_materials_emissive_strength.emissive_strength
)
for texture in mtoon1.all_textures(downgrade_to_mtoon0=is_vrm0):
texture.source = texture.get_connected_node_image()
def assign_dict(
target: dict[str, Json],
key: str,
value: Union[Json, tuple[float, float, float], tuple[float, float, float, float]],
default_value: Json = None,
) -> bool:
if value is None or value == default_value:
return False
target[key] = make_json(value)
return True
def generate_evaluated_mesh(context: Context, obj: Object) -> Optional[Mesh]:
obj_data = obj.data
if obj_data is None:
return None
# https://docs.blender.org/api/2.80/Depsgraph.html
# TODO: Shape keys may sometimes break
depsgraph = context.evaluated_depsgraph_get()
evaluated_obj = obj.evaluated_get(depsgraph)
evaluated_temporary_mesh = evaluated_obj.to_mesh(
preserve_all_data_layers=True, depsgraph=depsgraph
)
if not evaluated_temporary_mesh:
return None
# The documentation says to use BlendDataMeshes.new_from_object(), but
# that doesn't preserve shape keys.
if isinstance(obj_data, Mesh):
evaluated_mesh = obj_data.copy()
else:
logger.error(
"Unexpected object type: %s name=%s",
type(obj_data),
obj_data.name,
)
evaluated_mesh = context.blend_data.meshes.new(name=obj_data.name)
bm = bmesh.new()
bm.from_mesh(evaluated_temporary_mesh)
bm.to_mesh(evaluated_mesh)
bm.free()
evaluated_obj.to_mesh_clear()
return evaluated_mesh
def force_apply_modifiers(
context: Context,
obj: Object,
*,
preserve_shape_keys: bool,
transform: Optional[Matrix] = None,
) -> Optional[Mesh]:
if obj.type not in MESH_CONVERTIBLE_OBJECT_TYPES:
return None
evaluated_mesh = generate_evaluated_mesh(context, obj)
if evaluated_mesh is None:
return None
if transform:
evaluated_mesh.transform(transform)
if not preserve_shape_keys:
return evaluated_mesh
obj_data = obj.data
if obj_data is None:
return evaluated_mesh
if not isinstance(obj_data, Mesh):
return evaluated_mesh
shape_keys = obj_data.shape_keys
if not shape_keys:
return evaluated_mesh
evaluated_mesh_shape_keys = evaluated_mesh.shape_keys
if not evaluated_mesh_shape_keys:
return evaluated_mesh
# If the mesh has shape keys, reproduce them as much as possible
for shape_key in shape_keys.key_blocks:
evaluated_mesh_shape_key = evaluated_mesh_shape_keys.key_blocks.get(
shape_key.name
)
if not evaluated_mesh_shape_key:
continue
if shape_key.name == shape_keys.reference_key.name:
continue
shape_key.value = 1.0
context.view_layer.update()
depsgraph = context.evaluated_depsgraph_get()
baked_shape_key_obj = obj.evaluated_get(depsgraph)
baked_shape_key_mesh = baked_shape_key_obj.to_mesh(
preserve_all_data_layers=True, depsgraph=depsgraph
)
if baked_shape_key_mesh:
if transform:
baked_shape_key_mesh.transform(transform)
evaluated_mesh_shape_key_data = evaluated_mesh_shape_key.data
baked_shape_key_mesh_vertices = baked_shape_key_mesh.vertices
# TODO: If the number of vertices is different, we should use advanced
# graph matching algorithm.
for i in range(
min(
len(evaluated_mesh_shape_key_data),
len(baked_shape_key_mesh_vertices),
)
):
evaluated_mesh_shape_key_data[i].co = Vector(
baked_shape_key_mesh_vertices[i].co
)
baked_shape_key_obj.to_mesh_clear()
shape_key.value = 0.0
context.view_layer.update()
return evaluated_mesh

905
exporter/export_scene.py Normal file
View File

@@ -0,0 +1,905 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import traceback
from collections.abc import Set as AbstractSet
from pathlib import Path
from typing import TYPE_CHECKING, Optional
import bpy
from bpy.app.translations import pgettext
from bpy.props import BoolProperty, CollectionProperty, StringProperty
from bpy.types import (
Armature,
Context,
Event,
Object,
Operator,
Panel,
SpaceFileBrowser,
UILayout,
)
from bpy_extras.io_utils import ExportHelper
from ..common import ops, safe_removal, version
from ..common.error_dialog import show_error_dialog
from ..common.logger import get_logger
from ..common.preferences import (
ExportPreferencesProtocol,
copy_export_preferences,
draw_export_preferences_layout,
get_preferences,
)
from ..common.workspace import save_workspace
from ..editor import migration, search, validation
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 ..editor.validation import VrmValidationError
from ..editor.vrm0.panel import (
draw_vrm0_humanoid_operators_layout,
draw_vrm0_humanoid_optional_bones_layout,
draw_vrm0_humanoid_required_bones_layout,
)
from ..editor.vrm0.property_group import Vrm0HumanoidPropertyGroup
from ..editor.vrm1.ops import VRM_OT_assign_vrm1_humanoid_human_bones_automatically
from ..editor.vrm1.panel import (
draw_vrm1_humanoid_optional_bones_layout,
draw_vrm1_humanoid_required_bones_layout,
)
from ..editor.vrm1.property_group import Vrm1HumanBonesPropertyGroup
from .abstract_base_vrm_exporter import AbstractBaseVrmExporter
from .uni_vrm_vrm_animation_exporter import UniVrmVrmAnimationExporter
from .vrm0_exporter import Vrm0Exporter
from .vrm1_exporter import Vrm1Exporter
logger = get_logger(__name__)
def export_vrm_update_addon_preferences(
export_op: "EXPORT_SCENE_OT_vrm", context: Context
) -> None:
if export_op.use_addon_preferences:
copy_export_preferences(source=export_op, destination=get_preferences(context))
validation.WM_OT_vrm_validator.detect_errors(
context,
export_op.errors,
export_op.armature_object_name,
)
class EXPORT_SCENE_OT_vrm(Operator, ExportHelper):
bl_idname = "export_scene.vrm"
bl_label = "Save"
bl_description = "export VRM"
bl_options: AbstractSet[str] = {"REGISTER"}
filename_ext = ".vrm"
filter_glob: StringProperty( # type: ignore[valid-type]
default="*.vrm",
options={"HIDDEN"},
)
use_addon_preferences: BoolProperty( # type: ignore[valid-type]
name="Export using add-on preferences",
description="Export using add-on preferences instead of operator arguments",
)
export_invisibles: BoolProperty( # type: ignore[valid-type]
name="Export Invisible Objects",
update=export_vrm_update_addon_preferences,
)
export_only_selections: BoolProperty( # type: ignore[valid-type]
name="Export Only Selections",
update=export_vrm_update_addon_preferences,
)
enable_advanced_preferences: BoolProperty( # type: ignore[valid-type]
name="Enable Advanced Options",
update=export_vrm_update_addon_preferences,
)
export_all_influences: BoolProperty( # type: ignore[valid-type]
name="Export All Bone Influences",
update=export_vrm_update_addon_preferences,
)
export_lights: BoolProperty( # type: ignore[valid-type]
name="Export Lights",
update=export_vrm_update_addon_preferences,
)
export_gltf_animations: BoolProperty( # type: ignore[valid-type]
name="Export glTF Animations",
update=export_vrm_update_addon_preferences,
)
export_try_sparse_sk: BoolProperty( # type: ignore[valid-type]
name="Use Sparse Accessors",
update=export_vrm_update_addon_preferences,
)
errors: CollectionProperty( # type: ignore[valid-type]
type=validation.VrmValidationError,
options={"HIDDEN"},
)
armature_object_name: StringProperty( # type: ignore[valid-type]
options={"HIDDEN"},
)
ignore_warning: BoolProperty( # type: ignore[valid-type]
options={"HIDDEN"},
)
def execute(self, context: Context) -> set[str]:
try:
if not self.filepath:
return {"CANCELLED"}
if self.use_addon_preferences:
copy_export_preferences(
source=get_preferences(context), destination=self
)
return export_vrm(
Path(self.filepath),
self,
context,
armature_object_name=self.armature_object_name,
)
except Exception:
show_error_dialog(
pgettext("Failed to export VRM."),
traceback.format_exc(),
)
raise
def invoke(self, context: Context, event: Event) -> set[str]:
self.use_addon_preferences = True
copy_export_preferences(source=get_preferences(context), destination=self)
if "gltf" not in dir(bpy.ops.export_scene):
return ops.wm.vrm_gltf2_addon_disabled_warning(
"INVOKE_DEFAULT",
)
armatures, _ = collect_export_objects(
context,
self.armature_object_name,
self,
)
if len(armatures) > 1:
return ops.wm.vrm_export_armature_selection("INVOKE_DEFAULT")
if len(armatures) == 1:
armature = armatures[0]
armature_data = armature.data
if not isinstance(armature_data, Armature):
pass
elif get_armature_extension(armature_data).is_vrm0():
Vrm0HumanoidPropertyGroup.fixup_human_bones(armature)
Vrm0HumanoidPropertyGroup.update_all_node_candidates(
context, armature_data.name
)
humanoid = get_armature_extension(armature_data).vrm0.humanoid
if all(
b.node.bone_name not in b.node_candidates
for b in humanoid.human_bones
):
ops.vrm.assign_vrm0_humanoid_human_bones_automatically(
armature_object_name=armature.name
)
if not humanoid.all_required_bones_are_assigned():
return ops.wm.vrm_export_human_bones_assignment(
"INVOKE_DEFAULT",
armature_object_name=self.armature_object_name,
)
elif get_armature_extension(armature_data).is_vrm1():
Vrm1HumanBonesPropertyGroup.fixup_human_bones(armature)
Vrm1HumanBonesPropertyGroup.update_all_node_candidates(
context, armature_data.name
)
human_bones = get_armature_extension(
armature_data
).vrm1.humanoid.human_bones
if all(
human_bone.node.bone_name not in human_bone.node_candidates
for human_bone in (
human_bones.human_bone_name_to_human_bone().values()
)
):
ops.vrm.assign_vrm1_humanoid_human_bones_automatically(
armature_object_name=armature.name
)
if (
not human_bones.all_required_bones_are_assigned()
and not human_bones.allow_non_humanoid_rig
):
return ops.wm.vrm_export_human_bones_assignment(
"INVOKE_DEFAULT",
armature_object_name=self.armature_object_name,
)
if ops.vrm.model_validate(
"INVOKE_DEFAULT",
show_successful_message=False,
armature_object_name=self.armature_object_name,
) != {"FINISHED"}:
return {"CANCELLED"}
validation.WM_OT_vrm_validator.detect_errors(
context,
self.errors,
self.armature_object_name,
)
if not self.ignore_warning and any(
error.severity <= 1 for error in self.errors
):
return ops.wm.vrm_export_confirmation(
"INVOKE_DEFAULT", armature_object_name=self.armature_object_name
)
return ExportHelper.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]
export_invisibles: bool # type: ignore[no-redef]
export_only_selections: bool # type: ignore[no-redef]
enable_advanced_preferences: bool # type: ignore[no-redef]
export_all_influences: bool # type: ignore[no-redef]
export_lights: bool # type: ignore[no-redef]
export_gltf_animations: bool # type: ignore[no-redef]
export_try_sparse_sk: bool # type: ignore[no-redef]
errors: CollectionPropertyProtocol[ # type: ignore[no-redef]
VrmValidationError
]
armature_object_name: str # type: ignore[no-redef]
ignore_warning: bool # type: ignore[no-redef]
def export_vrm(
filepath: Path,
export_preferences: ExportPreferencesProtocol,
context: Context,
*,
armature_object_name: str,
) -> set[str]:
if ops.vrm.model_validate(
"INVOKE_DEFAULT",
show_successful_message=False,
armature_object_name=armature_object_name,
) != {"FINISHED"}:
return {"CANCELLED"}
armature_objects, export_objects = collect_export_objects(
context,
armature_object_name,
export_preferences,
)
armature_object: Optional[Object] = next(iter(armature_objects), None)
is_vrm1 = False
with save_workspace(
context,
# Allow restoring after changing active object
armature_object,
):
if armature_object:
armature_object_is_temporary = False
armature_data = armature_object.data
if isinstance(armature_data, Armature):
is_vrm1 = get_armature_extension(armature_data).is_vrm1()
else:
armature_object_is_temporary = True
ops.icyp.make_basic_armature("EXEC_DEFAULT")
armature_object = context.view_layer.objects.active
if not armature_object or armature_object.type != "ARMATURE":
message = "Failed to generate temporary armature"
raise RuntimeError(message)
migration.migrate(context, armature_object.name)
if is_vrm1:
vrm_exporter: AbstractBaseVrmExporter = Vrm1Exporter(
context,
export_objects,
armature_object,
export_preferences,
)
else:
vrm_exporter = Vrm0Exporter(
context,
export_objects,
armature_object,
)
vrm_bytes = vrm_exporter.export_vrm()
if vrm_bytes is None:
return {"CANCELLED"}
Path(filepath).write_bytes(vrm_bytes)
if armature_object_is_temporary and not safe_removal.remove_object(
context, armature_object
):
logger.warning("Failed to remove temporary armature")
return {"FINISHED"}
class VRM_PT_export_file_browser_tool_props(Panel):
bl_idname = "VRM_IMPORTER_PT_export_error_messages"
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 == "EXPORT_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, EXPORT_SCENE_OT_vrm):
return
layout = self.layout
warning_message = version.panel_warning_message()
if warning_message:
box = 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",
)
show_vrm1_options = False
armature_objects, _ = collect_export_objects(
context, operator.armature_object_name, operator
)
if (
armature_objects
and (armature_object := armature_objects[0])
and (armature_data := armature_object.data)
and isinstance(armature_data, Armature)
):
show_vrm1_options = get_armature_extension(armature_data).is_vrm1()
draw_export_preferences_layout(
operator,
layout,
show_vrm1_options=show_vrm1_options,
)
if operator.errors:
validation.WM_OT_vrm_validator.draw_errors(
layout.box(),
operator.errors,
show_successful_message=False,
)
class VRM_PT_export_vrma_help(Panel):
bl_idname = "VRM_PT_export_vrma_help"
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 == "EXPORT_SCENE_OT_vrma"
def draw(self, _context: Context) -> None:
draw_help_message(self.layout)
def menu_export(menu_op: Operator, _context: Context) -> None:
vrm_export_op = layout_operator(
menu_op.layout, EXPORT_SCENE_OT_vrm, text="VRM (.vrm)"
)
vrm_export_op.use_addon_preferences = True
vrm_export_op.armature_object_name = ""
vrm_export_op.ignore_warning = False
vrma_export_op = layout_operator(
menu_op.layout, EXPORT_SCENE_OT_vrma, text="VRM Animation (.vrma)"
)
vrma_export_op.armature_object_name = ""
class EXPORT_SCENE_OT_vrma(Operator, ExportHelper):
bl_idname = "export_scene.vrma"
bl_label = "Save"
bl_description = "Export VRM Animation"
bl_options: AbstractSet[str] = {"REGISTER"}
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:
if WM_OT_vrma_export_prerequisite.detect_errors(
context, self.armature_object_name
):
return {"CANCELLED"}
if not self.filepath:
return {"CANCELLED"}
if not self.armature_object_name:
armature = search.current_armature(context)
else:
armature = context.blend_data.objects.get(self.armature_object_name)
if not armature:
return {"CANCELLED"}
return UniVrmVrmAnimationExporter.execute(
context, Path(self.filepath), armature
)
except Exception:
show_error_dialog(
pgettext("Failed to export VRM Animation."),
traceback.format_exc(),
)
raise
def invoke(self, context: Context, event: Event) -> set[str]:
if WM_OT_vrma_export_prerequisite.detect_errors(
context, self.armature_object_name
):
return ops.wm.vrma_export_prerequisite(
"INVOKE_DEFAULT",
armature_object_name=self.armature_object_name,
)
return ExportHelper.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]
armature_object_name: str # type: ignore[no-redef]
class WM_OT_vrm_export_human_bones_assignment(Operator):
bl_label = "Required VRM Human Bones Assignment"
bl_idname = "wm.vrm_export_human_bones_assignment"
bl_options: AbstractSet[str] = {"REGISTER"}
armature_object_name: StringProperty( # type: ignore[valid-type]
options={"HIDDEN"},
)
def execute(self, context: Context) -> set[str]:
preferences = get_preferences(context)
armatures, _ = collect_export_objects(
context,
self.armature_object_name,
preferences,
)
if len(armatures) != 1:
return {"CANCELLED"}
armature = armatures[0]
armature_data = armature.data
if not isinstance(armature_data, Armature):
return {"CANCELLED"}
if get_armature_extension(armature_data).is_vrm0():
Vrm0HumanoidPropertyGroup.fixup_human_bones(armature)
Vrm0HumanoidPropertyGroup.update_all_node_candidates(
context, armature_data.name
)
humanoid = get_armature_extension(armature_data).vrm0.humanoid
if not humanoid.all_required_bones_are_assigned():
return {"CANCELLED"}
elif get_armature_extension(armature_data).is_vrm1():
Vrm1HumanBonesPropertyGroup.fixup_human_bones(armature)
Vrm1HumanBonesPropertyGroup.update_all_node_candidates(
context, armature_data.name
)
human_bones = get_armature_extension(
armature_data
).vrm1.humanoid.human_bones
if (
not human_bones.all_required_bones_are_assigned()
and not human_bones.allow_non_humanoid_rig
):
return {"CANCELLED"}
else:
return {"CANCELLED"}
return ops.export_scene.vrm(
"INVOKE_DEFAULT", armature_object_name=self.armature_object_name
)
def invoke(self, context: Context, _event: Event) -> set[str]:
return context.window_manager.invoke_props_dialog(self, width=800)
def draw(self, context: Context) -> None:
preferences = get_preferences(context)
armatures, _ = collect_export_objects(
context, self.armature_object_name, preferences
)
if not armatures:
return
armature = armatures[0]
armature_data = armature.data
if not isinstance(armature_data, Armature):
return
if get_armature_extension(armature_data).is_vrm0():
WM_OT_vrm_export_human_bones_assignment.draw_vrm0(self.layout, armature)
elif get_armature_extension(armature_data).is_vrm1():
WM_OT_vrm_export_human_bones_assignment.draw_vrm1(self.layout, armature)
@staticmethod
def draw_vrm0(layout: UILayout, armature: Object) -> None:
armature_data = armature.data
if not isinstance(armature_data, Armature):
return
humanoid = get_armature_extension(armature_data).vrm0.humanoid
if humanoid.all_required_bones_are_assigned():
alert_box = layout.box()
alert_box.label(
text="All Required VRM Human Bones have been assigned.",
icon="CHECKMARK",
)
else:
alert_box = layout.box()
alert_box.alert = True
alert_box.label(
text="There are unassigned Required VRM Human Bones."
+ " Please assign all.",
icon="ERROR",
)
draw_vrm0_humanoid_operators_layout(armature, layout)
row = layout.split(factor=0.5)
draw_vrm0_humanoid_required_bones_layout(armature, row.column())
draw_vrm0_humanoid_optional_bones_layout(armature, row.column())
@staticmethod
def draw_vrm1(layout: UILayout, armature: Object) -> None:
armature_data = armature.data
if not isinstance(armature_data, Armature):
return
human_bones = get_armature_extension(armature_data).vrm1.humanoid.human_bones
if human_bones.all_required_bones_are_assigned():
alert_box = layout.box()
alert_box.label(
text="All Required VRM Human Bones have been assigned.",
icon="CHECKMARK",
)
elif human_bones.allow_non_humanoid_rig:
alert_box = layout.box()
alert_box.label(
text="This armature will be exported but not as a humanoid."
+ " It cannot have animations applied"
+ " for humanoid avatars.",
icon="CHECKMARK",
)
else:
alert_box = layout.box()
alert_box.alert = True
alert_column = alert_box.column(align=True)
for error_message in human_bones.error_messages():
alert_column.label(text=error_message, translate=False, icon="ERROR")
layout_operator(
layout,
VRM_OT_assign_vrm1_humanoid_human_bones_automatically,
icon="ARMATURE_DATA",
).armature_object_name = armature.name
row = layout.split(factor=0.5)
draw_vrm1_humanoid_required_bones_layout(armature, row.column())
draw_vrm1_humanoid_optional_bones_layout(armature, row.column())
non_humanoid_export_column = layout.column()
non_humanoid_export_column.prop(human_bones, "allow_non_humanoid_rig")
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]
class WM_OT_vrm_export_confirmation(Operator):
bl_label = "VRM Export Confirmation"
bl_idname = "wm.vrm_export_confirmation"
bl_options: AbstractSet[str] = {"REGISTER"}
errors: CollectionProperty(type=validation.VrmValidationError) # type: ignore[valid-type]
armature_object_name: StringProperty( # type: ignore[valid-type]
options={"HIDDEN"},
)
export_anyway: BoolProperty( # type: ignore[valid-type]
name="Export Anyway",
)
def execute(self, _context: Context) -> set[str]:
if not self.export_anyway:
return {"CANCELLED"}
ops.export_scene.vrm(
"INVOKE_DEFAULT",
ignore_warning=True,
armature_object_name=self.armature_object_name,
)
return {"FINISHED"}
def invoke(self, context: Context, _event: Event) -> set[str]:
validation.WM_OT_vrm_validator.detect_errors(
context,
self.errors,
self.armature_object_name,
)
return context.window_manager.invoke_props_dialog(self, width=800)
def draw(self, _context: Context) -> None:
layout = self.layout
layout.label(
text="There is a high-impact warning. VRM may not export as intended.",
icon="ERROR",
)
column = layout.column()
for error in self.errors:
if error.severity != 1:
continue
column.prop(
error,
"message",
text="",
translate=False,
)
layout.prop(self, "export_anyway")
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
errors: CollectionPropertyProtocol[ # type: ignore[no-redef]
VrmValidationError
]
armature_object_name: str # type: ignore[no-redef]
export_anyway: bool # type: ignore[no-redef]
class WM_OT_vrm_export_armature_selection(Operator):
bl_label = "VRM Export Armature Selection"
bl_idname = "wm.vrm_export_armature_selection"
bl_options: AbstractSet[str] = {"REGISTER"}
armature_object_name: StringProperty( # type: ignore[valid-type]
options={"HIDDEN"},
)
armature_object_name_candidates: CollectionProperty( # type: ignore[valid-type]
type=StringPropertyGroup,
options={"HIDDEN"},
)
def execute(self, context: Context) -> set[str]:
if not self.armature_object_name:
return {"CANCELLED"}
armature_object = context.blend_data.objects.get(self.armature_object_name)
if not armature_object or armature_object.type != "ARMATURE":
return {"CANCELLED"}
ops.export_scene.vrm(
"INVOKE_DEFAULT", armature_object_name=self.armature_object_name
)
return {"FINISHED"}
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=600)
def draw(self, _context: Context) -> None:
layout = self.layout
layout.label(
text="Multiple armatures were found; please select one to export as VRM.",
icon="ERROR",
)
layout.prop_search(
self,
"armature_object_name",
self,
"armature_object_name_candidates",
icon="OUTLINER_OB_ARMATURE",
text="",
translate=False,
)
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
]
class WM_OT_vrma_export_prerequisite(Operator):
bl_label = "VRM Animation Export Prerequisite"
bl_idname = "wm.vrma_export_prerequisite"
bl_options: AbstractSet[str] = {"REGISTER"}
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:
error_messages.append(pgettext("Armature not found"))
return error_messages
armature_data = armature.data
if not isinstance(armature_data, Armature):
error_messages.append(pgettext("Armature not found"))
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.export_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 export requires a VRM 1.0 armature",
icon="INFO",
)
error_messages = WM_OT_vrma_export_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 exported",
)
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)
if not self.armature_object_name:
armature = search.current_armature(context)
else:
armature = context.blend_data.objects.get(self.armature_object_name)
if armature:
armature_data = armature.data
if isinstance(armature_data, Armature):
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():
WM_OT_vrm_export_human_bones_assignment.draw_vrm1(
self.layout, armature
)
draw_help_message(layout)
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
]
def draw_help_message(layout: UILayout) -> None:
help_message = pgettext(
"Animations to be exported\n"
+ "- Humanoid bone rotations\n"
+ "- Humanoid hips bone translations\n"
+ "- Expression preview value\n"
+ "- Look At preview target translation\n"
)
help_box = layout.box()
help_column = help_box.column(align=True)
for index, help_line in enumerate(help_message.splitlines()):
help_column.label(
text=help_line,
translate=False,
icon="NONE" if index else "INFO",
)
open_op = layout_operator(
help_column,
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/")
def collect_export_objects(
context: Context,
armature_object_name: str,
export_preferences: ExportPreferencesProtocol,
) -> tuple[list[Object], list[Object]]:
export_objects = search.export_objects(
context,
armature_object_name,
export_invisibles=export_preferences.export_invisibles,
export_only_selections=export_preferences.export_only_selections,
export_lights=export_preferences.enable_advanced_preferences
and export_preferences.export_lights,
)
armature_objects = [obj for obj in export_objects if obj.type == "ARMATURE"]
return armature_objects, export_objects

View File

@@ -0,0 +1,33 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import bpy
from .abstract_base_vrm_exporter import AbstractBaseVrmExporter
class glTF2ExportUserExtension:
def __init__(self) -> None:
context = bpy.context
self.object_name_to_modifier_names = (
AbstractBaseVrmExporter.enter_hide_mtoon1_outline_geometry_nodes(context)
)
# 3 arguments in Blender 2.93.0
# https://github.com/KhronosGroup/glTF-Blender-IO/blob/709630548cdc184af6ea50b2ff3ddc5450bc0af3/addons/io_scene_gltf2/blender/exp/gltf2_blender_export.py#L68
# 5 arguments in Blender 3.3.0
# https://github.com/KhronosGroup/glTF-Blender-IO/blob/92061fa5b8058c8dff964c9bf177e079926b9671/addons/io_scene_gltf2/blender/exp/gltf2_blender_export.py#L85
def gather_gltf_hook(
self,
# The number of arguments and specifications vary widely from version to version
# of the glTF 2.0 add-on.
_arg1: object,
_arg2: object,
_arg3: object = None,
_arg4: object = None,
) -> None:
context = bpy.context
AbstractBaseVrmExporter.exit_hide_mtoon1_outline_geometry_nodes(
context, self.object_name_to_modifier_names
)
self.object_name_to_modifier_names.clear()

File diff suppressed because it is too large Load Diff

3911
exporter/vrm0_exporter.py Normal file

File diff suppressed because it is too large Load Diff

3313
exporter/vrm1_exporter.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff