# 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, )