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:
2026-01-01 14:21:56 +08:00
commit 091ad6a49a
243 changed files with 60636 additions and 0 deletions

1
editor/__init__.py Normal file
View File

@@ -0,0 +1 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

539
editor/extension.py Normal file
View File

@@ -0,0 +1,539 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import math
from collections.abc import Sequence
from typing import TYPE_CHECKING, Optional, TypeVar
import bpy
from bpy.props import (
CollectionProperty,
EnumProperty,
IntVectorProperty,
PointerProperty,
StringProperty,
)
from bpy.types import (
Armature,
Bone,
Context,
Material,
NodeTree,
Object,
PropertyGroup,
Scene,
)
from mathutils import Matrix, Quaternion
from ..common.logger import get_logger
from ..common.preferences import VrmAddonPreferences
from .mtoon1.property_group import Mtoon1MaterialPropertyGroup
from .node_constraint1.property_group import NodeConstraint1NodeConstraintPropertyGroup
from .property_group import StringPropertyGroup, property_group_enum
from .spring_bone1.property_group import SpringBone1SpringBonePropertyGroup
from .vrm0.property_group import Vrm0HumanoidPropertyGroup, Vrm0PropertyGroup
from .vrm1.property_group import Vrm1HumanBonesPropertyGroup, Vrm1PropertyGroup
if TYPE_CHECKING:
from .property_group import CollectionPropertyProtocol
logger = get_logger(__name__)
class VrmAddonSceneExtensionPropertyGroup(PropertyGroup):
vrm0_material_gltf_property_names: CollectionProperty( # type: ignore[valid-type]
type=StringPropertyGroup
)
vrm0_material_mtoon0_property_names: CollectionProperty( # type: ignore[valid-type]
type=StringPropertyGroup
)
@staticmethod
def update_vrm0_material_property_names_timer_callback() -> Optional[float]:
context = bpy.context
VrmAddonSceneExtensionPropertyGroup.update_vrm0_material_property_names(context)
return None
@staticmethod
def defer_update_vrm0_material_property_names() -> None:
s = VrmAddonSceneExtensionPropertyGroup
bpy.app.timers.register(s.update_vrm0_material_property_names_timer_callback)
@staticmethod
def update_vrm0_material_property_names(
context: Context, scene_name: Optional[str] = None
) -> None:
# Unity 2022.3.4 + UniVRM 0.112.0
gltf_property_names = [
"_Color",
"_MainTex_ST",
"_MainTex_ST_S",
"_MainTex_ST_T",
"_MetallicGlossMap_ST",
"_MetallicGlossMap_ST_S",
"_MetallicGlossMap_ST_T",
"_BumpMap_ST",
"_BumpMap_ST_S",
"_BumpMap_ST_T",
"_ParallaxMap_ST",
"_ParallaxMap_ST_S",
"_ParallaxMap_ST_T",
"_OcclusionMap_ST",
"_OcclusionMap_ST_S",
"_OcclusionMap_ST_T",
"_EmissionColor",
"_EmissionMap_ST",
"_EmissionMap_ST_S",
"_EmissionMap_ST_T",
"_DetailMask_ST",
"_DetailMask_ST_S",
"_DetailMask_ST_T",
"_DetailAlbedoMap_ST",
"_DetailAlbedoMap_ST_S",
"_DetailAlbedoMap_ST_T",
"_DetailNormalMap_ST",
"_DetailNormalMap_ST_S",
"_DetailNormalMap_ST_T",
]
# UniVRM 0.112.0
mtoon0_property_names = [
"_Color",
"_ShadeColor",
"_MainTex_ST",
"_MainTex_ST_S",
"_MainTex_ST_T",
"_ShadeTexture_ST",
"_ShadeTexture_ST_S",
"_ShadeTexture_ST_T",
"_BumpMap_ST",
"_BumpMap_ST_S",
"_BumpMap_ST_T",
"_ReceiveShadowTexture_ST",
"_ReceiveShadowTexture_ST_S",
"_ReceiveShadowTexture_ST_T",
"_ShadingGradeTexture_ST",
"_ShadingGradeTexture_ST_S",
"_ShadingGradeTexture_ST_T",
"_RimColor",
"_RimTexture_ST",
"_RimTexture_ST_S",
"_RimTexture_ST_T",
"_SphereAdd_ST",
"_SphereAdd_ST_S",
"_SphereAdd_ST_T",
"_EmissionColor",
"_EmissionMap_ST",
"_EmissionMap_ST_S",
"_EmissionMap_ST_T",
"_OutlineWidthTexture_ST",
"_OutlineWidthTexture_ST_S",
"_OutlineWidthTexture_ST_T",
"_OutlineColor",
"_UvAnimMaskTexture_ST",
"_UvAnimMaskTexture_ST_S",
"_UvAnimMaskTexture_ST_T",
]
if scene_name is None:
scenes: Sequence[Scene] = list(context.blend_data.scenes)
else:
scene = context.blend_data.scenes.get(scene_name)
if not scene:
logger.error('No scene "%s"', scene_name)
return
scenes = [scene]
for scene in scenes:
ext = get_scene_extension(scene)
if gltf_property_names != [
n.value for n in ext.vrm0_material_gltf_property_names
]:
ext.vrm0_material_gltf_property_names.clear()
for gltf_property_name in gltf_property_names:
n = ext.vrm0_material_gltf_property_names.add()
n.value = gltf_property_name
if mtoon0_property_names != [
n.value for n in ext.vrm0_material_mtoon0_property_names
]:
ext.vrm0_material_mtoon0_property_names.clear()
for mtoon0_property_name in mtoon0_property_names:
n = ext.vrm0_material_mtoon0_property_names.add()
n.value = mtoon0_property_name
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
vrm0_material_gltf_property_names: CollectionPropertyProtocol[ # type: ignore[no-redef]
StringPropertyGroup
]
vrm0_material_mtoon0_property_names: CollectionPropertyProtocol[ # type: ignore[no-redef]
StringPropertyGroup
]
class VrmAddonBoneExtensionPropertyGroup(PropertyGroup):
uuid: StringProperty() # type: ignore[valid-type]
(
axis_translation_enum,
(
AXIS_TRANSLATION_AUTO,
AXIS_TRANSLATION_NONE,
AXIS_TRANSLATION_X_TO_Y,
AXIS_TRANSLATION_MINUS_X_TO_Y,
AXIS_TRANSLATION_MINUS_Y_TO_Y_AROUND_Z,
AXIS_TRANSLATION_Z_TO_Y,
AXIS_TRANSLATION_MINUS_Z_TO_Y,
),
) = property_group_enum(
("AUTO", "Auto", "", "NONE", 0),
("NONE", "None", "", "NONE", 1),
("X_TO_Y", "X,Y to Y,-X", "", "NONE", 2),
("MINUS_X_TO_Y", "X,Y to -Y,X", "", "NONE", 3),
("MINUS_Y_TO_Y_AROUND_Z", "X,Y to -X,-Y", "", "NONE", 4),
("Z_TO_Y", "Y,Z to -Z,Y", "", "NONE", 5),
("MINUS_Z_TO_Y", "Y,Z to Z,-Y", "", "NONE", 6),
)
@classmethod
def reverse_axis_translation(cls, axis_translation: str) -> str:
return {
cls.AXIS_TRANSLATION_AUTO.identifier: cls.AXIS_TRANSLATION_AUTO,
cls.AXIS_TRANSLATION_NONE.identifier: cls.AXIS_TRANSLATION_NONE,
cls.AXIS_TRANSLATION_X_TO_Y.identifier: (cls.AXIS_TRANSLATION_MINUS_X_TO_Y),
cls.AXIS_TRANSLATION_MINUS_X_TO_Y.identifier: (cls.AXIS_TRANSLATION_X_TO_Y),
cls.AXIS_TRANSLATION_Z_TO_Y.identifier: (cls.AXIS_TRANSLATION_MINUS_Z_TO_Y),
cls.AXIS_TRANSLATION_MINUS_Z_TO_Y.identifier: (cls.AXIS_TRANSLATION_Z_TO_Y),
cls.AXIS_TRANSLATION_MINUS_Y_TO_Y_AROUND_Z.identifier: (
cls.AXIS_TRANSLATION_MINUS_Y_TO_Y_AROUND_Z
),
}[axis_translation].identifier
@classmethod
def node_constraint_roll_axis_translation(
cls, axis_translation: str, roll_axis: Optional[str]
) -> Optional[str]:
if roll_axis is None:
return None
return {
cls.AXIS_TRANSLATION_AUTO.identifier: {"X": "X", "Y": "Y", "Z": "Z"},
cls.AXIS_TRANSLATION_NONE.identifier: {"X": "X", "Y": "Y", "Z": "Z"},
cls.AXIS_TRANSLATION_X_TO_Y.identifier: {"X": "Y", "Y": "X", "Z": "Z"},
cls.AXIS_TRANSLATION_MINUS_X_TO_Y.identifier: {
"X": "Y",
"Y": "X",
"Z": "Z",
},
cls.AXIS_TRANSLATION_Z_TO_Y.identifier: {"X": "X", "Y": "Z", "Z": "Y"},
cls.AXIS_TRANSLATION_MINUS_Z_TO_Y.identifier: {
"X": "X",
"Y": "Z",
"Z": "Y",
},
cls.AXIS_TRANSLATION_MINUS_Y_TO_Y_AROUND_Z.identifier: {
"X": "X",
"Y": "Y",
"Z": "Z",
},
}[axis_translation][roll_axis]
@classmethod
def node_constraint_aim_axis_translation(
cls, axis_translation: str, aim_axis: Optional[str]
) -> Optional[str]:
if aim_axis is None:
return None
return {
cls.AXIS_TRANSLATION_AUTO.identifier: {
"PositiveX": "PositiveX",
"PositiveY": "PositiveY",
"PositiveZ": "PositiveZ",
"NegativeX": "NegativeX",
"NegativeY": "NegativeY",
"NegativeZ": "NegativeZ",
},
cls.AXIS_TRANSLATION_NONE.identifier: {
"PositiveX": "PositiveX",
"PositiveY": "PositiveY",
"PositiveZ": "PositiveZ",
"NegativeX": "NegativeX",
"NegativeY": "NegativeY",
"NegativeZ": "NegativeZ",
},
cls.AXIS_TRANSLATION_X_TO_Y.identifier: {
"PositiveX": "PositiveY",
"PositiveY": "NegativeX",
"PositiveZ": "PositiveZ",
"NegativeX": "NegativeY",
"NegativeY": "PositiveX",
"NegativeZ": "NegativeZ",
},
cls.AXIS_TRANSLATION_MINUS_X_TO_Y.identifier: {
"PositiveY": "PositiveX",
"NegativeX": "PositiveY",
"PositiveZ": "PositiveZ",
"NegativeY": "NegativeX",
"PositiveX": "NegativeY",
"NegativeZ": "NegativeZ",
},
cls.AXIS_TRANSLATION_Z_TO_Y.identifier: {
"PositiveX": "PositiveX",
"PositiveY": "NegativeZ",
"PositiveZ": "PositiveY",
"NegativeX": "NegativeX",
"NegativeY": "PositiveZ",
"NegativeZ": "NegativeY",
},
cls.AXIS_TRANSLATION_MINUS_Z_TO_Y.identifier: {
"PositiveX": "PositiveX",
"NegativeZ": "PositiveY",
"PositiveY": "PositiveZ",
"NegativeX": "NegativeX",
"PositiveZ": "NegativeY",
"NegativeY": "NegativeZ",
},
cls.AXIS_TRANSLATION_MINUS_Y_TO_Y_AROUND_Z.identifier: {
"PositiveX": "NegativeX",
"PositiveY": "NegativeY",
"PositiveZ": "PositiveZ",
"NegativeX": "PositiveX",
"NegativeY": "PositiveY",
"NegativeZ": "NegativeZ",
},
}[axis_translation][aim_axis]
@classmethod
def translate_axis(cls, matrix: Matrix, axis_translation: str) -> Matrix:
location, rotation, scale = matrix.decompose()
if axis_translation == cls.AXIS_TRANSLATION_X_TO_Y.identifier:
rotation @= Quaternion((0, 0, 1), -math.pi / 2)
elif axis_translation == cls.AXIS_TRANSLATION_MINUS_X_TO_Y.identifier:
rotation @= Quaternion((0, 0, 1), math.pi / 2)
elif axis_translation == cls.AXIS_TRANSLATION_MINUS_Y_TO_Y_AROUND_Z.identifier:
rotation @= Quaternion((0, 0, 1), math.pi)
elif axis_translation == cls.AXIS_TRANSLATION_Z_TO_Y.identifier:
rotation @= Quaternion((1, 0, 0), math.pi / 2)
elif axis_translation == cls.AXIS_TRANSLATION_MINUS_Z_TO_Y.identifier:
rotation @= Quaternion((1, 0, 0), -math.pi / 2)
# return Matrix.LocRotScale(location, rotation, scale)
return (
Matrix.Translation(location)
@ rotation.to_matrix().to_4x4()
@ Matrix.Diagonal(scale).to_4x4()
)
axis_translation: EnumProperty( # type: ignore[valid-type]
items=axis_translation_enum.items(),
name="Axis Translation on Export",
)
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
uuid: str # type: ignore[no-redef]
axis_translation: str # type: ignore[no-redef]
class VrmAddonObjectExtensionPropertyGroup(PropertyGroup):
axis_translation: EnumProperty( # type: ignore[valid-type]
items=VrmAddonBoneExtensionPropertyGroup.axis_translation_enum.items(),
name="Axis Translation on Export",
)
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
axis_translation: str # type: ignore[no-redef]
class VrmAddonArmatureExtensionPropertyGroup(PropertyGroup):
INITIAL_ADDON_VERSION = VrmAddonPreferences.INITIAL_ADDON_VERSION
addon_version: IntVectorProperty( # type: ignore[valid-type]
size=3,
default=INITIAL_ADDON_VERSION,
)
vrm0: PointerProperty( # type: ignore[valid-type]
type=Vrm0PropertyGroup
)
vrm1: PointerProperty( # type: ignore[valid-type]
type=Vrm1PropertyGroup
)
spring_bone1: PointerProperty( # type: ignore[valid-type]
type=SpringBone1SpringBonePropertyGroup
)
node_constraint1: PointerProperty( # type: ignore[valid-type]
type=NodeConstraint1NodeConstraintPropertyGroup
)
SPEC_VERSION_VRM0 = "0.0"
SPEC_VERSION_VRM1 = "1.0"
spec_version_items = (
(SPEC_VERSION_VRM0, "VRM 0.0", "", "NONE", 0),
(SPEC_VERSION_VRM1, "VRM 1.0", "", "NONE", 1),
)
def update_spec_version(self, _context: Context) -> None:
for blend_shape_group in self.vrm0.blend_shape_master.blend_shape_groups:
blend_shape_group.preview = 0
if self.spec_version == self.SPEC_VERSION_VRM0:
vrm0_hidden = False
vrm1_hidden = True
elif self.spec_version == self.SPEC_VERSION_VRM1:
vrm0_hidden = True
vrm1_hidden = False
else:
return
for vrm0_collider in [
collider.bpy_object
for collider_group in self.vrm0.secondary_animation.collider_groups
for collider in collider_group.colliders
if collider.bpy_object
]:
vrm0_collider.hide_set(vrm0_hidden)
for vrm1_collider in [
collider.bpy_object
for collider in self.spring_bone1.colliders
if collider.bpy_object
]:
vrm1_collider.hide_set(vrm1_hidden)
for child in vrm1_collider.children:
child.hide_set(vrm1_hidden)
spec_version: EnumProperty( # type: ignore[valid-type]
items=spec_version_items,
name="Spec Version",
update=update_spec_version,
default=SPEC_VERSION_VRM1,
)
def is_vrm0(self) -> bool:
return str(self.spec_version) == self.SPEC_VERSION_VRM0
def is_vrm1(self) -> bool:
return str(self.spec_version) == self.SPEC_VERSION_VRM1
@staticmethod
def has_vrm_model_metadata(obj: Object) -> bool:
if obj.type != "ARMATURE":
return False
armature = obj.data
if not isinstance(armature, Armature):
return False
# https://github.com/saturday06/VRM-Addon-for-Blender/blob/2_0_3/io_scene_vrm/editor/migration.py#L372-L373
ext = get_armature_extension(armature)
if tuple(ext.addon_version) > (2, 0, 1):
return True
# https://github.com/saturday06/VRM-Addon-for-Blender/blob/0_79/importer/model_build.py#L731
humanoid_params_key = obj.get("humanoid_params")
if not isinstance(humanoid_params_key, str):
return False
# https://github.com/saturday06/VRM-Addon-for-Blender/blob/0_79/importer/model_build.py#L706
return "hips" in armature
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
addon_version: Sequence[int] # type: ignore[no-redef]
vrm0: Vrm0PropertyGroup # type: ignore[no-redef]
vrm1: Vrm1PropertyGroup # type: ignore[no-redef]
spring_bone1: SpringBone1SpringBonePropertyGroup # type: ignore[no-redef]
node_constraint1: ( # type: ignore[no-redef]
NodeConstraint1NodeConstraintPropertyGroup
)
spec_version: str # type: ignore[no-redef]
def update_internal_cache(context: Context) -> None:
for armature in context.blend_data.armatures:
Vrm0HumanoidPropertyGroup.update_all_node_candidates(context, armature.name)
Vrm1HumanBonesPropertyGroup.update_all_node_candidates(context, armature.name)
VrmAddonSceneExtensionPropertyGroup.update_vrm0_material_property_names(context)
class VrmAddonMaterialExtensionPropertyGroup(PropertyGroup):
mtoon1: PointerProperty( # type: ignore[valid-type]
type=Mtoon1MaterialPropertyGroup
)
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
mtoon1: Mtoon1MaterialPropertyGroup # type: ignore[no-redef]
class VrmAddonNodeTreeExtensionPropertyGroup(PropertyGroup):
INITIAL_ADDON_VERSION = VrmAddonPreferences.INITIAL_ADDON_VERSION
addon_version: IntVectorProperty( # type: ignore[valid-type]
size=3,
default=INITIAL_ADDON_VERSION,
)
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
addon_version: Sequence[int] # type: ignore[no-redef]
__Extension = TypeVar("__Extension")
def get_vrm_addon_extension_or_raise(
obj: object, expected_type: type[__Extension]
) -> __Extension:
extension = getattr(obj, "vrm_addon_extension", None)
if isinstance(extension, expected_type):
return extension
message = f"{extension} is not a {expected_type} but {type(extension)}"
raise TypeError(message)
def get_material_extension(
material: Material,
) -> VrmAddonMaterialExtensionPropertyGroup:
return get_vrm_addon_extension_or_raise(
material, VrmAddonMaterialExtensionPropertyGroup
)
def get_armature_extension(
armature: Armature,
) -> VrmAddonArmatureExtensionPropertyGroup:
return get_vrm_addon_extension_or_raise(
armature, VrmAddonArmatureExtensionPropertyGroup
)
def get_node_tree_extension(
node_tree: NodeTree,
) -> VrmAddonNodeTreeExtensionPropertyGroup:
return get_vrm_addon_extension_or_raise(
node_tree, VrmAddonNodeTreeExtensionPropertyGroup
)
def get_scene_extension(scene: Scene) -> VrmAddonSceneExtensionPropertyGroup:
return get_vrm_addon_extension_or_raise(scene, VrmAddonSceneExtensionPropertyGroup)
def get_bone_extension(bone: Bone) -> VrmAddonBoneExtensionPropertyGroup:
return get_vrm_addon_extension_or_raise(bone, VrmAddonBoneExtensionPropertyGroup)
def get_object_extension(obj: Object) -> VrmAddonObjectExtensionPropertyGroup:
return get_vrm_addon_extension_or_raise(obj, VrmAddonObjectExtensionPropertyGroup)

10
editor/handler.py Normal file
View File

@@ -0,0 +1,10 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from bpy.app.handlers import persistent
from . import migration
@persistent
def load_post(_unsed: object) -> None:
migration.state.blend_file_compatibility_warning_shown = False
migration.state.blend_file_addon_compatibility_warning_shown = False

712
editor/make_armature.py Normal file
View File

@@ -0,0 +1,712 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from collections.abc import Set as AbstractSet
from math import radians
from sys import float_info
from typing import TYPE_CHECKING, Optional
import bpy
from bpy.props import BoolProperty, FloatProperty, StringProperty
from bpy.types import Armature, Context, EditBone, Object, Operator
from mathutils import Matrix, Vector
from ..common.version import get_addon_version
from ..common.workspace import save_workspace
from . import migration
from .extension import get_armature_extension
from .vrm0.property_group import (
Vrm0BlendShapeGroupPropertyGroup,
Vrm0HumanoidPropertyGroup,
)
MIN_BONE_LENGTH = 0.00001 # 10μm
AUTO_BONE_CONNECTION_DISTANCE = 0.000001 # 1μm
class ICYP_OT_make_armature(Operator):
bl_idname = "icyp.make_basic_armature"
bl_label = "Add VRM Humanoid"
bl_description = "make armature and simple setup for VRM export"
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
skip_heavy_armature_setup: BoolProperty( # type: ignore[valid-type]
default=False,
options={"HIDDEN"},
)
wip_with_template_mesh: BoolProperty( # type: ignore[valid-type]
default=False
)
# Height in meters
tall: FloatProperty( # type: ignore[valid-type]
default=1.70,
min=0.3,
step=1,
name="Bone tall",
)
# Head-to-body ratio
head_ratio: FloatProperty( # type: ignore[valid-type]
default=8.0,
min=4,
step=5,
description="height per heads",
)
head_width_ratio: FloatProperty( # type: ignore[valid-type]
default=2 / 3,
min=0.3,
max=1.2,
step=5,
description="height per heads",
)
# Leg-to-torso ratio: 0: child-like, 1: adult-like (effective for low head count)
aging_ratio: FloatProperty( # type: ignore[valid-type]
default=0.5, min=0, max=1, step=10
)
# Eye depth
eye_depth: FloatProperty( # type: ignore[valid-type]
default=-0.03, min=-0.1, max=0, step=1
)
# Shoulder width
shoulder_in_width: FloatProperty( # type: ignore[valid-type]
default=0.05,
min=0.01,
step=1,
description="Inner shoulder position",
)
shoulder_width: FloatProperty( # type: ignore[valid-type]
default=0.08,
min=0.01,
step=1,
description="shoulder roll position",
)
# Arm length ratio
arm_length_ratio: FloatProperty( # type: ignore[valid-type]
default=1, min=0.5, step=1
)
# Hand
hand_ratio: FloatProperty( # type: ignore[valid-type]
default=1, min=0.5, max=2.0, step=5
)
finger_1_2_ratio: FloatProperty( # type: ignore[valid-type]
default=0.75,
min=0.5,
max=1,
step=1,
description="proximal / intermediate",
)
finger_2_3_ratio: FloatProperty( # type: ignore[valid-type]
default=0.75,
min=0.5,
max=1,
step=1,
description="intermediate / distal",
)
nail_bone: BoolProperty( # type: ignore[valid-type]
default=False,
description="may need for finger collider",
) # Needed for fingertip collision detection
# Foot
leg_length_ratio: FloatProperty( # type: ignore[valid-type]
default=0.5,
min=0.3,
max=0.6,
step=1,
description="upper body/lower body",
)
leg_width_ratio: FloatProperty( # type: ignore[valid-type]
default=1, min=0.01, step=1
)
leg_size: FloatProperty( # type: ignore[valid-type]
default=0.26, min=0.05, step=1
)
custom_property_name: StringProperty( # type: ignore[valid-type]
options={"HIDDEN"}
)
armature_obj: Optional[Object] = None
def execute(self, context: Context) -> set[str]:
with save_workspace(context):
self.armature_obj, compare_dict = self.make_armature(context)
self.setup_as_vrm(context, self.armature_obj, compare_dict)
if self.custom_property_name:
self.armature_obj[self.custom_property_name] = True
context.view_layer.objects.active = self.armature_obj
return {"FINISHED"}
def float_prop(self, name: str) -> float:
prop = getattr(self, name)
if not isinstance(prop, float):
message = f"prop {name} is not float"
raise TypeError(message)
return prop
def head_size(self) -> float:
return self.float_prop("tall") / self.float_prop("head_ratio")
def hand_size(self) -> float:
return self.head_size() * 0.75 * self.float_prop("hand_ratio")
def make_armature(self, context: Context) -> tuple[Object, dict[str, str]]:
def bone_add(
armature_data: Armature,
name: str,
head_pos: Vector,
tail_pos: Vector,
parent_bone: Optional[EditBone] = None,
radius: float = 0.1,
roll: float = 0,
) -> EditBone:
added_bone = armature_data.edit_bones.new(name)
added_bone.head = head_pos
added_bone.tail = tail_pos
added_bone.head_radius = radius
added_bone.tail_radius = radius
added_bone.envelope_distance = 0.01
added_bone.roll = radians(roll)
if parent_bone is not None:
added_bone.parent = parent_bone
bone_dict.update({name: added_bone})
return added_bone
def x_mirror_bones_add(
armature_data: Armature,
base_name: str,
right_head_pos: Vector,
right_tail_pos: Vector,
parent_bones: tuple[EditBone, EditBone],
radius: float = 0.1,
bone_type: str = "other",
) -> tuple[EditBone, EditBone]:
right_roll = 0
left_roll = 0
if bone_type == "arm":
right_roll = 0
elif bone_type == "leg":
right_roll = 0
left_roll = 0
left_bone = bone_add(
armature_data,
base_name + ".L",
right_head_pos,
right_tail_pos,
parent_bones[0],
radius=radius,
roll=left_roll,
)
head_pos = [pos * axis for pos, axis in zip(right_head_pos, (-1, 1, 1))]
tail_pos = [pos * axis for pos, axis in zip(right_tail_pos, (-1, 1, 1))]
right_bone = bone_add(
armature_data,
base_name + ".R",
Vector((head_pos[0], head_pos[1], head_pos[2])),
Vector((tail_pos[0], tail_pos[1], tail_pos[2])),
parent_bones[1],
radius=radius,
roll=right_roll,
)
return left_bone, right_bone
def x_add(pos_a: Vector, add_x: float) -> Vector:
pos = [p_a + _add for p_a, _add in zip(pos_a, [add_x, 0, 0])]
return Vector((pos[0], pos[1], pos[2]))
def y_add(pos_a: Vector, add_y: float) -> Vector:
pos = [p_a + _add for p_a, _add in zip(pos_a, [0, add_y, 0])]
return Vector((pos[0], pos[1], pos[2]))
def z_add(pos_a: Vector, add_z: float) -> Vector:
pos = [p_a + _add for p_a, _add in zip(pos_a, [0, 0, add_z])]
return Vector((pos[0], pos[1], pos[2]))
def fingers(
armature_data: Armature,
finger_name: str,
proximal_pos: Vector,
finger_len_sum: float,
) -> tuple[
tuple[EditBone, EditBone],
tuple[EditBone, EditBone],
tuple[EditBone, EditBone],
]:
finger_normalize = 1 / (
self.finger_1_2_ratio * self.finger_2_3_ratio
+ self.finger_1_2_ratio
+ 1
)
proximal_finger_len = finger_len_sum * finger_normalize
intermediate_finger_len = (
finger_len_sum * finger_normalize * self.finger_1_2_ratio
)
distal_finger_len = (
finger_len_sum
* finger_normalize
* self.finger_1_2_ratio
* self.finger_2_3_ratio
)
proximal_bones = x_mirror_bones_add(
armature_data,
f"{finger_name}_proximal",
proximal_pos,
x_add(proximal_pos, proximal_finger_len),
hands,
self.hand_size() / 18,
bone_type="arm",
)
intermediate_bones = x_mirror_bones_add(
armature_data,
f"{finger_name}_intermediate",
proximal_bones[0].tail,
x_add(proximal_bones[0].tail, intermediate_finger_len),
proximal_bones,
self.hand_size() / 18,
bone_type="arm",
)
distal_bones = x_mirror_bones_add(
armature_data,
f"{finger_name}_distal",
intermediate_bones[0].tail,
x_add(intermediate_bones[0].tail, distal_finger_len),
intermediate_bones,
self.hand_size() / 18,
bone_type="arm",
)
if self.nail_bone:
x_mirror_bones_add(
armature_data,
f"{finger_name}_nail",
distal_bones[0].tail,
x_add(distal_bones[0].tail, distal_finger_len),
distal_bones,
self.hand_size() / 20,
bone_type="arm",
)
return proximal_bones, intermediate_bones, distal_bones
bpy.ops.object.add(type="ARMATURE", enter_editmode=True, location=(0, 0, 0))
armature = context.object
if not armature:
message = "armature is not created"
raise ValueError(message)
armature_data = armature.data
if not isinstance(armature_data, Armature):
message = "armature data is not an Armature"
raise TypeError(message)
get_armature_extension(armature_data).addon_version = get_addon_version()
bone_dict: dict[str, EditBone] = {}
# bone_type = "leg" or "arm" for roll setting
head_size = self.head_size()
# down side (previously the lower leg ratio of upper leg/lower leg for
# 8-head proportions, later linearly interpolated with age factor for
# 4-head proportions)(breaks if upper leg is too high)
eight_upside_ratio, four_upside_ratio = (
1 - self.leg_length_ratio,
(2.5 / 4) * (1 - self.aging_ratio)
+ (1 - self.leg_length_ratio) * self.aging_ratio,
)
hip_up_down_ratio = (
eight_upside_ratio * (1 - (8 - self.head_ratio) / 4)
+ four_upside_ratio * (8 - self.head_ratio) / 4
)
# Torso
# Groin
body_separate = self.tall * (1 - hip_up_down_ratio)
# Neck length
neck_len = head_size * 2 / 3
# Sacrum (pelvic spine base)
hips_tall = body_separate + head_size * 3 / 4
# Thoracic spine total length # 1/3 of neck is hidden behind the jaw
backbone_len = self.tall - hips_tall - head_size - neck_len / 2
# TODO: Verify the ratio of thoracic spine to vertebrae
# Main flexion point located at the base of the spine, and another flexion
# point located at the base of the thoracic cage
# by Humanoid Doc
spine_len = backbone_len * 5 / 17
root = bone_add(armature_data, "root", Vector((0, 0, 0)), Vector((0, 0, 0.3)))
# Sacrum base
hips = bone_add(
armature_data,
"hips",
Vector((0, 0, body_separate)),
Vector((0, 0, hips_tall)),
root,
roll=0,
)
# Pelvic base -> Thoracic cage base
spine = bone_add(
armature_data, "spine", hips.tail, z_add(hips.tail, spine_len), hips, roll=0
)
# Thoracic cage base -> Neck base
chest = bone_add(
armature_data,
"chest",
spine.tail,
z_add(hips.tail, backbone_len),
spine,
roll=0,
)
neck = bone_add(
armature_data,
"neck",
Vector((0, 0, self.tall - head_size - neck_len / 2)),
Vector((0, 0, self.tall - head_size + neck_len / 2)),
chest,
roll=0,
)
# Half of the neck is hidden behind the jaw
head = bone_add(
armature_data,
"head",
Vector((0, 0, self.tall - head_size + neck_len / 2)),
Vector((0, 0, self.tall)),
neck,
roll=0,
)
# Eyes
eye_depth = self.eye_depth
eyes = x_mirror_bones_add(
armature_data,
"eye",
Vector(
(head_size * self.head_width_ratio / 5, 0, self.tall - head_size / 2)
),
Vector(
(
head_size * self.head_width_ratio / 5,
eye_depth,
self.tall - head_size / 2,
)
),
(head, head),
)
# Legs
leg_width = head_size / 4 * self.leg_width_ratio
leg_size = self.leg_size
leg_bone_length = (body_separate + head_size * 3 / 8 - self.tall * 0.05) / 2
upside_legs = x_mirror_bones_add(
armature_data,
"upper_leg",
x_add(Vector((0, 0, body_separate + head_size * 3 / 8)), leg_width),
x_add(
Vector(
z_add(
Vector((0, 0, body_separate + head_size * 3 / 8)),
-leg_bone_length,
)
),
leg_width,
),
(hips, hips),
radius=leg_width * 0.9,
bone_type="leg",
)
lower_legs = x_mirror_bones_add(
armature_data,
"lower_leg",
upside_legs[0].tail,
Vector((leg_width, 0, self.tall * 0.05)),
upside_legs,
radius=leg_width * 0.9,
bone_type="leg",
)
foots = x_mirror_bones_add(
armature_data,
"foot",
lower_legs[0].tail,
Vector((leg_width, -leg_size * (2 / 3), 0)),
lower_legs,
radius=leg_width * 0.9,
bone_type="leg",
)
toes = x_mirror_bones_add(
armature_data,
"toes",
foots[0].tail,
Vector((leg_width, -leg_size, 0)),
foots,
radius=leg_width * 0.5,
bone_type="leg",
)
# Shoulder to fingers
shoulder_in_pos = self.shoulder_in_width / 2
shoulder_parent = chest
shoulders = x_mirror_bones_add(
armature_data,
"shoulder",
x_add(shoulder_parent.tail, shoulder_in_pos),
x_add(shoulder_parent.tail, shoulder_in_pos + self.shoulder_width),
(shoulder_parent, shoulder_parent),
radius=self.hand_size() * 0.4,
bone_type="arm",
)
arm_length = (
head_size
* (1 * (1 - (self.head_ratio - 6) / 2) + 1.5 * ((self.head_ratio - 6) / 2))
* self.arm_length_ratio
)
arms = x_mirror_bones_add(
armature_data,
"upper_arm",
shoulders[0].tail,
x_add(shoulders[0].tail, arm_length),
shoulders,
radius=self.hand_size() * 0.4,
bone_type="arm",
)
# When making a fist, it becomes about half the size of an open palm.
# When making a fist, the length of the forearm including the hand
# is roughly the same as the length of the upper arm,
# but it breaks down if the hand is too big.
forearm_length = max(arm_length - self.hand_size() / 2, arm_length * 0.8)
forearms = x_mirror_bones_add(
armature_data,
"lower_arm",
arms[0].tail,
x_add(arms[0].tail, forearm_length),
arms,
radius=self.hand_size() * 0.4,
bone_type="arm",
)
hands = x_mirror_bones_add(
armature_data,
"hand",
forearms[0].tail,
x_add(forearms[0].tail, self.hand_size() / 2),
forearms,
radius=self.hand_size() / 4,
bone_type="arm",
)
finger_y_offset = -self.hand_size() / 16
thumbs = fingers(
armature_data,
"thumb",
y_add(hands[0].head, finger_y_offset * 3),
self.hand_size() / 2,
)
mats = [
Matrix.Translation(vec)
for vec in [thumbs[0][i].matrix.translation for i in [0, 1]]
]
for j in range(3):
for n, angle in enumerate([-45, 45]):
thumbs[j][n].transform(mats[n].inverted(), scale=False, roll=False)
thumbs[j][n].transform(Matrix.Rotation(radians(angle), 4, "Z"))
thumbs[j][n].transform(mats[n], scale=False, roll=False)
thumbs[j][n].roll = 0
index_fingers = fingers(
armature_data,
"index",
y_add(hands[0].tail, finger_y_offset * 3),
(self.hand_size() / 2) - (1 / 2.3125) * (self.hand_size() / 2) / 3,
)
middle_fingers = fingers(
armature_data,
"middle",
y_add(hands[0].tail, finger_y_offset),
self.hand_size() / 2,
)
ring_fingers = fingers(
armature_data,
"ring",
y_add(hands[0].tail, -finger_y_offset),
(self.hand_size() / 2) - (1 / 2.3125) * (self.hand_size() / 2) / 3,
)
little_fingers = fingers(
armature_data,
"little",
y_add(hands[0].tail, -finger_y_offset * 3),
((self.hand_size() / 2) - (1 / 2.3125) * (self.hand_size() / 2) / 3)
* ((1 / 2.3125) + (1 / 2.3125) * 0.75),
)
body_dict = {
"hips": hips.name,
"spine": spine.name,
"chest": chest.name,
"neck": neck.name,
"head": head.name,
}
left_right_body_dict = {
f"{left_right}{bone_name}": bones[lr].name
for bone_name, bones in {
"Eye": eyes,
"UpperLeg": upside_legs,
"LowerLeg": lower_legs,
"Foot": foots,
"Toes": toes,
"Shoulder": shoulders,
"UpperArm": arms,
"LowerArm": forearms,
"Hand": hands,
}.items()
for lr, left_right in enumerate(["left", "right"])
}
# VRM finger like name key
fingers_dict = {
f"{left_right}{finger_name}{position}": finger[i][lr].name
for finger_name, finger in zip(
["Thumb", "Index", "Middle", "Ring", "Little"],
[thumbs, index_fingers, middle_fingers, ring_fingers, little_fingers],
)
for i, position in enumerate(["Proximal", "Intermediate", "Distal"])
for lr, left_right in enumerate(["left", "right"])
}
# VRM bone name : blender bone name
bone_name_all_dict: dict[str, str] = {}
bone_name_all_dict.update(body_dict)
bone_name_all_dict.update(left_right_body_dict)
bone_name_all_dict.update(fingers_dict)
armature_data = armature.data
if isinstance(armature_data, Armature):
connect_parent_tail_and_child_head_if_very_close_position(armature_data)
context.scene.view_layers.update()
bpy.ops.object.mode_set(mode="OBJECT")
context.scene.view_layers.update()
return armature, bone_name_all_dict
def setup_as_vrm(
self, context: Context, armature: Object, compare_dict: dict[str, str]
) -> None:
armature_data = armature.data
if not isinstance(armature_data, Armature):
message = "armature data is not an Armature"
raise TypeError(message)
Vrm0HumanoidPropertyGroup.fixup_human_bones(armature)
ext = get_armature_extension(armature_data)
vrm0_humanoid = ext.vrm0.humanoid
vrm1_humanoid = ext.vrm1.humanoid
if not self.skip_heavy_armature_setup:
for vrm_bone_name, bpy_bone_name in compare_dict.items():
for human_bone in vrm0_humanoid.human_bones:
if human_bone.bone == vrm_bone_name:
human_bone.node.bone_name = bpy_bone_name
break
vrm0_humanoid.pose = vrm0_humanoid.POSE_REST_POSITION_POSE.identifier
vrm1_humanoid.pose = vrm1_humanoid.POSE_REST_POSITION_POSE.identifier
self.make_extension_setting_and_metas(
armature,
offset_from_head_bone=(-self.eye_depth, self.head_size() / 6, 0),
)
if not self.skip_heavy_armature_setup:
migration.migrate(context, armature.name)
@classmethod
def make_extension_setting_and_metas(
cls,
armature: Object,
offset_from_head_bone: tuple[float, float, float] = (0, 0, 0),
) -> None:
armature_data = armature.data
if not isinstance(armature_data, Armature):
return
vrm0 = get_armature_extension(armature_data).vrm0
vrm1 = get_armature_extension(armature_data).vrm1
vrm0.first_person.first_person_bone.bone_name = "head"
vrm0.first_person.first_person_bone_offset = (0, 0, 0.06)
vrm1.look_at.offset_from_head_bone = offset_from_head_bone
vrm0.first_person.look_at_horizontal_inner.y_range = 8
vrm0.first_person.look_at_horizontal_outer.y_range = 12
vrm0.meta.author = "undefined"
vrm0.meta.contact_information = "undefined"
vrm0.meta.other_license_url = "undefined"
vrm0.meta.other_permission_url = "undefined"
vrm0.meta.reference = "undefined"
vrm0.meta.title = "undefined"
vrm0.meta.version = "undefined"
for preset in Vrm0BlendShapeGroupPropertyGroup.preset_name_enum:
if (
preset.identifier
== Vrm0BlendShapeGroupPropertyGroup.PRESET_NAME_UNKNOWN.identifier
):
continue
blend_shape_group = vrm0.blend_shape_master.blend_shape_groups.add()
blend_shape_group.name = preset.name.replace(" ", "")
blend_shape_group.preset_name = preset.identifier
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
skip_heavy_armature_setup: bool # type: ignore[no-redef]
wip_with_template_mesh: bool # type: ignore[no-redef]
tall: float # type: ignore[no-redef]
head_ratio: float # type: ignore[no-redef]
head_width_ratio: float # type: ignore[no-redef]
aging_ratio: float # type: ignore[no-redef]
eye_depth: float # type: ignore[no-redef]
shoulder_in_width: float # type: ignore[no-redef]
shoulder_width: float # type: ignore[no-redef]
arm_length_ratio: float # type: ignore[no-redef]
hand_ratio: float # type: ignore[no-redef]
finger_1_2_ratio: float # type: ignore[no-redef]
finger_2_3_ratio: float # type: ignore[no-redef]
nail_bone: bool # type: ignore[no-redef]
leg_length_ratio: float # type: ignore[no-redef]
leg_width_ratio: float # type: ignore[no-redef]
leg_size: float # type: ignore[no-redef]
custom_property_name: str # type: ignore[no-redef]
def connect_parent_tail_and_child_head_if_very_close_position(
armature: Armature,
) -> None:
bones = [bone for bone in armature.edit_bones if not bone.parent]
while bones:
bone = bones.pop()
children_by_distance = sorted(
bone.children,
key=lambda child: (child.parent.tail - child.head).length_squared
if child.parent
else 0.0,
)
for child in children_by_distance:
if (bone.tail - child.head).length < AUTO_BONE_CONNECTION_DISTANCE and (
bone.head - child.head
).length >= MIN_BONE_LENGTH:
bone.tail = child.head
break
bones.extend(bone.children)
bones = [bone for bone in armature.edit_bones if not bone.parent]
while bones:
bone = bones.pop()
for child in bone.children:
if (bone.tail - child.head).length < float_info.epsilon:
child.use_connect = True
bones.append(child)

