commit 091ad6a49a34a734daea927524b4edc242913b9e Author: 小煜 <2082529121@qq.com> Date: Thu Jan 1 14:21:56 2026 +0800 feat: Add VRM Blender addon with complete import/export functionality - Add core VRM addon infrastructure with manifest and registration - Add common utilities module with file system, logging, and conversion helpers - Add human bone mapper with support for multiple rigging standards (Mixamo, MMD, Unreal, Rigify, etc.) - Add VRM 0.x and 1.x format support with property groups and handlers - Add editor UI panels for VRM metadata, spring bones, and MToon materials - Add exporter with glTF2 extension support for VRM format serialization - Add importer with scene reconstruction and armature generation - Add MToon shader support with auto-setup and material migration - Add spring bone physics simulation with constraint handling - Add node constraint editor for advanced rigging control - Add comprehensive validation and error handling with user dialogs - Add scene watcher for real-time property synchronization - Add workspace management and preference system - Include Python cache files and Blender manifest configuration - This is the initial commit establishing the complete VRM addon ecosystem for Blender diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..ed0b994 --- /dev/null +++ b/README.txt @@ -0,0 +1,3 @@ +# VRM Add-on for Blender + +See https://vrm-addon-for-blender.info for more information. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..ab06240 --- /dev/null +++ b/__init__.py @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: MIT OR GPL-3.0-or-later +# SPDX-FileCopyrightText: 2018 iCyP + +from . import registration +from .exporter import gltf2_export_user_extension +from .importer import gltf2_import_user_extension + +bl_info = { + "name": "VRM format", + "author": "saturday06, iCyP", + "version": ( + 3, # x-release-please-major + 17, # x-release-please-minor + 1, # x-release-please-patch + ), + "location": "File > Import-Export", + "description": "Import-Edit-Export VRM", + "blender": (2, 93, 0), + "warning": "", + "support": "COMMUNITY", + "wiki_url": "", + "doc_url": "https://vrm-addon-for-blender.info", + "tracker_url": "https://github.com/saturday06/VRM-Addon-for-Blender/issues", + "category": "Import-Export", +} + + +def register() -> None: + registration.register() + + +def unregister() -> None: + registration.unregister() + + +class glTF2ImportUserExtension(gltf2_import_user_extension.glTF2ImportUserExtension): + pass + + +class glTF2ExportUserExtension(gltf2_export_user_extension.glTF2ExportUserExtension): + pass diff --git a/__pycache__/__init__.cpython-311.pyc b/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..70beafd Binary files /dev/null and b/__pycache__/__init__.cpython-311.pyc differ diff --git a/__pycache__/registration.cpython-311.pyc b/__pycache__/registration.cpython-311.pyc new file mode 100644 index 0000000..e4ebd83 Binary files /dev/null and b/__pycache__/registration.cpython-311.pyc differ diff --git a/blender_manifest.toml b/blender_manifest.toml new file mode 100644 index 0000000..9c65819 --- /dev/null +++ b/blender_manifest.toml @@ -0,0 +1,26 @@ +schema_version = "1.0.0" + +id = "vrm" +version = "3.17.1" + +name = "VRM format" +tagline = "VRM import, export and editing capabilities" +maintainer = "Isamu Mogi " +type = "add-on" + +website = "https://vrm-addon-for-blender.info" + +tags = ["Import-Export", "Animation", "Modeling", "Material", "Physics"] + +blender_version_min = "4.2.0" +blender_version_max = "5.1.0" + +license = ["SPDX:MIT", "SPDX:GPL-3.0-or-later"] + +copyright = [ + "2020 Isamu Mogi", + "2018 iCyP", +] + +[permissions] +files = "Import/export VRM from/to disk" diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 0000000..3822ea3 --- /dev/null +++ b/common/__init__.py @@ -0,0 +1 @@ +# SPDX-License-Identifier: MIT OR GPL-3.0-or-later diff --git a/common/__pycache__/__init__.cpython-311.pyc b/common/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..28ae0f3 Binary files /dev/null and b/common/__pycache__/__init__.cpython-311.pyc differ diff --git a/common/__pycache__/blender_manifest.cpython-311.pyc b/common/__pycache__/blender_manifest.cpython-311.pyc new file mode 100644 index 0000000..73579ad Binary files /dev/null and b/common/__pycache__/blender_manifest.cpython-311.pyc differ diff --git a/common/__pycache__/char.cpython-311.pyc b/common/__pycache__/char.cpython-311.pyc new file mode 100644 index 0000000..ee4f220 Binary files /dev/null and b/common/__pycache__/char.cpython-311.pyc differ diff --git a/common/__pycache__/convert.cpython-311.pyc b/common/__pycache__/convert.cpython-311.pyc new file mode 100644 index 0000000..1a78095 Binary files /dev/null and b/common/__pycache__/convert.cpython-311.pyc differ diff --git a/common/__pycache__/convert_any.cpython-311.pyc b/common/__pycache__/convert_any.cpython-311.pyc new file mode 100644 index 0000000..4bf6591 Binary files /dev/null and b/common/__pycache__/convert_any.cpython-311.pyc differ diff --git a/common/__pycache__/debug.cpython-311.pyc b/common/__pycache__/debug.cpython-311.pyc new file mode 100644 index 0000000..2391baf Binary files /dev/null and b/common/__pycache__/debug.cpython-311.pyc differ diff --git a/common/__pycache__/deep.cpython-311.pyc b/common/__pycache__/deep.cpython-311.pyc new file mode 100644 index 0000000..8426b5d Binary files /dev/null and b/common/__pycache__/deep.cpython-311.pyc differ diff --git a/common/__pycache__/error_dialog.cpython-311.pyc b/common/__pycache__/error_dialog.cpython-311.pyc new file mode 100644 index 0000000..4e0588e Binary files /dev/null and b/common/__pycache__/error_dialog.cpython-311.pyc differ diff --git a/common/__pycache__/fs.cpython-311.pyc b/common/__pycache__/fs.cpython-311.pyc new file mode 100644 index 0000000..fb23d2f Binary files /dev/null and b/common/__pycache__/fs.cpython-311.pyc differ diff --git a/common/__pycache__/gl.cpython-311.pyc b/common/__pycache__/gl.cpython-311.pyc new file mode 100644 index 0000000..c48818f Binary files /dev/null and b/common/__pycache__/gl.cpython-311.pyc differ diff --git a/common/__pycache__/gltf.cpython-311.pyc b/common/__pycache__/gltf.cpython-311.pyc new file mode 100644 index 0000000..bf0d06a Binary files /dev/null and b/common/__pycache__/gltf.cpython-311.pyc differ diff --git a/common/__pycache__/legacy_gltf.cpython-311.pyc b/common/__pycache__/legacy_gltf.cpython-311.pyc new file mode 100644 index 0000000..6b6b290 Binary files /dev/null and b/common/__pycache__/legacy_gltf.cpython-311.pyc differ diff --git a/common/__pycache__/logger.cpython-311.pyc b/common/__pycache__/logger.cpython-311.pyc new file mode 100644 index 0000000..3abde51 Binary files /dev/null and b/common/__pycache__/logger.cpython-311.pyc differ diff --git a/common/__pycache__/mtoon_unversioned.cpython-311.pyc b/common/__pycache__/mtoon_unversioned.cpython-311.pyc new file mode 100644 index 0000000..bfd9af5 Binary files /dev/null and b/common/__pycache__/mtoon_unversioned.cpython-311.pyc differ diff --git a/common/__pycache__/preferences.cpython-311.pyc b/common/__pycache__/preferences.cpython-311.pyc new file mode 100644 index 0000000..d2c3904 Binary files /dev/null and b/common/__pycache__/preferences.cpython-311.pyc differ diff --git a/common/__pycache__/progress.cpython-311.pyc b/common/__pycache__/progress.cpython-311.pyc new file mode 100644 index 0000000..f6e0ae4 Binary files /dev/null and b/common/__pycache__/progress.cpython-311.pyc differ diff --git a/common/__pycache__/rotation.cpython-311.pyc b/common/__pycache__/rotation.cpython-311.pyc new file mode 100644 index 0000000..88b857b Binary files /dev/null and b/common/__pycache__/rotation.cpython-311.pyc differ diff --git a/common/__pycache__/safe_removal.cpython-311.pyc b/common/__pycache__/safe_removal.cpython-311.pyc new file mode 100644 index 0000000..8f67a0e Binary files /dev/null and b/common/__pycache__/safe_removal.cpython-311.pyc differ diff --git a/common/__pycache__/scene_watcher.cpython-311.pyc b/common/__pycache__/scene_watcher.cpython-311.pyc new file mode 100644 index 0000000..7f4c8d9 Binary files /dev/null and b/common/__pycache__/scene_watcher.cpython-311.pyc differ diff --git a/common/__pycache__/shader.cpython-311.pyc b/common/__pycache__/shader.cpython-311.pyc new file mode 100644 index 0000000..ef6199b Binary files /dev/null and b/common/__pycache__/shader.cpython-311.pyc differ diff --git a/common/__pycache__/version.cpython-311.pyc b/common/__pycache__/version.cpython-311.pyc new file mode 100644 index 0000000..ec3fa86 Binary files /dev/null and b/common/__pycache__/version.cpython-311.pyc differ diff --git a/common/__pycache__/workspace.cpython-311.pyc b/common/__pycache__/workspace.cpython-311.pyc new file mode 100644 index 0000000..125da75 Binary files /dev/null and b/common/__pycache__/workspace.cpython-311.pyc differ diff --git a/common/blender_manifest.py b/common/blender_manifest.py new file mode 100644 index 0000000..b2d7a71 --- /dev/null +++ b/common/blender_manifest.py @@ -0,0 +1,82 @@ +# SPDX-License-Identifier: MIT OR GPL-3.0-or-later +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + + +@dataclass(frozen=True) +class BlenderManifest: + id: str + version: tuple[int, int, int] + blender_version_min: tuple[int, int, int] + blender_version_max: Optional[tuple[int, int, int]] + + @classmethod + def default_blender_manifest_path(cls) -> Path: + return Path(__file__).parent.parent / "blender_manifest.toml" + + @classmethod + def read_str(cls, blender_manifest: str, key: str) -> Optional[str]: + # When the version of Python used by the minimum supported version of Blender + # exceeds 3.11, it is rewritten in the tomli library. + lines = list(map(str.strip, blender_manifest.splitlines())) + + pattern = key + r' = "([^"]+)"' + for line in lines: + match = re.fullmatch(pattern, line) + if not match: + continue + return match[1] + + return None + + @classmethod + def read_3_tuple_version( + cls, blender_manifest: str, key: str + ) -> Optional[tuple[int, int, int]]: + # When the version of Python used by the minimum supported version of Blender + # exceeds 3.11, it is rewritten in the tomli library. + lines = list(map(str.strip, blender_manifest.splitlines())) + + pattern = key + r' = "(\d+)\.(\d+)\.(\d+)"' + for line in lines: + match = re.fullmatch(pattern, line) + if not match: + continue + return (int(match[1]), int(match[2]), int(match[3])) + + return None + + @classmethod + def read(cls, blender_manifest: Optional[str] = None) -> "BlenderManifest": + if blender_manifest is None: + blender_manifest = cls.default_blender_manifest_path().read_text( + encoding="UTF-8" + ) + + addon_id = cls.read_str(blender_manifest, "id") + if addon_id is None: + message = "'id' was not found in blender manifest" + raise ValueError(message) + + version = cls.read_3_tuple_version(blender_manifest, "version") + if version is None: + message = "'version' was not found in blender manifest" + raise ValueError(message) + + blender_version_min = cls.read_3_tuple_version( + blender_manifest, "blender_version_min" + ) + if blender_version_min is None: + message = "'blender_version_min' was not found in blender manifest" + raise ValueError(message) + + return BlenderManifest( + id=addon_id, + version=version, + blender_version_min=blender_version_min, + blender_version_max=cls.read_3_tuple_version( + blender_manifest, "blender_version_max" + ), + ) diff --git a/common/char.py b/common/char.py new file mode 100644 index 0000000..147e530 --- /dev/null +++ b/common/char.py @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: MIT OR GPL-3.0-or-later +from collections.abc import Mapping +from typing import Final + +INTERNAL_NAME_PREFIX: Final = "\N{FULLWIDTH BROKEN BAR}" +""" +The letter to be prefixed when naming internal invisible objects. It was chosen so that +the sort order would be backward and also so that it would be easy to identify the +object as internal. +""" + +DISABLE_TRANSLATION: Final = "\N{ZERO WIDTH SPACE}" + +FULLWIDTH_ASCII_TO_ASCII_MAP: Final[Mapping[str, str]] = { + chr(ascii_char + 0x0000_FEE0): chr(ascii_char) for ascii_char in range(0x21, 0x7E) +} diff --git a/common/convert.py b/common/convert.py new file mode 100644 index 0000000..fb190c2 --- /dev/null +++ b/common/convert.py @@ -0,0 +1,297 @@ +# SPDX-License-Identifier: MIT OR GPL-3.0-or-later +import math +from collections.abc import Iterator, Mapping, Sequence +from sys import float_info +from typing import Optional, Union + +from . import convert_any +from .logger import get_logger + +logger = get_logger(__name__) + +Json = Union[ + None, + bool, + int, + float, + str, + list["Json"], + dict[str, "Json"], +] + + +def iterator_or_none(v: object) -> Optional[Iterator[object]]: + try: + # "isinstance(v, Iterable)" doesn't work. + # https://github.com/python/cpython/blob/3.9/Doc/library/collections.abc.rst?plain=1#L126-L127 + iterator = iter(v) # type: ignore[call-overload] + except TypeError: + return None + return convert_any.iterator_to_object_iterator(iterator) + + +def sequence_or_none( + sequence_object: object, +) -> Optional[Sequence[object]]: + sequence = sequence_object + if not isinstance(sequence, Sequence): + return None + iterator = iterator_or_none(sequence_object) + if iterator is None: + return None + return list(iterator) + + +def mapping_or_none( + mapping_object: object, +) -> Optional[Mapping[object, object]]: + mapping = mapping_object + if not isinstance(mapping, Mapping): + return None + key_iterator = iterator_or_none(mapping_object) + if key_iterator is None: + return None + return {key: convert_any.to_object(mapping[key]) for key in key_iterator} + + +def vrm_json_vector3_to_tuple( + value: Json, +) -> Optional[tuple[float, float, float]]: + if not isinstance(value, Mapping): + return None + x = float_or(value.get("x"), 0.0) + y = float_or(value.get("y"), 0.0) + z = float_or(value.get("z"), 0.0) + return (x, y, z) + + +def vrm_json_curve_to_list(curve: Json) -> Optional[list[float]]: + if not isinstance(curve, list): + return None + values: list[float] = [float_or(v, 0.0) for v in curve] + while len(values) < 8: + values.append(0) + while len(values) > 8: + values.pop() + return values + + +def vrm_json_array_to_float_vector( + input_values: Json, defaults: list[float] +) -> list[float]: + if not isinstance(input_values, list): + return defaults + + output_values: list[float] = [] + for index, default in enumerate(defaults): + if index >= len(input_values): + output_values.append(default) + continue + + input_value = float_or(input_values[index], default) + output_values.append(input_value) + + return output_values + + +BPY_TRACK_AXIS_TO_VRM_AIM_AXIS = { + "TRACK_X": "PositiveX", + "TRACK_Y": "PositiveY", + "TRACK_Z": "PositiveZ", + "TRACK_NEGATIVE_X": "NegativeX", + "TRACK_NEGATIVE_Y": "NegativeY", + "TRACK_NEGATIVE_Z": "NegativeZ", +} + +VRM_AIM_AXIS_TO_BPY_TRACK_AXIS = { + v: k for k, v in BPY_TRACK_AXIS_TO_VRM_AIM_AXIS.items() +} + + +def mtoon_shading_toony_1_to_0(shading_toony: float, shading_shift: float) -> float: + base = 2 - shading_toony + shading_shift + if abs(base) < float_info.epsilon: + # https://github.com/Santarh/MToon/blob/43f02fe8c9b19c3cf9f3238003b7d8b332933833/MToon/Resources/Shaders/MToon.shader#L17 + return 0.9 + return max(0, min((shading_toony + shading_shift) / base, 1)) + + +def mtoon_shading_shift_1_to_0(shading_toony: float, shading_shift: float) -> float: + return max(-1, min(shading_toony - shading_shift - 1, 1)) + + +def mtoon_gi_equalization_to_intensity(gi_equalization: float) -> float: + return max(0, min(1, 1 - gi_equalization)) + + +# https://github.com/vrm-c/UniVRM/blob/f3479190c330ec6ecd2b40be919285aa93a53aff/Assets/VRMShaders/VRM10/MToon10/Runtime/MToon10Migrator.cs#L10-L19 +def mtoon_shading_toony_0_to_1( + shading_toony_0x: float, shading_shift_0x: float +) -> float: + (range_min, range_max) = get_shading_range_0x(shading_toony_0x, shading_shift_0x) + return max(0, min(1, (2 - (range_max - range_min)) * 0.5)) + + +# https://github.com/vrm-c/UniVRM/blob/f3479190c330ec6ecd2b40be919285aa93a53aff/Assets/VRMShaders/VRM10/MToon10/Runtime/MToon10Migrator.cs#L21-L30 +def mtoon_shading_shift_0_to_1( + shading_toony_0x: float, shading_shift_0x: float +) -> float: + (range_min, range_max) = get_shading_range_0x(shading_toony_0x, shading_shift_0x) + return max(-1, min(1, ((range_max + range_min) * 0.5 * -1))) + + +# https://github.com/vrm-c/UniVRM/blob/f3479190c330ec6ecd2b40be919285aa93a53aff/Assets/VRMShaders/VRM10/MToon10/Runtime/MToon10Migrator.cs#L32-L38 +def mtoon_intensity_to_gi_equalization(gi_intensity_0x: float) -> float: + return max(0, min(1, 1 - gi_intensity_0x)) + + +# https://github.com/vrm-c/UniVRM/blob/f3479190c330ec6ecd2b40be919285aa93a53aff/Assets/VRMShaders/VRM10/MToon10/Runtime/MToon10Migrator.cs#L40-L46 +def get_shading_range_0x( + shading_toony_0x: float, shading_shift_0x: float +) -> tuple[float, float]: + range_min = shading_shift_0x + range_max = (1 - shading_toony_0x) + shading_toony_0x * shading_shift_0x + return (range_min, range_max) + + +def float_or_none( + v: object, + min_value: float = -float_info.max, + max_value: float = float_info.max, +) -> Optional[float]: + if isinstance(v, float): + if math.isnan(v): + return None + return max(min_value, min(v, max_value)) + + if isinstance(v, bool): + return 1.0 if v else 0.0 + + if isinstance(v, int): + return max(min_value, min(float(v), max_value)) + + return None + + +def float_or( + v: object, + default: float, + min_value: float = -float_info.max, + max_value: float = float_info.max, +) -> float: + float_or_none_value = float_or_none(v, min_value, max_value) + if float_or_none_value is not None: + return float_or_none_value + + if min_value <= default <= max_value: + return default + + logger.warning( + "Default float value %s is out of range [%s, %s]", default, min_value, max_value + ) + + return max(min_value, min(default, max_value)) + + +def float4_or_none(value4: object) -> Optional[tuple[float, float, float, float]]: + value4_iterator = iterator_or_none(value4) + if value4_iterator is None: + return None + + result: list[float] = [] + for value in value4_iterator: + if len(result) == 4: + return None + float_value = float_or_none(value) + if float_value is None: + return None + result.append(float_value) + if len(result) != 4: + return None + return (result[0], result[1], result[2], result[3]) + + +def float4_or( + value4: object, default: tuple[float, float, float, float] +) -> tuple[float, float, float, float]: + float4 = float4_or_none(value4) + return float4 if float4 is not None else default + + +def float3_or_none(value3: object) -> Optional[tuple[float, float, float]]: + value3_iterator = iterator_or_none(value3) + if value3_iterator is None: + return None + + result: list[float] = [] + for value in value3_iterator: + if len(result) == 3: + return None + float_value = float_or_none(value) + if float_value is None: + return None + result.append(float_value) + if len(result) != 3: + return None + return (result[0], result[1], result[2]) + + +def float3_or( + value3: object, default: tuple[float, float, float] +) -> tuple[float, float, float]: + float3 = float3_or_none(value3) + return float3 if float3 is not None else default + + +def float2_or_none(value2: object) -> Optional[tuple[float, float]]: + value2_iterator = iterator_or_none(value2) + if value2_iterator is None: + return None + result: list[float] = [] + for value in value2_iterator: + if len(result) == 2: + return None + float_value = float_or_none(value) + if float_value is None: + return None + result.append(float_value) + if len(result) != 2: + return None + return (result[0], result[1]) + + +def float2_or(value2: object, default: tuple[float, float]) -> tuple[float, float]: + float2 = float2_or_none(value2) + return float2 if float2 is not None else default + + +def str_or(v: object, default: str) -> str: + if isinstance(v, str): + return v + return default + + +def axis_blender_to_gltf(vector3: Sequence[float]) -> tuple[float, float, float]: + return ( + -vector3[0], + vector3[2], + vector3[1], + ) + + +def linear_to_srgb( + non_color: Sequence[float], +) -> Sequence[float]: + return [ + math.pow(channel_value, 1.0 / 2.2) if channel_index < 3 else channel_value + for channel_index, channel_value in enumerate(non_color) + ] + + +def srgb_to_linear( + srgb_color: Sequence[float], +) -> Sequence[float]: + return [ + math.pow(channel_value, 2.2) if channel_index < 3 else channel_value + for channel_index, channel_value in enumerate(srgb_color) + ] diff --git a/common/convert_any.py b/common/convert_any.py new file mode 100644 index 0000000..991eae9 --- /dev/null +++ b/common/convert_any.py @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: MIT OR GPL-3.0-or-later +"""A module provides a function to convert a variable of type Any to a concrete type. + +Inevitably, variables of type Any may occur, and such variables cannot be handled +in type checkers in strict mode. Any is allowed only in the module here. +""" + +import sys +from collections.abc import Iterator +from typing import ( + Any, # Any is allowed only in the module here. + Optional, +) + + +def to_object( # type: ignore[explicit-any] + any_object: Any, # noqa: ANN401 # Any is allowed only in the module here. +) -> object: + # Interpret Unknown as object + # https://github.com/microsoft/pyright/issues/3650 + if not isinstance(any_object, object): + if sys.version_info >= (3, 11): + from typing import assert_never + + assert_never(any_object) + raise TypeError + return any_object + + +def iterator_to_object_iterator( # type: ignore[explicit-any] + any_iterator: Any, # noqa: ANN401 # Any is allowed only in the module here. +) -> Optional[Iterator[object]]: + any_iterator_without_partial_type_narrowing = any_iterator + if not isinstance(any_iterator, Iterator): + return None + return map(to_object, any_iterator_without_partial_type_narrowing) diff --git a/common/debug.py b/common/debug.py new file mode 100644 index 0000000..e3dcbe4 --- /dev/null +++ b/common/debug.py @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: MIT OR GPL-3.0-or-later +import math +from collections.abc import Sequence +from typing import Union + +from mathutils import Matrix, Quaternion, Vector + + +def dump(v: Union[Matrix, Vector, Quaternion, float]) -> str: + if isinstance(v, (float, int)): + return str(v) + + if isinstance(v, Matrix): + t, r, s = v.decompose() + return f"Matrix(T={dump(t)},R={dump(r)},S={dump(s)})" + + if isinstance(v, Vector): + return f"({v.x:.3f},{v.y:.3f},{v.z:.3f})" + + x, y, z = (round(math.degrees(xyz)) for xyz in v.to_euler()[:]) + return f"Euler({x},{y},{z})" + + +def assert_vector3_equals( + expected: Vector, actual: Sequence[float], message: str +) -> None: + if len(actual) != 3: + message = f"actual length is not 3: {actual}" + raise AssertionError(message) + + threshold = 0.0001 + if abs(expected[0] - actual[0]) > threshold: + message = f"{message}: {tuple(expected)} is different from {tuple(actual)}" + raise AssertionError(message) + if abs(expected[1] - actual[1]) > threshold: + message = f"{message}: {tuple(expected)} is different from {tuple(actual)}" + raise AssertionError(message) + if abs(expected[2] - actual[2]) > threshold: + message = f"{message}: {tuple(expected)} is different from {tuple(actual)}" + raise AssertionError(message) + + +def cleanse_modules() -> None: + """Search for your plugin modules in blender python sys.modules and remove them. + + To support reload properly, try to access a package var, if it's there, + reload everything. + + This function may cause errors that are difficult to investigate. Please use with + caution. See also: + https://github.com/saturday06/VRM-Addon-for-Blender/issues/506#issuecomment-2183766778 + """ + import sys + + all_modules = sys.modules + all_modules = dict(sorted(all_modules.items(), key=lambda x: x[0])) # sort them + + for k in all_modules: + if k == __name__ or k.startswith(__name__ + "."): + del sys.modules[k] diff --git a/common/deep.py b/common/deep.py new file mode 100644 index 0000000..f6d198c --- /dev/null +++ b/common/deep.py @@ -0,0 +1,146 @@ +# SPDX-License-Identifier: MIT OR GPL-3.0-or-later +import difflib +import math +from collections.abc import Mapping +from json import dumps as json_dumps + +from . import convert +from .convert import Json +from .logger import get_logger + +logger = get_logger(__name__) + + +def make_json(v: object) -> Json: + if v is None: + return None + if isinstance(v, int): + return v + if isinstance(v, float): + if not math.isfinite(v): + logger.warning("%s %s is non-finite value", v, type(v)) + return 0.0 + return v + if isinstance(v, bool): + return v + if isinstance(v, str): + return v + + mapping = convert.mapping_or_none(v) + if mapping is not None: + dict_result: dict[str, Json] = {} + for key, value in mapping.items(): + if isinstance(key, str): + dict_result[key] = make_json(value) + continue + logger.warning("%s %s is unrecognized type for dict key", key, type(key)) + return dict_result + + iterator = convert.iterator_or_none(v) + if iterator is not None: + return [make_json(x) for x in iterator] + + logger.warning("%s %s is unrecognized type", v, type(v)) + return None + + +def make_json_dict(v: Mapping[str, object]) -> dict[str, Json]: + return {key: make_json(value) for key, value in v.items()} + + +def diff( + left: Json, + right: Json, + float_tolerance: float = 0, + path: str = "", +) -> list[str]: + if isinstance(left, list): + if not isinstance(right, list): + return [f"{path}: left is list but right is {type(right)}"] + if len(left) != len(right): + result = [ + f"{path}: left length is {len(left)} but right length is {len(right)}" + ] + + right_json_str = json_dumps(right, indent=4, sort_keys=True) + right_json_lines = right_json_str.splitlines(keepends=True) + left_json_str = json_dumps(left, indent=4, sort_keys=True) + left_json_lines = left_json_str.splitlines(keepends=True) + + if len(right_json_lines) > 1000 or len(left_json_lines) > 1000: + return result + + unified_diff = [ + line.rstrip() + for line in difflib.unified_diff( + right_json_lines, + left_json_lines, + f"{path}/right", + f"{path}/left", + ) + ] + if len(unified_diff) > 1000: + return result + return result + unified_diff + diffs: list[str] = [] + for i, (left_child, right_child) in enumerate(zip(left, right)): + diffs.extend(diff(left_child, right_child, float_tolerance, f"{path}[{i}]")) + return diffs + + if isinstance(left, dict): + if not isinstance(right, dict): + return [f"{path}: left is dict but right is {type(right)}"] + diffs = [] + for key in sorted(set(list(left.keys()) + list(right.keys()))): + if key not in left: + diffs.append(f'{path}: {key} not in left. right["{key}"]={right[key]}') + continue + if key not in right: + diffs.append(f'{path}: {key} not in right, left["{key}"]={left[key]}') + continue + diffs.extend( + diff(left[key], right[key], float_tolerance, f'{path}["{key}"]') + ) + return diffs + + if isinstance(left, bool): + if not isinstance(right, bool): + return [f"{path}: left is bool but right is {type(right)}"] + if left != right: + return [f"{path}: left is {left} but right is {right}"] + return [] + + if isinstance(left, str): + if not isinstance(right, str): + return [f"{path}: left is str but right is {type(right)}"] + if left != right: + return [f'{path}: left is "{left}" but right is "{right}"'] + return [] + + if left is None and right is not None: + return [f"{path}: left is None but right is {type(right)}"] + + if isinstance(left, int) and isinstance(right, int): + if left != right: + return [f"{path}: left is {left} but right is {right}"] + return [] + + if isinstance(left, (int, float)) and isinstance(right, (int, float)): + if (isinstance(left, float) and not math.isfinite(left)) or ( + isinstance(right, float) and not math.isfinite(right) + ): + return [ + f"{path}: left is {left}" + + f" but right is {right}, They are not comparable numbers" + ] + + error = math.fabs(float(left) - float(right)) + if error > float_tolerance: + return [ + f"{path}: left is {float(left):20.17f}" + + f" but right is {float(right):20.17f}, error={error:19.17f}" + ] + return [] + + message = f"{path}: unexpected type left={type(left)} right={type(right)}" + raise ValueError(message) diff --git a/common/error_dialog.py b/common/error_dialog.py new file mode 100644 index 0000000..a7454bc --- /dev/null +++ b/common/error_dialog.py @@ -0,0 +1,242 @@ +# SPDX-License-Identifier: MIT OR GPL-3.0-or-later + +import contextlib +import datetime +import getpass +import platform +import sys +import sysconfig +from collections.abc import Sequence +from collections.abc import Set as AbstractSet +from pathlib import Path +from typing import TYPE_CHECKING, Union + +import bpy +from bpy.props import CollectionProperty, IntProperty, StringProperty +from bpy.types import ( + Context, + Event, + Operator, + PropertyGroup, + UILayout, + UIList, +) +from bpy_extras.io_utils import ExportHelper + +from ..common import ops +from ..common.logger import get_logger +from ..common.version import get_addon_version +from ..editor.ops import VRM_OT_open_url_in_web_browser, layout_operator +from ..editor.property_group import CollectionPropertyProtocol + +logger = get_logger(__name__) + + +def show_error_dialog( + title: str, + lines: Union[str, Sequence[str]], + *, + append_environment: bool = True, +) -> set[str]: + if isinstance(lines, str): + lines = list(map(str.rstrip, lines.splitlines())) + while lines and not lines[0].strip(): + del lines[0] + while lines and not lines[-1].strip(): + del lines[-1] + else: + lines = list(lines) + if not lines: + return {"CANCELLED"} + if append_environment: + if lines: + lines[0] = f"Python: {lines[0]}" + + os_name = None + platform_system, _, _ = platform.system_alias( + platform.system(), platform.release(), platform.version() + ) + if sys.version_info >= (3, 10) and platform_system not in ["Windows", "Darwin"]: + # https://github.com/python/cpython/blob/v3.10.18/Doc/library/platform.rst?plain=1#L271-L272 + with contextlib.suppress(OSError): + os_name = platform.freedesktop_os_release().get("PRETTY_NAME") + + if not os_name: + os_name = platform_system + + python_version = ( + platform.python_implementation() + + " " + + ".".join(map(str, sys.version_info[:3])) + ) + runtime_platform = os_name + " " + platform.machine() + build_platform = sysconfig.get_platform() + lines.insert( + 0, + "Environment: Blender {} / VRM Add-on {} / {} / {} ({})".format( + bpy.app.version_string, + ".".join(map(str, get_addon_version())), + python_version, + runtime_platform, + build_platform, + ), + ) + return ops.wm.vrm_error_dialog( + "INVOKE_DEFAULT", + title=title, + lines=[ + { + "name": f"error_line_{i}", + "line": mask_private_string(line), + } + for i, line in enumerate(lines) + ], + ) + + +def mask_private_string(message: str) -> str: + script_path = Path(__file__).parent.parent.parent.parent.parent + if len(script_path.parts) > 3: + message = message.replace(str(script_path), "