Files
blender-vrm/importer/abstract_base_vrm_importer.py
小煜 091ad6a49a 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
2026-01-01 14:21:56 +08:00

1436 lines
58 KiB
Python

# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
# SPDX-FileCopyrightText: 2018 iCyP
import base64
import contextlib
import functools
import math
import operator
import os
import re
import secrets
import shutil
import struct
import tempfile
from abc import ABC, abstractmethod
from collections.abc import Iterator, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import bpy
from bpy.path import clean_name
from bpy.types import (
Armature,
Context,
Image,
Material,
Mesh,
Object,
SpaceView3D,
)
from mathutils import Euler, Matrix
from ..common import safe_removal, shader
from ..common.convert import Json
from ..common.deep import make_json
from ..common.fs import (
create_unique_indexed_directory_path,
create_unique_indexed_file_path,
)
from ..common.gl import GL_FLOAT, GL_LINEAR, GL_REPEAT, GL_UNSIGNED_SHORT
from ..common.gltf import FLOAT_NEGATIVE_MAX, FLOAT_POSITIVE_MAX, pack_glb, parse_glb
from ..common.logger import get_logger
from ..common.preferences import ImportPreferencesProtocol
from ..common.progress import PartialProgress, create_progress
from ..common.rotation import (
get_rotation_as_quaternion,
set_rotation_without_mode_change,
)
from ..common.workspace import save_workspace
from ..editor.extension import get_armature_extension
from ..external.io_scene_gltf2_support import (
ImportSceneGltfArguments,
import_scene_gltf,
)
from .gltf2_import_user_extension import glTF2ImportUserExtension
from .license_validation import validate_license
logger = get_logger(__name__)
@dataclass(frozen=True)
class ParseResult:
filepath: Path
json_dict: Mapping[str, Json]
spec_version_number: tuple[int, int]
spec_version_str: str
spec_version_is_stable: bool
vrm0_extension_dict: Mapping[str, Json]
vrm1_extension_dict: Mapping[str, Json]
hips_node_index: Optional[int]
class AbstractBaseVrmImporter(ABC):
def __init__(
self,
context: Context,
parse_result: ParseResult,
preferences: ImportPreferencesProtocol,
) -> None:
self.context = context
self.parse_result = parse_result
self.preferences = preferences
self.meshes: dict[int, Object] = {}
self.images: dict[int, Image] = {}
self.armature: Optional[Object] = None
self.bone_names: dict[int, str] = {}
self.materials: dict[int, Material] = {}
self.bone_child_object_world_matrices: dict[str, Matrix] = {}
self.import_id = glTF2ImportUserExtension.update_current_import_id()
self.temp_object_name_count = 0
self.object_names: dict[int, str] = {}
self.mesh_object_names: dict[int, str] = {}
self.imported_object_names: Optional[list[str]] = None
@abstractmethod
def load_materials(self, progress: PartialProgress) -> None:
pass
@abstractmethod
def load_gltf_extensions(self) -> None:
pass
@abstractmethod
def find_vrm_bone_node_indices(self) -> list[int]:
pass
def import_vrm(self) -> None:
try:
with create_progress(self.context) as progress:
with save_workspace(self.context):
progress.update(0.1)
self.import_gltf2_with_indices()
progress.update(0.3)
self.use_fake_user_for_thumbnail()
progress.update(0.4)
if (
self.parse_result.vrm1_extension_dict
or self.parse_result.vrm0_extension_dict
):
self.load_materials(progress.partial_progress(0.9))
if (
self.parse_result.vrm1_extension_dict
or self.parse_result.vrm0_extension_dict
):
self.load_gltf_extensions()
progress.update(0.92)
self.setup_viewport()
progress.update(0.94)
self.context.view_layer.update()
progress.update(0.96)
# Texture extraction occurs. During this process, .blend file saving
# may occur and save callbacks may run, so be careful not to apply
# those callbacks to incompletely imported VRM data
if self.preferences.extract_textures_into_folder:
self.extract_textures(repack=False)
elif bpy.app.version < (3, 1):
self.extract_textures(repack=True)
else:
self.assign_packed_image_filepaths()
self.save_t_pose_action()
progress.update(0.97)
self.setup_object_selection_and_activation()
progress.update(0.98)
finally:
glTF2ImportUserExtension.clear_current_import_id()
@property
def armature_data(self) -> Armature:
if not self.armature:
message = "armature is not set"
raise AssertionError(message)
armature_data = self.armature.data
if not isinstance(armature_data, Armature):
message = f"{type(armature_data)} is not an Armature"
raise TypeError(message)
return armature_data
@staticmethod
def enter_save_bone_child_object_transforms(
context: Context, armature_object: Object
) -> Optional[Mapping[str, Matrix]]:
armature_data = armature_object.data
if not isinstance(armature_data, Armature):
return None
# Save the world matrices of child objects of bones before editing
context.view_layer.update()
bone_child_object_world_matrices: dict[str, Matrix] = {}
for obj in context.blend_data.objects:
if (
obj.parent_type == "BONE"
and obj.parent == armature_object
and obj.parent_bone in armature_data.bones
):
bone_child_object_world_matrices[obj.name] = obj.matrix_world.copy()
return bone_child_object_world_matrices
@staticmethod
def leave_save_bone_child_object_transforms(
context: Context,
armature_object: Object,
bone_child_object_world_matrices: Mapping[str, Matrix],
) -> None:
armature_data = armature_object.data
if not isinstance(armature_data, Armature):
message = f"{type(armature_data)} is not an Armature"
raise TypeError(message)
# Restore the world matrices of child objects of bones before editing
context.view_layer.update()
for name, matrix_world in bone_child_object_world_matrices.items():
restore_obj = context.blend_data.objects.get(name)
if (
restore_obj
and restore_obj.parent_type == "BONE"
and restore_obj.parent == armature_object
and restore_obj.parent_bone in armature_data.bones
):
restore_obj.matrix_world = matrix_world.copy()
context.view_layer.update()
@staticmethod
@contextlib.contextmanager
def save_bone_child_object_transforms(
context: Context, armature: Object
) -> Iterator[Armature]:
bone_child_object_world_matrices = (
AbstractBaseVrmImporter.enter_save_bone_child_object_transforms(
context, armature
)
)
try:
with save_workspace(context, armature, mode="EDIT"):
armature_data = armature.data
if not isinstance(armature_data, Armature):
message = f"{type(armature_data)} is not an Armature"
raise TypeError(message)
yield armature_data
# After yield, bpy native objects may be deleted or frames may advance
# making them invalid. Accessing them in this state can cause crashes,
# so be careful not to access such native objects after yield
finally:
if bone_child_object_world_matrices is not None:
AbstractBaseVrmImporter.leave_save_bone_child_object_transforms(
context, armature, bone_child_object_world_matrices
)
def use_fake_user_for_thumbnail(self) -> None:
# The thumbnail is specified as an image index in the VRM specification,
# but in UniVRM's implementation it's a texture index
# https://github.com/vrm-c/UniVRM/blob/v0.67.0/Assets/VRM/Runtime/IO/VRMImporterself.context.cs#L308
meta_dict = self.parse_result.vrm0_extension_dict.get("meta")
if not isinstance(meta_dict, dict):
return
thumbnail_texture_index = meta_dict.get("texture")
if not isinstance(thumbnail_texture_index, int):
return
texture_dicts = self.parse_result.json_dict.get("textures", [])
if not isinstance(texture_dicts, list):
logger.warning('json["textures"] is not list')
return
if not (0 <= thumbnail_texture_index < len(texture_dicts)):
return
thumbnail_texture_dict = texture_dicts[thumbnail_texture_index]
if not isinstance(thumbnail_texture_dict, dict):
return
thumbnail_image_index = thumbnail_texture_dict.get("source")
if not isinstance(thumbnail_image_index, int):
return
thumbnail_image = self.images.get(thumbnail_image_index)
if not thumbnail_image:
return
thumbnail_image.use_fake_user = True
@staticmethod
def reset_material(material: Material) -> None:
if bpy.app.version < (5, 0, 0) and not material.use_nodes:
material.use_nodes = True
shader.clear_node_tree(material.node_tree)
if material.alpha_threshold != 0.5:
material.alpha_threshold = 0.5
if material.blend_method != "OPAQUE":
material.blend_method = "OPAQUE"
if bpy.app.version < (4, 3) and material.shadow_method != "OPAQUE":
material.shadow_method = "OPAQUE"
if material.use_backface_culling:
material.use_backface_culling = False
if material.show_transparent_back:
material.show_transparent_back = False
node_tree = material.node_tree
if node_tree:
node_tree.nodes.new("ShaderNodeOutputMaterial")
else:
logger.error("No node tree for material %s", material.name)
def assign_packed_image_filepaths(self) -> None:
# Assign image filepath for fbx export
for image in self.images.values():
if image.packed_file is None:
continue
if image.filepath:
continue
image_name = Path(image.filepath_from_user()).stem
if not image_name:
image_name = clean_name(image.name)
image_type = image.file_format.lower()
if bpy.app.version >= (3, 4):
image.filepath_raw = f"//textures{os.sep}{image_name}.{image_type}"
else:
image.filepath_raw = f"//{image_name}.{image_type}"
def extract_textures(self, *, repack: bool) -> None:
"""Extract textures to a folder as files.
In Blender 3.1 and later, texture extraction requires saving the .blend file.
Therefore, the file is saved, but be careful that file save callbacks may run
during this process.
"""
dir_path = self.parse_result.filepath.with_suffix(".vrm.textures").absolute()
if self.preferences.make_new_texture_folder or repack:
dir_path = create_unique_indexed_directory_path(dir_path)
dir_path.mkdir(parents=True, exist_ok=True)
if bpy.app.version >= (3, 1) and not self.context.blend_data.filepath:
temp_blend_path = None
for _ in range(10000):
image_suffix = (
".temp"
+ "".join(str(secrets.randbelow(10)) for _ in range(10))
+ ".blend"
)
temp_blend_path = self.parse_result.filepath.with_suffix(image_suffix)
if not temp_blend_path.exists():
break
if temp_blend_path is not None:
bpy.ops.wm.save_as_mainfile(filepath=str(temp_blend_path))
for image_index, image in self.images.items():
image_original_file_path = Path(image.filepath_from_user())
image_path_stem = image_original_file_path.stem
if image_path_stem:
legacy_image_name_prefix = self.import_id + "Image"
if image_path_stem.startswith(legacy_image_name_prefix):
image_path_stem = re.sub(
r"^\d+_",
"",
image_path_stem[len(legacy_image_name_prefix) :],
)
if not image_path_stem:
image_path_stem = image.name
if not image_path_stem:
image_path_stem = f"Image_{image_index}"
image_path_stem = clean_name(image_path_stem[:100])
original_image_path_suffix = image_original_file_path.suffix
detected_image_path_suffix = "." + image.file_format.lower()
if original_image_path_suffix.lower() != detected_image_path_suffix and (
original_image_path_suffix.lower(),
detected_image_path_suffix,
) != (".jpg", ".jpeg"):
image_path_suffix = detected_image_path_suffix
else:
image_path_suffix = original_image_path_suffix
image_path = dir_path / (image_path_stem + image_path_suffix)
try:
image.unpack(method="WRITE_ORIGINAL")
except RuntimeError:
logger.exception("Failed to unpack %s", image.name)
continue
image_unpacked_path_str = image.filepath_from_user()
if not image_unpacked_path_str:
continue
image_unpacked_file_path = Path(image_unpacked_path_str)
if not image_unpacked_file_path.exists():
continue
image_bytes = image_unpacked_file_path.read_bytes()
with contextlib.suppress(OSError):
image_unpacked_file_path.unlink()
image_path = create_unique_indexed_file_path(image_path, image_bytes)
if image.filepath != str(image_path):
image.filepath = str(image_path)
image.reload()
if repack:
image.pack()
if repack:
shutil.rmtree(dir_path, ignore_errors=True)
# Be careful not to let bones proliferate by repeating VRM re-import.
# Things to pay special attention to:
# - Root bones
# - Bones that have meshes parented to them
def find_retain_node_indices(self, scene_dict: dict[str, Json]) -> list[int]:
scene_node_index_jsons = scene_dict.get("nodes")
if not isinstance(scene_node_index_jsons, list):
return []
scene_node_indices = [
index for index in scene_node_index_jsons if isinstance(index, int)
]
json_dict = self.parse_result.json_dict
node_dict_jsons = json_dict.get("nodes")
if not isinstance(node_dict_jsons, list):
return []
node_dicts = [
node_dict for node_dict in node_dict_jsons if isinstance(node_dict, dict)
]
skin_dict_jsons = json_dict.get("skins")
if not isinstance(skin_dict_jsons, list):
skin_dict_jsons = []
skin_dicts = [
skin_dict for skin_dict in skin_dict_jsons if isinstance(skin_dict, dict)
]
bone_node_indices = self.find_vrm_bone_node_indices()
# Collect all nodes in the scene node tree where the hips bone exists.
# Also treat the root node of that tree as a bone.
all_scene_node_indices: list[int] = []
hips_found = False
for scene_node_index in scene_node_indices:
all_scene_node_indices.clear()
search_scene_node_indices = [scene_node_index]
while search_scene_node_indices:
search_scene_node_index = search_scene_node_indices.pop()
if search_scene_node_index == self.parse_result.hips_node_index:
bone_node_indices.append(scene_node_index)
hips_found = True
if not 0 <= search_scene_node_index < len(node_dicts):
continue
node_dict = node_dicts[search_scene_node_index]
all_scene_node_indices.append(search_scene_node_index)
child_indices = node_dict.get("children")
if not isinstance(child_indices, list):
continue
for child_index in child_indices:
if not isinstance(child_index, int):
continue
if child_index in all_scene_node_indices:
# Avoid recursive nodes
continue
search_scene_node_indices.append(child_index)
if hips_found:
break
if not hips_found:
return []
all_scene_node_indices = list(dict.fromkeys(all_scene_node_indices)) # Distinct
# Also treat indices registered in skin as bones
for node_index in all_scene_node_indices:
if not 0 <= node_index < len(node_dicts):
continue
node_dict = node_dicts[node_index]
skin_index = node_dict.get("skin")
if not isinstance(skin_index, int) or not 0 <= skin_index < len(skin_dicts):
continue
skin_dict = skin_dicts[skin_index]
skeleton_index = skin_dict.get("skeleton")
if isinstance(skeleton_index, int):
bone_node_indices.append(skeleton_index)
joint_indices = skin_dict.get("joints")
if isinstance(joint_indices, list):
for joint_index in joint_indices:
if isinstance(joint_index, int):
bone_node_indices.append(joint_index)
# Remove bone indices that are not in the scene node index
for bone_node_index in list(bone_node_indices):
if bone_node_index not in all_scene_node_indices:
bone_node_indices.remove(bone_node_index)
# Add children from currently found bone nodes until hitting mesh nodes
search_bone_node_indices = list(bone_node_indices)
while search_bone_node_indices:
search_bone_node_index = search_bone_node_indices.pop()
if not 0 <= search_bone_node_index < len(node_dicts):
continue
node_dict = node_dicts[search_bone_node_index]
if isinstance(node_dict.get("mesh"), int):
continue
bone_node_indices.append(search_bone_node_index)
child_indices = node_dict.get("children")
if not isinstance(child_indices, list):
continue
for child_index in child_indices:
if not isinstance(child_index, int):
continue
if child_index in bone_node_indices:
continue
search_bone_node_indices.append(child_index)
# If a mesh node has bone nodes as children,
# treat that mesh node as a bone too
bone_node_indices.extend(
functools.reduce(
operator.iconcat,
[
self.find_middle_bone_indices(
node_dicts, bone_node_indices, bone_node_index, []
)
for bone_node_index in bone_node_indices
],
list[int](),
)
)
return list(dict.fromkeys(bone_node_indices)) # Distinct
def find_middle_bone_indices(
self,
node_dicts: list[dict[str, Json]],
bone_node_indices: list[int],
bone_node_index: int,
middle_bone_node_indices: list[int],
) -> list[int]:
if not 0 <= bone_node_index < len(node_dicts):
return []
node_dict = node_dicts[bone_node_index]
child_indices = node_dict.get("children")
if not isinstance(child_indices, list):
return []
result: list[int] = []
for child_index in child_indices:
if not isinstance(child_index, int):
continue
if not 0 <= child_index < len(node_dicts):
continue
if child_index in bone_node_indices:
result.extend(middle_bone_node_indices)
continue
result.extend(
self.find_middle_bone_indices(
node_dicts,
bone_node_indices,
child_index,
[*middle_bone_node_indices, bone_node_index],
)
)
return result
def import_gltf2_with_indices(self) -> None:
json_dict, buffer0_bytes = parse_glb(self.parse_result.filepath.read_bytes())
for key in ["nodes", "materials", "meshes"]:
if key not in json_dict or not isinstance(json_dict[key], list):
continue
value_dicts = json_dict.get(key)
if not isinstance(value_dicts, list):
continue
for index, value_dict in enumerate(value_dicts):
if not isinstance(value_dict, dict):
continue
extras_dict = value_dict.get("extras")
if not isinstance(extras_dict, dict):
extras_dict = {}
value_dict["extras"] = extras_dict
extras_dict.update({self.import_id + key.capitalize(): index})
mesh_index = value_dict.get("mesh")
if key == "nodes" and isinstance(mesh_index, int):
extras_dict.update({self.import_id + "Meshes": mesh_index})
legacy_image_name_prefix = self.import_id + "Image"
image_dicts = json_dict.get("images")
if isinstance(image_dicts, list):
for image_index, image_dict in enumerate(image_dicts):
texture_dicts = json_dict.get("textures")
if not isinstance(texture_dicts, list) or not [
True
for texture_dict in texture_dicts
if isinstance(texture_dict, dict)
and texture_dict.get("source") == image_index
]:
sampler_dicts = json_dict.get("samplers")
if not isinstance(sampler_dicts, list):
sampler_dicts = []
json_dict["samplers"] = sampler_dicts
sampler_index = len(sampler_dicts)
sampler_dicts.append(
{
"magFilter": GL_LINEAR,
"minFilter": GL_LINEAR,
"wrapS": GL_REPEAT,
"wrapT": GL_REPEAT,
}
)
if not isinstance(texture_dicts, list):
texture_dicts = []
json_dict["textures"] = texture_dicts
texture_dicts.append(
{
"sampler": sampler_index,
"source": image_index,
}
)
if bpy.app.version < (3, 1):
if not isinstance(image_dict, dict):
continue
name = image_dict.get("name")
if not isinstance(name, str) or not name:
# https://github.com/KhronosGroup/glTF-Blender-IO/blob/709630548cdc184af6ea50b2ff3ddc5450bc0af3/addons/io_scene_gltf2/blender/imp/gltf2_blender_image.py#L54
name = f"Image_{image_index}"
image_dict["name"] = (
legacy_image_name_prefix + str(image_index) + "_" + name
)
mesh_dicts = json_dict.get("meshes")
if isinstance(mesh_dicts, list):
for mesh_dict in mesh_dicts:
if not isinstance(mesh_dict, dict):
continue
mesh_extras_dict = mesh_dict.get("extras")
if not isinstance(mesh_extras_dict, dict):
mesh_extras_dict = {}
mesh_dict["extras"] = mesh_extras_dict
mesh_target_names = mesh_extras_dict.get("targetNames")
if isinstance(mesh_target_names, list):
continue
primitive_dicts = mesh_dict.get("primitives")
if not isinstance(primitive_dicts, list):
continue
for primitive_dict in primitive_dicts:
if not isinstance(primitive_dict, dict):
continue
primitive_extras_dict = primitive_dict.get("extras")
if not isinstance(primitive_extras_dict, dict):
continue
primitive_target_names = primitive_extras_dict.get("targetNames")
if not isinstance(primitive_target_names, list):
continue
mesh_extras_dict["targetNames"] = primitive_target_names
break
texture_dicts = json_dict.get("textures")
if isinstance(texture_dicts, list) and texture_dicts:
primitive_dicts = list[Json]()
for texture_index, _ in enumerate(texture_dicts):
buffer_dicts = json_dict.get("buffers")
if not isinstance(buffer_dicts, list):
buffer_dicts = []
json_dict["buffers"] = buffer_dicts
position_buffer_index = len(buffer_dicts)
position_buffer_bytes = struct.pack(
"<9f", 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0
)
buffer_dicts.append(
{
"uri": "data:application/gltf-buffer;base64,"
+ base64.b64encode(position_buffer_bytes).decode("ascii"),
"byteLength": len(position_buffer_bytes),
}
)
texcoord_buffer_index = len(buffer_dicts)
texcoord_buffer_bytes = struct.pack("<6f", 0.0, 0.0, 1.0, 0.0, 0.0, 1.0)
buffer_dicts.append(
{
"uri": "data:application/gltf-buffer;base64,"
+ base64.b64encode(texcoord_buffer_bytes).decode("ascii"),
"byteLength": len(texcoord_buffer_bytes),
}
)
buffer_view_dicts = json_dict.get("bufferViews")
if not isinstance(buffer_view_dicts, list):
buffer_view_dicts = []
json_dict["bufferViews"] = buffer_view_dicts
position_buffer_view_index = len(buffer_view_dicts)
buffer_view_dicts.append(
{
"buffer": position_buffer_index,
"byteLength": len(position_buffer_bytes),
}
)
texcoord_buffer_view_index = len(buffer_view_dicts)
buffer_view_dicts.append(
{
"buffer": texcoord_buffer_index,
"byteLength": len(texcoord_buffer_bytes),
}
)
accessor_dicts = json_dict.get("accessors")
if not isinstance(accessor_dicts, list):
accessor_dicts = []
json_dict["accessors"] = accessor_dicts
position_accessors_index = len(accessor_dicts)
accessor_dicts.append(
{
"bufferView": position_buffer_view_index,
"type": "VEC3",
"componentType": GL_FLOAT,
"count": 3,
"min": [0, 0, 0],
"max": [1, 1, 0],
}
)
texcoord_accessors_index = len(accessor_dicts)
accessor_dicts.append(
{
"bufferView": texcoord_buffer_view_index,
"type": "VEC2",
"componentType": GL_FLOAT,
"count": 3,
}
)
material_dicts = json_dict.get("materials")
if not isinstance(material_dicts, list):
material_dicts = []
json_dict["materials"] = material_dicts
tex_material_index = len(material_dicts)
material_dicts.append(
{
"name": self.temp_object_name(),
"emissiveTexture": {"index": texture_index},
}
)
primitive_dicts.append(
{
"attributes": {
"POSITION": position_accessors_index,
"TEXCOORD_0": texcoord_accessors_index,
},
"material": tex_material_index,
}
)
mesh_dicts = json_dict.get("meshes")
if not isinstance(mesh_dicts, list):
mesh_dicts = []
json_dict["meshes"] = mesh_dicts
tex_mesh_index = len(mesh_dicts)
mesh_dicts.append(
{"name": self.temp_object_name(), "primitives": primitive_dicts}
)
node_dicts = json_dict.get("nodes")
if not isinstance(node_dicts, list):
node_dicts = []
json_dict["nodes"] = node_dicts
tex_node_index = len(node_dicts)
node_dicts.append({"name": self.temp_object_name(), "mesh": tex_mesh_index})
scene_dicts = json_dict.get("scenes")
if not isinstance(scene_dicts, list):
scene_dicts = []
json_dict["scenes"] = scene_dicts
scene_dicts.append(
{"name": self.temp_object_name(), "nodes": [tex_node_index]}
)
scene_dicts = json_dict.get("scenes")
node_dicts = json_dict.get("nodes")
if isinstance(scene_dicts, list) and isinstance(node_dicts, list):
for scene_dict in scene_dicts:
if not isinstance(scene_dict, dict):
continue
retain_node_indices = self.find_retain_node_indices(scene_dict)
if not retain_node_indices:
continue
buffer_dicts = json_dict.get("buffers")
if not isinstance(buffer_dicts, list):
buffer_dicts = []
json_dict["buffers"] = buffer_dicts
position_buffer_index = len(buffer_dicts)
position_buffer_bytes = struct.pack(
"<9f", 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0
)
buffer_dicts.append(
{
"uri": "data:application/gltf-buffer;base64,"
+ base64.b64encode(position_buffer_bytes).decode("ascii"),
"byteLength": len(position_buffer_bytes),
}
)
joints_buffer_index = len(buffer_dicts)
joints_buffer_bytes = struct.pack(
"<12H", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
)
buffer_dicts.append(
{
"uri": "data:application/gltf-buffer;base64,"
+ base64.b64encode(joints_buffer_bytes).decode("ascii"),
"byteLength": len(joints_buffer_bytes),
}
)
weights_buffer_index = len(buffer_dicts)
weights_buffer_bytes = struct.pack(
"<12f", 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0
)
buffer_dicts.append(
{
"uri": "data:application/gltf-buffer;base64,"
+ base64.b64encode(weights_buffer_bytes).decode("ascii"),
"byteLength": len(weights_buffer_bytes),
}
)
buffer_view_dicts = json_dict.get("bufferViews")
if not isinstance(buffer_view_dicts, list):
buffer_view_dicts = []
json_dict["bufferViews"] = buffer_view_dicts
position_buffer_view_index = len(buffer_view_dicts)
buffer_view_dicts.append(
{
"buffer": position_buffer_index,
"byteLength": len(position_buffer_bytes),
}
)
joints_buffer_view_index = len(buffer_view_dicts)
buffer_view_dicts.append(
{
"buffer": joints_buffer_index,
"byteLength": len(joints_buffer_bytes),
}
)
weights_buffer_view_index = len(buffer_view_dicts)
buffer_view_dicts.append(
{
"buffer": weights_buffer_index,
"byteLength": len(weights_buffer_bytes),
}
)
accessor_dicts = json_dict.get("accessors")
if not isinstance(accessor_dicts, list):
accessor_dicts = []
json_dict["accessors"] = accessor_dicts
position_accessors_index = len(accessor_dicts)
accessor_dicts.append(
{
"bufferView": position_buffer_view_index,
"type": "VEC3",
"componentType": GL_FLOAT,
"count": 3,
"min": [0, 0, 0],
"max": [1, 1, 0],
}
)
joints_accessors_index = len(accessor_dicts)
accessor_dicts.append(
{
"bufferView": joints_buffer_view_index,
"type": "VEC4",
"componentType": GL_UNSIGNED_SHORT,
"count": 3,
}
)
weights_accessors_index = len(accessor_dicts)
accessor_dicts.append(
{
"bufferView": weights_buffer_view_index,
"type": "VEC4",
"componentType": GL_FLOAT,
"count": 3,
}
)
primitive_dicts = [
{
"attributes": {
"POSITION": position_accessors_index,
"JOINTS_0": joints_accessors_index,
"WEIGHTS_0": weights_accessors_index,
}
}
]
mesh_dicts = json_dict.get("meshes")
if not isinstance(mesh_dicts, list):
mesh_dicts = []
json_dict["meshes"] = mesh_dicts
skin_mesh_index = len(mesh_dicts)
mesh_dicts.append(
make_json(
{"name": self.temp_object_name(), "primitives": primitive_dicts}
)
)
skin_dicts = json_dict.get("skins")
if not isinstance(skin_dicts, list):
skin_dicts = []
json_dict["skins"] = skin_dicts
skin_index = len(skin_dicts)
skin_dicts.append({"joints": list(retain_node_indices)})
node_dicts = json_dict.get("nodes")
if not isinstance(node_dicts, list):
node_dicts = []
json_dict["nodes"] = node_dicts
skin_node_index = len(node_dicts)
node_dicts.append(
{
"name": self.temp_object_name(),
"mesh": skin_mesh_index,
"skin": skin_index,
}
)
scene_nodes = scene_dict.get("nodes")
if not isinstance(scene_nodes, list):
scene_nodes = []
scene_dict["nodes"] = scene_nodes
scene_nodes.append(skin_node_index)
# Prevent errors when extensions not supported by the glTF 2.0 add-on
# are included in "extensionsRequired"
extensions_required = json_dict.get("extensionsRequired")
if isinstance(extensions_required, list):
for supported_extension in [
"VRM",
"VRMC_vrm",
"VRMC_springBone",
"VRMC_node_constraint",
"VRMC_materials_mtoon",
"VRMC_materials_hdr_emissiveMultiplier",
]:
while supported_extension in extensions_required:
extensions_required.remove(supported_extension)
# Unfortunately such VRMs exist.
accessor_dicts = json_dict.get("accessors")
if isinstance(accessor_dicts, list):
for accessor_dict in accessor_dicts:
if not isinstance(accessor_dict, dict):
continue
max_values = accessor_dict.get("max")
if isinstance(max_values, list):
for i, max_value in enumerate(list(max_values)):
if not isinstance(max_value, (float, int)) or math.isnan(
max_value
):
max_values[i] = FLOAT_POSITIVE_MAX
elif math.isinf(max_value):
max_values[i] = (
FLOAT_POSITIVE_MAX
if max_value > 0
else FLOAT_NEGATIVE_MAX
)
min_values = accessor_dict.get("min")
if isinstance(min_values, list):
for i, min_value in enumerate(list(min_values)):
if not isinstance(min_value, (float, int)) or math.isnan(
min_value
):
min_values[i] = FLOAT_NEGATIVE_MAX
elif math.isinf(min_value):
min_values[i] = (
FLOAT_POSITIVE_MAX
if min_value > 0
else FLOAT_NEGATIVE_MAX
)
if self.parse_result.spec_version_number < (1, 0):
bone_heuristic = "FORTUNE"
else:
bone_heuristic = "BLENDER"
full_vrm_import_success = False
with tempfile.TemporaryDirectory() as temp_dir:
indexed_vrm_filepath = Path(temp_dir, "indexed.vrm")
indexed_vrm_filepath.write_bytes(pack_glb(json_dict, buffer0_bytes))
try:
import_scene_gltf(
ImportSceneGltfArguments(
filepath=str(indexed_vrm_filepath),
import_pack_images=True,
bone_heuristic=bone_heuristic,
guess_original_bind_pose=False,
disable_bone_shape=True,
import_scene_as_collection=False,
)
)
full_vrm_import_success = True
except RuntimeError:
logger.exception(
'Failed to import "%s" generated from "%s" using glTF 2.0 Add-on',
indexed_vrm_filepath,
self.parse_result.filepath,
)
self.cleanup_gltf2_with_indices()
if not full_vrm_import_success:
# Some VRMs have broken animations.
# https://github.com/vrm-c/UniVRM/issues/1522
# https://github.com/saturday06/VRM-Addon-for-Blender/issues/58
json_dict.pop("animations", None)
with tempfile.TemporaryDirectory() as temp_dir:
indexed_vrm_filepath = Path(temp_dir, "indexed.vrm")
indexed_vrm_filepath.write_bytes(pack_glb(json_dict, buffer0_bytes))
try:
import_scene_gltf(
ImportSceneGltfArguments(
filepath=str(indexed_vrm_filepath),
import_pack_images=True,
bone_heuristic=bone_heuristic,
guess_original_bind_pose=False,
disable_bone_shape=True,
import_scene_as_collection=False,
)
)
except RuntimeError:
logger.exception(
'Failed to import "%s" generated from "%s"'
" using glTF 2.0 Add-on without animations key",
indexed_vrm_filepath,
self.parse_result.filepath,
)
self.cleanup_gltf2_with_indices()
raise
# Save the selection state of objects immediately after glTF import
self.imported_object_names = [
selected_object.name for selected_object in self.context.selected_objects
]
extras_node_index_key = self.import_id + "Nodes"
for obj in self.context.selectable_objects:
node_index = obj.pop(extras_node_index_key, None)
if isinstance(node_index, int):
self.object_names[node_index] = obj.name
if isinstance(obj.data, Mesh):
self.mesh_object_names[node_index] = obj.name
data = obj.data
if isinstance(data, Mesh):
data.pop(extras_node_index_key, None)
if not isinstance(data, Armature):
continue
for pose_bone in obj.pose.bones:
pose_bone.pop(extras_node_index_key, None)
for bone_name, bone in data.bones.items():
bone_node_index = bone.pop(extras_node_index_key, None)
if not isinstance(bone_node_index, int):
continue
node_dicts = self.parse_result.json_dict.get("nodes")
if not isinstance(node_dicts, list):
continue
if 0 <= bone_node_index < len(node_dicts):
node_dict = node_dicts[bone_node_index]
if isinstance(node_dict, dict):
node_dict["name"] = bone_name
self.bone_names[bone_node_index] = bone_name
if (
self.armature is not None
or bone_node_index != self.parse_result.hips_node_index
):
continue
vrm0_humanoid = get_armature_extension(data).vrm0.humanoid
vrm1_humanoid = get_armature_extension(data).vrm1.humanoid
if self.parse_result.spec_version_number < (1, 0):
vrm0_humanoid.initial_automatic_bone_assignment = False
else:
vrm1_humanoid.human_bones.initial_automatic_bone_assignment = False
vrm0_humanoid.pose = vrm0_humanoid.POSE_CURRENT_POSE.identifier
vrm1_humanoid.pose = vrm1_humanoid.POSE_CURRENT_POSE.identifier
self.armature = obj
if (
(armature := self.armature) is not None
and isinstance(armature.data, Armature)
and self.parse_result.spec_version_number < (1, 0)
):
bone_name_to_roll: dict[str, float] = {}
with save_workspace(self.context, armature, mode="EDIT"):
for edit_bone in armature.data.edit_bones:
bone_name_to_roll[edit_bone.name] = edit_bone.roll
with save_workspace(self.context, armature):
armature_rotation = get_rotation_as_quaternion(armature)
armature_rotation.rotate(Euler((0.0, 0.0, math.pi), "XYZ"))
set_rotation_without_mode_change(armature, armature_rotation)
bpy.ops.object.select_all(action="DESELECT")
armature.select_set(True)
bpy.ops.object.transform_apply(
location=False, rotation=True, scale=False, properties=False
)
with self.save_bone_child_object_transforms(self.context, armature):
edit_bones = [
edit_bone
for edit_bone in armature.data.edit_bones
if not edit_bone.parent
]
while edit_bones:
edit_bone = edit_bones.pop(0)
roll = bone_name_to_roll.get(edit_bone.name)
if roll is not None:
edit_bone.roll = roll
edit_bones.extend(edit_bone.children)
extras_material_index_key = self.import_id + "Materials"
for material in self.context.blend_data.materials:
if self.is_temp_object_name(material.name):
continue
material_index = material.pop(extras_material_index_key, None)
if isinstance(material_index, int):
self.materials[material_index] = material
extras_mesh_index_key = self.import_id + "Meshes"
for obj in self.context.blend_data.objects:
data = obj.data
if not isinstance(data, Mesh):
continue
data_custom_mesh_index = data.pop(extras_mesh_index_key, None)
obj_custom_mesh_index = obj.pop(extras_mesh_index_key, None)
if isinstance(data_custom_mesh_index, int):
self.meshes[data_custom_mesh_index] = obj
elif isinstance(obj_custom_mesh_index, int):
self.meshes[obj_custom_mesh_index] = obj
for image in list(self.context.blend_data.images):
custom_image_index = image.get(self.import_id)
if not isinstance(custom_image_index, int) and image.name.startswith(
legacy_image_name_prefix
):
custom_image_index_str = "".join(
image.name.split(legacy_image_name_prefix)[1:]
).split("_", maxsplit=1)[0]
with contextlib.suppress(ValueError):
custom_image_index = int(custom_image_index_str)
if not isinstance(custom_image_index, int):
continue
image_dicts = json_dict.get("images")
if isinstance(image_dicts, list) and 0 <= custom_image_index < len(
image_dicts
):
# image.name may be automatically shortened during import, so
# restore it from the json value
image_dict = image_dicts[custom_image_index]
indexed_image_name = None
if isinstance(image_dict, dict):
indexed_image_name = image_dict.get("name")
if isinstance(indexed_image_name, str):
if indexed_image_name.startswith(legacy_image_name_prefix):
indexed_image_name = "_".join(indexed_image_name.split("_")[1:])
else:
indexed_image_name = None
if indexed_image_name:
image.name = indexed_image_name
else:
# https://github.com/KhronosGroup/glTF-Blender-IO/blob/709630548cdc184af6ea50b2ff3ddc5450bc0af3/addons/io_scene_gltf2/blender/imp/gltf2_blender_image.py#L54
image.name = f"Image_{custom_image_index}"
else:
image.name = "_".join(image.name.split("_")[1:])
self.images[custom_image_index] = image
if self.context.object is not None and self.context.object.mode == "EDIT":
bpy.ops.object.mode_set(mode="OBJECT")
for scene in list(self.context.blend_data.scenes):
if not self.is_temp_object_name(scene.name):
continue
if bpy.app.version >= (3, 2) and scene.use_extra_user:
scene.use_extra_user = False
if scene.users:
logger.warning(
'Failed to remove "%s" with %d users while removing temp scenes',
scene.name,
scene.users,
)
else:
self.context.blend_data.scenes.remove(scene)
for obj in list(self.context.blend_data.objects):
if not self.is_temp_object_name(obj.name):
continue
if not safe_removal.remove_object(self.context, obj):
logger.warning(
'Failed to remove "%s" with %d users while removing temp objects',
obj.name,
obj.users,
)
for mesh in list(self.context.blend_data.meshes):
if not self.is_temp_object_name(mesh.name):
continue
if mesh.users:
logger.warning(
'Failed to remove "%s" with %d users while removing temp meshes',
mesh.name,
mesh.users,
)
else:
self.context.blend_data.meshes.remove(mesh)
for material in list(self.context.blend_data.materials):
if not self.is_temp_object_name(material.name):
continue
if material.users:
logger.warning(
'Failed to remove "%s" with %d users while removing temp materials',
material.name,
material.users,
)
else:
self.context.blend_data.materials.remove(material)
if self.armature is None:
logger.warning("Failed to read VRM Humanoid")
def cleanup_gltf2_with_indices(self) -> None:
with save_workspace(self.context):
meshes_key = self.import_id + "Meshes"
nodes_key = self.import_id + "Nodes"
remove_objs: list[Object] = []
for obj in list(self.context.scene.collection.objects):
if isinstance(armature_data := obj.data, Armature):
for bone in armature_data.bones:
if nodes_key in bone:
remove_objs.append(obj)
break
continue
if isinstance(mesh_data := obj.data, Mesh) and (
nodes_key in mesh_data
or meshes_key in mesh_data
or self.is_temp_object_name(mesh_data.name)
):
remove_objs.append(obj)
continue
if (
nodes_key in obj
or meshes_key in obj
or self.is_temp_object_name(obj.name)
):
remove_objs.append(obj)
for obj in remove_objs:
safe_removal.remove_object(self.context, obj)
def temp_object_name(self) -> str:
self.temp_object_name_count += 1
return f"{self.import_id}Temp_{self.temp_object_name_count}_"
def is_temp_object_name(self, name: str) -> bool:
return name.startswith(f"{self.import_id}Temp_")
def setup_viewport(self) -> None:
if self.armature:
if self.preferences.set_armature_display_to_wire:
self.armature.display_type = "WIRE"
if self.preferences.set_armature_display_to_show_in_front:
self.armature.show_in_front = True
if self.preferences.set_armature_bone_shape_to_default:
for bone in self.armature.pose.bones:
bone.custom_shape = None
if self.preferences.set_view_transform_to_standard_on_import:
# https://github.com/saturday06/VRM-Addon-for-Blender/issues/336#issuecomment-1760729404
view_settings = self.context.scene.view_settings
try:
view_settings.view_transform = "Standard"
except TypeError:
logger.exception(
'scene.view_settings.view_transform doesn\'t support "Standard".'
)
if self.preferences.set_shading_type_to_material_on_import:
screen = self.context.screen
for area in screen.areas:
for space in area.spaces:
if space.type == "VIEW_3D" and isinstance(space, SpaceView3D):
space.shading.type = "MATERIAL"
def save_t_pose_action(self) -> None:
if self.armature is None:
return
t_pose_action = self.context.blend_data.actions.new(name="T-Pose")
t_pose_action.use_fake_user = True
animation_data_created = False
if not self.armature.animation_data:
self.armature.animation_data_create()
animation_data_created = True
if not self.armature.animation_data:
message = "armature.animation_data is None"
raise ValueError(message)
original_action = self.armature.animation_data.action
self.armature.animation_data.action = t_pose_action
for bone in self.armature.pose.bones:
for data_path in [
"location",
"rotation_quaternion",
"rotation_axis_angle",
"rotation_euler",
"scale",
]:
bone.keyframe_insert(data_path=data_path, frame=1)
ext = get_armature_extension(self.armature_data)
ext.vrm0.humanoid.pose = ext.vrm0.humanoid.POSE_CUSTOM_POSE.identifier
ext.vrm1.humanoid.pose = ext.vrm1.humanoid.POSE_CUSTOM_POSE.identifier
ext.vrm0.humanoid.pose_library = t_pose_action
ext.vrm1.humanoid.pose_library = t_pose_action
if animation_data_created:
self.armature.animation_data_clear()
else:
self.armature.animation_data.action = original_action
def setup_object_selection_and_activation(self) -> None:
active_object = self.context.view_layer.objects.active
if active_object and active_object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
if self.imported_object_names is not None:
bpy.ops.object.select_all(action="DESELECT")
for obj in self.context.selectable_objects:
if obj.name in self.imported_object_names:
obj.select_set(True)
if self.armature is not None:
self.context.view_layer.objects.active = self.armature
def parse_vrm_json(filepath: Path, *, license_validation: bool) -> ParseResult:
json_dict, _ = parse_glb(filepath.read_bytes())
extensions_dict = json_dict.get("extensions")
if isinstance(extensions_dict, dict):
vrm1_extension_dict = extensions_dict.get("VRMC_vrm")
vrm0_extension_dict = extensions_dict.get("VRM")
else:
vrm1_extension_dict = None
vrm0_extension_dict = None
if isinstance(vrm1_extension_dict, dict):
(
spec_version_number,
spec_version_str,
spec_version_is_stable,
hips_node_index,
) = read_vrm1_extension(vrm1_extension_dict)
vrm0_extension_dict = {}
elif isinstance(vrm0_extension_dict, dict):
(
spec_version_number,
spec_version_str,
spec_version_is_stable,
hips_node_index,
) = read_vrm0_extension(vrm0_extension_dict)
vrm1_extension_dict = {}
else:
spec_version_number = (0, 0)
spec_version_str = "0.0"
spec_version_is_stable = True
hips_node_index = None
vrm0_extension_dict = {}
vrm1_extension_dict = {}
if license_validation:
validate_license(json_dict, spec_version_number)
return ParseResult(
filepath=filepath,
json_dict=json_dict,
spec_version_number=spec_version_number,
spec_version_str=spec_version_str,
spec_version_is_stable=spec_version_is_stable,
vrm0_extension_dict=vrm0_extension_dict,
vrm1_extension_dict=vrm1_extension_dict,
hips_node_index=hips_node_index,
)
def read_vrm0_extension(
vrm0_dict: dict[str, Json],
) -> tuple[tuple[int, int], str, bool, Optional[int]]:
spec_version_number = (0, 0)
spec_version_str = "0.0"
spec_version_is_stable = True
hips_node_index = None
spec_version = vrm0_dict.get("specVersion")
if isinstance(spec_version, str):
spec_version_str = spec_version
humanoid_dict = vrm0_dict.get("humanoid")
if isinstance(humanoid_dict, dict):
human_bone_dicts = humanoid_dict.get("humanBones")
if isinstance(human_bone_dicts, list):
for human_bone_dict in human_bone_dicts:
if not isinstance(human_bone_dict, dict):
continue
if human_bone_dict.get("bone") != "hips":
continue
node_index = human_bone_dict.get("node")
if isinstance(node_index, int):
hips_node_index = node_index
return (
spec_version_number,
spec_version_str,
spec_version_is_stable,
hips_node_index,
)
def read_vrm1_extension(
vrm1_dict: dict[str, Json],
) -> tuple[tuple[int, int], str, bool, Optional[int]]:
spec_version_number = (1, 0)
spec_version_str = "1.0"
spec_version_is_stable = True
hips_node_index = None
humanoid_dict = vrm1_dict.get("humanoid")
if isinstance(humanoid_dict, dict):
human_bones_dict = humanoid_dict.get("humanBones")
if isinstance(human_bones_dict, dict):
hips_dict = human_bones_dict.get("hips")
if isinstance(hips_dict, dict):
node_index = hips_dict.get("node")
if isinstance(node_index, int):
hips_node_index = node_index
return (
spec_version_number,
spec_version_str,
spec_version_is_stable,
hips_node_index,
)