258
editor/migration.py Normal file
View File

@@ -0,0 +1,258 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import functools
from dataclasses import dataclass
from typing import Final, Optional
import bpy
from bpy.types import Armature, Context
from ..common import ops
from ..common.logger import get_logger
from ..common.preferences import get_preferences
from ..common.version import get_addon_version
from .extension import (
VrmAddonArmatureExtensionPropertyGroup,
VrmAddonSceneExtensionPropertyGroup,
get_armature_extension,
)
from .mtoon1 import migration as mtoon1_migration
from .spring_bone1 import migration as spring_bone1_migration
from .vrm0 import migration as vrm0_migration
from .vrm1 import migration as vrm1_migration
logger = get_logger(__name__)
@dataclass
class State:
blend_file_compatibility_warning_shown: bool = False
blend_file_addon_compatibility_warning_shown: bool = False
state: Final = State()
def is_unnecessary(armature_data: Armature) -> bool:
ext = get_armature_extension(armature_data)
return tuple(
ext.addon_version
) >= get_addon_version() and vrm0_migration.is_unnecessary(ext.vrm0)
def defer_migrate(armature_object_name: str) -> bool:
context = bpy.context
armature = context.blend_data.objects.get(armature_object_name)
if not armature:
return False
armature_data = armature.data
if not isinstance(armature_data, Armature):
return False
if is_unnecessary(armature_data):
return True
bpy.app.timers.register(
functools.partial(
migrate_timer_callback,
armature_object_name,
)
)
return False
def migrate_timer_callback(armature_object_name: str) -> None:
"""Match the type of migrate() to bpy.app.timers.register."""
context = bpy.context # Context cannot span frames, so get it anew
migrate(context, armature_object_name)
def migrate(context: Optional[Context], armature_object_name: str) -> bool:
if context is None:
context = bpy.context
armature = context.blend_data.objects.get(armature_object_name)
if not armature:
return False
armature_data = armature.data
if not isinstance(armature_data, Armature):
return False
if is_unnecessary(armature_data):
return True
ext = get_armature_extension(armature_data)
vrm0_migration.migrate(context, ext.vrm0, armature)
vrm1_migration.migrate(context, ext.vrm1, armature)
spring_bone1_migration.migrate(context, armature)
if (
ext.has_vrm_model_metadata(armature)
and tuple(ext.addon_version) < (3, 14, 0)
and not ext.get("spec_version")
):
ext.spec_version = ext.SPEC_VERSION_VRM0
updated_addon_version = get_addon_version()
logger.info(
"Upgrade armature %s %s to %s",
armature_object_name,
tuple(ext.addon_version),
updated_addon_version,
)
ext.addon_version = updated_addon_version
return True
def migrate_all_objects(
context: Context,
*,
skip_non_migrated_armatures: bool = False,
show_progress: bool = False,
) -> None:
for obj in context.blend_data.objects:
if obj.type == "ARMATURE" and isinstance(armature_data := obj.data, Armature):
if skip_non_migrated_armatures:
ext = get_armature_extension(armature_data)
if (
tuple(ext.addon_version)
== VrmAddonArmatureExtensionPropertyGroup.INITIAL_ADDON_VERSION
):
continue
migrate(context, obj.name)
VrmAddonSceneExtensionPropertyGroup.update_vrm0_material_property_names(
context, context.scene.name
)
mtoon1_migration.migrate(context, show_progress=show_progress)
validate_blend_file_compatibility(context)
validate_blend_file_addon_compatibility(context)
preferences = get_preferences(context)
updated_addon_version = get_addon_version()
logger.debug(
"Upgrade preferences %s to %s",
tuple(preferences.addon_version),
updated_addon_version,
)
if tuple(preferences.addon_version) != preferences.INITIAL_ADDON_VERSION and tuple(
preferences.addon_version
) < (2, 34, 0):
preferences.enable_advanced_preferences = True
preferences.export_gltf_animations = True
preferences.addon_version = updated_addon_version
def validate_blend_file_compatibility(context: Context) -> None:
"""Warn when attempting to edit a file created in newer Blender with older Blender.
Due to add-on version support issues, there are often reports of users trying to
edit files created in newer Blender with older Blender, which can cause shape keys
to break. This warning alerts users to be careful.
"""
if not context.blend_data.filepath:
return
if not have_vrm_model(context):
return
blend_file_major_minor_version = (
context.blend_data.version[0],
context.blend_data.version[1],
)
current_major_minor_version = (bpy.app.version[0], bpy.app.version[1])
if blend_file_major_minor_version <= current_major_minor_version:
return
file_version_str = ".".join(map(str, blend_file_major_minor_version))
app_version_str = ".".join(map(str, current_major_minor_version))
logger.error(
"Opening incompatible file: file_blender_version=%s running_blender_version=%s",
file_version_str,
app_version_str,
)
if not state.blend_file_compatibility_warning_shown:
state.blend_file_compatibility_warning_shown = True
# Use timer because dialog disappears automatically if not executed with
# timer in Blender 4.2.0
bpy.app.timers.register(
functools.partial(
show_blend_file_compatibility_warning,
file_version_str,
app_version_str,
),
first_interval=0.1,
)
def show_blend_file_compatibility_warning(file_version: str, app_version: str) -> None:
ops.vrm.show_blend_file_compatibility_warning(
"INVOKE_DEFAULT",
file_version=file_version,
app_version=app_version,
)
def validate_blend_file_addon_compatibility(context: Context) -> None:
"""Warn when attempting to edit a file created with a newer VRM add-on.
This warning is for files using an older VRM add-on.
"""
if not context.blend_data.filepath:
return
installed_addon_version = get_addon_version()
# TODO: It might be better to store the version in Scene or similar
up_to_date = True
file_addon_version: tuple[int, ...] = (0, 0, 0)
for armature in context.blend_data.armatures:
file_addon_version = tuple(get_armature_extension(armature).addon_version)
if file_addon_version > installed_addon_version:
up_to_date = False
break
if up_to_date:
return
file_addon_version_str = ".".join(map(str, file_addon_version))
installed_addon_version_str = ".".join(map(str, installed_addon_version))
logger.error(
"Opening incompatible VRM add-on version: file=%s installed=%s",
file_addon_version_str,
installed_addon_version_str,
)
if not state.blend_file_compatibility_warning_shown:
state.blend_file_compatibility_warning_shown = True
# Use timer because dialog disappears automatically if not executed with
# timer in Blender 4.2.0
bpy.app.timers.register(
functools.partial(
show_blend_file_addon_compatibility_warning,
file_addon_version_str,
installed_addon_version_str,
),
first_interval=0.1,
)
def show_blend_file_addon_compatibility_warning(
file_addon_version: str, installed_addon_version: str
) -> None:
ops.vrm.show_blend_file_addon_compatibility_warning(
"INVOKE_DEFAULT",
file_addon_version=file_addon_version,
installed_addon_version=installed_addon_version,
)
def have_vrm_model(context: Context) -> bool:
return any(
map(
VrmAddonArmatureExtensionPropertyGroup.has_vrm_model_metadata,
context.blend_data.objects,
)
)

View File

@@ -0,0 +1 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

24
editor/mtoon1/handler.py Normal file
View File

@@ -0,0 +1,24 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import bpy
from bpy.app.handlers import persistent
from ...common.logger import get_logger
from ...common.scene_watcher import trigger_scene_watcher
from . import migration
from .scene_watcher import MToon1AutoSetup, OutlineUpdater
logger = get_logger(__name__)
@persistent
def depsgraph_update_pre(_unused: object) -> None:
trigger_scene_watcher(MToon1AutoSetup)
if bpy.app.version < (3, 3):
return
trigger_scene_watcher(OutlineUpdater)
@persistent
def load_post(_unsed: object) -> None:
migration.state.material_blender_4_2_warning_shown = False

529
editor/mtoon1/migration.py Normal file
View File

@@ -0,0 +1,529 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import functools
from dataclasses import dataclass
from typing import Final, Optional
import bpy
from bpy.types import (
Context,
Image,
Material,
NodeReroute,
PropertyGroup,
ShaderNodeGroup,
ShaderNodeTexImage,
)
from ...common import convert, ops, shader, version
from ...common.gl import GL_LINEAR, GL_NEAREST
from ...common.logger import get_logger
from ...common.progress import create_progress
from .. import search
from ..extension import get_material_extension
from .property_group import (
GL_LINEAR_IMAGE_INTERPOLATIONS,
IMAGE_INTERPOLATION_CLOSEST,
IMAGE_INTERPOLATION_LINEAR,
Mtoon1MaterialPropertyGroup,
Mtoon1SamplerPropertyGroup,
Mtoon1TextureInfoPropertyGroup,
Mtoon1VrmcMaterialsMtoonPropertyGroup,
reset_shader_node_group,
)
TextureInfoBackup = Mtoon1TextureInfoPropertyGroup.TextureInfoBackup
logger = get_logger(__name__)
@dataclass
class State:
material_blender_4_2_warning_shown: bool = False
state: Final = State()
def show_material_blender_4_2_warning_delay(material_name_lines: str) -> None:
ops.vrm.show_material_blender_4_2_warning(
"INVOKE_DEFAULT",
material_name_lines=material_name_lines,
)
def migrate(context: Context, *, show_progress: bool = False) -> None:
blender_4_2_migrated_material_names: list[str] = []
with create_progress(context, show_progress=show_progress) as progress:
for material_index, material in enumerate(context.blend_data.materials):
if not material:
continue
migrate_material(context, material, blender_4_2_migrated_material_names)
progress.update(float(material_index) / len(context.blend_data.materials))
progress.update(1)
if (
blender_4_2_migrated_material_names
and tuple(context.blend_data.version) < (4, 2)
and bpy.app.version >= (4, 2)
):
logger.warning(
"Migrating Materials from blender version data=%s app=%s",
context.blend_data.version,
bpy.app.version,
)
if not state.material_blender_4_2_warning_shown:
state.material_blender_4_2_warning_shown = True
# In Blender 4.2.0, if you don't run it with a timer, the dialog will
# disappear automatically.
bpy.app.timers.register(
functools.partial(
show_material_blender_4_2_warning_delay,
"\n".join(blender_4_2_migrated_material_names),
),
first_interval=0.1,
)
def migrate_material(
context: Context,
material: Material,
blender_4_2_migrated_material_names: list[str],
) -> None:
_, legacy_legacy_shader_name = search.legacy_shader_node(material)
if legacy_legacy_shader_name in search.LEGACY_SHADER_NAMES:
# Since old shader node groups are not compatible with Blender 4.2 as they are,
# always warn when upgrading to Blender 4.2 or later.
blender_4_2_migrated_material_names.append(material.name)
return
if bpy.app.version < (5, 0, 0) and not material.use_nodes:
return
node_tree = material.node_tree
if not node_tree:
return
mtoon1 = get_material_extension(material).mtoon1
if not mtoon1.get("enabled"):
return
vrmc_materials_mtoon = mtoon1.extensions.vrmc_materials_mtoon
if vrmc_materials_mtoon.get("is_outline_material"):
return
addon_version = convert.float3_or(mtoon1.get("addon_version"), (0, 0, 0))
if addon_version < (2, 16, 4):
# https://github.com/saturday06/VRM-Addon-for-Blender/blob/2_10_0/io_scene_vrm/editor/mtoon1/property_group.py#L1658-L1683
surface_node_name = "Mtoon1Material.MaterialOutputSurfaceIn"
surface_node = node_tree.nodes.get(surface_node_name)
if not isinstance(surface_node, NodeReroute):
return
connected = False
surface_socket = surface_node.outputs[0]
for link in node_tree.links:
if (
link.from_socket == surface_socket
and link.to_socket
and link.to_socket.node
and link.to_socket.node.type == "OUTPUT_MATERIAL"
):
connected = True
break
if not connected:
return
else:
# https://github.com/saturday06/VRM-Addon-for-Blender/blob/2_16_4/io_scene_vrm/editor/mtoon1/property_group.py#L1913-L1929
group_node = node_tree.nodes.get("Mtoon1Material.Mtoon1Output")
if not isinstance(group_node, ShaderNodeGroup):
return
if not group_node.node_tree:
return
if group_node.node_tree.name != "VRM Add-on MToon 1.0 Output Revision 1":
return
if addon_version < (2, 20, 50):
migrate_sampler_filter_node(material)
alpha_mode: Optional[str] = None
alpha_cutoff: Optional[float] = None
if addon_version < (2, 20, 55):
blender_4_2_migrated_material_names.append(material.name)
alpha_cutoff = material.alpha_threshold
blend_method = material.blend_method
if blend_method in ["BLEND", "HASHED"]:
alpha_mode = Mtoon1MaterialPropertyGroup.ALPHA_MODE_BLEND.identifier
elif blend_method == "CLIP":
alpha_mode = Mtoon1MaterialPropertyGroup.ALPHA_MODE_MASK.identifier
else:
alpha_mode = Mtoon1MaterialPropertyGroup.ALPHA_MODE_OPAQUE.identifier
base_color_factor: Optional[tuple[float, float, float, float]] = None
base_color_texture_backup: Optional[TextureInfoBackup] = None
normal_texture_backup: Optional[TextureInfoBackup] = None
normal_texture_scale: Optional[float] = None
emissive_texture_backup: Optional[TextureInfoBackup] = None
emissive_factor: Optional[tuple[float, float, float]] = None
emissive_strength: Optional[float] = None
transparent_with_z_write: Optional[bool] = None
render_queue_offset_number: Optional[int] = None
shade_multiply_texture_backup: Optional[TextureInfoBackup] = None
shade_color_factor: Optional[tuple[float, float, float]] = None
shading_shift_texture_backup: Optional[TextureInfoBackup] = None
shading_shift_texture_scale: Optional[float] = None
shading_shift_factor: Optional[float] = None
shading_toony_factor: Optional[float] = None
gi_equalization_factor: Optional[float] = None
matcap_factor: Optional[tuple[float, float, float]] = None
matcap_texture_backup: Optional[TextureInfoBackup] = None
parametric_rim_color_factor: Optional[tuple[float, float, float]] = None
rim_multiply_texture_backup: Optional[TextureInfoBackup] = None
rim_lighting_mix_factor: Optional[float] = None
parametric_rim_fresnel_power_factor: Optional[float] = None
parametric_rim_lift_factor: Optional[float] = None
outline_width_mode: Optional[str] = None
outline_width_factor: Optional[float] = None
outline_width_multiply_texture_backup: Optional[TextureInfoBackup] = None
outline_color_factor: Optional[tuple[float, float, float]] = None
outline_lighting_mix_factor: Optional[float] = None
uv_animation_mask_texture_backup: Optional[TextureInfoBackup] = None
uv_animation_scroll_x_speed_factor: Optional[float] = None
uv_animation_scroll_y_speed_factor: Optional[float] = None
uv_animation_rotation_speed_factor: Optional[float] = None
if addon_version < (2, 20, 62):
pbr_metallic_roughness = mtoon1.pbr_metallic_roughness
base_color_factor = convert.float4_or_none(
pbr_metallic_roughness.get("base_color_factor")
)
base_color_texture_backup = backup_texture_info(
pbr_metallic_roughness.base_color_texture
)
normal_texture = mtoon1.normal_texture
normal_texture_backup = backup_texture_info(normal_texture)
normal_texture_scale = convert.float_or_none(normal_texture.get("scale"))
emissive_factor = convert.float3_or_none(mtoon1.get("emissive_factor"))
emissive_texture_backup = backup_texture_info(mtoon1.emissive_texture)
emissive_strength = convert.float_or_none(
mtoon1.extensions.khr_materials_emissive_strength.get("emissive_strength")
)
transparent_with_z_write_object = vrmc_materials_mtoon.get(
"transparent_with_z_write"
)
if isinstance(transparent_with_z_write_object, int):
transparent_with_z_write = bool(transparent_with_z_write_object)
render_queue_offset_number_object = vrmc_materials_mtoon.get(
"render_queue_offset_number"
)
if isinstance(render_queue_offset_number_object, int):
render_queue_offset_number = render_queue_offset_number_object
shade_multiply_texture_backup = backup_texture_info(
vrmc_materials_mtoon.shade_multiply_texture
)
shade_color_factor = convert.float3_or_none(
vrmc_materials_mtoon.get("shade_color_factor")
)
shading_shift_texture = vrmc_materials_mtoon.shading_shift_texture
shading_shift_texture_backup = backup_texture_info(shading_shift_texture)
shading_shift_texture_scale = convert.float_or_none(
shading_shift_texture.get("scale")
)
shading_shift_factor = convert.float_or_none(
vrmc_materials_mtoon.get("shading_shift_factor")
)
shading_toony_factor = convert.float_or_none(
vrmc_materials_mtoon.get("shading_toony_factor")
)
matcap_factor = convert.float3_or_none(
vrmc_materials_mtoon.get("matcap_factor")
)
matcap_texture_backup = backup_texture_info(vrmc_materials_mtoon.matcap_texture)
gi_equalization_factor = convert.float_or_none(
vrmc_materials_mtoon.get("gi_equalization_factor")
)
parametric_rim_color_factor = convert.float3_or_none(
vrmc_materials_mtoon.get("parametric_rim_color_factor")
)
rim_multiply_texture_backup = backup_texture_info(
vrmc_materials_mtoon.rim_multiply_texture
)
rim_lighting_mix_factor = convert.float_or_none(
vrmc_materials_mtoon.get("rim_lighting_mix_factor")
)
parametric_rim_fresnel_power_factor = convert.float_or_none(
vrmc_materials_mtoon.get("parametric_rim_fresnel_power_factor")
)
parametric_rim_lift_factor = convert.float_or_none(
vrmc_materials_mtoon.get("parametric_rim_lift_factor")
)
outline_width_mode_number = vrmc_materials_mtoon.get("outline_width_mode")
if isinstance(outline_width_mode_number, int):
outline_width_mode_item = (
Mtoon1VrmcMaterialsMtoonPropertyGroup.outline_width_mode_enum
)
outline_width_mode = outline_width_mode_item.value_to_identifier(
outline_width_mode_number,
Mtoon1VrmcMaterialsMtoonPropertyGroup.OUTLINE_WIDTH_MODE_NONE.identifier,
)
outline_width_factor = convert.float_or_none(
vrmc_materials_mtoon.get("outline_width_factor")
)
outline_width_multiply_texture_backup = backup_texture_info(
vrmc_materials_mtoon.outline_width_multiply_texture
)
outline_color_factor = convert.float3_or_none(
vrmc_materials_mtoon.get("outline_color_factor")
)
outline_lighting_mix_factor = convert.float_or_none(
vrmc_materials_mtoon.get("outline_lighting_mix_factor")
)
uv_animation_mask_texture_backup = backup_texture_info(
vrmc_materials_mtoon.uv_animation_mask_texture
)
uv_animation_scroll_x_speed_factor = convert.float_or_none(
vrmc_materials_mtoon.get("uv_animation_scroll_x_speed_factor")
)
uv_animation_scroll_y_speed_factor = convert.float_or_none(
vrmc_materials_mtoon.get("uv_animation_scroll_y_speed_factor")
)
uv_animation_rotation_speed_factor = convert.float_or_none(
vrmc_materials_mtoon.get("uv_animation_rotation_speed_factor")
)
if (
addon_version < shader.LAST_MODIFIED_VERSION
# Blender 4.2 has a node specification change, so it will be forcibly reset.
or (bpy.app.version >= (4, 2) and tuple(context.blend_data.version) < (4, 2))
):
reset_shader_node_group(
context, material, reset_material_node_tree=True, reset_node_groups=False
)
# From this point on, you can write code that assumes the shader node is up to date.
vrmc_materials_mtoon = mtoon1.extensions.vrmc_materials_mtoon
if alpha_mode is not None:
mtoon1.alpha_mode = alpha_mode
if alpha_cutoff is not None:
mtoon1.alpha_cutoff = alpha_cutoff
if base_color_factor is not None:
mtoon1.pbr_metallic_roughness.base_color_factor = base_color_factor
if base_color_texture_backup is not None:
mtoon1.pbr_metallic_roughness.base_color_texture.restore(
base_color_texture_backup
)
if normal_texture_backup is not None:
mtoon1.normal_texture.restore(normal_texture_backup)
if normal_texture_scale is not None:
mtoon1.normal_texture.scale = normal_texture_scale
if emissive_texture_backup is not None:
mtoon1.emissive_texture.restore(emissive_texture_backup)
if emissive_factor is not None:
mtoon1.emissive_factor = emissive_factor
if emissive_strength is not None:
mtoon1.extensions.khr_materials_emissive_strength.emissive_strength = (
emissive_strength
)
if transparent_with_z_write is not None:
vrmc_materials_mtoon.transparent_with_z_write = transparent_with_z_write
if render_queue_offset_number is not None:
vrmc_materials_mtoon.render_queue_offset_number = render_queue_offset_number
if shade_multiply_texture_backup is not None:
vrmc_materials_mtoon.shade_multiply_texture.restore(
shade_multiply_texture_backup
)
if shade_color_factor is not None:
vrmc_materials_mtoon.shade_color_factor = shade_color_factor
if shading_shift_texture_backup is not None:
vrmc_materials_mtoon.shading_shift_texture.restore(shading_shift_texture_backup)
if shading_shift_factor is not None:
vrmc_materials_mtoon.shading_shift_factor = shading_shift_factor
if shading_shift_texture_scale is not None:
vrmc_materials_mtoon.shading_shift_texture.scale = shading_shift_texture_scale
if shading_toony_factor is not None:
vrmc_materials_mtoon.shading_toony_factor = shading_toony_factor
if gi_equalization_factor is not None:
vrmc_materials_mtoon.gi_equalization_factor = gi_equalization_factor
if matcap_factor is not None:
vrmc_materials_mtoon.matcap_factor = matcap_factor
if matcap_texture_backup is not None:
vrmc_materials_mtoon.matcap_texture.restore(matcap_texture_backup)
if parametric_rim_color_factor is not None:
vrmc_materials_mtoon.parametric_rim_color_factor = parametric_rim_color_factor
if rim_multiply_texture_backup is not None:
vrmc_materials_mtoon.rim_multiply_texture.restore(rim_multiply_texture_backup)
if rim_lighting_mix_factor is not None:
vrmc_materials_mtoon.rim_lighting_mix_factor = rim_lighting_mix_factor
if parametric_rim_fresnel_power_factor is not None:
vrmc_materials_mtoon.parametric_rim_fresnel_power_factor = (
parametric_rim_fresnel_power_factor
)
if parametric_rim_lift_factor is not None:
vrmc_materials_mtoon.parametric_rim_lift_factor = parametric_rim_lift_factor
if outline_width_mode is not None:
vrmc_materials_mtoon.outline_width_mode = outline_width_mode
if outline_width_factor is not None:
vrmc_materials_mtoon.outline_width_factor = outline_width_factor
if outline_width_multiply_texture_backup is not None:
vrmc_materials_mtoon.outline_width_multiply_texture.restore(
outline_width_multiply_texture_backup
)
if outline_color_factor is not None:
vrmc_materials_mtoon.outline_color_factor = outline_color_factor
if outline_lighting_mix_factor is not None:
vrmc_materials_mtoon.outline_lighting_mix_factor = outline_lighting_mix_factor
if uv_animation_mask_texture_backup is not None:
vrmc_materials_mtoon.uv_animation_mask_texture.restore(
uv_animation_mask_texture_backup
)
if uv_animation_scroll_x_speed_factor is not None:
vrmc_materials_mtoon.uv_animation_scroll_x_speed_factor = (
uv_animation_scroll_x_speed_factor
)
if uv_animation_scroll_y_speed_factor is not None:
vrmc_materials_mtoon.uv_animation_scroll_y_speed_factor = (
uv_animation_scroll_y_speed_factor
)
if uv_animation_rotation_speed_factor is not None:
vrmc_materials_mtoon.uv_animation_rotation_speed_factor = (
uv_animation_rotation_speed_factor
)
mtoon1.setup_drivers()
updated_addon_version = version.get_addon_version()
if tuple(mtoon1.addon_version) != updated_addon_version:
mtoon1.addon_version = updated_addon_version
def backup_texture_info(texture_info: object) -> Optional[TextureInfoBackup]:
if not isinstance(texture_info, PropertyGroup):
return None
source: Optional[Image] = None
wrap_s = Mtoon1SamplerPropertyGroup.WRAP_DEFAULT.identifier
wrap_t = Mtoon1SamplerPropertyGroup.WRAP_DEFAULT.identifier
offset_x = 0.0
offset_y = 0.0
scale_x = 1.0
scale_y = 1.0
index = getattr(texture_info, "index", None)
if isinstance(index, PropertyGroup):
source_object = index.get("source")
if isinstance(source_object, Image):
source = source_object
sampler = getattr(index, "sampler", None)
if isinstance(sampler, PropertyGroup):
wrap_s_number = sampler.get("wrap_s")
if isinstance(wrap_s_number, int):
wrap_s = Mtoon1SamplerPropertyGroup.wrap_enum.value_to_identifier(
wrap_s_number,
wrap_s,
)
wrap_t_number = sampler.get("wrap_t")
if isinstance(wrap_t_number, int):
wrap_t = Mtoon1SamplerPropertyGroup.wrap_enum.value_to_identifier(
wrap_t_number,
wrap_t,
)
extensions = getattr(texture_info, "extensions", None)
if isinstance(extensions, PropertyGroup):
khr_texture_transform = getattr(extensions, "khr_texture_transform", None)
if isinstance(khr_texture_transform, PropertyGroup):
offset = convert.float2_or_none(khr_texture_transform.get("offset"))
if offset is not None:
offset_x, offset_y = offset
scale = convert.float2_or_none(khr_texture_transform.get("scale"))
if scale is not None:
scale_x, scale_y = scale
return TextureInfoBackup(
source=source,
mag_filter=None,
min_filter=None,
wrap_s=wrap_s,
wrap_t=wrap_t,
offset=(offset_x, offset_y),
scale=(scale_x, scale_y),
)
def migrate_sampler_filter_node(material: Material) -> None:
node_tree = material.node_tree
if not node_tree:
return
mtoon1 = get_material_extension(material).mtoon1
for node_name, attrs in [
(
"Mtoon1BaseColorTexture.Image",
("pbr_metallic_roughness", "base_color_texture"),
),
("Mtoon1EmissiveTexture.Image", ("emissive_texture",)),
("Mtoon1NormalTexture.Image", ("normal_texture",)),
(
"Mtoon1ShadeMultiplyTexture.Image",
("extensions", "vrmc_materials_mtoon", "shade_multiply_texture"),
),
(
"Mtoon1ShadingShiftTexture.Image",
("extensions", "vrmc_materials_mtoon", "shading_shift_texture"),
),
(
"Mtoon1OutlineWidthMultiplyTexture.Image",
(
"extensions",
"vrmc_materials_mtoon",
"outline_width_multiply_texture",
),
),
(
"Mtoon1UvAnimationMaskTexture.Image",
("extensions", "vrmc_materials_mtoon", "uv_animation_mask_texture"),
),
(
"Mtoon1MatcapTexture.Image",
("extensions", "vrmc_materials_mtoon", "matcap_texture"),
),
(
"Mtoon1RimMultiplyTexture.Image",
("extensions", "vrmc_materials_mtoon", "rim_multiply_texture"),
),
]:
sampler = functools.reduce(
lambda prop, attr: getattr(prop, attr, None),
(*attrs, "index", "sampler"),
mtoon1,
)
if not isinstance(sampler, PropertyGroup):
continue
mag_filter = sampler.get("mag_filter")
if (
not isinstance(mag_filter, int)
or mag_filter not in Mtoon1SamplerPropertyGroup.mag_filter_enum.values()
):
continue
node = node_tree.nodes.get(node_name)
if not isinstance(node, ShaderNodeTexImage):
continue
if (
mag_filter == GL_NEAREST
and node.interpolation != IMAGE_INTERPOLATION_CLOSEST
):
node.interpolation = IMAGE_INTERPOLATION_CLOSEST
if (
mag_filter == GL_LINEAR
and node.interpolation not in GL_LINEAR_IMAGE_INTERPOLATIONS
):
node.interpolation = IMAGE_INTERPOLATION_LINEAR

1158
editor/mtoon1/ops.py Normal file

File diff suppressed because it is too large Load Diff

447
editor/mtoon1/panel.py Normal file
View File

