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