# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
# SPDX-FileCopyrightText: 2018 iCyP

import itertools
import re
import statistics
import struct
from collections.abc import Iterator, Mapping, MutableSequence, Sequence
from contextlib import contextmanager
from dataclasses import dataclass, field
from os import environ
from sys import float_info
from typing import Final, Optional, Union

import bpy
from bpy.types import (
    Armature,
    Context,
    Curve,
    Image,
    Key,
    Material,
    Mesh,
    MeshUVLoopLayer,
    Node,
    Object,
    PoseBone,
    ShaderNodeGroup,
    ShaderNodeTexImage,
    ShapeKey,
)
from mathutils import Matrix, Vector

from ..common import convert, gltf, shader
from ..common.convert import Json
from ..common.deep import make_json, make_json_dict
from ..common.gl import (
    GL_FLOAT,
    GL_LINEAR,
    GL_REPEAT,
    GL_UNSIGNED_INT,
    GL_UNSIGNED_SHORT,
)
from ..common.gltf import (
    FLOAT_NEGATIVE_MAX,
    FLOAT_POSITIVE_MAX,
)
from ..common.legacy_gltf import TEXTURE_INPUT_NAMES
from ..common.logger import get_logger
from ..common.mtoon_unversioned import MtoonUnversioned
from ..common.progress import Progress, create_progress
from ..common.version import get_addon_version
from ..common.vrm0.human_bone import HumanBoneSpecifications
from ..common.workspace import save_workspace
from ..editor import search
from ..editor.extension import get_armature_extension, get_material_extension
from ..editor.mtoon1.property_group import (
    Mtoon0TexturePropertyGroup,
    Mtoon1KhrTextureTransformPropertyGroup,
    Mtoon1SamplerPropertyGroup,
    Mtoon1TextureInfoPropertyGroup,
    Mtoon1TexturePropertyGroup,
)
from ..editor.search import MESH_CONVERTIBLE_OBJECT_TYPES
from ..editor.t_pose import setup_humanoid_t_pose
from ..editor.vrm0.property_group import Vrm0BlendShapeGroupPropertyGroup
from ..external.io_scene_gltf2_support import (
    gather_gltf2_io_material,
    image_to_image_bytes,
    init_extras_export,
)
from .abstract_base_vrm_exporter import (
    AbstractBaseVrmExporter,
    assign_dict,
    force_apply_modifiers,
)

logger = get_logger(__name__)