@@ -0,0 +1,447 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from typing import Optional
import bpy
from bpy.app.translations import pgettext
from bpy.types import Context, Panel, PropertyGroup, UILayout
from ...common.logger import get_logger
from .. import search
from ..extension import get_material_extension
from ..ops import VRM_OT_open_url_in_web_browser, layout_operator
from .ops import (
VRM_OT_import_mtoon1_texture_image_file,
VRM_OT_reset_mtoon1_material_shader_node_tree,
)
from .property_group import (
Mtoon0TexturePropertyGroup,
Mtoon1MaterialPropertyGroup,
Mtoon1TextureInfoPropertyGroup,
)
logger = get_logger(__name__)
def draw_texture_info(
material_name: str,
ext: Mtoon1MaterialPropertyGroup,
parent_layout: UILayout,
base_property_group: PropertyGroup,
texture_info_attr_name: str,
color_factor_attr_name: Optional[str] = None,
*,
is_vrm0: bool,
) -> UILayout:
texture_info = getattr(base_property_group, texture_info_attr_name, None)
if not isinstance(texture_info, Mtoon1TextureInfoPropertyGroup):
raise TypeError
layout = parent_layout.split(factor=0.3)
toggle_layout = layout.row()
toggle_layout.alignment = "LEFT"
toggle_layout.prop(
texture_info,
"show_expanded",
emboss=False,
text=texture_info.index.panel_label,
translate=False,
icon="TRIA_DOWN" if texture_info.show_expanded else "TRIA_RIGHT",
)
input_layout = layout.row(align=True)
node_image = texture_info.index.get_connected_node_image()
if node_image == texture_info.index.source:
source_prop_name = "source"
placeholder = ""
else:
source_prop_name = "source_for_desynced_node_tree"
placeholder = node_image.name if node_image else ""
if bpy.app.version >= (4, 1):
input_layout.prop(
texture_info.index,
source_prop_name,
text="",
translate=False,
placeholder=placeholder,
)
else:
input_layout.prop(
texture_info.index,
source_prop_name,
text="",
translate=False,
)
import_image_file_op = layout_operator(
input_layout,
VRM_OT_import_mtoon1_texture_image_file,
text="",
translate=False,
icon="FILEBROWSER",
)
import_image_file_op.material_name = material_name
import_image_file_op.target_texture = type(texture_info.index).__name__
if color_factor_attr_name:
input_layout.separator(factor=0.5)
input_layout.prop(base_property_group, color_factor_attr_name, text="")
if not texture_info.show_expanded:
return input_layout
box = parent_layout.box().column()
if texture_info.index.source:
box.prop(texture_info.index.source.colorspace_settings, "name")
if (
texture_info.index.source.colorspace_settings.name
!= texture_info.index.colorspace
):
box.box().label(
text=pgettext(
'It is recommended to set "{colorspace}"'
+ ' to "{input_colorspace}" for "{texture_label}"'
).format(
texture_label=texture_info.index.label,
colorspace=pgettext(texture_info.index.colorspace),
input_colorspace=pgettext("Input Color Space"),
),
icon="ERROR",
)
box.prop(texture_info.index.sampler, "mag_filter")
box.prop(texture_info.index.sampler, "min_filter")
box.prop(texture_info.index.sampler, "wrap_s")
box.prop(texture_info.index.sampler, "wrap_t")
box.prop(texture_info.extensions.khr_texture_transform, "offset")
box.prop(texture_info.extensions.khr_texture_transform, "scale")
if is_vrm0:
if ext.extensions.vrmc_materials_mtoon.matcap_texture == texture_info:
box.box().label(
text="Offset and Scale are ignored in VRM 0.0", icon="ERROR"
)
elif ext.pbr_metallic_roughness.base_color_texture != texture_info:
box.box().label(
text="Offset and Scale in VRM 0.0 are"
+ " the values of the Lit Color Texture",
icon="ERROR",
)
return input_layout
def draw_mtoon0_texture(
material_name: str,
parent_layout: UILayout,
base_property_group: PropertyGroup,
texture_attr_name: str,
scalar_factor_attr_name: str,
) -> UILayout:
texture = getattr(base_property_group, texture_attr_name, None)
if not isinstance(texture, Mtoon0TexturePropertyGroup):
raise TypeError
layout = parent_layout.split(factor=0.3)
toggle_layout = layout.row()
toggle_layout.alignment = "LEFT"
toggle_layout.prop(
texture,
"show_expanded",
emboss=False,
text=texture.panel_label,
translate=False,
icon="TRIA_DOWN" if texture.show_expanded else "TRIA_RIGHT",
)
input_layout = layout.row(align=True)
input_layout.prop(texture, "source", text="")
import_image_file_op = layout_operator(
input_layout,
VRM_OT_import_mtoon1_texture_image_file,
text="",
translate=False,
icon="FILEBROWSER",
)
import_image_file_op.material_name = material_name
import_image_file_op.target_texture = type(texture).__name__
input_layout.separator(factor=0.5)
input_layout.prop(base_property_group, scalar_factor_attr_name, text="")
if not texture.show_expanded:
return input_layout
box = parent_layout.box().column()
if texture.source:
box.prop(texture.source.colorspace_settings, "name")
if texture.source.colorspace_settings.name != texture.colorspace:
box.box().label(
text=pgettext(
'It is recommended to set "{colorspace}"'
+ ' to "{input_colorspace}" for "{texture_label}"'
).format(
texture_label=texture.label,
colorspace=pgettext(texture.colorspace),
input_colorspace=pgettext("Input Color Space"),
),
icon="ERROR",
)
box.prop(texture.sampler, "mag_filter")
box.prop(texture.sampler, "min_filter")
box.prop(texture.sampler, "wrap_s")
box.prop(texture.sampler, "wrap_t")
return input_layout
def draw_mtoon1_material(context: Context, layout: UILayout) -> None:
material = context.material
if not material:
return
ext = get_material_extension(material)
layout = layout.column()
layout.prop(ext.mtoon1, "enabled")
if not ext.mtoon1.enabled:
return
is_vrm0 = search.current_armature_is_vrm0(context)
gltf = ext.mtoon1
mtoon = gltf.extensions.vrmc_materials_mtoon
# https://github.com/vrm-c/UniVRM/blob/v0.102.0/Assets/VRMShaders/VRM10/MToon10/Editor/MToonInspector.cs#L14
layout.label(text="Rendering", translate=False)
rendering_box = layout.box().column()
rendering_box.prop(gltf, "alpha_mode")
if gltf.alpha_mode == gltf.ALPHA_MODE_MASK.identifier:
rendering_box.prop(gltf, "alpha_cutoff", slider=True)
if gltf.alpha_mode == gltf.ALPHA_MODE_BLEND.identifier:
rendering_box.prop(mtoon, "transparent_with_z_write")
rendering_box.prop(gltf, "double_sided")
rendering_box.prop(mtoon, "render_queue_offset_number", slider=True)
layout.label(text="Lighting", translate=False)
lighting_box = layout.box().column()
draw_texture_info(
material.name,
ext.mtoon1,
lighting_box,
gltf.pbr_metallic_roughness,
"base_color_texture",
"base_color_factor",
is_vrm0=is_vrm0,
)
draw_texture_info(
material.name,
ext.mtoon1,
lighting_box,
mtoon,
"shade_multiply_texture",
"shade_color_factor",
is_vrm0=is_vrm0,
)
normal_texture_layout = draw_texture_info(
material.name,
ext.mtoon1,
lighting_box,
gltf,
"normal_texture",
is_vrm0=is_vrm0,
)
normal_texture_layout.separator(factor=0.5)
normal_texture_layout.prop(gltf.normal_texture, "scale")
lighting_box.prop(mtoon, "shading_toony_factor", slider=True)
lighting_box.prop(mtoon, "shading_shift_factor", slider=True)
shading_shift_texture_layout = draw_texture_info(
material.name,
ext.mtoon1,
lighting_box,
mtoon,
"shading_shift_texture",
is_vrm0=is_vrm0,
)
shading_shift_texture_layout.separator(factor=0.5)
shading_shift_texture_layout.prop(mtoon.shading_shift_texture, "scale")
# UniVRM (MIT License)
# https://github.com/vrm-c/UniVRM/blob/d2b4ad1964b754341873f2a1b093d58e1df1713f/Assets/VRMShaders/VRM10/MToon10/Editor/MToonInspector.cs#L120-L128
if (
not mtoon.shading_shift_texture.index.source
and mtoon.shading_toony_factor - mtoon.shading_shift_factor < 1.0 - 0.001
):
lighting_box.box().label(
text="The lit area includes non-lit area.", icon="ERROR"
)
layout.label(text="Global Illumination", translate=False)
gi_box = layout.box()
gi_box.prop(mtoon, "gi_equalization_factor", slider=True)
layout.label(text="Emission", translate=False)
emission_box = layout.box().column()
emissive_texture_layout = draw_texture_info(
material.name,
ext.mtoon1,
emission_box,
gltf,
"emissive_texture",
is_vrm0=is_vrm0,
).row(align=True)
emissive_texture_layout.scale_x = 0.71
emissive_texture_layout.separator(factor=0.5 / 0.71)
emissive_texture_layout.prop(gltf, "emissive_factor", text="")
emissive_texture_layout.prop(
gltf.extensions.khr_materials_emissive_strength, "emissive_strength"
)
layout.label(text="Rim Lighting", translate=False)
rim_lighting_box = layout.box().column()
draw_texture_info(
material.name,
ext.mtoon1,
rim_lighting_box,
mtoon,
"rim_multiply_texture",
is_vrm0=is_vrm0,
)
rim_lighting_box.prop(mtoon, "rim_lighting_mix_factor", slider=True)
draw_texture_info(
material.name,
ext.mtoon1,
rim_lighting_box,
mtoon,
"matcap_texture",
"matcap_factor",
is_vrm0=is_vrm0,
)
rim_lighting_box.row().prop(mtoon, "parametric_rim_color_factor")
rim_lighting_box.prop(mtoon, "parametric_rim_fresnel_power_factor", slider=True)
rim_lighting_box.prop(mtoon, "parametric_rim_lift_factor", slider=True)
layout.label(text="Outline", translate=False)
outline_box = layout.box().column()
outline_box.prop(mtoon, "outline_width_mode")
if (
bpy.app.version >= (3, 3)
and mtoon.outline_width_mode
== mtoon.OUTLINE_WIDTH_MODE_SCREEN_COORDINATES.identifier
):
outline_warning_message = pgettext(
'The "Screen Coordinates" display is not yet implemented.\n'
+ 'It is displayed in the same way as "World Coordinates".'
)
outline_warning_column = outline_box.box().column(align=True)
for index, outline_warning_line in enumerate(
outline_warning_message.splitlines()
):
outline_warning_column.label(
text=outline_warning_line,
translate=False,
icon="BLANK1" if index else "INFO",
)
if mtoon.outline_width_mode != mtoon.OUTLINE_WIDTH_MODE_NONE.identifier:
outline_width_multiply_texture_layout = draw_texture_info(
material.name,
ext.mtoon1,
outline_box,
mtoon,
"outline_width_multiply_texture",
is_vrm0=is_vrm0,
)
outline_width_multiply_texture_layout.separator(factor=0.5)
outline_width_multiply_texture_layout.prop(
mtoon, "outline_width_factor", slider=True, text=""
)
outline_box.row().prop(mtoon, "outline_color_factor")
outline_box.prop(mtoon, "outline_lighting_mix_factor", slider=True)
outline_box.prop(mtoon, "enable_outline_preview", text="Enable Preview")
layout.label(text="UV Animation", translate=False)
uv_animation_box = layout.box().column()
draw_texture_info(
material.name,
ext.mtoon1,
uv_animation_box,
mtoon,
"uv_animation_mask_texture",
is_vrm0=is_vrm0,
)
uv_animation_box.prop(mtoon, "uv_animation_scroll_x_speed_factor")
uv_animation_box.prop(mtoon, "uv_animation_scroll_y_speed_factor")
uv_animation_box.prop(mtoon, "uv_animation_rotation_speed_factor")
layout.prop(gltf, "show_expanded_mtoon0")
if gltf.show_expanded_mtoon0:
mtoon0_box = layout.box().column()
mtoon0_box.prop(gltf, "mtoon0_front_cull_mode")
draw_mtoon0_texture(
material.name,
mtoon0_box,
ext.mtoon1,
"mtoon0_receive_shadow_texture",
"mtoon0_receive_shadow_rate",
)
draw_mtoon0_texture(
material.name,
mtoon0_box,
ext.mtoon1,
"mtoon0_shading_grade_texture",
"mtoon0_shading_grade_rate",
)
mtoon0_box.prop(gltf, "mtoon0_light_color_attenuation", slider=True)
mtoon0_box.prop(gltf, "mtoon0_rim_lighting_mix", slider=True)
mtoon0_box.prop(gltf, "mtoon0_outline_scaled_max_distance", slider=True)
mtoon0_box.prop(gltf, "mtoon0_render_queue_and_clamp", slider=True)
reset_op = layout_operator(layout, VRM_OT_reset_mtoon1_material_shader_node_tree)
reset_op.material_name = material.name
def draw_material(context: Context, layout: UILayout) -> None:
material = context.material
if not material:
return
ext = get_material_extension(material)
if ext.mtoon1.is_outline_material:
layout.box().label(icon="INFO", text="This is an MToon Outline material")
return
draw_mtoon1_material(context, layout)
node, legacy_shader_name = search.legacy_shader_node(material)
if ext.mtoon1.enabled or (node and legacy_shader_name == "MToon_unversioned"):
layout.prop(ext.mtoon1, "export_shape_key_normals")
return
if node and legacy_shader_name in ["TRANSPARENT_ZWRITE", "GLTF"]:
return
help_column = layout.box().column(align=True)
help_message = pgettext(
"How to export this material to VRM.\n"
+ "Meet one of the following conditions.\n"
+ " - VRM MToon Material is enabled\n"
+ ' - Connect the "Surface" to a "Principled BSDF"\n'
+ ' - Connect the "Surface" to a "MToon_unversioned"\n'
+ ' - Connect the "Surface" to a "TRANSPARENT_ZWRITE"\n'
+ " - Others that are compatible with the glTF 2.0 add-on export\n"
)
for index, help_line in enumerate(help_message.splitlines()):
help_column.label(
text=help_line, translate=False, icon="HELP" if index == 0 else "NONE"
)
url = "https://docs.blender.org/manual/en/2.93/addons/import_export/scene_gltf2.html#exported-materials"
link_row = help_column.split(factor=0.8)
link_row.label(text=" " + url, translate=False)
web_op = layout_operator(link_row, VRM_OT_open_url_in_web_browser, icon="URL")
web_op.url = url
class VRM_PT_vrm_material_property(Panel):
bl_idname = "VRM_PT_vrm_material_property"
bl_label = "VRM Material"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "material"
@classmethod
def poll(cls, context: Context) -> bool:
return bool(context.material)
def draw(self, context: Context) -> None:
draw_material(context, self.layout)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,377 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import sys
from dataclasses import dataclass, field
from typing import Final, Optional
import bpy
from bpy.types import Context, Material, Mesh, ShaderNodeGroup, ShaderNodeOutputMaterial
from ...common import ops
from ...common.logger import get_logger
from ...common.scene_watcher import RunState, SceneWatcher
from ...common.shader import MTOON1_AUTO_SETUP_GROUP_NODE_TREE_CUSTOM_KEY
from ..extension import get_material_extension
from .ops import VRM_OT_refresh_mtoon1_outline, generate_mtoon1_outline_material_name
logger = get_logger(__name__)
HAS_AUTO_SMOOTH: Final = tuple(bpy.app.version) < (4, 1)
@dataclass
class ComparisonMaterial:
name: str
@dataclass
class ComparisonObject:
use_auto_smooth: Optional[bool] = None
comparison_materials: list[Optional[ComparisonMaterial]] = field(
default_factory=list[Optional[ComparisonMaterial]]
)
@dataclass
class OutlineUpdater(SceneWatcher):
comparison_objects: list[ComparisonObject] = field(
default_factory=list[ComparisonObject]
)
outline_material_key_to_material_name: dict[int, str] = field(
default_factory=dict[int, str]
)
object_index: int = 0
comparison_object_index: int = 0
material_slot_index: int = 0
@staticmethod
def get_outline_material_key(material: Material) -> int:
return material.as_pointer() ^ 0x5F5FF5F5
def reset_run_progress(self) -> None:
self.object_index = 0
self.comparison_object_index = 0
self.material_slot_index = 0
self.outline_material_key_to_material_name.clear()
def run(self, context: Context) -> RunState:
"""Detect changes in material assignments to objects and assign outlines."""
blend_data = context.blend_data
# If this value becomes zero, return PREEMPT and interrupt the process.
# If a change is detected, set a virtually infinite value so that the
# process proceeds to the end.
preempt_countdown = 15
changed = False
create_modifier = False
objects = blend_data.objects
if not objects:
return RunState.FINISH
# If the number of objects is less than the previous state and the index
# range is exceeded, start over from the beginning
objects_len = len(objects)
if self.object_index >= objects_len:
self.object_index = 0
# Scan objects and check for differences with the comparison object
next_object_index = self.object_index
for obj in objects[self.object_index : objects_len]:
self.object_index = next_object_index
next_object_index += 1
preempt_countdown -= 1
if preempt_countdown <= 0:
return RunState.PREEMPT
# Only mesh objects are subject to investigation.
# Skip if it is not a mesh object
obj_data = obj.data
if not isinstance(obj_data, Mesh):
continue
mesh = obj_data
# If the number of comparison objects is insufficient, add a new
# comparison object
while self.comparison_object_index >= len(self.comparison_objects):
self.comparison_objects.append(ComparisonObject())
# Get a comparison object
comparison_object = self.comparison_objects[self.comparison_object_index]
# Comparison of use_auto_smooth
if HAS_AUTO_SMOOTH and (
(use_auto_smooth := comparison_object.use_auto_smooth) is None
or (use_auto_smooth != mesh.use_auto_smooth)
):
changed, preempt_countdown = True, sys.maxsize
# Resolve change differences
comparison_object.use_auto_smooth = mesh.use_auto_smooth
# If the number of MaterialSlots is less than the previous state and
# the index range is exceeded, start over from the beginning
material_slots = obj.material_slots
material_slots_len = len(material_slots)
if self.material_slot_index >= material_slots_len:
self.material_slot_index = 0
# Match the number of MaterialSlots and the number of comparison Materials
while material_slots_len > len(comparison_object.comparison_materials):
comparison_object.comparison_materials.append(None)
while material_slots_len < len(comparison_object.comparison_materials):
comparison_object.comparison_materials.pop()
# Scan MaterialSlots and check for differences with the comparison Material
next_material_slot_index = self.material_slot_index
for material_slot in material_slots[
self.material_slot_index : material_slots_len
]:
material_slot_index = self.material_slot_index = (
next_material_slot_index
)
next_material_slot_index += 1
preempt_countdown -= 1
if preempt_countdown <= 0:
return RunState.PREEMPT
# If the number of comparison objects is insufficient, add a new
# comparison object
while material_slot_index >= len(
comparison_object.comparison_materials
):
comparison_object.comparison_materials.append(None)
comparison_material = comparison_object.comparison_materials[
material_slot_index
]
# Difference check
if not (
(material := material_slot.material)
and (mtoon1 := get_material_extension(material).mtoon1)
and mtoon1.get_enabled()
):
# MToon of the material in the material slot is disabled
if comparison_material is None:
# No updates
continue
# if the comparison object is enabled, a change is detected
changed, preempt_countdown = True, sys.maxsize
# Resolve change differences
comparison_object.comparison_materials[material_slot_index] = None
continue
# MToon of the material in the material slot is enabled
outline_material = mtoon1.outline_material
outline_material_name = generate_mtoon1_outline_material_name(material)
if (
comparison_material
and comparison_material.name == material.name
and (
outline_material is None
or outline_material.name == outline_material_name
)
):
continue
# MToon of the material in the material slot is enabled,
# but if the comparison object does not exist or the name
# does not match, a change is detected
changed, preempt_countdown = True, sys.maxsize
# Resolve change differences
comparison_object.comparison_materials[material_slot_index] = (
ComparisonMaterial(material.name)
)
# A material with MToon enabled has been newly assigned
# to the object so, if necessary, create a new outline
# modifier.
# Originally, True/False should be set for each object and
# material pair, but for now, I think it's okay to fix it
# for practical use.
create_modifier = True
if outline_material is None:
continue
# When a material is copied, a single outline material may be
# shared by multiple MToon materials. To resolve this, copy the
# outline material and reassign it.
outline_material_key = self.get_outline_material_key(outline_material)
original_material_name = self.outline_material_key_to_material_name.get(
outline_material_key
)
if original_material_name is None:
self.outline_material_key_to_material_name[outline_material_key] = (
material.name
)
elif original_material_name != material.name:
outline_material = outline_material.copy()
outline_material_key = self.get_outline_material_key(
outline_material
)
self.outline_material_key_to_material_name[outline_material_key] = (
material.name
)
mtoon1.outline_material = outline_material
# The outline material name follows the naming changes of
# the base MToon material.
if outline_material.name != outline_material_name:
outline_material.name = outline_material_name
# Since the scanning of MaterialSlots is complete,
# reset the next scanning index to 0.
self.material_slot_index = 0
# Before scanning the next object,
# advance the index of the next comparison object.
self.comparison_object_index += 1
# Since the number of elements in self.comparison_objects may be
# unnecessarily large, reduce it to a sufficient size
while len(self.comparison_objects) > self.comparison_object_index:
self.comparison_objects.pop()
if not changed:
return RunState.FINISH
VRM_OT_refresh_mtoon1_outline.refresh(context, create_modifier=create_modifier)
return RunState.FINISH
def create_fast_path_performance_test_objects(self, context: Context) -> None:
blend_data = context.blend_data
for i in range(100):
blend_data.materials.new(f"Material#{i}")
for i in range(100):
mesh = blend_data.meshes.new(f"Mesh#{i}")
obj = blend_data.objects.new(f"Object#{i}", mesh)
context.scene.collection.objects.link(obj)
for k in range(50):
material = blend_data.materials[(k * 3) % len(blend_data.materials)]
mesh.materials.append(material)
if k % 5 == 0:
get_material_extension(material).mtoon1.enabled = True
@dataclass
class MToon1AutoSetup(SceneWatcher):
last_material_index: int = 0
last_node_index: int = 0
def reset_run_progress(self) -> None:
self.last_material_index: int = 0
self.last_node_index: int = 0
def run(self, context: Context) -> RunState:
"""Monitor the appearance of MToon auto-setup node groups and set them up.
Since this function is called frequently, keep the processing lightweight and
be careful to minimize IO and GC Allocation.
"""
# If this value becomes 0 or less, interrupt the process
search_preempt_countdown = 100
materials = context.blend_data.materials
# Restore the material traversal start position from the last interrupted state.
end_material_index = len(materials)
start_material_index = self.last_material_index
if start_material_index >= end_material_index:
self.last_material_index = 0
self.last_node_index = 0
start_material_index = 0
# Traverse the materials and enable MToon if necessary.
next_material_index = start_material_index
for material in materials[start_material_index:end_material_index]:
self.last_material_index = next_material_index
next_material_index += 1
search_preempt_countdown -= 1
if search_preempt_countdown <= 0:
return RunState.PREEMPT
if bpy.app.version < (5, 0, 0) and not material.use_nodes:
continue
node_tree = material.node_tree
if node_tree is None:
continue
nodes = node_tree.nodes
# Restore the node traversal start position from the last interrupted state.
end_node_index = len(nodes)
start_node_index = self.last_node_index
if start_node_index >= end_node_index:
start_node_index = 0
# Traverse the nodes and convert the material to MToon if the MToon
# placeholder node is connected to ShaderNodeOutputMaterial.
next_node_index = start_node_index
for node in nodes[start_node_index:end_node_index]:
self.last_node_index = next_node_index
next_node_index += 1
search_preempt_countdown -= 1
if search_preempt_countdown <= 0:
return RunState.PREEMPT
if not isinstance(node, ShaderNodeGroup):
continue
group_node_tree = node.node_tree
if group_node_tree is None:
continue
if not group_node_tree.get(
MTOON1_AUTO_SETUP_GROUP_NODE_TREE_CUSTOM_KEY
):
continue
found = False
for output in node.outputs:
for link in output.links:
if isinstance(link.to_node, ShaderNodeOutputMaterial):
found = True
break
if found:
break
if not found:
continue
mtoon1 = get_material_extension(material).mtoon1
if mtoon1.enabled:
ops.vrm.reset_mtoon1_material_shader_node_group(
material_name=material.name
)
else:
mtoon1.enabled = True
break
self.last_node_index = 0
self.last_material_index = 0
return RunState.FINISH
def create_fast_path_performance_test_objects(self, context: Context) -> None:
blend_data = context.blend_data
for i in range(100):
blend_data.materials.new(f"Material#{i}")
for i in range(100):
mesh = blend_data.meshes.new(f"Mesh#{i}")
obj = blend_data.objects.new(f"Object#{i}", mesh)
context.scene.collection.objects.link(obj)
for k in range(50):
material = blend_data.materials[(k * 3) % len(blend_data.materials)]
mesh.materials.append(material)
if k % 5 == 0:
get_material_extension(material).mtoon1.enabled = True

View File

@@ -0,0 +1 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later

View File

@@ -0,0 +1,316 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from collections.abc import Set as AbstractSet
from bpy.app.translations import pgettext
from bpy.types import (
Armature,
Context,
CopyRotationConstraint,
DampedTrackConstraint,
Object,
Panel,
UILayout,
)
from ...common.preferences import get_preferences
from .. import search
from ..extension import get_armature_extension
from ..panel import VRM_PT_vrm_armature_object_property
from ..search import active_object_is_vrm1_armature
from .property_group import NodeConstraint1NodeConstraintPropertyGroup
def draw_roll_constraint_layout(
layout: UILayout,
node_constraint: NodeConstraint1NodeConstraintPropertyGroup,
object_constraints: dict[str, CopyRotationConstraint],
bone_constraints: dict[str, CopyRotationConstraint],
) -> None:
constraints_box = layout.box()
constraints_row = constraints_box.row()
constraints_row.alignment = "LEFT"
constraints_row.prop(
node_constraint,
"show_expanded_roll_constraints",
icon="TRIA_DOWN"
if node_constraint.show_expanded_roll_constraints
else "TRIA_RIGHT",
emboss=False,
)
if not node_constraint.show_expanded_roll_constraints:
return
constraints_column = constraints_box.column()
if object_constraints or bone_constraints:
constraints_expanded_column = constraints_column.column()
for object_name, constraint in object_constraints.items():
constraint_row = constraints_expanded_column.row()
constraint_row.alignment = "LEFT"
constraint_row.label(
text=object_name + ": " + constraint.name,
icon="CONSTRAINT",
translate=False,
)
for bone_name, constraint in bone_constraints.items():
constraint_row = constraints_expanded_column.row()
constraint_row.alignment = "LEFT"
constraint_row.label(
text=bone_name + ": " + constraint.name,
icon="CONSTRAINT_BONE",
translate=False,
)
constraints_help_column = constraints_column.box().column(align=True)
help_message = pgettext(
"Conditions exported as Roll Constraint\n"
+ " - {copy_rotation}\n"
+ " - Enabled\n"
+ " - No Vertex Group\n"
+ " - {axis} is one of X, Y and Z\n"
+ " - No Inverted\n"
+ " - {mix} is {add}\n"
+ " - {target} is {local_space}\n"
+ " - {owner} is {local_space}\n"
+ " - No circular dependencies\n"
+ " - The one at the top of the list of\n"
+ " those that meet all the conditions\n",
).format(
copy_rotation=pgettext("Copy Rotation"),
axis=pgettext("Axis"),
mix=pgettext("Mix"),
add=pgettext("Add"),
target=pgettext("Target"),
local_space=pgettext("Local Space"),
owner=pgettext("Owner"),
)
for index, help_line in enumerate(help_message.splitlines()):
constraints_help_column.label(
text=help_line, translate=False, icon="HELP" if index == 0 else "NONE"
)
def draw_aim_constraint_layout(
layout: UILayout,
node_constraint: NodeConstraint1NodeConstraintPropertyGroup,
object_constraints: dict[str, DampedTrackConstraint],
bone_constraints: dict[str, DampedTrackConstraint],
) -> None:
constraints_box = layout.box()
constraints_row = constraints_box.row()
constraints_row.alignment = "LEFT"
constraints_row.prop(
node_constraint,
"show_expanded_aim_constraints",
icon="TRIA_DOWN"
if node_constraint.show_expanded_aim_constraints
else "TRIA_RIGHT",
emboss=False,
)
if not node_constraint.show_expanded_aim_constraints:
return
constraints_column = constraints_box.column()
if object_constraints or bone_constraints:
constraints_expanded_column = constraints_column.column()
for object_name, constraint in object_constraints.items():
constraint_row = constraints_expanded_column.row()
constraint_row.alignment = "LEFT"
constraint_row.label(
text=object_name + ": " + constraint.name,
icon="CONSTRAINT",
translate=False,
)
for bone_name, constraint in bone_constraints.items():
constraint_row = constraints_expanded_column.row()
constraint_row.alignment = "LEFT"
constraint_row.label(
text=bone_name + ": " + constraint.name,
icon="CONSTRAINT_BONE",
translate=False,
)
constraints_help_column = constraints_column.box().column(align=True)
help_message = pgettext(
"Conditions exported as Aim Constraint\n"
+ " - {damped_track}\n"
+ " - Enabled\n"
+ " - Target Bone {head_tail} is 0\n"
+ " - No Follow Target Bone B-Bone\n"
+ " - No circular dependencies\n"
+ " - The one at the top of the list of\n"
+ " those that meet all the conditions\n"
).format(
damped_track=pgettext("Damped Track"),
head_tail=pgettext("Head/Tail"),
)
for index, help_line in enumerate(help_message.splitlines()):
constraints_help_column.label(
text=help_line, translate=False, icon="HELP" if index == 0 else "NONE"
)
def draw_rotation_constraint_layout(
layout: UILayout,
node_constraint: NodeConstraint1NodeConstraintPropertyGroup,
object_constraints: dict[str, CopyRotationConstraint],
bone_constraints: dict[str, CopyRotationConstraint],
) -> None:
constraints_box = layout.box()
constraints_row = constraints_box.row()
constraints_row.alignment = "LEFT"
constraints_row.prop(
node_constraint,
"show_expanded_rotation_constraints",
icon="TRIA_DOWN"
if node_constraint.show_expanded_rotation_constraints
else "TRIA_RIGHT",
emboss=False,
)
if not node_constraint.show_expanded_rotation_constraints:
return
constraints_column = constraints_box.column()
if object_constraints or bone_constraints:
constraints_expanded_column = constraints_column.column()
for object_name, constraint in object_constraints.items():
constraint_row = constraints_expanded_column.row()
constraint_row.alignment = "LEFT"
constraint_row.label(
text=object_name + ": " + constraint.name,
icon="CONSTRAINT",
translate=False,
)
for bone_name, constraint in bone_constraints.items():
constraint_row = constraints_expanded_column.row()
constraint_row.alignment = "LEFT"
constraint_row.label(
text=bone_name + ": " + constraint.name,
icon="CONSTRAINT_BONE",
translate=False,
)
constraints_help_column = constraints_column.box().column(align=True)
help_message = pgettext(
"Conditions exported as Rotation Constraint\n"
+ " - {copy_rotation}\n"
+ " - Enabled\n"
+ " - No Vertex Group\n"
+ " - {axis} is X, Y and Z\n"
+ " - No Inverted\n"
+ " - {mix} is {add}\n"
+ " - {target} is {local_space}\n"
+ " - {owner} is {local_space}\n"
+ " - No circular dependencies\n"
+ " - The one at the top of the list of\n"
+ " those that meet all the conditions\n"
).format(
copy_rotation=pgettext("Copy Rotation"),
axis=pgettext("Axis"),
mix=pgettext("Mix"),
add=pgettext("Add"),
target=pgettext("Target"),
local_space=pgettext("Local Space"),
owner=pgettext("Owner"),
)
for index, help_line in enumerate(help_message.splitlines()):
constraints_help_column.label(
text=help_line, translate=False, icon="HELP" if index == 0 else "NONE"
)
def draw_node_constraint1_layout(
context: Context,
armature: Object,
layout: UILayout,
node_constraint: NodeConstraint1NodeConstraintPropertyGroup,
) -> None:
preferences = get_preferences(context)
objs = search.export_objects(
context,
armature_object_name=armature.name,
export_invisibles=preferences.export_invisibles,
export_only_selections=False,
export_lights=False,
)
object_constraints, bone_constraints, _ = search.export_constraints(objs, armature)
draw_roll_constraint_layout(
layout,
node_constraint,
object_constraints.roll_constraints,
bone_constraints.roll_constraints,
)
draw_aim_constraint_layout(
layout,
node_constraint,
object_constraints.aim_constraints,
bone_constraints.aim_constraints,
)
draw_rotation_constraint_layout(
layout,
node_constraint,
object_constraints.rotation_constraints,
bone_constraints.rotation_constraints,
)
class VRM_PT_node_constraint1_armature_object_property(Panel):
bl_idname = "VRM_PT_node_constraint1_armature_object_property"
bl_label = "Node Constraint"
bl_translation_context = "VRM"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "object"
bl_options: AbstractSet[str] = {"DEFAULT_CLOSED"}
bl_parent_id = VRM_PT_vrm_armature_object_property.bl_idname
@classmethod
def poll(cls, context: Context) -> bool:
return active_object_is_vrm1_armature(context)
def draw_header(self, _context: Context) -> None:
self.layout.label(icon="CONSTRAINT")
def draw(self, context: Context) -> None:
active_object = context.active_object
if not active_object:
return
armature_data = active_object.data
if not isinstance(armature_data, Armature):
return
draw_node_constraint1_layout(
context,
active_object,
self.layout,
get_armature_extension(armature_data).node_constraint1,
)
class VRM_PT_node_constraint1_ui(Panel):
bl_idname = "VRM_PT_node_constraint1_ui"
bl_label = "Node Constraint"
bl_translation_context = "VRM"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "VRM"
bl_options: AbstractSet[str] = {"DEFAULT_CLOSED"}
@classmethod
def poll(cls, context: Context) -> bool:
return search.current_armature_is_vrm1(context)
def draw_header(self, _context: Context) -> None:
self.layout.label(icon="CONSTRAINT")
def draw(self, context: Context) -> None:
armature = search.current_armature(context)
if not armature:
return
armature_data = armature.data
if not isinstance(armature_data, Armature):
return
draw_node_constraint1_layout(
context,
armature,
self.layout,
get_armature_extension(armature_data).node_constraint1,
)

View File

@@ -0,0 +1,25 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from typing import TYPE_CHECKING
from bpy.props import BoolProperty
from bpy.types import PropertyGroup
# https://github.com/vrm-c/vrm-specification/blob/6fb6baaf9b9095a84fb82c8384db36e1afeb3558/specification/VRMC_springBone-1.0-beta/schema/VRMC_springBone.schema.json
class NodeConstraint1NodeConstraintPropertyGroup(PropertyGroup):
# for UI
show_expanded_roll_constraints: BoolProperty( # type: ignore[valid-type]
name="Roll Constraint"
)
show_expanded_aim_constraints: BoolProperty( # type: ignore[valid-type]
name="Aim Constraint"
)
show_expanded_rotation_constraints: BoolProperty( # type: ignore[valid-type]
name="Rotation Constraint"
)
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
show_expanded_roll_constraints: bool # type: ignore[no-redef]
show_expanded_aim_constraints: bool # type: ignore[no-redef]
show_expanded_rotation_constraints: bool # type: ignore[no-redef]

405
editor/ops.py Normal file
View File

@@ -0,0 +1,405 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
# SPDX-FileCopyrightText: 2018 iCyP
# SPDX-FileCopyrightText: 2024 saturday06
import json
import warnings
import webbrowser
from collections.abc import Set as AbstractSet
from pathlib import Path
from typing import TYPE_CHECKING, Optional, TypeVar, cast
from urllib.parse import urlparse
from bpy.app.translations import pgettext
from bpy.props import StringProperty
from bpy.types import (
Armature,
Context,
Event,
Operator,
UILayout,
)
from bpy_extras.io_utils import ExportHelper, ImportHelper
from ..common.deep import make_json
from ..common.human_bone_mapper.vroid_mapping import (
FULL_PATTERN,
symmetrise_vroid_bone_name,
)
from ..common.logger import get_logger
from ..common.vrm0.human_bone import HumanBoneSpecifications
from ..common.workspace import save_workspace
from . import search
from .extension import get_armature_extension
from .t_pose import set_estimated_humanoid_t_pose
logger = get_logger(__name__)
class VRM_OT_simplify_vroid_bones(Operator):
bl_idname = "vrm.bones_rename"
bl_label = "Symmetrize VRoid Bone Names on X-Axis"
bl_description = "Make VRoid bone names editable for X-axis mirroring."
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
armature_object_name: StringProperty( # type: ignore[valid-type]
options={"HIDDEN"},
)
def update_armature_name(self, _context: Context) -> None:
message = (
f"`{type(self).__qualname__}.armature_name` is deprecated"
+ " and will be removed in the next major release."
+ f" `Please use {type(self).__qualname__}.armature_object_name` instead."
)
logger.warning(message)
warnings.warn(message, DeprecationWarning, stacklevel=5)
armature_name: StringProperty( # type: ignore[valid-type]
options={"HIDDEN"},
update=update_armature_name,
)
"""`armature_name` is deprecated and will be removed in the next major release.
Please use `armature_object_name` instead.
"""
@staticmethod
def vroid_bones_exist(armature: Armature) -> bool:
return any(map(FULL_PATTERN.match, armature.bones.keys()))
def execute(self, context: Context) -> set[str]:
if not self.armature_object_name and self.armature_name:
self.armature_object_name = self.armature_name
armature = context.blend_data.objects.get(self.armature_object_name)
if armature is None or armature.type != "ARMATURE":
return {"CANCELLED"}
armature_data = armature.data
if not isinstance(armature_data, Armature):
return {"CANCELLED"}
with save_workspace(context):
for bone_name, bone in armature_data.bones.items():
bone.name = symmetrise_vroid_bone_name(bone_name)
return {"FINISHED"}
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_name: str # type: ignore[no-redef]
class VRM_OT_save_human_bone_mappings(Operator, ExportHelper):
bl_idname = "vrm.save_human_bone_mappings"
bl_label = "Save Bone Mappings"
bl_description = "Save bone mappings."
bl_options: AbstractSet[str] = {"REGISTER"}
filename_ext = ".json"
filter_glob: StringProperty( # type: ignore[valid-type]
default="*.json",
options={"HIDDEN"},
)
@classmethod
def poll(cls, context: Context) -> bool:
return search.armature_exists(context)
def execute(self, context: Context) -> set[str]:
armature = search.current_armature(context)
if not armature:
return {"CANCELLED"}
armature_data = armature.data
if not isinstance(armature_data, Armature):
return {"CANCELLED"}
mappings = {}
for human_bone in get_armature_extension(
armature_data
).vrm0.humanoid.human_bones:
if human_bone.bone not in HumanBoneSpecifications.all_names:
continue
if not human_bone.node.bone_name:
continue
mappings[human_bone.bone] = human_bone.node.bone_name
Path(self.filepath).write_bytes(
json.dumps(mappings, sort_keys=True, indent=4)
.replace("\r\n", "\n")
.encode()
)
return {"FINISHED"}
def invoke(self, context: Context, event: Event) -> set[str]:
return ExportHelper.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]
class VRM_OT_load_human_bone_mappings(Operator, ImportHelper):
bl_idname = "vrm.load_human_bone_mappings"
bl_label = "Load Bone Mappings"
bl_description = "Load bone mappings."
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
filename_ext = ".json"
filter_glob: StringProperty( # type: ignore[valid-type]
default="*.json",
options={"HIDDEN"},
)
@classmethod
def poll(cls, context: Context) -> bool:
return search.armature_exists(context)
def execute(self, context: Context) -> set[str]:
armature = search.current_armature(context)
if not armature:
return {"CANCELLED"}
armature_data = armature.data
if not isinstance(armature_data, Armature):
return {"CANCELLED"}
obj = make_json(json.loads(Path(self.filepath).read_text(encoding="UTF-8")))
if not isinstance(obj, dict):
return {"CANCELLED"}
for human_bone_name, bpy_bone_name in obj.items():
if human_bone_name not in HumanBoneSpecifications.all_names:
continue
if not isinstance(bpy_bone_name, str):
continue
# INFO@MICROSOFT.COM
found = False
for human_bone in get_armature_extension(
armature_data
).vrm0.humanoid.human_bones:
if human_bone.bone == human_bone_name:
human_bone.node.bone_name = bpy_bone_name
found = True
break
if found:
continue
human_bone = get_armature_extension(
armature_data
).vrm0.humanoid.human_bones.add()
human_bone.bone = human_bone_name
human_bone.node.bone_name = bpy_bone_name
return {"FINISHED"}
def invoke(self, context: Context, event: Event) -> set[str]:
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]
class VRM_OT_open_url_in_web_browser(Operator):
bl_idname = "vrm.open_url_in_web_browser"
bl_label = "Open"
bl_description = "Open the URL in the default web browser."
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
url: StringProperty( # type: ignore[valid-type]
options={"HIDDEN"}
)
@staticmethod
def supported(url_str: str) -> bool:
try:
url = urlparse(url_str)
except ValueError:
return False
return url.scheme in ["http", "https"]
def execute(self, _context: Context) -> set[str]:
url = self.url
if not self.supported(url):
return {"CANCELLED"}
webbrowser.open(self.url)
return {"FINISHED"}
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
url: str # type: ignore[no-redef]
class VRM_OT_show_blend_file_compatibility_warning(Operator):
bl_idname = "vrm.show_blend_file_compatibility_warning"
bl_label = "File Compatibility Warning"
bl_description = "Show blend file compatibility warning."
bl_options: AbstractSet[str] = {"REGISTER"}
file_version: StringProperty(options={"HIDDEN"}) # type: ignore[valid-type]
app_version: StringProperty(options={"HIDDEN"}) # type: ignore[valid-type]
def execute(self, _context: Context) -> set[str]:
return {"FINISHED"}
def invoke(self, context: Context, _event: Event) -> set[str]:
return context.window_manager.invoke_props_dialog(self, width=500)
def draw(self, _context: Context) -> None:
column = self.layout.row(align=True).column()
text = pgettext(
"The current file is not compatible with the running Blender.\n"
+ "The current file was created in Blender {file_version}, but the running"
+ " Blender version is {app_version}.\n"
+ "This incompatibility may result in data loss or corruption."
).format(
app_version=self.app_version,
file_version=self.file_version,
)
description_outer_column = column.column()
description_outer_column.emboss = "NONE"
description_column = description_outer_column.box().column(align=True)
for i, line in enumerate(text.splitlines()):
icon = "ERROR" if i == 0 else "NONE"
description_column.label(text=line, translate=False, icon=icon)
open_url = layout_operator(
self.layout,
VRM_OT_open_url_in_web_browser,
text="Open Documentation",
icon="URL",
)
open_url.url = "https://developer.blender.org/docs/handbook/guidelines/compatibility_handling_for_blend_files/#forward-compatibility"
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
file_version: str # type: ignore[no-redef]
app_version: str # type: ignore[no-redef]
class VRM_OT_show_blend_file_addon_compatibility_warning(Operator):
bl_idname = "vrm.show_blend_file_addon_compatibility_warning"
bl_label = "VRM Add-on Compatibility Warning"
bl_description = "Show blend file and VRM add-on compatibility warning."
bl_options: AbstractSet[str] = {"REGISTER"}
file_addon_version: StringProperty(options={"HIDDEN"}) # type: ignore[valid-type]
installed_addon_version: StringProperty(options={"HIDDEN"}) # type: ignore[valid-type]
def execute(self, _context: Context) -> set[str]:
return {"FINISHED"}
def invoke(self, context: Context, _event: Event) -> set[str]:
return context.window_manager.invoke_props_dialog(self, width=500)
def draw(self, _context: Context) -> None:
column = self.layout.row(align=True).column()
text = pgettext(
"The current file is not compatible with the installed VRM Add-on.\n"
+ "The current file was created in VRM Add-on {file_addon_version}, but the"
+ " installed\n"
+ "VRM Add-on version is {installed_addon_version}. This incompatibility\n"
+ "may result in data loss or corruption."
).format(
file_addon_version=self.file_addon_version,
installed_addon_version=self.installed_addon_version,
)
description_outer_column = column.column()
description_outer_column.emboss = "NONE"
description_column = description_outer_column.box().column(align=True)
for i, line in enumerate(text.splitlines()):
icon = "ERROR" if i == 0 else "NONE"
description_column.label(text=line, translate=False, icon=icon)
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
file_addon_version: str # type: ignore[no-redef]
installed_addon_version: str # type: ignore[no-redef]
__Operator = TypeVar("__Operator", bound=Operator)
def layout_operator(
layout: UILayout,
operator_type: type[__Operator],
*,
text: Optional[str] = None,
text_ctxt: str = "",
translate: bool = True,
icon: str = "NONE",
emboss: bool = True,
depress: bool = False,
icon_value: int = 0,
) -> __Operator:
if text is None:
text = operator_type.bl_label
operator = layout.operator(
operator_type.bl_idname,
text=text,
text_ctxt=text_ctxt,
translate=translate,
icon=icon,
emboss=emboss,
depress=depress,
icon_value=icon_value,
)
split = operator_type.bl_idname.split(".")
if len(split) != 2:
message = f"Unexpected bl_idname: {operator_type.bl_idname}"
raise AssertionError(message)
name = f"{split[0].encode().upper().decode()}_OT_{split[1]}"
if type(operator).__qualname__ != name:
raise AssertionError(
f"{type(operator)} is not compatible with {operator_type}."
+ f"the expected name is {name}"
)
return cast("__Operator", operator)
class VRM_OT_make_estimated_humanoid_t_pose(Operator):
bl_idname = "vrm.make_estimated_humanoid_t_pose"
bl_label = "Make Estimated T-Pose"
bl_description = "Create VRM estimated humanoid T-pose."
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
armature_object_name: StringProperty( # type: ignore[valid-type]
options={"HIDDEN"},
)
def update_armature_name(self, _context: Context) -> None:
message = (
f"`{type(self).__qualname__}.armature_name` is deprecated"
+ " and will be removed in the next major release."
+ f" `Please use {type(self).__qualname__}.armature_object_name` instead."
)
logger.warning(message)
warnings.warn(message, DeprecationWarning, stacklevel=5)
armature_name: StringProperty( # type: ignore[valid-type]
options={"HIDDEN"},
update=update_armature_name,
)
"""`armature_name` is deprecated and will be removed in the next major release.
Please use `armature_object_name` instead.
"""
def execute(self, context: Context) -> set[str]:
if not self.armature_object_name and self.armature_name:
self.armature_object_name = self.armature_name
armature = context.blend_data.objects.get(self.armature_object_name)
if armature is None or armature.type != "ARMATURE":
return {"CANCELLED"}
if not set_estimated_humanoid_t_pose(context, armature):
return {"CANCELLED"}
return {"FINISHED"}
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_name: str # type: ignore[no-redef]

