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

310
importer/vrm_diff.py Normal file
View File

@@ -0,0 +1,310 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import math
import re
from sys import float_info
from mathutils import Quaternion
from ..common import deep, gltf
from ..common.convert import Json
from ..common.deep import make_json
from ..common.gltf import read_accessors
def human_bone_sort_key(human_bone_dict: Json) -> int:
if not isinstance(human_bone_dict, dict):
return -1
node = human_bone_dict.get("node")
if not isinstance(node, int):
return -1
return node
asset_generator_pattern = (
r"\AVRM Add-on for Blender v999\.999\.999"
+ r" with Khronos glTF Blender I/O v[0-9]+\.[0-9]+\.[0-9]+\Z"
)
fixed_asset_generator_value = (
"VRM Add-on for Blender v999.999.999"
+ " with Khronos glTF Blender I/O v999.999.999"
)
def create_vrm_json_dict(data: bytes) -> dict[str, Json]:
json_dict, buffer0_bytes = gltf.parse_glb(data)
json_dict["__decoded_accessors"] = make_json(
read_accessors(json_dict, buffer0_bytes)
)
extensions_dict = json_dict.get("extensions")
if isinstance(extensions_dict, dict):
is_vrm0 = bool(extensions_dict.get("VRM"))
else:
is_vrm0 = False
asset_dict = json_dict.get("asset")
if isinstance(asset_dict, dict):
asset_generator = asset_dict.get("generator")
if isinstance(asset_generator, str):
asset_dict["generator"] = re.sub(
asset_generator_pattern,
fixed_asset_generator_value,
asset_generator,
)
node_dicts = json_dict.get("nodes")
if isinstance(node_dicts, list):
for node_dict in node_dicts:
if not isinstance(node_dict, dict):
continue
if is_vrm0:
skin_index = node_dict.get("skin")
if isinstance(skin_index, int):
skin_dicts = json_dict.get("skins")
if (
isinstance(skin_dicts, list)
and skin_dicts
and 0 <= skin_index < len(skin_dicts)
and not deep.diff(skin_dicts[0], skin_dicts[skin_index])
):
node_dict["skin"] = 0
if "matrix" in node_dict:
continue
if "scale" not in node_dict:
node_dict["scale"] = [1.0, 1.0, 1.0]
rotation = node_dict.get("rotation")
if not isinstance(rotation, list) or len(rotation) != 4:
continue
rotation_x, rotation_y, rotation_z, rotation_w = rotation
if (
not isinstance(rotation_x, (float, int))
or not isinstance(rotation_y, (float, int))
or not isinstance(rotation_z, (float, int))
or not isinstance(rotation_w, (float, int))
):
node_dict["rotation"] = [0.0, 0.0, 0.0, 1.0]
continue
axis, angle = Quaternion(
(rotation_w, rotation_x, rotation_y, rotation_z)
).to_axis_angle()
if abs(angle - math.pi) > 0.000001:
continue
if abs(axis.x) > abs(axis.y) and abs(axis.x) > abs(axis.z):
if axis.x < 0:
axis = -axis
elif abs(axis.y) > abs(axis.z):
if axis.y < 0:
axis = -axis
elif axis.z < 0:
axis = -axis
q = Quaternion(axis, angle).normalized()
node_dict["rotation"] = [q.x, q.y, q.z, q.w]
if is_vrm0:
skin_dicts = json_dict.get("skins")
if isinstance(skin_dicts, list) and skin_dicts:
first_skin_dict = skin_dicts[0]
if all(
not deep.diff(skin_dict, first_skin_dict)
for skin_dict in skin_dicts
if isinstance(skin_dict, dict)
):
while len(skin_dicts) > 1:
skin_dicts.pop()
scene_dicts = json_dict.get("scenes")
if isinstance(scene_dicts, list):
for scene_dict in scene_dicts:
if not isinstance(scene_dict, dict):
continue
scene_extras_dict = scene_dict.get("extras")
if not isinstance(scene_extras_dict, dict):
continue
for k in ["show_mmd_tabs", "embed_textures", "ui_lang"]:
scene_extras_dict.pop(k, None)
if not scene_extras_dict:
scene_dict.pop("extras", None)
extensions_dict = json_dict.get("extensions")
if not isinstance(extensions_dict, dict):
return json_dict
vrm0_extension_dict = extensions_dict.get("VRM")
if not isinstance(vrm0_extension_dict, dict):
return json_dict
vrm0_first_person_dict = vrm0_extension_dict.get("firstPerson")
if not isinstance(vrm0_first_person_dict, dict):
vrm0_first_person_dict = {}
vrm0_extension_dict["firstPerson"] = vrm0_first_person_dict
vrm0_humanoid_dict = vrm0_extension_dict.get("humanoid")
if isinstance(vrm0_humanoid_dict, dict):
vrm0_human_bone_dicts = vrm0_humanoid_dict.get("humanBones")
if isinstance(vrm0_human_bone_dicts, list):
vrm0_humanoid_dict["humanBones"] = sorted(
vrm0_human_bone_dicts, key=human_bone_sort_key
)
vrm0_first_person_bone = vrm0_first_person_dict.get("firstPersonBone")
if vrm0_first_person_bone in [None, -1]:
for vrm0_human_bone_dict in vrm0_human_bone_dicts:
if not isinstance(vrm0_human_bone_dict, dict):
continue
node = vrm0_human_bone_dict.get("node")
if not isinstance(node, int) or node < 0:
continue
if vrm0_human_bone_dict.get("bone") == "head":
vrm0_first_person_dict["firstPersonBone"] = node
break
for look_at_key in [
"lookAtHorizontalInner",
"lookAtHorizontalOuter",
"lookAtVerticalDown",
"lookAtVerticalUp",
]:
if look_at_key not in vrm0_first_person_dict:
vrm0_first_person_dict[look_at_key] = {}
look_at_dict = vrm0_first_person_dict[look_at_key]
if not isinstance(look_at_dict, dict):
continue
if "curve" not in look_at_dict:
look_at_dict["curve"] = [0, 0, 0, 1, 1, 1, 1, 0]
vrm0_blend_shape_master_dict = vrm0_extension_dict.get("blendShapeMaster")
if isinstance(vrm0_blend_shape_master_dict, dict):
vrm0_blend_shape_group_dicts = vrm0_blend_shape_master_dict.get(
"blendShapeGroups"
)
if isinstance(vrm0_blend_shape_group_dicts, list):
for vrm0_blend_shape_group_dict in vrm0_blend_shape_group_dicts:
if not isinstance(vrm0_blend_shape_group_dict, dict):
continue
if "isBinary" not in vrm0_blend_shape_group_dict:
vrm0_blend_shape_group_dict["isBinary"] = False
if "binds" not in vrm0_blend_shape_group_dict:
vrm0_blend_shape_group_dict["binds"] = []
if "materialValues" not in vrm0_blend_shape_group_dict:
vrm0_blend_shape_group_dict["materialValues"] = []
vrm0_secondary_animation = vrm0_extension_dict.get("secondaryAnimation")
if isinstance(vrm0_secondary_animation, dict):
vrm0_collider_group_dicts = vrm0_secondary_animation.get("colliderGroups")
if isinstance(vrm0_collider_group_dicts, list):
def sort_collider_groups_with_index_key(
collider_group_with_index: tuple[int, Json],
) -> int:
(_, collider_group) = collider_group_with_index
if not isinstance(collider_group, dict):
return -999
node = collider_group.get("node")
if not isinstance(node, int):
return -999
return node
sorted_collider_groups_with_original_index = sorted(
enumerate(vrm0_collider_group_dicts),
key=sort_collider_groups_with_index_key,
)
vrm0_collider_group_dicts = [
collider_group
for _, collider_group in sorted_collider_groups_with_original_index
]
original_index_to_sorted_index = {
original_index: sorted_index
for (sorted_index, (original_index, _)) in enumerate(
sorted_collider_groups_with_original_index
)
}
vrm0_bone_groups = vrm0_secondary_animation.get("boneGroups")
if isinstance(vrm0_bone_groups, list):
for vrm0_bone_group in vrm0_bone_groups:
if not isinstance(vrm0_bone_group, dict):
continue
if "comment" not in vrm0_bone_group:
vrm0_bone_group["comment"] = ""
collider_groups = vrm0_bone_group.get("colliderGroups")
if not isinstance(collider_groups, list):
continue
for i, collider_group in enumerate(list(collider_groups)):
if not isinstance(collider_group, int):
continue
if collider_group not in original_index_to_sorted_index:
collider_groups[i] = -999
continue
collider_groups[i] = original_index_to_sorted_index[
collider_group
]
vrm0_material_properties = vrm0_extension_dict.get("materialProperties")
if isinstance(vrm0_material_properties, list):
for vrm0_material_property in vrm0_material_properties:
if not isinstance(vrm0_material_property, dict):
continue
if vrm0_material_property.get("shader") != "VRM/MToon":
continue
keyword_map = vrm0_material_property.get("keywordMap")
if not isinstance(keyword_map, dict):
keyword_map = {}
vrm0_float_properties = vrm0_material_property.get("floatProperties")
if isinstance(vrm0_float_properties, dict):
if vrm0_float_properties.get("_OutlineWidthMode", 0) == 0:
vrm0_float_properties["_OutlineWidth"] = 0
vrm0_float_properties["_OutlineColorMode"] = 0
vrm0_float_properties["_OutlineLightingMix"] = 0
keyword_map.pop("MTOON_OUTLINE_COLOR_FIXED", None)
keyword_map.pop("MTOON_OUTLINE_COLOR_MIXED", None)
if vrm0_float_properties.get("_OutlineWidthMode") != 2:
vrm0_float_properties["_OutlineScaledMaxDistance"] = 1
if vrm0_float_properties.get("_OutlineColorMode") == 0:
vrm0_float_properties["_OutlineLightingMix"] = 0
outline_lighting_mix = vrm0_float_properties.get("_OutlineLightingMix")
if (
isinstance(outline_lighting_mix, (float, int))
and abs(outline_lighting_mix) < float_info.epsilon
):
vrm0_float_properties["_OutlineColorMode"] = 0
keyword_map.pop("MTOON_OUTLINE_COLOR_MIXED", None)
if "MTOON_OUTLINE_COLOR_FIXED" not in keyword_map:
keyword_map["MTOON_OUTLINE_COLOR_FIXED"] = True
vrm0_vector_properties = vrm0_material_property.get("vectorProperties")
if isinstance(vrm0_vector_properties, dict):
vrm0_emission_color = vrm0_vector_properties.get("_EmissionColor")
if (
isinstance(vrm0_emission_color, list)
and len(vrm0_emission_color) == 4
):
vrm0_emission_color[3] = 1
vrm0_outline_color = vrm0_vector_properties.get("_OutlineColor")
if (
isinstance(vrm0_outline_color, list)
and len(vrm0_outline_color) == 4
):
vrm0_outline_color[3] = 1
return json_dict
def vrm_diff(before: bytes, after: bytes, float_tolerance: float) -> list[str]:
return deep.diff(
create_vrm_json_dict(before), create_vrm_json_dict(after), float_tolerance
)