class Vrm0Exporter(AbstractBaseVrmExporter):
    @dataclass(frozen=True)
    class Gltf2IoTextureImage:
        name: str
        mime_type: str
        image_bytes: bytes
        export_image_index: int

    @dataclass
    class PrimitiveTarget:
        name: str

        position: bytearray = field(default_factory=bytearray)
        position_max_x = FLOAT_NEGATIVE_MAX
        position_min_x = FLOAT_POSITIVE_MAX
        position_max_y = FLOAT_NEGATIVE_MAX
        position_min_y = FLOAT_POSITIVE_MAX
        position_max_z = FLOAT_NEGATIVE_MAX
        position_min_z = FLOAT_POSITIVE_MAX

        normal: bytearray = field(default_factory=bytearray)

    class VertexAttributesCollector:
        POSITION_STRUCT: Final = struct.Struct("<fff")
        NORMAL_STRUCT: Final = struct.Struct("<fff")
        TEXCOORD_STRUCT: Final = struct.Struct("<ff")
        WEIGHTS_STRUCT: Final = struct.Struct("<ffff")
        JOINTS_STRUCT: Final = struct.Struct("<HHHH")

        IndexSearchKey = tuple[
            int, tuple[float, float, float], Optional[tuple[float, float]]
        ]

        def __init__(self) -> None:
            self.count = 0

            self.position = bytearray()
            self.position_max_x = FLOAT_NEGATIVE_MAX
            self.position_min_x = FLOAT_POSITIVE_MAX
            self.position_max_y = FLOAT_NEGATIVE_MAX
            self.position_min_y = FLOAT_POSITIVE_MAX
            self.position_max_z = FLOAT_NEGATIVE_MAX
            self.position_min_z = FLOAT_POSITIVE_MAX

            self.normal = bytearray()
            self.texcoord = bytearray()
            self.weights = bytearray()
            self.joints = bytearray()

            self.index_search_dict: dict[
                tuple[int, tuple[float, float, float], Optional[tuple[float, float]]],
                int,
            ] = {}

        @staticmethod
        def create_index_search_key(
            *,
            blender_vertex_index: int,
            normal: tuple[float, float, float],
            texcoord: Optional[tuple[float, float]],
        ) -> IndexSearchKey:
            return (
                # TODO: Format compatible with old exporter
                blender_vertex_index,
                normal,
                texcoord,
            )

        def find_added_vertex_index(
            self,
            blender_vertex_index: int,
            normal: tuple[float, float, float],
            texcoord: Optional[tuple[float, float]],
        ) -> Optional[int]:
            index_search_key = self.create_index_search_key(
                blender_vertex_index=blender_vertex_index,
                normal=normal,
                texcoord=texcoord,
            )
            return self.index_search_dict.get(index_search_key)

        def add_vertex(
            self,
            *,
            blender_vertex_index: int,
            position: tuple[float, float, float],
            normal: tuple[float, float, float],
            texcoord: Optional[tuple[float, float]],
            weights: Optional[tuple[float, float, float, float]],
            joints: Optional[tuple[int, int, int, int]],
        ) -> int:
            index = self.count
            self.count += 1

            index_search_key = self.create_index_search_key(
                blender_vertex_index=blender_vertex_index,
                normal=normal,
                texcoord=texcoord,
            )
            self.index_search_dict[index_search_key] = index

            self.position.extend(self.POSITION_STRUCT.pack(*position))
            position_x, position_y, position_z = position
            self.position_max_x = max(self.position_max_x, position_x)
            self.position_min_x = min(self.position_min_x, position_x)
            self.position_max_y = max(self.position_max_y, position_y)
            self.position_min_y = min(self.position_min_y, position_y)
            self.position_max_z = max(self.position_max_z, position_z)
            self.position_min_z = min(self.position_min_z, position_z)

            self.normal.extend(self.NORMAL_STRUCT.pack(*normal))
            if texcoord is not None:
                self.texcoord.extend(self.TEXCOORD_STRUCT.pack(*texcoord))
            if weights is not None:
                self.weights.extend(self.WEIGHTS_STRUCT.pack(*weights))
            if joints is not None:
                self.joints.extend(self.JOINTS_STRUCT.pack(*joints))

            return index

    class VertexMorphTargetCollector:
        def __init__(self, count: int) -> None:
            self.count = count

            self.POSITION_STRUCT = (
                Vrm0Exporter.VertexAttributesCollector.POSITION_STRUCT
            )
            self.position = bytearray(self.POSITION_STRUCT.size * count)
            self.position_max_x = FLOAT_NEGATIVE_MAX
            self.position_min_x = FLOAT_POSITIVE_MAX
            self.position_max_y = FLOAT_NEGATIVE_MAX
            self.position_min_y = FLOAT_POSITIVE_MAX
            self.position_max_z = FLOAT_NEGATIVE_MAX
            self.position_min_z = FLOAT_POSITIVE_MAX

            self.NORMAL_STRUCT = Vrm0Exporter.VertexAttributesCollector.NORMAL_STRUCT
            self.normal = bytearray(self.NORMAL_STRUCT.size * count)

        def add_vertex(
            self,
            *,
            vertex_index: int,
            target_position: tuple[float, float, float],
            target_normal: tuple[float, float, float],
        ) -> None:
            if not (0 <= vertex_index < self.count):
                logger.error(
                    "invalid vertex_index: %d, count %d", vertex_index, self.count
                )
                return

            target_position_x, target_position_y, target_position_z = target_position

            position_start_index = vertex_index * self.POSITION_STRUCT.size
            self.position[
                position_start_index : position_start_index + self.POSITION_STRUCT.size
            ] = self.POSITION_STRUCT.pack(
                target_position_x, target_position_y, target_position_z
            )

            self.position_max_x = max(self.position_max_x, target_position_x)
            self.position_min_x = min(self.position_min_x, target_position_x)
            self.position_max_y = max(self.position_max_y, target_position_y)
            self.position_min_y = min(self.position_min_y, target_position_y)
            self.position_max_z = max(self.position_max_z, target_position_z)
            self.position_min_z = min(self.position_min_z, target_position_z)

            normal_start_index = vertex_index * self.NORMAL_STRUCT.size
            self.normal[
                normal_start_index : normal_start_index + self.NORMAL_STRUCT.size
            ] = self.NORMAL_STRUCT.pack(*target_normal)

    def export_vrm(self) -> Optional[bytes]:
        init_extras_export()

        with (
            save_workspace(self.context),
            self.clear_blend_shape_proxy_previews(self.armature_data),
            self.enable_deform_for_all_referenced_bones(self.armature_data),
            setup_humanoid_t_pose(self.context, self.armature),
            self.hide_mtoon1_outline_geometry_nodes(self.context),
            create_progress(self.context) as progress,
        ):
            json_dict: dict[str, Json] = {}
            buffer0 = bytearray()
            self.write_glb_structure(progress, json_dict, buffer0)
            return gltf.pack_glb(json_dict, buffer0)

    def write_glb_structure(
        self, progress: Progress, json_dict: dict[str, Json], buffer0: bytearray
    ) -> None:
        json_dict["asset"] = {"generator": self.get_asset_generator(), "version": "2.0"}

        scene_dicts: list[dict[str, Json]] = []
        node_dicts: list[dict[str, Json]] = []
        mesh_dicts: list[dict[str, Json]] = []
        skin_dicts: list[dict[str, Json]] = []
        material_dicts: list[dict[str, Json]] = []
        texture_dicts: list[dict[str, Json]] = []
        sampler_dicts: list[dict[str, Json]] = []
        image_dicts: list[dict[str, Json]] = []
        accessor_dicts: list[dict[str, Json]] = []
        buffer_view_dicts: list[dict[str, Json]] = []
        extensions_vrm_material_property_dicts: list[Json] = []
        extensions_vrm_dict: dict[str, Json] = {
            "materialProperties": extensions_vrm_material_property_dicts,
        }
        extensions_used: list[str] = []
        material_name_to_material_index: dict[str, int] = {}
        image_name_to_image_index: dict[str, int] = {}

        self.write_materials(
            progress,
            material_dicts,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            extensions_vrm_material_property_dicts,
            extensions_used,
            buffer0,
            material_name_to_material_index,
            image_name_to_image_index,
        )

        bone_name_to_node_index: dict[str, int] = {}
        mesh_object_name_to_mesh_index: dict[str, int] = {}
        self.write_scene(
            progress,
            scene_dicts,
            node_dicts,
            mesh_dicts,
            skin_dicts,
            material_dicts,
            accessor_dicts,
            buffer_view_dicts,
            extensions_vrm_material_property_dicts,
            buffer0,
            bone_name_to_node_index,
            mesh_object_name_to_mesh_index,
            material_name_to_material_index,
        )
        self.write_extensions_vrm(
            progress,
            mesh_dicts,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            extensions_vrm_dict,
            extensions_used,
            buffer0,
            image_name_to_image_index,
            bone_name_to_node_index,
            mesh_object_name_to_mesh_index,
        )

        if scene_dicts:
            json_dict["scenes"] = make_json(scene_dicts)
            json_dict["scene"] = 0
        if node_dicts:
            json_dict["nodes"] = make_json(node_dicts)
        if mesh_dicts:
            json_dict["meshes"] = make_json(mesh_dicts)
        if material_dicts:
            json_dict["materials"] = make_json(material_dicts)
        if skin_dicts:
            json_dict["skins"] = make_json(skin_dicts)
        if accessor_dicts:
            json_dict["accessors"] = make_json(accessor_dicts)
        if texture_dicts:
            json_dict["textures"] = make_json(texture_dicts)
        if image_dicts:
            json_dict["images"] = make_json(image_dicts)
        if sampler_dicts:
            json_dict["samplers"] = make_json(sampler_dicts)
        if buffer_view_dicts:
            json_dict["bufferViews"] = make_json(buffer_view_dicts)
        if extensions_used:
            json_dict["extensionsUsed"] = list(dict.fromkeys(extensions_used).keys())
        json_dict["extensions"] = make_json({"VRM": extensions_vrm_dict})
        json_dict["buffers"] = [
            {
                "byteLength": len(buffer0),
            }
        ]

    def write_extensions_vrm(
        self,
        progress: Progress,
        mesh_dicts: list[dict[str, Json]],
        texture_dicts: list[dict[str, Json]],
        sampler_dicts: list[dict[str, Json]],
        image_dicts: list[dict[str, Json]],
        buffer_view_dicts: list[dict[str, Json]],
        vrm_dict: dict[str, Json],
        extensions_used: list[str],
        buffer0: bytearray,
        image_name_to_image_index: dict[str, int],
        bone_name_to_node_index: Mapping[str, int],
        mesh_object_name_to_mesh_index: Mapping[str, int],
    ) -> None:
        vrm_dict["specVersion"] = "0.0"
        vrm_dict["exporterVersion"] = self.get_asset_generator()

        self.write_extensions_vrm_first_person(
            progress, vrm_dict, bone_name_to_node_index, mesh_object_name_to_mesh_index
        )
        self.write_extensions_vrm_meta(
            progress,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            vrm_dict,
            buffer0,
            image_name_to_image_index,
        )
        self.write_extensions_vrm_blend_shape_master(
            progress, mesh_dicts, mesh_object_name_to_mesh_index, vrm_dict
        )
        self.write_extensions_vrm_secondary_animation(
            progress, vrm_dict, bone_name_to_node_index
        )
        self.write_extensions_vrm_humanoid(progress, vrm_dict, bone_name_to_node_index)
        extensions_used.append("VRM")

    def write_extensions_vrm_humanoid(
        self,
        _progress: Progress,
        vrm_dict: dict[str, Json],
        bone_name_to_node_index: Mapping[str, int],
    ) -> None:
        human_bone_dicts: list[Json] = []
        humanoid_dict: dict[str, Json] = {"humanBones": human_bone_dicts}
        vrm_dict["humanoid"] = humanoid_dict
        humanoid = get_armature_extension(self.armature_data).vrm0.humanoid
        for human_bone_name in HumanBoneSpecifications.all_names:
            for human_bone in humanoid.human_bones:
                if not (
                    human_bone.bone == human_bone_name
                    and (
                        node_index := bone_name_to_node_index.get(
                            human_bone.node.bone_name
                        )
                    )
                    is not None
                ):
                    continue
                human_bone_dict: dict[str, Json] = {
                    "bone": human_bone_name,
                    "node": node_index,
                    "useDefaultValues": human_bone.use_default_values,
                }
                human_bone_dicts.append(human_bone_dict)
                if not human_bone.use_default_values:
                    human_bone_dict.update(
                        {
                            "min": {
                                "x": human_bone.min[0],
                                "y": human_bone.min[1],
                                "z": human_bone.min[2],
                            },
                            "max": {
                                "x": human_bone.max[0],
                                "y": human_bone.max[1],
                                "z": human_bone.max[2],
                            },
                            "center": {
                                "x": human_bone.center[0],
                                "y": human_bone.center[1],
                                "z": human_bone.center[2],
                            },
                            "axisLength": human_bone.axis_length,
                        }
                    )
                break
        humanoid_dict["armStretch"] = humanoid.arm_stretch
        humanoid_dict["legStretch"] = humanoid.leg_stretch
        humanoid_dict["upperArmTwist"] = humanoid.upper_arm_twist
        humanoid_dict["lowerArmTwist"] = humanoid.lower_arm_twist
        humanoid_dict["upperLegTwist"] = humanoid.upper_leg_twist
        humanoid_dict["lowerLegTwist"] = humanoid.lower_leg_twist
        humanoid_dict["feetSpacing"] = humanoid.feet_spacing
        humanoid_dict["hasTranslationDoF"] = humanoid.has_translation_dof

    def write_extensions_vrm_blend_shape_master(
        self,
        _progress: Progress,
        mesh_dicts: list[dict[str, Json]],
        mesh_object_name_to_mesh_index: Mapping[str, int],
        vrm_dict: dict[str, Json],
    ) -> None:
        blend_shape_master = get_armature_extension(
            self.armature_data
        ).vrm0.blend_shape_master
        first_person = get_armature_extension(self.armature_data).vrm0.first_person
        blend_shape_group_dicts: list[Json] = []
        blend_shape_master_dict: dict[str, Json] = {
            "blendShapeGroups": blend_shape_group_dicts
        }
        vrm_dict["blendShapeMaster"] = blend_shape_master_dict

        remaining_preset_names = [
            preset_name.identifier
            for preset_name in Vrm0BlendShapeGroupPropertyGroup.preset_name_enum
            if preset_name != Vrm0BlendShapeGroupPropertyGroup.PRESET_NAME_UNKNOWN
        ]

        for blend_shape_group in blend_shape_master.blend_shape_groups:
            blend_shape_group_dict: dict[str, Json] = {}

            if not blend_shape_group.name:
                continue
            blend_shape_group_dict["name"] = blend_shape_group.name

            if not blend_shape_group.preset_name:
                continue

            if blend_shape_group.preset_name != "unknown":
                if blend_shape_group.preset_name not in remaining_preset_names:
                    continue
                remaining_preset_names.remove(blend_shape_group.preset_name)

            blend_shape_group_dict["presetName"] = blend_shape_group.preset_name

            bind_dicts: list[Json] = []
            blend_shape_group_dict["binds"] = bind_dicts
            for bind in blend_shape_group.binds:
                bind_dict: dict[str, Json] = {}
                mesh_index = mesh_object_name_to_mesh_index.get(
                    bind.mesh.mesh_object_name
                )
                if mesh_index is None:
                    # logger.warning("%s => None", bind.mesh.mesh_object_name)
                    continue
                bind_dict["mesh"] = mesh_index

                if not (
                    0 <= mesh_index < len(mesh_dicts)
                    and (mesh_dict := mesh_dicts[mesh_index])
                    and isinstance(mesh_extras_dict := mesh_dict.get("extras"), dict)
                    and isinstance(
                        target_names := mesh_extras_dict.get("targetNames"), list
                    )
                ):
                    continue

                if bind.index not in target_names:
                    continue

                bind_dict["index"] = target_names.index(bind.index)
                bind_dict["weight"] = min(max(bind.weight * 100, 0), 100)

                bind_dicts.append(bind_dict)

            material_value_dicts: list[Json] = []
            blend_shape_group_dict["materialValues"] = material_value_dicts
            for material_value in blend_shape_group.material_values:
                material_value_material = material_value.material
                if not material_value_material or not material_value_material.name:
                    continue

                material_value_dicts.append(
                    {
                        "materialName": material_value_material.name,
                        "propertyName": material_value.property_name,
                        "targetValue": [v.value for v in material_value.target_value],
                    }
                )

            blend_shape_group_dict["isBinary"] = blend_shape_group.is_binary
            blend_shape_group_dicts.append(blend_shape_group_dict)

        # VirtualMotionCapture requires some blend shape presets
        # https://twitter.com/sh_akira/status/1674237253231714305
        # Note: the VRM specification does not require them. UniVRM 0.112.0
        # can be configured not to output them.
        for preset_name in remaining_preset_names:
            if first_person.look_at_type_name == "Bone" and preset_name.startswith(
                "look"
            ):
                continue
            name = next(
                (
                    enum.name.replace(" ", "")
                    for enum in Vrm0BlendShapeGroupPropertyGroup.preset_name_enum
                    if enum.identifier == preset_name
                ),
                preset_name.capitalize(),
            )
            blend_shape_group_dicts.append(
                {
                    "name": name,
                    "presetName": preset_name,
                    "binds": [],
                    "materialValues": [],
                    "isBinary": False,
                }
            )

    def write_extensions_vrm_secondary_animation(
        self,
        _progress: Progress,
        vrm_dict: dict[str, Json],
        bone_name_to_node_index: Mapping[str, int],
    ) -> None:
        secondary_animation = get_armature_extension(
            self.armature_data
        ).vrm0.secondary_animation
        secondary_animation_dict: dict[str, Json] = {}
        vrm_dict["secondaryAnimation"] = secondary_animation_dict

        collider_group_dicts: list[Json] = []
        secondary_animation_dict["colliderGroups"] = collider_group_dicts
        secondary_animation = get_armature_extension(
            self.armature_data
        ).vrm0.secondary_animation

        collider_group_name_to_index: dict[str, int] = {}

        for collider_group in secondary_animation.collider_groups:
            node_index = bone_name_to_node_index.get(collider_group.node.bone_name)
            if node_index is None:
                continue

            collider_group_dict: dict[str, Json] = {}
            collider_dicts: list[Json] = []
            collider_group_dict["colliders"] = collider_dicts

            collider_group_name_to_index[collider_group.name] = len(
                collider_group_dicts
            )
            collider_group_dicts.append(collider_group_dict)

            collider_group_dict["node"] = node_index

            for collider in collider_group.colliders:
                collider_object = collider.bpy_object
                if (
                    not collider_object
                    or collider_object.parent_bone not in self.armature.pose.bones
                ):
                    continue

                collider_dict: dict[str, Json] = {}
                offset = [
                    collider_object.matrix_world.to_translation()[i]
                    - (
                        self.armature.matrix_world
                        @ Matrix.Translation(
                            self.armature.pose.bones[collider_object.parent_bone].head
                        )
                    ).to_translation()[i]
                    for i in range(3)
                ]

                object_mean_scale = statistics.mean(
                    abs(s) for s in collider_object.matrix_world.to_scale()
                )
                collider_dict["radius"] = (
                    collider_object.empty_display_size * object_mean_scale
                )

                collider_dict["offset"] = {
                    # https://github.com/vrm-c/UniVRM/issues/65
                    "x": -offset[0],
                    "y": offset[2],
                    "z": -offset[1],
                }
                collider_dicts.append(collider_dict)

        bone_group_dicts: list[Json] = []
        secondary_animation_dict["boneGroups"] = bone_group_dicts
        for bone_group in secondary_animation.bone_groups:
            bone_group_dict: dict[str, Json] = {
                "comment": bone_group.comment,
                "stiffiness": bone_group.stiffiness,
                "gravityPower": bone_group.gravity_power,
                "gravityDir": {
                    # TODO: firstPerson.firstPersonBoneOffset and BoneGroup.gravityDir
                    # axis conversion is original. Need to document this.
                    "x": bone_group.gravity_dir[0],
                    "y": bone_group.gravity_dir[2],
                    "z": bone_group.gravity_dir[1],
                },
                "dragForce": bone_group.drag_force,
                "center": bone_name_to_node_index.get(bone_group.center.bone_name, -1),
                "hitRadius": bone_group.hit_radius,
                "bones": [
                    node_index
                    for bone in bone_group.bones
                    if (node_index := bone_name_to_node_index.get(bone.bone_name))
                    is not None
                ],
            }
            collider_group_indices: list[Json] = []
            for collider_group_name in bone_group.collider_groups:
                collider_group_index = collider_group_name_to_index.get(
                    collider_group_name.value
                )
                if collider_group_index is None:
                    continue
                collider_group_indices.append(collider_group_index)

            bone_group_dict["colliderGroups"] = collider_group_indices
            bone_group_dicts.append(bone_group_dict)

    def write_extensions_vrm_meta(
        self,
        _progress: Progress,
        texture_dicts: list[dict[str, Json]],
        sampler_dicts: list[dict[str, Json]],
        image_dicts: list[dict[str, Json]],
        buffer_view_dicts: list[dict[str, Json]],
        vrm_dict: dict[str, Json],
        buffer0: bytearray,
        image_name_to_index_dict: dict[str, int],
    ) -> None:
        meta = get_armature_extension(self.armature_data).vrm0.meta
        meta_dict: dict[str, Json] = {
            "title": meta.title,
            "version": meta.version,
            "author": meta.author,
            "contactInformation": meta.contact_information,
            "reference": meta.reference,
            "allowedUserName": meta.allowed_user_name,
            "violentUssageName": meta.violent_ussage_name,
            "sexualUssageName": meta.sexual_ussage_name,
            "commercialUssageName": meta.commercial_ussage_name,
            "otherPermissionUrl": meta.other_permission_url,
            "licenseName": meta.license_name,
            "otherLicenseUrl": meta.other_license_url,
        }

        if meta.texture:
            image_index = self.find_or_create_image(
                image_dicts,
                buffer_view_dicts,
                buffer0,
                image_name_to_index_dict,
                meta.texture,
            )
            sampler_dict: dict[str, Json] = {
                "magFilter": GL_LINEAR,
                "minFilter": GL_LINEAR,
                "wrapS": GL_REPEAT,
                "wrapT": GL_REPEAT,
            }
            if sampler_dict in sampler_dicts:
                sampler_index = sampler_dicts.index(sampler_dict)
            else:
                sampler_index = len(sampler_dicts)
                sampler_dicts.append(sampler_dict)
            texture_index = len(texture_dicts)
            texture_dicts.append(
                {
                    "sampler": sampler_index,
                    "source": image_index,
                },
            )
            meta_dict["texture"] = texture_index

        vrm_dict["meta"] = meta_dict

    def write_extensions_vrm_first_person(
        self,
        _progress: Progress,
        vrm_dict: dict[str, Json],
        bone_name_to_node_index: Mapping[str, int],
        mesh_object_name_to_mesh_index: Mapping[str, int],
    ) -> None:
        first_person_dict: dict[str, Json] = {}
        vrm_dict["firstPerson"] = first_person_dict

        first_person = get_armature_extension(self.armature_data).vrm0.first_person

        if first_person.first_person_bone.bone_name:
            first_person_bone_name: Optional[str] = (
                first_person.first_person_bone.bone_name
            )
        else:
            first_person_bone_name = next(
                human_bone.node.bone_name
                for human_bone in (
                    get_armature_extension(self.armature_data).vrm0.humanoid.human_bones
                )
                if human_bone.bone == "head"
            )

        if first_person_bone_name:
            first_person_bone_index = bone_name_to_node_index.get(
                first_person_bone_name
            )
            if isinstance(first_person_bone_index, int):
                first_person_dict["firstPersonBone"] = first_person_bone_index

        first_person_dict["firstPersonBoneOffset"] = {
            # TODO: firstPerson.firstPersonBoneOffset and BoneGroup.gravityDir
            # axis conversion is original. Need to document this.
            "x": first_person.first_person_bone_offset[0],
            "y": first_person.first_person_bone_offset[2],
            "z": first_person.first_person_bone_offset[1],
        }

        mesh_annotation_dicts: list[Json] = []
        first_person_dict["meshAnnotations"] = mesh_annotation_dicts
        for mesh_annotation in first_person.mesh_annotations:
            mesh_index = mesh_object_name_to_mesh_index.get(
                mesh_annotation.mesh.mesh_object_name
            )
            if mesh_index is None:
                mesh_index = -1
            mesh_annotation_dicts.append(
                {
                    "mesh": mesh_index,
                    "firstPersonFlag": mesh_annotation.first_person_flag,
                }
            )
        first_person_dict["lookAtTypeName"] = first_person.look_at_type_name
        for look_at, look_at_dict_key in [
            (
                first_person.look_at_horizontal_inner,
                "lookAtHorizontalInner",
            ),
            (
                first_person.look_at_horizontal_outer,
                "lookAtHorizontalOuter",
            ),
            (
                first_person.look_at_vertical_down,
                "lookAtVerticalDown",
            ),
            (
                first_person.look_at_vertical_up,
                "lookAtVerticalUp",
            ),
        ]:
            first_person_dict[look_at_dict_key] = {
                "curve": list(look_at.curve),
                "xRange": look_at.x_range,
                "yRange": look_at.y_range,
            }

    def write_scene(
        self,
        progress: Progress,
        scene_dicts: list[dict[str, Json]],
        node_dicts: list[dict[str, Json]],
        mesh_dicts: list[dict[str, Json]],
        skin_dicts: list[dict[str, Json]],
        material_dicts: list[dict[str, Json]],
        accessor_dicts: list[dict[str, Json]],
        buffer_view_dicts: list[dict[str, Json]],
        extensions_vrm_material_property_dicts: list[Json],
        buffer0: bytearray,
        bone_name_to_node_index: dict[str, int],
        mesh_object_name_to_mesh_index: dict[str, int],
        material_name_to_material_index: Mapping[str, int],
    ) -> None:
        scene0_nodes: list[Json] = []

        armature_root_node_indices, skin_dict, skin_joints = self.write_armature(
            progress,
            node_dicts,
            accessor_dicts,
            buffer_view_dicts,
            buffer0,
            bone_name_to_node_index,
        )
        scene0_nodes.extend(armature_root_node_indices)
        scene0_nodes.extend(
            self.write_mesh_nodes(
                progress,
                node_dicts,
                mesh_dicts,
                skin_dicts,
                material_dicts,
                accessor_dicts,
                buffer_view_dicts,
                extensions_vrm_material_property_dicts,
                buffer0,
                bone_name_to_node_index,
                mesh_object_name_to_mesh_index,
                material_name_to_material_index,
                skin_dict,
                skin_joints,
            )
        )

        scene_dicts.append({"nodes": scene0_nodes})

    def write_mtoon1_downgraded_material(
        self,
        _progress: Progress,
        texture_dicts: list[dict[str, Json]],
        sampler_dicts: list[dict[str, Json]],
        image_dicts: list[dict[str, Json]],
        buffer_view_dicts: list[dict[str, Json]],
        extensions_used: list[str],
        buffer0: bytearray,
        image_name_to_image_index: dict[str, int],
        material: Material,
        material_dict: dict[str, Json],
        vrm_material_property_dict: dict[str, Json],
    ) -> None:
        gltf = get_material_extension(material).mtoon1
        mtoon = gltf.extensions.vrmc_materials_mtoon

        material_dict |= make_json_dict(
            {
                "alphaMode": gltf.alpha_mode,
                "doubleSided": gltf.double_sided,
                "extensions": {"KHR_materials_unlit": {}},
            }
        )

        extensions_used.append("KHR_materials_unlit")
        pbr_metallic_roughness_dict: dict[str, Json] = {
            "metallicFactor": 0,
            "roughnessFactor": 0.9,
        }
        keyword_map: dict[str, bool] = {}
        tag_map: dict[str, str] = {}
        float_properties: dict[str, float] = {}
        vector_properties: dict[str, Sequence[float]] = {}
        texture_properties: dict[str, int] = {}
        pbr_metallic_roughness_dict["baseColorFactor"] = list(
            gltf.pbr_metallic_roughness.base_color_factor
        )
        extensions = gltf.pbr_metallic_roughness.base_color_texture.extensions
        khr_texture_transform = extensions.khr_texture_transform

        vector_properties["_Color"] = convert.linear_to_srgb(
            gltf.pbr_metallic_roughness.base_color_factor
        )

        if assign_dict(
            pbr_metallic_roughness_dict,
            "baseColorTexture",
            self.create_mtoon1_downgraded_texture_info(
                gltf.pbr_metallic_roughness.base_color_texture,
                texture_properties,
                "_MainTex",
                vector_properties,
                texture_dicts,
                sampler_dicts,
                image_dicts,
                buffer_view_dicts,
                buffer0,
                image_name_to_image_index,
                khr_texture_transform,
            ),
        ):
            vector_properties["_MainTex"] = [
                khr_texture_transform.offset[0],
                khr_texture_transform.offset[1],
                khr_texture_transform.scale[0],
                khr_texture_transform.scale[1],
            ]
            extensions_used.append("KHR_texture_transform")

        vector_properties["_ShadeColor"] = convert.linear_to_srgb(
            [*mtoon.shade_color_factor, 1]
        )
        self.create_mtoon1_downgraded_texture(
            mtoon.shade_multiply_texture.index,
            texture_properties,
            "_ShadeTexture",
            vector_properties,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            buffer0,
            image_name_to_image_index,
        )

        float_properties["_BumpScale"] = gltf.normal_texture.scale
        if assign_dict(
            material_dict,
            "normalTexture",
            self.create_mtoon1_downgraded_texture_info(
                gltf.normal_texture,
                texture_properties,
                "_BumpMap",
                vector_properties,
                texture_dicts,
                sampler_dicts,
                image_dicts,
                buffer_view_dicts,
                buffer0,
                image_name_to_image_index,
                khr_texture_transform,
            ),
        ):
            normal_texture_to_index_dict = material_dict.get("normalTexture")
            if isinstance(normal_texture_to_index_dict, dict):
                normal_texture_to_index_dict["scale"] = gltf.normal_texture.scale
            keyword_map["_NORMALMAP"] = True

        self.create_mtoon1_downgraded_texture(
            gltf.mtoon0_shading_grade_texture,
            texture_properties,
            "_ShadingGradeTexture",
            vector_properties,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            buffer0,
            image_name_to_image_index,
        )
        float_properties["_ShadingGradeRate"] = gltf.mtoon0_shading_grade_rate

        float_properties["_ShadeShift"] = convert.mtoon_shading_shift_1_to_0(
            mtoon.shading_toony_factor, mtoon.shading_shift_factor
        )
        float_properties["_ShadeToony"] = convert.mtoon_shading_toony_1_to_0(
            mtoon.shading_toony_factor, mtoon.shading_shift_factor
        )
        float_properties["_IndirectLightIntensity"] = (
            convert.mtoon_gi_equalization_to_intensity(mtoon.gi_equalization_factor)
        )
        float_properties["_RimLightingMix"] = gltf.mtoon0_rim_lighting_mix
        float_properties["_RimFresnelPower"] = mtoon.parametric_rim_fresnel_power_factor
        float_properties["_RimLift"] = mtoon.parametric_rim_lift_factor

        emissive_strength = (
            gltf.extensions.khr_materials_emissive_strength.emissive_strength
        )
        emissive_factor = Vector(gltf.emissive_factor)
        hdr_emissive_factor = (
            Vector(convert.linear_to_srgb(emissive_factor)) * emissive_strength
        )
        vector_properties["_EmissionColor"] = [*hdr_emissive_factor, 1]
        if emissive_factor.length_squared > 0:
            material_dict["emissiveFactor"] = list(emissive_factor)

        assign_dict(
            material_dict,
            "emissiveTexture",
            self.create_mtoon1_downgraded_texture_info(
                gltf.emissive_texture,
                texture_properties,
                "_EmissionMap",
                vector_properties,
                texture_dicts,
                sampler_dicts,
                image_dicts,
                buffer_view_dicts,
                buffer0,
                image_name_to_image_index,
                khr_texture_transform,
            ),
        )
        if pbr_metallic_roughness_dict:
            material_dict["pbrMetallicRoughness"] = pbr_metallic_roughness_dict

        self.create_mtoon1_downgraded_texture(
            mtoon.matcap_texture.index,
            texture_properties,
            "_SphereAdd",
            vector_properties,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            buffer0,
            image_name_to_image_index,
        )

        vector_properties["_RimColor"] = convert.linear_to_srgb(
            [*mtoon.parametric_rim_color_factor, 1]
        )
        self.create_mtoon1_downgraded_texture(
            mtoon.rim_multiply_texture.index,
            texture_properties,
            "_RimTexture",
            vector_properties,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            buffer0,
            image_name_to_image_index,
        )

        vector_properties["_OutlineColor"] = convert.linear_to_srgb(
            [*mtoon.outline_color_factor, 1]
        )
        self.create_mtoon1_downgraded_texture(
            mtoon.outline_width_multiply_texture.index,
            texture_properties,
            "_OutlineWidthTexture",
            vector_properties,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            buffer0,
            image_name_to_image_index,
        )

        float_properties["_UvAnimScrollX"] = mtoon.uv_animation_scroll_x_speed_factor
        float_properties["_UvAnimScrollY"] = -mtoon.uv_animation_scroll_y_speed_factor
        float_properties["_UvAnimRotation"] = mtoon.uv_animation_rotation_speed_factor
        self.create_mtoon1_downgraded_texture(
            mtoon.uv_animation_mask_texture.index,
            texture_properties,
            "_UvAnimMaskTexture",
            vector_properties,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            buffer0,
            image_name_to_image_index,
        )

        float_properties["_OutlineLightingMix"] = mtoon.outline_lighting_mix_factor
        outline_color_mode = 1 if mtoon.outline_lighting_mix_factor > 0 else 0
        float_properties["_OutlineColorMode"] = outline_color_mode

        float_properties["_OutlineWidth"] = 0.0
        outline_width_world = False
        outline_width_screen = False
        outline_color_fixed = False
        outline_color_mixed = False
        if mtoon.outline_width_mode == mtoon.OUTLINE_WIDTH_MODE_NONE.identifier:
            float_properties["_OutlineWidthMode"] = 0
            float_properties["_OutlineLightingMix"] = 0
            float_properties["_OutlineColorMode"] = 0
        elif (
            mtoon.outline_width_mode
            == mtoon.OUTLINE_WIDTH_MODE_WORLD_COORDINATES.identifier
        ):
            float_properties["_OutlineWidth"] = mtoon.outline_width_factor * 100
            float_properties["_OutlineWidthMode"] = 1
            outline_width_world = True
            if outline_color_mode == 0:
                outline_color_fixed = True
            else:
                outline_color_mixed = True
        elif (
            mtoon.outline_width_mode
            == mtoon.OUTLINE_WIDTH_MODE_SCREEN_COORDINATES.identifier
        ):
            float_properties["_OutlineWidth"] = mtoon.outline_width_factor * 200
            float_properties["_OutlineWidthMode"] = 2
            outline_width_screen = True
            if outline_color_mode == 0:
                outline_color_fixed = True
            else:
                outline_color_mixed = True

        if outline_width_world:
            keyword_map["MTOON_OUTLINE_WIDTH_WORLD"] = True
        elif outline_width_screen:
            keyword_map["MTOON_OUTLINE_WIDTH_SCREEN"] = True

        if outline_color_fixed:
            keyword_map["MTOON_OUTLINE_COLOR_FIXED"] = outline_color_fixed
        elif outline_color_mixed:
            keyword_map["MTOON_OUTLINE_COLOR_MIXED"] = outline_color_mixed

        float_properties["_Cutoff"] = 0.5
        if gltf.alpha_mode == gltf.ALPHA_MODE_OPAQUE.identifier:
            blend_mode = 0
            src_blend = 1
            dst_blend = 0
            z_write = 1
            alphatest_on = False
            alphablend_on = False
            render_queue = -1
            render_type = "Opaque"
        elif gltf.alpha_mode == gltf.ALPHA_MODE_MASK.identifier:
            blend_mode = 1
            src_blend = 1
            dst_blend = 0
            z_write = 1
            alphatest_on = True
            alphablend_on = False
            render_queue = gltf.mtoon0_render_queue
            render_type = "TransparentCutout"
            float_properties["_Cutoff"] = gltf.alpha_cutoff
            material_dict["alphaCutoff"] = gltf.alpha_cutoff
        elif not mtoon.transparent_with_z_write:
            blend_mode = 2
            src_blend = 5
            dst_blend = 10
            z_write = 0
            alphatest_on = False
            alphablend_on = True
            render_queue = gltf.mtoon0_render_queue
            render_type = "Transparent"
        else:
            blend_mode = 3
            src_blend = 5
            dst_blend = 10
            z_write = 1
            alphatest_on = False
            alphablend_on = True
            render_queue = gltf.mtoon0_render_queue
            render_type = "Transparent"
            float_properties["_Cutoff"] = gltf.alpha_cutoff  # for compatibility

        self.create_mtoon1_downgraded_texture(
            gltf.mtoon0_receive_shadow_texture,
            texture_properties,
            "_ReceiveShadowTexture",
            vector_properties,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            buffer0,
            image_name_to_image_index,
        )
        float_properties["_ReceiveShadowRate"] = gltf.mtoon0_receive_shadow_rate

        keyword_map["_ALPHABLEND_ON"] = alphablend_on
        keyword_map["_ALPHAPREMULTIPLY_ON"] = False

        float_properties["_BlendMode"] = blend_mode
        float_properties["_SrcBlend"] = src_blend
        float_properties["_DstBlend"] = dst_blend
        float_properties["_ZWrite"] = z_write
        if alphatest_on:
            keyword_map["_ALPHATEST_ON"] = alphatest_on
        tag_map["RenderType"] = render_type

        float_properties["_MToonVersion"] = MtoonUnversioned.version
        if gltf.mtoon0_front_cull_mode:
            float_properties["_CullMode"] = 1
        elif material.use_backface_culling:
            float_properties["_CullMode"] = 2
        else:
            float_properties["_CullMode"] = 0
        float_properties["_OutlineCullMode"] = 1
        float_properties["_DebugMode"] = 0
        float_properties["_LightColorAttenuation"] = gltf.mtoon0_light_color_attenuation
        float_properties["_OutlineScaledMaxDistance"] = (
            gltf.mtoon0_outline_scaled_max_distance
        )

        keyword_map["MTOON_DEBUG_NORMAL"] = False
        keyword_map["MTOON_DEBUG_LITSHADERATE"] = False

        vrm_material_property_dict.update(
            {
                "name": material.name,
                "shader": "VRM/MToon",
                "keywordMap": make_json(keyword_map),
                "tagMap": make_json(tag_map),
                "floatProperties": make_json(float_properties),
                "vectorProperties": make_json(vector_properties),
                "textureProperties": make_json(texture_properties),
                "renderQueue": render_queue,
            }
        )

    def find_or_create_image(
        self,
        image_dicts: list[dict[str, Json]],
        buffer_view_dicts: list[dict[str, Json]],
        buffer0: bytearray,
        image_name_to_index_dict: dict[str, int],
        image: Image,
    ) -> int:
        image_index = image_name_to_index_dict.get(image.name)
        if isinstance(image_index, int):
            return image_index

        image_bytes, mime = image_to_image_bytes(
            image, self.gltf2_addon_export_settings
        )

        image_buffer_view_index = len(buffer_view_dicts)
        buffer_view_dicts.append(
            {
                "buffer": 0,
                "byteOffset": len(buffer0),
                "byteLength": len(image_bytes),
            }
        )
        buffer0.extend(image_bytes)

        image_index = len(image_dicts)
        image_dicts.append(
            {
                "name": image.name,
                "mimeType": mime,
                "bufferView": image_buffer_view_index,
            }
        )

        image_name_to_index_dict[image.name] = image_index

        return image_index

    def create_mtoon0_khr_texture_transform(
        self, node: Node, texture_input_name: str
    ) -> tuple[dict[str, Json], tuple[float, float, float, float]]:
        default: tuple[dict[str, Json], tuple[float, float, float, float]] = (
            {
                "offset": [0, 0],
                "scale": [1, 1],
            },
            (0, 0, 1, 1),
        )

        texture_input = node.inputs.get(texture_input_name)
        if not texture_input:
            return default

        texture_input_links = texture_input.links
        if not texture_input_links:
            return default

        x_from_node = texture_input_links[0].from_node
        if not x_from_node:
            return default

        x_inputs = x_from_node.inputs
        if not x_inputs:
            return default

        x_links = x_inputs[0].links
        if not x_links:
            return default

        uv_offset_scaling_node = x_links[0].from_node
        if not uv_offset_scaling_node or uv_offset_scaling_node.type != "MAPPING'":
            return default

        location_input = uv_offset_scaling_node.inputs.get("Location")
        offset = (0.0, 0.0)
        if isinstance(location_input, shader.VECTOR_SOCKET_CLASSES):
            offset = (
                location_input.default_value[0],
                location_input.default_value[1],
            )

        scale_input = uv_offset_scaling_node.inputs.get("Scale")
        scale = (1.0, 1.0)
        if isinstance(scale_input, shader.VECTOR_SOCKET_CLASSES):
            scale = (
                scale_input.default_value[0],
                scale_input.default_value[1],
            )

        return (
            {
                "offset": list(offset),
                "scale": list(scale),
            },
            (*offset, *scale),
        )

    def create_mtoon0_texture_info_dict(
        self,
        context: Context,
        texture_dicts: list[dict[str, Json]],
        sampler_dicts: list[dict[str, Json]],
        image_dicts: list[dict[str, Json]],
        buffer_view_dicts: list[dict[str, Json]],
        extensions_used: list[str],
        buffer0: bytearray,
        node: Node,
        texture_input_name: str,
        image_name_to_index_dict: dict[str, int],
        *,
        use_khr_texture_transform: bool = False,
    ) -> Optional[tuple[dict[str, Json], int, tuple[float, float, float, float]]]:
        image_name_and_sampler_type = shader.get_image_name_and_sampler_type(
            node, texture_input_name
        )
        if image_name_and_sampler_type is None:
            return None

        image_name, wrap_type, filter_type = image_name_and_sampler_type
        image_index = self.find_or_create_image(
            image_dicts,
            buffer_view_dicts,
            buffer0,
            image_name_to_index_dict,
            context.blend_data.images[image_name],
        )

        sampler_dict: dict[str, Json] = {
            "magFilter": filter_type,
            "minFilter": filter_type,
            "wrapS": wrap_type,
            "wrapT": wrap_type,
        }
        if sampler_dict in sampler_dicts:
            sampler_index = sampler_dicts.index(sampler_dict)
        else:
            sampler_index = len(sampler_dicts)
            sampler_dicts.append(sampler_dict)

        texture_dict: dict[str, Json] = {
            "sampler": sampler_index,
            "source": image_index,
        }

        if texture_dict in texture_dicts:
            texture_index = texture_dicts.index(texture_dict)
        else:
            texture_index = len(texture_dicts)
            texture_dicts.append(texture_dict)

        khr_texture_transform_dict, vector_property = (
            self.create_mtoon0_khr_texture_transform(node, texture_input_name)
        )

        texture_info: dict[str, Json] = {"index": texture_index}

        if use_khr_texture_transform:
            texture_info["extensions"] = {
                "KHR_texture_transform": khr_texture_transform_dict
            }
            extensions_used.append("KHR_texture_transform")

        return (
            texture_info,
            texture_index,
            vector_property,
        )

    def write_legacy_mtoon_unversioned_material(
        self,
        _progress: Progress,
        texture_dicts: list[dict[str, Json]],
        sampler_dicts: list[dict[str, Json]],
        image_dicts: list[dict[str, Json]],
        buffer_view_dicts: list[dict[str, Json]],
        extensions_used: list[str],
        buffer0: bytearray,
        image_name_to_image_index: dict[str, int],
        material: Material,
        material_dict: dict[str, Json],
        vrm_material_property_dict: dict[str, Json],
        node: ShaderNodeGroup,
    ) -> None:
        keyword_map: dict[str, bool] = {}
        tag_map: dict[str, str] = {}
        texture_properties: dict[str, int] = {}
        material_dict |= make_json_dict(
            {
                "name": material.name,
                "extensions": {"KHR_materials_unlit": {}},
                "doubleSided": not material.use_backface_culling,
            }
        )
        extensions_used.append("KHR_materials_unlit")
        pbr_metallic_roughness_dict: dict[str, Json] = {
            "metallicFactor": 0,
            "roughnessFactor": 0.9,
        }

        color = shader.get_rgba_value_or(
            node,
            "DiffuseColor",
            0.0,
            1.0,
            (
                1.0,
                1.0,
                1.0,
                1.0,
            ),
        )
        emission_color = shader.get_rgba_value(node, "EmissionColor", 0.0, 1.0)
        if emission_color is None:
            emission_color = (0.0, 0.0, 0.0, 1.0)
        else:
            material_dict["emissiveFactor"] = make_json(emission_color[:3])

        pbr_metallic_roughness_dict["baseColorFactor"] = make_json(color)

        vector_properties: dict[str, Sequence[float]] = {
            "_Color": color,
            "_EmissionColor": emission_color,
            "_ShadeColor": shader.get_rgba_value_or(node, "ShadeColor", 0.0, 1.0),
            "_OutlineColor": shader.get_rgba_value_or(node, "OutlineColor", 0.0, 1.0),
            "_RimColor": shader.get_rgba_value_or(node, "RimColor", 0.0, 1.0),
        }

        float_properties: dict[str, Union[float, int]] = {
            "_MToonVersion": MtoonUnversioned.version,
            "_DebugMode": 0,
            "_ShadeShift": shader.get_float_value_or(node, "ShadeShift"),
            "_ShadeToony": shader.get_float_value_or(
                node, "ShadeToony", default_value=0.5
            ),
            "_ShadingGradeRate": shader.get_float_value_or(
                node, "ShadingGradeRate", default_value=0.5
            ),
            "_ReceiveShadowRate": shader.get_float_value_or(
                node, "ReceiveShadowRate", default_value=0.5
            ),
            "_LightColorAttenuation": shader.get_float_value_or(
                node, "LightColorAttenuation", default_value=0.5
            ),
            "_IndirectLightIntensity": shader.get_float_value_or(
                node, "IndirectLightIntensity", default_value=0.5
            ),
            "_RimFresnelPower": shader.get_float_value_or(
                node, "RimFresnelPower", 0.0, float_info.max, default_value=1.0
            ),
            "_RimLift": shader.get_float_value_or(node, "RimLift"),
            "_RimLightingMix": shader.get_float_value_or(node, "RimLightingMix"),
            "_OutlineLightingMix": shader.get_float_value_or(
                node, "OutlineLightingMix"
            ),
            "_OutlineScaledMaxDistance": shader.get_float_value_or(
                node, "OutlineScaleMaxDistance"
            ),
            "_OutlineWidth": shader.get_float_value_or(node, "OutlineWidth"),
            "_UvAnimRotation": shader.get_float_value_or(node, "UV_Scroll_Rotation"),
            "_UvAnimScrollX": shader.get_float_value_or(node, "UV_Scroll_X"),
            "_UvAnimScrollY": shader.get_float_value_or(node, "UV_Scroll_Y"),
        }

        alpha_cutoff = shader.get_float_value(node, "CutoffRate", 0, float_info.max)
        if alpha_cutoff is not None:
            float_properties["_Cutoff"] = alpha_cutoff

        outline_width_mode = max(
            0, min(2, round(shader.get_float_value_or(node, "OutlineWidthMode")))
        )
        float_properties["_OutlineWidthMode"] = outline_width_mode
        if outline_width_mode == 1:
            keyword_map["MTOON_OUTLINE_WIDTH_WORLD"] = True
        elif outline_width_mode == 2:
            keyword_map["MTOON_OUTLINE_WIDTH_SCREEN"] = True

        outline_color_mode = max(
            0, min(2, round(shader.get_float_value_or(node, "OutlineColorMode")))
        )
        float_properties["_OutlineColorMode"] = outline_color_mode
        if outline_width_mode > 0:
            if outline_color_mode == 1:
                keyword_map["MTOON_OUTLINE_COLOR_MIXED"] = True
            elif outline_color_mode == 2:
                keyword_map["MTOON_OUTLINE_COLOR_FIXED"] = True

        if material.blend_method == "OPAQUE":
            tag_map["RenderType"] = "Opaque"
            material_dict["alphaMode"] = "OPAQUE"
            float_properties["_BlendMode"] = 0
            float_properties["_SrcBlend"] = 1
            float_properties["_DstBlend"] = 0
            float_properties["_ZWrite"] = 1
            float_properties["_OutlineCullMode"] = 1
            keyword_map["MTOON_DEBUG_LITSHADERATE"] = False
            keyword_map["MTOON_DEBUG_NORMAL"] = False
            keyword_map["_ALPHABLEND_ON"] = False
            keyword_map["_ALPHAPREMULTIPLY_ON"] = False
            render_queue = -1
        elif material.blend_method == "CLIP":
            tag_map["RenderType"] = "TransparentCutout"
            material_dict["alphaMode"] = "MASK"
            float_properties["_BlendMode"] = 1
            float_properties["_SrcBlend"] = 1
            float_properties["_DstBlend"] = 0
            float_properties["_ZWrite"] = 1
            float_properties["_OutlineCullMode"] = 1
            keyword_map["MTOON_DEBUG_LITSHADERATE"] = False
            keyword_map["MTOON_DEBUG_NORMAL"] = False
            keyword_map["_ALPHABLEND_ON"] = False
            keyword_map["_ALPHAPREMULTIPLY_ON"] = False
            keyword_map["_ALPHATEST_ON"] = True
            if alpha_cutoff is not None:
                material_dict["alphaCutoff"] = alpha_cutoff
            render_queue = 2450
        else:
            tag_map["RenderType"] = "Transparent"
            material_dict["alphaMode"] = "BLEND"
            float_properties["_SrcBlend"] = 5
            float_properties["_DstBlend"] = 10
            float_properties["_OutlineCullMode"] = 1
            keyword_map["MTOON_DEBUG_LITSHADERATE"] = False
            keyword_map["MTOON_DEBUG_NORMAL"] = False
            keyword_map["_ALPHABLEND_ON"] = True
            keyword_map["_ALPHAPREMULTIPLY_ON"] = False
            transparent_with_z_write = shader.get_float_value_or(
                node, "TransparentWithZWrite", 0
            )
            if transparent_with_z_write < float_info.epsilon:
                float_properties["_BlendMode"] = 2
                float_properties["_ZWrite"] = 0
                render_queue = 3000
            else:
                float_properties["_BlendMode"] = 3
                float_properties["_ZWrite"] = 1
                render_queue = 2501

        float_properties["_CullMode"] = 2 if material.use_backface_culling else 0

        # Control texture addition order for compatibility with old exporter

        main_tex = self.create_mtoon0_texture_info_dict(
            self.context,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            extensions_used,
            buffer0,
            node,
            "MainTexture",
            image_name_to_image_index,
            use_khr_texture_transform=True,
        )
        if main_tex:
            (
                base_color_texture_dict,
                main_tex_texture_property,
                main_tex_vector_property,
            ) = main_tex
            pbr_metallic_roughness_dict["baseColorTexture"] = base_color_texture_dict
            texture_properties["_MainTex"] = main_tex_texture_property
            vector_properties["_MainTex"] = main_tex_vector_property

        # TODO: For compatibility. There might be correct configuration values
        default_texture_vector_property = [0, 0, 1, 1]

        shade_texture = self.create_mtoon0_texture_info_dict(
            self.context,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            extensions_used,
            buffer0,
            node,
            "ShadeTexture",
            image_name_to_image_index,
        )
        if shade_texture:
            _, shade_texture_texture_property, _ = shade_texture
            texture_properties["_ShadeTexture"] = shade_texture_texture_property
            vector_properties["_ShadeTexture"] = default_texture_vector_property

        bump_scale = shader.get_float_value_or(node, "BumpScale", default_value=0.5)
        float_properties["_BumpScale"] = bump_scale

        bump_map = self.create_mtoon0_texture_info_dict(
            self.context,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            extensions_used,
            buffer0,
            node,
            "NormalmapTexture",
            image_name_to_image_index,
            use_khr_texture_transform=True,
        )
        if not bump_map:
            bump_map = self.create_mtoon0_texture_info_dict(
                self.context,
                texture_dicts,
                sampler_dicts,
                image_dicts,
                buffer_view_dicts,
                extensions_used,
                buffer0,
                node,
                "NomalmapTexture",
                image_name_to_image_index,
                use_khr_texture_transform=True,
            )
        if bump_map:
            normal_texture_info, bump_map_texture_property, _ = bump_map
            material_dict["normalTexture"] = normal_texture_info
            texture_properties["_BumpMap"] = bump_map_texture_property
            vector_properties["_BumpMap"] = default_texture_vector_property
            keyword_map["_NORMALMAP"] = True
            normal_texture_info["scale"] = bump_scale

        for socket_name, texture_property_key in {
            "ReceiveShadow_Texture": "_ReceiveShadowTexture",
            "ShadingGradeTexture": "_ShadingGradeTexture",
        }.items():
            texture = self.create_mtoon0_texture_info_dict(
                self.context,
                texture_dicts,
                sampler_dicts,
                image_dicts,
                buffer_view_dicts,
                extensions_used,
                buffer0,
                node,
                socket_name,
                image_name_to_image_index,
            )
            if not texture:
                continue
            _, texture_property, _ = texture
            texture_properties[texture_property_key] = texture_property
            vector_properties[texture_property_key] = default_texture_vector_property

        emission_map = self.create_mtoon0_texture_info_dict(
            self.context,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            extensions_used,
            buffer0,
            node,
            "Emission_Texture",
            image_name_to_image_index,
            use_khr_texture_transform=True,
        )
        if emission_map:
            emissive_texture_info, emission_map_texture_property, _ = emission_map
            material_dict["emissiveTexture"] = emissive_texture_info
            texture_properties["_EmissionMap"] = emission_map_texture_property
            vector_properties["_EmissionMap"] = default_texture_vector_property

        for socket_name, texture_property_key in {
            "SphereAddTexture": "_SphereAdd",
            "RimTexture": "_RimTexture",
            "OutlineWidthTexture": "_OutlineWidthTexture",
            "UV_Animation_Mask_Texture": "_UvAnimMaskTexture",
        }.items():
            texture = self.create_mtoon0_texture_info_dict(
                self.context,
                texture_dicts,
                sampler_dicts,
                image_dicts,
                buffer_view_dicts,
                extensions_used,
                buffer0,
                node,
                socket_name,
                image_name_to_image_index,
            )
            if not texture:
                continue
            _, texture_property, _ = texture
            texture_properties[texture_property_key] = texture_property
            vector_properties[texture_property_key] = default_texture_vector_property

        if pbr_metallic_roughness_dict:
            material_dict["pbrMetallicRoughness"] = pbr_metallic_roughness_dict

        vrm_material_property_dict.update(
            {
                "name": material.name,
                "shader": "VRM/MToon",
                "renderQueue": render_queue,
                "keywordMap": make_json(keyword_map),
                "tagMap": make_json(tag_map),
                "floatProperties": make_json(float_properties),
                "vectorProperties": make_json(vector_properties),
                "textureProperties": make_json(texture_properties),
            }
        )

    def write_legacy_gltf_material(
        self,
        _progress: Progress,
        texture_dicts: list[dict[str, Json]],
        sampler_dicts: list[dict[str, Json]],
        image_dicts: list[dict[str, Json]],
        buffer_view_dicts: list[dict[str, Json]],
        extensions_used: list[str],
        buffer0: bytearray,
        image_name_to_image_index: dict[str, int],
        material: Material,
        material_dict: dict[str, Json],
        vrm_material_property_dict: dict[str, Json],
        node: ShaderNodeGroup,
    ) -> None:
        vrm_material_property_dict |= make_json_dict(
            {
                "name": material.name,
                "shader": "VRM_USE_GLTFSHADER",
                "keywordMap": {},
                "tagMap": {},
                "floatProperties": {},
                "vectorProperties": {},
                "textureProperties": {},
                "extras": {"VRM_Addon_for_Blender_legacy_gltf_material": {}},
            }
        )

        pbr_metallic_roughness_dict: dict[str, Json] = {
            "baseColorFactor": list(
                shader.get_rgba_value_or(
                    node, "base_Color", 0.0, 1.0, default_value=(1, 1, 1, 1)
                )
            ),
            "metallicFactor": shader.get_float_value_or(node, "metallic", 0.0, 1.0),
            "roughnessFactor": shader.get_float_value_or(node, "roughness", 0.0, 1.0),
        }

        material_dict |= make_json_dict(
            {
                "name": material.name,
                "emissiveFactor": list(
                    shader.get_rgb_value_or(node, "emissive_color", 0.0, 1.0)
                ),
                "doubleSided": not material.use_backface_culling,
            }
        )

        if material.blend_method == "OPAQUE":
            material_dict["alphaMode"] = "OPAQUE"
        elif material.blend_method == "CLIP":
            material_dict["alphaMode"] = "MASK"
            material_dict["alphaCutoff"] = material.alpha_threshold
        else:
            material_dict["alphaMode"] = "BLEND"

        normal_texture = self.create_mtoon0_texture_info_dict(
            self.context,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            extensions_used,
            buffer0,
            node,
            "normal",
            image_name_to_image_index,
        )
        if normal_texture:
            (normal_texture_dict, _, _) = normal_texture
            material_dict["normalTexture"] = normal_texture_dict

        emissive_texture = self.create_mtoon0_texture_info_dict(
            self.context,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            extensions_used,
            buffer0,
            node,
            "emissive_texture",
            image_name_to_image_index,
        )
        if emissive_texture:
            (emissive_texture_dict, _, _) = emissive_texture
            material_dict["emissiveTexture"] = emissive_texture_dict

        base_color_texture = self.create_mtoon0_texture_info_dict(
            self.context,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            extensions_used,
            buffer0,
            node,
            "color_texture",
            image_name_to_image_index,
        )
        if base_color_texture:
            (base_color_texture_dict, _, _) = base_color_texture
            pbr_metallic_roughness_dict["baseColorTexture"] = base_color_texture_dict

        metallic_roughness_texture = self.create_mtoon0_texture_info_dict(
            self.context,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            extensions_used,
            buffer0,
            node,
            "metallic_roughness_texture",
            image_name_to_image_index,
        )
        if metallic_roughness_texture:
            (metallic_roughness_texture_dict, _, _) = metallic_roughness_texture
            pbr_metallic_roughness_dict["metallicRoughnessTexture"] = (
                metallic_roughness_texture_dict
            )

        occlusion_texture = self.create_mtoon0_texture_info_dict(
            self.context,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            extensions_used,
            buffer0,
            node,
            "occlusion_texture",
            image_name_to_image_index,
        )
        if occlusion_texture:
            (occlusion_texture_dict, _, _) = occlusion_texture
            pbr_metallic_roughness_dict["occlusionTexture"] = occlusion_texture_dict

        if pbr_metallic_roughness_dict:
            material_dict["pbrMetallicRoughness"] = pbr_metallic_roughness_dict

        if shader.get_float_value_or(node, "unlit") > 0.5:
            material_dict["extensions"] = {"KHR_materials_unlit": {}}
            extensions_used.append("KHR_materials_unlit")

    def write_legacy_transparent_zwrite_material(
        self,
        _progress: Progress,
        texture_dicts: list[dict[str, Json]],
        sampler_dicts: list[dict[str, Json]],
        image_dicts: list[dict[str, Json]],
        buffer_view_dicts: list[dict[str, Json]],
        extensions_used: list[str],
        buffer0: bytearray,
        image_name_to_image_index: dict[str, int],
        material: Material,
        material_dict: dict[str, Json],
        vrm_material_property_dict: dict[str, Json],
        node: ShaderNodeGroup,
    ) -> None:
        vector_properties: dict[str, Sequence[float]] = {}
        texture_properties: dict[str, int] = {}

        vrm_material_property_dict |= make_json_dict(
            {
                "name": material.name,
                "shader": "VRM/UnlitTransparentZWrite",
                "renderQueue": 2600,
                "keywordMap": {},
                "tagMap": {"RenderType": "Transparent"},
                "floatProperties": {},
            }
        )

        pbr_metallic_roughness_dict: dict[str, Json] = {
            "baseColorFactor": [1, 1, 1, 1],
            "metallicFactor": 0,
            "roughnessFactor": 0.9,
        }

        material_dict |= make_json_dict(
            {
                "name": material.name,
                "alphaMode": "BLEND",
                "doubleSided": False,
                "extensions": {"KHR_materials_unlit": {}},
            }
        )

        if pbr_metallic_roughness_dict:
            material_dict["pbrMetallicRoughness"] = pbr_metallic_roughness_dict

        main_tex = self.create_mtoon0_texture_info_dict(
            self.context,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            extensions_used,
            buffer0,
            node,
            "Main_Texture",
            image_name_to_image_index,
        )
        if main_tex:
            (
                base_color_texture_dict,
                main_tex_texture_property,
                main_tex_vector_property,
            ) = main_tex
            pbr_metallic_roughness_dict["baseColorTexture"] = base_color_texture_dict
            texture_properties["_MainTex"] = main_tex_texture_property
            vector_properties["_MainTex"] = main_tex_vector_property

        vrm_material_property_dict["vectorProperties"] = make_json(vector_properties)
        vrm_material_property_dict["textureProperties"] = make_json(texture_properties)

    def write_gltf_material(
        self,
        _progress: Progress,
        texture_dicts: list[dict[str, Json]],
        sampler_dicts: list[dict[str, Json]],
        image_dicts: list[dict[str, Json]],
        buffer_view_dicts: list[dict[str, Json]],
        extensions_used: list[str],
        buffer0: bytearray,
        gltf2_io_texture_images: list[Gltf2IoTextureImage],
        material: Material,
        material_dict: dict[str, Json],
        vrm_material_property_dict: dict[str, Json],
    ) -> None:
        vrm_material_property_dict.update(
            {
                "name": material.name,
                "shader": "VRM_USE_GLTFSHADER",
                "keywordMap": {},
                "tagMap": {},
                "floatProperties": {},
                "vectorProperties": {},
                "textureProperties": {},
            }
        )

        gltf2_io_material = gather_gltf2_io_material(
            material, self.gltf2_addon_export_settings
        )
        if not gltf2_io_material:
            return

        try:
            alpha_cutoff = convert.float_or_none(
                getattr(gltf2_io_material, "alpha_cutoff", None)
            )
            if alpha_cutoff is not None:
                material_dict["alphaCutoff"] = alpha_cutoff

            alpha_mode = getattr(gltf2_io_material, "alpha_mode", None)
            if isinstance(alpha_mode, str):
                material_dict["alphaMode"] = alpha_mode

            double_sided = getattr(gltf2_io_material, "double_sided", None)
            if isinstance(double_sided, bool):
                material_dict["doubleSided"] = double_sided

            emissive_factor = convert.sequence_or_none(
                getattr(gltf2_io_material, "emissive_factor", None)
            )
            if emissive_factor is not None:
                material_dict["emissiveFactor"] = make_json(emissive_factor)

            assign_dict(
                material_dict,
                "emissiveTexture",
                self.create_gltf2_io_texture(
                    getattr(gltf2_io_material, "emissive_texture", None),
                    texture_dicts,
                    sampler_dicts,
                    image_dicts,
                    buffer_view_dicts,
                    buffer0,
                    gltf2_io_texture_images,
                ),
            )

            extensions = convert.mapping_or_none(
                getattr(gltf2_io_material, "extensions", None)
            )
            if extensions is not None:
                extensions_dict: dict[str, Json] = {}

                # https://github.com/KhronosGroup/glTF/tree/19a1d820040239bca1327fc26220ae8cae9f948c/extensions/2.0/Khronos/KHR_materials_unlit
                if extensions.get("KHR_materials_unlit") is not None:
                    extensions_dict["KHR_materials_unlit"] = {}
                    extensions_used.append("KHR_materials_unlit")

                # https://github.com/KhronosGroup/glTF/blob/9c4a3567384b4d9f2706cdd9623bbb5ca7b341ad/extensions/2.0/Khronos/KHR_materials_emissive_strength
                khr_materials_emissive_strength = convert.mapping_or_none(
                    getattr(
                        extensions.get("KHR_materials_emissive_strength"),
                        "extension",
                        None,
                    )
                )
                if isinstance(khr_materials_emissive_strength, dict):
                    emissive_strength = convert.float_or_none(
                        khr_materials_emissive_strength.get("emissiveStrength")
                    )
                    if (
                        emissive_strength is not None
                        and emissive_strength >= 0
                        and emissive_strength != 1.0
                    ):
                        extensions_dict["KHR_materials_emissive_strength"] = {
                            "emissiveStrength": emissive_strength,
                        }
                        extensions_used.append("KHR_materials_emissive_strength")

                if extensions_dict:
                    material_dict["extensions"] = extensions_dict

            assign_dict(
                material_dict,
                "normalTexture",
                self.create_gltf2_io_texture(
                    getattr(gltf2_io_material, "normal_texture", None),
                    texture_dicts,
                    sampler_dicts,
                    image_dicts,
                    buffer_view_dicts,
                    buffer0,
                    gltf2_io_texture_images,
                ),
            )

            assign_dict(
                material_dict,
                "occlusionTexture",
                self.create_gltf2_io_texture(
                    getattr(gltf2_io_material, "occlusion_texture", None),
                    texture_dicts,
                    sampler_dicts,
                    image_dicts,
                    buffer_view_dicts,
                    buffer0,
                    gltf2_io_texture_images,
                ),
            )

            pbr_metallic_roughness = getattr(
                gltf2_io_material, "pbr_metallic_roughness", None
            )
            if pbr_metallic_roughness is not None:
                pbr_metallic_roughness_dict: dict[str, Json] = {}

                base_color_factor = convert.sequence_or_none(
                    getattr(pbr_metallic_roughness, "base_color_factor", None)
                )
                if base_color_factor is not None:
                    pbr_metallic_roughness_dict["baseColorFactor"] = make_json(
                        base_color_factor
                    )

                assign_dict(
                    pbr_metallic_roughness_dict,
                    "baseColorTexture",
                    self.create_gltf2_io_texture(
                        getattr(pbr_metallic_roughness, "base_color_texture", None),
                        texture_dicts,
                        sampler_dicts,
                        image_dicts,
                        buffer_view_dicts,
                        buffer0,
                        gltf2_io_texture_images,
                    ),
                )

                metallic_factor = convert.float_or_none(
                    getattr(pbr_metallic_roughness, "metallic_factor", None)
                )
                if metallic_factor is not None:
                    pbr_metallic_roughness_dict["metallicFactor"] = metallic_factor

                assign_dict(
                    pbr_metallic_roughness_dict,
                    "metallicRoughnessTexture",
                    self.create_gltf2_io_texture(
                        getattr(
                            pbr_metallic_roughness, "metallic_roughness_texture", None
                        ),
                        texture_dicts,
                        sampler_dicts,
                        image_dicts,
                        buffer_view_dicts,
                        buffer0,
                        gltf2_io_texture_images,
                    ),
                )

                roughness_factor = convert.float_or_none(
                    getattr(pbr_metallic_roughness, "roughness_factor", None)
                )
                if roughness_factor is not None:
                    pbr_metallic_roughness_dict["roughnessFactor"] = roughness_factor

                if pbr_metallic_roughness_dict:
                    material_dict["pbrMetallicRoughness"] = pbr_metallic_roughness_dict
        except Exception:
            logger.exception("Failed to generate glTF Material using glTF 2.0 add-on")

    def write_material(
        self,
        progress: Progress,
        material_dicts: list[dict[str, Json]],
        texture_dicts: list[dict[str, Json]],
        sampler_dicts: list[dict[str, Json]],
        image_dicts: list[dict[str, Json]],
        buffer_view_dicts: list[dict[str, Json]],
        extensions_vrm_material_property_dicts: list[Json],
        extensions_used: list[str],
        buffer0: bytearray,
        material_name_to_material_index: dict[str, int],
        image_name_to_image_index: dict[str, int],
        gltf2_io_texture_images: list[Gltf2IoTextureImage],
        material: Material,
    ) -> None:
        material_dict: dict[str, Json] = {
            "name": material.name,
        }
        vrm_material_property_dict: dict[str, Json] = {
            "name": material.name,
            "shader": "VRM_USE_GLTFSHADER",
        }
        material_index = len(material_dicts)
        material_name_to_material_index[material.name] = material_index
        material_dicts.append(material_dict)
        extensions_vrm_material_property_dicts.append(vrm_material_property_dict)

        mtoon1 = get_material_extension(material).mtoon1
        if mtoon1.enabled:
            self.write_mtoon1_downgraded_material(
                progress,
                texture_dicts,
                sampler_dicts,
                image_dicts,
                buffer_view_dicts,
                extensions_used,
                buffer0,
                image_name_to_image_index,
                material,
                material_dict,
                vrm_material_property_dict,
            )
            return

        legacy_shader_node_group, legacy_shader_name = search.legacy_shader_node(
            material
        )
        if not legacy_shader_node_group:
            pass
        elif legacy_shader_name == "MToon_unversioned":
            self.write_legacy_mtoon_unversioned_material(
                progress,
                texture_dicts,
                sampler_dicts,
                image_dicts,
                buffer_view_dicts,
                extensions_used,
                buffer0,
                image_name_to_image_index,
                material,
                material_dict,
                vrm_material_property_dict,
                legacy_shader_node_group,
            )
            return
        elif legacy_shader_name == "GLTF":
            self.write_legacy_gltf_material(
                progress,
                texture_dicts,
                sampler_dicts,
                image_dicts,
                buffer_view_dicts,
                extensions_used,
                buffer0,
                image_name_to_image_index,
                material,
                material_dict,
                vrm_material_property_dict,
                legacy_shader_node_group,
            )
            return
        elif legacy_shader_name == "TRANSPARENT_ZWRITE":
            self.write_legacy_transparent_zwrite_material(
                progress,
                texture_dicts,
                sampler_dicts,
                image_dicts,
                buffer_view_dicts,
                extensions_used,
                buffer0,
                image_name_to_image_index,
                material,
                material_dict,
                vrm_material_property_dict,
                legacy_shader_node_group,
            )
            return

        self.write_gltf_material(
            progress,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            extensions_used,
            buffer0,
            gltf2_io_texture_images,
            material,
            material_dict,
            vrm_material_property_dict,
        )

    def write_materials(
        self,
        _progress: Progress,
        material_dicts: list[dict[str, Json]],
        texture_dicts: list[dict[str, Json]],
        sampler_dicts: list[dict[str, Json]],
        image_dicts: list[dict[str, Json]],
        buffer_view_dicts: list[dict[str, Json]],
        extensions_vrm_material_property_dicts: list[Json],
        extensions_used: list[str],
        buffer0: bytearray,
        material_name_to_material_index: dict[str, int],
        image_name_to_image_index: dict[str, int],
    ) -> None:
        gltf2_io_texture_images: list[Vrm0Exporter.Gltf2IoTextureImage] = []
        for material in search.export_materials(self.context, self.export_objects):
            self.write_material(
                _progress,
                material_dicts,
                texture_dicts,
                sampler_dicts,
                image_dicts,
                buffer_view_dicts,
                extensions_vrm_material_property_dicts,
                extensions_used,
                buffer0,
                material_name_to_material_index,
                image_name_to_image_index,
                gltf2_io_texture_images,
                material,
            )

    def write_armature(
        self,
        progress: Progress,
        node_dicts: list[dict[str, Json]],
        accessor_dicts: list[dict[str, Json]],
        buffer_view_dicts: list[dict[str, Json]],
        buffer0: bytearray,
        bone_name_to_node_index: dict[str, int],
    ) -> tuple[Sequence[int], Mapping[str, Json], Sequence[int]]:
        bones = [bone for bone in self.armature.pose.bones if not bone.parent]
        root_node_index = len(node_dicts)
        if not bones:
            logger.error("No bones")
            node_dicts.append(
                {
                    "name": self.armature.name,
                }
            )
            return [root_node_index], {}, []

        bone_name_to_inverse_bind_matrix: dict[str, Matrix] = {}
        humanoid_root_bone_index: Optional[int] = None
        humanoid_root_bone: Optional[PoseBone] = None

        if len(bones) == 1:
            humanoid_root_bone = bones[0]
            humanoid_root_bone_index = self.write_armature_bone_nodes(
                progress,
                node_dicts,
                buffer0,
                bones[0],
                bone_name_to_node_index,
                bone_name_to_inverse_bind_matrix,
            )
            scene_node_indices: list[int] = [humanoid_root_bone_index]
        else:
            # If there are multiple root bones, expand each to the scene
            # This follows the old exporter specification, but I'm not
            # confident it's correct
            # It might be better to create a parent bone and attach skin to it
            scene_node_indices = []
            for bone in bones:
                root_node_index = self.write_armature_bone_nodes(
                    progress,
                    node_dicts,
                    buffer0,
                    bone,
                    bone_name_to_node_index,
                    bone_name_to_inverse_bind_matrix,
                )
                scene_node_indices.append(root_node_index)

                # Check if there are bones belonging to Humanoid,
                # and set them as humanoid_root_bone_index
                humanoid = get_armature_extension(self.armature_data).vrm0.humanoid
                traversing_bones = [bone]
                while traversing_bones:
                    traversing_bone = traversing_bones.pop()
                    if any(
                        human_bone.node.bone_name == traversing_bone.name
                        for human_bone in humanoid.human_bones
                    ):
                        humanoid_root_bone = bone
                        humanoid_root_bone_index = root_node_index
                        break
                    traversing_bones.extend(traversing_bone.children)

        if humanoid_root_bone is None or humanoid_root_bone_index is None:
            logger.error("No human bone")
            return [root_node_index], {}, []

        while len(buffer0) % 4:
            buffer0.append(0)
        inverse_bind_matrices_offset = len(buffer0)

        skin_joint_node_indices: list[int] = []
        inverse_bind_matrix_struct = struct.Struct("<16f")
        for bone_name, node_index in bone_name_to_node_index.items():
            skin_joint_node_indices.append(node_index)
            inverse_bind_matrix = bone_name_to_inverse_bind_matrix.get(bone_name)
            if inverse_bind_matrix is None:
                message = f"No inverse bind matrix for {bone_name}"
                raise AssertionError(message)
            buffer0.extend(
                inverse_bind_matrix_struct.pack(*itertools.chain(*inverse_bind_matrix))
            )

        buffer_view_index = len(buffer_view_dicts)
        buffer_view_dicts.append(
            {
                "buffer": 0,
                "byteOffset": inverse_bind_matrices_offset,
                "byteLength": len(buffer0) - inverse_bind_matrices_offset,
            }
        )

        accessor_index = len(accessor_dicts)
        accessor_dicts.append(
            {
                "bufferView": buffer_view_index,
                "byteOffset": 0,
                "type": "MAT4",
                "componentType": GL_FLOAT,
                "count": len(skin_joint_node_indices),
            }
        )

        # Hitogata 0.6.0.1 seems to error when sharing skin,
        # so give each mesh its own skin with the same content
        skin_dict: dict[str, Json] = {
            "joints": make_json(skin_joint_node_indices),
            "inverseBindMatrices": accessor_index,
            "skeleton": humanoid_root_bone_index,
        }

        return scene_node_indices, skin_dict, skin_joint_node_indices

    def write_armature_bone_nodes(
        self,
        progress: Progress,
        node_dicts: list[dict[str, Json]],
        buffer0: bytearray,
        bone: PoseBone,
        bone_name_to_node_index: dict[str, int],
        bone_name_to_inverse_bind_matrices: dict[str, Matrix],
    ) -> int:
        node_index = len(node_dicts)
        bone_name_to_node_index[bone.name] = node_index

        parent_bone = bone.parent
        if parent_bone is None:
            parent_world_translation = Vector((0, 0, 0))
        else:
            parent_world_translation = self.armature.matrix_world @ parent_bone.head

        world_translation = self.armature.matrix_world @ bone.head

        bone_name_to_inverse_bind_matrices[bone.name] = Matrix(
            (
                (1, 0, 0, 0),
                (0, 1, 0, 0),
                (0, 0, 1, 0),
                (world_translation.x, -world_translation.z, -world_translation.y, 1),
            )
        )

        node_dict: dict[str, Json] = {
            "name": bone.name,
            "translation": make_json(
                convert.axis_blender_to_gltf(
                    world_translation - parent_world_translation
                )
            ),
        }
        node_dicts.append(node_dict)
        if bone.children:
            node_dict["children"] = [
                self.write_armature_bone_nodes(
                    progress,
                    node_dicts,
                    buffer0,
                    child,
                    bone_name_to_node_index,
                    bone_name_to_inverse_bind_matrices,
                )
                for child in bone.children
            ]
        return node_index

    def get_or_write_cluster_empty_material(
        self,
        material_dicts: list[dict[str, Json]],
        extensions_vrm_material_property_dicts: list[Json],
    ) -> int:
        # Cluster does not allow primitives without materials,
        # so assign empty material.
        missing_material_name = "glTF_2_0_default_material"
        for i, material_dict in enumerate(material_dicts):
            if material_dict.get("name") == missing_material_name:
                return i

        new_missing_material_index = len(material_dicts)
        material_dicts.append({"name": missing_material_name})
        extensions_vrm_material_property_dicts.append(
            {
                "name": missing_material_name,
                "shader": "VRM_USE_GLTFSHADER",
                "keywordMap": {},
                "tagMap": {},
                "floatProperties": {},
                "vectorProperties": {},
                "textureProperties": {},
            }
        )
        return new_missing_material_index

    def collect_vertex(
        self,
        obj: Object,
        main_mesh_data: Mesh,
        vertex_index: int,
        uv_layer: Optional[MeshUVLoopLayer],
        loop_index: int,
        vertex_group_index_to_joint: Mapping[int, int],
        bone_name_to_node_index: Mapping[str, int],
        skin_joints: Sequence[int],
        vertex_attributes_collector: VertexAttributesCollector,
        *,
        have_skin: bool,
    ) -> int:
        texcoord = None
        if uv_layer:
            texcoord_u, texcoord_v = uv_layer.data[loop_index].uv
            texcoord = (texcoord_u, 1 - texcoord_v)

        # Use loop normals instead of vertex normals. This may lose something,
        # but it's judged safer to keep it the same as the glTF 2.0 addon.
        # https://github.com/KhronosGroup/glTF-Blender-IO/pull/1127
        # TODO: This implementation should really average the three normals
        # from the loop
        normal = main_mesh_data.loops[loop_index].normal

        already_added_vertex_index = (
            vertex_attributes_collector.find_added_vertex_index(
                blender_vertex_index=vertex_index,
                normal=convert.axis_blender_to_gltf(normal),
                texcoord=texcoord,
            )
        )
        if isinstance(already_added_vertex_index, int):
            return already_added_vertex_index

        vertex = main_mesh_data.vertices[vertex_index]
        position_x, position_y, position_z = vertex.co

        joints: Optional[tuple[int, int, int, int]] = None
        weights: Optional[tuple[float, float, float, float]] = None
        if have_skin:
            weight_and_joint_list: list[tuple[float, int]] = [
                (weight, joint)
                for vertex_group_element in vertex.groups
                if (
                    (
                        joint := vertex_group_index_to_joint.get(
                            vertex_group_element.group
                        )
                    )
                    is not None
                )
                # Set joint to zero when weight is zero
                # https://github.com/KhronosGroup/glTF/tree/f33f90ad9439a228bf90cde8319d851a52a3f470/specification/2.0#skinned-mesh-attributes
                and not ((weight := vertex_group_element.weight) < float_info.epsilon)
            ]
            weight_and_joint_list.sort(reverse=True)
            while len(weight_and_joint_list) < 4:
                weight_and_joint_list.append((0.0, 0))

            weights = (
                weight_and_joint_list[0][0],
                weight_and_joint_list[1][0],
                weight_and_joint_list[2][0],
                weight_and_joint_list[3][0],
            )
            joints = (
                weight_and_joint_list[0][1],
                weight_and_joint_list[1][1],
                weight_and_joint_list[2][1],
                weight_and_joint_list[3][1],
            )

            total_weight = sum(weights)
            if total_weight < float_info.epsilon:
                logger.debug(
                    "No weight on vertex index=%d mesh=%s",
                    vertex_index,
                    main_mesh_data.name,
                )

                # Attach near bone
                joint = None
                mesh_parent: Optional[Object] = obj
                while mesh_parent:
                    if mesh_parent.parent_type == "BONE":
                        if (
                            mesh_parent.parent == self.armature
                            and (
                                bone_index := bone_name_to_node_index.get(
                                    mesh_parent.parent_bone
                                )
                            )
                            is not None
                            and bone_index in skin_joints
                        ):
                            joint = skin_joints.index(bone_index)
                        break
                    if mesh_parent.parent_type == "OBJECT":
                        mesh_parent = mesh_parent.parent
                    else:
                        break

                if joint is None:
                    # TODO: Probably better to use root bone traced from hips
                    # rather than hips itself
                    ext = get_armature_extension(self.armature_data)
                    for human_bone in ext.vrm0.humanoid.human_bones:
                        if human_bone.bone != "hips":
                            continue
                        if (
                            bone_index := bone_name_to_node_index.get(
                                human_bone.node.bone_name
                            )
                        ) is not None and bone_index in skin_joints:
                            joint = skin_joints.index(bone_index)

                if joint is None:
                    message = "No fallback bone index found"
                    if not skin_joints:
                        raise ValueError(message)
                    logger.error(message)
                    joint = skin_joints[0]

                weights = (1.0, 0, 0, 0)
                joints = (joint, 0, 0, 0)
            else:
                weights = (
                    weights[0] / total_weight,
                    weights[1] / total_weight,
                    weights[2] / total_weight,
                    weights[3] / total_weight,
                )

        return vertex_attributes_collector.add_vertex(
            blender_vertex_index=vertex_index,
            position=convert.axis_blender_to_gltf(
                (
                    position_x,
                    position_y,
                    position_z,
                )
            ),
            normal=convert.axis_blender_to_gltf(normal),
            texcoord=texcoord,
            joints=joints,
            weights=weights,
        )

    def collect_morph_target(
        self,
        main_mesh_data: Mesh,
        shape_key_mesh_data: Mesh,
        vertex_index: int,
        uv_layer: Optional[MeshUVLoopLayer],
        loop_index: int,
        vertex_attributes_collector: VertexAttributesCollector,
        vertex_morph_target_collector: VertexMorphTargetCollector,
        vertex_index_to_morph_normal_diffs: tuple[tuple[float, float, float], ...],
    ) -> None:
        texcoord = None
        if uv_layer:
            texcoord_u, texcoord_v = uv_layer.data[loop_index].uv
            texcoord = (texcoord_u, 1 - texcoord_v)

        gltf_vertex_index = vertex_attributes_collector.find_added_vertex_index(
            blender_vertex_index=vertex_index,
            normal=convert.axis_blender_to_gltf(
                main_mesh_data.loops[loop_index].normal
            ),
            texcoord=texcoord,
        )
        if gltf_vertex_index is None:
            return

        main_mesh_data_vertices = main_mesh_data.vertices
        shape_key_mesh_data_vertices = shape_key_mesh_data.vertices
        if vertex_index < min(
            len(main_mesh_data_vertices), len(shape_key_mesh_data_vertices)
        ):
            (
                position_x,
                position_y,
                position_z,
            ) = main_mesh_data_vertices[vertex_index].co
            (
                shape_key_position_x,
                shape_key_position_y,
                shape_key_position_z,
            ) = shape_key_mesh_data_vertices[vertex_index].co
            target_position = convert.axis_blender_to_gltf(
                (
                    shape_key_position_x - position_x,
                    shape_key_position_y - position_y,
                    shape_key_position_z - position_z,
                )
            )
        else:
            target_position = (0, 0, 0)

        if vertex_index < len(vertex_index_to_morph_normal_diffs):
            morph_normal_diff = vertex_index_to_morph_normal_diffs[vertex_index]
            target_normal = convert.axis_blender_to_gltf(morph_normal_diff)
        else:
            target_normal = (0, 0, 0)

        vertex_morph_target_collector.add_vertex(
            vertex_index=gltf_vertex_index,
            target_position=target_position,
            target_normal=target_normal,
        )

    def write_mesh_node(
        self,
        _progress: Progress,
        node_dicts: list[dict[str, Json]],
        mesh_dicts: list[dict[str, Json]],
        skin_dicts: list[dict[str, Json]],
        material_dicts: list[dict[str, Json]],
        accessor_dicts: list[dict[str, Json]],
        buffer_view_dicts: list[dict[str, Json]],
        extensions_vrm_material_property_dicts: list[Json],
        buffer0: bytearray,
        object_name_to_node_index: dict[str, int],
        bone_name_to_node_index: Mapping[str, int],
        obj: Object,
        mesh_convertible_objects: Sequence[Object],
        mesh_object_name_to_mesh_index: dict[str, int],
        material_name_to_material_index: Mapping[str, int],
        skin_dict: Mapping[str, Json],
        skin_joints: Sequence[int],
    ) -> Optional[int]:
        if obj.type not in MESH_CONVERTIBLE_OBJECT_TYPES:
            return None

        have_skin = bool(skin_joints) and self.have_skin(obj)

        node_index = len(node_dicts)
        node_dict: dict[str, Json] = {
            "name": obj.name,
            # TODO: Planned for removal as it's identical to default value
            "rotation": [0, 0, 0, 1],
            "scale": [1, 1, 1],
        }

        parent_node_index = None
        parent_translation = None
        if have_skin:
            # Becomes scene root node when there's skin
            pass
        else:
            if (
                obj.parent_type in ["ARMATURE", "OBJECT"]
                and (
                    parent := self.get_export_parent_object(
                        obj, mesh_convertible_objects
                    )
                )
                and parent in self.export_objects
            ):
                # TODO: Don't restore nested meshes for compatibility, but will
                # restore in the future
                # parent_translation = parent.matrix_world.to_translation()
                # parent_node_index = object_name_to_node_index.get(parent.name)
                pass

            if obj.parent_type == "BONE" and (
                bone := self.armature.pose.bones.get(obj.parent_bone)
            ):
                parent_translation = (
                    self.armature.matrix_world @ bone.matrix
                ).to_translation()
                parent_node_index = bone_name_to_node_index.get(obj.parent_bone)

        if parent_node_index is not None and 0 <= parent_node_index < len(node_dicts):
            parent_node_dict = node_dicts[parent_node_index]
            parent_children = parent_node_dict.get("children")
            if parent_children is None or not isinstance(
                parent_children, MutableSequence
            ):
                parent_children = []
                parent_node_dict["children"] = parent_children
            parent_children.append(node_index)

        if not parent_translation:
            parent_translation = Vector((0, 0, 0))

        if not have_skin:
            node_dict["translation"] = make_json(
                convert.axis_blender_to_gltf(
                    obj.matrix_world.to_translation() - parent_translation
                )
            )

        node_dicts.append(node_dict)
        object_name_to_node_index[obj.name] = node_index

        scene_node_index = None
        if parent_node_index is None:
            scene_node_index = node_index

        original_mesh_convertible = obj.data
        if not isinstance(original_mesh_convertible, (Curve, Mesh)):
            logger.error(
                "Unexpected mesh convertible object type: %s",
                type(original_mesh_convertible),
            )
            return scene_node_index

        mesh_index = len(mesh_dicts)

        vertex_morph_target_collectors: dict[
            str, Vrm0Exporter.VertexMorphTargetCollector
        ] = {}
        original_shape_keys: Optional[Key] = None
        if isinstance(original_mesh_convertible, Mesh):
            original_mesh_convertible.calc_loop_triangles()
            original_shape_keys = original_mesh_convertible.shape_keys

        with save_workspace(self.context):
            # TODO: Executing move for compatibility with old addon,
            # but seems unnecessary
            mesh_data_transform = Matrix.Identity(4)
            if not have_skin:
                mesh_data_transform @= Matrix.Translation(
                    -obj.matrix_world.to_translation()
                )
            mesh_data_transform @= obj.matrix_world

            main_mesh_data = force_apply_modifiers(
                self.context,
                obj,
                preserve_shape_keys=False,
                transform=mesh_data_transform,
            )
            if main_mesh_data is None:
                return scene_node_index

            if bpy.app.version < (4, 1):
                main_mesh_data.calc_normals_split()

            material_slot_index_to_material_name: Mapping[int, str] = {
                material_index: material_ref.name
                for material_index, material_slot in enumerate(obj.material_slots)
                if (material_ref := material_slot.material) and material_ref.name
            }

            material_index_to_vertex_indices: dict[int, bytearray] = {}
            vertex_indices_struct = struct.Struct("<I")

            vertex_group_index_to_joint: Mapping[int, int] = {
                vertex_group_index: skin_joints.index(vertex_group_node_index)
                for vertex_group_index, vertex_group in enumerate(obj.vertex_groups)
                if (
                    vertex_group_node_index := bone_name_to_node_index.get(
                        vertex_group.name
                    )
                )
                is not None
                and vertex_group_node_index in skin_joints
            }

            vertex_attributes_collector = Vrm0Exporter.VertexAttributesCollector()
            uv_layers = main_mesh_data.uv_layers
            uv_layer = uv_layers.active
            main_mesh_data.calc_loop_triangles()
            for loop_triangle in main_mesh_data.loop_triangles:
                material_slot_index = loop_triangle.material_index
                material_name = material_slot_index_to_material_name.get(
                    material_slot_index
                )
                material_index = None
                if material_name:
                    material_index = material_name_to_material_index.get(material_name)
                if material_index is None:
                    material_index = self.get_or_write_cluster_empty_material(
                        material_dicts, extensions_vrm_material_property_dicts
                    )

                vertex_indices = material_index_to_vertex_indices.get(material_index)
                if vertex_indices is None:
                    vertex_indices = bytearray()
                    material_index_to_vertex_indices[material_index] = vertex_indices

                for loop_index in loop_triangle.loops:
                    loop = main_mesh_data.loops[loop_index]
                    original_vertex_index = loop.vertex_index
                    vertex_index = self.collect_vertex(
                        obj,
                        main_mesh_data,
                        original_vertex_index,
                        uv_layer,
                        loop_index,
                        vertex_group_index_to_joint,
                        bone_name_to_node_index,
                        skin_joints,
                        vertex_attributes_collector,
                        have_skin=have_skin,
                    )
                    vertex_indices.extend(vertex_indices_struct.pack(vertex_index))

            if original_shape_keys:
                no_morph_normal_export_vertex_indices: set[int] = (
                    self.create_no_morph_normal_export_vertex_indices(
                        self.context,
                        main_mesh_data,
                    )
                )

                reference_vertex_normal_vectors = (
                    Vrm0Exporter.create_export_vertex_normal_vectors(
                        no_morph_normal_export_vertex_indices,
                        main_mesh_data,
                    )
                )

                # Create mesh with modifiers applied for each shape key.
                # This is because for VRM 0.x, glTF Node rotation and scale
                # are normalized, but when rotation or scale is applied in
                # pose mode, the weight calculation for that normalization is
                # not applied to shape keys, so we need to recalculate the
                # change amount for each shape key ourselves.
                # There might be bad patterns where vertex indices change,
                # so there's room for improvement.
                for shape_key in original_shape_keys.key_blocks:
                    if original_shape_keys.reference_key.name == shape_key.name:
                        continue

                    shape_key.value = 1.0
                    self.context.view_layer.update()
                    shape_mesh_data = force_apply_modifiers(
                        self.context,
                        obj,
                        preserve_shape_keys=False,
                        transform=mesh_data_transform,
                    )
                    shape_key.value = 0.0
                    self.context.view_layer.update()

                    if not shape_mesh_data:
                        continue

                    if bpy.app.version < (4, 1):
                        shape_mesh_data.calc_normals_split()

                    vertex_morph_target_collector = vertex_morph_target_collectors.get(
                        shape_key.name
                    )
                    if vertex_morph_target_collector is None:
                        vertex_morph_target_collector = (
                            Vrm0Exporter.VertexMorphTargetCollector(
                                vertex_attributes_collector.count
                            )
                        )
                        vertex_morph_target_collectors[shape_key.name] = (
                            vertex_morph_target_collector
                        )

                    shape_mesh_data.calc_loop_triangles()
                    vertex_index_to_morph_normal_diffs = (
                        Vrm0Exporter.create_vertex_index_to_morph_normal_diffs(
                            no_morph_normal_export_vertex_indices,
                            shape_mesh_data,
                            reference_vertex_normal_vectors,
                        )
                    )

                    for loop_triangle in main_mesh_data.loop_triangles:
                        for loop_index in loop_triangle.loops:
                            loop = main_mesh_data.loops[loop_index]
                            original_vertex_index = loop.vertex_index
                            self.collect_morph_target(
                                main_mesh_data,
                                shape_mesh_data,
                                original_vertex_index,
                                uv_layer,
                                loop_index,
                                vertex_attributes_collector,
                                vertex_morph_target_collector,
                                vertex_index_to_morph_normal_diffs,
                            )

                    if shape_mesh_data.users:
                        logger.warning(
                            'Failed to remove "%s" with %d users while exporting'
                            " shape key meshes",
                            shape_mesh_data.name,
                            shape_mesh_data.users,
                        )
                    else:
                        self.context.blend_data.meshes.remove(shape_mesh_data)

        if main_mesh_data.users:
            logger.warning(
                'Failed to remove "%s" with %d users while exporting meshes',
                main_mesh_data.name,
                main_mesh_data.users,
            )
        else:
            self.context.blend_data.meshes.remove(main_mesh_data)
        main_mesh_data = None

        if not material_index_to_vertex_indices:
            return scene_node_index

        node_dict["mesh"] = mesh_index

        primitive_dicts: list[dict[str, Json]] = []

        while len(buffer0) % 4:
            buffer0.append(0)

        # TODO: Make buffer writing class independent

        # Write indices
        for (
            primitive_material_index,
            vertex_indices,
        ) in material_index_to_vertex_indices.items():
            indices_buffer_offset = len(buffer0)
            buffer0.extend(vertex_indices)
            indices_buffer_view_index = len(buffer_view_dicts)
            buffer_view_dicts.append(
                {
                    "buffer": 0,
                    "byteOffset": indices_buffer_offset,
                    "byteLength": len(vertex_indices),
                }
            )
            indices_accessor_index = len(accessor_dicts)
            accessor_dicts.append(
                {
                    "bufferView": indices_buffer_view_index,
                    "byteOffset": 0,
                    "type": "SCALAR",
                    "componentType": GL_UNSIGNED_INT,
                    # TODO: Avoid division as it can lead to mistakes
                    "count": int(len(vertex_indices) / 4),
                }
            )
            primitive_dicts.append(
                {
                    "material": primitive_material_index,
                    "indices": indices_accessor_index,
                }
            )

        # Made attributes shared by design
        primitive_attribute_dict: dict[str, Json] = {}
        for primitive_dict in primitive_dicts:
            primitive_dict["attributes"] = primitive_attribute_dict

        position_buffer_offset = len(buffer0)
        buffer0.extend(vertex_attributes_collector.position)
        position_buffer_view_index = len(buffer_view_dicts)
        buffer_view_dicts.append(
            {
                "buffer": 0,
                "byteOffset": position_buffer_offset,
                "byteLength": len(vertex_attributes_collector.position),
            }
        )
        position_accessor_index = len(accessor_dicts)

        accessor_dicts.append(
            {
                "bufferView": position_buffer_view_index,
                "byteOffset": 0,
                "type": "VEC3",
                "componentType": GL_FLOAT,
                "count": vertex_attributes_collector.count,
                "min": [
                    vertex_attributes_collector.position_min_x,
                    vertex_attributes_collector.position_min_y,
                    vertex_attributes_collector.position_min_z,
                ],
                "max": [
                    vertex_attributes_collector.position_max_x,
                    vertex_attributes_collector.position_max_y,
                    vertex_attributes_collector.position_max_z,
                ],
            }
        )
        primitive_attribute_dict["POSITION"] = position_accessor_index

        normal_buffer_offset = len(buffer0)
        buffer0.extend(vertex_attributes_collector.normal)
        normal_buffer_view_index = len(buffer_view_dicts)
        buffer_view_dicts.append(
            {
                "buffer": 0,
                "byteOffset": normal_buffer_offset,
                "byteLength": len(vertex_attributes_collector.normal),
            }
        )
        normal_accessor_index = len(accessor_dicts)
        accessor_dicts.append(
            {
                "bufferView": normal_buffer_view_index,
                "byteOffset": 0,
                "type": "VEC3",
                "componentType": GL_FLOAT,
                "count": vertex_attributes_collector.count,
                # "normalized": True, # TODO: Needs investigation
            }
        )
        primitive_attribute_dict["NORMAL"] = normal_accessor_index

        primitive_texcoord = vertex_attributes_collector.texcoord
        if primitive_texcoord:
            texcoord_buffer_offset = len(buffer0)
            buffer0.extend(primitive_texcoord)
            texcoord_buffer_view_index = len(buffer_view_dicts)
            buffer_view_dicts.append(
                {
                    "buffer": 0,
                    "byteOffset": texcoord_buffer_offset,
                    "byteLength": len(primitive_texcoord),
                }
            )
            texcoord_accessor_index = len(accessor_dicts)
            accessor_dicts.append(
                {
                    "bufferView": texcoord_buffer_view_index,
                    "byteOffset": 0,
                    "type": "VEC2",
                    "componentType": GL_FLOAT,
                    "count": vertex_attributes_collector.count,
                }
            )
            primitive_attribute_dict["TEXCOORD_0"] = texcoord_accessor_index

        primitive_joints = vertex_attributes_collector.joints
        if primitive_joints:
            joints_buffer_offset = len(buffer0)
            buffer0.extend(primitive_joints)
            joints_buffer_view_index = len(buffer_view_dicts)
            buffer_view_dicts.append(
                {
                    "buffer": 0,
                    "byteOffset": joints_buffer_offset,
                    "byteLength": len(primitive_joints),
                }
            )
            joints_accessor_index = len(accessor_dicts)
            accessor_dicts.append(
                {
                    "bufferView": joints_buffer_view_index,
                    "byteOffset": 0,
                    "type": "VEC4",
                    "componentType": GL_UNSIGNED_SHORT,
                    "count": vertex_attributes_collector.count,
                }
            )
            primitive_attribute_dict["JOINTS_0"] = joints_accessor_index

        primitive_weights = vertex_attributes_collector.weights
        if primitive_weights:
            while len(buffer0) % 4:
                buffer0.append(0)

            weights_buffer_offset = len(buffer0)
            buffer0.extend(primitive_weights)
            weights_buffer_view_index = len(buffer_view_dicts)
            buffer_view_dicts.append(
                {
                    "buffer": 0,
                    "byteOffset": weights_buffer_offset,
                    "byteLength": len(primitive_weights),
                }
            )
            weights_accessor_index = len(accessor_dicts)
            accessor_dicts.append(
                {
                    "bufferView": weights_buffer_view_index,
                    "byteOffset": 0,
                    "type": "VEC4",
                    "componentType": GL_FLOAT,
                    "count": vertex_attributes_collector.count,
                }
            )
            primitive_attribute_dict["WEIGHTS_0"] = weights_accessor_index

        # Made targets shared by design
        morph_target_names = make_json(vertex_morph_target_collectors.keys())
        if vertex_morph_target_collectors:
            while len(buffer0) % 4:
                buffer0.append(0)

            primitive_target_dicts: list[dict[str, Json]] = []
            for target in vertex_morph_target_collectors.values():
                target_position_buffer_offset = len(buffer0)
                buffer0.extend(target.position)
                target_position_buffer_view_index = len(buffer_view_dicts)
                buffer_view_dicts.append(
                    {
                        "buffer": 0,
                        "byteOffset": target_position_buffer_offset,
                        "byteLength": len(target.position),
                    }
                )
                target_position_accessor_index = len(accessor_dicts)

                accessor_dicts.append(
                    {
                        "bufferView": target_position_buffer_view_index,
                        "byteOffset": 0,
                        "type": "VEC3",
                        "componentType": GL_FLOAT,
                        "count": vertex_attributes_collector.count,
                        "min": [
                            target.position_min_x,
                            target.position_min_y,
                            target.position_min_z,
                        ],
                        "max": [
                            target.position_max_x,
                            target.position_max_y,
                            target.position_max_z,
                        ],
                    }
                )

                target_normal_buffer_offset = len(buffer0)
                buffer0.extend(target.normal)
                target_normal_buffer_view_index = len(buffer_view_dicts)
                buffer_view_dicts.append(
                    {
                        "buffer": 0,
                        "byteOffset": target_normal_buffer_offset,
                        "byteLength": len(target.normal),
                    }
                )
                target_normal_accessor_index = len(accessor_dicts)
                accessor_dicts.append(
                    {
                        "bufferView": target_normal_buffer_view_index,
                        "byteOffset": 0,
                        "type": "VEC3",
                        "componentType": GL_FLOAT,
                        "count": vertex_attributes_collector.count,
                    }
                )

                primitive_target_dicts.append(
                    {
                        "POSITION": target_position_accessor_index,
                        "NORMAL": target_normal_accessor_index,
                    }
                )

            for primitive_dict in primitive_dicts:
                primitive_dict["targets"] = make_json(primitive_target_dicts)
                primitive_dict["extras"] = {
                    # for compatibility with legacy practices
                    # https://github.com/vrm-c/UniVRM/blob/38ccb92300c9ab41c72eb3d5b8dc8ce664a659d5/Assets/UniGLTF/Runtime/UniGLTF/Format/ExtensionsAndExtras/gltf_mesh_extras_targetNames.cs#L7-L12
                    "targetNames": morph_target_names,
                }

        mesh_dict = {
            "name": original_mesh_convertible.name,
            "primitives": make_json(primitive_dicts),
        }
        if morph_target_names:
            mesh_dict["extras"] = {
                # targetNames is not included in glTF specification, but is used
                # in many implementations
                # https://github.com/KhronosGroup/glTF/blob/0251c5c0cce8daec69bd54f29f891e3d0cdb52c8/specification/2.0/Specification.adoc?plain=1#L1500-L1504
                "targetNames": morph_target_names,
            }

        mesh_dicts.append(mesh_dict)
        mesh_object_name_to_mesh_index[obj.name] = mesh_index
        if skin_dict and have_skin:
            # TODO: Create separate skin for each mesh
            node_dict["skin"] = len(skin_dicts)
            skin_dicts.append(dict(skin_dict))

        return scene_node_index

    def get_export_parent_object(
        self, obj: Object, mesh_convertible_objects: Sequence[Object]
    ) -> Optional[Object]:
        if obj.parent_type != "OBJECT":
            return None

        parent = obj.parent
        while parent:
            if parent in mesh_convertible_objects:
                return parent
            parent = parent.parent

        return None

    def write_mesh_nodes(
        self,
        progress: Progress,
        node_dicts: list[dict[str, Json]],
        mesh_dicts: list[dict[str, Json]],
        skin_dicts: list[dict[str, Json]],
        material_dicts: list[dict[str, Json]],
        accessor_dicts: list[dict[str, Json]],
        buffer_view_dicts: list[dict[str, Json]],
        extensions_vrm_material_property_dicts: list[Json],
        buffer0: bytearray,
        bone_name_to_node_index: Mapping[str, int],
        mesh_object_name_to_mesh_index: dict[str, int],
        material_name_to_material_index: Mapping[str, int],
        skin_dict: Mapping[str, Json],
        skin_joints: Sequence[int],
    ) -> list[int]:
        object_name_to_node_index: dict[str, int] = {}
        node_indices: list[int] = []

        mesh_convertible_objects = [
            mesh_object
            for mesh_object in self.export_objects
            if mesh_object.type in search.MESH_CONVERTIBLE_OBJECT_TYPES
        ]

        # Sort meshes according to parent-child relationships
        while True:
            swapped = False
            for mesh_object in list(mesh_convertible_objects):
                parent_mesh_object = self.get_export_parent_object(
                    mesh_object, mesh_convertible_objects
                )
                if not parent_mesh_object:
                    continue

                if mesh_convertible_objects.index(
                    mesh_object
                ) < mesh_convertible_objects.index(parent_mesh_object):
                    mesh_convertible_objects.remove(mesh_object)
                    mesh_convertible_objects.append(mesh_object)
                    swapped = True

            if not swapped:
                break

        for obj in self.export_objects:
            node_index = None
            with save_workspace(self.context, obj):
                node_index = self.write_mesh_node(
                    progress,
                    node_dicts,
                    mesh_dicts,
                    skin_dicts,
                    material_dicts,
                    accessor_dicts,
                    buffer_view_dicts,
                    extensions_vrm_material_property_dicts,
                    buffer0,
                    object_name_to_node_index,
                    bone_name_to_node_index,
                    obj,
                    mesh_convertible_objects,
                    mesh_object_name_to_mesh_index,
                    material_name_to_material_index,
                    skin_dict,
                    skin_joints,
                )
            if node_index is None:
                continue
            node_indices.append(node_index)

        return node_indices

    @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_clear_shape_key_values(
        context: Context, export_objects: Sequence[Object]
    ) -> Mapping[tuple[str, str], float]:
        mesh_name_and_shape_key_name_to_value: dict[tuple[str, str], float] = {}
        mesh_objs = [obj for obj in export_objects if obj.type == "MESH"]
        for mesh_obj in mesh_objs:
            mesh = mesh_obj.data
            if not isinstance(mesh, Mesh):
                continue
            shape_keys = mesh.shape_keys
            if not shape_keys:
                continue
            key_block: Optional[ShapeKey] = None
            for key_block in shape_keys.key_blocks:
                mesh_name_and_shape_key_name_to_value[(mesh.name, key_block.name)] = (
                    key_block.value
                )
                key_block.value = 0
        context.view_layer.update()
        return mesh_name_and_shape_key_name_to_value

    @staticmethod
    def leave_clear_shape_key_values(
        context: Context,
        mesh_name_and_shape_key_name_to_value: Mapping[tuple[str, str], float],
    ) -> None:
        for (
            mesh_name,
            shape_key_name,
        ), value in mesh_name_and_shape_key_name_to_value.items():
            mesh = context.blend_data.meshes.get(mesh_name)
            if not mesh:
                continue
            shape_keys = mesh.shape_keys
            if not shape_keys:
                continue
            key_block = shape_keys.key_blocks.get(shape_key_name)
            if not key_block:
                continue
            key_block.value = value

    @contextmanager
    def clear_shape_key_values(self) -> Iterator[Mapping[tuple[str, str], float]]:
        mesh_name_and_shape_key_name_to_value = self.enter_clear_shape_key_values(
            self.context, self.export_objects
        )
        try:
            yield mesh_name_and_shape_key_name_to_value
            # 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 potentially invalid native objects after yield
        finally:
            self.leave_clear_shape_key_values(
                self.context, mesh_name_and_shape_key_name_to_value
            )

    def get_legacy_shader_images(self, material: Material) -> Sequence[Image]:
        node, legacy_shader_name = search.legacy_shader_node(material)
        if not node or not legacy_shader_name:
            return []

        images: list[Image] = []
        if legacy_shader_name == "MToon_unversioned":
            for (
                raw_input_socket_name
            ) in MtoonUnversioned.texture_kind_exchange_dict.values():
                # 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_vals' which comes from
                # MaterialMtoon.texture_kind_exchange_dict is "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 (
                    raw_input_socket_name == "NormalmapTexture"
                    and "NormalmapTexture" not in node.inputs
                    and "NomalmapTexture" in node.inputs
                ):
                    input_socket_name = "NomalmapTexture"
                elif raw_input_socket_name == "ReceiveShadow_Texture":
                    input_socket_name = "ReceiveShadow_Texture_alpha"
                else:
                    input_socket_name = raw_input_socket_name

                input_socket = node.inputs.get(input_socket_name)
                if not input_socket:
                    continue

                image_node = shader.search_input_node(input_socket, ShaderNodeTexImage)
                if not image_node:
                    continue

                image = image_node.image
                if not image:
                    continue

                images.append(image)
        elif legacy_shader_name == "GLTF":
            for input_socket_name in TEXTURE_INPUT_NAMES:
                input_socket = node.inputs.get(input_socket_name)
                if not input_socket:
                    continue

                image_node = shader.search_input_node(input_socket, ShaderNodeTexImage)
                if not image_node:
                    continue

                image = image_node.image
                if not image:
                    continue

                images.append(image)

        elif legacy_shader_name == "TRANSPARENT_ZWRITE":
            input_socket = node.inputs.get("Main_Texture")
            if not input_socket:
                return []

            image_node = shader.search_input_node(input_socket, ShaderNodeTexImage)
            if not image_node:
                return []

            image = image_node.image
            if not image:
                return []

            return [image]

        return list(dict.fromkeys(images).keys())  # Remove duplicates

    def create_gltf2_io_texture(
        self,
        gltf2_io_texture_info: object,
        texture_dicts: list[dict[str, Json]],
        sampler_dicts: list[dict[str, Json]],
        image_dicts: list[dict[str, Json]],
        buffer_view_dicts: list[dict[str, Json]],
        buffer0: bytearray,
        gltf2_io_texture_images: list[Gltf2IoTextureImage],
    ) -> Json:
        source = getattr(getattr(gltf2_io_texture_info, "index", None), "source", None)
        if not source:
            return None

        source_name = getattr(source, "name", None)
        if not isinstance(source_name, str):
            source_name = ""

        source_mime_type = getattr(source, "mime_type", None)
        if not isinstance(source_mime_type, str):
            source_mime_type = "image/png"

        source_buffer_view_data = getattr(
            getattr(source, "buffer_view", None), "data", None
        )
        if not isinstance(source_buffer_view_data, bytes):
            return None

        export_image_index = None
        for gltf2_io_texture_image in gltf2_io_texture_images:
            if gltf2_io_texture_image.name != source_name:
                continue
            if gltf2_io_texture_image.mime_type != source_mime_type:
                continue
            if gltf2_io_texture_image.image_bytes != source_buffer_view_data:
                continue
            export_image_index = gltf2_io_texture_image.export_image_index
            break

        if export_image_index is None:
            image_base_name = re.sub(
                r"^BlenderVrmAddonImport[0-9]+Image[0-9]+_", "", source_name
            )

            # Avoid duplicate naming for compatibility with old exporter
            image_name = image_base_name
            for count in range(100000):
                if count:
                    image_name = image_base_name + "." + str(count)
                if not any(
                    image_name == image_dict.get("name") for image_dict in image_dicts
                ):
                    break

            image_buffer_view_index = len(buffer_view_dicts)
            buffer_view_dicts.append(
                {
                    "buffer": 0,
                    "byteOffset": len(buffer0),
                    "byteLength": len(source_buffer_view_data),
                }
            )
            buffer0.extend(source_buffer_view_data)

            export_image_index = len(image_dicts)
            image_dict: dict[str, Json] = {
                "name": image_name,
                "mimeType": source_mime_type,
                "bufferView": image_buffer_view_index,
            }
            image_dicts.append(image_dict)

            gltf2_io_texture_images.append(
                Vrm0Exporter.Gltf2IoTextureImage(
                    name=source_name,
                    mime_type=source_mime_type,
                    image_bytes=source_buffer_view_data,
                    export_image_index=export_image_index,
                )
            )

        sampler = getattr(
            getattr(gltf2_io_texture_info, "index", None), "sampler", None
        )
        wrap_s = getattr(sampler, "wrap_s", None)
        wrap_t = getattr(sampler, "wrap_t", None)
        mag_filter = getattr(sampler, "mag_filter", None)
        min_filter = getattr(sampler, "min_filter", None)

        if not isinstance(wrap_s, int):
            wrap_s = GL_REPEAT
        if not isinstance(wrap_t, int):
            wrap_t = GL_REPEAT
        if not isinstance(mag_filter, int):
            mag_filter = GL_LINEAR
        if not isinstance(min_filter, int):
            min_filter = GL_LINEAR

        sampler_dict: dict[str, Json] = {
            "magFilter": mag_filter,
            "minFilter": min_filter,
            "wrapS": wrap_s,
            "wrapT": wrap_t,
        }
        if sampler_dict in sampler_dicts:
            sampler_index = sampler_dicts.index(sampler_dict)
        else:
            sampler_index = len(sampler_dicts)
            sampler_dicts.append(sampler_dict)

        texture_dict: dict[str, Json] = {
            "sampler": sampler_index,
            "source": export_image_index,
        }

        if texture_dict in texture_dicts:
            texture_index = texture_dicts.index(texture_dict)
        else:
            texture_index = len(texture_dicts)
            texture_dicts.append(texture_dict)

        texture_info: dict[str, Json] = {"index": texture_index}

        texture_info_scale = convert.float_or_none(
            getattr(gltf2_io_texture_info, "scale", None)
        )
        if texture_info_scale is not None:
            texture_info["scale"] = texture_info_scale

        texture_info_strength = convert.float_or_none(
            getattr(gltf2_io_texture_info, "strength", None)
        )
        if texture_info_strength is not None:
            texture_info["strength"] = texture_info_strength

        return make_json(texture_info)

    def create_mtoon1_downgraded_texture(
        self,
        texture: Union[Mtoon0TexturePropertyGroup, Mtoon1TexturePropertyGroup],
        texture_properties: dict[str, int],
        texture_properties_key: str,
        vector_properties: dict[str, Sequence[float]],
        texture_dicts: list[dict[str, Json]],
        sampler_dicts: list[dict[str, Json]],
        image_dicts: list[dict[str, Json]],
        buffer_view_dicts: list[dict[str, Json]],
        buffer0: bytearray,
        image_name_to_image_index: dict[str, int],
    ) -> Optional[dict[str, Json]]:
        source = texture.get_connected_node_image()
        if not source:
            return None

        wrap_s = Mtoon1SamplerPropertyGroup.wrap_enum.identifier_to_value(
            texture.sampler.wrap_s,
            Mtoon1SamplerPropertyGroup.WRAP_DEFAULT.value,
        )

        wrap_t = Mtoon1SamplerPropertyGroup.wrap_enum.identifier_to_value(
            texture.sampler.wrap_t,
            Mtoon1SamplerPropertyGroup.WRAP_DEFAULT.value,
        )

        mag_filter = Mtoon1SamplerPropertyGroup.mag_filter_enum.identifier_to_value(
            texture.sampler.mag_filter,
            Mtoon1SamplerPropertyGroup.MAG_FILTER_DEFAULT.value,
        )

        min_filter = Mtoon1SamplerPropertyGroup.min_filter_enum.identifier_to_value(
            texture.sampler.min_filter,
            Mtoon1SamplerPropertyGroup.MIN_FILTER_DEFAULT.value,
        )

        sampler_dict: dict[str, Json] = {
            "magFilter": mag_filter,
            "minFilter": min_filter,
            "wrapS": wrap_s,
            "wrapT": wrap_t,
        }
        if sampler_dict in sampler_dicts:
            sampler_index = sampler_dicts.index(sampler_dict)
        else:
            sampler_index = len(sampler_dicts)
            sampler_dicts.append(sampler_dict)

        image_index = self.find_or_create_image(
            image_dicts,
            buffer_view_dicts,
            buffer0,
            image_name_to_image_index,
            source,
        )

        texture_dict: dict[str, Json] = {
            "sampler": sampler_index,
            "source": image_index,
        }
        if texture_dict in texture_dicts:
            texture_index = texture_dicts.index(texture_dict)
        else:
            texture_index = len(texture_dicts)
            texture_dicts.append(texture_dict)

        texture_properties[texture_properties_key] = texture_index
        vector_properties[texture_properties_key] = [0, 0, 1, 1]

        texture_info: dict[str, Json] = {"index": texture_index}
        return texture_info

    def create_mtoon1_downgraded_texture_info(
        self,
        texture_info: Mtoon1TextureInfoPropertyGroup,
        texture_properties: dict[str, int],
        texture_properties_key: str,
        vector_properties: dict[str, Sequence[float]],
        texture_dicts: list[dict[str, Json]],
        sampler_dicts: list[dict[str, Json]],
        image_dicts: list[dict[str, Json]],
        buffer_view_dicts: list[dict[str, Json]],
        buffer0: bytearray,
        image_name_to_image_index: dict[str, int],
        khr_texture_transform: Optional[Mtoon1KhrTextureTransformPropertyGroup],
    ) -> Optional[dict[str, Json]]:
        texture_info_dict = self.create_mtoon1_downgraded_texture(
            texture_info.index,
            texture_properties,
            texture_properties_key,
            vector_properties,
            texture_dicts,
            sampler_dicts,
            image_dicts,
            buffer_view_dicts,
            buffer0,
            image_name_to_image_index,
        )
        if texture_info_dict is None:
            return None

        if khr_texture_transform is not None:
            texture_info_dict["extensions"] = {
                "KHR_texture_transform": {
                    "offset": list(khr_texture_transform.offset),
                    "scale": list(khr_texture_transform.scale),
                }
            }
        return texture_info_dict

    @staticmethod
    def create_no_morph_normal_export_vertex_indices(
        context: Context,
        mesh_data: Mesh,
    ) -> set[int]:
        # logger.error("CREATE UNIQ:")
        # Collect vertex indices where normal difference is forced to zero
        # setting is enabled
        exclusion_vertex_indices: set[int] = set()
        material_refs = mesh_data.materials
        for polygon in mesh_data.polygons:
            material_ref_index = polygon.material_index
            if not (0 <= material_ref_index < len(material_refs)):
                continue
            material_ref = material_refs[material_ref_index]
            if material_ref is None:
                continue
            # Use non-evaluated material
            material = context.blend_data.materials.get(material_ref.name)
            if material is None:
                continue
            material_extension = get_material_extension(material)
            if material_extension.mtoon1.export_shape_key_normals:
                continue
            if material_extension.mtoon1.enabled:
                exclusion_vertex_indices.update(polygon.vertices)
                continue
            node, legacy_shader_name = search.legacy_shader_node(material)
            if not node:
                continue
            if legacy_shader_name == "MToon_unversioned":
                exclusion_vertex_indices.update(polygon.vertices)

        return exclusion_vertex_indices

    @staticmethod
    def create_export_vertex_normal_vectors(
        no_morph_normal_export_vertex_indices: set[int],
        mesh_data: Mesh,
    ) -> list[Vector]:
        # Collect normal values for each shape key
        # logger.error("  refkey=%s key=%s", reference_key_name, shape_key_name)
        # Use split (loop) normals instead of vertex normals
        # https://github.com/KhronosGroup/glTF-Blender-IO/pull/1129
        vertex_normal_sum_vectors = [Vector([0.0, 0.0, 0.0])] * len(mesh_data.vertices)
        for loop_triangle in mesh_data.loop_triangles:
            # logger.error("    loop_triangle mat=%s", loop_triangle.material_index)
            for vertex_index, normal in zip(
                loop_triangle.vertices, loop_triangle.split_normals
            ):
                # logger.error(
                #     "      vindex=%s normal=%s",
                #     vertex_index,
                #     normal,
                # )
                if vertex_index in no_morph_normal_export_vertex_indices:
                    # logger.error("      EXCLUDE")
                    continue
                if not (0 <= vertex_index < len(vertex_normal_sum_vectors)):
                    # logger.error("      OUT OF RANGE")
                    continue
                vertex_normal_sum_vectors[vertex_index] = (
                    # Normally we would use the += operator, but for some
                    # reason the result changes so we don't use it
                    vertex_normal_sum_vectors[vertex_index] + Vector(normal)
                )
                # logger.error(
                #     "      => %s:%s",
                #     vertex_normal_sum_vectors[vertex_index],
                #     list(vertex_normal_sum_vectors[vertex_index]),
                # )
        return [
            Vector((0, 0, 0))
            if vector.length_squared < float_info.epsilon
            else vector.normalized()
            for vector in vertex_normal_sum_vectors
        ]

    @staticmethod
    def create_vertex_index_to_morph_normal_diffs(
        no_morph_normal_export_vertex_indices: set[int],
        shape_key_mesh_data: Mesh,
        reference_vertex_normal_vectors: list[Vector],
    ) -> tuple[tuple[float, float, float], ...]:
        vertex_normal_vectors = Vrm0Exporter.create_export_vertex_normal_vectors(
            no_morph_normal_export_vertex_indices,
            shape_key_mesh_data,
        )

        # Collect normal differences from reference key for each shape key

        # logger.error("REFERENCE_KEY: %s", mesh_data.shape_keys.reference_key.name)
        # for v in reference_vertex_normal_vectors:
        #     logger.error("  %s", v)

        # for n, vv in shape_key_name_to_vertex_normal_vectors.items():
        #     logger.error("KEY: %s", n)
        #     for v in vv:
        #         logger.error("  %s", v)

        # logger.error("RESULT:")
        vertex_index_to_morph_normal_diffs: tuple[tuple[float, float, float], ...] = (
            tuple(
                (
                    vertex_normal_vector.x - reference_vertex_normal_vector.x,
                    vertex_normal_vector.y - reference_vertex_normal_vector.y,
                    vertex_normal_vector.z - reference_vertex_normal_vector.z,
                )
                for vertex_normal_vector, reference_vertex_normal_vector in zip(
                    vertex_normal_vectors,
                    reference_vertex_normal_vectors,
                )
            )
        )
        # logger.error(
        #     "  %s:%s",
        #     shape_key_name,
        #     vertex_index_to_morph_normal_diffs,
        # )
        return vertex_index_to_morph_normal_diffs

    def have_skin(self, mesh: Object) -> bool:
        # TODO: This method has false positives but is kept as-is for compatibility.
        # In the future, this will be replaced with correct implementation
        while mesh:
            if any(
                modifier.show_viewport and modifier.type == "ARMATURE"
                for modifier in mesh.modifiers
            ):
                return True
            parent_mesh = mesh.parent
            if not parent_mesh:
                return True
            if (
                mesh.parent_type == "BONE"
                and parent_mesh.type == "ARMATURE"
                and mesh.parent_bone
            ):
                return False
            if (
                mesh.parent_type != "OBJECT"
                or parent_mesh.type not in search.MESH_CONVERTIBLE_OBJECT_TYPES
            ):
                return True
            mesh = parent_mesh
        return True

    def get_asset_generator(self) -> str:
        addon_version = get_addon_version()
        if environ.get("BLENDER_VRM_USE_TEST_EXPORTER_VERSION") == "true":
            addon_version = (999, 999, 999)
        return "saturday06_blender_vrm_exporter_experimental_" + ".".join(
            map(str, addon_version)
        )