287
editor/panel.py Normal file
View File

@@ -0,0 +1,287 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from collections.abc import Set as AbstractSet
from typing import Callable, Optional, Protocol, TypeVar, Union, runtime_checkable
from bpy.app.translations import pgettext
from bpy.types import (
AnyType,
Armature,
Context,
Menu,
Operator,
Panel,
UILayout,
UIList,
)
from ..common import version
from ..common.preferences import get_preferences
from . import make_armature, search, validation
from .extension import get_armature_extension
from .ops import layout_operator
__AddOperator = TypeVar("__AddOperator", bound=Operator)
__RemoveOperator = TypeVar("__RemoveOperator", bound=Operator)
__MoveUpOperator = TypeVar("__MoveUpOperator", bound=Operator)
__MoveDownOperator = TypeVar("__MoveDownOperator", bound=Operator)
@runtime_checkable
class TemplateListCollectionProtocol(Protocol):
def __len__(self) -> int: ...
def __getitem__(self, index: int) -> object: ...
def draw_template_list(
layout: UILayout,
template_list: type[UIList],
base_object: AnyType,
collection_attribue_name: str,
active_index_attribute_name: str,
add_operator_type: type[__AddOperator],
remove_operator_type: type[__RemoveOperator],
move_up_operator_type: type[__MoveUpOperator],
move_down_operator_type: type[__MoveDownOperator],
*,
can_remove: Callable[[int], bool] = lambda _: True,
can_move: Callable[[int], bool] = lambda _: True,
compact: bool = False,
menu: Optional[type[Menu]] = None,
) -> tuple[
list[Union[__AddOperator, __RemoveOperator, __MoveUpOperator, __MoveDownOperator]],
list[Union[__RemoveOperator, __MoveUpOperator, __MoveDownOperator]],
int,
object,
tuple[
__AddOperator,
__RemoveOperator,
Optional[__MoveUpOperator],
Optional[__MoveDownOperator],
],
]:
collection = getattr(base_object, collection_attribue_name, None)
if not isinstance(collection, TemplateListCollectionProtocol):
message = (
f"{collection}.{collection_attribue_name}"
+ " is not a Template List Collection Protocol."
)
raise TypeError(message)
active_index = getattr(base_object, active_index_attribute_name, None)
if not isinstance(active_index, int):
message = f"{base_object}.{active_index_attribute_name} is not an int."
raise TypeError(message)
if 0 <= active_index < len(collection):
active_object = collection[active_index]
else:
active_object = None
length = len(collection)
list_row_len = 4 if length > int(compact) else 2
list_row = layout.row()
list_row.template_list(
template_list.bl_idname,
"",
base_object,
collection_attribue_name,
base_object,
active_index_attribute_name,
rows=list_row_len,
)
list_side_column = list_row.column(align=True)
add_operator = layout_operator(
list_side_column, add_operator_type, icon="ADD", text="", translate=False
)
if length >= 1 and 0 <= active_index < length and can_remove(active_index):
remove_operator_parent = list_side_column
else:
remove_operator_parent = list_side_column.column(align=True)
remove_operator_parent.enabled = False
remove_operator = layout_operator(
remove_operator_parent,
remove_operator_type,
icon="REMOVE",
text="",
translate=False,
)
if menu is not None:
list_side_column.separator()
list_side_column.menu(menu=menu.bl_idname, text="", icon="DOWNARROW_HLT")
move_operator_parent = list_side_column.column(align=True)
# Without separator, the button width changes mysteriously when list elements are 0
move_operator_parent.separator()
if length <= 1 or not (0 <= active_index < length) or not can_move(active_index):
move_operator_parent.enabled = False
collection_ops: list[
Union[__AddOperator, __RemoveOperator, __MoveUpOperator, __MoveDownOperator]
] = [add_operator]
collection_item_ops: list[
Union[__RemoveOperator, __MoveUpOperator, __MoveDownOperator]
] = [remove_operator]
if length > int(compact):
move_up_operator = layout_operator(
move_operator_parent,
move_up_operator_type,
icon="TRIA_UP",
text="",
translate=False,
)
collection_item_ops.append(move_up_operator)
move_down_operator = layout_operator(
move_operator_parent,
move_down_operator_type,
icon="TRIA_DOWN",
text="",
translate=False,
)
collection_item_ops.append(move_down_operator)
else:
move_up_operator = None
move_down_operator = None
collection_ops.extend(collection_item_ops)
return (
collection_ops,
collection_item_ops,
active_index,
active_object,
(add_operator, remove_operator, move_up_operator, move_down_operator),
)
class VRM_PT_vrm_armature_object_property(Panel):
bl_idname = "VRM_PT_vrm_armature_object_property"
bl_label = "VRM"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "object"
@classmethod
def poll(cls, context: Context) -> bool:
active_object = context.active_object
if not active_object:
return False
armature_data = active_object.data
return isinstance(armature_data, Armature)
def draw(self, _context: Context) -> None:
warning_message = version.panel_warning_message()
if not warning_message:
return
box = self.layout.box()
warning_column = box.column()
for index, warning_line in enumerate(warning_message.splitlines()):
warning_column.label(
text=warning_line,
translate=False,
icon="NONE" if index else "ERROR",
)
def add_armature(add_armature_op: Operator, _context: Context) -> None:
layout_operator(
add_armature_op.layout,
make_armature.ICYP_OT_make_armature,
text="VRM Humanoid",
icon="OUTLINER_OB_ARMATURE",
).skip_heavy_armature_setup = True
class VRM_PT_current_selected_armature(Panel):
bl_idname = "VRM_PT_current_selected_armature"
bl_label = "Current selected armature"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "VRM"
bl_options: AbstractSet[str] = {"HIDE_HEADER"}
@classmethod
def poll(cls, context: Context) -> bool:
return search.multiple_armatures_exist(context)
def draw(self, context: Context) -> None:
armature = search.current_armature(context)
if not armature:
return
layout = self.layout
layout.label(text=armature.name, icon="ARMATURE_DATA", translate=False)
class VRM_PT_controller(Panel):
bl_idname = "ICYP_PT_ui_controller"
bl_label = "Operator"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "VRM"
def draw_header(self, _context: Context) -> None:
self.layout.label(icon="TOOL_SETTINGS")
def draw(self, context: Context) -> None:
layout = self.layout
preferences = get_preferences(context)
# draw_main
layout_operator(
layout,
make_armature.ICYP_OT_make_armature,
text=pgettext("Create VRM Model"),
icon="OUTLINER_OB_ARMATURE",
).skip_heavy_armature_setup = True
vrm_validator_op = layout_operator(
layout,
validation.WM_OT_vrm_validator,
text=pgettext("Check VRM Model"),
icon="VIEWZOOM",
)
vrm_validator_op.show_successful_message = True
layout.prop(preferences, "export_invisibles")
layout.prop(preferences, "export_only_selections")
armature = search.current_armature(context)
if armature:
vrm_validator_op.armature_object_name = armature.name
armature_data = armature.data
if isinstance(armature_data, Armature):
layout.prop(
get_armature_extension(armature_data),
"spec_version",
text="",
translate=False,
)
class VRM_PT_controller_unsupported_blender_version_warning(Panel):
bl_idname = "VRM_PT_controller_unsupported_blender_version_warning"
bl_label = "Unsupported Blender Version Warning"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "VRM"
bl_options: AbstractSet[str] = {"HIDE_HEADER"}
@classmethod
def poll(cls, _context: Context) -> bool:
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()
for index, warning_line in enumerate(warning_message.splitlines()):
warning_column.label(
text=warning_line,
translate=False,
icon="NONE" if index else "ERROR",
)

851
editor/property_group.py Normal file
View File

@@ -0,0 +1,851 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import uuid
import warnings
from collections.abc import Iterator, Mapping, Sequence, ValuesView
from dataclasses import dataclass
from enum import Enum
from typing import (
TYPE_CHECKING,
ClassVar,
Final,
Optional,
Protocol,
TypeVar,
Union,
overload,
)
import bpy
from bpy.app.translations import pgettext
from bpy.props import FloatProperty, PointerProperty, StringProperty
from bpy.types import Armature, Bone, Context, Material, Object, PropertyGroup, UILayout
from ..common.logger import get_logger
from ..common.vrm0 import human_bone as vrm0_human_bone
from ..common.vrm1 import human_bone as vrm1_human_bone
HumanBoneSpecification = TypeVar(
"HumanBoneSpecification",
vrm0_human_bone.HumanBoneSpecification,
vrm1_human_bone.HumanBoneSpecification,
)
logger = get_logger(__name__)
# https://docs.blender.org/api/2.93/bpy.types.EnumPropertyItem.html#bpy.types.EnumPropertyItem
@dataclass(frozen=True)
class PropertyGroupEnumItem:
identifier: str
name: str
description: str
icon: str
value: int
@staticmethod
def from_enum_property_items(
items: tuple[tuple[str, str, str, str, int], ...],
) -> tuple[tuple[str, ...], tuple[int, ...], tuple["PropertyGroupEnumItem", ...]]:
enum_items = tuple(
PropertyGroupEnumItem(
identifier=identifier,
name=name,
description=description,
icon=icon,
value=value,
)
for identifier, name, description, icon, value in items
)
identifiers = tuple(enum_item.identifier for enum_item in enum_items)
values = tuple(enum_item.value for enum_item in enum_items)
return identifiers, values, enum_items
class PropertyGroupEnum:
def __init__(self, enum_items: tuple[tuple[str, str, str, str, int], ...]) -> None:
self.enum_items = enum_items
def items(self) -> tuple[tuple[str, str, str, str, int], ...]:
return self.enum_items
def value_to_identifier(self, value: int, default: str) -> str:
return next(
(enum.identifier for enum in self if enum.value == value),
default,
)
def identifier_to_value(self, identifier: str, default: int) -> int:
return next(
(enum.value for enum in self if enum.identifier == identifier),
default,
)
def identifiers(self) -> tuple[str, ...]:
return tuple(enum.identifier for enum in self)
def values(self) -> tuple[int, ...]:
return tuple(enum.value for enum in self)
def __iter__(self) -> Iterator[PropertyGroupEnumItem]:
return (
PropertyGroupEnumItem(
identifier=identifier,
name=name,
description=description,
icon=icon,
value=value,
)
for identifier, name, description, icon, value in self.items()
)
def property_group_enum(
*items: tuple[str, str, str, str, int],
) -> tuple[PropertyGroupEnum, Iterator[PropertyGroupEnumItem]]:
enum = PropertyGroupEnum(items)
return enum, enum.__iter__()
class StringPropertyGroup(PropertyGroup):
def get_value(self) -> str:
value = self.get("value")
if isinstance(value, str):
return value
return str(value)
def set_value(self, value: str) -> None:
self.name = value
self["value"] = value
value: StringProperty( # type: ignore[valid-type]
name="String Value",
get=get_value,
set=set_value,
)
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
value: str # type: ignore[no-redef]
class FloatPropertyGroup(PropertyGroup):
def get_value(self) -> float:
value = self.get("value")
if isinstance(value, (float, int)):
return float(value)
return 0.0
def set_value(self, value: float) -> None:
self.name = str(value)
self["value"] = value
value: FloatProperty( # type: ignore[valid-type]
name="Float Value",
get=get_value,
set=set_value,
)
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
value: float # type: ignore[no-redef]
class MeshObjectPropertyGroup(PropertyGroup):
def get_mesh_object_name(self) -> str:
bpy_object = self.bpy_object
if not bpy_object or not bpy_object.name or bpy_object.type != "MESH":
return ""
return str(bpy_object.name)
def set_mesh_object_name(self, value: object) -> None:
context = bpy.context
if (
not isinstance(value, str)
or not (obj := context.blend_data.objects.get(value))
or obj.type != "MESH"
):
if self.bpy_object:
self.bpy_object = None
return
if self.bpy_object != obj:
self.bpy_object = obj
mesh_object_name: StringProperty( # type: ignore[valid-type]
get=get_mesh_object_name, set=set_mesh_object_name
)
saved_mesh_object_name_to_restore: StringProperty() # type: ignore[valid-type]
def get_value(self) -> str:
message = (
"`MeshObjectPropertyGroup.value` is deprecated and will be removed in the"
" next major release. Please use `MeshObjectPropertyGroup.mesh_object_name`"
" instead."
)
logger.warning(message)
warnings.warn(message, DeprecationWarning, stacklevel=5)
return str(self.mesh_object_name)
def set_value(self, value: str) -> None:
message = (
"`MeshObjectPropertyGroup.value` is deprecated and will be removed in the"
" next major release. Please use `MeshObjectPropertyGroup.mesh_object_name`"
" instead."
)
logger.warning(message)
warnings.warn(message, DeprecationWarning, stacklevel=5)
self.mesh_object_name = value
value: StringProperty( # type: ignore[valid-type]
get=get_value,
set=set_value,
)
"""`value` is deprecated and will be removed in the next
major release. Please use `mesh_object_name` instead.
"""
def poll_bpy_object(self, obj: object) -> bool:
return isinstance(obj, Object) and obj.type == "MESH"
def update_bpy_object(self, _context: Context) -> None:
bpy_object = self.bpy_object
self.saved_mesh_object_name_to_restore = bpy_object.name if bpy_object else ""
bpy_object: PointerProperty( # type: ignore[valid-type]
type=Object,
poll=poll_bpy_object,
update=update_bpy_object,
)
def restore_object_assignment(self, context: Context) -> None:
if self.bpy_object:
return
obj = context.blend_data.objects.get(self.saved_mesh_object_name_to_restore)
if not obj:
return
self.set_mesh_object_name(obj.name)
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
mesh_object_name: str # type: ignore[no-redef]
saved_mesh_object_name_to_restore: str # type: ignore[no-redef]
value: str # type: ignore[no-redef]
bpy_object: Optional[Object] # type: ignore[no-redef]
class MaterialPropertyGroup(PropertyGroup):
material: PointerProperty( # type: ignore[valid-type]
type=Material,
)
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
material: Optional[Material] # type: ignore[no-redef]
class BonePropertyGroupType(Enum):
VRM0_FIRST_PERSON = 1
VRM0_HUMAN = 2
VRM0_COLLIDER_GROUP = 3
VRM0_BONE_GROUP_CENTER = 4
VRM0_BONE_GROUP = 5
VRM1_HUMAN = 6
SPRING_BONE1_COLLIDER = 7
SPRING_BONE1_SPRING_CENTER = 8
SPRING_BONE1_SPRING_JOINT = 9
@staticmethod
def is_vrm0(bone_property_group_type: "BonePropertyGroupType") -> bool:
return bone_property_group_type in {
BonePropertyGroupType.VRM0_FIRST_PERSON,
BonePropertyGroupType.VRM0_HUMAN,
BonePropertyGroupType.VRM0_COLLIDER_GROUP,
BonePropertyGroupType.VRM0_BONE_GROUP_CENTER,
BonePropertyGroupType.VRM0_BONE_GROUP,
}
@staticmethod
def is_vrm1(bone_property_group_type: "BonePropertyGroupType") -> bool:
return bone_property_group_type in {
BonePropertyGroupType.VRM1_HUMAN,
BonePropertyGroupType.SPRING_BONE1_COLLIDER,
BonePropertyGroupType.SPRING_BONE1_SPRING_CENTER,
BonePropertyGroupType.SPRING_BONE1_SPRING_JOINT,
}
class BonePropertyGroup(PropertyGroup):
@staticmethod
def get_all_bone_property_groups(
armature: Union[Object, Armature],
) -> Iterator[tuple["BonePropertyGroup", BonePropertyGroupType]]:
"""Return (bone: BonePropertyGroup, is_vrm0: bool, is_vrm1: bool)."""
from .extension import get_armature_extension
if isinstance(armature, Object):
armature_data = armature.data
if not isinstance(armature_data, Armature):
return
else:
armature_data = armature
ext = get_armature_extension(armature_data)
yield (
ext.vrm0.first_person.first_person_bone,
BonePropertyGroupType.VRM0_FIRST_PERSON,
)
for human_bone in ext.vrm0.humanoid.human_bones:
yield human_bone.node, BonePropertyGroupType.VRM0_HUMAN
for collider_group in ext.vrm0.secondary_animation.collider_groups:
yield collider_group.node, BonePropertyGroupType.VRM0_COLLIDER_GROUP
for bone_group in ext.vrm0.secondary_animation.bone_groups:
yield bone_group.center, BonePropertyGroupType.VRM0_BONE_GROUP_CENTER
yield from (
(bone, BonePropertyGroupType.VRM0_BONE_GROUP)
for bone in bone_group.bones
)
for (
human_bone
) in ext.vrm1.humanoid.human_bones.human_bone_name_to_human_bone().values():
yield human_bone.node, BonePropertyGroupType.VRM1_HUMAN
for collider in ext.spring_bone1.colliders:
yield collider.node, BonePropertyGroupType.SPRING_BONE1_COLLIDER
for spring in ext.spring_bone1.springs:
yield spring.center, BonePropertyGroupType.SPRING_BONE1_SPRING_CENTER
for joint in spring.joints:
yield joint.node, BonePropertyGroupType.SPRING_BONE1_SPRING_JOINT
@staticmethod
def find_bone_candidates(
armature_data: Armature,
target: HumanBoneSpecification,
bpy_bone_name_to_human_bone_specification: Mapping[str, HumanBoneSpecification],
error_bpy_bone_names: Sequence[str],
diagnostics_layout: Optional[UILayout] = None,
) -> set[str]:
bones = armature_data.bones
human_bone_name_to_bpy_bone_name = {
value.name: key
for key, value in bpy_bone_name_to_human_bone_specification.items()
}
search_conditions: list[str] = []
# If the target's ancestor has a Human Bone assignment, use that as the
# search start bone
searching_bones: Optional[list[Bone]] = None
ancestor: Optional[HumanBoneSpecification] = target.parent()
while ancestor:
ancestor_bpy_bone_name = human_bone_name_to_bpy_bone_name.get(ancestor.name)
if not ancestor_bpy_bone_name or not (
ancestor_bpy_bone := bones.get(ancestor_bpy_bone_name)
):
# If there's no Human Bone assignment, traverse to the parent
ancestor = ancestor.parent()
continue
if ancestor_bpy_bone.name in error_bpy_bone_names:
# If there's an error in the Human Bone assignment, return empty result
if diagnostics_layout:
diagnostics_message = pgettext(
'The bone assigned to the VRM Human Bone "{human_bone}" must'
+ " be descendants of the bones assigned \nto the VRM human"
+ ' bone "{parent_human_bone}". However, it cannot retrieve'
+ " bone candidates because there\nis an error in the"
+ ' assignment of the VRM Human Bone "{parent_human_bone}".'
+ " Please resolve the error in the\nassignment of the VRM"
+ ' Human Bone "{parent_human_bone}" first.'
).format(
human_bone=target.title,
parent_human_bone=ancestor.title,
)
diagnostics_column = diagnostics_layout.column(align=True)
for i, line in enumerate(diagnostics_message.splitlines()):
diagnostics_column.label(
text=line,
translate=False,
icon="ERROR" if i == 0 else "BLANK1",
)
return set()
# Use the child bones of the found ancestor bone as the search start bones
searching_bones = list(ancestor_bpy_bone.children)
if diagnostics_layout:
search_conditions.append(
pgettext(
'Being a descendant of the bone "{bpy_bone}" assigned'
+ ' to the VRM Human Bone "{human_bone}"'
).format(
human_bone=ancestor.title,
bpy_bone=ancestor_bpy_bone_name,
)
)
break
root_bones: Final[Sequence[Bone]] = [
bone for bone in bones.values() if not bone.parent
]
# If no ancestor bone is found, use the root bone as the search start bone
if searching_bones is None and len(root_bones) > 1:
# If there are multiple root bones,
# select the root bone of the already assigned bone
human_bone_specification = None
for (
bpy_bone_name,
human_bone_specification,
) in bpy_bone_name_to_human_bone_specification.items():
if not bpy_bone_name:
continue
if bpy_bone_name in error_bpy_bone_names:
continue
bpy_bone = bones.get(bpy_bone_name)
if not bpy_bone:
continue
main_root_bone: Optional[Bone] = None
parent: Optional[Bone] = bpy_bone
while parent:
main_root_bone = parent
parent = parent.parent
if not main_root_bone:
continue
searching_bones = [main_root_bone]
if diagnostics_layout:
search_conditions.append(
pgettext(
'Sharing the root bone with the bone "{bpy_bone}" assigned'
+ ' to the VRM Human Bone "{human_bone}"'
).format(
human_bone=human_bone_specification.title,
bpy_bone=bpy_bone_name,
)
)
break
if searching_bones is None:
searching_bones = list(root_bones)
# Bone candidates
bone_candidates: set[Bone] = set(searching_bones)
# First, register all descendant bones as candidates
filling_bones = searching_bones.copy()
while filling_bones:
filling_bone = filling_bones.pop()
bone_candidates.update(filling_bone.children)
filling_bones.extend(filling_bone.children)
removing_bone_tree = set[Bone]()
# Traverse descendant bones and when we encounter an already assigned bone,
# examine its relationship and exclude unnecessary candidates.
while searching_bones:
searching_bone = searching_bones.pop()
human_bone_specification = bpy_bone_name_to_human_bone_specification.get(
searching_bone.name
)
# If not assigned to a Human Bone or if it's the target bone,
# recursively traverse child bones
if (
human_bone_specification is None
or human_bone_specification == target
or searching_bone.name in error_bpy_bone_names
):
searching_bones.extend(searching_bone.children)
continue
if human_bone_specification.is_ancestor_of(target):
# If an ancestor bone has an assignment
# This case shouldn't exist since we start from the nearest
# ancestor bone
continue
if target.is_ancestor_of(human_bone_specification):
# If a descendant bone has an assignment:
# - Exclude that bone and its descendants
# - Exclude branches when traversing from that bone to the root bone
removing_bone_tree.add(searching_bone)
parent = searching_bone
while parent:
grand_parent = parent.parent
if grand_parent is None:
# If parent is a root bone,
# exclude other root bones except parent
removing_bone_tree.update(
root_bone for root_bone in root_bones if root_bone != parent
)
break
# Exclude sibling bones of parent
removing_bone_tree.update(
parent_sibling
for parent_sibling in grand_parent.children
if parent_sibling != parent
)
parent = grand_parent
if diagnostics_layout:
search_conditions.append(
pgettext(
'Being an ancestor of the bone "{bpy_bone}" assigned'
+ ' to the VRM Human Bone "{human_bone}"'
).format(
human_bone=human_bone_specification.title,
bpy_bone=searching_bone.name,
)
)
else:
# If a bone that is neither ancestor nor descendant has an assignment:
# - Exclude that bone and its descendants
# - Exclude that bone and its ancestors
removing_bone_tree.add(searching_bone)
parent = searching_bone
while parent:
bone_candidates.discard(parent)
parent = parent.parent
if diagnostics_layout:
search_conditions.append(
pgettext(
'Not being an ancestor of the bone "{bpy_bone}" assigned'
+ ' to the VRM Human Bone "{human_bone}"'
).format(
human_bone=human_bone_specification.title,
bpy_bone=searching_bone.name,
)
)
bone_candidates.difference_update(removing_bone_tree)
while removing_bone_tree:
removing_bone = removing_bone_tree.pop()
bone_candidates.difference_update(removing_bone.children)
removing_bone_tree.update(removing_bone.children)
if diagnostics_layout and search_conditions:
diagnostics_layout.label(
text=pgettext(
"Bones that meet all of the following conditions will be "
+ "candidates for assignment:"
),
translate=False,
icon="INFO",
)
for search_condition in sorted(search_conditions):
diagnostics_layout.label(
text=search_condition,
translate=False,
icon="DOT",
)
return {bone_cancidate.name for bone_cancidate in bone_candidates}
armature_data_name_and_bone_uuid_to_bone_name_cache: ClassVar[
dict[tuple[str, str], str]
] = {}
def get_bone_name(self) -> str:
from .extension import get_bone_extension
if not self.bone_uuid:
return ""
armature_data = self.find_armature()
# Use cache to speed up this function since profiling showed it was slow
cache_key = (armature_data.name, self.bone_uuid)
cached_bone_name = self.armature_data_name_and_bone_uuid_to_bone_name_cache.get(
cache_key
)
if cached_bone_name is not None:
if (
cached_bone := armature_data.bones.get(cached_bone_name)
) and get_bone_extension(cached_bone).uuid == self.bone_uuid:
return cached_bone_name
# If there's even one old value in the cache, clear everything for safety
self.armature_data_name_and_bone_uuid_to_bone_name_cache.clear()
for bone in armature_data.bones:
if get_bone_extension(bone).uuid == self.bone_uuid:
self.armature_data_name_and_bone_uuid_to_bone_name_cache[cache_key] = (
bone.name
)
return bone.name
return ""
def find_armature(self) -> Armature:
id_data = self.id_data
if isinstance(id_data, Armature):
return id_data
message = (
f"{type(self)}/{self}.id_data is not a {Armature}"
+ f" but {type(id_data)}/{id_data}"
)
raise AssertionError(message)
def get_bone_property_group_type(self) -> BonePropertyGroupType:
armature_data = self.find_armature()
for (
bone_property_group,
bone_property_group_type,
) in self.get_all_bone_property_groups(armature_data):
if bone_property_group == self:
return bone_property_group_type
message = (
f"{type(self)}/{self} is not associated with "
+ "BonePropertyGroupType.get_all_bone_property_groups"
)
raise AssertionError(message)
def set_bone_name(self, value: str) -> None:
from .extension import get_armature_extension, get_bone_extension
context = bpy.context
armature_data = self.find_armature()
armature_objects = [
obj for obj in context.blend_data.objects if obj.data == armature_data
]
bone_property_group_type = self.get_bone_property_group_type()
# Assign UUIDs and regenerate it if duplication exist
found_uuids: set[str] = set()
for bone in armature_data.bones:
bone_extension = get_bone_extension(bone)
found_uuid = bone_extension.uuid
if not found_uuid or found_uuid in found_uuids:
bone_extension.uuid = uuid.uuid4().hex
self.armature_data_name_and_bone_uuid_to_bone_name_cache.clear()
found_uuids.add(bone_extension.uuid)
if not value or not (bone := armature_data.bones.get(value)):
if not self.bone_uuid:
# Not changed
return
self.bone_uuid = ""
elif self.bone_uuid and self.bone_uuid == get_bone_extension(bone).uuid:
# Not changed
return
else:
self.bone_uuid = get_bone_extension(bone).uuid
ext = get_armature_extension(armature_data)
if bone_property_group_type in [
BonePropertyGroupType.VRM0_COLLIDER_GROUP,
BonePropertyGroupType.VRM0_BONE_GROUP,
]:
for collider_group in ext.vrm0.secondary_animation.collider_groups:
for armature_object in armature_objects:
collider_group.refresh(armature_object)
if bone_property_group_type == BonePropertyGroupType.SPRING_BONE1_COLLIDER:
for collider in ext.spring_bone1.colliders:
for armature_object in armature_objects:
collider.reset_bpy_object(context, armature_object)
if (
ext.is_vrm0()
and bone_property_group_type == BonePropertyGroupType.VRM0_HUMAN
):
self.update_all_vrm0_node_candidates(armature_data)
if (
ext.is_vrm1()
and bone_property_group_type == BonePropertyGroupType.VRM1_HUMAN
):
self.update_all_vrm1_node_candidates(armature_data)
@staticmethod
def update_all_vrm0_node_candidates(armature_data: Armature) -> None:
from .extension import get_armature_extension
ext = get_armature_extension(armature_data)
bpy_bone_name_to_human_bone_specification: dict[
str, vrm0_human_bone.HumanBoneSpecification
] = {}
for human_bone in ext.vrm0.humanoid.human_bones:
if not human_bone.node.bone_name:
continue
human_bone_name = vrm0_human_bone.HumanBoneName.from_str(human_bone.bone)
if human_bone_name is None:
continue
bpy_bone_name_to_human_bone_specification[human_bone.node.bone_name] = (
vrm0_human_bone.HumanBoneSpecifications.get(human_bone_name)
)
# Set from parent to child since child nodes will always have errors
# if parent node has errors
traversing_human_bone_specifications = [
vrm0_human_bone.HumanBoneSpecifications.HIPS
]
error_bpy_bone_names = []
node_candidates_updated = True
while traversing_human_bone_specifications:
traversing_human_bone_specification = (
traversing_human_bone_specifications.pop()
)
if node_candidates_updated:
error_bpy_bone_names = [
error_human_bone.node.bone_name
for error_human_bone in ext.vrm0.humanoid.human_bones
if error_human_bone.node.bone_name
and error_human_bone.node.bone_name
not in error_human_bone.node_candidates
]
human_bone = next(
(
human_bone
for human_bone in ext.vrm0.humanoid.human_bones
if vrm0_human_bone.HumanBoneName.from_str(human_bone.bone)
== traversing_human_bone_specification.name
),
None,
)
if human_bone:
node_candidates_updated = human_bone.update_node_candidates(
armature_data,
bpy_bone_name_to_human_bone_specification,
error_bpy_bone_names,
)
traversing_human_bone_specifications.extend(
traversing_human_bone_specification.children()
)
@staticmethod
def update_all_vrm1_node_candidates(armature_data: Armature) -> None:
from .extension import get_armature_extension
ext = get_armature_extension(armature_data)
human_bone_name_to_human_bone = (
ext.vrm1.humanoid.human_bones.human_bone_name_to_human_bone()
)
bpy_bone_name_to_human_bone_specification: dict[
str, vrm1_human_bone.HumanBoneSpecification
] = {}
for human_bone_name, human_bone in human_bone_name_to_human_bone.items():
if not human_bone.node.bone_name:
continue
bpy_bone_name_to_human_bone_specification[human_bone.node.bone_name] = (
vrm1_human_bone.HumanBoneSpecifications.get(human_bone_name)
)
# Set from parent to child since child nodes will always have errors
# if parent node has errors
traversing_human_bone_specifications = [
vrm1_human_bone.HumanBoneSpecifications.HIPS
]
error_bpy_bone_names = []
node_candidates_updated = True
while traversing_human_bone_specifications:
traversing_human_bone_specification = (
traversing_human_bone_specifications.pop()
)
if node_candidates_updated:
error_bpy_bone_names = [
error_human_bone.node.bone_name
for error_human_bone in human_bone_name_to_human_bone.values()
if error_human_bone.node.bone_name
and error_human_bone.node.bone_name
not in error_human_bone.node_candidates
]
human_bone = human_bone_name_to_human_bone[
traversing_human_bone_specification.name
]
node_candidates_updated = human_bone.update_node_candidates(
armature_data,
traversing_human_bone_specification,
bpy_bone_name_to_human_bone_specification,
error_bpy_bone_names,
)
traversing_human_bone_specifications.extend(
traversing_human_bone_specification.children()
)
bone_name: StringProperty( # type: ignore[valid-type]
name="Bone",
get=get_bone_name,
set=set_bone_name,
)
def get_value(self) -> str:
message = (
"`BonePropertyGroup.value` is deprecated and will be removed in the"
" next major release. Please use `BonePropertyGroup.bone_name` instead."
)
logger.warning(message)
warnings.warn(message, DeprecationWarning, stacklevel=5)
return str(self.bone_name)
def set_value(self, value: str) -> None:
message = (
"`BonePropertyGroup.value` is deprecated and will be removed in the"
" next major release. Please use `BonePropertyGroup.bone_name` instead."
)
logger.warning(message)
warnings.warn(message, DeprecationWarning, stacklevel=5)
self.bone_name = value
value: StringProperty( # type: ignore[valid-type]
name="Bone",
get=get_value,
set=set_value,
)
"""`value` is deprecated and will be removed in the next major
release. Please use `bone_name` instead."
"""
bone_uuid: StringProperty() # type: ignore[valid-type]
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
bone_name: str # type: ignore[no-redef]
value: str # type: ignore[no-redef]
bone_uuid: str # type: ignore[no-redef]
T_co = TypeVar("T_co", covariant=True)
# The actual type does not take type arguments.
class CollectionPropertyProtocol(Protocol[T_co]):
def add(self) -> T_co: ... # TODO: undocumented
def __len__(self) -> int: ... # TODO: undocumented
def __iter__(self) -> Iterator[T_co]: ... # TODO: undocumented
def clear(self) -> None: ... # TODO: undocumented
@overload
def __getitem__(self, index: int) -> T_co: ... # TODO: undocumented
@overload
def __getitem__(
self, index: "slice[Optional[int], Optional[int], Optional[int]]"
) -> tuple[T_co, ...]: ... # TODO: undocumented
def remove(self, index: int) -> None: ... # TODO: undocumented
def values(self) -> ValuesView[T_co]: ... # TODO: undocumented
def __contains__(self, value: str) -> bool: ... # TODO: undocumented
def move(self, from_index: int, to_index: int) -> None: ... # TODO: undocumented

721
editor/search.py Normal file
View File

