Files
blender-vrm/editor/validation.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

1007 lines
42 KiB
Python

# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from pathlib import Path
from sys import float_info
from typing import TYPE_CHECKING, Optional
from bpy.app.translations import pgettext
from bpy.props import BoolProperty, CollectionProperty, IntProperty, StringProperty
from bpy.types import (
Armature,
Bone,
Context,
Event,
Image,
Material,
Mesh,
NodesModifier,
Object,
Operator,
PropertyGroup,
ShaderNodeGroup,
ShaderNodeTexImage,
UILayout,
)
from ..common import shader, version
from ..common.legacy_gltf import RGBA_INPUT_NAMES, TEXTURE_INPUT_NAMES, VAL_INPUT_NAMES
from ..common.logger import get_logger
from ..common.mtoon_unversioned import MtoonUnversioned
from ..common.preferences import get_preferences
from ..common.vrm0 import human_bone as vrm0_human_bone
from ..common.vrm1 import human_bone as vrm1_human_bone
from . import migration, search
from .extension import get_armature_extension, get_material_extension
from .property_group import CollectionPropertyProtocol
logger = get_logger(__name__)
class VrmValidationError(PropertyGroup):
message: StringProperty() # type: ignore[valid-type]
severity: IntProperty(min=0) # type: ignore[valid-type]
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
message: str # type: ignore[no-redef]
severity: int # type: ignore[no-redef]
class WM_OT_vrm_validator(Operator):
bl_idname = "vrm.model_validate"
bl_label = "Check VRM Model"
bl_description = "NO Quad_Poly & N_GON, NO unSkined Mesh etc..."
show_successful_message: BoolProperty( # type: ignore[valid-type]
default=True
)
errors: CollectionProperty(type=VrmValidationError) # type: ignore[valid-type]
armature_object_name: StringProperty() # type: ignore[valid-type]
def execute(self, context: Context) -> set[str]:
self.detect_errors(
context,
self.errors,
self.armature_object_name,
execute_migration=True,
readonly=False,
)
fatal_error_count = 0
for error in self.errors:
if fatal_error_count > 10:
logger.warning("Validation error: truncated ...")
break
if error.severity == 0:
logger.warning("Validation error: %s", error.message)
fatal_error_count += 1
if fatal_error_count > 0:
return {"CANCELLED"}
return {"FINISHED"}
def invoke(self, context: Context, _event: Event) -> set[str]:
self.detect_errors(
context,
self.errors,
self.armature_object_name,
execute_migration=True,
readonly=False,
)
if (
not any(error.severity == 0 for error in self.errors)
and not self.show_successful_message
):
return {"FINISHED"}
return context.window_manager.invoke_props_dialog(self, width=800)
def draw(self, _context: Context) -> None:
self.draw_errors(
self.layout,
self.errors,
show_successful_message=self.show_successful_message,
)
@staticmethod
def validate_bone_order_vrm0(
context: Context,
messages: list[str],
armature: Object,
*,
readonly: bool,
) -> None:
armature_data = armature.data
if not isinstance(armature_data, Armature):
message = f"{type(armature_data)} is not an Armature"
raise TypeError(message)
humanoid = get_armature_extension(armature_data).vrm0.humanoid
if readonly:
humanoid.defer_update_all_node_candidates(armature_data.name)
else:
humanoid.update_all_node_candidates(context, armature_data.name)
for human_bone in humanoid.human_bones:
if (
not human_bone.node.bone_name
or human_bone.node.bone_name in human_bone.node_candidates
):
continue
messages.append(
pgettext(
'Couldn\'t assign "{bone}" bone'
+ ' to VRM Human Bone "{human_bone}". '
+ 'Confirm hierarchy of "{bone}" and its children. '
+ '"VRM" Panel → "Humanoid""{human_bone}" is empty'
+ " if wrong hierarchy"
).format(
bone=human_bone.node.bone_name,
human_bone=human_bone.specification().title,
)
)
break
@staticmethod
def validate_bone_order_vrm1(
context: Context,
messages: list[str],
armature: Object,
*,
readonly: bool,
) -> None:
armature_data = armature.data
if not isinstance(armature_data, Armature):
message = f"{type(armature_data)} is not an Armature"
raise TypeError(message)
human_bones = get_armature_extension(armature_data).vrm1.humanoid.human_bones
if readonly:
human_bones.defer_update_all_node_candidates(armature_data.name)
else:
human_bones.update_all_node_candidates(context, armature_data.name)
for (
human_bone_name,
human_bone,
) in human_bones.human_bone_name_to_human_bone().items():
if (
not human_bone.node.bone_name
or human_bone.node.bone_name in human_bone.node_candidates
):
continue
specification = vrm1_human_bone.HumanBoneSpecifications.get(human_bone_name)
messages.append(
pgettext(
'Couldn\'t assign "{bone}" bone'
+ ' to VRM Human Bone "{human_bone}". '
+ 'Confirm hierarchy of "{bone}" and its children. '
+ '"VRM" Panel → "Humanoid""{human_bone}" is empty'
+ " if wrong hierarchy"
).format(
bone=human_bone.node.bone_name,
human_bone=specification.title,
)
)
break
@staticmethod
def validate_bone_order(
context: Context,
messages: list[str],
armature: Object,
*,
readonly: bool,
) -> None:
armature_data = armature.data
if not isinstance(armature_data, Armature):
message = f"{type(armature_data)} is not an Armature"
raise TypeError(message)
if get_armature_extension(armature_data).is_vrm0():
WM_OT_vrm_validator.validate_bone_order_vrm0(
context, messages, armature, readonly=readonly
)
else:
WM_OT_vrm_validator.validate_bone_order_vrm1(
context, messages, armature, readonly=readonly
)
@staticmethod
def detect_errors(
context: Context,
error_collection: CollectionPropertyProtocol[VrmValidationError],
armature_object_name: str,
*,
execute_migration: bool = False,
readonly: bool = True,
) -> None:
error_messages: list[str] = []
warning_messages: list[str] = []
skippable_warning_messages: list[str] = []
info_messages: list[str] = []
armature_count = 0
armature: Optional[Object] = None
node_names: list[str] = []
# export object seeking
preferences = get_preferences(context)
version_warning_message = version.validation_warning_message()
if version_warning_message is not None:
warning_messages.append(version_warning_message)
export_objects = search.export_objects(
context,
armature_object_name,
export_invisibles=preferences.export_invisibles,
export_only_selections=preferences.export_only_selections,
export_lights=preferences.export_lights,
)
if not any(
obj.type in search.MESH_CONVERTIBLE_OBJECT_TYPES for obj in export_objects
):
if preferences.export_only_selections:
warning_messages.append(
pgettext(
'"{export_only_selections}" is enabled'
+ ", but no mesh is selected."
).format(export_only_selections=pgettext("Export Only Selections"))
)
else:
warning_messages.append(pgettext("There is no mesh to export."))
armature_count = len([True for obj in export_objects if obj.type == "ARMATURE"])
if armature_count >= 2: # only one armature
error_messages.append(
pgettext("VRM exporter needs only one armature not some armatures.")
)
if armature_count == 0:
info_messages.append(pgettext("No armature exists."))
for obj in export_objects:
if (parnet_obj := obj.parent) and obj.parent_type in ["VERTEX", "VERTEX_3"]:
skippable_warning_messages.append(
pgettext(
'The vertex "{parent_name}" is set as the parent of "{name}",'
+ " but this is not supported in VRM."
).format(name=obj.name, parent_name=parnet_obj.name)
)
if (parnet_obj := obj.parent) and obj.parent_type == "LATTICE":
skippable_warning_messages.append(
pgettext(
'"{lattice}" is set as the {parent_type} for "{name}",'
+ " but this is not supported in VRM."
).format(
name=obj.name,
parent_type=pgettext("Parent Type"),
lattice=pgettext("Lattice"),
)
)
if obj.type in search.MESH_CONVERTIBLE_OBJECT_TYPES:
if obj.name in node_names:
error_messages.append(
pgettext(
"glTF nodes (mesh, bone) cannot have duplicate names."
+ " {name} is duplicated."
).format(name=obj.name)
)
if obj.name not in node_names:
node_names.append(obj.name)
if (
obj.type == "MESH"
and isinstance(obj_data := obj.data, Mesh)
and (obj_data_shape_keys := obj_data.shape_keys) is not None
and len(obj_data_shape_keys.key_blocks)
>= 2 # Exclude a "Basis" shape key
and any(
modifier.type != "ARMATURE"
and not (
modifier.type == "NODES"
and isinstance(modifier, NodesModifier)
and (modifier_node_group := modifier.node_group)
and modifier_node_group.name
== shader.OUTLINE_GEOMETRY_GROUP_NAME
)
for modifier in obj.modifiers
)
):
skippable_warning_messages.append(
pgettext(
'The "{name}" mesh has both a non-armature modifier'
+ " and a shape key. However, they cannot coexist"
+ ", so shape keys may not be exported correctly."
).format(name=obj.name)
)
if obj.type == "ARMATURE" and armature_count == 1:
armature = obj
armature_data = armature.data
if not isinstance(armature_data, Armature):
logger.error("%s is not an Armature", type(armature_data))
continue
if execute_migration:
migration.migrate(context, armature.name)
bone: Optional[Bone] = None
for bone in armature_data.bones:
if bone.name in node_names: # nodes name is unique
error_messages.append(
pgettext(
"The same name cannot be used"
+ " for a mesh object and a bone."
+ ' Rename either one whose name is "{name}".'
).format(name=bone.name)
)
if bone.name not in node_names:
node_names.append(bone.name)
# TODO: T_POSE,
all_required_bones_exist = True
armature_extension = get_armature_extension(armature_data)
if armature_extension.is_vrm1():
_, _, constraint_warning_messages = search.export_constraints(
export_objects, armature
)
skippable_warning_messages.extend(constraint_warning_messages)
humanoid = armature_extension.vrm1.humanoid
if humanoid.pose == humanoid.POSE_AUTO_POSE.identifier:
info_messages.append(
pgettext(
"Automatic T-Pose Conversion is enabled."
+ " There is a setting"
+ ' in "VRM" panel → "Humanoid""T-Pose".'
)
)
human_bones = humanoid.human_bones
human_bone_name_to_human_bone = (
human_bones.human_bone_name_to_human_bone()
)
for (
human_bone_name,
human_bone,
) in human_bone_name_to_human_bone.items():
human_bone_specification = (
vrm1_human_bone.HumanBoneSpecifications.get(human_bone_name)
)
if not human_bone_specification.requirement:
continue
if (
human_bone.node
and human_bone.node.bone_name
and human_bone.node.bone_name in armature_data.bones
):
continue
all_required_bones_exist = False
if not human_bones.allow_non_humanoid_rig:
error_messages.append(
pgettext(
'Required VRM Human Bone "{humanoid_name}" is'
+ " not assigned. Please confirm hierarchy"
+ " of {humanoid_name} and its children. "
+ '"VRM" Panel → "Humanoid"{humanoid_name}'
+ " will be empty or displayed in red"
+ " if hierarchy is wrong"
).format(humanoid_name=human_bone_specification.title)
)
if all_required_bones_exist:
# https://github.com/vrm-c/vrm-specification/blob/master/specification/VRMC_vrm-1.0-beta/humanoid.md#list-of-humanoid-bones
for (
human_bone_specification
) in vrm1_human_bone.HumanBoneSpecifications.all_human_bones:
if not human_bone_specification.parent_requirement:
continue
parent = human_bone_specification.parent()
if parent is None:
message = (
f"Fatal: {human_bone_specification.name} has"
+ " no parent"
)
raise ValueError(message)
child_human_bone = human_bone_name_to_human_bone[
human_bone_specification.name
]
parent_human_bone = human_bone_name_to_human_bone[
parent.name
]
if (
child_human_bone.node.bone_name
and not parent_human_bone.node.bone_name
):
error_messages.append(
pgettext(
'VRM Human Bone "{child}" needs "{parent}".'
+ " Please confirm"
+ ' "VRM" Panel → "Humanoid"'
+ '"Optional VRM Human Bones""{parent}".'
).format(
child=human_bone_specification.title,
parent=parent.title,
)
)
if (
not human_bones.all_required_bones_are_assigned()
and human_bones.allow_non_humanoid_rig
):
warning_messages.append(
pgettext(
"This armature will be exported but not as a humanoid."
+ " It cannot have animations applied"
+ " for humanoid avatars."
)
)
elif armature_extension.is_vrm0():
humanoid = armature_extension.vrm0.humanoid
if humanoid.pose == humanoid.POSE_AUTO_POSE.identifier:
info_messages.append(
pgettext(
"Automatic T-Pose Conversion is enabled."
+ " There is a setting"
+ ' in "VRM" panel → "VRM 0.x Humanoid""T-Pose".'
)
)
human_bones = humanoid.human_bones
all_required_bones_exist = True
for (
humanoid_name
) in vrm0_human_bone.HumanBoneSpecifications.required_names:
if any(
human_bone.bone == humanoid_name
and human_bone.node
and human_bone.node.bone_name
and human_bone.node.bone_name in armature_data.bones
for human_bone in human_bones
):
continue
all_required_bones_exist = False
error_messages.append(
pgettext(
'Required VRM Human Bone "{humanoid_name}" is'
+ " not assigned. Please confirm hierarchy"
+ " of {humanoid_name} and its children."
+ ' "VRM" Panel → "VRM 0.x Humanoid"{humanoid_name}'
+ " will be empty or displayed in red"
+ " if hierarchy is wrong"
).format(humanoid_name=humanoid_name.capitalize())
)
if all_required_bones_exist:
WM_OT_vrm_validator.validate_bone_order(
context, error_messages, armature, readonly=readonly
)
if obj.type == "MESH":
mesh_data = obj.data
if not isinstance(mesh_data, Mesh):
logger.error("%s is not a Mesh", type(mesh_data))
continue
for poly in mesh_data.polygons:
if poly.loop_total > 3: # polygons need all triangle
info_messages.append(
pgettext(
'Non-triangular faces detected in "{name}". '
+ "They will be triangulated automatically.",
).format(name=obj.name)
)
break
# TODO: modifier applied, vertex weight Bone exist
# and vertex weight numbers.
if (
armature is not None
and isinstance(armature_data := armature.data, Armature)
and get_armature_extension(armature_data).is_vrm1()
):
error_messages.extend(
pgettext(
'Object "{name}" contains a negative value for the scale;'
+ " VRM 1.0 does not allow negative values to be specified"
+ " for the scale."
).format(name=obj.name)
for obj in export_objects
if obj.scale[0] < 0 or obj.scale[1] < 0 or obj.scale[2] < 0
)
joint_chain_bone_names_to_spring_vrm_name: dict[str, str] = {}
for spring in get_armature_extension(armature_data).spring_bone1.springs:
joint_bone_names: list[str] = []
for joint in spring.joints:
bone_name = joint.node.bone_name
if bone_name and bone_name not in joint_bone_names:
joint_bone_names.append(bone_name)
joint_chain_bone_names: list[str] = []
for bone_name in joint_bone_names:
search_joint_chain_bone_names: list[str] = []
bone = armature_data.bones.get(bone_name)
terminated = False
while bone:
if bone.name != bone_name and bone.name in joint_bone_names:
terminated = True
break
search_joint_chain_bone_names.append(bone.name)
bone = bone.parent
if not terminated:
if bone_name not in joint_chain_bone_names:
joint_chain_bone_names.append(bone_name)
continue
joint_chain_bone_names.extend(search_joint_chain_bone_names)
for joint_chain_bone_name in joint_chain_bone_names:
spring_vrm_name = joint_chain_bone_names_to_spring_vrm_name.get(
joint_chain_bone_name
)
if not spring_vrm_name:
joint_chain_bone_names_to_spring_vrm_name[
joint_chain_bone_name
] = spring.vrm_name
continue
skippable_warning_messages.append(
pgettext(
'Spring "{spring_name1}" and "{spring_name2}" have'
+ ' common bone "{bone_name}".'
).format(
spring_name1=spring_vrm_name,
spring_name2=spring.vrm_name,
bone_name=joint_chain_bone_name,
)
)
used_materials = search.export_materials(context, export_objects)
used_images: list[Image] = []
bones_names = []
if armature is not None and isinstance(
armature_data := armature.data, Armature
):
bones_names = [b.name for b in armature_data.bones]
vertex_error_count = 0
for mesh in [obj for obj in export_objects if obj.type == "MESH"]:
if not isinstance(mesh_data := mesh.data, Mesh):
continue
mesh_vertex_group_names = [g.name for g in mesh.vertex_groups]
for v in mesh_data.vertices:
if not v.groups and mesh.parent_bone == "":
if vertex_error_count > 5:
continue
if (
armature is not None
and isinstance((armature_data := armature.data), Armature)
and get_armature_extension(armature_data).is_vrm1()
):
continue
info_messages.append(
pgettext(
'vertex index "{vertex_index}" is no weight'
+ ' in "{mesh_name}".'
+ " Add weight to parent bone automatically."
).format(vertex_index=v.index, mesh_name=mesh.name)
)
vertex_error_count = vertex_error_count + 1
elif len(v.groups) >= 5 and armature is not None:
weight_count = 0
for g in v.groups:
if (
0 <= g.group < len(mesh_vertex_group_names)
and mesh_vertex_group_names[g.group] in bones_names
and g.weight < float_info.epsilon
):
weight_count += 1
if weight_count > 4 and vertex_error_count < 5:
info_messages.append(
pgettext(
'vertex index "{vertex_index}" has'
+ ' too many (over 4) weight in "{mesh_name}".'
+ " It will be truncated to 4 descending"
+ " order by its weight."
).format(vertex_index=v.index, mesh_name=mesh.name)
)
vertex_error_count = vertex_error_count + 1
for mat in used_materials:
if (
not (mat_node_tree := mat.node_tree)
or get_material_extension(mat).mtoon1.enabled
):
continue
for node in mat_node_tree.nodes:
if node.type != "OUTPUT_MATERIAL":
continue
links = node.inputs["Surface"].links
if links and links[0].from_node.type == "BSDF_PRINCIPLED":
continue
if links:
from_node = links[0].from_node
if from_node.type == "GROUP" and isinstance(
from_node, ShaderNodeGroup
):
node_tree = from_node.node_tree
if (
node_tree
and node_tree.get("SHADER") in search.LEGACY_SHADER_NAMES
):
continue
skippable_warning_messages.append(
pgettext(
'"{material_name}" needs to enable'
+ ' "VRM MToon Material" or connect'
+ " Principled BSDF/MToon_unversioned/TRANSPARENT_ZWRITE"
+ ' to "Surface" directly. Empty material will be exported.'
).format(material_name=mat.name)
)
for node, legacy_shader_name, material in search.shader_nodes_and_materials(
used_materials
):
# MToon
if legacy_shader_name == "MToon_unversioned":
for texture_val in MtoonUnversioned.texture_kind_exchange_dict.values():
suffix = "_alpha" if texture_val == "ReceiveShadow_Texture" else ""
node_material_input_check(
node,
material,
"TEX_IMAGE",
texture_val + suffix,
error_messages,
used_images,
)
for float_val in MtoonUnversioned.float_props_exchange_dict.values():
if float_val is None:
continue
node_material_input_check(
node, material, "VALUE", float_val, error_messages, used_images
)
for k in ["_Color", "_ShadeColor", "_EmissionColor", "_OutlineColor"]:
node_material_input_check(
node,
material,
"RGB",
MtoonUnversioned.vector_props_exchange_dict[k],
error_messages,
used_images,
)
# GLTF
elif legacy_shader_name == "GLTF":
for k in TEXTURE_INPUT_NAMES:
node_material_input_check(
node, material, "TEX_IMAGE", k, error_messages, used_images
)
for k in VAL_INPUT_NAMES:
node_material_input_check(
node, material, "VALUE", k, error_messages, used_images
)
for k in RGBA_INPUT_NAMES:
node_material_input_check(
node, material, "RGB", k, error_messages, used_images
)
# Transparent_Zwrite
elif legacy_shader_name == "TRANSPARENT_ZWRITE":
node_material_input_check(
node,
material,
"TEX_IMAGE",
"Main_Texture",
error_messages,
used_images,
)
for mat in used_materials:
gltf = get_material_extension(mat).mtoon1
if not gltf.enabled:
continue
for texture in gltf.all_textures(
downgrade_to_mtoon0=(
armature is None
or not isinstance(armature_data := armature.data, Armature)
or get_armature_extension(armature_data).is_vrm0()
)
):
source = texture.get_connected_node_image()
if not source:
continue
if source not in used_images:
used_images.append(source)
if source.colorspace_settings.name == texture.colorspace:
continue
skippable_warning_messages.append(
pgettext(
'It is recommended to set "{colorspace}"'
+ ' to "{input_colorspace}" for "{texture_label}"'
+ ' in Material "{name}"'
).format(
name=mat.name,
texture_label=source.name,
colorspace=pgettext(texture.colorspace),
input_colorspace=pgettext("Input Color Space"),
)
)
for texture_info in gltf.all_texture_info():
source = texture_info.index.get_connected_node_image()
if source and source not in used_images:
used_images.append(source)
if (
armature is None
or not isinstance((armature_data := armature.data), Armature)
or not get_armature_extension(armature_data).is_vrm0()
or not source
):
continue
base_color_texture = gltf.pbr_metallic_roughness.base_color_texture
base_extensions = base_color_texture.extensions
base_scale = base_extensions.khr_texture_transform.scale
base_offset = base_extensions.khr_texture_transform.offset
scale = texture_info.extensions.khr_texture_transform.scale
offset = texture_info.extensions.khr_texture_transform.offset
if texture_info == gltf.extensions.vrmc_materials_mtoon.matcap_texture:
if (
abs(scale[0] - 1) > 0
or abs(scale[1] - 1) > 0
or abs(offset[0]) > 0
or abs(offset[1]) > 0
):
skippable_warning_messages.append(
pgettext(
'Material "{name}" {texture}\'s Offset and Scale are'
+ " ignored in VRM 0.0"
).format(
name=mat.name,
texture=texture_info.index.label,
)
)
elif (
abs(base_scale[0] - scale[0]) > 0
or abs(base_scale[1] - scale[1]) > 0
or abs(base_offset[0] - offset[0]) > 0
or abs(base_offset[1] - offset[1]) > 0
):
skippable_warning_messages.append(
pgettext(
'Material "{name}" {texture}\'s Offset and Scale'
+ " in VRM 0.0 are the values of"
+ " the Lit Color Texture"
).format(
name=mat.name,
texture=texture_info.index.label,
)
)
if (
armature is not None
and isinstance(armature_data := armature.data, Armature)
and get_armature_extension(armature_data).is_vrm0()
):
# first_person
first_person = get_armature_extension(armature_data).vrm0.first_person
if not first_person.first_person_bone.bone_name:
info_messages.append(
pgettext(
"firstPersonBone was not found. "
+ 'VRM HumanBone "head" will be used automatically instead.'
)
)
# blend_shape_master
# TODO: material value and material existence
blend_shape_master = get_armature_extension(
armature_data
).vrm0.blend_shape_master
for blend_shape_group in blend_shape_master.blend_shape_groups:
for bind in blend_shape_group.binds:
mesh_object_name = bind.mesh.mesh_object_name
if not bind.index or not mesh_object_name:
continue
mesh_object = next(
iter(
obj
for obj in export_objects
if obj.name == mesh_object_name
),
None,
)
if (
not mesh_object
and mesh_object_name in context.blend_data.objects
):
info_messages.append(
pgettext(
'A mesh named "{mesh_name}" is assigned to a blend'
+ ' shape group named "{blend_shape_group_name}" but'
+ " the mesh will not be exported"
).format(
blend_shape_group_name=blend_shape_group.name,
mesh_name=mesh_object_name,
)
)
if not mesh_object:
continue
mesh_data = mesh_object.data
if not isinstance(mesh_data, Mesh):
continue
shape_keys = mesh_data.shape_keys
if not shape_keys or bind.index not in shape_keys.key_blocks:
info_messages.append(
pgettext(
'A shape key named "{shape_key_name}" in a mesh'
+ ' named "{mesh_name}" is assigned to a blend shape'
+ ' group named "{blend_shape_group_name}" but the'
+ " shape key doesn't exist."
).format(
mesh_name=mesh_object.name,
shape_key_name=bind.index,
blend_shape_group_name=blend_shape_group.name,
)
)
error_messages.extend(
pgettext(
'"{image_name}" was not found at file path "{image_filepath}". '
+ "Please load the file in Blender."
).format(image_name=image.name, image_filepath=image.filepath_from_user())
for image in used_images
if (
image.source == "FILE"
and not image.is_dirty
and image.packed_file is None
and not Path(image.filepath_from_user()).exists()
)
)
error_collection.clear()
for message in error_messages:
error = error_collection.add()
error.name = f"VrmModelError{len(error_collection)}"
error.severity = 0
error.message = message
for message in warning_messages:
error = error_collection.add()
error.name = f"VrmModelError{len(error_collection)}"
error.severity = 1
error.message = message
for message in skippable_warning_messages:
error = error_collection.add()
error.name = f"VrmModelError{len(error_collection)}"
error.severity = 2
error.message = message
for message in info_messages:
error = error_collection.add()
error.name = f"VrmModelError{len(error_collection)}"
error.severity = 3
error.message = message
@staticmethod
def draw_errors(
layout: UILayout,
error_collection: CollectionPropertyProtocol[VrmValidationError],
*,
show_successful_message: bool,
) -> None:
error_errors: list[VrmValidationError] = []
warning_errors: list[VrmValidationError] = []
info_errors: list[VrmValidationError] = []
for error in error_collection:
if error.severity == 0:
error_errors.append(error)
elif error.severity <= 2:
warning_errors.append(error)
else:
info_errors.append(error)
if not error_errors and warning_errors:
row = layout.row()
row.emboss = "NONE"
row.box().label(
text=pgettext(
"No errors found. But there are {warning_count} warning(s)."
+ " The output may not be what you expected."
).format(
warning_count=len(warning_errors),
),
translate=False,
)
elif not error_errors and show_successful_message:
row = layout.row()
row.emboss = "NONE"
row.box().label(text="No errors found. Ready to export VRM")
if error_errors:
layout.label(text="Error", icon="ERROR")
column = layout.column(align=True)
for error in error_errors:
column.prop(
error,
"message",
text="",
translate=False,
)
if warning_errors:
layout.label(text="Warning", icon="CANCEL")
column = layout.column(align=True)
for error in warning_errors:
column.prop(
error,
"message",
text="",
translate=False,
)
if info_errors:
layout.label(text="Info", icon="INFO")
column = layout.column(align=True)
for error in info_errors:
column.prop(
error,
"message",
text="",
translate=False,
)
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
show_successful_message: bool # type: ignore[no-redef]
errors: CollectionPropertyProtocol[ # type: ignore[no-redef]
VrmValidationError
]
armature_object_name: str # type: ignore[no-redef]
def node_material_input_check(
node: ShaderNodeGroup,
material: Material,
expect_node_type: str,
shader_val: str,
messages: list[str],
used_images: list[Image],
) -> None:
# Support models that were loaded by earlier versions (1.3.5 or earlier), which had
# this typo
#
# Those models have node.inputs["NomalmapTexture"] instead of "NormalmapTexture".
# But 'shader_val', which is come from MaterialMtoon.texture_kind_exchange_dict,
# can be "NormalmapTexture". if script reference node.inputs["NormalmapTexture"] in
# that situation, it will occur error. So change it to "NomalmapTexture" which is
# typo but points to the same thing in those models.
if (
shader_val == "NormalmapTexture"
and "NormalmapTexture" not in node.inputs.keys()
):
shader_val = "NomalmapTexture"
if node.inputs[shader_val].links:
n = node.inputs[shader_val].links[0].from_node
if n.type != expect_node_type:
messages.append(
pgettext(
'need "{expect_node_type}" input'
+ ' in "{shader_val}" of "{material_name}"',
).format(
expect_node_type=expect_node_type,
shader_val=shader_val,
material_name=material.name,
)
)
elif expect_node_type == "TEX_IMAGE" and isinstance(n, ShaderNodeTexImage):
if (image := n.image) is not None:
if image not in used_images:
used_images.append(image)
else:
messages.append(
pgettext(
'Image in material "{material_name}" is not set.'
+ " Please add an image.",
).format(material_name=material.name)
)