Files
blender-vrm/editor/search.py
小煜 091ad6a49a 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
2026-01-01 14:21:56 +08:00

722 lines
22 KiB
Python

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