@@ -0,0 +1,721 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Final, Optional, Union
import bpy
from bpy.app.translations import pgettext
from bpy.types import (
Armature,
Collection,
Constraint,
Context,
CopyRotationConstraint,
Curve,
DampedTrackConstraint,
Material,
Mesh,
Object,
ObjectConstraints,
PoseBoneConstraints,
ShaderNodeGroup,
ShaderNodeOutputMaterial,
)
from ..common.logger import get_logger
from .extension import get_armature_extension
logger = get_logger(__name__)
MESH_CONVERTIBLE_OBJECT_TYPES = [
"CURVE",
"FONT",
"MESH",
# "META", # Disable until the glTF 2.0 add-on supports it
"SURFACE",
]
def export_materials(context: Context, objects: Sequence[Object]) -> Sequence[Material]:
result: list[Material] = []
for obj in objects:
if obj.type not in MESH_CONVERTIBLE_OBJECT_TYPES:
continue
mesh_convertible = obj.data
if isinstance(mesh_convertible, (Curve, Mesh)):
for material_ref in mesh_convertible.materials:
if not isinstance(material_ref, Material):
continue
material = context.blend_data.materials.get(material_ref.name)
if not isinstance(material, Material):
continue
result.append(material)
else:
logger.error(
"Unexpected mesh convertible object type: %s", type(mesh_convertible)
)
for material_slot in obj.material_slots:
if not material_slot:
continue
material_ref = material_slot.material
if not material_ref:
continue
material = context.blend_data.materials.get(material_ref.name)
if not isinstance(material, Material):
continue
result.append(material)
return list(dict.fromkeys(result)) # Remove duplicates
LEGACY_SHADER_NAMES: Final = (
"MToon_unversioned",
"GLTF",
"TRANSPARENT_ZWRITE",
)
def legacy_shader_node(
material: Material,
) -> tuple[Optional[ShaderNodeGroup], Optional[str]]:
if bpy.app.version < (5, 0, 0) and not material.use_nodes:
return (None, None)
node_tree = material.node_tree
if not node_tree:
return (None, None)
for node in node_tree.nodes:
if node.mute:
continue
if not isinstance(node, ShaderNodeOutputMaterial):
continue
surface_socket = node.inputs.get("Surface")
if not surface_socket:
continue
links = surface_socket.links
if not links:
continue
link = links[0]
if not link.is_valid or link.is_muted:
continue
group_node = link.from_node
if group_node.mute:
continue
if not isinstance(group_node, ShaderNodeGroup):
continue
group_node_tree = group_node.node_tree
if not group_node_tree:
continue
legacy_shader_name = group_node_tree.get("SHADER")
if not isinstance(legacy_shader_name, str):
continue
return (group_node, legacy_shader_name)
return (None, None)
def shader_nodes_and_materials(
materials: Sequence[Material],
) -> Sequence[tuple[ShaderNodeGroup, str, Material]]:
result: list[tuple[ShaderNodeGroup, str, Material]] = []
for material in materials:
node, legacy_shader_name = legacy_shader_node(material)
if node is not None and legacy_shader_name is not None:
result.append((node, legacy_shader_name, material))
return result
def object_distance(
left: Object,
right: Object,
collection_child_to_parent: dict[Collection, Optional[Collection]],
) -> tuple[int, int, int, int]:
left_collection_path: list[Collection] = []
left_collections = [
collection
for collection in left.users_collection
if collection in collection_child_to_parent
]
if left_collections:
left_collection: Optional[Collection] = left_collections[0]
while left_collection:
left_collection_path.insert(0, left_collection)
left_collection = collection_child_to_parent.get(left_collection)
right_collection_path: list[Collection] = []
right_collections = [
collection
for collection in right.users_collection
if collection in collection_child_to_parent
]
if right_collections:
right_collection: Optional[Collection] = right_collections[0]
while right_collection:
right_collection_path.insert(0, right_collection)
right_collection = collection_child_to_parent.get(right_collection)
while (
left_collection_path
and right_collection_path
and left_collection_path[0] == right_collection_path[0]
):
left_collection_path.pop(0)
right_collection_path.pop(0)
left_parent_path: list[Object] = []
traversing_left: Optional[Object] = left
while traversing_left:
left_parent_path.insert(0, traversing_left)
traversing_left = traversing_left.parent
right_parent_path: list[Object] = []
traversing_right: Optional[Object] = right
while traversing_right:
right_parent_path.insert(0, traversing_right)
traversing_right = traversing_right.parent
while (
left_parent_path
and right_parent_path
and left_parent_path[0] == right_parent_path[0]
):
left_parent_path.pop(0)
right_parent_path.pop(0)
return (
len(left_parent_path),
len(right_parent_path),
len(left_collection_path),
len(right_collection_path),
)
def armature_exists(context: Context) -> bool:
return any(armature.users > 0 for armature in context.blend_data.armatures) and any(
obj.type == "ARMATURE" for obj in context.blend_data.objects
)
def current_armature_is_vrm0(context: Context) -> bool:
live_armature_datum = [
armature_data
for armature_data in context.blend_data.armatures
if armature_data.users
]
if not live_armature_datum:
return False
if all(
get_armature_extension(armature_data).is_vrm0()
for armature_data in live_armature_datum
) and any(obj.type == "ARMATURE" for obj in context.blend_data.objects):
return True
armature = current_armature(context)
if armature is None:
return False
armature_data = armature.data
if not isinstance(armature_data, Armature):
return False
return get_armature_extension(armature_data).is_vrm0()
def current_armature_is_vrm1(context: Context) -> bool:
live_armature_datum = [
armature_data
for armature_data in context.blend_data.armatures
if armature_data.users
]
if not live_armature_datum:
return False
if all(
get_armature_extension(armature_data).is_vrm1()
for armature_data in live_armature_datum
) and any(obj.type == "ARMATURE" for obj in context.blend_data.objects):
return True
armature = current_armature(context)
if armature is None:
return False
armature_data = armature.data
if not isinstance(armature_data, Armature):
return False
return get_armature_extension(armature_data).is_vrm1()
def multiple_armatures_exist(context: Context) -> bool:
first_data_exists = False
for data in context.blend_data.armatures:
if not data.users:
continue
if not first_data_exists:
first_data_exists = True
continue
first_obj_exists = False
for obj in context.blend_data.objects:
if obj.type == "ARMATURE":
if first_obj_exists:
return True
first_obj_exists = True
break
return False
def current_armature(context: Context) -> Optional[Object]:
objects = [obj for obj in context.blend_data.objects if obj.type == "ARMATURE"]
if not objects:
return None
if len(objects) == 1:
return objects[0]
active_object = getattr(context, "active_object", None)
if not isinstance(active_object, Object):
active_object = context.view_layer.objects.active
if not active_object:
return objects[0]
collection_child_to_parent: dict[Collection, Optional[Collection]] = {
context.scene.collection: None
}
collections = [context.scene.collection]
while collections:
parent = collections.pop()
for child in parent.children:
collections.append(child)
collection_child_to_parent[child] = parent
min_distance: Optional[tuple[int, int, int, int]] = None
nearest_object: Optional[Object] = None
for obj in objects:
distance = object_distance(active_object, obj, collection_child_to_parent)
if min_distance is None or min_distance > distance:
min_distance = distance
nearest_object = obj
return objects[0] if nearest_object is None else nearest_object
def export_objects(
context: Context,
armature_object_name: Optional[str],
*,
export_invisibles: bool,
export_only_selections: bool,
export_lights: bool,
) -> list[Object]:
selected_objects = []
if export_only_selections:
selected_objects = list(context.selected_objects)
elif export_invisibles:
selected_objects = list(context.blend_data.objects)
else:
selected_objects = list(context.selectable_objects)
objects: list[Object] = []
# https://projects.blender.org/blender/blender/issues/113378
context.view_layer.update()
armature_object = None
if armature_object_name:
armature_object = context.blend_data.objects.get(armature_object_name)
if armature_object and armature_object.name in context.view_layer.objects:
objects.append(armature_object)
else:
armature_object = None
if not armature_object:
objects.extend(
obj
for obj in context.blend_data.objects
if obj.type == "ARMATURE" and obj.name in context.view_layer.objects
)
collider_bpy_objects: list[Optional[Object]] = []
for armature_data in context.blend_data.armatures:
ext = get_armature_extension(armature_data)
for spring_bone1_collider in ext.spring_bone1.colliders:
spring_bone1_collider_bpy_object = spring_bone1_collider.bpy_object
if not spring_bone1_collider_bpy_object:
continue
collider_bpy_objects.append(spring_bone1_collider_bpy_object)
collider_bpy_objects.extend(spring_bone1_collider_bpy_object.children)
for collider_group in ext.vrm0.secondary_animation.collider_groups:
collider_bpy_objects.extend(
collider.bpy_object for collider in collider_group.colliders
)
for obj in selected_objects:
if obj.type in ["ARMATURE", "CAMERA"]:
continue
if obj.type == "LIGHT" and not export_lights:
continue
if obj.name not in context.view_layer.objects:
continue
if not export_invisibles and not obj.visible_get():
continue
objects.append(obj)
return [
# Remove colliders and duplicates
obj
for obj in dict.fromkeys(objects)
if obj not in collider_bpy_objects
]
@dataclass(frozen=True)
class ExportConstraint:
roll_constraints: dict[str, CopyRotationConstraint]
aim_constraints: dict[str, DampedTrackConstraint]
rotation_constraints: dict[str, CopyRotationConstraint]
@property
def all_constraints(self) -> list[tuple[str, Constraint]]:
all_constraints: list[tuple[str, Constraint]] = []
all_constraints.extend(self.roll_constraints.items())
all_constraints.extend(self.aim_constraints.items())
all_constraints.extend(self.rotation_constraints.items())
return all_constraints
def roll_constraint_or_none(
constraint: Constraint,
objs: Sequence[Object],
armature: Object,
) -> Optional[CopyRotationConstraint]:
if (
not isinstance(constraint, CopyRotationConstraint)
or not constraint.is_valid
or constraint.mute
or not (constraint_target := constraint.target)
or constraint_target not in objs
or constraint.mix_mode != "ADD"
or (int(constraint.use_x) + int(constraint.use_y) + int(constraint.use_z)) != 1
or constraint.owner_space != "LOCAL"
or constraint.target_space != "LOCAL"
):
return None
if constraint_target.type != "ARMATURE":
return constraint
if constraint_target != armature:
return None
armature_data = armature.data
if (
not isinstance(armature_data, Armature)
or constraint.subtarget not in armature_data.bones
):
return None
return constraint
def aim_constraint_or_none(
constraint: Constraint,
objs: Sequence[Object],
armature: Object,
) -> Optional[DampedTrackConstraint]:
if (
not isinstance(constraint, DampedTrackConstraint)
or not constraint.is_valid
or constraint.mute
or not (constraint_target := constraint.target)
or constraint_target not in objs
):
return None
if constraint_target.type != "ARMATURE":
return constraint
if constraint_target != armature:
return None
armature_data = armature.data
if (
not isinstance(armature_data, Armature)
or constraint.subtarget not in armature_data.bones
or abs(constraint.head_tail) > 0
):
return None
return constraint
def rotation_constraint_or_none(
constraint: Constraint,
objs: Sequence[Object],
armature: Object,
) -> Optional[CopyRotationConstraint]:
if (
not isinstance(constraint, CopyRotationConstraint)
or not constraint.is_valid
or constraint.mute
or not (constraint_target := constraint.target)
or constraint.target not in objs
or constraint.invert_x
or constraint.invert_y
or constraint.invert_z
or constraint.mix_mode != "ADD"
or not constraint.use_x
or not constraint.use_y
or not constraint.use_z
or constraint.owner_space != "LOCAL"
or constraint.target_space != "LOCAL"
):
return None
if constraint_target.type != "ARMATURE":
return constraint
if constraint_target != armature:
return None
armature_data = armature.data
if (
not isinstance(armature_data, Armature)
or constraint.subtarget not in armature_data.bones
):
return None
return constraint
def export_object_constraints(
objs: Sequence[Object],
armature: Object,
) -> ExportConstraint:
roll_constraints: dict[str, CopyRotationConstraint] = {}
aim_constraints: dict[str, DampedTrackConstraint] = {}
rotation_constraints: dict[str, CopyRotationConstraint] = {}
for obj in objs:
for constraint in obj.constraints:
roll_constraint = roll_constraint_or_none(constraint, objs, armature)
if roll_constraint:
roll_constraints[obj.name] = roll_constraint
break
aim_constraint = aim_constraint_or_none(constraint, objs, armature)
if aim_constraint:
aim_constraints[obj.name] = aim_constraint
break
rotation_constraint = rotation_constraint_or_none(
constraint, objs, armature
)
if rotation_constraint:
rotation_constraints[obj.name] = rotation_constraint
break
return ExportConstraint(
roll_constraints=roll_constraints,
aim_constraints=aim_constraints,
rotation_constraints=rotation_constraints,
)
def export_bone_constraints(
objs: Sequence[Object],
armature: Object,
) -> ExportConstraint:
roll_constraints: dict[str, CopyRotationConstraint] = {}
aim_constraints: dict[str, DampedTrackConstraint] = {}
rotation_constraints: dict[str, CopyRotationConstraint] = {}
for bone in armature.pose.bones:
for constraint in bone.constraints:
roll_constraint = roll_constraint_or_none(constraint, objs, armature)
if roll_constraint:
roll_constraints[bone.name] = roll_constraint
break
aim_constraint = aim_constraint_or_none(constraint, objs, armature)
if aim_constraint:
aim_constraints[bone.name] = aim_constraint
break
rotation_constraint = rotation_constraint_or_none(
constraint, objs, armature
)
if rotation_constraint:
rotation_constraints[bone.name] = rotation_constraint
break
return ExportConstraint(
roll_constraints=roll_constraints,
aim_constraints=aim_constraints,
rotation_constraints=rotation_constraints,
)
def export_constraints(
objs: Sequence[Object],
armature: Object,
) -> tuple[ExportConstraint, ExportConstraint, Sequence[str]]:
messages: list[str] = []
object_constraints = export_object_constraints(objs, armature)
bone_constraints = export_bone_constraints(objs, armature)
all_roll_constraints: list[CopyRotationConstraint] = list(
object_constraints.roll_constraints.values()
) + list(bone_constraints.roll_constraints.values())
all_rotation_constraints: list[CopyRotationConstraint] = list(
object_constraints.rotation_constraints.values()
) + list(bone_constraints.rotation_constraints.values())
# TODO: Aim Constraint's circular dependency detection
# + list(object_constraints.aim_constraints.values())
# + list(bone_constraints.aim_constraints.values())
all_constraints: list[Constraint] = []
all_constraints.extend(all_roll_constraints)
all_constraints.extend(all_rotation_constraints)
excluded_constraints: list[
Union[
CopyRotationConstraint,
DampedTrackConstraint,
]
] = []
for search_constraint in all_constraints:
if not isinstance(search_constraint, CopyRotationConstraint):
continue
current_constraints = [
(
search_constraint,
(
search_constraint.use_x,
search_constraint.use_y,
search_constraint.use_z,
),
)
]
iterated_constraints: set[Constraint] = set()
while current_constraints:
current_constraint, current_axis = current_constraints.pop()
if current_constraint in iterated_constraints:
break
iterated_constraints.add(current_constraint)
found = False
target = current_constraint.target
if not target:
continue
if target.type == "ARMATURE":
bone = target.pose.bones[current_constraint.subtarget]
owner_name = bone.name
target_constraints: Union[ObjectConstraints, PoseBoneConstraints] = (
bone.constraints
)
else:
owner_name = target.name
target_constraints = target.constraints
for target_constraint in target_constraints:
if not isinstance(target_constraint, CopyRotationConstraint):
continue
if target_constraint not in all_constraints:
continue
next_target_axis = (
current_axis[0] and target_constraint.use_x,
current_axis[1] and target_constraint.use_y,
current_axis[2] and target_constraint.use_z,
)
if next_target_axis == (False, False, False):
continue
if target_constraint != search_constraint:
current_constraints.append((target_constraint, next_target_axis))
continue
excluded_constraints.append(search_constraint)
messages.append(
pgettext(
'Node Constraint "{owner_name} / {constraint_name}" has'
+ " a circular dependency"
).format(
owner_name=owner_name,
constraint_name=search_constraint.name,
)
)
found = True
break
if found:
break
return (
ExportConstraint(
roll_constraints={
k: v
for k, v in object_constraints.roll_constraints.items()
if v not in excluded_constraints
},
aim_constraints={
k: v
for k, v in object_constraints.aim_constraints.items()
if v not in excluded_constraints
},
rotation_constraints={
k: v
for k, v in object_constraints.rotation_constraints.items()
if v not in excluded_constraints
},
),
ExportConstraint(
roll_constraints={
k: v
for k, v in bone_constraints.roll_constraints.items()
if v not in excluded_constraints
},
aim_constraints={
k: v
for k, v in bone_constraints.aim_constraints.items()
if v not in excluded_constraints
},
rotation_constraints={
k: v
for k, v in bone_constraints.rotation_constraints.items()
if v not in excluded_constraints
},
),
messages,
)
def active_object_is_vrm1_armature(context: Context) -> bool:
active_object = context.active_object
if not active_object:
return False
if active_object.type != "ARMATURE":
return False
armature_data = active_object.data
if not isinstance(armature_data, Armature):
return False
return get_armature_extension(armature_data).is_vrm1()
def active_object_is_vrm0_armature(context: Context) -> bool:
active_object = context.active_object
if not active_object:
return False
if active_object.type != "ARMATURE":
return False
armature_data = active_object.data
if not isinstance(armature_data, Armature):
return False
return get_armature_extension(armature_data).is_vrm0()

View File

@@ -0,0 +1 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,858 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from decimal import Decimal
from sys import float_info
from typing import Optional, Union
import bpy
from bpy.app.handlers import persistent
from bpy.types import Armature, Context, Object, PoseBone
from mathutils import Matrix, Quaternion, Vector
from ...common.rotation import (
get_rotation_as_quaternion,
set_rotation_without_mode_change,
)
from ..extension import get_armature_extension
from ..property_group import CollectionPropertyProtocol
from .property_group import (
SpringBone1JointPropertyGroup,
SpringBone1SpringPropertyGroup,
)
@dataclass
class State:
frame_count: Decimal = Decimal()
spring_bone_60_fps_update_count: Decimal = Decimal()
last_fps: Optional[Decimal] = None
last_fps_base: Optional[Decimal] = None
def reset(self, context: Context) -> None:
self.frame_count = Decimal()
self.spring_bone_60_fps_update_count = Decimal()
self.last_fps_base = Decimal(context.scene.render.fps_base)
self.last_fps = Decimal(context.scene.render.fps)
state = State()
def reset_state(context: Context) -> None:
state.reset(context)
@dataclass(frozen=True)
class SphereWorldCollider:
offset: Vector
radius: float
def calculate_collision(
self, target: Vector, target_radius: float
) -> tuple[Vector, float]:
diff = target - self.offset
diff_length = diff.length
if diff_length < float_info.epsilon:
return Vector((0, 0, -1)), -0.01
return diff / diff_length, diff_length - target_radius - self.radius
@dataclass(frozen=True)
class CapsuleWorldCollider:
offset: Vector
radius: float
tail: Vector
offset_to_tail_diff: Vector # Must be non-zero vector
offset_to_tail_diff_length_squared: float # Must be non-negative value
def calculate_collision(
self, target: Vector, target_radius: float
) -> tuple[Vector, float]:
offset_to_target_diff = target - self.offset
# Find the shortest point on the line containing offset and tail to the target
# self.offset + (self.tail - self.offset) * offset_to_tail_ratio_for_nearest
# Calculate offset_to_tail_ratio_for_nearest to express it as the above formula
offset_to_tail_ratio_for_nearest = (
self.offset_to_tail_diff.dot(offset_to_target_diff)
/ self.offset_to_tail_diff_length_squared
)
# The line segment from offset to tail has start point 0 and end point 1,
# so clamp outside ranges
offset_to_tail_ratio_for_nearest = max(
0, min(1, offset_to_tail_ratio_for_nearest)
)
# Calculate the shortest point to the target
nearest = (
self.offset + self.offset_to_tail_diff * offset_to_tail_ratio_for_nearest
)
# Collision detection
diff = target - nearest
diff_length = diff.length
if diff_length < float_info.epsilon:
return Vector((0, 0, -1)), -0.01
return diff / diff_length, diff_length - target_radius - self.radius
@dataclass(frozen=True)
class SphereInsideWorldCollider:
offset: Vector
radius: float
def calculate_collision(
self, target: Vector, target_radius: float
) -> tuple[Vector, float]:
diff = self.offset - target
diff_length = diff.length
if diff_length < float_info.epsilon:
return Vector((0, 0, -1)), -0.01
return diff / diff_length, -diff_length - target_radius + self.radius
@dataclass(frozen=True)
class CapsuleInsideWorldCollider:
offset: Vector
radius: float
tail: Vector
offset_to_tail_diff: Vector # Must be non-zero vector
offset_to_tail_diff_length_squared: float # Must be non-negative value
def calculate_collision(
self, target: Vector, target_radius: float
) -> tuple[Vector, float]:
offset_to_target_diff = target - self.offset
# Find the shortest point on the line containing offset and tail to the target
# self.offset + (self.tail - self.offset) * offset_to_tail_ratio_for_nearest
# Calculate offset_to_tail_ratio_for_nearest to express it as the above formula
offset_to_tail_ratio_for_nearest = (
self.offset_to_tail_diff.dot(offset_to_target_diff)
/ self.offset_to_tail_diff_length_squared
)
# The line segment from offset to tail has start point 0 and end point 1,
# so clamp outside ranges
offset_to_tail_ratio_for_nearest = max(
0, min(1, offset_to_tail_ratio_for_nearest)
)
# Calculate the shortest point to the target
nearest = (
self.offset + self.offset_to_tail_diff * offset_to_tail_ratio_for_nearest
)
# Collision detection
diff = nearest - target
diff_length = diff.length
if diff_length < float_info.epsilon:
return Vector((0, 0, -1)), -0.01
return diff / diff_length, -diff_length - target_radius + self.radius
@dataclass(frozen=True)
class PlaneWorldCollider:
offset: Vector
normal: Vector
def calculate_collision(
self, target: Vector, target_radius: float
) -> tuple[Vector, float]:
distance = (target - self.offset).dot(self.normal) - target_radius
return self.normal, distance
# https://github.com/vrm-c/vrm-specification/tree/993a90a5bda9025f3d9e2923ad6dea7506f88553/specification/VRMC_springBone-1.0#update-procedure
def update_pose_bone_rotations(context: Context, delta_time: float) -> None:
pose_bone_and_rotations: list[tuple[PoseBone, Quaternion]] = []
for obj in context.blend_data.objects:
calculate_object_pose_bone_rotations(delta_time, obj, pose_bone_and_rotations)
for pose_bone, pose_bone_rotation in pose_bone_and_rotations:
# Assigning rotation to pose_bone is expensive, so avoid it as much as possible
angle_diff = pose_bone_rotation.rotation_difference(
get_rotation_as_quaternion(pose_bone)
).angle
if abs(angle_diff) < float_info.epsilon:
continue
set_rotation_without_mode_change(pose_bone, pose_bone_rotation)
def calculate_object_pose_bone_rotations(
delta_time: float,
obj: Object,
pose_bone_and_rotations: list[tuple[PoseBone, Quaternion]],
) -> None:
if obj.type != "ARMATURE":
return
armature_data = obj.data
if not isinstance(armature_data, Armature):
return
ext = get_armature_extension(armature_data)
if not ext.is_vrm1():
return
spring_bone1 = ext.spring_bone1
if not spring_bone1.enable_animation:
return
obj_matrix_world = obj.matrix_world
obj_matrix_world_inverted = obj_matrix_world.inverted_safe()
obj_matrix_world_quaternion = obj_matrix_world.to_quaternion()
collider_uuid_to_world_collider: dict[
str,
Union[
SphereWorldCollider,
CapsuleWorldCollider,
SphereInsideWorldCollider,
CapsuleInsideWorldCollider,
PlaneWorldCollider,
],
] = {}
for collider in spring_bone1.colliders:
pose_bone = obj.pose.bones.get(collider.node.bone_name)
if not pose_bone:
continue
pose_bone_world_matrix = obj_matrix_world @ pose_bone.matrix
extended_collider = collider.extensions.vrmc_spring_bone_extended_collider
world_collider: Union[
None,
SphereWorldCollider,
CapsuleWorldCollider,
SphereInsideWorldCollider,
CapsuleInsideWorldCollider,
PlaneWorldCollider,
] = None
if extended_collider.enabled:
if (
extended_collider.shape_type
== extended_collider.SHAPE_TYPE_EXTENDED_SPHERE.identifier
):
offset = pose_bone_world_matrix @ Vector(
extended_collider.shape.sphere.offset
)
radius = extended_collider.shape.sphere.radius
if extended_collider.shape.sphere.inside:
world_collider = SphereInsideWorldCollider(
offset=offset, radius=radius
)
else:
world_collider = SphereWorldCollider(offset=offset, radius=radius)
elif (
extended_collider.shape_type
== extended_collider.SHAPE_TYPE_EXTENDED_CAPSULE.identifier
):
offset = pose_bone_world_matrix @ Vector(
extended_collider.shape.capsule.offset
)
tail = pose_bone_world_matrix @ Vector(
extended_collider.shape.capsule.tail
)
radius = extended_collider.shape.sphere.radius
offset_to_tail_diff = tail - offset
offset_to_tail_diff_length_squared = offset_to_tail_diff.length_squared
if offset_to_tail_diff_length_squared < float_info.epsilon:
# If offset and tail positions are the same, use as sphere collider
if extended_collider.shape.capsule.inside:
world_collider = SphereInsideWorldCollider(
offset=offset, radius=radius
)
else:
world_collider = SphereWorldCollider(
offset=offset, radius=radius
)
elif extended_collider.shape.capsule.inside:
world_collider = CapsuleInsideWorldCollider(
offset=offset,
radius=radius,
tail=tail,
offset_to_tail_diff=offset_to_tail_diff,
offset_to_tail_diff_length_squared=offset_to_tail_diff_length_squared,
)
else:
world_collider = CapsuleWorldCollider(
offset=offset,
radius=radius,
tail=tail,
offset_to_tail_diff=offset_to_tail_diff,
offset_to_tail_diff_length_squared=offset_to_tail_diff_length_squared,
)
elif (
extended_collider.shape_type
== extended_collider.SHAPE_TYPE_EXTENDED_PLANE.identifier
):
offset = pose_bone_world_matrix @ Vector(
extended_collider.shape.plane.offset
)
normal = pose_bone_world_matrix.to_quaternion() @ Vector(
extended_collider.shape.plane.normal
)
world_collider = PlaneWorldCollider(
offset=offset,
normal=normal,
)
elif collider.shape_type == collider.SHAPE_TYPE_SPHERE.identifier:
offset = pose_bone_world_matrix @ Vector(collider.shape.sphere.offset)
radius = collider.shape.sphere.radius
world_collider = SphereWorldCollider(
offset=offset,
radius=radius,
)
elif collider.shape_type == collider.SHAPE_TYPE_CAPSULE.identifier:
offset = pose_bone_world_matrix @ Vector(collider.shape.capsule.offset)
tail = pose_bone_world_matrix @ Vector(collider.shape.capsule.tail)
radius = collider.shape.sphere.radius
offset_to_tail_diff = tail - offset
offset_to_tail_diff_length_squared = offset_to_tail_diff.length_squared
if offset_to_tail_diff_length_squared < float_info.epsilon:
# If offset and tail positions are the same, use as sphere collider
world_collider = SphereWorldCollider(
offset=offset,
radius=radius,
)
else:
world_collider = CapsuleWorldCollider(
offset=offset,
radius=radius,
tail=tail,
offset_to_tail_diff=offset_to_tail_diff,
offset_to_tail_diff_length_squared=offset_to_tail_diff_length_squared,
)
if world_collider:
collider_uuid_to_world_collider[collider.uuid] = world_collider
collider_group_uuid_to_world_colliders: dict[
str,
list[
Union[
SphereWorldCollider,
CapsuleWorldCollider,
SphereInsideWorldCollider,
CapsuleInsideWorldCollider,
PlaneWorldCollider,
]
],
] = {}
for collider_group in spring_bone1.collider_groups:
for collider_reference in collider_group.colliders:
world_collider = collider_uuid_to_world_collider.get(
collider_reference.collider_uuid
)
if world_collider is None:
continue
world_colliders = collider_group_uuid_to_world_colliders.get(
collider_group.uuid
)
if world_colliders is None:
world_colliders = []
collider_group_uuid_to_world_colliders[collider_group.uuid] = (
world_colliders
)
world_colliders.append(world_collider)
for spring in spring_bone1.springs:
joints = spring.joints
if not joints:
continue
calculate_spring_pose_bone_rotations(
delta_time,
obj,
obj_matrix_world,
obj_matrix_world_inverted,
obj_matrix_world_quaternion,
spring,
pose_bone_and_rotations,
collider_group_uuid_to_world_colliders,
)
def calculate_spring_pose_bone_rotations(
delta_time: float,
obj: Object,
obj_matrix_world: Matrix,
obj_matrix_world_inverted: Matrix,
obj_matrix_world_quaternion: Quaternion,
spring: SpringBone1SpringPropertyGroup,
pose_bone_and_rotations: list[tuple[PoseBone, Quaternion]],
collider_group_uuid_to_world_colliders: dict[
str,
list[
Union[
SphereWorldCollider,
CapsuleWorldCollider,
SphereInsideWorldCollider,
CapsuleInsideWorldCollider,
PlaneWorldCollider,
]
],
],
) -> None:
world_collider_groups: Sequence[
Sequence[
Union[
SphereWorldCollider,
CapsuleWorldCollider,
SphereInsideWorldCollider,
CapsuleInsideWorldCollider,
PlaneWorldCollider,
]
]
] = [
collider_group_world_colliders
for collider_group_reference in spring.collider_groups
if (
collider_group_world_colliders
:= collider_group_uuid_to_world_colliders.get(
collider_group_reference.collider_group_uuid
)
)
and collider_group_world_colliders
]
center_pose_bone = obj.pose.bones.get(spring.center.bone_name)
if center_pose_bone:
current_center_world_translation = (
obj_matrix_world @ center_pose_bone.matrix
).to_translation()
previous_center_world_translation = Vector(
spring.animation_state.previous_center_world_translation
)
previous_to_current_center_world_translation = (
current_center_world_translation - previous_center_world_translation
)
if not spring.animation_state.use_center_space:
spring.animation_state.previous_center_world_translation = (
current_center_world_translation.copy()
)
spring.animation_state.use_center_space = True
else:
current_center_world_translation = Vector((0, 0, 0))
previous_to_current_center_world_translation = Vector((0, 0, 0))
if spring.animation_state.use_center_space:
spring.animation_state.use_center_space = False
for sorted_joint_and_bones in sort_spring_bone_joints(obj, spring.joints):
joints: list[
tuple[
SpringBone1JointPropertyGroup,
PoseBone,
Matrix,
]
] = [
(
joint,
pose_bone,
pose_bone.bone.convert_local_to_pose(
Matrix(), pose_bone.bone.matrix_local
),
)
for joint, pose_bone in sorted_joint_and_bones
]
# https://github.com/vrm-c/vrm-specification/blob/7279e169ac0dcf37e7d81b2adcad9107101d7e25/specification/VRMC_springBone-1.0/README.md#center-space
enable_center_space = False
if center_pose_bone:
first_pose_bone = next((pose_bone for (_, pose_bone, _) in joints), None)
ancestor_of_first_pose_bone: Optional[PoseBone] = first_pose_bone
while ancestor_of_first_pose_bone:
if center_pose_bone == ancestor_of_first_pose_bone:
enable_center_space = True
break
ancestor_of_first_pose_bone = ancestor_of_first_pose_bone.parent
next_head_pose_bone_before_rotation_matrix = None
for (
head_joint,
head_pose_bone,
head_rest_object_matrix,
), (
tail_joint,
tail_pose_bone,
tail_rest_object_matrix,
) in zip(joints, joints[1:]):
head_tail_parented = False
searching_tail_parent = tail_pose_bone.parent
while searching_tail_parent:
if searching_tail_parent.name == head_pose_bone.name:
head_tail_parented = True
break
searching_tail_parent = searching_tail_parent.parent
if not head_tail_parented:
break
(
head_pose_bone_rotation,
next_head_pose_bone_before_rotation_matrix,
) = calculate_joint_pair_head_pose_bone_rotations(
delta_time,
obj_matrix_world,
obj_matrix_world_inverted,
obj_matrix_world_quaternion,
head_joint,
head_pose_bone,
head_rest_object_matrix,
tail_joint,
tail_pose_bone,
tail_rest_object_matrix,
next_head_pose_bone_before_rotation_matrix,
world_collider_groups,
previous_to_current_center_world_translation
if enable_center_space
else Vector((0, 0, 0)),
)
pose_bone_and_rotations.append((head_pose_bone, head_pose_bone_rotation))
spring.animation_state.previous_center_world_translation = (
current_center_world_translation
)
def calculate_joint_pair_head_pose_bone_rotations(
delta_time: float,
obj_matrix_world: Matrix,
obj_matrix_world_inverted: Matrix,
obj_matrix_world_quaternion: Quaternion,
head_joint: SpringBone1JointPropertyGroup,
head_pose_bone: PoseBone,
current_head_rest_object_matrix: Matrix,
tail_joint: SpringBone1JointPropertyGroup,
tail_pose_bone: PoseBone,
current_tail_rest_object_matrix: Matrix,
next_head_pose_bone_before_rotation_matrix: Optional[Matrix],
world_collider_groups: Sequence[
Sequence[
Union[
SphereWorldCollider,
CapsuleWorldCollider,
SphereInsideWorldCollider,
CapsuleInsideWorldCollider,
PlaneWorldCollider,
]
]
],
previous_to_current_center_world_translation: Vector,
) -> tuple[Quaternion, Matrix]:
current_head_pose_bone_matrix = head_pose_bone.matrix
current_tail_pose_bone_matrix = tail_pose_bone.matrix
if next_head_pose_bone_before_rotation_matrix is None:
if head_pose_bone_parent := head_pose_bone.parent:
current_head_parent_matrix = head_pose_bone_parent.matrix
current_head_parent_rest_object_matrix = (
head_pose_bone_parent.bone.convert_local_to_pose(
Matrix(), head_pose_bone_parent.bone.matrix_local
)
)
next_head_pose_bone_before_rotation_matrix = current_head_parent_matrix @ (
current_head_parent_rest_object_matrix.inverted_safe()
@ current_head_rest_object_matrix
)
else:
next_head_pose_bone_before_rotation_matrix = (
current_head_rest_object_matrix.copy()
)
(
next_head_pose_bone_translation,
next_head_parent_pose_bone_object_rotation,
next_head_pose_bone_scale,
) = next_head_pose_bone_before_rotation_matrix.decompose()
next_head_world_translation = obj_matrix_world @ next_head_pose_bone_translation
if not tail_joint.animation_state.initialized_as_tail:
initial_tail_world_translation = (
obj_matrix_world @ current_tail_pose_bone_matrix
).to_translation()
tail_joint.animation_state.initialized_as_tail = True
tail_joint.animation_state.previous_world_translation = list(
initial_tail_world_translation
)
tail_joint.animation_state.current_world_translation = list(
initial_tail_world_translation
)
previous_tail_world_translation = (
Vector(tail_joint.animation_state.previous_world_translation)
+ previous_to_current_center_world_translation
)
current_tail_world_translation = (
Vector(tail_joint.animation_state.current_world_translation)
+ previous_to_current_center_world_translation
)
inertia = (current_tail_world_translation - previous_tail_world_translation) * (
1.0 - head_joint.drag_force
)
current_head_rest_object_matrix_inverted = (
current_head_rest_object_matrix.inverted_safe()
)
next_head_rotation_start_target_local_translation = (
current_head_rest_object_matrix_inverted
@ current_tail_rest_object_matrix.to_translation()
)
stiffness_direction = (
obj_matrix_world_quaternion
@ next_head_parent_pose_bone_object_rotation
@ next_head_rotation_start_target_local_translation
).normalized()
stiffness = stiffness_direction * delta_time * head_joint.stiffness
external = Vector(head_joint.gravity_dir) * delta_time * head_joint.gravity_power
next_tail_world_translation = (
current_tail_world_translation + inertia + stiffness + external
)
head_to_tail_world_distance = (
obj_matrix_world @ current_head_pose_bone_matrix.to_translation()
- (obj_matrix_world @ current_tail_pose_bone_matrix.to_translation())
).length
# Apply distance constraint to next Tail
next_tail_world_translation = (
next_head_world_translation
+ (next_tail_world_translation - next_head_world_translation).normalized()
* head_to_tail_world_distance
)
# Calculate collider collision
for world_colliders in world_collider_groups:
for world_collider in world_colliders:
direction, distance = world_collider.calculate_collision(
next_tail_world_translation,
head_joint.hit_radius,
)
if distance >= 0:
continue
# Push away
next_tail_world_translation = (
next_tail_world_translation - direction * distance
)
# Apply distance constraint to next Tail
next_tail_world_translation = (
next_head_world_translation
+ (
next_tail_world_translation - next_head_world_translation
).normalized()
* head_to_tail_world_distance
)
next_tail_object_local_translation = (
obj_matrix_world_inverted @ next_tail_world_translation
)
next_head_rotation_end_target_local_translation = (
next_head_pose_bone_before_rotation_matrix.inverted_safe()
@ next_tail_object_local_translation
)
next_head_pose_bone_rotation = Quaternion(
next_head_rotation_start_target_local_translation.cross(
next_head_rotation_end_target_local_translation
),
next_head_rotation_start_target_local_translation.angle(
next_head_rotation_end_target_local_translation, 0
),
)
next_head_pose_bone_object_rotation = (
next_head_parent_pose_bone_object_rotation @ next_head_pose_bone_rotation
)
next_head_pose_bone_matrix = (
Matrix.Translation(next_head_pose_bone_translation)
@ next_head_pose_bone_object_rotation.to_matrix().to_4x4()
@ Matrix.Diagonal(next_head_pose_bone_scale).to_4x4()
)
next_tail_pose_bone_before_rotation_matrix = (
next_head_pose_bone_matrix
@ current_head_rest_object_matrix_inverted
@ current_tail_rest_object_matrix
)
tail_joint.animation_state.previous_world_translation = list(
current_tail_world_translation
)
tail_joint.animation_state.current_world_translation = list(
next_tail_world_translation
)
return (
next_head_pose_bone_rotation
if head_pose_bone.bone.use_inherit_rotation
else next_head_pose_bone_object_rotation,
next_tail_pose_bone_before_rotation_matrix,
)
@persistent
def depsgraph_update_pre(_unused: object) -> None:
context = bpy.context
state.reset(context)
@persistent
def frame_change_pre(_unused: object) -> None:
context = bpy.context
fps = Decimal(context.scene.render.fps)
last_fps = state.last_fps
fps_base = Decimal(context.scene.render.fps_base)
last_fps_base = state.last_fps_base
if (
last_fps_base is None
or (fps_base - last_fps_base).copy_abs() > 0.00001
or fps != last_fps
):
state.reset(context)
state.frame_count += 1
# If the current time is future than the next SpringBone calculation
# time, move the SpringBone
# To minimize floating-point rounding errors, multiply numerator by
# common denominator to minimize decimal handling
frame_time_x_60_x_fps = state.frame_count * Decimal(60) * fps_base
while True:
next_spring_bone_60_fps_update_count = (
state.spring_bone_60_fps_update_count + Decimal(1)
)
next_spring_bone_update_time_x_60_x_fps = (
next_spring_bone_60_fps_update_count * fps
)
if next_spring_bone_update_time_x_60_x_fps > frame_time_x_60_x_fps:
break
# To accumulate float rounding errors, don't hardcode delta_time as 1.0/60.0
# Use the difference between previous and next times
next_spring_bone_update_time = next_spring_bone_60_fps_update_count / Decimal(
60
)
current_spring_bone_update_time = (
state.spring_bone_60_fps_update_count / Decimal(60)
)
delta_time = float(next_spring_bone_update_time) - float(
current_spring_bone_update_time
)
update_pose_bone_rotations(context, delta_time)
state.spring_bone_60_fps_update_count += 1
def sort_spring_bone_joints(
obj: Object, joints: CollectionPropertyProtocol[SpringBone1JointPropertyGroup]
) -> Sequence[Iterable[tuple[SpringBone1JointPropertyGroup, PoseBone]]]:
bones = obj.pose.bones
# Check if it's sorted and return as-is if already sorted.
# This is logically unnecessary but done for simulation efficiency.
already_sorted = True
sorted_pose_bones: list[PoseBone] = []
for joint in joints:
joint_bone = bones.get(joint.node.bone_name)
if not joint_bone:
already_sorted = False
break
if not sorted_pose_bones:
sorted_pose_bones.append(joint_bone)
continue
parent_bone = sorted_pose_bones[-1]
sorted_pose_bones.append(joint_bone)
traversing_bone = joint_bone.parent
connected = False
while traversing_bone:
if traversing_bone == parent_bone:
connected = True
break
traversing_bone = traversing_bone.parent
if not connected:
already_sorted = False
break
if already_sorted:
return [zip(joints, sorted_pose_bones)]
# Perform sorting
chains = list[list[tuple[SpringBone1JointPropertyGroup, PoseBone]]]()
for joint in joints:
joint_bone = bones.get(joint.node.bone_name)
if not joint_bone:
continue
if not chains:
chains.append([(joint, joint_bone)])
continue
# Skip if already registered in chain
if any(joint_bone == bone for chain in chains for _, bone in chain):
continue
# If ancestor of chain head, or descendant of chain tail,
# or descendant of chain head and ancestor of chain tail,
# add to that chain
# Otherwise, create a new chain
assigned = False
for chain in chains:
if not chain:
# This should not happen
continue
# Check if it's an ancestor of the chain head
_, chain_head_bone = chain[0]
traversing_bone = chain_head_bone.parent
assigned = False
while traversing_bone:
if traversing_bone == joint_bone:
chain.insert(0, (joint, joint_bone))
assigned = True
break
traversing_bone = traversing_bone.parent
if assigned:
break
# Check if it's an ancestor of the chain tail
_, chain_tail_bone = chain[-1]
traversing_bone = joint_bone.parent
assigned = False
while traversing_bone:
if traversing_bone == chain_tail_bone:
chain.append((joint, joint_bone))
assigned = True
break
traversing_bone = traversing_bone.parent
if assigned:
break
# Check if it's a descendant of the chain head and ancestor of
# the chain tail
assigned = False
for i in range(len(chain) - 1):
_, chain_parent_bone = chain[i]
_, chain_child_bone = chain[i + 1]
traversing_bone = chain_child_bone.parent
while traversing_bone:
if traversing_bone == joint_bone:
chain.insert(i + 1, (joint, joint_bone))
assigned = True
break
if traversing_bone == chain_parent_bone:
break
traversing_bone = traversing_bone.parent
if assigned:
break
if assigned:
break
if not assigned:
chains.append([(joint, joint_bone)])
return chains

