feat: Add VRM Blender addon with complete import/export functionality
- Add core VRM addon infrastructure with manifest and registration - Add common utilities module with file system, logging, and conversion helpers - Add human bone mapper with support for multiple rigging standards (Mixamo, MMD, Unreal, Rigify, etc.) - Add VRM 0.x and 1.x format support with property groups and handlers - Add editor UI panels for VRM metadata, spring bones, and MToon materials - Add exporter with glTF2 extension support for VRM format serialization - Add importer with scene reconstruction and armature generation - Add MToon shader support with auto-setup and material migration - Add spring bone physics simulation with constraint handling - Add node constraint editor for advanced rigging control - Add comprehensive validation and error handling with user dialogs - Add scene watcher for real-time property synchronization - Add workspace management and preference system - Include Python cache files and Blender manifest configuration - This is the initial commit establishing the complete VRM addon ecosystem for Blender
This commit is contained in:
1
importer/__init__.py
Normal file
1
importer/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
|
||||
BIN
importer/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
importer/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
importer/__pycache__/abstract_base_vrm_importer.cpython-311.pyc
Normal file
BIN
importer/__pycache__/abstract_base_vrm_importer.cpython-311.pyc
Normal file
Binary file not shown.
BIN
importer/__pycache__/file_handler.cpython-311.pyc
Normal file
BIN
importer/__pycache__/file_handler.cpython-311.pyc
Normal file
Binary file not shown.
BIN
importer/__pycache__/gltf2_import_user_extension.cpython-311.pyc
Normal file
BIN
importer/__pycache__/gltf2_import_user_extension.cpython-311.pyc
Normal file
Binary file not shown.
BIN
importer/__pycache__/import_scene.cpython-311.pyc
Normal file
BIN
importer/__pycache__/import_scene.cpython-311.pyc
Normal file
Binary file not shown.
BIN
importer/__pycache__/license_validation.cpython-311.pyc
Normal file
BIN
importer/__pycache__/license_validation.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
importer/__pycache__/vrm0_importer.cpython-311.pyc
Normal file
BIN
importer/__pycache__/vrm0_importer.cpython-311.pyc
Normal file
Binary file not shown.
BIN
importer/__pycache__/vrm1_importer.cpython-311.pyc
Normal file
BIN
importer/__pycache__/vrm1_importer.cpython-311.pyc
Normal file
Binary file not shown.
1435
importer/abstract_base_vrm_importer.py
Normal file
1435
importer/abstract_base_vrm_importer.py
Normal file
File diff suppressed because it is too large
Load Diff
83
importer/file_handler.py
Normal file
83
importer/file_handler.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bpy.props import StringProperty
|
||||
from bpy.types import Context, Event, FileHandler, Operator
|
||||
|
||||
from ..common import ops
|
||||
|
||||
|
||||
class VRM_OT_import_vrm_via_file_handler(Operator):
|
||||
bl_idname = "vrm.import_vrm_via_file_handler"
|
||||
bl_label = "Import VRM via FileHandler"
|
||||
|
||||
filepath: StringProperty( # type: ignore[valid-type]
|
||||
subtype="FILE_PATH",
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, _context: Context) -> bool:
|
||||
return True
|
||||
|
||||
def execute(self, _context: Context) -> set[str]:
|
||||
return ops.import_scene.vrm(filepath=self.filepath, use_addon_preferences=True)
|
||||
|
||||
def invoke(self, context: Context, _event: Event) -> set[str]:
|
||||
return self.execute(context)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This code is auto generated.
|
||||
# To regenerate, run the `uv run tools/property_typing.py` command.
|
||||
filepath: str # type: ignore[no-redef]
|
||||
|
||||
|
||||
class VRM_OT_import_vrma_via_file_handler(Operator):
|
||||
bl_idname = "vrm.import_vrma_via_file_handler"
|
||||
bl_label = "Import VRMA via FileHandler"
|
||||
|
||||
filepath: StringProperty( # type: ignore[valid-type]
|
||||
subtype="FILE_PATH",
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, _context: Context) -> bool:
|
||||
return True
|
||||
|
||||
def execute(self, _context: Context) -> set[str]:
|
||||
return ops.import_scene.vrma(filepath=self.filepath)
|
||||
|
||||
def invoke(self, context: Context, _event: Event) -> set[str]:
|
||||
return self.execute(context)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This code is auto generated.
|
||||
# To regenerate, run the `uv run tools/property_typing.py` command.
|
||||
filepath: str # type: ignore[no-redef]
|
||||
|
||||
|
||||
class VRM_FH_vrm_import(FileHandler):
|
||||
bl_idname = "VRM_FH_vrm_import"
|
||||
bl_label = "Import VRM"
|
||||
bl_import_operator = VRM_OT_import_vrm_via_file_handler.bl_idname
|
||||
bl_export_operator = "export_scene.vrm"
|
||||
bl_file_extensions = ".vrm"
|
||||
|
||||
@classmethod
|
||||
def poll_drop(cls, context: Context) -> bool:
|
||||
_ = context
|
||||
return True
|
||||
|
||||
|
||||
class VRM_FH_vrma_import(FileHandler):
|
||||
bl_idname = "VRM_FH_vrma_import"
|
||||
bl_label = "Import VRMA"
|
||||
bl_import_operator = VRM_OT_import_vrma_via_file_handler.bl_idname
|
||||
bl_export_operator = "export_scene.vrma"
|
||||
bl_file_extensions = ".vrma"
|
||||
|
||||
@classmethod
|
||||
def poll_drop(cls, context: Context) -> bool:
|
||||
_ = context
|
||||
return True
|
||||
62
importer/gltf2_import_user_extension.py
Normal file
62
importer/gltf2_import_user_extension.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
|
||||
import secrets
|
||||
import string
|
||||
from typing import Optional
|
||||
|
||||
from bpy.types import Image
|
||||
|
||||
from ..common.convert import sequence_or_none
|
||||
from ..common.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class glTF2ImportUserExtension:
|
||||
current_import_id: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def update_current_import_id(cls) -> str:
|
||||
import_id = "BlenderVrmAddonImport" + (
|
||||
"".join(secrets.choice(string.digits) for _ in range(10))
|
||||
)
|
||||
cls.current_import_id = import_id
|
||||
return import_id
|
||||
|
||||
@classmethod
|
||||
def clear_current_import_id(cls) -> None:
|
||||
cls.current_import_id = None
|
||||
|
||||
# https://github.com/KhronosGroup/glTF-Blender-IO/blob/6f9d0d9fc1bb30e2b0bb019342ffe86bd67358fc/addons/io_scene_gltf2/blender/imp/gltf2_blender_image.py#L51
|
||||
def gather_import_image_after_hook(
|
||||
self, image: object, bpy_image: object, gltf_importer: object
|
||||
) -> None:
|
||||
if self.current_import_id is None:
|
||||
return
|
||||
|
||||
if not isinstance(bpy_image, Image):
|
||||
logger.warning(
|
||||
"gather_import_image_after_hook: bpy_image is not a Image but %s",
|
||||
type(bpy_image),
|
||||
)
|
||||
return
|
||||
|
||||
images = sequence_or_none(
|
||||
getattr(getattr(gltf_importer, "data", None), "images", None)
|
||||
)
|
||||
if images is None:
|
||||
logger.warning(
|
||||
"gather_import_image_after_hook:"
|
||||
" gltf_importer is unexpected structure: %s",
|
||||
gltf_importer,
|
||||
)
|
||||
return
|
||||
|
||||
if image not in images:
|
||||
logger.warning(
|
||||
"gather_import_image_after_hook: %s not in %s", image, images
|
||||
)
|
||||
return
|
||||
|
||||
index = images.index(image)
|
||||
|
||||
bpy_image[self.current_import_id] = index
|
||||
559
importer/import_scene.py
Normal file
559
importer/import_scene.py
Normal file
@@ -0,0 +1,559 @@
|
||||
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
|
||||
import traceback
|
||||
from collections.abc import Set as AbstractSet
|
||||
from os import environ
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import bpy
|
||||
from bpy.app.translations import pgettext
|
||||
from bpy.props import BoolProperty, CollectionProperty, StringProperty
|
||||
from bpy.types import (
|
||||
Armature,
|
||||
Context,
|
||||
Event,
|
||||
Operator,
|
||||
Panel,
|
||||
PropertyGroup,
|
||||
SpaceFileBrowser,
|
||||
)
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
|
||||
from ..common import ops, version
|
||||
from ..common.error_dialog import show_error_dialog
|
||||
from ..common.logger import get_logger
|
||||
from ..common.preferences import (
|
||||
ImportPreferencesProtocol,
|
||||
copy_import_preferences,
|
||||
create_import_preferences_dict,
|
||||
draw_import_preferences_layout,
|
||||
get_preferences,
|
||||
)
|
||||
from ..editor import search
|
||||
from ..editor.extension import get_armature_extension
|
||||
from ..editor.ops import VRM_OT_open_url_in_web_browser, layout_operator
|
||||
from ..editor.property_group import CollectionPropertyProtocol, StringPropertyGroup
|
||||
from .abstract_base_vrm_importer import AbstractBaseVrmImporter, parse_vrm_json
|
||||
from .license_validation import LicenseConfirmationRequiredError
|
||||
from .uni_vrm_vrm_animation_importer import UniVrmVrmAnimationImporter
|
||||
from .vrm0_importer import Vrm0Importer
|
||||
from .vrm1_importer import Vrm1Importer
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LicenseConfirmation(PropertyGroup):
|
||||
message: StringProperty() # type: ignore[valid-type]
|
||||
url: StringProperty() # type: ignore[valid-type]
|
||||
json_key: StringProperty() # type: ignore[valid-type]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This code is auto generated.
|
||||
# To regenerate, run the `uv run tools/property_typing.py` command.
|
||||
message: str # type: ignore[no-redef]
|
||||
url: str # type: ignore[no-redef]
|
||||
json_key: str # type: ignore[no-redef]
|
||||
|
||||
|
||||
def import_vrm_update_addon_preferences(
|
||||
import_op: "IMPORT_SCENE_OT_vrm", context: Context
|
||||
) -> None:
|
||||
if import_op.use_addon_preferences:
|
||||
copy_import_preferences(source=import_op, destination=get_preferences(context))
|
||||
|
||||
|
||||
class IMPORT_SCENE_OT_vrm(Operator, ImportHelper):
|
||||
bl_idname = "import_scene.vrm"
|
||||
bl_label = "Open"
|
||||
bl_description = "import VRM"
|
||||
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
|
||||
|
||||
filename_ext = ".vrm"
|
||||
filter_glob: StringProperty( # type: ignore[valid-type]
|
||||
default="*.vrm",
|
||||
options={"HIDDEN"},
|
||||
)
|
||||
|
||||
use_addon_preferences: BoolProperty( # type: ignore[valid-type]
|
||||
name="Import using add-on preferences",
|
||||
description="Import using add-on preferences instead of operator arguments",
|
||||
)
|
||||
|
||||
extract_textures_into_folder: BoolProperty( # type: ignore[valid-type]
|
||||
name="Extract texture images into the folder",
|
||||
update=import_vrm_update_addon_preferences,
|
||||
default=False,
|
||||
)
|
||||
make_new_texture_folder: BoolProperty( # type: ignore[valid-type]
|
||||
name="Don't overwrite existing texture folder",
|
||||
update=import_vrm_update_addon_preferences,
|
||||
default=True,
|
||||
)
|
||||
set_shading_type_to_material_on_import: BoolProperty( # type: ignore[valid-type]
|
||||
name='Set shading type to "Material"',
|
||||
update=import_vrm_update_addon_preferences,
|
||||
default=True,
|
||||
)
|
||||
set_view_transform_to_standard_on_import: BoolProperty( # type: ignore[valid-type]
|
||||
name='Set view transform to "Standard"',
|
||||
update=import_vrm_update_addon_preferences,
|
||||
default=True,
|
||||
)
|
||||
set_armature_display_to_wire: BoolProperty( # type: ignore[valid-type]
|
||||
name='Set an imported armature display to "Wire"',
|
||||
update=import_vrm_update_addon_preferences,
|
||||
default=True,
|
||||
)
|
||||
set_armature_display_to_show_in_front: BoolProperty( # type: ignore[valid-type]
|
||||
name='Set an imported armature display to show "In-Front"',
|
||||
update=import_vrm_update_addon_preferences,
|
||||
default=True,
|
||||
)
|
||||
set_armature_bone_shape_to_default: BoolProperty( # type: ignore[valid-type]
|
||||
name="Set an imported bone shape to default",
|
||||
update=import_vrm_update_addon_preferences,
|
||||
default=True,
|
||||
)
|
||||
enable_mtoon_outline_preview: BoolProperty( # type: ignore[valid-type]
|
||||
name="Enable MToon Outline Preview",
|
||||
default=True,
|
||||
)
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
try:
|
||||
filepath = Path(self.filepath)
|
||||
if not filepath.is_file():
|
||||
return {"CANCELLED"}
|
||||
|
||||
if self.use_addon_preferences:
|
||||
copy_import_preferences(
|
||||
source=get_preferences(context), destination=self
|
||||
)
|
||||
|
||||
license_error = None
|
||||
try:
|
||||
return import_vrm(
|
||||
filepath,
|
||||
self,
|
||||
context,
|
||||
license_validation=True,
|
||||
)
|
||||
except LicenseConfirmationRequiredError as e:
|
||||
license_error = e # Prevent traceback dump on another exception
|
||||
except Exception:
|
||||
show_error_dialog(
|
||||
pgettext("Failed to import VRM."),
|
||||
traceback.format_exc(),
|
||||
)
|
||||
raise
|
||||
|
||||
logger.warning(license_error.description())
|
||||
|
||||
execution_context = "INVOKE_DEFAULT"
|
||||
import_anyway = False
|
||||
if environ.get("BLENDER_VRM_AUTOMATIC_LICENSE_CONFIRMATION") == "true":
|
||||
execution_context = "EXEC_DEFAULT"
|
||||
import_anyway = True
|
||||
|
||||
return ops.wm.vrm_license_warning(
|
||||
execution_context,
|
||||
import_anyway=import_anyway,
|
||||
license_confirmations=license_error.license_confirmations(),
|
||||
filepath=str(filepath),
|
||||
**create_import_preferences_dict(self),
|
||||
)
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||
self.use_addon_preferences = True
|
||||
copy_import_preferences(source=get_preferences(context), destination=self)
|
||||
|
||||
if "gltf" not in dir(bpy.ops.import_scene):
|
||||
return ops.wm.vrm_gltf2_addon_disabled_warning("INVOKE_DEFAULT")
|
||||
return ImportHelper.invoke(self, context, event)
|
||||
|
||||
def draw(self, _context: Context) -> None:
|
||||
pass # Is needed to get panels available
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This code is auto generated.
|
||||
# To regenerate, run the `uv run tools/property_typing.py` command.
|
||||
filter_glob: str # type: ignore[no-redef]
|
||||
use_addon_preferences: bool # type: ignore[no-redef]
|
||||
extract_textures_into_folder: bool # type: ignore[no-redef]
|
||||
make_new_texture_folder: bool # type: ignore[no-redef]
|
||||
set_shading_type_to_material_on_import: bool # type: ignore[no-redef]
|
||||
set_view_transform_to_standard_on_import: bool # type: ignore[no-redef]
|
||||
set_armature_display_to_wire: bool # type: ignore[no-redef]
|
||||
set_armature_display_to_show_in_front: bool # type: ignore[no-redef]
|
||||
set_armature_bone_shape_to_default: bool # type: ignore[no-redef]
|
||||
enable_mtoon_outline_preview: bool # type: ignore[no-redef]
|
||||
|
||||
|
||||
class VRM_PT_import_file_browser_tool_props(Panel):
|
||||
bl_idname = "VRM_PT_import_file_browser_tool_props"
|
||||
bl_space_type = "FILE_BROWSER"
|
||||
bl_region_type = "TOOL_PROPS"
|
||||
bl_parent_id = "FILE_PT_operator"
|
||||
bl_label = ""
|
||||
bl_options: AbstractSet[str] = {"HIDE_HEADER"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
space_data = context.space_data
|
||||
if not isinstance(space_data, SpaceFileBrowser):
|
||||
return False
|
||||
return space_data.active_operator.bl_idname == "IMPORT_SCENE_OT_vrm"
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
space_data = context.space_data
|
||||
if not isinstance(space_data, SpaceFileBrowser):
|
||||
return
|
||||
|
||||
operator = space_data.active_operator
|
||||
if not isinstance(operator, IMPORT_SCENE_OT_vrm):
|
||||
return
|
||||
|
||||
layout = self.layout
|
||||
draw_import_preferences_layout(operator, layout)
|
||||
|
||||
|
||||
class VRM_PT_import_unsupported_blender_version_warning(Panel):
|
||||
bl_idname = "VRM_PT_import_unsupported_blender_version_warning"
|
||||
bl_space_type = "FILE_BROWSER"
|
||||
bl_region_type = "TOOL_PROPS"
|
||||
bl_parent_id = "FILE_PT_operator"
|
||||
bl_label = ""
|
||||
bl_options: AbstractSet[str] = {"HIDE_HEADER"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
space_data = context.space_data
|
||||
if not isinstance(space_data, SpaceFileBrowser):
|
||||
return False
|
||||
if space_data.active_operator.bl_idname != "IMPORT_SCENE_OT_vrm":
|
||||
return False
|
||||
return bool(version.panel_warning_message())
|
||||
|
||||
def draw(self, _context: Context) -> None:
|
||||
warning_message = version.panel_warning_message()
|
||||
if warning_message is None:
|
||||
return
|
||||
|
||||
box = self.layout.box()
|
||||
warning_column = box.column(align=True)
|
||||
for index, warning_line in enumerate(warning_message.splitlines()):
|
||||
warning_column.label(
|
||||
text=warning_line,
|
||||
translate=False,
|
||||
icon="NONE" if index else "ERROR",
|
||||
)
|
||||
|
||||
|
||||
class WM_OT_vrm_license_confirmation(Operator):
|
||||
bl_label = "VRM License Confirmation"
|
||||
bl_idname = "wm.vrm_license_warning"
|
||||
bl_options: AbstractSet[str] = {"REGISTER"}
|
||||
|
||||
filepath: StringProperty() # type: ignore[valid-type]
|
||||
|
||||
license_confirmations: CollectionProperty(type=LicenseConfirmation) # type: ignore[valid-type]
|
||||
import_anyway: BoolProperty( # type: ignore[valid-type]
|
||||
name="Import Anyway",
|
||||
)
|
||||
|
||||
extract_textures_into_folder: BoolProperty() # type: ignore[valid-type]
|
||||
make_new_texture_folder: BoolProperty() # type: ignore[valid-type]
|
||||
set_shading_type_to_material_on_import: BoolProperty() # type: ignore[valid-type]
|
||||
set_view_transform_to_standard_on_import: BoolProperty() # type: ignore[valid-type]
|
||||
set_armature_display_to_wire: BoolProperty() # type: ignore[valid-type]
|
||||
set_armature_display_to_show_in_front: BoolProperty() # type: ignore[valid-type]
|
||||
set_armature_bone_shape_to_default: BoolProperty() # type: ignore[valid-type]
|
||||
enable_mtoon_outline_preview: BoolProperty() # type: ignore[valid-type]
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
try:
|
||||
filepath = Path(self.filepath)
|
||||
if not filepath.is_file():
|
||||
return {"CANCELLED"}
|
||||
if not self.import_anyway:
|
||||
return {"CANCELLED"}
|
||||
return import_vrm(
|
||||
filepath,
|
||||
self,
|
||||
context,
|
||||
license_validation=False,
|
||||
)
|
||||
except Exception:
|
||||
show_error_dialog(
|
||||
pgettext("Failed to import VRM."),
|
||||
traceback.format_exc(),
|
||||
)
|
||||
raise
|
||||
|
||||
def invoke(self, context: Context, _event: Event) -> set[str]:
|
||||
return context.window_manager.invoke_props_dialog(self, width=600)
|
||||
|
||||
def draw(self, _context: Context) -> None:
|
||||
layout = self.layout
|
||||
layout.label(text=self.filepath, translate=False)
|
||||
for license_confirmation in self.license_confirmations:
|
||||
box = layout.box()
|
||||
for line in license_confirmation.message.split("\n"):
|
||||
box.label(text=line, translate=False, icon="INFO")
|
||||
if license_confirmation.json_key:
|
||||
box.label(
|
||||
text=pgettext("For more information please check following URL.")
|
||||
)
|
||||
if VRM_OT_open_url_in_web_browser.supported(license_confirmation.url):
|
||||
split = box.split(factor=0.85, align=True)
|
||||
split.prop(
|
||||
license_confirmation,
|
||||
"url",
|
||||
text=license_confirmation.json_key,
|
||||
translate=False,
|
||||
)
|
||||
op = layout_operator(split, VRM_OT_open_url_in_web_browser)
|
||||
op.url = license_confirmation.url
|
||||
else:
|
||||
box.prop(
|
||||
license_confirmation,
|
||||
"url",
|
||||
text=license_confirmation.json_key,
|
||||
translate=False,
|
||||
)
|
||||
|
||||
layout.prop(self, "import_anyway")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This code is auto generated.
|
||||
# To regenerate, run the `uv run tools/property_typing.py` command.
|
||||
filepath: str # type: ignore[no-redef]
|
||||
license_confirmations: CollectionPropertyProtocol[ # type: ignore[no-redef]
|
||||
LicenseConfirmation
|
||||
]
|
||||
import_anyway: bool # type: ignore[no-redef]
|
||||
extract_textures_into_folder: bool # type: ignore[no-redef]
|
||||
make_new_texture_folder: bool # type: ignore[no-redef]
|
||||
set_shading_type_to_material_on_import: bool # type: ignore[no-redef]
|
||||
set_view_transform_to_standard_on_import: bool # type: ignore[no-redef]
|
||||
set_armature_display_to_wire: bool # type: ignore[no-redef]
|
||||
set_armature_display_to_show_in_front: bool # type: ignore[no-redef]
|
||||
set_armature_bone_shape_to_default: bool # type: ignore[no-redef]
|
||||
enable_mtoon_outline_preview: bool # type: ignore[no-redef]
|
||||
|
||||
|
||||
def import_vrm(
|
||||
filepath: Path,
|
||||
preferences: ImportPreferencesProtocol,
|
||||
context: Context,
|
||||
*,
|
||||
license_validation: bool,
|
||||
) -> set[str]:
|
||||
parse_result = parse_vrm_json(filepath, license_validation=license_validation)
|
||||
if parse_result.spec_version_number >= (1,):
|
||||
vrm_importer: AbstractBaseVrmImporter = Vrm1Importer(
|
||||
context,
|
||||
parse_result,
|
||||
preferences,
|
||||
)
|
||||
else:
|
||||
vrm_importer = Vrm0Importer(
|
||||
context,
|
||||
parse_result,
|
||||
preferences,
|
||||
)
|
||||
|
||||
vrm_importer.import_vrm()
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def menu_import(
|
||||
menu_op: Operator, _context: Context
|
||||
) -> None: # Same as test/blender_io.py for now
|
||||
vrm_import_op = layout_operator(
|
||||
menu_op.layout, IMPORT_SCENE_OT_vrm, text="VRM (.vrm)"
|
||||
)
|
||||
vrm_import_op.use_addon_preferences = True
|
||||
|
||||
vrma_import_op = layout_operator(
|
||||
menu_op.layout, IMPORT_SCENE_OT_vrma, text="VRM Animation (.vrma)"
|
||||
)
|
||||
vrma_import_op.armature_object_name = ""
|
||||
|
||||
|
||||
class IMPORT_SCENE_OT_vrma(Operator, ImportHelper):
|
||||
bl_idname = "import_scene.vrma"
|
||||
bl_label = "Open"
|
||||
bl_description = "Import VRM Animation"
|
||||
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
|
||||
|
||||
filename_ext = ".vrma"
|
||||
filter_glob: StringProperty( # type: ignore[valid-type]
|
||||
default="*.vrma",
|
||||
options={"HIDDEN"},
|
||||
)
|
||||
|
||||
armature_object_name: StringProperty( # type: ignore[valid-type]
|
||||
options={"HIDDEN"},
|
||||
)
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
try:
|
||||
errors = WM_OT_vrma_import_prerequisite.detect_errors(
|
||||
context, self.armature_object_name
|
||||
)
|
||||
if errors:
|
||||
for error in errors:
|
||||
logger.error(error)
|
||||
return {"CANCELLED"}
|
||||
|
||||
filepath = Path(self.filepath)
|
||||
if not filepath.is_file():
|
||||
return {"CANCELLED"}
|
||||
|
||||
armature = None
|
||||
if self.armature_object_name:
|
||||
armature = context.blend_data.objects.get(self.armature_object_name)
|
||||
if not armature:
|
||||
return {"CANCELLED"}
|
||||
|
||||
if not armature:
|
||||
armature = search.current_armature(context)
|
||||
|
||||
if not armature:
|
||||
added = ops.icyp.make_basic_armature()
|
||||
if added != {"FINISHED"}:
|
||||
return added
|
||||
armature = search.current_armature(context)
|
||||
if not armature:
|
||||
return {"CANCELLED"}
|
||||
armature_data = armature.data
|
||||
if not isinstance(armature_data, Armature):
|
||||
return {"CANCELLED"}
|
||||
ext = get_armature_extension(armature_data)
|
||||
ext.spec_version = ext.SPEC_VERSION_VRM1
|
||||
|
||||
return UniVrmVrmAnimationImporter.execute(context, filepath, armature)
|
||||
except Exception:
|
||||
show_error_dialog(
|
||||
pgettext("Failed to import VRM Animation."),
|
||||
traceback.format_exc(),
|
||||
)
|
||||
raise
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||
if WM_OT_vrma_import_prerequisite.detect_errors(
|
||||
context, self.armature_object_name
|
||||
):
|
||||
return ops.wm.vrma_import_prerequisite(
|
||||
"INVOKE_DEFAULT",
|
||||
armature_object_name=self.armature_object_name,
|
||||
)
|
||||
return ImportHelper.invoke(self, context, event)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This code is auto generated.
|
||||
# To regenerate, run the `uv run tools/property_typing.py` command.
|
||||
filter_glob: str # type: ignore[no-redef]
|
||||
armature_object_name: str # type: ignore[no-redef]
|
||||
|
||||
|
||||
class WM_OT_vrma_import_prerequisite(Operator):
|
||||
bl_label = "VRM Animation Import Prerequisite"
|
||||
bl_idname = "wm.vrma_import_prerequisite"
|
||||
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
|
||||
|
||||
armature_object_name: StringProperty( # type: ignore[valid-type]
|
||||
options={"HIDDEN"},
|
||||
)
|
||||
armature_object_name_candidates: CollectionProperty( # type: ignore[valid-type]
|
||||
type=StringPropertyGroup,
|
||||
options={"HIDDEN"},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def detect_errors(context: Context, armature_object_name: str) -> list[str]:
|
||||
error_messages: list[str] = []
|
||||
|
||||
if not armature_object_name:
|
||||
armature = search.current_armature(context)
|
||||
else:
|
||||
armature = context.blend_data.objects.get(armature_object_name)
|
||||
|
||||
if not armature:
|
||||
return error_messages
|
||||
|
||||
armature_data = armature.data
|
||||
if not isinstance(armature_data, Armature):
|
||||
return error_messages
|
||||
|
||||
ext = get_armature_extension(armature_data)
|
||||
if get_armature_extension(armature_data).is_vrm1():
|
||||
humanoid = ext.vrm1.humanoid
|
||||
if not humanoid.human_bones.all_required_bones_are_assigned():
|
||||
error_messages.append(pgettext("Please assign required human bones"))
|
||||
else:
|
||||
error_messages.append(pgettext("Please set the version of VRM to 1.0"))
|
||||
|
||||
return error_messages
|
||||
|
||||
def execute(self, _context: Context) -> set[str]:
|
||||
return ops.import_scene.vrma(
|
||||
"INVOKE_DEFAULT", armature_object_name=self.armature_object_name
|
||||
)
|
||||
|
||||
def invoke(self, context: Context, _event: Event) -> set[str]:
|
||||
if not self.armature_object_name:
|
||||
armature_object = search.current_armature(context)
|
||||
if armature_object:
|
||||
self.armature_object_name = armature_object.name
|
||||
self.armature_object_name_candidates.clear()
|
||||
for obj in context.blend_data.objects:
|
||||
if obj.type != "ARMATURE":
|
||||
continue
|
||||
candidate = self.armature_object_name_candidates.add()
|
||||
candidate.value = obj.name
|
||||
return context.window_manager.invoke_props_dialog(self, width=800)
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
layout = self.layout
|
||||
|
||||
layout.label(
|
||||
text="VRM Animation import requires a VRM 1.0 armature",
|
||||
icon="INFO",
|
||||
)
|
||||
|
||||
error_messages = WM_OT_vrma_import_prerequisite.detect_errors(
|
||||
context, self.armature_object_name
|
||||
)
|
||||
|
||||
layout.prop_search(
|
||||
self,
|
||||
"armature_object_name",
|
||||
self,
|
||||
"armature_object_name_candidates",
|
||||
icon="OUTLINER_OB_ARMATURE",
|
||||
text="Armature to be animated",
|
||||
)
|
||||
|
||||
if error_messages:
|
||||
error_column = layout.box().column(align=True)
|
||||
for error_message in error_messages:
|
||||
error_column.label(text=error_message, icon="ERROR", translate=False)
|
||||
|
||||
open_op = layout_operator(
|
||||
layout,
|
||||
VRM_OT_open_url_in_web_browser,
|
||||
icon="URL",
|
||||
text="Open help in a Web Browser",
|
||||
)
|
||||
open_op.url = pgettext("https://vrm-addon-for-blender.info/en-us/animation/")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This code is auto generated.
|
||||
# To regenerate, run the `uv run tools/property_typing.py` command.
|
||||
armature_object_name: str # type: ignore[no-redef]
|
||||
armature_object_name_candidates: CollectionPropertyProtocol[ # type: ignore[no-redef]
|
||||
StringPropertyGroup
|
||||
]
|
||||
198
importer/license_validation.py
Normal file
198
importer/license_validation.py
Normal file
@@ -0,0 +1,198 @@
|
||||
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
|
||||
import contextlib
|
||||
from typing import Optional
|
||||
from urllib.parse import ParseResult, parse_qsl, urlparse
|
||||
|
||||
from bpy.app.translations import pgettext
|
||||
|
||||
from ..common.convert import Json
|
||||
|
||||
|
||||
class LicenseConfirmationRequiredProp:
|
||||
def __init__(
|
||||
self,
|
||||
url: Optional[str],
|
||||
json_key: Optional[str],
|
||||
message: str,
|
||||
) -> None:
|
||||
self.url = url
|
||||
self.json_key = json_key
|
||||
self.message = message
|
||||
|
||||
def description(self) -> str:
|
||||
return f"""class=LicenseConfirmationRequired
|
||||
url={self.url}
|
||||
json_key={self.json_key}
|
||||
message={self.message}
|
||||
"""
|
||||
|
||||
|
||||
class LicenseConfirmationRequiredError(Exception):
|
||||
def __init__(self, props: list[LicenseConfirmationRequiredProp]) -> None:
|
||||
self.props = props
|
||||
super().__init__(self.description())
|
||||
|
||||
def description(self) -> str:
|
||||
return "\n".join(prop.description() for prop in self.props)
|
||||
|
||||
def license_confirmations(self) -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
"name": "LicenseConfirmation" + str(index),
|
||||
"url": prop.url or "",
|
||||
"json_key": prop.json_key or "",
|
||||
"message": prop.message or "",
|
||||
}
|
||||
for index, prop in enumerate(self.props)
|
||||
]
|
||||
|
||||
|
||||
def validate_license_url(
|
||||
url_str: str, json_key: str, props: list[LicenseConfirmationRequiredProp]
|
||||
) -> None:
|
||||
if not url_str:
|
||||
return
|
||||
url = None
|
||||
with contextlib.suppress(ValueError):
|
||||
url = urlparse(url_str)
|
||||
if url:
|
||||
query_dict = dict(parse_qsl(url.query))
|
||||
if validate_vroid_hub_license_url(
|
||||
url, query_dict, json_key, props
|
||||
) or validate_uni_virtual_license_url(url, query_dict, json_key, props):
|
||||
return
|
||||
props.append(
|
||||
LicenseConfirmationRequiredProp(
|
||||
url_str,
|
||||
json_key,
|
||||
pgettext("This VRM is not allowed to be edited. Please check its license"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def validate_vroid_hub_license_url(
|
||||
url: ParseResult,
|
||||
query_dict: dict[str, str],
|
||||
json_key: str,
|
||||
props: list[LicenseConfirmationRequiredProp],
|
||||
) -> bool:
|
||||
# https://hub.vroid.com/en/license?allowed_to_use_user=everyone&characterization_allowed_user=everyone&corporate_commercial_use=allow&credit=unnecessary&modification=allow&personal_commercial_use=profit&redistribution=allow&sexual_expression=allow&version=1&violent_expression=allow
|
||||
if url.hostname != "hub.vroid.com" or not url.path.endswith("/license"):
|
||||
return False
|
||||
if query_dict.get("modification") == "disallow":
|
||||
props.append(
|
||||
LicenseConfirmationRequiredProp(
|
||||
url.geturl(),
|
||||
json_key,
|
||||
pgettext(
|
||||
'This VRM is licensed by VRoid Hub License "Alterations: No".'
|
||||
),
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def validate_uni_virtual_license_url(
|
||||
url: ParseResult,
|
||||
query_dict: dict[str, str],
|
||||
json_key: str,
|
||||
props: list[LicenseConfirmationRequiredProp],
|
||||
) -> bool:
|
||||
# https://uv-license.com/en/license?utf8=%E2%9C%93&pcu=true
|
||||
if url.hostname != "uv-license.com" or not url.path.endswith("/license"):
|
||||
return False
|
||||
if query_dict.get("remarks") == "true":
|
||||
props.append(
|
||||
LicenseConfirmationRequiredProp(
|
||||
url.geturl(),
|
||||
json_key,
|
||||
pgettext('This VRM is licensed by UV License with "Remarks".'),
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def validate_vrm1_license(
|
||||
json_dict: dict[str, Json], _confirmations: list[LicenseConfirmationRequiredProp]
|
||||
) -> None:
|
||||
extensions_dict = json_dict.get("extensions")
|
||||
if not isinstance(extensions_dict, dict):
|
||||
return
|
||||
|
||||
vrmc_vrm_dict = extensions_dict.get("VRMC_vrm")
|
||||
if not isinstance(vrmc_vrm_dict, dict):
|
||||
return
|
||||
|
||||
return
|
||||
|
||||
|
||||
def validate_vrm0_license(
|
||||
json_dict: dict[str, Json], confirmations: list[LicenseConfirmationRequiredProp]
|
||||
) -> None:
|
||||
extensions_dict = json_dict.get("extensions")
|
||||
if not isinstance(extensions_dict, dict):
|
||||
return
|
||||
|
||||
vrm_dict = extensions_dict.get("VRM")
|
||||
if not isinstance(vrm_dict, dict):
|
||||
return
|
||||
|
||||
meta_dict = vrm_dict.get("meta")
|
||||
if not isinstance(meta_dict, dict):
|
||||
return
|
||||
|
||||
license_name = meta_dict.get("licenseName")
|
||||
if license_name in [
|
||||
# https://github.com/vrm-c/vrm-specification/blob/master/specification/0.0/schema/vrm.meta.schema.json#L56
|
||||
"CC_BY_ND",
|
||||
"CC_BY_NC_ND",
|
||||
]:
|
||||
confirmations.append(
|
||||
LicenseConfirmationRequiredProp(
|
||||
None,
|
||||
None,
|
||||
pgettext(
|
||||
"This VRM is not allowed to be edited. Please check its license"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if license_name == "Other":
|
||||
other_license_url = meta_dict.get("otherLicenseUrl")
|
||||
if other_license_url is None:
|
||||
confirmations.append(
|
||||
LicenseConfirmationRequiredProp(
|
||||
None,
|
||||
None,
|
||||
pgettext(
|
||||
'The VRM selects "Other" license but no license url is found.'
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
validate_license_url(
|
||||
str(other_license_url), "otherLicenseUrl", confirmations
|
||||
)
|
||||
|
||||
other_permission_url = meta_dict.get("otherPermissionUrl")
|
||||
if other_permission_url is not None:
|
||||
validate_license_url(
|
||||
str(other_permission_url),
|
||||
"otherPermissionUrl",
|
||||
confirmations,
|
||||
)
|
||||
|
||||
|
||||
def validate_license(
|
||||
json_dict: dict[str, Json], spec_version_number: tuple[int, int]
|
||||
) -> None:
|
||||
"""Validate that the license is not a non-modifiable license, such as CC_ND."""
|
||||
confirmations: list[LicenseConfirmationRequiredProp] = []
|
||||
|
||||
if tuple(spec_version_number) >= (1, 0):
|
||||
validate_vrm1_license(json_dict, confirmations)
|
||||
else:
|
||||
validate_vrm0_license(json_dict, confirmations)
|
||||
|
||||
if confirmations:
|
||||
raise LicenseConfirmationRequiredError(confirmations)
|
||||
781
importer/uni_vrm_vrm_animation_importer.py
Normal file
781
importer/uni_vrm_vrm_animation_importer.py
Normal file
@@ -0,0 +1,781 @@
|
||||
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
|
||||
import itertools
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from bpy.types import Armature, Context, Object
|
||||
from mathutils import Matrix, Quaternion, Vector
|
||||
|
||||
from ..common import convert
|
||||
from ..common.convert import Json
|
||||
from ..common.debug import dump
|
||||
from ..common.gltf import (
|
||||
parse_glb,
|
||||
read_accessor_as_animation_sampler_input,
|
||||
read_accessor_as_animation_sampler_rotation_output,
|
||||
read_accessor_as_animation_sampler_translation_output,
|
||||
)
|
||||
from ..common.logger import get_logger
|
||||
from ..common.rotation import (
|
||||
get_rotation_as_quaternion,
|
||||
insert_rotation_keyframe,
|
||||
set_rotation_without_mode_change,
|
||||
)
|
||||
from ..common.vrm1.human_bone import HumanBoneName
|
||||
from ..common.workspace import save_workspace
|
||||
from ..editor.extension import get_armature_extension
|
||||
from ..editor.t_pose import setup_humanoid_t_pose
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class UniVrmVrmAnimationImporter:
|
||||
"""Import VRM animation. The import result is the same as UniVRM.
|
||||
|
||||
https://github.com/vrm-c/UniVRM
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def execute(context: Context, path: Path, armature: Object) -> set[str]:
|
||||
armature_data = armature.data
|
||||
if not isinstance(armature_data, Armature):
|
||||
return {"CANCELLED"}
|
||||
|
||||
ext = get_armature_extension(armature_data)
|
||||
humanoid = ext.vrm1.humanoid
|
||||
if not humanoid.human_bones.all_required_bones_are_assigned():
|
||||
return {"CANCELLED"}
|
||||
|
||||
with (
|
||||
setup_humanoid_t_pose(context, armature),
|
||||
save_workspace(context, armature, mode="POSE"),
|
||||
):
|
||||
result = import_vrm_animation(context, path, armature)
|
||||
look_at_preview_enabled = ext.vrm1.look_at.enable_preview
|
||||
|
||||
ext.vrm1.look_at.enable_preview = look_at_preview_enabled
|
||||
return result
|
||||
|
||||
|
||||
def find_root_node_index(
|
||||
node_dicts: list[Json], node_index: int, skip_node_indices: set[int]
|
||||
) -> int:
|
||||
for parent_node_index, node_dict in enumerate(node_dicts):
|
||||
if parent_node_index in skip_node_indices:
|
||||
continue
|
||||
if not isinstance(node_dict, dict):
|
||||
continue
|
||||
child_node_indices = node_dict.get("children")
|
||||
if not isinstance(child_node_indices, list):
|
||||
continue
|
||||
for child_node_index in child_node_indices:
|
||||
if child_node_index in skip_node_indices:
|
||||
continue
|
||||
if child_node_index != node_index:
|
||||
continue
|
||||
skip_node_indices.add(node_index)
|
||||
return find_root_node_index(
|
||||
node_dicts, parent_node_index, skip_node_indices
|
||||
)
|
||||
return node_index
|
||||
|
||||
|
||||
def import_vrm_animation(context: Context, path: Path, armature: Object) -> set[str]:
|
||||
if not path.exists():
|
||||
return {"CANCELLED"}
|
||||
armature_data = armature.data
|
||||
if not isinstance(armature_data, Armature):
|
||||
return {"CANCELLED"}
|
||||
humanoid = get_armature_extension(armature_data).vrm1.humanoid
|
||||
if not humanoid.human_bones.all_required_bones_are_assigned():
|
||||
return {"CANCELLED"}
|
||||
look_at = get_armature_extension(armature_data).vrm1.look_at
|
||||
|
||||
vrma_dict, buffer0_bytes = parse_glb(path.read_bytes())
|
||||
|
||||
node_dicts = vrma_dict.get("nodes")
|
||||
if not isinstance(node_dicts, list) or not node_dicts:
|
||||
return {"CANCELLED"}
|
||||
|
||||
animation_dicts = vrma_dict.get("animations")
|
||||
if not isinstance(animation_dicts, list) or not animation_dicts:
|
||||
return {"CANCELLED"}
|
||||
animation_dict = animation_dicts[0]
|
||||
if not isinstance(animation_dict, dict):
|
||||
return {"CANCELLED"}
|
||||
animation_channel_dicts = animation_dict.get("channels")
|
||||
if not isinstance(animation_channel_dicts, list) or not animation_channel_dicts:
|
||||
return {"CANCELLED"}
|
||||
animation_sampler_dicts = animation_dict.get("samplers")
|
||||
if not isinstance(animation_sampler_dicts, list) or not animation_sampler_dicts:
|
||||
return {"CANCELLED"}
|
||||
|
||||
extensions_dict = vrma_dict.get("extensions")
|
||||
if not isinstance(extensions_dict, dict):
|
||||
return {"CANCELLED"}
|
||||
vrmc_vrm_animation_dict = extensions_dict.get("VRMC_vrm_animation")
|
||||
if not isinstance(vrmc_vrm_animation_dict, dict):
|
||||
return {"CANCELLED"}
|
||||
humanoid_dict = vrmc_vrm_animation_dict.get("humanoid")
|
||||
if not isinstance(humanoid_dict, dict):
|
||||
return {"CANCELLED"}
|
||||
human_bones_dict = humanoid_dict.get("humanBones")
|
||||
if not isinstance(human_bones_dict, dict):
|
||||
return {"CANCELLED"}
|
||||
hips_dict = human_bones_dict.get("hips")
|
||||
if not isinstance(hips_dict, dict):
|
||||
return {"CANCELLED"}
|
||||
hips_node_index = hips_dict.get("node")
|
||||
if not isinstance(hips_node_index, int):
|
||||
return {"CANCELLED"}
|
||||
if not 0 <= hips_node_index < len(node_dicts):
|
||||
return {"CANCELLED"}
|
||||
hips_node_dict = node_dicts[hips_node_index]
|
||||
if not isinstance(hips_node_dict, dict):
|
||||
return {"CANCELLED"}
|
||||
|
||||
expression_name_to_node_index: dict[str, int] = {}
|
||||
expressions_dict = vrmc_vrm_animation_dict.get("expressions")
|
||||
if isinstance(expressions_dict, dict):
|
||||
preset_dict = expressions_dict.get("preset")
|
||||
if isinstance(preset_dict, dict):
|
||||
for name, node_dict in preset_dict.items():
|
||||
if not isinstance(node_dict, dict):
|
||||
continue
|
||||
node_index = node_dict.get("node")
|
||||
if not isinstance(node_index, int):
|
||||
continue
|
||||
expression_name_to_node_index[name] = node_index
|
||||
|
||||
custom_dict = expressions_dict.get("custom")
|
||||
if isinstance(custom_dict, dict):
|
||||
for name, node_dict in custom_dict.items():
|
||||
if not isinstance(node_dict, dict):
|
||||
continue
|
||||
if name in expression_name_to_node_index:
|
||||
continue
|
||||
node_index = node_dict.get("node")
|
||||
if not isinstance(node_index, int):
|
||||
continue
|
||||
expression_name_to_node_index[name] = node_index
|
||||
|
||||
node_index_to_human_bone_name: dict[int, HumanBoneName] = {}
|
||||
for human_bone_name_str, human_bone_dict in human_bones_dict.items():
|
||||
if not isinstance(human_bone_dict, dict):
|
||||
continue
|
||||
node_index = human_bone_dict.get("node")
|
||||
if not isinstance(node_index, int):
|
||||
continue
|
||||
if not 0 <= node_index < len(node_dicts):
|
||||
continue
|
||||
human_bone_name = HumanBoneName.from_str(human_bone_name_str)
|
||||
if not human_bone_name:
|
||||
continue
|
||||
node_index_to_human_bone_name[node_index] = human_bone_name
|
||||
|
||||
root_node_index = find_root_node_index(node_dicts, hips_node_index, set())
|
||||
node_rest_pose_trees = NodeRestPoseTree.build(
|
||||
node_dicts, root_node_index, is_root=True
|
||||
)
|
||||
if len(node_rest_pose_trees) != 1:
|
||||
return {"CANCELLED"}
|
||||
node_rest_pose_tree = node_rest_pose_trees[0]
|
||||
|
||||
accessor_dicts = vrma_dict.get("accessors")
|
||||
if not isinstance(accessor_dicts, list):
|
||||
return {"CANCELLED"}
|
||||
buffer_view_dicts = vrma_dict.get("bufferViews")
|
||||
if not isinstance(buffer_view_dicts, list):
|
||||
return {"CANCELLED"}
|
||||
buffer_dicts = vrma_dict.get("buffers")
|
||||
if not isinstance(buffer_dicts, list):
|
||||
return {"CANCELLED"}
|
||||
|
||||
humanoid_action = context.blend_data.actions.new(name="Humanoid")
|
||||
if not armature.animation_data:
|
||||
armature.animation_data_create()
|
||||
armature_animation_data = armature.animation_data
|
||||
if not armature_animation_data:
|
||||
message = "armature.animation_data is None"
|
||||
raise ValueError(message)
|
||||
armature_animation_data.action = humanoid_action
|
||||
|
||||
expression_action = context.blend_data.actions.new(name="Expressions")
|
||||
if not armature_data.animation_data:
|
||||
armature_data.animation_data_create()
|
||||
armature_data_animation_data = armature_data.animation_data
|
||||
if not armature_data_animation_data:
|
||||
message = "armature_data.animation_data is None"
|
||||
raise ValueError(message)
|
||||
armature_data_animation_data.action = expression_action
|
||||
|
||||
node_index_to_translation_keyframes: dict[
|
||||
int, tuple[tuple[float, Vector], ...]
|
||||
] = {}
|
||||
node_index_to_rotation_keyframes: dict[
|
||||
int, tuple[tuple[float, Quaternion], ...]
|
||||
] = {}
|
||||
|
||||
for animation_channel_dict in animation_channel_dicts:
|
||||
if not isinstance(animation_channel_dict, dict):
|
||||
continue
|
||||
target_dict = animation_channel_dict.get("target")
|
||||
if not isinstance(target_dict, dict):
|
||||
continue
|
||||
node_index = target_dict.get("node")
|
||||
if not isinstance(node_index, int):
|
||||
continue
|
||||
if not 0 <= node_index < len(node_dicts):
|
||||
continue
|
||||
animation_path = target_dict.get("path")
|
||||
animation_sampler_index = animation_channel_dict.get("sampler")
|
||||
if not isinstance(animation_sampler_index, int):
|
||||
continue
|
||||
if not 0 <= animation_sampler_index < len(animation_sampler_dicts):
|
||||
continue
|
||||
animation_sampler_dict = animation_sampler_dicts[animation_sampler_index]
|
||||
if not isinstance(animation_sampler_dict, dict):
|
||||
continue
|
||||
|
||||
input_index = animation_sampler_dict.get("input")
|
||||
if not isinstance(input_index, int):
|
||||
continue
|
||||
if not 0 <= input_index < len(accessor_dicts):
|
||||
continue
|
||||
input_accessor_dict = accessor_dicts[input_index]
|
||||
if not isinstance(input_accessor_dict, dict):
|
||||
continue
|
||||
|
||||
output_index = animation_sampler_dict.get("output")
|
||||
if not isinstance(output_index, int):
|
||||
continue
|
||||
if not 0 <= output_index < len(accessor_dicts):
|
||||
continue
|
||||
output_accessor_dict = accessor_dicts[output_index]
|
||||
if not isinstance(output_accessor_dict, dict):
|
||||
continue
|
||||
|
||||
if animation_path == "translation":
|
||||
timestamps = read_accessor_as_animation_sampler_input(
|
||||
input_accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
|
||||
)
|
||||
if timestamps is None:
|
||||
continue
|
||||
translations = read_accessor_as_animation_sampler_translation_output(
|
||||
output_accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
|
||||
)
|
||||
if translations is None:
|
||||
continue
|
||||
translation_keyframes = tuple(sorted(zip(timestamps, translations)))
|
||||
node_index_to_translation_keyframes[node_index] = translation_keyframes
|
||||
elif animation_path == "rotation":
|
||||
timestamps = read_accessor_as_animation_sampler_input(
|
||||
input_accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
|
||||
)
|
||||
if timestamps is None:
|
||||
continue
|
||||
rotations = read_accessor_as_animation_sampler_rotation_output(
|
||||
output_accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
|
||||
)
|
||||
if rotations is None:
|
||||
continue
|
||||
rotation_keyframes = tuple(sorted(zip(timestamps, rotations)))
|
||||
node_index_to_rotation_keyframes[node_index] = rotation_keyframes
|
||||
|
||||
expression_name_to_default_preview_value: dict[str, float] = {}
|
||||
expression_name_to_translation_keyframes: dict[
|
||||
str, tuple[tuple[float, Vector], ...]
|
||||
] = {}
|
||||
for expression_name, node_index in expression_name_to_node_index.items():
|
||||
if not 0 <= node_index < len(node_dicts):
|
||||
continue
|
||||
node_dict = node_dicts[node_index]
|
||||
if not isinstance(node_dict, dict):
|
||||
continue
|
||||
|
||||
translation = node_dict.get("translation")
|
||||
if isinstance(translation, list) and translation:
|
||||
default_preview_value = translation[0] # TODO: In case of Matrix
|
||||
if not isinstance(default_preview_value, (float, int)):
|
||||
default_preview_value = 0.0
|
||||
else:
|
||||
default_preview_value = 0.0
|
||||
expression_name_to_default_preview_value[expression_name] = (
|
||||
default_preview_value
|
||||
)
|
||||
|
||||
expression_translation_keyframes = node_index_to_translation_keyframes.get(
|
||||
node_index
|
||||
)
|
||||
if expression_translation_keyframes is None:
|
||||
continue
|
||||
expression_name_to_translation_keyframes[expression_name] = (
|
||||
expression_translation_keyframes
|
||||
)
|
||||
|
||||
timestamps = [
|
||||
timestamp
|
||||
for keyframes in itertools.chain(
|
||||
node_index_to_translation_keyframes.values(),
|
||||
node_index_to_rotation_keyframes.values(),
|
||||
)
|
||||
for (timestamp, _) in keyframes
|
||||
]
|
||||
timestamps.sort()
|
||||
if not timestamps:
|
||||
return {"CANCELLED"}
|
||||
|
||||
first_timestamp = timestamps[0]
|
||||
last_timestamp = timestamps[-1]
|
||||
|
||||
logger.debug(
|
||||
"first_timestamp=%s ... last_timestamp=%s",
|
||||
first_timestamp,
|
||||
last_timestamp,
|
||||
)
|
||||
|
||||
look_at_target_object = None
|
||||
look_at_translation_keyframes = None
|
||||
look_at_dict = vrmc_vrm_animation_dict.get("lookAt")
|
||||
if isinstance(look_at_dict, dict):
|
||||
look_at_target_node_index = look_at_dict.get("node")
|
||||
if isinstance(
|
||||
look_at_target_node_index, int
|
||||
) and 0 <= look_at_target_node_index < len(node_dicts):
|
||||
look_at_translation_keyframes = node_index_to_translation_keyframes.get(
|
||||
look_at_target_node_index
|
||||
)
|
||||
look_at_target_node_dict = node_dicts[look_at_target_node_index]
|
||||
if look_at_translation_keyframes and isinstance(
|
||||
look_at_target_node_dict, dict
|
||||
):
|
||||
look_at_target_translation = convert.float3_or_none(
|
||||
look_at_target_node_dict.get("translation")
|
||||
)
|
||||
look_at_target_name = look_at_target_node_dict.get("name")
|
||||
if not isinstance(look_at_target_name, str) or not look_at_target_name:
|
||||
look_at_target_name = "LookAtTarget"
|
||||
if look_at_target_translation is not None:
|
||||
look_at_target_object = context.blend_data.objects.new(
|
||||
name=look_at_target_name, object_data=None
|
||||
)
|
||||
look_at_target_object.empty_display_size = 0.125
|
||||
x, y, z = look_at_target_translation
|
||||
look_at_target_object.location = Vector((x, -z, y))
|
||||
context.scene.collection.objects.link(look_at_target_object)
|
||||
look_at.enable_preview = True
|
||||
look_at.preview_target_bpy_object = look_at_target_object
|
||||
|
||||
first_zero_origin_frame_count: int = math.floor(
|
||||
first_timestamp * context.scene.render.fps / context.scene.render.fps_base
|
||||
)
|
||||
if abs(last_timestamp - first_timestamp) > 0:
|
||||
last_zero_origin_frame_count: int = math.ceil(
|
||||
last_timestamp * context.scene.render.fps / context.scene.render.fps_base
|
||||
)
|
||||
else:
|
||||
last_zero_origin_frame_count = first_zero_origin_frame_count
|
||||
for zero_origin_frame_count in range(
|
||||
first_zero_origin_frame_count, last_zero_origin_frame_count + 1
|
||||
):
|
||||
timestamp = (
|
||||
zero_origin_frame_count
|
||||
* context.scene.render.fps_base
|
||||
/ context.scene.render.fps
|
||||
)
|
||||
frame_count = zero_origin_frame_count + 1
|
||||
|
||||
assign_humanoid_keyframe(
|
||||
armature,
|
||||
node_rest_pose_tree,
|
||||
node_index_to_human_bone_name,
|
||||
node_index_to_translation_keyframes,
|
||||
node_index_to_rotation_keyframes,
|
||||
frame_count,
|
||||
timestamp,
|
||||
humanoid_parent_rest_world_matrix=Matrix(),
|
||||
intermediate_rest_local_matrix=Matrix(),
|
||||
intermediate_pose_local_matrix=Matrix(),
|
||||
parent_node_rest_pose_world_matrix=Matrix(),
|
||||
)
|
||||
assign_expression_keyframe(
|
||||
armature_data,
|
||||
expression_name_to_default_preview_value,
|
||||
expression_name_to_translation_keyframes,
|
||||
frame_count,
|
||||
timestamp,
|
||||
)
|
||||
if look_at_target_object and look_at_translation_keyframes:
|
||||
assign_look_at_keyframe(
|
||||
look_at_target_object,
|
||||
look_at_translation_keyframes,
|
||||
frame_count,
|
||||
timestamp,
|
||||
)
|
||||
|
||||
# This process should be done last because bone state changes occur.
|
||||
# If translation is assigned to hips and hips is connected to parent
|
||||
# with "use_connect", the movement animation will not be reflected, so
|
||||
# we need to disconnect it
|
||||
if node_index_to_translation_keyframes.get(hips_node_index):
|
||||
with save_workspace(context, armature, mode="EDIT"):
|
||||
armature_data = armature.data
|
||||
if not isinstance(armature_data, Armature):
|
||||
raise TypeError
|
||||
hips_bone = armature_data.edit_bones.get(
|
||||
humanoid.human_bones.hips.node.bone_name
|
||||
)
|
||||
if hips_bone and hips_bone.use_connect:
|
||||
hips_bone.use_connect = False
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def assign_look_at_keyframe(
|
||||
look_at_target_object: Object,
|
||||
translation_keyframes: tuple[tuple[float, Vector], ...],
|
||||
frame_count: int,
|
||||
timestamp: float,
|
||||
) -> None:
|
||||
if not translation_keyframes:
|
||||
return
|
||||
|
||||
animation_translation = None
|
||||
begin_timestamp, begin_translation = translation_keyframes[0]
|
||||
for end_timestamp, end_translation in translation_keyframes:
|
||||
if end_timestamp >= timestamp:
|
||||
timestamp_duration = end_timestamp - begin_timestamp
|
||||
if timestamp_duration > 0:
|
||||
animation_translation = (
|
||||
begin_translation
|
||||
+ (end_translation - begin_translation)
|
||||
* (timestamp - begin_timestamp)
|
||||
/ timestamp_duration
|
||||
)
|
||||
else:
|
||||
animation_translation = begin_translation
|
||||
break
|
||||
begin_timestamp = end_timestamp
|
||||
begin_translation = end_translation
|
||||
if animation_translation is None:
|
||||
animation_translation = begin_translation
|
||||
|
||||
current_location = look_at_target_object.location.copy()
|
||||
look_at_target_object.location = animation_translation
|
||||
look_at_target_object.keyframe_insert(data_path="location", frame=frame_count)
|
||||
look_at_target_object.location = current_location
|
||||
|
||||
|
||||
def assign_expression_keyframe(
|
||||
armature_data: Armature,
|
||||
expression_name_to_default_preview_value: dict[str, float],
|
||||
expression_name_to_translation_keyframes: dict[
|
||||
str, tuple[tuple[float, Vector], ...]
|
||||
],
|
||||
frame_count: int,
|
||||
timestamp: float,
|
||||
) -> None:
|
||||
expressions = get_armature_extension(armature_data).vrm1.expressions
|
||||
expression_name_to_expression = expressions.all_name_to_expression_dict()
|
||||
for (
|
||||
expression_name,
|
||||
translation_keyframes,
|
||||
) in expression_name_to_translation_keyframes.items():
|
||||
if expression_name in [
|
||||
"lookUp",
|
||||
"lookDown",
|
||||
"lookLeft",
|
||||
"lookRight",
|
||||
]:
|
||||
continue
|
||||
expression = expression_name_to_expression.get(expression_name)
|
||||
if not expression:
|
||||
continue
|
||||
|
||||
if translation_keyframes:
|
||||
preview = None
|
||||
begin_timestamp, begin_translation = translation_keyframes[0]
|
||||
for end_timestamp, end_translation in translation_keyframes:
|
||||
if end_timestamp >= timestamp:
|
||||
timestamp_duration = end_timestamp - begin_timestamp
|
||||
if timestamp_duration > 0:
|
||||
preview = (
|
||||
begin_translation.x
|
||||
+ (end_translation.x - begin_translation.x)
|
||||
* (timestamp - begin_timestamp)
|
||||
/ timestamp_duration
|
||||
)
|
||||
else:
|
||||
preview = begin_translation.x
|
||||
break
|
||||
begin_timestamp = end_timestamp
|
||||
begin_translation = end_translation
|
||||
if preview is None:
|
||||
preview = begin_translation.x
|
||||
else:
|
||||
preview = (
|
||||
expression_name_to_default_preview_value.get(expression_name) or 0.0
|
||||
)
|
||||
|
||||
current_preview = expression.preview
|
||||
expression.preview = preview
|
||||
expression.keyframe_insert(data_path="preview", frame=frame_count)
|
||||
expression.preview = current_preview
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NodeRestPoseTree:
|
||||
node_index: int
|
||||
local_matrix: Matrix
|
||||
children: tuple["NodeRestPoseTree", ...]
|
||||
is_root: bool
|
||||
|
||||
@staticmethod
|
||||
def build(
|
||||
node_dicts: list[Json],
|
||||
node_index: int,
|
||||
*,
|
||||
is_root: bool,
|
||||
) -> list["NodeRestPoseTree"]:
|
||||
if not 0 <= node_index < len(node_dicts):
|
||||
return []
|
||||
node_dict = node_dicts[node_index]
|
||||
if not isinstance(node_dict, dict):
|
||||
return []
|
||||
|
||||
translation_float3 = convert.float3_or_none(node_dict.get("translation"))
|
||||
if translation_float3:
|
||||
x, y, z = translation_float3
|
||||
translation = Vector((x, -z, y))
|
||||
else:
|
||||
translation = Vector((0.0, 0.0, 0.0))
|
||||
|
||||
rotation_float4 = convert.float4_or_none(node_dict.get("rotation"))
|
||||
if rotation_float4:
|
||||
x, y, z, w = rotation_float4
|
||||
rotation = Quaternion((w, x, -z, y)).normalized()
|
||||
else:
|
||||
rotation = Quaternion()
|
||||
|
||||
scale_float3 = convert.float3_or_none(node_dict.get("scale"))
|
||||
if scale_float3:
|
||||
x, y, z = scale_float3
|
||||
scale = Vector((x, z, y))
|
||||
else:
|
||||
scale = Vector((1, 1, 1))
|
||||
|
||||
local_matrix = (
|
||||
Matrix.Translation(translation)
|
||||
@ rotation.to_matrix().to_4x4()
|
||||
@ Matrix.Diagonal(scale).to_4x4()
|
||||
)
|
||||
|
||||
# TODO: Is it an Euler angle in the case of 3 elements?
|
||||
# TODO: Decompose if it's a Matrix
|
||||
|
||||
child_indices = node_dict.get("children")
|
||||
if isinstance(child_indices, list):
|
||||
children = tuple(
|
||||
itertools.chain(
|
||||
*(
|
||||
NodeRestPoseTree.build(node_dicts, child_index, is_root=False)
|
||||
for child_index in child_indices
|
||||
if isinstance(child_index, int)
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
children = ()
|
||||
|
||||
return [
|
||||
NodeRestPoseTree(
|
||||
node_index=node_index,
|
||||
local_matrix=local_matrix,
|
||||
children=children,
|
||||
is_root=is_root,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def assign_humanoid_keyframe(
|
||||
armature: Object,
|
||||
node_rest_pose_tree: NodeRestPoseTree,
|
||||
node_index_to_human_bone_name: dict[int, HumanBoneName],
|
||||
node_index_to_translation_keyframes: dict[int, tuple[tuple[float, Vector], ...]],
|
||||
node_index_to_rotation_keyframes: dict[int, tuple[tuple[float, Quaternion], ...]],
|
||||
frame_count: int,
|
||||
timestamp: float,
|
||||
humanoid_parent_rest_world_matrix: Matrix,
|
||||
intermediate_rest_local_matrix: Matrix,
|
||||
intermediate_pose_local_matrix: Matrix,
|
||||
parent_node_rest_pose_world_matrix: Matrix,
|
||||
) -> None:
|
||||
armature_data = armature.data
|
||||
if not isinstance(armature_data, Armature):
|
||||
return
|
||||
|
||||
translation_keyframes = node_index_to_translation_keyframes.get(
|
||||
node_rest_pose_tree.node_index
|
||||
)
|
||||
|
||||
if translation_keyframes:
|
||||
keyframe_translation = None
|
||||
begin_timestamp, begin_translation = translation_keyframes[0]
|
||||
for end_timestamp, end_translation in translation_keyframes:
|
||||
if end_timestamp >= timestamp:
|
||||
timestamp_duration = end_timestamp - begin_timestamp
|
||||
if timestamp_duration > 0:
|
||||
keyframe_translation = begin_translation.lerp(
|
||||
end_translation,
|
||||
(timestamp - begin_timestamp) / timestamp_duration,
|
||||
)
|
||||
else:
|
||||
keyframe_translation = begin_translation
|
||||
break
|
||||
begin_timestamp = end_timestamp
|
||||
begin_translation = end_translation
|
||||
if keyframe_translation is None:
|
||||
keyframe_translation = begin_translation
|
||||
else:
|
||||
keyframe_translation = node_rest_pose_tree.local_matrix.to_translation()
|
||||
|
||||
rotation_keyframes = node_index_to_rotation_keyframes.get(
|
||||
node_rest_pose_tree.node_index
|
||||
)
|
||||
if rotation_keyframes:
|
||||
keyframe_rotation = None
|
||||
begin_timestamp, begin_rotation = rotation_keyframes[0]
|
||||
for end_timestamp, end_rotation in rotation_keyframes:
|
||||
if end_timestamp >= timestamp:
|
||||
timestamp_duration = end_timestamp - begin_timestamp
|
||||
if timestamp_duration > 0:
|
||||
keyframe_rotation = begin_rotation.slerp(
|
||||
end_rotation, (timestamp - begin_timestamp) / timestamp_duration
|
||||
)
|
||||
else:
|
||||
keyframe_rotation = begin_rotation
|
||||
break
|
||||
begin_timestamp = end_timestamp
|
||||
begin_rotation = end_rotation
|
||||
if keyframe_rotation is None:
|
||||
keyframe_rotation = begin_rotation
|
||||
else:
|
||||
keyframe_rotation = node_rest_pose_tree.local_matrix.to_quaternion()
|
||||
|
||||
humanoid_rest_world_matrix = humanoid_parent_rest_world_matrix
|
||||
rest_local_matrix = (
|
||||
intermediate_rest_local_matrix @ node_rest_pose_tree.local_matrix
|
||||
)
|
||||
pose_local_matrix = (
|
||||
intermediate_pose_local_matrix
|
||||
@ Matrix.Translation(keyframe_translation)
|
||||
@ keyframe_rotation.to_matrix().to_4x4()
|
||||
@ Matrix.Diagonal(node_rest_pose_tree.local_matrix.to_scale()).to_4x4()
|
||||
)
|
||||
|
||||
human_bone_name = node_index_to_human_bone_name.get(node_rest_pose_tree.node_index)
|
||||
human_bones = get_armature_extension(armature_data).vrm1.humanoid.human_bones
|
||||
if (
|
||||
human_bone_name
|
||||
and human_bone_name not in [HumanBoneName.LEFT_EYE, HumanBoneName.RIGHT_EYE]
|
||||
and (
|
||||
human_bone := human_bones.human_bone_name_to_human_bone().get(
|
||||
human_bone_name
|
||||
)
|
||||
)
|
||||
and (bone := armature.pose.bones.get(human_bone.node.bone_name))
|
||||
):
|
||||
humanoid_rest_world_matrix = humanoid_parent_rest_world_matrix
|
||||
rest_world_matrix = humanoid_rest_world_matrix @ rest_local_matrix
|
||||
pose_world_matrix = humanoid_rest_world_matrix @ pose_local_matrix
|
||||
# rest_to_pose_matrix = rest_world_matrix.inverted() @ pose_world_matrix
|
||||
# rest_to_pose_matrix = rest_local_matrix.inverted() @ pose_local_matrix
|
||||
rest_to_pose_matrix = rest_local_matrix.inverted() @ pose_local_matrix
|
||||
axis, angle = rest_to_pose_matrix.to_quaternion().to_axis_angle()
|
||||
axis.rotate(rest_world_matrix.to_quaternion())
|
||||
rest_to_pose_world_rotation = Quaternion(axis, angle).copy()
|
||||
|
||||
target_axis, target_angle = rest_to_pose_world_rotation.to_axis_angle()
|
||||
target_axis.rotate(bone.matrix.to_quaternion().inverted())
|
||||
|
||||
rest_to_pose_target_local_rotation = Quaternion(
|
||||
target_axis, target_angle
|
||||
).copy()
|
||||
|
||||
if rotation_keyframes:
|
||||
logger.debug(
|
||||
"================= %s =================", human_bone_name.value
|
||||
)
|
||||
logger.debug("humanoid world matrix = %s", dump(humanoid_rest_world_matrix))
|
||||
logger.debug("rest_local_matrix = %s", dump(rest_local_matrix))
|
||||
logger.debug("pose_local_matrix = %s", dump(pose_local_matrix))
|
||||
logger.debug("rest_world_matrix = %s", dump(rest_world_matrix))
|
||||
logger.debug("pose_world_matrix = %s", dump(pose_world_matrix))
|
||||
logger.debug("rest_to_pose_matrix = %s", dump(rest_to_pose_matrix))
|
||||
logger.debug(
|
||||
"rest_to_pose_world_rotation = %s",
|
||||
dump(rest_to_pose_world_rotation),
|
||||
)
|
||||
logger.debug(
|
||||
"rest_to_pose_target_local_rotation = %s",
|
||||
dump(rest_to_pose_target_local_rotation),
|
||||
)
|
||||
|
||||
backup_rotation_quaternion = get_rotation_as_quaternion(bone)
|
||||
|
||||
# logger.debug("parent bone matrix = %s", dump(parent_matrix))
|
||||
logger.debug(" bone matrix = %s", dump(bone.matrix))
|
||||
logger.debug("current bone rotation = %s", dump(backup_rotation_quaternion))
|
||||
|
||||
set_rotation_without_mode_change(
|
||||
bone, backup_rotation_quaternion @ rest_to_pose_target_local_rotation
|
||||
)
|
||||
insert_rotation_keyframe(bone, frame=frame_count)
|
||||
set_rotation_without_mode_change(bone, backup_rotation_quaternion)
|
||||
|
||||
if human_bone_name == HumanBoneName.HIPS and translation_keyframes:
|
||||
translation = (
|
||||
bone.matrix.to_quaternion().inverted()
|
||||
@ humanoid_rest_world_matrix.to_quaternion()
|
||||
@ (
|
||||
pose_local_matrix.to_translation()
|
||||
- rest_local_matrix.to_translation()
|
||||
)
|
||||
)
|
||||
|
||||
# TODO: UniVRM seems to adjust the movement amount by the ratio of
|
||||
# hips height. This is not described in the specification.
|
||||
# Investigation of how it works in UniVRM source code is needed.
|
||||
rest_world_translation_z = rest_world_matrix.to_translation().z
|
||||
if abs(rest_world_translation_z) > 0:
|
||||
world_height_ratio = (
|
||||
bone.matrix.to_translation().z / rest_world_translation_z
|
||||
)
|
||||
translation *= world_height_ratio
|
||||
|
||||
# logger.debug(f"translation = {dump(translation)}")
|
||||
backup_translation = bone.location.copy()
|
||||
bone.location = translation
|
||||
bone.keyframe_insert(data_path="location", frame=frame_count)
|
||||
bone.location = backup_translation
|
||||
|
||||
humanoid_rest_world_matrix = (
|
||||
humanoid_parent_rest_world_matrix @ rest_local_matrix
|
||||
)
|
||||
rest_local_matrix = Matrix()
|
||||
pose_local_matrix = Matrix()
|
||||
|
||||
for child in node_rest_pose_tree.children:
|
||||
assign_humanoid_keyframe(
|
||||
armature,
|
||||
child,
|
||||
node_index_to_human_bone_name,
|
||||
node_index_to_translation_keyframes,
|
||||
node_index_to_rotation_keyframes,
|
||||
frame_count,
|
||||
timestamp,
|
||||
humanoid_rest_world_matrix,
|
||||
rest_local_matrix,
|
||||
pose_local_matrix,
|
||||
parent_node_rest_pose_world_matrix @ node_rest_pose_tree.local_matrix,
|
||||
)
|
||||
1562
importer/vrm0_importer.py
Normal file
1562
importer/vrm0_importer.py
Normal file
File diff suppressed because it is too large
Load Diff
1875
importer/vrm1_importer.py
Normal file
1875
importer/vrm1_importer.py
Normal file
File diff suppressed because it is too large
Load Diff
776
importer/vrm_animation_importer.py
Normal file
776
importer/vrm_animation_importer.py
Normal file
@@ -0,0 +1,776 @@
|
||||
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
|
||||
import itertools
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from bpy.types import Armature, Context, Object
|
||||
from mathutils import Matrix, Quaternion, Vector
|
||||
|
||||
from ..common import convert
|
||||
from ..common.convert import Json
|
||||
from ..common.debug import dump
|
||||
from ..common.gltf import (
|
||||
parse_glb,
|
||||
read_accessor_as_animation_sampler_input,
|
||||
read_accessor_as_animation_sampler_rotation_output,
|
||||
read_accessor_as_animation_sampler_translation_output,
|
||||
)
|
||||
from ..common.logger import get_logger
|
||||
from ..common.rotation import (
|
||||
get_rotation_as_quaternion,
|
||||
insert_rotation_keyframe,
|
||||
set_rotation_without_mode_change,
|
||||
)
|
||||
from ..common.vrm1.human_bone import HumanBoneName
|
||||
from ..common.workspace import save_workspace
|
||||
from ..editor.extension import get_armature_extension
|
||||
from ..editor.t_pose import setup_humanoid_t_pose
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NodeRestPoseTree:
|
||||
node_index: int
|
||||
local_matrix: Matrix
|
||||
children: tuple["NodeRestPoseTree", ...]
|
||||
is_root: bool
|
||||
|
||||
@staticmethod
|
||||
def build(
|
||||
node_dicts: list[Json],
|
||||
node_index: int,
|
||||
*,
|
||||
is_root: bool,
|
||||
) -> list["NodeRestPoseTree"]:
|
||||
if not 0 <= node_index < len(node_dicts):
|
||||
return []
|
||||
node_dict = node_dicts[node_index]
|
||||
if not isinstance(node_dict, dict):
|
||||
return []
|
||||
|
||||
translation_float3 = convert.float3_or_none(node_dict.get("translation"))
|
||||
if translation_float3:
|
||||
x, y, z = translation_float3
|
||||
translation = Vector((x, -z, y))
|
||||
else:
|
||||
translation = Vector((0.0, 0.0, 0.0))
|
||||
|
||||
rotation_float4 = convert.float4_or_none(node_dict.get("rotation"))
|
||||
if rotation_float4:
|
||||
x, y, z, w = rotation_float4
|
||||
rotation = Quaternion((w, x, -z, y)).normalized()
|
||||
else:
|
||||
rotation = Quaternion()
|
||||
|
||||
scale_float3 = convert.float3_or_none(node_dict.get("scale"))
|
||||
if scale_float3:
|
||||
x, y, z = scale_float3
|
||||
scale = Vector((x, z, y))
|
||||
else:
|
||||
scale = Vector((1, 1, 1))
|
||||
|
||||
local_matrix = (
|
||||
Matrix.Translation(translation)
|
||||
@ rotation.to_matrix().to_4x4()
|
||||
@ Matrix.Diagonal(scale).to_4x4()
|
||||
)
|
||||
|
||||
# TODO: Is it an Euler angle in the case of 3 elements?
|
||||
# TODO: Decompose if it's a Matrix
|
||||
|
||||
child_indices = node_dict.get("children")
|
||||
if isinstance(child_indices, list):
|
||||
children = tuple(
|
||||
itertools.chain(
|
||||
*(
|
||||
NodeRestPoseTree.build(node_dicts, child_index, is_root=False)
|
||||
for child_index in child_indices
|
||||
if isinstance(child_index, int)
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
children = ()
|
||||
|
||||
return [
|
||||
NodeRestPoseTree(
|
||||
node_index=node_index,
|
||||
local_matrix=local_matrix,
|
||||
children=children,
|
||||
is_root=is_root,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class VrmAnimationImporter:
|
||||
@staticmethod
|
||||
def execute(context: Context, path: Path, armature: Object) -> set[str]:
|
||||
armature_data = armature.data
|
||||
if not isinstance(armature_data, Armature):
|
||||
return {"CANCELLED"}
|
||||
|
||||
ext = get_armature_extension(armature_data)
|
||||
humanoid = ext.vrm1.humanoid
|
||||
if not humanoid.human_bones.all_required_bones_are_assigned():
|
||||
return {"CANCELLED"}
|
||||
|
||||
with (
|
||||
setup_humanoid_t_pose(context, armature),
|
||||
save_workspace(context, armature, mode="POSE"),
|
||||
):
|
||||
result = import_vrm_animation(context, path, armature)
|
||||
look_at_preview_enabled = ext.vrm1.look_at.enable_preview
|
||||
|
||||
ext.vrm1.look_at.enable_preview = look_at_preview_enabled
|
||||
return result
|
||||
|
||||
|
||||
def find_root_node_index(
|
||||
node_dicts: list[Json], node_index: int, skip_node_indices: set[int]
|
||||
) -> int:
|
||||
for parent_node_index, node_dict in enumerate(node_dicts):
|
||||
if parent_node_index in skip_node_indices:
|
||||
continue
|
||||
if not isinstance(node_dict, dict):
|
||||
continue
|
||||
child_node_indices = node_dict.get("children")
|
||||
if not isinstance(child_node_indices, list):
|
||||
continue
|
||||
for child_node_index in child_node_indices:
|
||||
if child_node_index in skip_node_indices:
|
||||
continue
|
||||
if child_node_index != node_index:
|
||||
continue
|
||||
skip_node_indices.add(node_index)
|
||||
return find_root_node_index(
|
||||
node_dicts, parent_node_index, skip_node_indices
|
||||
)
|
||||
return node_index
|
||||
|
||||
|
||||
def import_vrm_animation(context: Context, path: Path, armature: Object) -> set[str]:
|
||||
if not path.exists():
|
||||
return {"CANCELLED"}
|
||||
armature_data = armature.data
|
||||
if not isinstance(armature_data, Armature):
|
||||
return {"CANCELLED"}
|
||||
humanoid = get_armature_extension(armature_data).vrm1.humanoid
|
||||
if not humanoid.human_bones.all_required_bones_are_assigned():
|
||||
return {"CANCELLED"}
|
||||
look_at = get_armature_extension(armature_data).vrm1.look_at
|
||||
|
||||
vrma_dict, buffer0_bytes = parse_glb(path.read_bytes())
|
||||
|
||||
node_dicts = vrma_dict.get("nodes")
|
||||
if not isinstance(node_dicts, list) or not node_dicts:
|
||||
return {"CANCELLED"}
|
||||
|
||||
animation_dicts = vrma_dict.get("animations")
|
||||
if not isinstance(animation_dicts, list) or not animation_dicts:
|
||||
return {"CANCELLED"}
|
||||
animation_dict = animation_dicts[0]
|
||||
if not isinstance(animation_dict, dict):
|
||||
return {"CANCELLED"}
|
||||
animation_channel_dicts = animation_dict.get("channels")
|
||||
if not isinstance(animation_channel_dicts, list) or not animation_channel_dicts:
|
||||
return {"CANCELLED"}
|
||||
animation_sampler_dicts = animation_dict.get("samplers")
|
||||
if not isinstance(animation_sampler_dicts, list) or not animation_sampler_dicts:
|
||||
return {"CANCELLED"}
|
||||
|
||||
extensions_dict = vrma_dict.get("extensions")
|
||||
if not isinstance(extensions_dict, dict):
|
||||
return {"CANCELLED"}
|
||||
vrmc_vrm_animation_dict = extensions_dict.get("VRMC_vrm_animation")
|
||||
if not isinstance(vrmc_vrm_animation_dict, dict):
|
||||
return {"CANCELLED"}
|
||||
humanoid_dict = vrmc_vrm_animation_dict.get("humanoid")
|
||||
if not isinstance(humanoid_dict, dict):
|
||||
return {"CANCELLED"}
|
||||
human_bones_dict = humanoid_dict.get("humanBones")
|
||||
if not isinstance(human_bones_dict, dict):
|
||||
return {"CANCELLED"}
|
||||
hips_dict = human_bones_dict.get("hips")
|
||||
if not isinstance(hips_dict, dict):
|
||||
return {"CANCELLED"}
|
||||
hips_node_index = hips_dict.get("node")
|
||||
if not isinstance(hips_node_index, int):
|
||||
return {"CANCELLED"}
|
||||
if not 0 <= hips_node_index < len(node_dicts):
|
||||
return {"CANCELLED"}
|
||||
hips_node_dict = node_dicts[hips_node_index]
|
||||
if not isinstance(hips_node_dict, dict):
|
||||
return {"CANCELLED"}
|
||||
|
||||
expression_name_to_node_index: dict[str, int] = {}
|
||||
expressions_dict = vrmc_vrm_animation_dict.get("expressions")
|
||||
if isinstance(expressions_dict, dict):
|
||||
preset_dict = expressions_dict.get("preset")
|
||||
if isinstance(preset_dict, dict):
|
||||
for name, node_dict in preset_dict.items():
|
||||
if not isinstance(node_dict, dict):
|
||||
continue
|
||||
node_index = node_dict.get("node")
|
||||
if not isinstance(node_index, int):
|
||||
continue
|
||||
expression_name_to_node_index[name] = node_index
|
||||
|
||||
custom_dict = expressions_dict.get("custom")
|
||||
if isinstance(custom_dict, dict):
|
||||
for name, node_dict in custom_dict.items():
|
||||
if not isinstance(node_dict, dict):
|
||||
continue
|
||||
if name in expression_name_to_node_index:
|
||||
continue
|
||||
node_index = node_dict.get("node")
|
||||
if not isinstance(node_index, int):
|
||||
continue
|
||||
expression_name_to_node_index[name] = node_index
|
||||
|
||||
node_index_to_human_bone_name: dict[int, HumanBoneName] = {}
|
||||
for human_bone_name_str, human_bone_dict in human_bones_dict.items():
|
||||
if not isinstance(human_bone_dict, dict):
|
||||
continue
|
||||
node_index = human_bone_dict.get("node")
|
||||
if not isinstance(node_index, int):
|
||||
continue
|
||||
if not 0 <= node_index < len(node_dicts):
|
||||
continue
|
||||
human_bone_name = HumanBoneName.from_str(human_bone_name_str)
|
||||
if not human_bone_name:
|
||||
continue
|
||||
node_index_to_human_bone_name[node_index] = human_bone_name
|
||||
|
||||
root_node_index = find_root_node_index(node_dicts, hips_node_index, set())
|
||||
node_rest_pose_trees = NodeRestPoseTree.build(
|
||||
node_dicts, root_node_index, is_root=True
|
||||
)
|
||||
if len(node_rest_pose_trees) != 1:
|
||||
return {"CANCELLED"}
|
||||
node_rest_pose_tree = node_rest_pose_trees[0]
|
||||
|
||||
accessor_dicts = vrma_dict.get("accessors")
|
||||
if not isinstance(accessor_dicts, list):
|
||||
return {"CANCELLED"}
|
||||
buffer_view_dicts = vrma_dict.get("bufferViews")
|
||||
if not isinstance(buffer_view_dicts, list):
|
||||
return {"CANCELLED"}
|
||||
buffer_dicts = vrma_dict.get("buffers")
|
||||
if not isinstance(buffer_dicts, list):
|
||||
return {"CANCELLED"}
|
||||
|
||||
humanoid_action = context.blend_data.actions.new(name="Humanoid")
|
||||
if not armature.animation_data:
|
||||
armature.animation_data_create()
|
||||
armature_animation_data = armature.animation_data
|
||||
if not armature_animation_data:
|
||||
message = "armature.animation_data is None"
|
||||
raise ValueError(message)
|
||||
armature_animation_data.action = humanoid_action
|
||||
|
||||
expression_action = context.blend_data.actions.new(name="Expressions")
|
||||
if not armature_data.animation_data:
|
||||
armature_data.animation_data_create()
|
||||
armature_data_animation_data = armature_data.animation_data
|
||||
if not armature_data_animation_data:
|
||||
message = "armature_data.animation_data is None"
|
||||
raise ValueError(message)
|
||||
armature_data_animation_data.action = expression_action
|
||||
|
||||
node_index_to_translation_keyframes: dict[
|
||||
int, tuple[tuple[float, Vector], ...]
|
||||
] = {}
|
||||
node_index_to_rotation_keyframes: dict[
|
||||
int, tuple[tuple[float, Quaternion], ...]
|
||||
] = {}
|
||||
|
||||
for animation_channel_dict in animation_channel_dicts:
|
||||
if not isinstance(animation_channel_dict, dict):
|
||||
continue
|
||||
target_dict = animation_channel_dict.get("target")
|
||||
if not isinstance(target_dict, dict):
|
||||
continue
|
||||
node_index = target_dict.get("node")
|
||||
if not isinstance(node_index, int):
|
||||
continue
|
||||
if not 0 <= node_index < len(node_dicts):
|
||||
continue
|
||||
animation_path = target_dict.get("path")
|
||||
animation_sampler_index = animation_channel_dict.get("sampler")
|
||||
if not isinstance(animation_sampler_index, int):
|
||||
continue
|
||||
if not 0 <= animation_sampler_index < len(animation_sampler_dicts):
|
||||
continue
|
||||
animation_sampler_dict = animation_sampler_dicts[animation_sampler_index]
|
||||
if not isinstance(animation_sampler_dict, dict):
|
||||
continue
|
||||
|
||||
input_index = animation_sampler_dict.get("input")
|
||||
if not isinstance(input_index, int):
|
||||
continue
|
||||
if not 0 <= input_index < len(accessor_dicts):
|
||||
continue
|
||||
input_accessor_dict = accessor_dicts[input_index]
|
||||
if not isinstance(input_accessor_dict, dict):
|
||||
continue
|
||||
|
||||
output_index = animation_sampler_dict.get("output")
|
||||
if not isinstance(output_index, int):
|
||||
continue
|
||||
if not 0 <= output_index < len(accessor_dicts):
|
||||
continue
|
||||
output_accessor_dict = accessor_dicts[output_index]
|
||||
if not isinstance(output_accessor_dict, dict):
|
||||
continue
|
||||
|
||||
if animation_path == "translation":
|
||||
timestamps = read_accessor_as_animation_sampler_input(
|
||||
input_accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
|
||||
)
|
||||
if timestamps is None:
|
||||
continue
|
||||
translations = read_accessor_as_animation_sampler_translation_output(
|
||||
output_accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
|
||||
)
|
||||
if translations is None:
|
||||
continue
|
||||
translation_keyframes = tuple(sorted(zip(timestamps, translations)))
|
||||
node_index_to_translation_keyframes[node_index] = translation_keyframes
|
||||
elif animation_path == "rotation":
|
||||
timestamps = read_accessor_as_animation_sampler_input(
|
||||
input_accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
|
||||
)
|
||||
if timestamps is None:
|
||||
continue
|
||||
rotations = read_accessor_as_animation_sampler_rotation_output(
|
||||
output_accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
|
||||
)
|
||||
if rotations is None:
|
||||
continue
|
||||
rotation_keyframes = tuple(sorted(zip(timestamps, rotations)))
|
||||
node_index_to_rotation_keyframes[node_index] = rotation_keyframes
|
||||
|
||||
expression_name_to_default_preview_value: dict[str, float] = {}
|
||||
expression_name_to_translation_keyframes: dict[
|
||||
str, tuple[tuple[float, Vector], ...]
|
||||
] = {}
|
||||
for expression_name, node_index in expression_name_to_node_index.items():
|
||||
if not 0 <= node_index < len(node_dicts):
|
||||
continue
|
||||
node_dict = node_dicts[node_index]
|
||||
if not isinstance(node_dict, dict):
|
||||
continue
|
||||
|
||||
translation = node_dict.get("translation")
|
||||
if isinstance(translation, list) and translation:
|
||||
default_preview_value = translation[0] # TODO: In case of Matrix
|
||||
if not isinstance(default_preview_value, (float, int)):
|
||||
default_preview_value = 0.0
|
||||
else:
|
||||
default_preview_value = 0.0
|
||||
expression_name_to_default_preview_value[expression_name] = (
|
||||
default_preview_value
|
||||
)
|
||||
|
||||
expression_translation_keyframes = node_index_to_translation_keyframes.get(
|
||||
node_index
|
||||
)
|
||||
if expression_translation_keyframes is None:
|
||||
continue
|
||||
expression_name_to_translation_keyframes[expression_name] = (
|
||||
expression_translation_keyframes
|
||||
)
|
||||
|
||||
timestamps = [
|
||||
timestamp
|
||||
for keyframes in itertools.chain(
|
||||
node_index_to_translation_keyframes.values(),
|
||||
node_index_to_rotation_keyframes.values(),
|
||||
)
|
||||
for (timestamp, _) in keyframes
|
||||
]
|
||||
timestamps.sort()
|
||||
if not timestamps:
|
||||
return {"CANCELLED"}
|
||||
|
||||
first_timestamp = timestamps[0]
|
||||
last_timestamp = timestamps[-1]
|
||||
|
||||
logger.debug(
|
||||
"first_timestamp=%s ... last_timestamp=%s",
|
||||
first_timestamp,
|
||||
last_timestamp,
|
||||
)
|
||||
|
||||
look_at_target_object = None
|
||||
look_at_translation_keyframes = None
|
||||
look_at_dict = vrmc_vrm_animation_dict.get("lookAt")
|
||||
if isinstance(look_at_dict, dict):
|
||||
look_at_target_node_index = look_at_dict.get("node")
|
||||
if isinstance(
|
||||
look_at_target_node_index, int
|
||||
) and 0 <= look_at_target_node_index < len(node_dicts):
|
||||
look_at_translation_keyframes = node_index_to_translation_keyframes.get(
|
||||
look_at_target_node_index
|
||||
)
|
||||
look_at_target_node_dict = node_dicts[look_at_target_node_index]
|
||||
if look_at_translation_keyframes and isinstance(
|
||||
look_at_target_node_dict, dict
|
||||
):
|
||||
look_at_target_translation = convert.float3_or_none(
|
||||
look_at_target_node_dict.get("translation")
|
||||
)
|
||||
look_at_target_name = look_at_target_node_dict.get("name")
|
||||
if not isinstance(look_at_target_name, str) or not look_at_target_name:
|
||||
look_at_target_name = "LookAtTarget"
|
||||
if look_at_target_translation is not None:
|
||||
look_at_target_object = context.blend_data.objects.new(
|
||||
name=look_at_target_name, object_data=None
|
||||
)
|
||||
look_at_target_object.empty_display_size = 0.125
|
||||
x, y, z = look_at_target_translation
|
||||
look_at_target_object.location = Vector((x, -z, y))
|
||||
context.scene.collection.objects.link(look_at_target_object)
|
||||
look_at.enable_preview = True
|
||||
look_at.preview_target_bpy_object = look_at_target_object
|
||||
|
||||
first_zero_origin_frame_count: int = math.floor(
|
||||
first_timestamp * context.scene.render.fps / context.scene.render.fps_base
|
||||
)
|
||||
if abs(last_timestamp - first_timestamp) > 0:
|
||||
last_zero_origin_frame_count: int = math.ceil(
|
||||
last_timestamp * context.scene.render.fps / context.scene.render.fps_base
|
||||
)
|
||||
else:
|
||||
last_zero_origin_frame_count = first_zero_origin_frame_count
|
||||
for zero_origin_frame_count in range(
|
||||
first_zero_origin_frame_count, last_zero_origin_frame_count + 1
|
||||
):
|
||||
timestamp = (
|
||||
zero_origin_frame_count
|
||||
* context.scene.render.fps_base
|
||||
/ context.scene.render.fps
|
||||
)
|
||||
frame_count = zero_origin_frame_count + 1
|
||||
|
||||
assign_humanoid_keyframe(
|
||||
armature,
|
||||
node_rest_pose_tree,
|
||||
node_index_to_human_bone_name,
|
||||
node_index_to_translation_keyframes,
|
||||
node_index_to_rotation_keyframes,
|
||||
frame_count,
|
||||
timestamp,
|
||||
humanoid_parent_rest_world_matrix=Matrix(),
|
||||
intermediate_rest_local_matrix=Matrix(),
|
||||
intermediate_pose_local_matrix=Matrix(),
|
||||
parent_node_rest_pose_world_matrix=Matrix(),
|
||||
)
|
||||
assign_expression_keyframe(
|
||||
armature_data,
|
||||
expression_name_to_default_preview_value,
|
||||
expression_name_to_translation_keyframes,
|
||||
frame_count,
|
||||
timestamp,
|
||||
)
|
||||
if look_at_target_object and look_at_translation_keyframes:
|
||||
assign_look_at_keyframe(
|
||||
look_at_target_object,
|
||||
look_at_translation_keyframes,
|
||||
frame_count,
|
||||
timestamp,
|
||||
)
|
||||
|
||||
# This process should be done last because bone state changes occur.
|
||||
# If translation is assigned to hips and hips is connected to parent
|
||||
# with "use_connect", the movement animation will not be reflected, so
|
||||
# we need to disconnect it
|
||||
if node_index_to_translation_keyframes.get(hips_node_index):
|
||||
with save_workspace(context, armature, mode="EDIT"):
|
||||
armature_data = armature.data
|
||||
if not isinstance(armature_data, Armature):
|
||||
raise TypeError
|
||||
hips_bone = armature_data.edit_bones.get(
|
||||
humanoid.human_bones.hips.node.bone_name
|
||||
)
|
||||
if hips_bone and hips_bone.use_connect:
|
||||
hips_bone.use_connect = False
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def assign_look_at_keyframe(
|
||||
look_at_target_object: Object,
|
||||
translation_keyframes: tuple[tuple[float, Vector], ...],
|
||||
frame_count: int,
|
||||
timestamp: float,
|
||||
) -> None:
|
||||
if not translation_keyframes:
|
||||
return
|
||||
|
||||
animation_translation = None
|
||||
begin_timestamp, begin_translation = translation_keyframes[0]
|
||||
for end_timestamp, end_translation in translation_keyframes:
|
||||
if end_timestamp >= timestamp:
|
||||
timestamp_duration = end_timestamp - begin_timestamp
|
||||
if timestamp_duration > 0:
|
||||
animation_translation = (
|
||||
begin_translation
|
||||
+ (end_translation - begin_translation)
|
||||
* (timestamp - begin_timestamp)
|
||||
/ timestamp_duration
|
||||
)
|
||||
else:
|
||||
animation_translation = begin_translation
|
||||
break
|
||||
begin_timestamp = end_timestamp
|
||||
begin_translation = end_translation
|
||||
if animation_translation is None:
|
||||
animation_translation = begin_translation
|
||||
|
||||
current_location = look_at_target_object.location.copy()
|
||||
look_at_target_object.location = animation_translation
|
||||
look_at_target_object.keyframe_insert(data_path="location", frame=frame_count)
|
||||
look_at_target_object.location = current_location
|
||||
|
||||
|
||||
def assign_expression_keyframe(
|
||||
armature_data: Armature,
|
||||
expression_name_to_default_preview_value: dict[str, float],
|
||||
expression_name_to_translation_keyframes: dict[
|
||||
str, tuple[tuple[float, Vector], ...]
|
||||
],
|
||||
frame_count: int,
|
||||
timestamp: float,
|
||||
) -> None:
|
||||
expressions = get_armature_extension(armature_data).vrm1.expressions
|
||||
expression_name_to_expression = expressions.all_name_to_expression_dict()
|
||||
for (
|
||||
expression_name,
|
||||
translation_keyframes,
|
||||
) in expression_name_to_translation_keyframes.items():
|
||||
if expression_name in [
|
||||
"lookUp",
|
||||
"lookDown",
|
||||
"lookLeft",
|
||||
"lookRight",
|
||||
]:
|
||||
continue
|
||||
expression = expression_name_to_expression.get(expression_name)
|
||||
if not expression:
|
||||
continue
|
||||
|
||||
if translation_keyframes:
|
||||
preview = None
|
||||
begin_timestamp, begin_translation = translation_keyframes[0]
|
||||
for end_timestamp, end_translation in translation_keyframes:
|
||||
if end_timestamp >= timestamp:
|
||||
timestamp_duration = end_timestamp - begin_timestamp
|
||||
if timestamp_duration > 0:
|
||||
preview = (
|
||||
begin_translation.x
|
||||
+ (end_translation.x - begin_translation.x)
|
||||
* (timestamp - begin_timestamp)
|
||||
/ timestamp_duration
|
||||
)
|
||||
else:
|
||||
preview = begin_translation.x
|
||||
break
|
||||
begin_timestamp = end_timestamp
|
||||
begin_translation = end_translation
|
||||
if preview is None:
|
||||
preview = begin_translation.x
|
||||
else:
|
||||
preview = (
|
||||
expression_name_to_default_preview_value.get(expression_name) or 0.0
|
||||
)
|
||||
|
||||
current_preview = expression.preview
|
||||
expression.preview = preview
|
||||
expression.keyframe_insert(data_path="preview", frame=frame_count)
|
||||
expression.preview = current_preview
|
||||
|
||||
|
||||
def assign_humanoid_keyframe(
|
||||
armature: Object,
|
||||
node_rest_pose_tree: NodeRestPoseTree,
|
||||
node_index_to_human_bone_name: dict[int, HumanBoneName],
|
||||
node_index_to_translation_keyframes: dict[int, tuple[tuple[float, Vector], ...]],
|
||||
node_index_to_rotation_keyframes: dict[int, tuple[tuple[float, Quaternion], ...]],
|
||||
frame_count: int,
|
||||
timestamp: float,
|
||||
humanoid_parent_rest_world_matrix: Matrix,
|
||||
intermediate_rest_local_matrix: Matrix,
|
||||
intermediate_pose_local_matrix: Matrix,
|
||||
parent_node_rest_pose_world_matrix: Matrix,
|
||||
) -> None:
|
||||
armature_data = armature.data
|
||||
if not isinstance(armature_data, Armature):
|
||||
return
|
||||
|
||||
translation_keyframes = node_index_to_translation_keyframes.get(
|
||||
node_rest_pose_tree.node_index
|
||||
)
|
||||
|
||||
if translation_keyframes:
|
||||
keyframe_translation = None
|
||||
begin_timestamp, begin_translation = translation_keyframes[0]
|
||||
for end_timestamp, end_translation in translation_keyframes:
|
||||
if end_timestamp >= timestamp:
|
||||
timestamp_duration = end_timestamp - begin_timestamp
|
||||
if timestamp_duration > 0:
|
||||
keyframe_translation = begin_translation.lerp(
|
||||
end_translation,
|
||||
(timestamp - begin_timestamp) / timestamp_duration,
|
||||
)
|
||||
else:
|
||||
keyframe_translation = begin_translation
|
||||
break
|
||||
begin_timestamp = end_timestamp
|
||||
begin_translation = end_translation
|
||||
if keyframe_translation is None:
|
||||
keyframe_translation = begin_translation
|
||||
else:
|
||||
keyframe_translation = node_rest_pose_tree.local_matrix.to_translation()
|
||||
|
||||
rotation_keyframes = node_index_to_rotation_keyframes.get(
|
||||
node_rest_pose_tree.node_index
|
||||
)
|
||||
if rotation_keyframes:
|
||||
keyframe_rotation = None
|
||||
begin_timestamp, begin_rotation = rotation_keyframes[0]
|
||||
for end_timestamp, end_rotation in rotation_keyframes:
|
||||
if end_timestamp >= timestamp:
|
||||
timestamp_duration = end_timestamp - begin_timestamp
|
||||
if timestamp_duration > 0:
|
||||
keyframe_rotation = begin_rotation.slerp(
|
||||
end_rotation, (timestamp - begin_timestamp) / timestamp_duration
|
||||
)
|
||||
else:
|
||||
keyframe_rotation = begin_rotation
|
||||
break
|
||||
begin_timestamp = end_timestamp
|
||||
begin_rotation = end_rotation
|
||||
if keyframe_rotation is None:
|
||||
keyframe_rotation = begin_rotation
|
||||
else:
|
||||
keyframe_rotation = node_rest_pose_tree.local_matrix.to_quaternion()
|
||||
|
||||
humanoid_rest_world_matrix = humanoid_parent_rest_world_matrix
|
||||
rest_local_matrix = (
|
||||
intermediate_rest_local_matrix @ node_rest_pose_tree.local_matrix
|
||||
)
|
||||
pose_local_matrix = (
|
||||
intermediate_pose_local_matrix
|
||||
@ Matrix.Translation(keyframe_translation)
|
||||
@ keyframe_rotation.to_matrix().to_4x4()
|
||||
@ Matrix.Diagonal(node_rest_pose_tree.local_matrix.to_scale()).to_4x4()
|
||||
)
|
||||
|
||||
human_bone_name = node_index_to_human_bone_name.get(node_rest_pose_tree.node_index)
|
||||
human_bones = get_armature_extension(armature_data).vrm1.humanoid.human_bones
|
||||
if (
|
||||
human_bone_name
|
||||
and human_bone_name not in [HumanBoneName.LEFT_EYE, HumanBoneName.RIGHT_EYE]
|
||||
and (
|
||||
human_bone := human_bones.human_bone_name_to_human_bone().get(
|
||||
human_bone_name
|
||||
)
|
||||
)
|
||||
and (bone := armature.pose.bones.get(human_bone.node.bone_name))
|
||||
):
|
||||
humanoid_rest_world_matrix = humanoid_parent_rest_world_matrix
|
||||
rest_world_matrix = humanoid_rest_world_matrix @ rest_local_matrix
|
||||
pose_world_matrix = humanoid_rest_world_matrix @ pose_local_matrix
|
||||
# rest_to_pose_matrix = rest_world_matrix.inverted() @ pose_world_matrix
|
||||
# rest_to_pose_matrix = rest_local_matrix.inverted() @ pose_local_matrix
|
||||
rest_to_pose_matrix = rest_local_matrix.inverted() @ pose_local_matrix
|
||||
axis, angle = rest_to_pose_matrix.to_quaternion().to_axis_angle()
|
||||
axis.rotate(rest_world_matrix.to_quaternion())
|
||||
rest_to_pose_world_rotation = Quaternion(axis, angle).copy()
|
||||
|
||||
target_axis, target_angle = rest_to_pose_world_rotation.to_axis_angle()
|
||||
target_axis.rotate(bone.matrix.to_quaternion().inverted())
|
||||
|
||||
rest_to_pose_target_local_rotation = Quaternion(
|
||||
target_axis, target_angle
|
||||
).copy()
|
||||
|
||||
if rotation_keyframes:
|
||||
logger.debug(
|
||||
"================= %s =================", human_bone_name.value
|
||||
)
|
||||
logger.debug("humanoid world matrix = %s", dump(humanoid_rest_world_matrix))
|
||||
logger.debug("rest_local_matrix = %s", dump(rest_local_matrix))
|
||||
logger.debug("pose_local_matrix = %s", dump(pose_local_matrix))
|
||||
logger.debug("rest_world_matrix = %s", dump(rest_world_matrix))
|
||||
logger.debug("pose_world_matrix = %s", dump(pose_world_matrix))
|
||||
logger.debug("rest_to_pose_matrix = %s", dump(rest_to_pose_matrix))
|
||||
logger.debug(
|
||||
"rest_to_pose_world_rotation = %s",
|
||||
dump(rest_to_pose_world_rotation),
|
||||
)
|
||||
logger.debug(
|
||||
"rest_to_pose_target_local_rotation = %s",
|
||||
dump(rest_to_pose_target_local_rotation),
|
||||
)
|
||||
|
||||
backup_rotation_quaternion = get_rotation_as_quaternion(bone)
|
||||
|
||||
# logger.debug("parent bone matrix = %s", dump(parent_matrix))
|
||||
logger.debug(" bone matrix = %s", dump(bone.matrix))
|
||||
logger.debug("current bone rotation = %s", dump(backup_rotation_quaternion))
|
||||
|
||||
set_rotation_without_mode_change(
|
||||
bone, backup_rotation_quaternion @ rest_to_pose_target_local_rotation
|
||||
)
|
||||
insert_rotation_keyframe(bone, frame=frame_count)
|
||||
set_rotation_without_mode_change(bone, backup_rotation_quaternion)
|
||||
|
||||
if human_bone_name == HumanBoneName.HIPS and translation_keyframes:
|
||||
translation = (
|
||||
bone.matrix.to_quaternion().inverted()
|
||||
@ humanoid_rest_world_matrix.to_quaternion()
|
||||
@ (
|
||||
pose_local_matrix.to_translation()
|
||||
- rest_local_matrix.to_translation()
|
||||
)
|
||||
)
|
||||
|
||||
# TODO: UniVRM seems to adjust the movement amount by the ratio of
|
||||
# hips height. This is not described in the specification.
|
||||
# Investigation of how it works in UniVRM source code is needed.
|
||||
rest_world_translation_z = rest_world_matrix.to_translation().z
|
||||
if abs(rest_world_translation_z) > 0:
|
||||
world_height_ratio = (
|
||||
bone.matrix.to_translation().z / rest_world_translation_z
|
||||
)
|
||||
translation *= world_height_ratio
|
||||
|
||||
# logger.debug(f"translation = {dump(translation)}")
|
||||
backup_translation = bone.location.copy()
|
||||
bone.location = translation
|
||||
bone.keyframe_insert(data_path="location", frame=frame_count)
|
||||
bone.location = backup_translation
|
||||
|
||||
humanoid_rest_world_matrix = (
|
||||
humanoid_parent_rest_world_matrix @ rest_local_matrix
|
||||
)
|
||||
rest_local_matrix = Matrix()
|
||||
pose_local_matrix = Matrix()
|
||||
|
||||
for child in node_rest_pose_tree.children:
|
||||
assign_humanoid_keyframe(
|
||||
armature,
|
||||
child,
|
||||
node_index_to_human_bone_name,
|
||||
node_index_to_translation_keyframes,
|
||||
node_index_to_rotation_keyframes,
|
||||
frame_count,
|
||||
timestamp,
|
||||
humanoid_rest_world_matrix,
|
||||
rest_local_matrix,
|
||||
pose_local_matrix,
|
||||
parent_node_rest_pose_world_matrix @ node_rest_pose_tree.local_matrix,
|
||||
)
|
||||
310
importer/vrm_diff.py
Normal file
310
importer/vrm_diff.py
Normal file
@@ -0,0 +1,310 @@
|
||||
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
|
||||
import math
|
||||
import re
|
||||
from sys import float_info
|
||||
|
||||
from mathutils import Quaternion
|
||||
|
||||
from ..common import deep, gltf
|
||||
from ..common.convert import Json
|
||||
from ..common.deep import make_json
|
||||
from ..common.gltf import read_accessors
|
||||
|
||||
|
||||
def human_bone_sort_key(human_bone_dict: Json) -> int:
|
||||
if not isinstance(human_bone_dict, dict):
|
||||
return -1
|
||||
node = human_bone_dict.get("node")
|
||||
if not isinstance(node, int):
|
||||
return -1
|
||||
return node
|
||||
|
||||
|
||||
asset_generator_pattern = (
|
||||
r"\AVRM Add-on for Blender v999\.999\.999"
|
||||
+ r" with Khronos glTF Blender I/O v[0-9]+\.[0-9]+\.[0-9]+\Z"
|
||||
)
|
||||
|
||||
fixed_asset_generator_value = (
|
||||
"VRM Add-on for Blender v999.999.999"
|
||||
+ " with Khronos glTF Blender I/O v999.999.999"
|
||||
)
|
||||
|
||||
|
||||
def create_vrm_json_dict(data: bytes) -> dict[str, Json]:
|
||||
json_dict, buffer0_bytes = gltf.parse_glb(data)
|
||||
json_dict["__decoded_accessors"] = make_json(
|
||||
read_accessors(json_dict, buffer0_bytes)
|
||||
)
|
||||
|
||||
extensions_dict = json_dict.get("extensions")
|
||||
if isinstance(extensions_dict, dict):
|
||||
is_vrm0 = bool(extensions_dict.get("VRM"))
|
||||
else:
|
||||
is_vrm0 = False
|
||||
|
||||
asset_dict = json_dict.get("asset")
|
||||
if isinstance(asset_dict, dict):
|
||||
asset_generator = asset_dict.get("generator")
|
||||
if isinstance(asset_generator, str):
|
||||
asset_dict["generator"] = re.sub(
|
||||
asset_generator_pattern,
|
||||
fixed_asset_generator_value,
|
||||
asset_generator,
|
||||
)
|
||||
|
||||
node_dicts = json_dict.get("nodes")
|
||||
if isinstance(node_dicts, list):
|
||||
for node_dict in node_dicts:
|
||||
if not isinstance(node_dict, dict):
|
||||
continue
|
||||
|
||||
if is_vrm0:
|
||||
skin_index = node_dict.get("skin")
|
||||
if isinstance(skin_index, int):
|
||||
skin_dicts = json_dict.get("skins")
|
||||
if (
|
||||
isinstance(skin_dicts, list)
|
||||
and skin_dicts
|
||||
and 0 <= skin_index < len(skin_dicts)
|
||||
and not deep.diff(skin_dicts[0], skin_dicts[skin_index])
|
||||
):
|
||||
node_dict["skin"] = 0
|
||||
|
||||
if "matrix" in node_dict:
|
||||
continue
|
||||
|
||||
if "scale" not in node_dict:
|
||||
node_dict["scale"] = [1.0, 1.0, 1.0]
|
||||
|
||||
rotation = node_dict.get("rotation")
|
||||
if not isinstance(rotation, list) or len(rotation) != 4:
|
||||
continue
|
||||
|
||||
rotation_x, rotation_y, rotation_z, rotation_w = rotation
|
||||
if (
|
||||
not isinstance(rotation_x, (float, int))
|
||||
or not isinstance(rotation_y, (float, int))
|
||||
or not isinstance(rotation_z, (float, int))
|
||||
or not isinstance(rotation_w, (float, int))
|
||||
):
|
||||
node_dict["rotation"] = [0.0, 0.0, 0.0, 1.0]
|
||||
continue
|
||||
|
||||
axis, angle = Quaternion(
|
||||
(rotation_w, rotation_x, rotation_y, rotation_z)
|
||||
).to_axis_angle()
|
||||
if abs(angle - math.pi) > 0.000001:
|
||||
continue
|
||||
|
||||
if abs(axis.x) > abs(axis.y) and abs(axis.x) > abs(axis.z):
|
||||
if axis.x < 0:
|
||||
axis = -axis
|
||||
elif abs(axis.y) > abs(axis.z):
|
||||
if axis.y < 0:
|
||||
axis = -axis
|
||||
elif axis.z < 0:
|
||||
axis = -axis
|
||||
|
||||
q = Quaternion(axis, angle).normalized()
|
||||
node_dict["rotation"] = [q.x, q.y, q.z, q.w]
|
||||
|
||||
if is_vrm0:
|
||||
skin_dicts = json_dict.get("skins")
|
||||
if isinstance(skin_dicts, list) and skin_dicts:
|
||||
first_skin_dict = skin_dicts[0]
|
||||
if all(
|
||||
not deep.diff(skin_dict, first_skin_dict)
|
||||
for skin_dict in skin_dicts
|
||||
if isinstance(skin_dict, dict)
|
||||
):
|
||||
while len(skin_dicts) > 1:
|
||||
skin_dicts.pop()
|
||||
|
||||
scene_dicts = json_dict.get("scenes")
|
||||
if isinstance(scene_dicts, list):
|
||||
for scene_dict in scene_dicts:
|
||||
if not isinstance(scene_dict, dict):
|
||||
continue
|
||||
scene_extras_dict = scene_dict.get("extras")
|
||||
if not isinstance(scene_extras_dict, dict):
|
||||
continue
|
||||
for k in ["show_mmd_tabs", "embed_textures", "ui_lang"]:
|
||||
scene_extras_dict.pop(k, None)
|
||||
if not scene_extras_dict:
|
||||
scene_dict.pop("extras", None)
|
||||
|
||||
extensions_dict = json_dict.get("extensions")
|
||||
if not isinstance(extensions_dict, dict):
|
||||
return json_dict
|
||||
|
||||
vrm0_extension_dict = extensions_dict.get("VRM")
|
||||
if not isinstance(vrm0_extension_dict, dict):
|
||||
return json_dict
|
||||
|
||||
vrm0_first_person_dict = vrm0_extension_dict.get("firstPerson")
|
||||
if not isinstance(vrm0_first_person_dict, dict):
|
||||
vrm0_first_person_dict = {}
|
||||
vrm0_extension_dict["firstPerson"] = vrm0_first_person_dict
|
||||
|
||||
vrm0_humanoid_dict = vrm0_extension_dict.get("humanoid")
|
||||
if isinstance(vrm0_humanoid_dict, dict):
|
||||
vrm0_human_bone_dicts = vrm0_humanoid_dict.get("humanBones")
|
||||
if isinstance(vrm0_human_bone_dicts, list):
|
||||
vrm0_humanoid_dict["humanBones"] = sorted(
|
||||
vrm0_human_bone_dicts, key=human_bone_sort_key
|
||||
)
|
||||
|
||||
vrm0_first_person_bone = vrm0_first_person_dict.get("firstPersonBone")
|
||||
if vrm0_first_person_bone in [None, -1]:
|
||||
for vrm0_human_bone_dict in vrm0_human_bone_dicts:
|
||||
if not isinstance(vrm0_human_bone_dict, dict):
|
||||
continue
|
||||
node = vrm0_human_bone_dict.get("node")
|
||||
if not isinstance(node, int) or node < 0:
|
||||
continue
|
||||
if vrm0_human_bone_dict.get("bone") == "head":
|
||||
vrm0_first_person_dict["firstPersonBone"] = node
|
||||
break
|
||||
|
||||
for look_at_key in [
|
||||
"lookAtHorizontalInner",
|
||||
"lookAtHorizontalOuter",
|
||||
"lookAtVerticalDown",
|
||||
"lookAtVerticalUp",
|
||||
]:
|
||||
if look_at_key not in vrm0_first_person_dict:
|
||||
vrm0_first_person_dict[look_at_key] = {}
|
||||
look_at_dict = vrm0_first_person_dict[look_at_key]
|
||||
if not isinstance(look_at_dict, dict):
|
||||
continue
|
||||
if "curve" not in look_at_dict:
|
||||
look_at_dict["curve"] = [0, 0, 0, 1, 1, 1, 1, 0]
|
||||
|
||||
vrm0_blend_shape_master_dict = vrm0_extension_dict.get("blendShapeMaster")
|
||||
if isinstance(vrm0_blend_shape_master_dict, dict):
|
||||
vrm0_blend_shape_group_dicts = vrm0_blend_shape_master_dict.get(
|
||||
"blendShapeGroups"
|
||||
)
|
||||
if isinstance(vrm0_blend_shape_group_dicts, list):
|
||||
for vrm0_blend_shape_group_dict in vrm0_blend_shape_group_dicts:
|
||||
if not isinstance(vrm0_blend_shape_group_dict, dict):
|
||||
continue
|
||||
if "isBinary" not in vrm0_blend_shape_group_dict:
|
||||
vrm0_blend_shape_group_dict["isBinary"] = False
|
||||
if "binds" not in vrm0_blend_shape_group_dict:
|
||||
vrm0_blend_shape_group_dict["binds"] = []
|
||||
if "materialValues" not in vrm0_blend_shape_group_dict:
|
||||
vrm0_blend_shape_group_dict["materialValues"] = []
|
||||
|
||||
vrm0_secondary_animation = vrm0_extension_dict.get("secondaryAnimation")
|
||||
if isinstance(vrm0_secondary_animation, dict):
|
||||
vrm0_collider_group_dicts = vrm0_secondary_animation.get("colliderGroups")
|
||||
if isinstance(vrm0_collider_group_dicts, list):
|
||||
|
||||
def sort_collider_groups_with_index_key(
|
||||
collider_group_with_index: tuple[int, Json],
|
||||
) -> int:
|
||||
(_, collider_group) = collider_group_with_index
|
||||
if not isinstance(collider_group, dict):
|
||||
return -999
|
||||
node = collider_group.get("node")
|
||||
if not isinstance(node, int):
|
||||
return -999
|
||||
return node
|
||||
|
||||
sorted_collider_groups_with_original_index = sorted(
|
||||
enumerate(vrm0_collider_group_dicts),
|
||||
key=sort_collider_groups_with_index_key,
|
||||
)
|
||||
vrm0_collider_group_dicts = [
|
||||
collider_group
|
||||
for _, collider_group in sorted_collider_groups_with_original_index
|
||||
]
|
||||
original_index_to_sorted_index = {
|
||||
original_index: sorted_index
|
||||
for (sorted_index, (original_index, _)) in enumerate(
|
||||
sorted_collider_groups_with_original_index
|
||||
)
|
||||
}
|
||||
|
||||
vrm0_bone_groups = vrm0_secondary_animation.get("boneGroups")
|
||||
if isinstance(vrm0_bone_groups, list):
|
||||
for vrm0_bone_group in vrm0_bone_groups:
|
||||
if not isinstance(vrm0_bone_group, dict):
|
||||
continue
|
||||
if "comment" not in vrm0_bone_group:
|
||||
vrm0_bone_group["comment"] = ""
|
||||
collider_groups = vrm0_bone_group.get("colliderGroups")
|
||||
if not isinstance(collider_groups, list):
|
||||
continue
|
||||
for i, collider_group in enumerate(list(collider_groups)):
|
||||
if not isinstance(collider_group, int):
|
||||
continue
|
||||
if collider_group not in original_index_to_sorted_index:
|
||||
collider_groups[i] = -999
|
||||
continue
|
||||
collider_groups[i] = original_index_to_sorted_index[
|
||||
collider_group
|
||||
]
|
||||
|
||||
vrm0_material_properties = vrm0_extension_dict.get("materialProperties")
|
||||
if isinstance(vrm0_material_properties, list):
|
||||
for vrm0_material_property in vrm0_material_properties:
|
||||
if not isinstance(vrm0_material_property, dict):
|
||||
continue
|
||||
if vrm0_material_property.get("shader") != "VRM/MToon":
|
||||
continue
|
||||
|
||||
keyword_map = vrm0_material_property.get("keywordMap")
|
||||
if not isinstance(keyword_map, dict):
|
||||
keyword_map = {}
|
||||
|
||||
vrm0_float_properties = vrm0_material_property.get("floatProperties")
|
||||
if isinstance(vrm0_float_properties, dict):
|
||||
if vrm0_float_properties.get("_OutlineWidthMode", 0) == 0:
|
||||
vrm0_float_properties["_OutlineWidth"] = 0
|
||||
vrm0_float_properties["_OutlineColorMode"] = 0
|
||||
vrm0_float_properties["_OutlineLightingMix"] = 0
|
||||
keyword_map.pop("MTOON_OUTLINE_COLOR_FIXED", None)
|
||||
keyword_map.pop("MTOON_OUTLINE_COLOR_MIXED", None)
|
||||
|
||||
if vrm0_float_properties.get("_OutlineWidthMode") != 2:
|
||||
vrm0_float_properties["_OutlineScaledMaxDistance"] = 1
|
||||
|
||||
if vrm0_float_properties.get("_OutlineColorMode") == 0:
|
||||
vrm0_float_properties["_OutlineLightingMix"] = 0
|
||||
|
||||
outline_lighting_mix = vrm0_float_properties.get("_OutlineLightingMix")
|
||||
if (
|
||||
isinstance(outline_lighting_mix, (float, int))
|
||||
and abs(outline_lighting_mix) < float_info.epsilon
|
||||
):
|
||||
vrm0_float_properties["_OutlineColorMode"] = 0
|
||||
keyword_map.pop("MTOON_OUTLINE_COLOR_MIXED", None)
|
||||
if "MTOON_OUTLINE_COLOR_FIXED" not in keyword_map:
|
||||
keyword_map["MTOON_OUTLINE_COLOR_FIXED"] = True
|
||||
|
||||
vrm0_vector_properties = vrm0_material_property.get("vectorProperties")
|
||||
if isinstance(vrm0_vector_properties, dict):
|
||||
vrm0_emission_color = vrm0_vector_properties.get("_EmissionColor")
|
||||
if (
|
||||
isinstance(vrm0_emission_color, list)
|
||||
and len(vrm0_emission_color) == 4
|
||||
):
|
||||
vrm0_emission_color[3] = 1
|
||||
|
||||
vrm0_outline_color = vrm0_vector_properties.get("_OutlineColor")
|
||||
if (
|
||||
isinstance(vrm0_outline_color, list)
|
||||
and len(vrm0_outline_color) == 4
|
||||
):
|
||||
vrm0_outline_color[3] = 1
|
||||
|
||||
return json_dict
|
||||
|
||||
|
||||
def vrm_diff(before: bytes, after: bytes, float_tolerance: float) -> list[str]:
|
||||
return deep.diff(
|
||||
create_vrm_json_dict(before), create_vrm_json_dict(after), float_tolerance
|
||||
)
|
||||
Reference in New Issue
Block a user