View File

@@ -0,0 +1,61 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from bpy.types import Armature, Context, Object
from ..extension import get_armature_extension
def migrate_blender_object(armature: Armature) -> None:
ext = get_armature_extension(armature)
if tuple(ext.addon_version) >= (2, 3, 27):
return
for collider in ext.spring_bone1.colliders:
bpy_object = collider.pop("blender_object", None)
if isinstance(bpy_object, Object):
collider.bpy_object = bpy_object
def fixup_gravity_dir(armature: Armature) -> None:
ext = get_armature_extension(armature)
if tuple(ext.addon_version) <= (2, 14, 3):
for spring in ext.spring_bone1.springs:
for joint in spring.joints:
joint.gravity_dir = [
joint.gravity_dir[0],
joint.gravity_dir[2],
joint.gravity_dir[1],
]
if tuple(ext.addon_version) <= (2, 14, 10):
for spring in ext.spring_bone1.springs:
for joint in spring.joints:
joint.gravity_dir = [
joint.gravity_dir[0],
-joint.gravity_dir[1],
joint.gravity_dir[2],
]
if tuple(ext.addon_version) <= (2, 15, 3):
for spring in ext.spring_bone1.springs:
for joint in spring.joints:
gravity_dir = list(joint.gravity_dir)
joint.gravity_dir = (gravity_dir[0] + 1, 0, 0) # Make a change
joint.gravity_dir = gravity_dir
def fixup_collider_group_name(armature: Armature) -> None:
ext = get_armature_extension(armature)
if tuple(ext.addon_version) <= (2, 20, 38):
spring_bone = get_armature_extension(armature).spring_bone1
for collider_group in spring_bone.collider_groups:
collider_group.fix_index()
def migrate(_context: Context, armature: Object) -> None:
armature_data = armature.data
if not isinstance(armature_data, Armature):
return
migrate_blender_object(armature_data)
fixup_gravity_dir(armature_data)
fixup_collider_group_name(armature_data)

1601
editor/spring_bone1/ops.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,599 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from collections.abc import Set as AbstractSet
from typing import Optional
from bpy.types import Armature, Context, Object, Panel, UILayout
from .. import search
from ..extension import get_armature_extension
from ..migration import defer_migrate
from ..panel import VRM_PT_vrm_armature_object_property, draw_template_list
from ..search import active_object_is_vrm1_armature
from . import ops
from .property_group import (
SpringBone1ColliderGroupPropertyGroup,
SpringBone1ColliderPropertyGroup,
SpringBone1JointPropertyGroup,
SpringBone1SpringBonePropertyGroup,
SpringBone1SpringPropertyGroup,
)
from .ui_list import (
VRM_UL_spring_bone1_collider,
VRM_UL_spring_bone1_collider_group,
VRM_UL_spring_bone1_collider_group_collider,
VRM_UL_spring_bone1_joint,
VRM_UL_spring_bone1_spring,
VRM_UL_spring_bone1_spring_collider_group,
)
def draw_spring_bone1_collider_sphere_layout(
layout: UILayout,
armature_data: Armature,
collider: SpringBone1ColliderPropertyGroup,
) -> None:
layout.prop_search(collider.node, "bone_name", armature_data, "bones")
layout.prop(collider, "ui_collider_type")
bpy_object = collider.bpy_object
if bpy_object:
layout.prop(bpy_object, "name", icon="MESH_UVSPHERE", text="Name")
layout.prop(collider.shape.sphere, "offset", text="Offset")
layout.separator(factor=0.5)
layout.prop(collider.shape.sphere, "radius", slider=True)
layout.prop(collider.extensions.vrmc_spring_bone_extended_collider, "enabled")
def draw_spring_bone1_collider_capsule_layout(
layout: UILayout,
armature_data: Armature,
collider: SpringBone1ColliderPropertyGroup,
) -> None:
layout.prop_search(collider.node, "bone_name", armature_data, "bones")
layout.prop(collider, "ui_collider_type")
bpy_object = collider.bpy_object
if bpy_object:
layout.prop(bpy_object, "name", icon="MESH_UVSPHERE", text="Head")
layout.prop(collider.shape.capsule, "offset", text="")
layout.separator(factor=0.5)
layout.prop(collider.shape.capsule, "radius", slider=True)
layout.separator(factor=0.5)
if bpy_object and bpy_object.children:
layout.prop(
bpy_object.children[0],
"name",
icon="MESH_UVSPHERE",
text="Tail",
)
layout.prop(collider.shape.capsule, "tail", text="")
layout.prop(collider.extensions.vrmc_spring_bone_extended_collider, "enabled")
def draw_spring_bone1_collider_extended_fallback_layout(
layout: UILayout,
_armature_data: Armature,
collider: SpringBone1ColliderPropertyGroup,
) -> None:
extended = collider.extensions.vrmc_spring_bone_extended_collider
layout.prop(extended, "automatic_fallback_generation")
if extended.automatic_fallback_generation:
return
layout.prop(collider, "shape_type", text="Fallback")
if collider.shape_type == collider.SHAPE_TYPE_SPHERE.identifier:
layout.prop(collider.shape.sphere, "fallback_offset")
layout.separator(factor=0.5)
layout.prop(collider.shape.sphere, "fallback_radius", slider=True)
elif collider.shape_type == collider.SHAPE_TYPE_CAPSULE.identifier:
layout.prop(collider.shape.capsule, "fallback_offset")
layout.separator(factor=0.5)
layout.prop(collider.shape.capsule, "fallback_radius", slider=True)
layout.separator(factor=0.5)
layout.prop(collider.shape.capsule, "fallback_tail")
def draw_spring_bone1_collider_extended_sphere_layout(
layout: UILayout,
armature_data: Armature,
collider: SpringBone1ColliderPropertyGroup,
) -> None:
extended = collider.extensions.vrmc_spring_bone_extended_collider
layout.prop_search(collider.node, "bone_name", armature_data, "bones")
layout.prop(collider, "ui_collider_type")
bpy_object = collider.bpy_object
if bpy_object:
layout.prop(bpy_object, "name", icon="MESH_UVSPHERE", text="Offset")
layout.prop(extended.shape.sphere, "offset", text="")
layout.separator(factor=0.5)
layout.prop(extended.shape.sphere, "radius", slider=True)
if not (extended.enabled and extended.shape.sphere.inside):
layout.prop(extended, "enabled")
draw_spring_bone1_collider_extended_fallback_layout(
layout,
armature_data,
collider,
)
def draw_spring_bone1_collider_extended_capsule_layout(
layout: UILayout,
armature_data: Armature,
collider: SpringBone1ColliderPropertyGroup,
) -> None:
extended = collider.extensions.vrmc_spring_bone_extended_collider
layout.prop_search(collider.node, "bone_name", armature_data, "bones")
layout.prop(collider, "ui_collider_type")
bpy_object = collider.bpy_object
if bpy_object:
layout.prop(bpy_object, "name", icon="MESH_UVSPHERE", text="Head")
layout.prop(extended.shape.capsule, "offset", text="")
layout.separator(factor=0.5)
layout.prop(extended.shape.capsule, "radius", slider=True)
layout.separator(factor=0.5)
if bpy_object and bpy_object.children:
layout.prop(
bpy_object.children[0],
"name",
icon="MESH_UVSPHERE",
text="Tail",
)
layout.prop(extended.shape.capsule, "tail", text="")
if not (extended.enabled and extended.shape.capsule.inside):
layout.prop(extended, "enabled")
draw_spring_bone1_collider_extended_fallback_layout(
layout,
armature_data,
collider,
)
def draw_spring_bone1_collider_extended_plane_layout(
layout: UILayout,
armature_data: Armature,
collider: SpringBone1ColliderPropertyGroup,
) -> None:
extended = collider.extensions.vrmc_spring_bone_extended_collider
layout.prop_search(collider.node, "bone_name", armature_data, "bones")
layout.prop(collider, "ui_collider_type")
bpy_object = collider.bpy_object
if bpy_object:
layout.prop(bpy_object, "name", icon="MESH_UVSPHERE", text="Offset")
layout.prop(extended.shape.plane, "offset", text="")
layout.separator(factor=0.5)
layout.prop(extended.shape.plane, "normal")
draw_spring_bone1_collider_extended_fallback_layout(
layout,
armature_data,
collider,
)
def draw_spring_bone1_collider_layout(
armature: Object,
layout: UILayout,
collider: SpringBone1ColliderPropertyGroup,
) -> None:
armature_data = armature.data
if not isinstance(armature_data, Armature):
return
extended = collider.extensions.vrmc_spring_bone_extended_collider
if extended.enabled:
if extended.shape_type == extended.SHAPE_TYPE_EXTENDED_PLANE.identifier:
draw_spring_bone1_collider_extended_plane_layout(
layout, armature_data, collider
)
elif extended.shape_type == extended.SHAPE_TYPE_EXTENDED_SPHERE.identifier:
draw_spring_bone1_collider_extended_sphere_layout(
layout, armature_data, collider
)
elif extended.shape_type == extended.SHAPE_TYPE_EXTENDED_CAPSULE.identifier:
draw_spring_bone1_collider_extended_capsule_layout(
layout, armature_data, collider
)
elif collider.shape_type == collider.SHAPE_TYPE_SPHERE.identifier:
draw_spring_bone1_collider_sphere_layout(layout, armature_data, collider)
elif collider.shape_type == collider.SHAPE_TYPE_CAPSULE.identifier:
draw_spring_bone1_collider_capsule_layout(layout, armature_data, collider)
layout.separator(factor=0.5)
def draw_spring_bone1_spring_bone_layout(
armature: Object,
layout: UILayout,
spring_bone: SpringBone1SpringBonePropertyGroup,
) -> None:
defer_migrate(armature.name)
layout.prop(spring_bone, "enable_animation")
# layout.operator(ops.VRM_OT_reset_spring_bone1_animation_state.bl_idname)
draw_spring_bone1_colliders_layout(armature, layout, spring_bone)
draw_spring_bone1_collider_groups_layout(armature, layout, spring_bone)
draw_spring_bone1_springs_layout(armature, layout, spring_bone)
def draw_spring_bone1_colliders_layout(
armature: Object,
layout: UILayout,
spring_bone: SpringBone1SpringBonePropertyGroup,
) -> None:
colliders_box = layout.box()
colliders_header_row = colliders_box.row()
colliders_header_row.alignment = "LEFT"
colliders_header_row.prop(
spring_bone,
"show_expanded_colliders",
icon="TRIA_DOWN" if spring_bone.show_expanded_colliders else "TRIA_RIGHT",
emboss=False,
)
if not spring_bone.show_expanded_colliders:
return
(
collider_collection_ops,
collider_collection_item_ops,
collider_index,
collider,
_,
) = draw_template_list(
colliders_box,
VRM_UL_spring_bone1_collider,
spring_bone,
"colliders",
"active_collider_index",
ops.VRM_OT_add_spring_bone1_collider,
ops.VRM_OT_remove_spring_bone1_collider,
ops.VRM_OT_move_up_spring_bone1_collider,
ops.VRM_OT_move_down_spring_bone1_collider,
)
for collider_collection_op in collider_collection_ops:
collider_collection_op.armature_object_name = armature.name
for collider_collection_item_op in collider_collection_item_ops:
collider_collection_item_op.collider_index = collider_index
if not isinstance(collider, SpringBone1ColliderPropertyGroup):
return
draw_spring_bone1_collider_layout(armature, colliders_box.column(), collider)
def draw_spring_bone1_collider_groups_layout(
armature: Object,
layout: UILayout,
spring_bone: SpringBone1SpringBonePropertyGroup,
) -> None:
collider_groups_box = layout.box()
collider_groups_header_row = collider_groups_box.row()
collider_groups_header_row.alignment = "LEFT"
collider_groups_header_row.prop(
spring_bone,
"show_expanded_collider_groups",
icon="TRIA_DOWN" if spring_bone.show_expanded_collider_groups else "TRIA_RIGHT",
emboss=False,
)
if not spring_bone.show_expanded_collider_groups:
return
(
collider_group_collection_ops,
collider_group_collection_item_ops,
collider_group_index,
collider_group,
_,
) = draw_template_list(
collider_groups_box,
VRM_UL_spring_bone1_collider_group,
spring_bone,
"collider_groups",
"active_collider_group_index",
ops.VRM_OT_add_spring_bone1_collider_group,
ops.VRM_OT_remove_spring_bone1_collider_group,
ops.VRM_OT_move_up_spring_bone1_collider_group,
ops.VRM_OT_move_down_spring_bone1_collider_group,
)
for collider_group_collection_op in collider_group_collection_ops:
collider_group_collection_op.armature_object_name = armature.name
for collider_group_collection_item_op in collider_group_collection_item_ops:
collider_group_collection_item_op.collider_group_index = collider_group_index
if not isinstance(collider_group, SpringBone1ColliderGroupPropertyGroup):
return
collider_group_column = collider_groups_box.column()
collider_group_column.prop(
collider_group,
"vrm_name",
)
colliders_box = collider_group_column.box()
colliders_column = colliders_box.column()
colliders_column.label(text="Colliders:")
(
collider_collection_ops,
collider_collection_item_ops,
collider_index,
_collider,
_,
) = draw_template_list(
colliders_column,
VRM_UL_spring_bone1_collider_group_collider,
collider_group,
"colliders",
"active_collider_index",
ops.VRM_OT_add_spring_bone1_collider_group_collider,
ops.VRM_OT_remove_spring_bone1_collider_group_collider,
ops.VRM_OT_move_up_spring_bone1_collider_group_collider,
ops.VRM_OT_move_down_spring_bone1_collider_group_collider,
)
for collider_collection_op in collider_collection_ops:
collider_collection_op.armature_object_name = armature.name
collider_collection_op.collider_group_index = collider_group_index
for collider_collection_item_op in collider_collection_item_ops:
collider_collection_item_op.collider_index = collider_index
def draw_spring_bone1_springs_layout(
armature: Object,
layout: UILayout,
spring_bone: SpringBone1SpringBonePropertyGroup,
) -> None:
armature_data = armature.data
if not isinstance(armature_data, Armature):
return
springs_box = layout.box()
springs_header_row = springs_box.row()
springs_header_row.alignment = "LEFT"
springs_header_row.prop(
spring_bone,
"show_expanded_springs",
icon="TRIA_DOWN" if spring_bone.show_expanded_springs else "TRIA_RIGHT",
emboss=False,
)
if not spring_bone.show_expanded_springs:
return
(
spring_collection_ops,
spring_collection_item_ops,
spring_index,
spring,
_,
) = draw_template_list(
springs_box,
VRM_UL_spring_bone1_spring,
spring_bone,
"springs",
"active_spring_index",
ops.VRM_OT_add_spring_bone1_spring,
ops.VRM_OT_remove_spring_bone1_spring,
ops.VRM_OT_move_up_spring_bone1_spring,
ops.VRM_OT_move_down_spring_bone1_spring,
)
for spring_collection_op in spring_collection_ops:
spring_collection_op.armature_object_name = armature.name
for spring_collection_item_op in spring_collection_item_ops:
spring_collection_item_op.spring_index = spring_index
if not isinstance(spring, SpringBone1SpringPropertyGroup):
return
spring_column = springs_box.column()
spring_column.prop(
spring,
"vrm_name",
)
spring_column.prop_search(
spring.center,
"bone_name",
armature_data,
"bones",
text="Center",
)
joints_box = spring_column.box()
joints_column = joints_box.column()
joints_column.label(text="Joints:")
(
joint_collection_ops,
joint_collection_item_ops,
joint_index,
joint,
(add_joint_op, _, _, _),
) = draw_template_list(
joints_column,
VRM_UL_spring_bone1_joint,
spring,
"joints",
"active_joint_index",
ops.VRM_OT_add_spring_bone1_joint,
ops.VRM_OT_remove_spring_bone1_joint,
ops.VRM_OT_move_up_spring_bone1_joint,
ops.VRM_OT_move_down_spring_bone1_joint,
)
for joint_collection_op in joint_collection_ops:
joint_collection_op.armature_object_name = armature.name
joint_collection_op.spring_index = spring_index
for joint_collection_item_op in joint_collection_item_ops:
joint_collection_item_op.joint_index = joint_index
add_joint_op.guess_properties = True
if isinstance(joint, SpringBone1JointPropertyGroup):
joints_column.prop_search(joint.node, "bone_name", armature_data, "bones")
joints_column.prop(joint, "stiffness", slider=True)
joints_column.prop(joint, "gravity_power", slider=True)
joints_column.prop(joint, "gravity_dir")
joints_column.prop(joint, "drag_force", slider=True)
joints_column.prop(joint, "hit_radius", slider=True)
collider_groups_box = spring_column.box()
collider_groups_column = collider_groups_box.column()
collider_groups_column.label(text="Collider Groups:")
(
collider_group_collection_ops,
collider_group_collection_item_ops,
collider_group_index,
_collider_group,
_,
) = draw_template_list(
collider_groups_column,
VRM_UL_spring_bone1_spring_collider_group,
spring,
"collider_groups",
"active_collider_group_index",
ops.VRM_OT_add_spring_bone1_spring_collider_group,
ops.VRM_OT_remove_spring_bone1_spring_collider_group,
ops.VRM_OT_move_up_spring_bone1_spring_collider_group,
ops.VRM_OT_move_down_spring_bone1_spring_collider_group,
)
for collider_group_collection_op in collider_group_collection_ops:
collider_group_collection_op.armature_object_name = armature.name
collider_group_collection_op.spring_index = spring_index
for collider_group_collection_item_op in collider_group_collection_item_ops:
collider_group_collection_item_op.collider_group_index = collider_group_index
class VRM_PT_spring_bone1_armature_object_property(Panel):
bl_idname = "VRM_PT_vrm1_spring_bone_armature_object_property"
bl_label = "Spring Bone"
bl_translation_context = "VRM"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "object"
bl_options: AbstractSet[str] = {"DEFAULT_CLOSED"}
bl_parent_id = VRM_PT_vrm_armature_object_property.bl_idname
@classmethod
def poll(cls, context: Context) -> bool:
return active_object_is_vrm1_armature(context)
def draw_header(self, _context: Context) -> None:
self.layout.label(icon="PHYSICS")
def draw(self, context: Context) -> None:
active_object = context.active_object
if not active_object:
return
armature_data = active_object.data
if not isinstance(armature_data, Armature):
return
draw_spring_bone1_spring_bone_layout(
active_object,
self.layout,
get_armature_extension(armature_data).spring_bone1,
)
class VRM_PT_spring_bone1_ui(Panel):
bl_idname = "VRM_PT_vrm1_spring_bone_ui"
bl_label = "Spring Bone"
bl_translation_context = "VRM"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "VRM"
bl_options: AbstractSet[str] = {"DEFAULT_CLOSED"}
@classmethod
def poll(cls, context: Context) -> bool:
return search.current_armature_is_vrm1(context)
def draw_header(self, _context: Context) -> None:
self.layout.label(icon="PHYSICS")
def draw(self, context: Context) -> None:
armature = search.current_armature(context)
if not armature:
return
armature_data = armature.data
if not isinstance(armature_data, Armature):
return
draw_spring_bone1_spring_bone_layout(
armature,
self.layout,
get_armature_extension(armature_data).spring_bone1,
)
class VRM_PT_spring_bone1_collider_property(Panel):
bl_idname = "VRM_PT_spring_bone1_collider_property"
bl_label = "VRM Spring Bone Collider"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "data"
@classmethod
def active_armature_and_collider(
cls, context: Context
) -> Optional[tuple[Object, SpringBone1ColliderPropertyGroup]]:
active_object = context.active_object
if not active_object:
return None
if active_object.type != "EMPTY":
return None
parent = active_object.parent
if not parent:
return None
if active_object.parent_type == "BONE":
collider_object = context.active_object
elif active_object.parent_type == "OBJECT":
if parent.type == "ARMATURE":
collider_object = active_object
elif parent.parent_type == "BONE" or (
parent.parent_type == "OBJECT"
and parent.parent
and parent.parent.type == "ARMATURE"
):
collider_object = parent
else:
return None
else:
return None
for obj in context.blend_data.objects:
if obj.type != "ARMATURE":
continue
armature_data = obj.data
if not isinstance(armature_data, Armature):
continue
for collider in get_armature_extension(
armature_data
).spring_bone1.colliders:
if collider.bpy_object == collider_object:
return (obj, collider)
return None
@classmethod
def poll(cls, context: Context) -> bool:
return cls.active_armature_and_collider(context) is not None
def draw(self, context: Context) -> None:
armature_and_collider = self.active_armature_and_collider(context)
if armature_and_collider is None:
return
armature, collider = armature_and_collider
armature_data = armature.data
if not isinstance(armature_data, Armature):
return
if get_armature_extension(armature_data).is_vrm1():
draw_spring_bone1_collider_layout(armature, self.layout.column(), collider)
return
self.layout.label(text="This is a VRM 1.0 Spring Bone Collider", icon="INFO")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,246 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from bpy.types import Armature, Context, UILayout, UIList
from ...common.logger import get_logger
from ..extension import get_armature_extension
from .property_group import (
SpringBone1ColliderGroupPropertyGroup,
SpringBone1ColliderGroupReferencePropertyGroup,
SpringBone1ColliderPropertyGroup,
SpringBone1ColliderReferencePropertyGroup,
SpringBone1JointPropertyGroup,
SpringBone1SpringPropertyGroup,
)
logger = get_logger(__name__)
class VRM_UL_spring_bone1_collider(UIList):
bl_idname = "VRM_UL_spring_bone1_collider"
def draw_item(
self,
_context: Context,
layout: UILayout,
_data: object,
collider: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
_index: int,
_flt_flag: int,
) -> None:
if not isinstance(collider, SpringBone1ColliderPropertyGroup):
return
icon = "SPHERE"
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
name = ""
bpy_object = collider.bpy_object
if bpy_object:
name = bpy_object.name
layout.label(text=name, translate=False, icon=icon)
class VRM_UL_spring_bone1_collider_group(UIList):
bl_idname = "VRM_UL_spring_bone1_collider_group"
def draw_item(
self,
_context: Context,
layout: UILayout,
_data: object,
collider_group: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
_index: int,
_flt_flag: int,
) -> None:
if not isinstance(collider_group, SpringBone1ColliderGroupPropertyGroup):
return
icon = "PIVOT_INDIVIDUAL"
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
layout.label(text=collider_group.vrm_name, translate=False, icon=icon)
class VRM_UL_spring_bone1_collider_group_collider(UIList):
bl_idname = "VRM_UL_spring_bone1_collider_group_collider"
def draw_item(
self,
_context: Context,
layout: UILayout,
collider_group: object,
collider: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
index: int,
_flt_flag: int,
) -> None:
if not isinstance(collider_group, SpringBone1ColliderGroupPropertyGroup):
return
if not isinstance(collider, SpringBone1ColliderReferencePropertyGroup):
return
icon = "SPHERE"
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
armature_data = collider_group.id_data
if not isinstance(armature_data, Armature):
logger.error("Failed to find armature")
return
spring_bone = get_armature_extension(armature_data).spring_bone1
if index == collider_group.active_collider_index:
layout.prop_search(
collider,
"collider_name",
spring_bone,
"colliders",
text="",
translate=False,
icon=icon,
)
else:
layout.label(text=collider.collider_name, translate=False, icon=icon)
class VRM_UL_spring_bone1_spring(UIList):
bl_idname = "VRM_UL_spring_bone1_spring"
def draw_item(
self,
_context: Context,
layout: UILayout,
_data: object,
spring: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
_index: int,
_flt_flag: int,
) -> None:
if not isinstance(spring, SpringBone1SpringPropertyGroup):
return
icon = "PHYSICS"
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
layout.label(text=spring.vrm_name, translate=False, icon=icon)
class VRM_UL_spring_bone1_joint(UIList):
bl_idname = "VRM_UL_spring_bone1_joint"
def draw_item(
self,
_context: Context,
layout: UILayout,
_data: object,
joint: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
_index: int,
_flt_flag: int,
) -> None:
if not isinstance(joint, SpringBone1JointPropertyGroup):
return
icon = "BONE_DATA"
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
layout.label(text=joint.node.bone_name, translate=False, icon=icon)
class VRM_UL_spring_bone1_spring_collider_group(UIList):
bl_idname = "VRM_UL_spring_bone1_spring_collider_group"
def draw_item(
self,
_context: Context,
layout: UILayout,
spring: object,
collider_group: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
index: int,
_flt_flag: int,
) -> None:
if not isinstance(spring, SpringBone1SpringPropertyGroup):
return
if not isinstance(
collider_group, SpringBone1ColliderGroupReferencePropertyGroup
):
return
icon = "PIVOT_INDIVIDUAL"
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
armature_data = spring.id_data
if not isinstance(armature_data, Armature):
logger.error("Failed to find armature")
return
spring_bone = get_armature_extension(armature_data).spring_bone1
if index == spring.active_collider_group_index:
layout.prop_search(
collider_group,
"collider_group_name",
spring_bone,
"collider_groups",
text="",
translate=False,
icon=icon,
)
else:
layout.label(
text=collider_group.collider_group_name, translate=False, icon=icon
)

147
editor/subscription.py Normal file
View File

@@ -0,0 +1,147 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from dataclasses import dataclass
import bpy
from bpy.types import Armature, Bone, Object
from ..common.logger import get_logger
from .extension import VrmAddonArmatureExtensionPropertyGroup, get_armature_extension
from .migration import migrate_all_objects
from .mtoon1 import ops as mtoon1_ops
from .vrm0.property_group import Vrm0HumanoidPropertyGroup
from .vrm1 import property_group as vrm1_property_group
logger = get_logger(__name__)
@dataclass
class Subscription:
object_name_subscription_owner = object()
object_mode_subscription_owner = object()
object_location_subscription_owner = object()
bone_name_subscription_owner = object()
armature_name_subscription_owner = object()
setup_once: bool = False
subscription = Subscription()
def setup_subscription(*, load_post: bool) -> None:
if subscription.setup_once:
if load_post:
# If called by load_post, unsubscribe and setup again.
teardown_subscription()
else:
# If a subscription is already setup, do nothing.
return
subscription.setup_once = True
object_name_subscribe_to = (Object, "name")
bpy.msgbus.subscribe_rna(
key=object_name_subscribe_to,
owner=subscription.object_name_subscription_owner,
args=(),
notify=on_change_bpy_object_name,
)
object_mode_subscribe_to = (Object, "mode")
bpy.msgbus.subscribe_rna(
key=object_mode_subscribe_to,
owner=subscription.object_mode_subscription_owner,
args=(),
notify=on_change_bpy_object_mode,
)
object_location_subscribe_to = (Object, "location")
bpy.msgbus.subscribe_rna(
key=object_location_subscribe_to,
owner=subscription.object_location_subscription_owner,
args=(),
notify=on_change_bpy_object_location,
)
bone_name_subscribe_to = (Bone, "name")
bpy.msgbus.subscribe_rna(
key=bone_name_subscribe_to,
owner=subscription.bone_name_subscription_owner,
args=(),
notify=on_change_bpy_bone_name,
)
armature_name_subscribe_to = (Armature, "name")
bpy.msgbus.subscribe_rna(
key=armature_name_subscribe_to,
owner=subscription.armature_name_subscription_owner,
args=(),
notify=on_change_bpy_armature_name,
)
bpy.msgbus.publish_rna(key=object_name_subscribe_to)
bpy.msgbus.publish_rna(key=object_mode_subscribe_to)
bpy.msgbus.publish_rna(key=object_location_subscribe_to)
bpy.msgbus.publish_rna(key=bone_name_subscribe_to)
bpy.msgbus.publish_rna(key=armature_name_subscribe_to)
def teardown_subscription() -> None:
subscription.setup_once = False
bpy.msgbus.clear_by_owner(subscription.armature_name_subscription_owner)
bpy.msgbus.clear_by_owner(subscription.bone_name_subscription_owner)
bpy.msgbus.clear_by_owner(subscription.object_location_subscription_owner)
bpy.msgbus.clear_by_owner(subscription.object_mode_subscription_owner)
bpy.msgbus.clear_by_owner(subscription.object_name_subscription_owner)
def on_change_bpy_object_name() -> None:
context = bpy.context
for armature in context.blend_data.armatures:
ext = get_armature_extension(armature)
if (
tuple(ext.addon_version)
== VrmAddonArmatureExtensionPropertyGroup.INITIAL_ADDON_VERSION
):
continue
# TODO: Needs optimization!
for collider in ext.spring_bone1.colliders:
collider.broadcast_bpy_object_name()
def on_change_bpy_object_mode() -> None:
context = bpy.context
active_object = context.active_object
if not active_object:
return
mtoon1_ops.VRM_OT_refresh_mtoon1_outline.refresh_object(context, active_object)
def on_change_bpy_object_location() -> None:
context = bpy.context
active_object = context.active_object
if not active_object:
return
vrm1_property_group.Vrm1LookAtPropertyGroup.update_all_previews(context)
def on_change_bpy_bone_name() -> None:
context = bpy.context
for armature in context.blend_data.armatures:
ext = get_armature_extension(armature)
if (
tuple(ext.addon_version)
== VrmAddonArmatureExtensionPropertyGroup.INITIAL_ADDON_VERSION
):
continue
Vrm0HumanoidPropertyGroup.update_all_node_candidates(context, armature.name)
def on_change_bpy_armature_name() -> None:
context = bpy.context
migrate_all_objects(context, skip_non_migrated_armatures=True)

716
editor/t_pose.py Normal file
View File

@@ -0,0 +1,716 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import math
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from sys import float_info
from typing import Optional, Union
import bpy
from bpy.types import (
Armature,
Context,
Object,
Pose,
PoseBone,
)
from mathutils import Euler, Matrix, Quaternion, Vector
from ..common import ops
from ..common.logger import get_logger
from ..common.rotation import (
get_rotation_as_quaternion,
set_rotation_without_mode_change,
)
from ..common.vrm0.human_bone import HumanBoneName as Vrm0HumanBoneName
from ..common.vrm1.human_bone import HumanBoneName as Vrm1HumanBoneName
from ..common.workspace import save_workspace
from .extension import VrmAddonArmatureExtensionPropertyGroup, get_armature_extension
from .vrm0.property_group import Vrm0HumanoidPropertyGroup
from .vrm1.property_group import Vrm1HumanoidPropertyGroup
logger = get_logger(__name__)
def set_bone_direction_to_align_child_bone(
context: Context,
armature: Object,
direction: Vector,
bone: PoseBone,
child_bone: PoseBone,
) -> None:
world_bone_matrix = armature.matrix_world @ bone.matrix
world_child_bone_matrix = armature.matrix_world @ child_bone.matrix
world_bone_length = (
world_child_bone_matrix.translation - world_bone_matrix.translation
).length
world_child_bone_from_translation = world_child_bone_matrix.translation
world_child_bone_to_translation = (
world_bone_matrix.translation + direction * world_bone_length
)
bone_local_child_bone_from_translation = (
armature.matrix_world @ bone.matrix
).inverted_safe() @ world_child_bone_from_translation
bone_local_child_bone_to_translation = (
armature.matrix_world @ bone.matrix
).inverted_safe() @ world_child_bone_to_translation
if bone_local_child_bone_from_translation.length_squared < float_info.epsilon:
return
if bone_local_child_bone_to_translation.length_squared < float_info.epsilon:
return
rotation = bone_local_child_bone_from_translation.rotation_difference(
bone_local_child_bone_to_translation
)
set_rotation_without_mode_change(bone, rotation @ get_rotation_as_quaternion(bone))
context.view_layer.update()
def set_bone_direction_to_align_z_world_location(
context: Context,
armature: Object,
direction: Vector,
bone: PoseBone,
from_world_location: Vector,
) -> None:
world_bone_translation = (armature.matrix_world @ bone.matrix).to_translation()
aligned_from_world_location = Vector(
(
from_world_location.x,
world_bone_translation.y,
from_world_location.z,
)
)
aligned_to_world_location = world_bone_translation + direction
bone_local_aligned_from_world_location = (
armature.matrix_world @ bone.matrix
).inverted_safe() @ aligned_from_world_location
bone_local_aligned_to_world_location = (
armature.matrix_world @ bone.matrix
).inverted_safe() @ aligned_to_world_location
if bone_local_aligned_from_world_location.length_squared < float_info.epsilon:
return
if bone_local_aligned_to_world_location.length_squared < float_info.epsilon:
return
rotation = bone_local_aligned_from_world_location.rotation_difference(
bone_local_aligned_to_world_location
)
set_rotation_without_mode_change(bone, rotation @ get_rotation_as_quaternion(bone))
context.view_layer.update()
@dataclass(frozen=True)
class ChainSingleChild:
direction: Vector
vrm0_human_bone_names: tuple[Vrm0HumanBoneName, ...]
vrm1_human_bone_names: tuple[Vrm1HumanBoneName, ...]
def execute(self, context: Context, armature: Object) -> None:
armature_data = armature.data
if not isinstance(armature_data, Armature):
raise TypeError
ext = get_armature_extension(armature_data)
if ext.is_vrm0():
humanoid = ext.vrm0.humanoid
bones: list[PoseBone] = []
for human_bone in humanoid.human_bones:
human_bone_name = Vrm0HumanBoneName.from_str(human_bone.bone)
if not human_bone_name:
continue
if human_bone_name not in self.vrm0_human_bone_names:
continue
bone = armature.pose.bones.get(human_bone.node.bone_name)
if not bone:
continue
bones.append(bone)
else:
human_bones = ext.vrm1.humanoid.human_bones
human_bone_name_to_human_bone = human_bones.human_bone_name_to_human_bone()
bones = [
bone
for bone in [
armature.pose.bones.get(human_bone.node.bone_name)
for human_bone in [
human_bone_name_to_human_bone.get(human_bone_name)
for human_bone_name in self.vrm1_human_bone_names
]
if human_bone
]
if bone
]
if len(bones) < 2:
return
root_bone = bones[0]
tip_bone = bones[-1]
chained_bones: list[PoseBone] = []
searching_bone: Optional[PoseBone] = tip_bone
while True:
if not searching_bone:
return
chained_bones.insert(0, searching_bone)
if searching_bone == root_bone:
break
searching_bone = searching_bone.parent
for bone, child_bone in zip(chained_bones, chained_bones[1:]):
set_bone_direction_to_align_child_bone(
context, armature, self.direction, bone, child_bone
)
@dataclass(frozen=True)
class ChainHorizontalMultipleChildren:
direction: Vector
vrm0_parent_human_bone_name: Vrm0HumanBoneName
vrm0_human_bone_names: tuple[Vrm0HumanBoneName, ...]
vrm1_parent_human_bone_name: Vrm1HumanBoneName
vrm1_human_bone_names: tuple[Vrm1HumanBoneName, ...]
def execute(self, context: Context, armature: Object) -> None:
armature_data = armature.data
if not isinstance(armature_data, Armature):
raise TypeError
ext = get_armature_extension(armature_data)
if ext.is_vrm0():
humanoid = ext.vrm0.humanoid
bones: list[PoseBone] = []
parent_bone: Optional[PoseBone] = None
for human_bone in humanoid.human_bones:
human_bone_name = Vrm0HumanBoneName.from_str(human_bone.bone)
if not human_bone_name:
continue
if human_bone_name == self.vrm0_parent_human_bone_name:
parent_bone = armature.pose.bones.get(human_bone.node.bone_name)
elif human_bone_name in self.vrm0_human_bone_names:
bone = armature.pose.bones.get(human_bone.node.bone_name)
if bone:
bones.append(bone)
else:
human_bones = ext.vrm1.humanoid.human_bones
human_bone_name_to_human_bone = human_bones.human_bone_name_to_human_bone()
bones = [
bone
for bone in [
armature.pose.bones.get(human_bone.node.bone_name)
for human_bone in [
human_bone_name_to_human_bone.get(human_bone_name)
for human_bone_name in self.vrm1_human_bone_names
]
if human_bone
]
if bone
]
parent_human_bone = human_bone_name_to_human_bone.get(
self.vrm1_parent_human_bone_name
)
parent_bone = None
if parent_human_bone:
parent_bone = armature.pose.bones.get(parent_human_bone.node.bone_name)
if not bones:
return
world_location = Vector((0, 0, 0))
for bone in bones:
world_location += (armature.matrix_world @ bone.matrix).to_translation()
world_location /= len(bones)
if not parent_bone:
return
set_bone_direction_to_align_z_world_location(
context, armature, self.direction, parent_bone, world_location
)
def reset_root_to_human_bone_translation(
pose: Pose, ext: VrmAddonArmatureExtensionPropertyGroup
) -> None:
# Reset positions from root bone to any Human bone
# No particular reason for not resetting all bones, just intuition
bones: list[PoseBone] = [bone for bone in pose.bones if not bone.parent]
while bones:
bone = bones.pop()
bone.location = Vector((0, 0, 0))
if ext.is_vrm1():
human_bone_name_to_human_bones = (
ext.vrm1.humanoid.human_bones.human_bone_name_to_human_bone()
)
if any(
True
for human_bone in human_bone_name_to_human_bones.values()
if human_bone.node.bone_name == bone.name
):
continue
if ext.is_vrm0() and any(
True
for human_bone in ext.vrm0.humanoid.human_bones
if human_bone.node.bone_name == bone.name
):
continue
bones.extend(bone.children)
def set_estimated_humanoid_t_pose(context: Context, armature: Object) -> bool:
armature_data = armature.data
if not isinstance(armature_data, Armature):
return False
ext = get_armature_extension(armature_data)
if ext.is_vrm0():
if not ext.vrm0.humanoid.all_required_bones_are_assigned():
return False
else:
human_bones = ext.vrm1.humanoid.human_bones
if not human_bones.all_required_bones_are_assigned():
return False
for bone in armature.pose.bones:
set_rotation_without_mode_change(bone, Quaternion())
reset_root_to_human_bone_translation(armature.pose, ext)
context.view_layer.update()
# https://github.com/vrm-c/vrm-specification/blob/73855bb77d431a3374212551a4fa48e043be3ced/specification/VRMC_vrm-1.0/tpose.md
chains: tuple[Union[ChainSingleChild, ChainHorizontalMultipleChildren], ...] = (
ChainSingleChild(
Vector((-1, 0, 0)),
(
Vrm0HumanBoneName.RIGHT_UPPER_ARM,
Vrm0HumanBoneName.RIGHT_LOWER_ARM,
Vrm0HumanBoneName.RIGHT_HAND,
),
(
Vrm1HumanBoneName.RIGHT_UPPER_ARM,
Vrm1HumanBoneName.RIGHT_LOWER_ARM,
Vrm1HumanBoneName.RIGHT_HAND,
),
),
ChainHorizontalMultipleChildren(
Vector((-1, 0, 0)),
Vrm0HumanBoneName.RIGHT_HAND,
(
Vrm0HumanBoneName.RIGHT_INDEX_PROXIMAL,
Vrm0HumanBoneName.RIGHT_MIDDLE_PROXIMAL,
Vrm0HumanBoneName.RIGHT_RING_PROXIMAL,
Vrm0HumanBoneName.RIGHT_LITTLE_PROXIMAL,
),
Vrm1HumanBoneName.RIGHT_HAND,
(
Vrm1HumanBoneName.RIGHT_INDEX_PROXIMAL,
Vrm1HumanBoneName.RIGHT_MIDDLE_PROXIMAL,
Vrm1HumanBoneName.RIGHT_RING_PROXIMAL,
Vrm1HumanBoneName.RIGHT_LITTLE_PROXIMAL,
),
),
ChainSingleChild(
Vector((-math.sqrt(0.5), -math.sqrt(0.5), 0)),
(
Vrm0HumanBoneName.RIGHT_THUMB_PROXIMAL,
Vrm0HumanBoneName.RIGHT_THUMB_INTERMEDIATE,
Vrm0HumanBoneName.RIGHT_THUMB_DISTAL,
),
(
Vrm1HumanBoneName.RIGHT_THUMB_METACARPAL,
Vrm1HumanBoneName.RIGHT_THUMB_PROXIMAL,
Vrm1HumanBoneName.RIGHT_THUMB_DISTAL,
),
),
ChainSingleChild(
Vector((-1, 0, 0)),
(
Vrm0HumanBoneName.RIGHT_INDEX_PROXIMAL,
Vrm0HumanBoneName.RIGHT_INDEX_INTERMEDIATE,
Vrm0HumanBoneName.RIGHT_INDEX_DISTAL,
),
(
Vrm1HumanBoneName.RIGHT_INDEX_PROXIMAL,
Vrm1HumanBoneName.RIGHT_INDEX_INTERMEDIATE,
Vrm1HumanBoneName.RIGHT_INDEX_DISTAL,
),
),
ChainSingleChild(
Vector((-1, 0, 0)),
(
Vrm0HumanBoneName.RIGHT_MIDDLE_PROXIMAL,
Vrm0HumanBoneName.RIGHT_MIDDLE_INTERMEDIATE,
Vrm0HumanBoneName.RIGHT_MIDDLE_DISTAL,
),
(
Vrm1HumanBoneName.RIGHT_MIDDLE_PROXIMAL,
Vrm1HumanBoneName.RIGHT_MIDDLE_INTERMEDIATE,
Vrm1HumanBoneName.RIGHT_MIDDLE_DISTAL,
),
),
ChainSingleChild(
Vector((-1, 0, 0)),
(
Vrm0HumanBoneName.RIGHT_RING_PROXIMAL,
Vrm0HumanBoneName.RIGHT_RING_INTERMEDIATE,
Vrm0HumanBoneName.RIGHT_RING_DISTAL,
),
(
Vrm1HumanBoneName.RIGHT_RING_PROXIMAL,
Vrm1HumanBoneName.RIGHT_RING_INTERMEDIATE,
Vrm1HumanBoneName.RIGHT_RING_DISTAL,
),
),
ChainSingleChild(
Vector((-1, 0, 0)),
(
Vrm0HumanBoneName.RIGHT_LITTLE_PROXIMAL,
Vrm0HumanBoneName.RIGHT_LITTLE_INTERMEDIATE,
Vrm0HumanBoneName.RIGHT_LITTLE_DISTAL,
),
(
Vrm1HumanBoneName.RIGHT_LITTLE_PROXIMAL,
Vrm1HumanBoneName.RIGHT_LITTLE_INTERMEDIATE,
Vrm1HumanBoneName.RIGHT_LITTLE_DISTAL,
),
),
ChainSingleChild(
Vector((0, 0, -1)),
(
Vrm0HumanBoneName.RIGHT_UPPER_LEG,
Vrm0HumanBoneName.RIGHT_LOWER_LEG,
Vrm0HumanBoneName.RIGHT_FOOT,
),
(
Vrm1HumanBoneName.RIGHT_UPPER_LEG,
Vrm1HumanBoneName.RIGHT_LOWER_LEG,
Vrm1HumanBoneName.RIGHT_FOOT,
),
),
ChainSingleChild(
Vector((1, 0, 0)),
(
Vrm0HumanBoneName.LEFT_UPPER_ARM,
Vrm0HumanBoneName.LEFT_LOWER_ARM,
Vrm0HumanBoneName.LEFT_HAND,
),
(
Vrm1HumanBoneName.LEFT_UPPER_ARM,
Vrm1HumanBoneName.LEFT_LOWER_ARM,
Vrm1HumanBoneName.LEFT_HAND,
),
),
ChainHorizontalMultipleChildren(
Vector((1, 0, 0)),
Vrm0HumanBoneName.LEFT_HAND,
(
Vrm0HumanBoneName.LEFT_INDEX_PROXIMAL,
Vrm0HumanBoneName.LEFT_MIDDLE_PROXIMAL,
Vrm0HumanBoneName.LEFT_RING_PROXIMAL,
Vrm0HumanBoneName.LEFT_LITTLE_PROXIMAL,
),
Vrm1HumanBoneName.LEFT_HAND,
(
Vrm1HumanBoneName.LEFT_INDEX_PROXIMAL,
Vrm1HumanBoneName.LEFT_MIDDLE_PROXIMAL,
Vrm1HumanBoneName.LEFT_RING_PROXIMAL,
Vrm1HumanBoneName.LEFT_LITTLE_PROXIMAL,
),
),
ChainSingleChild(
Vector((math.sqrt(0.5), -math.sqrt(0.5), 0)),
(
Vrm0HumanBoneName.LEFT_THUMB_PROXIMAL,
Vrm0HumanBoneName.LEFT_THUMB_INTERMEDIATE,
Vrm0HumanBoneName.LEFT_THUMB_DISTAL,
),
(
Vrm1HumanBoneName.LEFT_THUMB_METACARPAL,
Vrm1HumanBoneName.LEFT_THUMB_PROXIMAL,
Vrm1HumanBoneName.LEFT_THUMB_DISTAL,
),
),
ChainSingleChild(
Vector((1, 0, 0)),
(
Vrm0HumanBoneName.LEFT_INDEX_PROXIMAL,
Vrm0HumanBoneName.LEFT_INDEX_INTERMEDIATE,
Vrm0HumanBoneName.LEFT_INDEX_DISTAL,
),
(
Vrm1HumanBoneName.LEFT_INDEX_PROXIMAL,
Vrm1HumanBoneName.LEFT_INDEX_INTERMEDIATE,
Vrm1HumanBoneName.LEFT_INDEX_DISTAL,
),
),
ChainSingleChild(
Vector((1, 0, 0)),
(
Vrm0HumanBoneName.LEFT_MIDDLE_PROXIMAL,
Vrm0HumanBoneName.LEFT_MIDDLE_INTERMEDIATE,
Vrm0HumanBoneName.LEFT_MIDDLE_DISTAL,
),
(
Vrm1HumanBoneName.LEFT_MIDDLE_PROXIMAL,
Vrm1HumanBoneName.LEFT_MIDDLE_INTERMEDIATE,
Vrm1HumanBoneName.LEFT_MIDDLE_DISTAL,
),
),
ChainSingleChild(
Vector((1, 0, 0)),
(
Vrm0HumanBoneName.LEFT_RING_PROXIMAL,
Vrm0HumanBoneName.LEFT_RING_INTERMEDIATE,
Vrm0HumanBoneName.LEFT_RING_DISTAL,
),
(
Vrm1HumanBoneName.LEFT_RING_PROXIMAL,
Vrm1HumanBoneName.LEFT_RING_INTERMEDIATE,
Vrm1HumanBoneName.LEFT_RING_DISTAL,
),
),
ChainSingleChild(
Vector((1, 0, 0)),
(
Vrm0HumanBoneName.LEFT_LITTLE_PROXIMAL,
Vrm0HumanBoneName.LEFT_LITTLE_INTERMEDIATE,
Vrm0HumanBoneName.LEFT_LITTLE_DISTAL,
),
(
Vrm1HumanBoneName.LEFT_LITTLE_PROXIMAL,
Vrm1HumanBoneName.LEFT_LITTLE_INTERMEDIATE,
Vrm1HumanBoneName.LEFT_LITTLE_DISTAL,
),
),
ChainSingleChild(
Vector((0, 0, -1)),
(
Vrm0HumanBoneName.LEFT_UPPER_LEG,
Vrm0HumanBoneName.LEFT_LOWER_LEG,
Vrm0HumanBoneName.LEFT_FOOT,
),
(
Vrm1HumanBoneName.LEFT_UPPER_LEG,
Vrm1HumanBoneName.LEFT_LOWER_LEG,
Vrm1HumanBoneName.LEFT_FOOT,
),
),
)
for chain in chains:
chain.execute(context, armature)
return True
@dataclass(frozen=True)
class PoseBonePose:
matrix_basis: Matrix
rotation_mode: str
rotation_quaternion: Quaternion
rotation_axis_angle: tuple[float, float, float, float]
rotation_euler: Euler
scale: Vector
location: Vector
bone_select: bool
@staticmethod
def save(pose: Pose) -> Mapping[str, "PoseBonePose"]:
return {
bone.name: PoseBonePose(
matrix_basis=bone.matrix_basis.copy(),
rotation_mode=bone.rotation_mode,
rotation_quaternion=bone.rotation_quaternion.copy(),
rotation_axis_angle=(
bone.rotation_axis_angle[0],
bone.rotation_axis_angle[1],
bone.rotation_axis_angle[2],
bone.rotation_axis_angle[3],
),
rotation_euler=bone.rotation_euler.copy(),
scale=bone.scale.copy(),
location=bone.location.copy(),
bone_select=(bone.bone.select if bpy.app.version < (5, 0) else False),
)
for bone in pose.bones
}
@staticmethod
def load(
context: Context,
pose: Pose,
bone_name_to_pose_bone_pose: Mapping[str, "PoseBonePose"],
) -> None:
context.view_layer.update()
bones = [bone for bone in pose.bones if not bone.parent]
while bones:
bone = bones.pop()
pose_bone_pose = bone_name_to_pose_bone_pose.get(bone.name)
if pose_bone_pose:
bone.matrix_basis = pose_bone_pose.matrix_basis.copy()
# Directly restoring bone.matrix seems more efficient, but doing so
# can cause problems when constraints are attached
# https://github.com/saturday06/VRM-Addon-for-Blender/issues/671
bone.rotation_mode = pose_bone_pose.rotation_mode
bone.rotation_axis_angle = list(pose_bone_pose.rotation_axis_angle)
bone.rotation_quaternion = pose_bone_pose.rotation_quaternion.copy()
bone.rotation_euler = pose_bone_pose.rotation_euler.copy()
bone.scale = pose_bone_pose.scale.copy()
bone.location = pose_bone_pose.location.copy()
if bpy.app.version < (5, 0):
bone.bone.select = pose_bone_pose.bone_select
bones.extend(bone.children)
context.view_layer.update()
def leave_setup_humanoid_t_pose(
context: Context,
armature: Object,
saved_pose_bone_pose: Mapping[str, PoseBonePose],
saved_pose_position: str,
*,
saved_vrm1_look_at_preview: bool,
) -> None:
with save_workspace(context, armature):
bpy.ops.object.select_all(action="DESELECT")
bpy.ops.object.mode_set(mode="POSE")
PoseBonePose.load(context, armature.pose, saved_pose_bone_pose)
bpy.ops.object.mode_set(mode="OBJECT")
armature_data = armature.data
if not isinstance(armature_data, Armature):
return
armature_data.pose_position = saved_pose_position
ext = get_armature_extension(armature_data)
if (
ext.is_vrm1()
and ext.vrm1.look_at.enable_preview != saved_vrm1_look_at_preview
):
ext.vrm1.look_at.enable_preview = saved_vrm1_look_at_preview
@contextmanager
def setup_humanoid_t_pose(
context: Context,
armature: Object,
) -> Iterator[None]:
armature_data = armature.data
if not isinstance(armature_data, Armature):
raise TypeError
ext = get_armature_extension(armature_data)
if ext.is_vrm0():
humanoid: Union[Vrm0HumanoidPropertyGroup, Vrm1HumanoidPropertyGroup] = (
ext.vrm0.humanoid
)
else:
humanoid = ext.vrm1.humanoid
pose = humanoid.pose
action = humanoid.pose_library
pose_marker_name = humanoid.pose_marker_name
if pose != humanoid.POSE_CUSTOM_POSE.identifier:
action = None
pose_marker_name = ""
if (
pose == humanoid.POSE_CURRENT_POSE.identifier
and armature_data.pose_position == "REST"
):
yield
return
if pose == humanoid.POSE_REST_POSITION_POSE.identifier or (
pose == humanoid.POSE_CUSTOM_POSE.identifier
and not (action and action.name in context.blend_data.actions)
):
saved_pose_position = armature_data.pose_position
armature_data.pose_position = "REST"
try:
yield
finally:
armature_data.pose_position = saved_pose_position
return
with save_workspace(context, armature):
bpy.ops.object.select_all(action="DESELECT")
bpy.ops.object.mode_set(mode="POSE")
saved_pose_position = armature_data.pose_position
if armature_data.pose_position != "POSE":
armature_data.pose_position = "POSE"
context.view_layer.update()
saved_pose_bone_pose = PoseBonePose.save(armature.pose)
if bpy.app.version < (5, 0):
for bone in armature.pose.bones:
bone.bone.select = False
ext = get_armature_extension(armature_data)
saved_vrm1_look_at_preview = ext.vrm1.look_at.enable_preview
if ext.is_vrm1() and ext.vrm1.look_at.enable_preview:
# TODO: It would be helpful to warn in advance if this is
# reached during export
ext.vrm1.look_at.enable_preview = False
if ext.vrm1.look_at.type == ext.vrm1.look_at.TYPE_BONE.identifier:
human_bones = ext.vrm1.humanoid.human_bones
left_eye_bone_name = human_bones.left_eye.node.bone_name
left_eye_bone = armature.pose.bones.get(left_eye_bone_name)
if left_eye_bone:
set_rotation_without_mode_change(left_eye_bone, Quaternion())
right_eye_bone_name = human_bones.right_eye.node.bone_name
right_eye_bone = armature.pose.bones.get(right_eye_bone_name)
if right_eye_bone:
set_rotation_without_mode_change(right_eye_bone, Quaternion())
if pose == humanoid.POSE_AUTO_POSE.identifier:
ops.vrm.make_estimated_humanoid_t_pose(armature_object_name=armature.name)
elif pose == humanoid.POSE_CUSTOM_POSE.identifier:
if action and action.name in context.blend_data.actions:
pose_marker_frame = 0
if pose_marker_name:
for search_pose_marker in action.pose_markers.values():
if search_pose_marker.name == pose_marker_name:
pose_marker_frame = search_pose_marker.frame
break
armature.pose.apply_pose_from_action(
action, evaluation_time=pose_marker_frame
)
else:
# TODO: It would be helpful to warn in advance if this is
# reached during export
ops.vrm.make_estimated_humanoid_t_pose(
armature_object_name=armature.name
)
context.view_layer.update()
try:
yield
# After yield, bpy native objects may be deleted or frames may advance
# making them invalid. Accessing them in this state can cause crashes,
# so be careful not to access potentially invalid native objects after yield
finally:
leave_setup_humanoid_t_pose(
context,
armature,
saved_pose_bone_pose,
saved_pose_position,
saved_vrm1_look_at_preview=saved_vrm1_look_at_preview,
)

1006
editor/validation.py Normal file

File diff suppressed because it is too large Load Diff

1
editor/vrm0/__init__.py Normal file
View File

@@ -0,0 +1 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,62 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from collections.abc import Set as AbstractSet
from bpy.types import Armature, Context, GizmoGroup
from ..extension import get_armature_extension
# https://gist.github.com/FujiSunflower/09fdabc7ca991f8292657abc4ef001b0
class Vrm0FirstPersonBoneOffsetGizmoGroup(GizmoGroup):
bl_idname = "VRM_GGT_vrm0_first_person_bone_offset"
bl_label = "First Person Bone Offset Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options: AbstractSet[str] = {"3D", "PERSISTENT"}
@classmethod
def poll(cls, context: Context) -> bool:
active_object = context.active_object
if not active_object:
return False
return active_object.type == "ARMATURE"
def setup(self, context: Context) -> None:
active_object = context.active_object
if not active_object:
return
armature_data = active_object.data
if not isinstance(armature_data, Armature):
return
ext = get_armature_extension(armature_data)
first_person = ext.vrm0.first_person
first_person_bone = armature_data.bones[
first_person.first_person_bone.bone_name
]
gizmo = self.gizmos.new("GIZMO_GT_move_3d")
gizmo.target_set_prop("offset", first_person, "first_person_bone_offset")
gizmo.matrix_basis = first_person_bone.matrix_local
gizmo.draw_style = "CROSS_2D"
gizmo.draw_options = {"ALIGN_VIEW"}
gizmo.color = 1.0, 0.5, 0.0
gizmo.alpha = 0.5
gizmo.color_highlight = 1.0, 0.5, 1.0
gizmo.alpha_highlight = 0.5
gizmo.scale_basis = 0.25
self.first_person_gizmo = gizmo
def refresh(self, context: Context) -> None:
active_object = context.active_object
if not active_object:
return
armature_data = active_object.data
if not isinstance(armature_data, Armature):
return
ext = get_armature_extension(armature_data)
gizmo = self.first_person_gizmo
first_person = ext.vrm0.first_person
first_person_bone = armature_data.bones[
first_person.first_person_bone.bone_name
]
gizmo.matrix_basis = first_person_bone.matrix_local

37
editor/vrm0/handler.py Normal file
View File

@@ -0,0 +1,37 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import bpy
from bpy.app.handlers import persistent
from ...common.logger import get_logger
from .property_group import Vrm0BlendShapeGroupPropertyGroup
logger = get_logger(__name__)
@persistent
def frame_change_pre(_unused: object) -> None:
Vrm0BlendShapeGroupPropertyGroup.frame_change_post_shape_key_updates.clear()
@persistent
def frame_change_post(_unused: object) -> None:
context = bpy.context
for (
(
shape_key_name,
key_block_name,
),
value,
) in Vrm0BlendShapeGroupPropertyGroup.frame_change_post_shape_key_updates.items():
shape_key = context.blend_data.shape_keys.get(shape_key_name)
if not shape_key:
continue
key_blocks = shape_key.key_blocks
if not key_blocks:
continue
key_block = key_blocks.get(key_block_name)
if not key_block:
continue
key_block.value = value
Vrm0BlendShapeGroupPropertyGroup.frame_change_post_shape_key_updates.clear()

24
editor/vrm0/menu.py Normal file
View File

@@ -0,0 +1,24 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from bpy.types import Context, Menu
from ...common.logger import get_logger
from ..ops import layout_operator
from ..search import current_armature
from .ops import VRM_OT_restore_vrm0_blend_shape_group_bind_object
logger = get_logger(__name__)
class VRM_MT_vrm0_blend_shape_master(Menu):
bl_label = "Blend Shape Proxy Menu"
bl_idname = "VRM_MT_vrm0_blend_shape_master"
def draw(self, context: Context) -> None:
layout = self.layout
armature = current_armature(context)
if not armature:
return
op = layout_operator(layout, VRM_OT_restore_vrm0_blend_shape_group_bind_object)
op.armature_object_name = armature.name

731
editor/vrm0/migration.py Normal file
View File

@@ -0,0 +1,731 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import contextlib
import json
import uuid
from typing import Optional
from bpy.types import ID, Armature, Context, Mesh, Object, Text
from ...common import convert, ops
from ...common.convert import Json
from ...common.deep import make_json
from ...common.vrm0.human_bone import HumanBoneSpecifications
from ..extension import get_armature_extension, get_bone_extension
from ..property_group import BonePropertyGroup
from .property_group import (
Vrm0BlendShapeMasterPropertyGroup,
Vrm0FirstPersonPropertyGroup,
Vrm0HumanoidPropertyGroup,
Vrm0MeshAnnotationPropertyGroup,
Vrm0MetaPropertyGroup,
Vrm0PropertyGroup,
Vrm0SecondaryAnimationPropertyGroup,
)
def read_textblock_json(context: Context, armature: Object, armature_key: str) -> Json:
text_key = armature.get(armature_key)
if isinstance(text_key, Text):
textblock: Optional[Text] = text_key
elif not isinstance(text_key, str):
return None
else:
textblock = context.blend_data.texts.get(text_key)
if not isinstance(textblock, Text):
return None
textblock_str = "".join(line.body for line in textblock.lines)
with contextlib.suppress(json.JSONDecodeError):
return make_json(json.loads(textblock_str))
return None
def migrate_vrm0_meta(
context: Context, meta: Vrm0MetaPropertyGroup, armature: Object
) -> None:
allowed_user_name = armature.get("allowedUserName")
if (
isinstance(allowed_user_name, str)
and allowed_user_name
in Vrm0MetaPropertyGroup.allowed_user_name_enum.identifiers()
):
meta.allowed_user_name = allowed_user_name
author = armature.get("author")
if isinstance(author, str):
meta.author = author
commercial_ussage_name = armature.get("commercialUssageName")
if (
isinstance(commercial_ussage_name, str)
and commercial_ussage_name
in Vrm0MetaPropertyGroup.commercial_ussage_name_enum.identifiers()
):
meta.commercial_ussage_name = commercial_ussage_name
contact_information = armature.get("contactInformation")
if isinstance(contact_information, str):
meta.contact_information = contact_information
license_name = armature.get("licenseName")
if (
isinstance(license_name, str)
and license_name in Vrm0MetaPropertyGroup.license_name_enum.identifiers()
):
meta.license_name = license_name
other_license_url = armature.get("otherLicenseUrl")
if isinstance(other_license_url, str):
meta.other_license_url = other_license_url
other_permission_url = armature.get("otherPermissionUrl")
if isinstance(other_permission_url, str):
meta.other_permission_url = other_permission_url
reference = armature.get("reference")
if isinstance(reference, str):
meta.reference = reference
sexual_ussage_name = armature.get("sexualUssageName")
if (
isinstance(sexual_ussage_name, str)
and sexual_ussage_name
in Vrm0MetaPropertyGroup.sexual_ussage_name_enum.identifiers()
):
meta.sexual_ussage_name = sexual_ussage_name
title = armature.get("title")
if isinstance(title, str):
meta.title = title
version = armature.get("version")
if isinstance(version, str):
meta.version = version
violent_ussage_name = armature.get("violentUssageName")
if (
isinstance(violent_ussage_name, str)
and violent_ussage_name
in Vrm0MetaPropertyGroup.violent_ussage_name_enum.identifiers()
):
meta.violent_ussage_name = violent_ussage_name
texture = armature.get("texture")
if isinstance(texture, str):
texture_image = context.blend_data.images.get(texture)
if texture_image:
meta.texture = texture_image
def migrate_vrm0_humanoid(
humanoid: Vrm0HumanoidPropertyGroup, humanoid_dict: Json
) -> None:
if not isinstance(humanoid_dict, dict):
return
arm_stretch = convert.float_or_none(humanoid_dict.get("armStretch"))
if arm_stretch is not None:
humanoid.arm_stretch = arm_stretch
leg_stretch = convert.float_or_none(humanoid_dict.get("legStretch"))
if leg_stretch is not None:
humanoid.leg_stretch = leg_stretch
upper_arm_twist = convert.float_or_none(humanoid_dict.get("upperArmTwist"))
if upper_arm_twist is not None:
humanoid.upper_arm_twist = upper_arm_twist
lower_arm_twist = convert.float_or_none(humanoid_dict.get("lowerArmTwist"))
if lower_arm_twist is not None:
humanoid.lower_arm_twist = lower_arm_twist
upper_leg_twist = convert.float_or_none(humanoid_dict.get("upperLegTwist"))
if upper_leg_twist is not None:
humanoid.upper_leg_twist = upper_leg_twist
lower_leg_twist = convert.float_or_none(humanoid_dict.get("lowerLegTwist"))
if lower_leg_twist is not None:
humanoid.lower_leg_twist = lower_leg_twist
feet_spacing = convert.float_or_none(humanoid_dict.get("feetSpacing"))
if feet_spacing is not None:
humanoid.feet_spacing = feet_spacing
has_translation_dof = humanoid_dict.get("hasTranslationDoF")
if isinstance(has_translation_dof, bool):
humanoid.has_translation_dof = has_translation_dof
def migrate_vrm0_first_person(
context: Context,
first_person: Vrm0FirstPersonPropertyGroup,
first_person_dict: Json,
) -> None:
if not isinstance(first_person_dict, dict):
return
first_person_bone = first_person_dict.get("firstPersonBone")
if isinstance(first_person_bone, str):
first_person.first_person_bone.bone_name = first_person_bone
first_person_bone_offset = convert.vrm_json_vector3_to_tuple(
first_person_dict.get("firstPersonBoneOffset")
)
if first_person_bone_offset is not None:
# Axis confusing
(x, y, z) = first_person_bone_offset
first_person.first_person_bone_offset = (x, z, y)
mesh_annotation_dicts = first_person_dict.get("meshAnnotations")
if isinstance(mesh_annotation_dicts, list):
for mesh_annotation_dict in mesh_annotation_dicts:
mesh_annotation = first_person.mesh_annotations.add()
if not isinstance(mesh_annotation_dict, dict):
continue
mesh = mesh_annotation_dict.get("mesh")
if isinstance(mesh, str):
if mesh in context.blend_data.meshes:
for obj in context.blend_data.objects:
if obj.data == context.blend_data.meshes[mesh]:
mesh_annotation.mesh.mesh_object_name = obj.name
break
elif (
mesh in context.blend_data.objects
and context.blend_data.objects[mesh].type == "MESH"
):
mesh_annotation.mesh.mesh_object_name = context.blend_data.objects[
mesh
].name
first_person_flag = mesh_annotation_dict.get("firstPersonFlag")
if (
isinstance(first_person_flag, str)
and first_person_flag
in Vrm0MeshAnnotationPropertyGroup.first_person_flag_enum.identifiers()
):
mesh_annotation.first_person_flag = first_person_flag
look_at_type_name = first_person_dict.get("lookAtTypeName")
if (
isinstance(look_at_type_name, str)
and look_at_type_name in first_person.look_at_type_name_enum.identifiers()
):
first_person.look_at_type_name = look_at_type_name
for look_at, look_at_dict in [
(
first_person.look_at_horizontal_inner,
first_person_dict.get("lookAtHorizontalInner"),
),
(
first_person.look_at_horizontal_outer,
first_person_dict.get("lookAtHorizontalOuter"),
),
(
first_person.look_at_vertical_down,
first_person_dict.get("lookAtVerticalDown"),
),
(
first_person.look_at_vertical_up,
first_person_dict.get("lookAtVerticalUp"),
),
]:
if not isinstance(look_at_dict, dict):
continue
curve = convert.vrm_json_curve_to_list(look_at_dict.get("curve"))
if curve is not None:
look_at.curve = curve
x_range = look_at_dict.get("xRange")
if isinstance(x_range, (float, int)):
look_at.x_range = x_range
y_range = look_at_dict.get("yRange")
if isinstance(y_range, (float, int)):
look_at.y_range = y_range
def migrate_vrm0_blend_shape_groups(
context: Context,
blend_shape_master: Vrm0BlendShapeMasterPropertyGroup,
blend_shape_group_dicts: Json,
) -> None:
if not isinstance(blend_shape_group_dicts, list):
return
for blend_shape_group_dict in blend_shape_group_dicts:
blend_shape_group = blend_shape_master.blend_shape_groups.add()
if not isinstance(blend_shape_group_dict, dict):
continue
name = blend_shape_group_dict.get("name")
if isinstance(name, str):
blend_shape_group.name = name
preset_name = blend_shape_group_dict.get("presetName")
if (
isinstance(preset_name, str)
and preset_name in blend_shape_group.preset_name_enum.identifiers()
):
blend_shape_group.preset_name = preset_name
bind_dicts = blend_shape_group_dict.get("binds")
if isinstance(bind_dicts, list):
for bind_dict in bind_dicts:
bind = blend_shape_group.binds.add()
if not isinstance(bind_dict, dict):
continue
mesh_name = bind_dict.get("mesh")
if isinstance(mesh_name, str):
if mesh_name in context.blend_data.meshes:
mesh: Optional[ID] = context.blend_data.meshes[mesh_name]
for obj in context.blend_data.objects:
if obj.data == mesh:
bind.mesh.mesh_object_name = obj.name
break
elif (
mesh_name in context.blend_data.objects
and context.blend_data.objects[mesh_name].type == "MESH"
):
obj = context.blend_data.objects[mesh_name]
bind.mesh.mesh_object_name = obj.name
mesh = obj.data
else:
mesh = None
if isinstance(mesh, Mesh):
index = bind_dict.get("index")
shape_keys = mesh.shape_keys
if (
isinstance(index, str)
and shape_keys
and index in shape_keys.key_blocks
):
bind.index = index
weight = convert.float_or_none(bind_dict.get("weight"))
if weight is not None:
bind.weight = weight
material_value_dicts = blend_shape_group_dict.get("materialValues")
if isinstance(material_value_dicts, list):
for material_value_dict in material_value_dicts:
material_value = blend_shape_group.material_values.add()
if not isinstance(material_value_dict, dict):
continue
material_name = material_value_dict.get("materialName")
if (
isinstance(material_name, str)
and material_name in context.blend_data.materials
):
material_value.material = context.blend_data.materials[
material_name
]
property_name = material_value_dict.get("propertyName")
if isinstance(property_name, str):
material_value.property_name = property_name
target_value_vector = material_value_dict.get("targetValue")
if isinstance(target_value_vector, list):
for v in target_value_vector:
material_value.target_value.add().value = convert.float_or(
v, 0.0
)
is_binary = blend_shape_group_dict.get("isBinary")
if isinstance(is_binary, bool):
blend_shape_group.is_binary = is_binary
def migrate_vrm0_secondary_animation(
secondary_animation: Vrm0SecondaryAnimationPropertyGroup,
bone_group_dicts: Json,
armature: Object,
armature_data: Armature,
) -> None:
bone_name_to_collider_objects: dict[str, list[Object]] = {}
for collider_object in [
child
for child in armature.children
if child.type == "EMPTY"
and child.empty_display_type == "SPHERE"
and child.parent_type == "BONE"
and child.parent_bone in armature_data.bones
]:
if collider_object.parent_bone not in bone_name_to_collider_objects:
bone_name_to_collider_objects[collider_object.parent_bone] = []
bone_name_to_collider_objects[collider_object.parent_bone].append(
collider_object
)
for bone_name, collider_objects in bone_name_to_collider_objects.items():
collider_group = secondary_animation.collider_groups.add()
collider_group.uuid = uuid.uuid4().hex
collider_group.node.bone_name = bone_name
for collider_object in collider_objects:
collider_prop = collider_group.colliders.add()
collider_prop.bpy_object = collider_object
for collider_group in secondary_animation.collider_groups:
collider_group.refresh(armature)
if not isinstance(bone_group_dicts, list):
bone_group_dicts = []
for bone_group_dict in bone_group_dicts:
bone_group = secondary_animation.bone_groups.add()
if not isinstance(bone_group_dict, dict):
continue
comment = bone_group_dict.get("comment")
if isinstance(comment, str):
bone_group.comment = comment
stiffiness = convert.float_or_none(bone_group_dict.get("stiffiness"))
if stiffiness is not None:
bone_group.stiffiness = stiffiness
gravity_power = convert.float_or_none(bone_group_dict.get("gravityPower"))
if gravity_power is not None:
bone_group.gravity_power = gravity_power
gravity_dir = convert.vrm_json_vector3_to_tuple(
bone_group_dict.get("gravityDir")
)
if gravity_dir is not None:
# Axis confusing
(x, y, z) = gravity_dir
bone_group.gravity_dir = (x, z, y)
drag_force = convert.float_or_none(bone_group_dict.get("dragForce"))
if drag_force is not None:
bone_group.drag_force = drag_force
center = bone_group_dict.get("center")
if isinstance(center, str):
bone_group.center.bone_name = center
hit_radius = convert.float_or_none(bone_group_dict.get("hitRadius"))
if hit_radius is not None:
bone_group.hit_radius = hit_radius
bones = bone_group_dict.get("bones")
if isinstance(bones, list):
for bone in bones:
bone_prop = bone_group.bones.add()
if not isinstance(bone, str):
continue
bone_prop.bone_name = bone
collider_group_node_names = bone_group_dict.get("colliderGroups")
if not isinstance(collider_group_node_names, list):
continue
for collider_group_node_name in collider_group_node_names:
if not isinstance(collider_group_node_name, str):
continue
for collider_group in secondary_animation.collider_groups:
if collider_group.node.bone_name != collider_group_node_name:
continue
collider_group_name = bone_group.collider_groups.add()
collider_group_name.value = collider_group.name
break
for bone_group in secondary_animation.bone_groups:
bone_group.refresh(armature)
def migrate_legacy_custom_properties(
context: Context, armature: Object, armature_data: Armature
) -> None:
ext = get_armature_extension(armature_data)
if tuple(ext.addon_version) >= (2, 0, 1):
return
migrate_vrm0_meta(context, ext.vrm0.meta, armature)
migrate_vrm0_blend_shape_groups(
context,
ext.vrm0.blend_shape_master,
read_textblock_json(context, armature, "blendshape_group"),
)
migrate_vrm0_first_person(
context,
ext.vrm0.first_person,
read_textblock_json(context, armature, "firstPerson_params"),
)
migrate_vrm0_humanoid(
ext.vrm0.humanoid, read_textblock_json(context, armature, "humanoid_params")
)
migrate_vrm0_secondary_animation(
ext.vrm0.secondary_animation,
read_textblock_json(context, armature, "spring_bone"),
armature,
armature_data,
)
assigned_bpy_bone_names: list[str] = []
for human_bone_name in HumanBoneSpecifications.all_names:
bpy_bone_name = armature_data.get(human_bone_name)
if (
not isinstance(bpy_bone_name, str)
or not bpy_bone_name
or bpy_bone_name in assigned_bpy_bone_names
):
continue
assigned_bpy_bone_names.append(bpy_bone_name)
for human_bone in ext.vrm0.humanoid.human_bones:
if human_bone.bone == human_bone_name:
human_bone.node.bone_name = bpy_bone_name
break
def migrate_blender_object(armature_data: Armature) -> None:
ext = get_armature_extension(armature_data)
if tuple(ext.addon_version) >= (2, 3, 27):
return
for collider_group in ext.vrm0.secondary_animation.collider_groups:
for collider in collider_group.colliders:
bpy_object = collider.pop("blender_object", None)
if isinstance(bpy_object, Object):
collider.bpy_object = bpy_object
def migrate_link_to_bone_object(
context: Context, armature: Object, armature_data: Armature
) -> None:
ext = get_armature_extension(armature_data)
if tuple(ext.addon_version) >= (2, 3, 27):
return
for (
bone_property_group,
_bone_property_group_type,
) in BonePropertyGroup.get_all_bone_property_groups(armature):
link_to_bone = bone_property_group.get("link_to_bone")
if not isinstance(link_to_bone, Object) or not link_to_bone.parent_bone:
continue
parent = link_to_bone.parent
if not parent or not parent.name or parent.type != "ARMATURE":
continue
parent_data = parent.data
if not isinstance(parent_data, Armature):
continue
bone = parent_data.bones.get(link_to_bone.parent_bone)
if not bone:
continue
bone_extension = get_bone_extension(bone)
if not bone_extension.uuid:
bone_extension.uuid = uuid.uuid4().hex
bone_property_group.bone_uuid = bone_extension.uuid
for (
bone_property_group,
_bone_property_group_type,
) in BonePropertyGroup.get_all_bone_property_groups(armature):
link_to_bone = bone_property_group.pop("link_to_bone", None)
if not isinstance(link_to_bone, Object):
continue
if link_to_bone.parent_type != "OBJECT":
link_to_bone.parent_type = "OBJECT"
if link_to_bone.parent_bone:
link_to_bone.parent_bone = ""
if link_to_bone.parent is not None:
link_to_bone.parent = None
Vrm0HumanoidPropertyGroup.update_all_node_candidates(
context,
armature_data.name,
force=True,
)
def migrate_link_to_mesh_object(armature_data: Armature) -> None:
ext = get_armature_extension(armature_data)
if tuple(ext.addon_version) >= (2, 3, 23):
return
meshes = [
mesh_annotation.mesh
for mesh_annotation in ext.vrm0.first_person.mesh_annotations
] + [
bind.mesh
for blend_shape_group in ext.vrm0.blend_shape_master.blend_shape_groups
for bind in blend_shape_group.binds
]
for mesh in meshes:
if not mesh:
continue
link_to_mesh = mesh.get("link_to_mesh")
if (
not isinstance(link_to_mesh, Object)
or not link_to_mesh.parent
or not link_to_mesh.parent.name
or link_to_mesh.parent.type != "MESH"
):
continue
mesh.mesh_object_name = link_to_mesh.parent.name
def remove_link_to_mesh_object(armature_data: Armature) -> None:
ext = get_armature_extension(armature_data)
if tuple(ext.addon_version) >= (2, 3, 27):
return
meshes = [
mesh_annotation.mesh
for mesh_annotation in ext.vrm0.first_person.mesh_annotations
] + [
bind.mesh
for blend_shape_group in ext.vrm0.blend_shape_master.blend_shape_groups
for bind in blend_shape_group.binds
]
for mesh in meshes:
if not mesh:
continue
link_to_mesh = mesh.pop("link_to_mesh", None)
if not isinstance(link_to_mesh, Object):
continue
if link_to_mesh.parent_type != "OBJECT":
link_to_mesh.parent_type = "OBJECT"
if link_to_mesh.parent_bone:
link_to_mesh.parent_bone = ""
if link_to_mesh.parent is not None:
link_to_mesh.parent = None
def fixup_gravity_dir(armature_data: Armature) -> None:
ext = get_armature_extension(armature_data)
if tuple(ext.addon_version) >= (2, 15, 4):
return
for bone_group in ext.vrm0.secondary_animation.bone_groups:
gravity_dir = list(bone_group.gravity_dir)
bone_group.gravity_dir = (gravity_dir[0] + 1, 0, 0) # Make a change
bone_group.gravity_dir = gravity_dir
def fixup_humanoid_feet_spacing(armature_data: Armature) -> None:
ext = get_armature_extension(armature_data)
if tuple(ext.addon_version) >= (2, 18, 2):
return
humanoid = ext.vrm0.humanoid
feet_spacing = convert.float_or_none(humanoid.get("feet_spacing"))
if feet_spacing is not None:
humanoid.feet_spacing = float(feet_spacing)
def migrate_pose(context: Context, armature: Object, armature_data: Armature) -> None:
ext = get_armature_extension(armature_data)
if tuple(ext.addon_version) >= (2, 20, 34):
return
humanoid = ext.vrm0.humanoid
if isinstance(humanoid.get("pose"), int):
return
if tuple(ext.addon_version) == ext.INITIAL_ADDON_VERSION:
if ext.has_vrm_model_metadata(armature):
humanoid.pose = humanoid.POSE_CURRENT_POSE.identifier
return
action = humanoid.pose_library
if action and action.name in context.blend_data.actions:
humanoid.pose = humanoid.POSE_CUSTOM_POSE.identifier
elif armature_data.pose_position == "REST":
humanoid.pose = humanoid.POSE_REST_POSITION_POSE.identifier
else:
humanoid.pose = humanoid.POSE_CURRENT_POSE.identifier
def migrate_auto_pose(_context: Context, armature_data: Armature) -> None:
ext = get_armature_extension(armature_data)
if tuple(ext.addon_version) == ext.INITIAL_ADDON_VERSION or tuple(
ext.addon_version
) >= (2, 20, 81):
return
humanoid = ext.vrm0.humanoid
if not isinstance(humanoid.get("pose"), int):
humanoid.pose = humanoid.POSE_CURRENT_POSE.identifier
def migrate_saved_mesh_object_name_to_restore(
_context: Context, armature_data: Armature
) -> None:
ext = get_armature_extension(armature_data)
if tuple(ext.addon_version) == ext.INITIAL_ADDON_VERSION or tuple(
ext.addon_version
) >= (3, 9, 0):
return
for blend_shape_group in ext.vrm0.blend_shape_master.blend_shape_groups:
for bind in blend_shape_group.binds:
bind.mesh.saved_mesh_object_name_to_restore = bind.mesh.name
def is_unnecessary(vrm0: Vrm0PropertyGroup) -> bool:
if vrm0.humanoid.initial_automatic_bone_assignment:
return False
if vrm0.first_person.first_person_bone.bone_name:
return True
return all(
(human_bone.bone != "head" or not human_bone.node.bone_name)
for human_bone in vrm0.humanoid.human_bones
)
def migrate(context: Context, vrm0: Vrm0PropertyGroup, armature: Object) -> None:
armature_data = armature.data
if not isinstance(armature_data, Armature):
return
migrate_blender_object(armature_data)
migrate_link_to_bone_object(context, armature, armature_data)
Vrm0HumanoidPropertyGroup.fixup_human_bones(armature)
for collider_group in vrm0.secondary_animation.collider_groups:
collider_group.refresh(armature)
for bone_group in vrm0.secondary_animation.bone_groups:
bone_group.refresh(armature)
if not vrm0.first_person.first_person_bone.bone_name:
for human_bone in vrm0.humanoid.human_bones:
if human_bone.bone == "head":
vrm0.first_person.first_person_bone.bone_name = (
human_bone.node.bone_name
)
break
migrate_legacy_custom_properties(context, armature, armature_data)
migrate_link_to_mesh_object(armature_data)
remove_link_to_mesh_object(armature_data)
fixup_gravity_dir(armature_data)
fixup_humanoid_feet_spacing(armature_data)
migrate_pose(context, armature, armature_data)
migrate_auto_pose(context, armature_data)
migrate_saved_mesh_object_name_to_restore(context, armature_data)
Vrm0HumanoidPropertyGroup.update_all_node_candidates(
context,
armature_data.name,
force=True,
)
if vrm0.humanoid.initial_automatic_bone_assignment:
vrm0.humanoid.initial_automatic_bone_assignment = False
if all(not b.node.bone_name for b in vrm0.humanoid.human_bones):
ops.vrm.assign_vrm0_humanoid_human_bones_automatically(
armature_object_name=armature.name
)

2767
editor/vrm0/ops.py Normal file

File diff suppressed because it is too large Load Diff

1249
editor/vrm0/panel.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

419
editor/vrm0/ui_list.py Normal file
View File

@@ -0,0 +1,419 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from bpy.types import Armature, Context, Mesh, UILayout, UIList
from ...common.logger import get_logger
from ..extension import get_armature_extension
from ..property_group import BonePropertyGroup, StringPropertyGroup
from .property_group import (
Vrm0BlendShapeBindPropertyGroup,
Vrm0BlendShapeGroupPropertyGroup,
Vrm0FirstPersonPropertyGroup,
Vrm0MaterialValueBindPropertyGroup,
Vrm0MeshAnnotationPropertyGroup,
Vrm0SecondaryAnimationColliderGroupPropertyGroup,
Vrm0SecondaryAnimationColliderPropertyGroup,
Vrm0SecondaryAnimationGroupPropertyGroup,
)
logger = get_logger(__name__)
class VRM_UL_vrm0_first_person_mesh_annotation(UIList):
bl_idname = "VRM_UL_vrm0_first_person_mesh_annotation"
def draw_item(
self,
_context: Context,
layout: UILayout,
first_person: object,
mesh_annotation: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
index: int,
_flt_flag: int,
) -> None:
if not isinstance(first_person, Vrm0FirstPersonPropertyGroup):
return
if not isinstance(mesh_annotation, Vrm0MeshAnnotationPropertyGroup):
return
icon = "OUTLINER_OB_MESH"
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
row = layout.split(factor=0.6, align=True)
if index == first_person.active_mesh_annotation_index:
row.prop(
mesh_annotation.mesh,
"bpy_object",
icon=icon,
text="",
translate=False,
)
else:
row.label(
text=mesh_annotation.mesh.mesh_object_name,
translate=False,
icon=icon,
)
row.prop(mesh_annotation, "first_person_flag", text="", translate=False)
class VRM_UL_vrm0_secondary_animation_group(UIList):
bl_idname = "VRM_UL_vrm0_secondary_animation_group"
def draw_item(
self,
_context: Context,
layout: UILayout,
_data: object,
bone_group: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
_index: int,
_flt_flag: int,
) -> None:
if not isinstance(bone_group, Vrm0SecondaryAnimationGroupPropertyGroup):
return
icon = "BONE_DATA"
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
text = ""
if bone_group.bones:
text = (
"(" + ", ".join(str(bone.bone_name) for bone in bone_group.bones) + ")"
)
if bone_group.center.bone_name:
if text:
text = " - " + text
text = bone_group.center.bone_name + text
if bone_group.comment:
if text:
text = " / " + text
text = bone_group.comment + text
if not text:
text = "(EMPTY)"
layout.label(text=text, translate=False, icon=icon)
class VRM_UL_vrm0_secondary_animation_group_bone(UIList):
bl_idname = "VRM_UL_vrm0_secondary_animation_group_bone"
def draw_item(
self,
_context: Context,
layout: UILayout,
bone_group: object,
bone: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
index: int,
_flt_flag: int,
) -> None:
if not isinstance(bone_group, Vrm0SecondaryAnimationGroupPropertyGroup):
return
if not isinstance(bone, BonePropertyGroup):
return
armature = bone.find_armature()
icon = "BONE_DATA"
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
if index == bone_group.active_bone_index:
layout.prop_search(
bone,
"bone_name",
armature,
"bones",
text="",
translate=False,
icon=icon,
)
else:
layout.label(text=bone.bone_name, translate=False, icon=icon)
class VRM_UL_vrm0_secondary_animation_group_collider_group(UIList):
bl_idname = "VRM_UL_vrm0_secondary_animation_group_collider_group"
def draw_item(
self,
_context: Context,
layout: UILayout,
bone_group: object,
collider_group: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
index: int,
_flt_flag: int,
) -> None:
if not isinstance(bone_group, Vrm0SecondaryAnimationGroupPropertyGroup):
return
if not isinstance(collider_group, StringPropertyGroup):
return
armature_data = bone_group.id_data
if not isinstance(armature_data, Armature):
logger.error("Failed to find armature")
return
secondary_animation = get_armature_extension(
armature_data
).vrm0.secondary_animation
icon = "PIVOT_INDIVIDUAL"
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
if index == bone_group.active_collider_group_index:
layout.prop_search(
collider_group,
"value",
secondary_animation,
"collider_groups",
text="",
translate=False,
icon=icon,
)
else:
layout.label(text=collider_group.value, translate=False, icon=icon)
class VRM_UL_vrm0_secondary_animation_collider_group(UIList):
bl_idname = "VRM_UL_vrm0_secondary_animation_collider_group"
def draw_item(
self,
_context: Context,
layout: UILayout,
_data: object,
collider_group: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
_index: int,
_flt_flag: int,
) -> None:
if not isinstance(
collider_group, Vrm0SecondaryAnimationColliderGroupPropertyGroup
):
return
icon = "SPHERE"
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
layout.label(text=collider_group.name, translate=False, icon=icon)
class VRM_UL_vrm0_secondary_animation_collider_group_collider(UIList):
bl_idname = "VRM_UL_vrm0_secondary_animation_collider_group_collider"
def draw_item(
self,
_context: Context,
layout: UILayout,
collider_group: object,
collider: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
index: int,
_flt_flag: int,
) -> None:
if not isinstance(
collider_group, Vrm0SecondaryAnimationColliderGroupPropertyGroup
):
return
if not isinstance(collider, Vrm0SecondaryAnimationColliderPropertyGroup):
return
icon = "MESH_UVSPHERE"
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
bpy_object = collider.bpy_object
if bpy_object is None:
return
row = layout.split(align=True, factor=0.7)
if index == collider_group.active_collider_index:
row.prop(
bpy_object,
"name",
icon=icon,
translate=False,
text="",
)
row.prop(bpy_object, "empty_display_size", text="")
else:
row.label(text=bpy_object.name, icon=icon, translate=False)
row.prop(bpy_object, "empty_display_size", text="", emboss=False)
class VRM_UL_vrm0_blend_shape_group(UIList):
bl_idname = "VRM_UL_vrm0_blend_shape_group"
def draw_item(
self,
_context: Context,
layout: UILayout,
_data: object,
item: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
_index: int,
_flt_flag: int,
) -> None:
blend_shape_group = item
if not isinstance(blend_shape_group, Vrm0BlendShapeGroupPropertyGroup):
return
preset = next(
(
preset
for preset in blend_shape_group.preset_name_enum
if preset.identifier == blend_shape_group.preset_name
),
None,
)
if not preset:
return
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=preset.icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
text = blend_shape_group.name + " / " + preset.name
split = layout.split(align=True, factor=0.55)
split.label(text=text, translate=False, icon=preset.icon)
split.prop(blend_shape_group, "preview", text="Preview")
class VRM_UL_vrm0_blend_shape_bind(UIList):
bl_idname = "VRM_UL_vrm0_blend_shape_bind"
def draw_item(
self,
context: Context,
layout: UILayout,
_data: object,
item: object,
icon: int,
_active_data: object,
_active_prop_name: str,
_index: int,
_flt_flag: int,
) -> None:
blend_data = context.blend_data
blend_shape_bind = item
if not isinstance(blend_shape_bind, Vrm0BlendShapeBindPropertyGroup):
return
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon_value=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
name = blend_shape_bind.mesh.mesh_object_name
mesh_object = blend_data.objects.get(blend_shape_bind.mesh.mesh_object_name)
if mesh_object:
mesh_data = mesh_object.data
if isinstance(mesh_data, Mesh):
shape_keys = mesh_data.shape_keys
if shape_keys:
keys = shape_keys.key_blocks.keys()
if blend_shape_bind.index in keys:
name += " / " + blend_shape_bind.index
layout.label(text=name, translate=False, icon="MESH_DATA")
class VRM_UL_vrm0_material_value_bind(UIList):
bl_idname = "VRM_UL_vrm0_material_value_bind"
def draw_item(
self,
_context: Context,
layout: UILayout,
_data: object,
item: object,
icon: int,
_active_data: object,
_active_prop_name: str,
_index: int,
_flt_flag: int,
) -> None:
material_value_bind = item
if not isinstance(material_value_bind, Vrm0MaterialValueBindPropertyGroup):
return
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon_value=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
name = ""
material = material_value_bind.material
if material:
name = material.name
if material_value_bind.property_name:
name += " / " + material_value_bind.property_name
layout.label(text=name, translate=False, icon="MATERIAL")

1
editor/vrm1/__init__.py Normal file
View File

@@ -0,0 +1 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

45
editor/vrm1/handler.py Normal file
View File

@@ -0,0 +1,45 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import bpy
from bpy.app.handlers import persistent
from ...common.logger import get_logger
from ...common.scene_watcher import trigger_scene_watcher
from .property_group import Vrm1ExpressionPropertyGroup
from .scene_watcher import LookAtPreviewUpdater
logger = get_logger(__name__)
@persistent
def frame_change_pre(_unused: object) -> None:
Vrm1ExpressionPropertyGroup.frame_change_post_shape_key_updates.clear()
@persistent
def frame_change_post(_unused: object) -> None:
context = bpy.context
for (
shape_key_name,
key_block_name,
), value in Vrm1ExpressionPropertyGroup.frame_change_post_shape_key_updates.items():
shape_key = context.blend_data.shape_keys.get(shape_key_name)
if not shape_key:
continue
key_blocks = shape_key.key_blocks
if not key_blocks:
continue
key_block = key_blocks.get(key_block_name)
if not key_block:
continue
key_block.value = value
Vrm1ExpressionPropertyGroup.frame_change_post_shape_key_updates.clear()
# Update materials
Vrm1ExpressionPropertyGroup.update_materials(context)
@persistent
def depsgraph_update_pre(_unused: object) -> None:
trigger_scene_watcher(LookAtPreviewUpdater)

26
editor/vrm1/menu.py Normal file
View File

@@ -0,0 +1,26 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from bpy.types import Context, Menu
from ...common.logger import get_logger
from ..ops import layout_operator
from ..search import current_armature
from .ops import VRM_OT_restore_vrm1_expression_morph_target_bind_object
logger = get_logger(__name__)
class VRM_MT_vrm1_expression(Menu):
bl_label = "Expression Menu"
bl_idname = "VRM_MT_vrm1_expression"
def draw(self, context: Context) -> None:
layout = self.layout
armature = current_armature(context)
if not armature:
return
op = layout_operator(
layout, VRM_OT_restore_vrm1_expression_morph_target_bind_object
)
op.armature_object_name = armature.name

281
editor/vrm1/migration.py Normal file
View File

@@ -0,0 +1,281 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from bpy.types import Armature, Context, Material, Object
from idprop.types import IDPropertyGroup
from mathutils import Vector
from ...common import convert, ops, shader
from ..extension import get_armature_extension
from .property_group import (
Vrm1ExpressionPropertyGroup,
Vrm1ExpressionsPropertyGroup,
Vrm1HumanBonesPropertyGroup,
Vrm1MaterialColorBindPropertyGroup,
Vrm1PropertyGroup,
)
def migrate_old_expression_layout(
old_expression: object, expression: Vrm1ExpressionPropertyGroup
) -> None:
if not isinstance(old_expression, IDPropertyGroup):
return
old_morph_target_binds = convert.iterator_or_none(
old_expression.get("morph_target_binds")
)
if old_morph_target_binds is not None:
for old_morph_target_bind in old_morph_target_binds:
if not isinstance(old_morph_target_bind, IDPropertyGroup):
continue
morph_target_bind = expression.morph_target_binds.add()
old_node = old_morph_target_bind.get("node")
if isinstance(old_node, IDPropertyGroup):
old_bpy_object = old_node.get("bpy_object")
if isinstance(old_bpy_object, Object) and old_bpy_object.type == "MESH":
morph_target_bind.node.mesh_object_name = old_bpy_object.name
old_node.clear()
old_index = old_morph_target_bind.get("index")
if isinstance(old_index, str):
morph_target_bind.index = old_index
old_weight = old_morph_target_bind.get("weight")
if isinstance(old_weight, (float, int)):
morph_target_bind.weight = old_weight
old_morph_target_bind.clear()
old_material_color_binds = convert.iterator_or_none(
old_expression.get("material_color_binds")
)
if old_material_color_binds is not None:
for old_material_color_bind in old_material_color_binds:
if not isinstance(old_material_color_bind, IDPropertyGroup):
continue
material_color_bind = expression.material_color_binds.add()
old_material = old_material_color_bind.get("material")
if isinstance(old_material, Material):
material_color_bind.material = old_material
old_type = next(
(
enum.identifier
for enum in Vrm1MaterialColorBindPropertyGroup.type_enum
if old_material_color_bind.get("type") == enum.value
),
None,
)
if old_type is not None:
material_color_bind.type = old_type
old_target_value = shader.rgba_or_none(
old_material_color_bind.get("target_value"), 0.0, 1.0
)
if old_target_value:
material_color_bind.target_value = old_target_value
old_material_color_bind.clear()
old_texture_transform_binds = convert.iterator_or_none(
old_expression.get("texture_transform_binds")
)
if old_texture_transform_binds is not None:
for old_texture_transform_bind in old_texture_transform_binds:
if not isinstance(old_texture_transform_bind, IDPropertyGroup):
continue
texture_transform_bind = expression.texture_transform_binds.add()
old_material = old_texture_transform_bind.get("material")
if isinstance(old_material, Material):
texture_transform_bind.material = old_material
old_scale = convert.float2_or_none(old_texture_transform_bind.get("scale"))
if old_scale:
texture_transform_bind.scale = old_scale
old_offset = convert.float2_or_none(
old_texture_transform_bind.get("offset")
)
if old_offset:
texture_transform_bind.offset = old_offset
old_texture_transform_bind.clear()
old_is_binary = old_expression.get("is_binary")
if isinstance(old_is_binary, int):
expression.is_binary = bool(old_is_binary)
old_override_blink = next(
(
enum.identifier
for enum in Vrm1ExpressionPropertyGroup.expression_override_type_enum
if old_expression.get("override_blink") == enum.value
),
None,
)
if old_override_blink is not None:
expression.override_blink = old_override_blink
old_override_look_at = next(
(
enum.identifier
for enum in Vrm1ExpressionPropertyGroup.expression_override_type_enum
if old_expression.get("override_look_at") == enum.value
),
None,
)
if old_override_look_at is not None:
expression.override_look_at = old_override_look_at
old_override_mouth = next(
(
enum.identifier
for enum in Vrm1ExpressionPropertyGroup.expression_override_type_enum
if old_expression.get("override_mouth") == enum.value
),
None,
)
if old_override_mouth is not None:
expression.override_mouth = old_override_mouth
def migrate_old_expressions_layout(expressions: Vrm1ExpressionsPropertyGroup) -> None:
for name, expression in expressions.preset.name_to_expression_dict().items():
property_name = {
"blinkLeft": "blink_left",
"blinkRight": "blink_right",
"lookUp": "look_up",
"lookDown": "look_down",
"lookLeft": "look_left",
"lookRight": "look_right",
}.get(name)
if property_name is None:
property_name = name
old_expression = expressions.get(property_name)
migrate_old_expression_layout(old_expression, expression)
expression.name = name
for expression in expressions.custom:
old_expression = expression.get("expression")
migrate_old_expression_layout(old_expression, expression)
def migrate_pose(context: Context, armature: Object, armature_data: Armature) -> None:
ext = get_armature_extension(armature_data)
if tuple(ext.addon_version) >= (2, 20, 34):
return
humanoid = ext.vrm1.humanoid
if isinstance(humanoid.get("pose"), int):
return
if tuple(ext.addon_version) == ext.INITIAL_ADDON_VERSION:
if ext.has_vrm_model_metadata(armature):
humanoid.pose = humanoid.POSE_CURRENT_POSE.identifier
return
action = humanoid.pose_library
if action and action.name in context.blend_data.actions:
humanoid.pose = humanoid.POSE_CUSTOM_POSE.identifier
elif armature_data.pose_position == "REST":
humanoid.pose = humanoid.POSE_REST_POSITION_POSE.identifier
else:
humanoid.pose = humanoid.POSE_CURRENT_POSE.identifier
def migrate_auto_pose(_context: Context, armature_data: Armature) -> None:
ext = get_armature_extension(armature_data)
if tuple(ext.addon_version) == ext.INITIAL_ADDON_VERSION or tuple(
ext.addon_version
) >= (2, 20, 81):
return
humanoid = ext.vrm1.humanoid
if not isinstance(humanoid.get("pose"), int):
humanoid.pose = humanoid.POSE_CURRENT_POSE.identifier
def migrate(context: Context, vrm1: Vrm1PropertyGroup, armature: Object) -> None:
armature_data = armature.data
if not isinstance(armature_data, Armature):
return
human_bones = vrm1.humanoid.human_bones
human_bones.last_bone_names_str = ""
Vrm1HumanBonesPropertyGroup.fixup_human_bones(armature)
Vrm1HumanBonesPropertyGroup.update_all_node_candidates(context, armature_data.name)
if human_bones.initial_automatic_bone_assignment:
human_bones.initial_automatic_bone_assignment = False
human_bone_name_to_human_bone = human_bones.human_bone_name_to_human_bone()
if all(not b.node.bone_name for b in human_bone_name_to_human_bone.values()):
ops.vrm.assign_vrm1_humanoid_human_bones_automatically(
armature_object_name=armature.name
)
if tuple(get_armature_extension(armature_data).addon_version) <= (2, 14, 10):
ext = get_armature_extension(armature_data)
head_bone_name = ext.vrm1.humanoid.human_bones.head.node.bone_name
head_bone = armature_data.bones.get(head_bone_name)
if head_bone:
look_at = get_armature_extension(armature_data).vrm1.look_at
world_translation = (
armature.matrix_world @ head_bone.matrix_local
).to_quaternion() @ Vector(look_at.offset_from_head_bone)
look_at.offset_from_head_bone = list(world_translation)
if tuple(get_armature_extension(armature_data).addon_version) <= (2, 15, 5):
# Apply lower limit value
look_at = get_armature_extension(armature_data).vrm1.look_at
look_at.range_map_horizontal_inner.input_max_value = (
look_at.range_map_horizontal_inner.input_max_value
)
look_at.range_map_horizontal_outer.input_max_value = (
look_at.range_map_horizontal_outer.input_max_value
)
look_at.range_map_vertical_down.input_max_value = (
look_at.range_map_vertical_down.input_max_value
)
look_at.range_map_vertical_up.input_max_value = (
look_at.range_map_vertical_up.input_max_value
)
if tuple(get_armature_extension(armature_data).addon_version) < (2, 18, 0):
migrate_old_expressions_layout(
get_armature_extension(armature_data).vrm1.expressions
)
if tuple(get_armature_extension(armature_data).addon_version) < (2, 20, 0):
look_at = get_armature_extension(armature_data).vrm1.look_at
look_at.offset_from_head_bone = (
look_at.offset_from_head_bone[0],
look_at.offset_from_head_bone[2],
-look_at.offset_from_head_bone[1],
)
migrate_pose(context, armature, armature_data)
migrate_auto_pose(context, armature_data)
# Set a name for the expression preset.
# It's not necessary for management, but I want to set it because it's
# displayed in the animation keyframes.
expressions = get_armature_extension(armature_data).vrm1.expressions
preset_name_to_expression_dict = expressions.preset.name_to_expression_dict()
for preset_name, preset_expression in preset_name_to_expression_dict.items():
if preset_expression.name != preset_name:
preset_expression.name = preset_name
if tuple(get_armature_extension(armature_data).addon_version) < (3, 9, 0):
for expression in expressions.preset.name_to_expression_dict().values():
for morph_target_bind in expression.morph_target_binds:
morph_target_bind.node.saved_mesh_object_name_to_restore = (
morph_target_bind.node.mesh_object_name
)
Vrm1HumanBonesPropertyGroup.update_all_node_candidates(
context,
armature_data.name,
force=True,
)
ops.vrm.update_vrm1_expression_ui_list_elements()

2730
editor/vrm1/ops.py Normal file

File diff suppressed because it is too large Load Diff

1222
editor/vrm1/panel.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from dataclasses import dataclass
from bpy.types import Armature, Context
from mathutils import Vector
from ...common import ops
from ...common.logger import get_logger
from ...common.scene_watcher import RunState, SceneWatcher
from ..extension import get_armature_extension
from .property_group import Vrm1LookAtPropertyGroup
logger = get_logger(__name__)
@dataclass
class LookAtPreviewUpdater(SceneWatcher):
armature_index: int = 0
def reset_run_progress(self) -> None:
self.armature_index = 0
def run(self, context: Context) -> RunState:
"""Detect updates to the target object of Look At and update the state."""
blend_data = context.blend_data
if not blend_data.armatures:
return RunState.FINISH
count = 20
armatures_len = len(blend_data.armatures)
if self.armature_index >= armatures_len:
self.armature_index = 0
start_armature_index = self.armature_index
end_armature_index = min(self.armature_index + count, armatures_len)
changed = False
for armature in blend_data.armatures[start_armature_index:end_armature_index]:
ext = get_armature_extension(armature)
if not ext.is_vrm1():
continue
look_at = ext.vrm1.look_at
if not look_at.enable_preview:
continue
preview_target_bpy_object = look_at.preview_target_bpy_object
if not preview_target_bpy_object:
continue
if (
Vector(look_at.previous_preview_target_bpy_object_location)
- preview_target_bpy_object.location
).length_squared > 0:
changed = True
break
if changed:
Vrm1LookAtPropertyGroup.update_all_previews(context)
return RunState.FINISH
self.armature_index += count
if end_armature_index < armatures_len:
return RunState.PREEMPT
return RunState.FINISH
def create_fast_path_performance_test_objects(self, context: Context) -> None:
blend_data = context.blend_data
for i in range(300):
if i % 3 != 0:
mesh = blend_data.meshes.new(f"Mesh#{i}")
obj = blend_data.objects.new(f"Object#{i}", mesh)
context.scene.collection.objects.link(obj)
continue
ops.icyp.make_basic_armature()
active_object = context.active_object
if not active_object:
message = f"Not an armature: {active_object}"
raise ValueError(message)
armature = active_object.data
if not isinstance(armature, Armature):
raise TypeError
ext = get_armature_extension(armature)
ext.spec_version = ext.SPEC_VERSION_VRM1
look_at = ext.vrm1.look_at
look_at.type = ext.vrm1.look_at.TYPE_BONE.identifier
look_at.preview_target_bpy_object = blend_data.objects[
i * 5 % len(blend_data.objects)
]
ext.vrm1.look_at.enable_preview = True

302
editor/vrm1/ui_list.py Normal file
View File

@@ -0,0 +1,302 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from bpy.types import Context, Mesh, UILayout, UIList
from ...common.logger import get_logger
from ..property_group import StringPropertyGroup
from .property_group import (
Vrm1ExpressionsPropertyGroup,
Vrm1FirstPersonPropertyGroup,
Vrm1MaterialColorBindPropertyGroup,
Vrm1MeshAnnotationPropertyGroup,
Vrm1MetaPropertyGroup,
Vrm1MorphTargetBindPropertyGroup,
Vrm1TextureTransformBindPropertyGroup,
)
logger = get_logger(__name__)
class VRM_UL_vrm1_meta_author(UIList):
bl_idname = "VRM_UL_vrm1_meta_author"
def draw_item(
self,
_context: Context,
layout: UILayout,
meta: object,
author: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
index: int,
_flt_flag: int,
) -> None:
if not isinstance(meta, Vrm1MetaPropertyGroup):
return
if not isinstance(author, StringPropertyGroup):
return
icon = "USER"
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
if index == meta.active_author_index:
layout.prop(author, "value", icon=icon, text="", translate=False)
else:
layout.label(text=author.value, icon=icon, translate=False)
class VRM_UL_vrm1_meta_reference(UIList):
bl_idname = "VRM_UL_vrm1_meta_reference"
def draw_item(
self,
_context: Context,
layout: UILayout,
meta: object,
reference: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
index: int,
_flt_flag: int,
) -> None:
if not isinstance(meta, Vrm1MetaPropertyGroup):
return
if not isinstance(reference, StringPropertyGroup):
return
icon = "URL"
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
if index == meta.active_reference_index:
layout.prop(reference, "value", icon=icon, text="", translate=False)
else:
layout.label(text=reference.value, icon=icon, translate=False)
class VRM_UL_vrm1_first_person_mesh_annotation(UIList):
bl_idname = "VRM_UL_vrm1_first_person_mesh_annotation"
def draw_item(
self,
_context: Context,
layout: UILayout,
first_person: object,
mesh_annotation: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
index: int,
_flt_flag: int,
) -> None:
if not isinstance(first_person, Vrm1FirstPersonPropertyGroup):
return
if not isinstance(mesh_annotation, Vrm1MeshAnnotationPropertyGroup):
return
icon = "OUTLINER_OB_MESH"
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
row = layout.split(factor=0.6, align=True)
if index == first_person.active_mesh_annotation_index:
row.prop(
mesh_annotation.node,
"bpy_object",
text="",
translate=False,
icon=icon,
)
else:
row.label(
text=mesh_annotation.node.mesh_object_name,
translate=False,
icon=icon,
)
row.prop(mesh_annotation, "type", text="", translate=False)
class VRM_UL_vrm1_expression(UIList):
bl_idname = "VRM_UL_vrm1_expression"
def draw_item(
self,
_context: Context,
layout: UILayout,
expressions: object,
_item: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
index: int,
_flt_flag: int,
) -> None:
if not isinstance(expressions, Vrm1ExpressionsPropertyGroup):
return
preset_expression_items = list(
expressions.preset.name_to_expression_dict().items()
)
if index < len(preset_expression_items):
name, expression = preset_expression_items[index]
icon = expressions.preset.get_icon(name)
if not icon:
logger.error("Unknown preset expression: %s", name)
icon = "SHAPEKEY_DATA"
else:
custom_expressions = expressions.custom
custom_index = index - len(preset_expression_items)
if custom_index >= len(custom_expressions):
return
expression = custom_expressions[custom_index]
name = expression.custom_name
icon = "SHAPEKEY_DATA"
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
split = layout.split(align=True, factor=0.55)
split.label(text=name, translate=False, icon=icon)
split.prop(expression, "preview", text="Preview")
class VRM_UL_vrm1_morph_target_bind(UIList):
bl_idname = "VRM_UL_vrm1_morph_target_bind"
def draw_item(
self,
context: Context,
layout: UILayout,
_data: object,
morph_target_bind: object,
icon: int,
_active_data: object,
_active_prop_name: str,
_index: int,
_flt_flag: int,
) -> None:
blend_data = context.blend_data
if not isinstance(morph_target_bind, Vrm1MorphTargetBindPropertyGroup):
return
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", icon_value=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
name = morph_target_bind.node.mesh_object_name
mesh_object = blend_data.objects.get(morph_target_bind.node.mesh_object_name)
if mesh_object:
mesh_data = mesh_object.data
if isinstance(mesh_data, Mesh):
shape_keys = mesh_data.shape_keys
if shape_keys:
keys = shape_keys.key_blocks.keys()
if morph_target_bind.index in keys:
name += " / " + morph_target_bind.index
layout.label(text=name, translate=False, icon="MESH_DATA")
class VRM_UL_vrm1_material_color_bind(UIList):
bl_idname = "VRM_UL_vrm0_material_color_bind"
def draw_item(
self,
_context: Context,
layout: UILayout,
_data: object,
material_color_bind: object,
icon: int,
_active_data: object,
_active_prop_name: str,
_index: int,
_flt_flag: int,
) -> None:
if not isinstance(material_color_bind, Vrm1MaterialColorBindPropertyGroup):
return
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon_value=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
name = ""
material = material_color_bind.material
if material:
name = material.name
type_name = next(
(
enum.name
for enum in Vrm1MaterialColorBindPropertyGroup.type_enum
if enum.identifier == material_color_bind.type
),
None,
)
if type_name:
name += " / " + type_name
layout.label(text=name, translate=False, icon="MATERIAL")
class VRM_UL_vrm1_texture_transform_bind(UIList):
bl_idname = "VRM_UL_vrm1_texture_transform_bind"
def draw_item(
self,
_context: Context,
layout: UILayout,
_data: object,
texture_transform_bind: object,
icon: int,
_active_data: object,
_active_prop_name: str,
_index: int,
_flt_flag: int,
) -> None:
if not isinstance(
texture_transform_bind, Vrm1TextureTransformBindPropertyGroup
):
return
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon_value=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
name = ""
material = texture_transform_bind.material
if material:
name = material.name
layout.label(text=name, translate=False, icon="MATERIAL")