Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:18:51 +08:00
commit d80558b1cf
52 changed files with 12920 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
"""
Command Handlers
WebSocket 명령 핸들러 모듈
"""
from .armature import list_armatures, get_bones
from .retargeting import auto_map_bones, retarget_animation, get_preset_bone_mapping
from .animation import list_animations, play_animation, stop_animation, add_to_nla
from .import_ import import_fbx, import_dae
from .bone_mapping import store_bone_mapping, load_bone_mapping
from .geometry import (
# Primitive creation
create_cube, create_sphere, create_cylinder, create_plane,
create_cone, create_torus,
# Object operations
delete_object, transform_object, duplicate_object, list_objects,
# Vertex operations
get_vertices, move_vertex, subdivide_mesh, extrude_face
)
from .modifier import (
# Modifier operations
add_modifier, apply_modifier, list_modifiers, remove_modifier,
toggle_modifier, modify_modifier_properties, get_modifier_info, reorder_modifier
)
from .material import (
# Material creation
create_material, list_materials, delete_material,
# Material assignment
assign_material, list_object_materials,
# Material properties
set_material_base_color, set_material_metallic, set_material_roughness,
set_material_emission, get_material_properties
)
__all__ = [
# Armature commands
'list_armatures',
'get_bones',
# Retargeting commands
'auto_map_bones',
'retarget_animation',
'get_preset_bone_mapping',
# Animation commands
'list_animations',
'play_animation',
'stop_animation',
'add_to_nla',
# Import commands
'import_fbx',
'import_dae',
# Bone mapping commands
'store_bone_mapping',
'load_bone_mapping',
# Geometry - Primitive creation
'create_cube',
'create_sphere',
'create_cylinder',
'create_plane',
'create_cone',
'create_torus',
# Geometry - Object operations
'delete_object',
'transform_object',
'duplicate_object',
'list_objects',
# Geometry - Vertex operations
'get_vertices',
'move_vertex',
'subdivide_mesh',
'extrude_face',
# Modifier operations
'add_modifier',
'apply_modifier',
'list_modifiers',
'remove_modifier',
'toggle_modifier',
'modify_modifier_properties',
'get_modifier_info',
'reorder_modifier',
# Material operations
'create_material',
'list_materials',
'delete_material',
'assign_material',
'list_object_materials',
'set_material_base_color',
'set_material_metallic',
'set_material_roughness',
'set_material_emission',
'get_material_properties',
]

View File

@@ -0,0 +1,136 @@
"""
Animation 관련 명령 핸들러
애니메이션 재생, NLA 트랙 관리
"""
import bpy
from typing import List
from ..utils.logger import get_logger
logger = get_logger(__name__)
def list_animations(armature_name: str) -> List[str]:
"""
아마추어의 애니메이션 액션 목록
Args:
armature_name: 아마추어 이름
Returns:
액션 이름 리스트
Raises:
ValueError: 아마추어를 찾을 수 없는 경우
"""
logger.debug(f"Listing animations for armature: {armature_name}")
armature = bpy.data.objects.get(armature_name)
if not armature:
logger.error(f"Armature '{armature_name}' not found")
raise ValueError(f"Armature '{armature_name}' not found")
actions = []
if armature.animation_data:
for action in bpy.data.actions:
if action.id_root == 'OBJECT':
actions.append(action.name)
logger.info(f"Found {len(actions)} animations for {armature_name}")
return actions
def play_animation(armature_name: str, action_name: str, loop: bool = True) -> str:
"""
애니메이션 재생
Args:
armature_name: 아마추어 이름
action_name: 액션 이름
loop: 루프 재생 여부
Returns:
결과 메시지
Raises:
ValueError: 아마추어 또는 액션을 찾을 수 없는 경우
"""
logger.info(f"Playing animation: {action_name} on {armature_name}")
armature = bpy.data.objects.get(armature_name)
if not armature:
logger.error(f"Armature '{armature_name}' not found")
raise ValueError(f"Armature '{armature_name}' not found")
action = bpy.data.actions.get(action_name)
if not action:
logger.error(f"Action '{action_name}' not found")
raise ValueError(f"Action '{action_name}' not found")
if not armature.animation_data:
armature.animation_data_create()
armature.animation_data.action = action
bpy.context.scene.frame_set(int(action.frame_range[0]))
bpy.ops.screen.animation_play()
logger.info(f"Started playing {action_name}")
return f"Playing {action_name}"
def stop_animation() -> str:
"""
애니메이션 중지
Returns:
결과 메시지
"""
logger.info("Stopping animation playback")
bpy.ops.screen.animation_cancel()
return "Animation stopped"
def add_to_nla(armature_name: str, action_name: str, track_name: str) -> str:
"""
NLA 트랙에 애니메이션 추가
Args:
armature_name: 아마추어 이름
action_name: 액션 이름
track_name: 트랙 이름
Returns:
결과 메시지
Raises:
ValueError: 아마추어 또는 액션을 찾을 수 없는 경우
"""
logger.info(f"Adding {action_name} to NLA track {track_name} on {armature_name}")
armature = bpy.data.objects.get(armature_name)
if not armature:
logger.error(f"Armature '{armature_name}' not found")
raise ValueError(f"Armature '{armature_name}' not found")
action = bpy.data.actions.get(action_name)
if not action:
logger.error(f"Action '{action_name}' not found")
raise ValueError(f"Action '{action_name}' not found")
if not armature.animation_data:
armature.animation_data_create()
# NLA 트랙 생성 또는 찾기
nla_tracks = armature.animation_data.nla_tracks
track = nla_tracks.get(track_name)
if not track:
track = nla_tracks.new()
track.name = track_name
logger.debug(f"Created new NLA track: {track_name}")
# 액션을 스트립으로 추가
strip = track.strips.new(action.name, int(action.frame_range[0]), action)
logger.info(f"Added strip {strip.name} to track {track_name}")
return f"Added {action_name} to NLA track {track_name}"

View File

@@ -0,0 +1,55 @@
"""
Armature 관련 명령 핸들러
아마추어 정보 조회 및 본 구조 분석
"""
import bpy
from typing import List, Dict
from ..utils.logger import get_logger
logger = get_logger(__name__)
def list_armatures() -> List[str]:
"""
모든 아마추어 오브젝트 목록 반환
Returns:
아마추어 이름 리스트
"""
logger.debug("Listing all armatures")
armatures = [obj.name for obj in bpy.data.objects if obj.type == 'ARMATURE']
logger.info(f"Found {len(armatures)} armatures")
return armatures
def get_bones(armature_name: str) -> List[Dict]:
"""
아마추어의 본 정보 가져오기
Args:
armature_name: 아마추어 이름
Returns:
본 정보 리스트 (name, parent, children)
Raises:
ValueError: 아마추어를 찾을 수 없거나 타입이 잘못된 경우
"""
logger.debug(f"Getting bones for armature: {armature_name}")
armature = bpy.data.objects.get(armature_name)
if not armature or armature.type != 'ARMATURE':
logger.error(f"Armature '{armature_name}' not found or invalid type")
raise ValueError(f"Armature '{armature_name}' not found")
bones = []
for bone in armature.data.bones:
bones.append({
"name": bone.name,
"parent": bone.parent.name if bone.parent else None,
"children": [child.name for child in bone.children]
})
logger.info(f"Retrieved {len(bones)} bones from {armature_name}")
return bones

View File

@@ -0,0 +1,90 @@
"""
Bone Mapping 관련 명령 핸들러
본 매핑 저장/로드, UI 표시
"""
import bpy
from typing import Dict
from ..utils.logger import get_logger
logger = get_logger(__name__)
def store_bone_mapping(source_armature: str, target_armature: str, bone_mapping: Dict[str, str]) -> str:
"""
본 매핑을 Scene 속성에 저장
Args:
source_armature: 소스 아마추어 이름
target_armature: 타겟 아마추어 이름
bone_mapping: 본 매핑 딕셔너리
Returns:
결과 메시지
"""
logger.info(f"Storing bone mapping: {source_armature} -> {target_armature} ({len(bone_mapping)} bones)")
scene = bpy.context.scene
# 기존 매핑 클리어
scene.bone_mapping_items.clear()
# 새 매핑 저장
for source_bone, target_bone in bone_mapping.items():
item = scene.bone_mapping_items.add()
item.source_bone = source_bone
item.target_bone = target_bone
# 아마추어 정보 저장
scene.bone_mapping_source_armature = source_armature
scene.bone_mapping_target_armature = target_armature
logger.info(f"Stored {len(bone_mapping)} bone mappings")
print(f"✅ Stored bone mapping: {len(bone_mapping)} bones")
return f"Bone mapping stored ({len(bone_mapping)} bones)"
def load_bone_mapping(source_armature: str, target_armature: str) -> Dict[str, str]:
"""
Scene 속성에서 본 매핑 로드
Args:
source_armature: 소스 아마추어 이름
target_armature: 타겟 아마추어 이름
Returns:
본 매핑 딕셔너리
Raises:
ValueError: 저장된 매핑이 없거나 불일치하는 경우
"""
logger.info(f"Loading bone mapping: {source_armature} -> {target_armature}")
scene = bpy.context.scene
# 아마추어 검증
if not scene.bone_mapping_source_armature:
logger.error("No bone mapping stored")
raise ValueError("No bone mapping stored. Please generate mapping first using BoneMapping.show command.")
if (scene.bone_mapping_source_armature != source_armature or
scene.bone_mapping_target_armature != target_armature):
logger.error("Stored mapping doesn't match requested armatures")
raise ValueError(
f"Stored mapping for ({scene.bone_mapping_source_armature}"
f"{scene.bone_mapping_target_armature}) doesn't match requested "
f"({source_armature}{target_armature})"
)
# 매핑 로드
bone_mapping = {}
for item in scene.bone_mapping_items:
bone_mapping[item.source_bone] = item.target_bone
if not bone_mapping:
logger.error("Bone mapping is empty")
raise ValueError("Bone mapping is empty. Please generate mapping first.")
logger.info(f"Loaded {len(bone_mapping)} bone mappings")
print(f"✅ Loaded bone mapping: {len(bone_mapping)} bones")
return bone_mapping

View File

@@ -0,0 +1,88 @@
"""
Collection Operations
컬렉션 관리 명령 핸들러
"""
import bpy
from typing import Dict, List, Any
from ..utils.logger import get_logger
logger = get_logger(__name__)
def create_collection(name: str) -> Dict[str, Any]:
"""컬렉션 생성"""
logger.info(f"Creating collection: {name}")
if name in bpy.data.collections:
logger.warn(f"Collection '{name}' already exists")
coll = bpy.data.collections[name]
else:
coll = bpy.data.collections.new(name)
bpy.context.scene.collection.children.link(coll)
return {'name': coll.name, 'objects': len(coll.objects)}
def list_collections() -> List[Dict[str, Any]]:
"""모든 컬렉션 목록 조회"""
logger.info("Listing all collections")
collections = []
for coll in bpy.data.collections:
collections.append({
'name': coll.name,
'objects': len(coll.objects),
'children': len(coll.children)
})
return collections
def add_to_collection(object_name: str, collection_name: str) -> Dict[str, str]:
"""오브젝트를 컬렉션에 추가"""
logger.info(f"Adding '{object_name}' to collection '{collection_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
coll = bpy.data.collections.get(collection_name)
if not coll:
raise ValueError(f"Collection '{collection_name}' not found")
if obj.name not in coll.objects:
coll.objects.link(obj)
return {'status': 'success', 'message': f"Added '{object_name}' to '{collection_name}'"}
def remove_from_collection(object_name: str, collection_name: str) -> Dict[str, str]:
"""오브젝트를 컬렉션에서 제거"""
logger.info(f"Removing '{object_name}' from collection '{collection_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
coll = bpy.data.collections.get(collection_name)
if not coll:
raise ValueError(f"Collection '{collection_name}' not found")
if obj.name in coll.objects:
coll.objects.unlink(obj)
return {'status': 'success', 'message': f"Removed '{object_name}' from '{collection_name}'"}
def delete_collection(name: str) -> Dict[str, str]:
"""컬렉션 삭제"""
logger.info(f"Deleting collection: {name}")
coll = bpy.data.collections.get(name)
if not coll:
raise ValueError(f"Collection '{name}' not found")
bpy.data.collections.remove(coll)
return {'status': 'success', 'message': f"Collection '{name}' deleted"}

View File

@@ -0,0 +1,552 @@
"""
Geometry Operations
도형 생성, 수정, 삭제 등 기하학적 작업을 처리하는 명령 핸들러
"""
import bpy
import bmesh
from typing import Dict, List, Tuple, Optional, Any
from ..utils.logger import get_logger
logger = get_logger(__name__)
# ============================================================================
# Primitive Creation (기본 도형 생성)
# ============================================================================
def create_cube(
location: Tuple[float, float, float] = (0, 0, 0),
size: float = 2.0,
name: Optional[str] = None
) -> Dict[str, Any]:
"""
큐브 생성
Args:
location: 위치 (x, y, z)
size: 크기
name: 오브젝트 이름 (None이면 자동 생성)
Returns:
생성된 오브젝트 정보
"""
logger.info(f"Creating cube at {location} with size {size}")
bpy.ops.mesh.primitive_cube_add(size=size, location=location)
obj = bpy.context.active_object
if name:
obj.name = name
return {
'name': obj.name,
'type': obj.type,
'location': list(obj.location),
'vertices': len(obj.data.vertices),
'faces': len(obj.data.polygons)
}
def create_sphere(
location: Tuple[float, float, float] = (0, 0, 0),
radius: float = 1.0,
segments: int = 32,
ring_count: int = 16,
name: Optional[str] = None
) -> Dict[str, Any]:
"""
구(Sphere) 생성
Args:
location: 위치 (x, y, z)
radius: 반지름
segments: 세그먼트 수 (수평)
ring_count: 링 수 (수직)
name: 오브젝트 이름
Returns:
생성된 오브젝트 정보
"""
logger.info(f"Creating sphere at {location} with radius {radius}")
bpy.ops.mesh.primitive_uv_sphere_add(
radius=radius,
segments=segments,
ring_count=ring_count,
location=location
)
obj = bpy.context.active_object
if name:
obj.name = name
return {
'name': obj.name,
'type': obj.type,
'location': list(obj.location),
'vertices': len(obj.data.vertices),
'faces': len(obj.data.polygons)
}
def create_cylinder(
location: Tuple[float, float, float] = (0, 0, 0),
radius: float = 1.0,
depth: float = 2.0,
vertices: int = 32,
name: Optional[str] = None
) -> Dict[str, Any]:
"""
실린더 생성
Args:
location: 위치 (x, y, z)
radius: 반지름
depth: 높이
vertices: 버텍스 수
name: 오브젝트 이름
Returns:
생성된 오브젝트 정보
"""
logger.info(f"Creating cylinder at {location}")
bpy.ops.mesh.primitive_cylinder_add(
radius=radius,
depth=depth,
vertices=vertices,
location=location
)
obj = bpy.context.active_object
if name:
obj.name = name
return {
'name': obj.name,
'type': obj.type,
'location': list(obj.location),
'vertices': len(obj.data.vertices),
'faces': len(obj.data.polygons)
}
def create_plane(
location: Tuple[float, float, float] = (0, 0, 0),
size: float = 2.0,
name: Optional[str] = None
) -> Dict[str, Any]:
"""
평면(Plane) 생성
Args:
location: 위치 (x, y, z)
size: 크기
name: 오브젝트 이름
Returns:
생성된 오브젝트 정보
"""
logger.info(f"Creating plane at {location}")
bpy.ops.mesh.primitive_plane_add(size=size, location=location)
obj = bpy.context.active_object
if name:
obj.name = name
return {
'name': obj.name,
'type': obj.type,
'location': list(obj.location),
'vertices': len(obj.data.vertices),
'faces': len(obj.data.polygons)
}
def create_cone(
location: Tuple[float, float, float] = (0, 0, 0),
radius1: float = 1.0,
depth: float = 2.0,
vertices: int = 32,
name: Optional[str] = None
) -> Dict[str, Any]:
"""
원뿔(Cone) 생성
Args:
location: 위치 (x, y, z)
radius1: 아래 반지름
depth: 높이
vertices: 버텍스 수
name: 오브젝트 이름
Returns:
생성된 오브젝트 정보
"""
logger.info(f"Creating cone at {location}")
bpy.ops.mesh.primitive_cone_add(
radius1=radius1,
depth=depth,
vertices=vertices,
location=location
)
obj = bpy.context.active_object
if name:
obj.name = name
return {
'name': obj.name,
'type': obj.type,
'location': list(obj.location),
'vertices': len(obj.data.vertices),
'faces': len(obj.data.polygons)
}
def create_torus(
location: Tuple[float, float, float] = (0, 0, 0),
major_radius: float = 1.0,
minor_radius: float = 0.25,
major_segments: int = 48,
minor_segments: int = 12,
name: Optional[str] = None
) -> Dict[str, Any]:
"""
토러스(Torus) 생성
Args:
location: 위치 (x, y, z)
major_radius: 주 반지름
minor_radius: 부 반지름
major_segments: 주 세그먼트 수
minor_segments: 부 세그먼트 수
name: 오브젝트 이름
Returns:
생성된 오브젝트 정보
"""
logger.info(f"Creating torus at {location}")
bpy.ops.mesh.primitive_torus_add(
major_radius=major_radius,
minor_radius=minor_radius,
major_segments=major_segments,
minor_segments=minor_segments,
location=location
)
obj = bpy.context.active_object
if name:
obj.name = name
return {
'name': obj.name,
'type': obj.type,
'location': list(obj.location),
'vertices': len(obj.data.vertices),
'faces': len(obj.data.polygons)
}
# ============================================================================
# Object Operations (오브젝트 작업)
# ============================================================================
def delete_object(name: str) -> Dict[str, str]:
"""
오브젝트 삭제
Args:
name: 오브젝트 이름
Returns:
삭제 결과
"""
logger.info(f"Deleting object: {name}")
obj = bpy.data.objects.get(name)
if not obj:
raise ValueError(f"Object '{name}' not found")
bpy.data.objects.remove(obj, do_unlink=True)
return {'status': 'success', 'message': f"Object '{name}' deleted"}
def transform_object(
name: str,
location: Optional[Tuple[float, float, float]] = None,
rotation: Optional[Tuple[float, float, float]] = None,
scale: Optional[Tuple[float, float, float]] = None
) -> Dict[str, Any]:
"""
오브젝트 변형 (이동, 회전, 스케일)
Args:
name: 오브젝트 이름
location: 위치 (x, y, z)
rotation: 회전 (x, y, z) in radians
scale: 스케일 (x, y, z)
Returns:
변형된 오브젝트 정보
"""
logger.info(f"Transforming object: {name}")
obj = bpy.data.objects.get(name)
if not obj:
raise ValueError(f"Object '{name}' not found")
if location:
obj.location = location
if rotation:
obj.rotation_euler = rotation
if scale:
obj.scale = scale
return {
'name': obj.name,
'location': list(obj.location),
'rotation': list(obj.rotation_euler),
'scale': list(obj.scale)
}
def duplicate_object(
name: str,
new_name: Optional[str] = None,
location: Optional[Tuple[float, float, float]] = None
) -> Dict[str, Any]:
"""
오브젝트 복제
Args:
name: 원본 오브젝트 이름
new_name: 새 오브젝트 이름 (None이면 자동 생성)
location: 새 위치 (None이면 원본 위치)
Returns:
복제된 오브젝트 정보
"""
logger.info(f"Duplicating object: {name}")
obj = bpy.data.objects.get(name)
if not obj:
raise ValueError(f"Object '{name}' not found")
# 복제
new_obj = obj.copy()
new_obj.data = obj.data.copy()
if new_name:
new_obj.name = new_name
if location:
new_obj.location = location
# 씬에 추가
bpy.context.collection.objects.link(new_obj)
return {
'name': new_obj.name,
'type': new_obj.type,
'location': list(new_obj.location)
}
def list_objects(object_type: Optional[str] = None) -> List[Dict[str, Any]]:
"""
씬의 오브젝트 목록 조회
Args:
object_type: 오브젝트 타입 필터 (None이면 전체)
예: 'MESH', 'ARMATURE', 'CAMERA', 'LIGHT'
Returns:
오브젝트 목록
"""
logger.info(f"Listing objects (type: {object_type or 'ALL'})")
objects = []
for obj in bpy.data.objects:
if object_type and obj.type != object_type:
continue
objects.append({
'name': obj.name,
'type': obj.type,
'location': list(obj.location),
'rotation': list(obj.rotation_euler),
'scale': list(obj.scale)
})
return objects
# ============================================================================
# Vertex Operations (버텍스 작업)
# ============================================================================
def get_vertices(name: str) -> List[Dict[str, Any]]:
"""
오브젝트의 버텍스 정보 조회
Args:
name: 오브젝트 이름
Returns:
버텍스 목록
"""
logger.info(f"Getting vertices for object: {name}")
obj = bpy.data.objects.get(name)
if not obj or obj.type != 'MESH':
raise ValueError(f"Mesh object '{name}' not found")
vertices = []
for i, vert in enumerate(obj.data.vertices):
vertices.append({
'index': i,
'co': list(vert.co),
'normal': list(vert.normal)
})
return vertices
def move_vertex(
object_name: str,
vertex_index: int,
new_position: Tuple[float, float, float]
) -> Dict[str, Any]:
"""
버텍스 이동
Args:
object_name: 오브젝트 이름
vertex_index: 버텍스 인덱스
new_position: 새 위치 (x, y, z)
Returns:
수정된 버텍스 정보
"""
logger.info(f"Moving vertex {vertex_index} in object {object_name}")
obj = bpy.data.objects.get(object_name)
if not obj or obj.type != 'MESH':
raise ValueError(f"Mesh object '{object_name}' not found")
mesh = obj.data
if vertex_index >= len(mesh.vertices):
raise ValueError(f"Vertex index {vertex_index} out of range")
mesh.vertices[vertex_index].co = new_position
mesh.update()
return {
'object': object_name,
'vertex_index': vertex_index,
'position': list(mesh.vertices[vertex_index].co)
}
def subdivide_mesh(
name: str,
cuts: int = 1
) -> Dict[str, Any]:
"""
메쉬 세분화 (Subdivide)
Args:
name: 오브젝트 이름
cuts: 세분화 횟수
Returns:
세분화된 메쉬 정보
"""
logger.info(f"Subdividing mesh: {name} (cuts: {cuts})")
obj = bpy.data.objects.get(name)
if not obj or obj.type != 'MESH':
raise ValueError(f"Mesh object '{name}' not found")
# Edit 모드로 전환
bpy.context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode='EDIT')
# 모든 에지 선택
bpy.ops.mesh.select_all(action='SELECT')
# 세분화
bpy.ops.mesh.subdivide(number_cuts=cuts)
# Object 모드로 복귀
bpy.ops.object.mode_set(mode='OBJECT')
return {
'name': obj.name,
'vertices': len(obj.data.vertices),
'edges': len(obj.data.edges),
'faces': len(obj.data.polygons)
}
def extrude_face(
object_name: str,
face_index: int,
offset: float = 1.0
) -> Dict[str, Any]:
"""
페이스 돌출 (Extrude)
Args:
object_name: 오브젝트 이름
face_index: 페이스 인덱스
offset: 돌출 거리
Returns:
돌출 결과 정보
"""
logger.info(f"Extruding face {face_index} in object {object_name}")
obj = bpy.data.objects.get(object_name)
if not obj or obj.type != 'MESH':
raise ValueError(f"Mesh object '{object_name}' not found")
# BMesh를 사용한 extrude
mesh = obj.data
bm = bmesh.new()
bm.from_mesh(mesh)
# 페이스 선택
if face_index >= len(bm.faces):
bm.free()
raise ValueError(f"Face index {face_index} out of range")
face = bm.faces[face_index]
# Extrude
ret = bmesh.ops.extrude_face_region(bm, geom=[face])
extruded_verts = [v for v in ret['geom'] if isinstance(v, bmesh.types.BMVert)]
# 오프셋 적용
for v in extruded_verts:
v.co += face.normal * offset
# 메쉬 업데이트
bm.to_mesh(mesh)
bm.free()
mesh.update()
return {
'object': object_name,
'face_index': face_index,
'vertices': len(mesh.vertices),
'faces': len(mesh.polygons)
}

View File

@@ -0,0 +1,79 @@
"""
Import 관련 명령 핸들러
FBX, DAE 파일 임포트
"""
import bpy
import os
from ..utils.logger import get_logger
from ..utils.security import validate_file_path
logger = get_logger(__name__)
def import_fbx(filepath: str) -> str:
"""
FBX 파일 임포트
Args:
filepath: FBX 파일 경로
Returns:
결과 메시지
Raises:
RuntimeError: 임포트 실패
ValueError: 잘못된 파일 경로
"""
logger.info(f"Importing FBX file: {filepath}")
# 경로 보안 검증 (path traversal 방지)
try:
# 사용자 홈 디렉토리 또는 현재 작업 디렉토리 내로 제한
allowed_root = os.path.expanduser("~")
validated_path = validate_file_path(filepath, allowed_root)
except ValueError as e:
logger.error(f"Invalid file path: {e}")
raise ValueError(f"Invalid file path: {e}")
try:
bpy.ops.import_scene.fbx(filepath=validated_path)
logger.info(f"FBX import successful: {validated_path}")
return f"Imported {validated_path}"
except Exception as e:
logger.error(f"FBX import failed: {e}", exc_info=True)
raise RuntimeError(f"Failed to import FBX: {str(e)}")
def import_dae(filepath: str) -> str:
"""
DAE (Collada) 파일 임포트
Args:
filepath: DAE 파일 경로
Returns:
결과 메시지
Raises:
RuntimeError: 임포트 실패
ValueError: 잘못된 파일 경로
"""
logger.info(f"Importing DAE file: {filepath}")
# 경로 보안 검증 (path traversal 방지)
try:
# 사용자 홈 디렉토리 또는 현재 작업 디렉토리 내로 제한
allowed_root = os.path.expanduser("~")
validated_path = validate_file_path(filepath, allowed_root)
except ValueError as e:
logger.error(f"Invalid file path: {e}")
raise ValueError(f"Invalid file path: {e}")
try:
bpy.ops.wm.collada_import(filepath=validated_path)
logger.info(f"DAE import successful: {validated_path}")
return f"Imported {validated_path}"
except Exception as e:
logger.error(f"DAE import failed: {e}", exc_info=True)
raise RuntimeError(f"Failed to import DAE: {str(e)}")

View File

@@ -0,0 +1,361 @@
"""
Material Operations
머티리얼 및 셰이더 관련 작업을 처리하는 명령 핸들러
"""
import bpy
from typing import Dict, List, Tuple, Optional, Any
from ..utils.logger import get_logger
logger = get_logger(__name__)
# ============================================================================
# Material Creation (머티리얼 생성)
# ============================================================================
def create_material(
name: str,
use_nodes: bool = True
) -> Dict[str, Any]:
"""
머티리얼 생성
Args:
name: 머티리얼 이름
use_nodes: 노드 시스템 사용 여부 (기본값: True)
Returns:
생성된 머티리얼 정보
"""
logger.info(f"Creating material: {name}")
# 기존 머티리얼 확인
if name in bpy.data.materials:
logger.warn(f"Material '{name}' already exists, returning existing")
mat = bpy.data.materials[name]
else:
mat = bpy.data.materials.new(name=name)
mat.use_nodes = use_nodes
return {
'name': mat.name,
'use_nodes': mat.use_nodes
}
def list_materials() -> List[Dict[str, Any]]:
"""
모든 머티리얼 목록 조회
Returns:
머티리얼 목록
"""
logger.info("Listing all materials")
materials = []
for mat in bpy.data.materials:
materials.append({
'name': mat.name,
'use_nodes': mat.use_nodes,
'users': mat.users # 사용 중인 오브젝트 수
})
return materials
def delete_material(name: str) -> Dict[str, str]:
"""
머티리얼 삭제
Args:
name: 머티리얼 이름
Returns:
삭제 결과
"""
logger.info(f"Deleting material: {name}")
mat = bpy.data.materials.get(name)
if not mat:
raise ValueError(f"Material '{name}' not found")
bpy.data.materials.remove(mat)
return {'status': 'success', 'message': f"Material '{name}' deleted"}
# ============================================================================
# Material Assignment (머티리얼 할당)
# ============================================================================
def assign_material(
object_name: str,
material_name: str,
slot_index: int = 0
) -> Dict[str, Any]:
"""
오브젝트에 머티리얼 할당
Args:
object_name: 오브젝트 이름
material_name: 머티리얼 이름
slot_index: 머티리얼 슬롯 인덱스 (기본값: 0)
Returns:
할당 결과
"""
logger.info(f"Assigning material '{material_name}' to object '{object_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
mat = bpy.data.materials.get(material_name)
if not mat:
raise ValueError(f"Material '{material_name}' not found")
# 머티리얼 슬롯이 없으면 생성
if len(obj.data.materials) == 0:
obj.data.materials.append(mat)
else:
# 기존 슬롯에 할당
if slot_index < len(obj.data.materials):
obj.data.materials[slot_index] = mat
else:
obj.data.materials.append(mat)
return {
'object': object_name,
'material': material_name,
'slot_index': slot_index
}
def list_object_materials(object_name: str) -> List[Dict[str, Any]]:
"""
오브젝트의 머티리얼 슬롯 목록 조회
Args:
object_name: 오브젝트 이름
Returns:
머티리얼 슬롯 목록
"""
logger.info(f"Listing materials for object: {object_name}")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
materials = []
for i, mat_slot in enumerate(obj.material_slots):
materials.append({
'slot_index': i,
'material': mat_slot.material.name if mat_slot.material else None
})
return materials
# ============================================================================
# Material Properties (머티리얼 속성)
# ============================================================================
def set_material_base_color(
material_name: str,
color: Tuple[float, float, float, float]
) -> Dict[str, Any]:
"""
머티리얼 기본 색상 설정 (Principled BSDF)
Args:
material_name: 머티리얼 이름
color: RGBA 색상 (0.0 ~ 1.0)
Returns:
설정 결과
"""
logger.info(f"Setting base color for material: {material_name}")
mat = bpy.data.materials.get(material_name)
if not mat:
raise ValueError(f"Material '{material_name}' not found")
if not mat.use_nodes:
raise ValueError(f"Material '{material_name}' does not use nodes")
# Principled BSDF 노드 찾기
principled = None
for node in mat.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED':
principled = node
break
if not principled:
raise ValueError(f"Principled BSDF node not found in material '{material_name}'")
# Base Color 설정
principled.inputs['Base Color'].default_value = color
return {
'material': material_name,
'base_color': list(color)
}
def set_material_metallic(
material_name: str,
metallic: float
) -> Dict[str, Any]:
"""
머티리얼 Metallic 값 설정
Args:
material_name: 머티리얼 이름
metallic: Metallic 값 (0.0 ~ 1.0)
Returns:
설정 결과
"""
logger.info(f"Setting metallic for material: {material_name}")
mat = bpy.data.materials.get(material_name)
if not mat or not mat.use_nodes:
raise ValueError(f"Material '{material_name}' not found or does not use nodes")
# Principled BSDF 노드 찾기
principled = None
for node in mat.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED':
principled = node
break
if not principled:
raise ValueError(f"Principled BSDF node not found")
principled.inputs['Metallic'].default_value = metallic
return {
'material': material_name,
'metallic': metallic
}
def set_material_roughness(
material_name: str,
roughness: float
) -> Dict[str, Any]:
"""
머티리얼 Roughness 값 설정
Args:
material_name: 머티리얼 이름
roughness: Roughness 값 (0.0 ~ 1.0)
Returns:
설정 결과
"""
logger.info(f"Setting roughness for material: {material_name}")
mat = bpy.data.materials.get(material_name)
if not mat or not mat.use_nodes:
raise ValueError(f"Material '{material_name}' not found or does not use nodes")
# Principled BSDF 노드 찾기
principled = None
for node in mat.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED':
principled = node
break
if not principled:
raise ValueError(f"Principled BSDF node not found")
principled.inputs['Roughness'].default_value = roughness
return {
'material': material_name,
'roughness': roughness
}
def set_material_emission(
material_name: str,
color: Tuple[float, float, float, float],
strength: float = 1.0
) -> Dict[str, Any]:
"""
머티리얼 Emission 설정
Args:
material_name: 머티리얼 이름
color: Emission 색상 RGBA (0.0 ~ 1.0)
strength: Emission 강도 (기본값: 1.0)
Returns:
설정 결과
"""
logger.info(f"Setting emission for material: {material_name}")
mat = bpy.data.materials.get(material_name)
if not mat or not mat.use_nodes:
raise ValueError(f"Material '{material_name}' not found or does not use nodes")
# Principled BSDF 노드 찾기
principled = None
for node in mat.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED':
principled = node
break
if not principled:
raise ValueError(f"Principled BSDF node not found")
principled.inputs['Emission'].default_value = color
principled.inputs['Emission Strength'].default_value = strength
return {
'material': material_name,
'emission_color': list(color),
'emission_strength': strength
}
def get_material_properties(material_name: str) -> Dict[str, Any]:
"""
머티리얼 속성 조회
Args:
material_name: 머티리얼 이름
Returns:
머티리얼 속성
"""
logger.info(f"Getting properties for material: {material_name}")
mat = bpy.data.materials.get(material_name)
if not mat:
raise ValueError(f"Material '{material_name}' not found")
props = {
'name': mat.name,
'use_nodes': mat.use_nodes
}
if mat.use_nodes:
# Principled BSDF 속성 가져오기
principled = None
for node in mat.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED':
principled = node
break
if principled:
props['base_color'] = list(principled.inputs['Base Color'].default_value)
props['metallic'] = principled.inputs['Metallic'].default_value
props['roughness'] = principled.inputs['Roughness'].default_value
props['emission'] = list(principled.inputs['Emission'].default_value)
props['emission_strength'] = principled.inputs['Emission Strength'].default_value
return props

View File

@@ -0,0 +1,388 @@
"""
Modifier Operations
모디파이어 관리 명령 핸들러
"""
import bpy
from typing import Dict, List, Any, Optional
from ..utils.logger import get_logger
logger = get_logger(__name__)
def add_modifier(object_name: str, modifier_type: str, name: Optional[str] = None) -> Dict[str, Any]:
"""오브젝트에 모디파이어 추가
Args:
object_name: 대상 오브젝트 이름
modifier_type: 모디파이어 타입 (SUBSURF, MIRROR, ARRAY, BEVEL, etc.)
name: 모디파이어 이름 (optional)
Supported modifier types:
- SUBSURF: Subdivision Surface
- MIRROR: Mirror
- ARRAY: Array
- BEVEL: Bevel
- BOOLEAN: Boolean
- SOLIDIFY: Solidify
- WIREFRAME: Wireframe
- SKIN: Skin
- ARMATURE: Armature
- LATTICE: Lattice
- CURVE: Curve
- SIMPLE_DEFORM: Simple Deform
- CAST: Cast
- DISPLACE: Displace
- HOOK: Hook
- LAPLACIANDEFORM: Laplacian Deform
- MESH_DEFORM: Mesh Deform
- SHRINKWRAP: Shrinkwrap
- WAVE: Wave
- OCEAN: Ocean
- PARTICLE_SYSTEM: Particle System
- CLOTH: Cloth
- COLLISION: Collision
- DYNAMIC_PAINT: Dynamic Paint
- EXPLODE: Explode
- FLUID: Fluid
- SOFT_BODY: Soft Body
"""
logger.info(f"Adding modifier '{modifier_type}' to '{object_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
mod = obj.modifiers.new(name or modifier_type, modifier_type)
return {
'name': mod.name,
'type': mod.type,
'show_viewport': mod.show_viewport,
'show_render': mod.show_render
}
def apply_modifier(object_name: str, modifier_name: str) -> Dict[str, str]:
"""모디파이어 적용"""
logger.info(f"Applying modifier '{modifier_name}' on '{object_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
mod = obj.modifiers.get(modifier_name)
if not mod:
raise ValueError(f"Modifier '{modifier_name}' not found on '{object_name}'")
# 모디파이어 적용은 Edit 모드에서는 할 수 없음
if bpy.context.mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
# 오브젝트 선택 및 활성화
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
# 모디파이어 적용
bpy.ops.object.modifier_apply(modifier=modifier_name)
return {'status': 'success', 'message': f"Applied modifier '{modifier_name}' to '{object_name}'"}
def list_modifiers(object_name: str) -> List[Dict[str, Any]]:
"""오브젝트의 모디파이어 목록 조회"""
logger.info(f"Listing modifiers for '{object_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
modifiers = []
for mod in obj.modifiers:
mod_info = {
'name': mod.name,
'type': mod.type,
'show_viewport': mod.show_viewport,
'show_render': mod.show_render,
}
# 타입별 특화 속성 추가
if mod.type == 'SUBSURF':
mod_info['levels'] = mod.levels
mod_info['render_levels'] = mod.render_levels
elif mod.type == 'MIRROR':
mod_info['use_axis'] = [mod.use_axis[0], mod.use_axis[1], mod.use_axis[2]]
mod_info['use_bisect_axis'] = [mod.use_bisect_axis[0], mod.use_bisect_axis[1], mod.use_bisect_axis[2]]
elif mod.type == 'ARRAY':
mod_info['count'] = mod.count
mod_info['use_relative_offset'] = mod.use_relative_offset
mod_info['relative_offset_displace'] = list(mod.relative_offset_displace)
elif mod.type == 'BEVEL':
mod_info['width'] = mod.width
mod_info['segments'] = mod.segments
mod_info['limit_method'] = mod.limit_method
elif mod.type == 'BOOLEAN':
mod_info['operation'] = mod.operation
mod_info['object'] = mod.object.name if mod.object else None
elif mod.type == 'SOLIDIFY':
mod_info['thickness'] = mod.thickness
mod_info['offset'] = mod.offset
elif mod.type == 'ARMATURE':
mod_info['object'] = mod.object.name if mod.object else None
mod_info['use_vertex_groups'] = mod.use_vertex_groups
elif mod.type == 'LATTICE':
mod_info['object'] = mod.object.name if mod.object else None
elif mod.type == 'CURVE':
mod_info['object'] = mod.object.name if mod.object else None
elif mod.type == 'SIMPLE_DEFORM':
mod_info['deform_method'] = mod.deform_method
mod_info['factor'] = mod.factor
elif mod.type == 'CAST':
mod_info['cast_type'] = mod.cast_type
mod_info['factor'] = mod.factor
elif mod.type == 'DISPLACE':
mod_info['strength'] = mod.strength
mod_info['direction'] = mod.direction
elif mod.type == 'WAVE':
mod_info['time_offset'] = mod.time_offset
mod_info['height'] = mod.height
modifiers.append(mod_info)
return modifiers
def remove_modifier(object_name: str, modifier_name: str) -> Dict[str, str]:
"""모디파이어 제거"""
logger.info(f"Removing modifier '{modifier_name}' from '{object_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
mod = obj.modifiers.get(modifier_name)
if not mod:
raise ValueError(f"Modifier '{modifier_name}' not found on '{object_name}'")
obj.modifiers.remove(mod)
return {'status': 'success', 'message': f"Removed modifier '{modifier_name}' from '{object_name}'"}
def toggle_modifier(object_name: str, modifier_name: str,
viewport: Optional[bool] = None,
render: Optional[bool] = None) -> Dict[str, Any]:
"""모디파이어 활성화/비활성화
Args:
object_name: 대상 오브젝트 이름
modifier_name: 모디파이어 이름
viewport: 뷰포트 표시 on/off (None이면 토글)
render: 렌더 표시 on/off (None이면 토글)
"""
logger.info(f"Toggling modifier '{modifier_name}' on '{object_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
mod = obj.modifiers.get(modifier_name)
if not mod:
raise ValueError(f"Modifier '{modifier_name}' not found on '{object_name}'")
if viewport is not None:
mod.show_viewport = viewport
else:
mod.show_viewport = not mod.show_viewport
if render is not None:
mod.show_render = render
else:
mod.show_render = not mod.show_render
return {
'name': mod.name,
'show_viewport': mod.show_viewport,
'show_render': mod.show_render
}
def modify_modifier_properties(object_name: str, modifier_name: str, **properties) -> Dict[str, Any]:
"""모디파이어 속성 수정
Args:
object_name: 대상 오브젝트 이름
modifier_name: 모디파이어 이름
**properties: 수정할 속성들 (key=value 형태)
Example properties by modifier type:
SUBSURF: levels, render_levels
MIRROR: use_axis, use_bisect_axis, mirror_object
ARRAY: count, relative_offset_displace
BEVEL: width, segments, limit_method
BOOLEAN: operation, object
SOLIDIFY: thickness, offset
ARMATURE: object, use_vertex_groups
SIMPLE_DEFORM: deform_method, factor, angle
CAST: cast_type, factor, radius
DISPLACE: strength, direction
"""
logger.info(f"Modifying properties of '{modifier_name}' on '{object_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
mod = obj.modifiers.get(modifier_name)
if not mod:
raise ValueError(f"Modifier '{modifier_name}' not found on '{object_name}'")
updated_properties = {}
for key, value in properties.items():
if hasattr(mod, key):
# 특수 처리가 필요한 속성들
if key in ['use_axis', 'use_bisect_axis'] and isinstance(value, list):
# Mirror 모디파이어의 axis는 boolean 배열
for i, v in enumerate(value):
if i < 3:
getattr(mod, key)[i] = v
elif key == 'relative_offset_displace' and isinstance(value, list):
# Array 모디파이어의 offset은 Vector
for i, v in enumerate(value):
if i < 3:
mod.relative_offset_displace[i] = v
elif key == 'object' and isinstance(value, str):
# 오브젝트 참조는 문자열로 받아서 변환
target_obj = bpy.data.objects.get(value)
if target_obj:
setattr(mod, key, target_obj)
else:
logger.warn(f"Target object '{value}' not found for property '{key}'")
continue
else:
# 일반 속성
setattr(mod, key, value)
updated_properties[key] = value
else:
logger.warn(f"Property '{key}' not found on modifier '{modifier_name}'")
return {
'name': mod.name,
'type': mod.type,
'updated_properties': updated_properties
}
def get_modifier_info(object_name: str, modifier_name: str) -> Dict[str, Any]:
"""특정 모디파이어의 상세 정보 조회"""
logger.info(f"Getting info for modifier '{modifier_name}' on '{object_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
mod = obj.modifiers.get(modifier_name)
if not mod:
raise ValueError(f"Modifier '{modifier_name}' not found on '{object_name}'")
# 모든 읽기 가능한 속성을 추출
info = {
'name': mod.name,
'type': mod.type,
'show_viewport': mod.show_viewport,
'show_render': mod.show_render,
}
# 타입별 모든 관련 속성 추가
if mod.type == 'SUBSURF':
info.update({
'levels': mod.levels,
'render_levels': mod.render_levels,
'subdivision_type': mod.subdivision_type,
'use_limit_surface': mod.use_limit_surface
})
elif mod.type == 'MIRROR':
info.update({
'use_axis': [mod.use_axis[0], mod.use_axis[1], mod.use_axis[2]],
'use_bisect_axis': [mod.use_bisect_axis[0], mod.use_bisect_axis[1], mod.use_bisect_axis[2]],
'use_bisect_flip_axis': [mod.use_bisect_flip_axis[0], mod.use_bisect_flip_axis[1], mod.use_bisect_flip_axis[2]],
'mirror_object': mod.mirror_object.name if mod.mirror_object else None,
'use_clip': mod.use_clip,
'use_mirror_merge': mod.use_mirror_merge,
'merge_threshold': mod.merge_threshold
})
elif mod.type == 'ARRAY':
info.update({
'count': mod.count,
'use_constant_offset': mod.use_constant_offset,
'use_relative_offset': mod.use_relative_offset,
'use_object_offset': mod.use_object_offset,
'constant_offset_displace': list(mod.constant_offset_displace),
'relative_offset_displace': list(mod.relative_offset_displace),
'offset_object': mod.offset_object.name if mod.offset_object else None
})
elif mod.type == 'BEVEL':
info.update({
'width': mod.width,
'segments': mod.segments,
'limit_method': mod.limit_method,
'offset_type': mod.offset_type,
'profile': mod.profile,
'material': mod.material
})
elif mod.type == 'BOOLEAN':
info.update({
'operation': mod.operation,
'object': mod.object.name if mod.object else None,
'solver': mod.solver
})
elif mod.type == 'SOLIDIFY':
info.update({
'thickness': mod.thickness,
'offset': mod.offset,
'use_rim': mod.use_rim,
'use_even_offset': mod.use_even_offset,
'material_offset': mod.material_offset
})
return info
def reorder_modifier(object_name: str, modifier_name: str, direction: str) -> Dict[str, Any]:
"""모디파이어 순서 변경
Args:
object_name: 대상 오브젝트 이름
modifier_name: 모디파이어 이름
direction: 'UP' 또는 'DOWN'
"""
logger.info(f"Moving modifier '{modifier_name}' {direction} on '{object_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
mod = obj.modifiers.get(modifier_name)
if not mod:
raise ValueError(f"Modifier '{modifier_name}' not found on '{object_name}'")
# 오브젝트 선택 및 활성화
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if direction.upper() == 'UP':
bpy.ops.object.modifier_move_up(modifier=modifier_name)
elif direction.upper() == 'DOWN':
bpy.ops.object.modifier_move_down(modifier=modifier_name)
else:
raise ValueError(f"Invalid direction '{direction}'. Use 'UP' or 'DOWN'")
# 현재 순서 반환
modifiers = [m.name for m in obj.modifiers]
return {
'status': 'success',
'modifier': modifier_name,
'new_order': modifiers
}

View File

@@ -0,0 +1,260 @@
"""
Animation Retargeting 관련 명령 핸들러
본 매핑, 애니메이션 리타게팅 실행
"""
import bpy
from typing import Dict
from ..utils.logger import get_logger
from ..utils.bone_matching import fuzzy_match_bones, get_match_quality_report
logger = get_logger(__name__)
def auto_map_bones(source_armature: str, target_armature: str) -> Dict[str, str]:
"""
자동 본 매핑 (Mixamo -> 사용자 캐릭터)
Fuzzy matching 알고리즘 사용으로 정확도 개선
Args:
source_armature: 소스 아마추어 이름 (Mixamo)
target_armature: 타겟 아마추어 이름 (사용자 캐릭터)
Returns:
본 매핑 딕셔너리 {source_bone: target_bone}
Raises:
ValueError: 아마추어를 찾을 수 없는 경우
"""
logger.info(f"Auto-mapping bones: {source_armature} -> {target_armature}")
source = bpy.data.objects.get(source_armature)
target = bpy.data.objects.get(target_armature)
if not source or not target:
logger.error("Source or target armature not found")
raise ValueError("Source or target armature not found")
# Mixamo 표준 본 이름과 알려진 별칭 (확장: 손가락, 발가락 포함)
mixamo_bone_aliases = {
# 몸통 (6개)
"Hips": ["hips", "pelvis", "root"],
"Spine": ["spine", "spine1"],
"Spine1": ["spine1", "spine2"],
"Spine2": ["spine2", "spine3", "chest"],
"Neck": ["neck"],
"Head": ["head"],
# 왼쪽 팔 (4개)
"LeftShoulder": ["shoulder.l", "clavicle.l", "leftshoulder"],
"LeftArm": ["upper_arm.l", "leftarm", "upperarm.l"],
"LeftForeArm": ["forearm.l", "leftforearm", "lowerarm.l"],
"LeftHand": ["hand.l", "lefthand"],
# 오른쪽 팔 (4개)
"RightShoulder": ["shoulder.r", "clavicle.r", "rightshoulder"],
"RightArm": ["upper_arm.r", "rightarm", "upperarm.r"],
"RightForeArm": ["forearm.r", "rightforearm", "lowerarm.r"],
"RightHand": ["hand.r", "righthand"],
# 왼쪽 다리 (4개)
"LeftUpLeg": ["thigh.l", "leftupleg", "upperleg.l"],
"LeftLeg": ["shin.l", "leftleg", "lowerleg.l"],
"LeftFoot": ["foot.l", "leftfoot"],
"LeftToeBase": ["toe.l", "lefttoebase", "foot.l.001"],
# 오른쪽 다리 (4개)
"RightUpLeg": ["thigh.r", "rightupleg", "upperleg.r"],
"RightLeg": ["shin.r", "rightleg", "lowerleg.r"],
"RightFoot": ["foot.r", "rightfoot"],
"RightToeBase": ["toe.r", "righttoebase", "foot.r.001"],
# 왼쪽 손가락 (15개)
"LeftHandThumb1": ["thumb.01.l", "lefthandthumb1", "thumb_01.l"],
"LeftHandThumb2": ["thumb.02.l", "lefthandthumb2", "thumb_02.l"],
"LeftHandThumb3": ["thumb.03.l", "lefthandthumb3", "thumb_03.l"],
"LeftHandIndex1": ["f_index.01.l", "lefthandindex1", "index_01.l"],
"LeftHandIndex2": ["f_index.02.l", "lefthandindex2", "index_02.l"],
"LeftHandIndex3": ["f_index.03.l", "lefthandindex3", "index_03.l"],
"LeftHandMiddle1": ["f_middle.01.l", "lefthandmiddle1", "middle_01.l"],
"LeftHandMiddle2": ["f_middle.02.l", "lefthandmiddle2", "middle_02.l"],
"LeftHandMiddle3": ["f_middle.03.l", "lefthandmiddle3", "middle_03.l"],
"LeftHandRing1": ["f_ring.01.l", "lefthandring1", "ring_01.l"],
"LeftHandRing2": ["f_ring.02.l", "lefthandring2", "ring_02.l"],
"LeftHandRing3": ["f_ring.03.l", "lefthandring3", "ring_03.l"],
"LeftHandPinky1": ["f_pinky.01.l", "lefthandpinky1", "pinky_01.l"],
"LeftHandPinky2": ["f_pinky.02.l", "lefthandpinky2", "pinky_02.l"],
"LeftHandPinky3": ["f_pinky.03.l", "lefthandpinky3", "pinky_03.l"],
# 오른쪽 손가락 (15개)
"RightHandThumb1": ["thumb.01.r", "righthandthumb1", "thumb_01.r"],
"RightHandThumb2": ["thumb.02.r", "righthandthumb2", "thumb_02.r"],
"RightHandThumb3": ["thumb.03.r", "righthandthumb3", "thumb_03.r"],
"RightHandIndex1": ["f_index.01.r", "righthandindex1", "index_01.r"],
"RightHandIndex2": ["f_index.02.r", "righthandindex2", "index_02.r"],
"RightHandIndex3": ["f_index.03.r", "righthandindex3", "index_03.r"],
"RightHandMiddle1": ["f_middle.01.r", "righthandmiddle1", "middle_01.r"],
"RightHandMiddle2": ["f_middle.02.r", "righthandmiddle2", "middle_02.r"],
"RightHandMiddle3": ["f_middle.03.r", "righthandmiddle3", "middle_03.r"],
"RightHandRing1": ["f_ring.01.r", "righthandring1", "ring_01.r"],
"RightHandRing2": ["f_ring.02.r", "righthandring2", "ring_02.r"],
"RightHandRing3": ["f_ring.03.r", "righthandring3", "ring_03.r"],
"RightHandPinky1": ["f_pinky.01.r", "righthandpinky1", "pinky_01.r"],
"RightHandPinky2": ["f_pinky.02.r", "righthandpinky2", "pinky_02.r"],
"RightHandPinky3": ["f_pinky.03.r", "righthandpinky3", "pinky_03.r"],
}
# 소스 본 리스트 (실제로 존재하는 본만)
source_bones = [bone.name for bone in source.data.bones
if bone.name in mixamo_bone_aliases]
# 타겟 본 리스트
target_bones = [bone.name for bone in target.data.bones]
# Fuzzy matching 실행 (정확한 매칭 우선, 그 다음 유사도 매칭)
logger.info("Running fuzzy bone matching algorithm...")
logger.debug(f"Source bones: {len(source_bones)}, Target bones: {len(target_bones)}")
bone_map = fuzzy_match_bones(
source_bones=source_bones,
target_bones=target_bones,
known_aliases=mixamo_bone_aliases,
threshold=0.6, # 60% 이상 유사도
prefer_exact=True # 정확한 매칭 우선
)
# 매칭 품질 보고서
quality_report = get_match_quality_report(bone_map)
logger.info(f"Auto-mapped {quality_report['total_mappings']} bones")
logger.info(f"Quality: {quality_report['quality'].upper()}")
logger.info(f"Critical bones: {quality_report['critical_bones_mapped']}")
logger.debug(f"Bone mapping: {bone_map}")
# 콘솔 출력 (사용자에게 피드백)
print(f"✅ Auto-mapped {quality_report['total_mappings']} bones")
print(f" Quality: {quality_report['quality'].upper()}")
print(f" Critical bones: {quality_report['critical_bones_mapped']}")
return bone_map
def retarget_animation(
source_armature: str,
target_armature: str,
bone_map: Dict[str, str],
preserve_rotation: bool = True,
preserve_location: bool = False
) -> str:
"""
애니메이션 리타게팅 실행
Args:
source_armature: 소스 아마추어 이름
target_armature: 타겟 아마추어 이름
bone_map: 본 매핑 딕셔너리
preserve_rotation: 회전 보존 여부
preserve_location: 위치 보존 여부 (보통 루트 본만)
Returns:
결과 메시지
Raises:
ValueError: 아마추어를 찾을 수 없거나 애니메이션이 없는 경우
"""
logger.info(f"Retargeting animation: {source_armature} -> {target_armature}")
logger.debug(f"Bone mappings: {len(bone_map)}, Rotation: {preserve_rotation}, Location: {preserve_location}")
source = bpy.data.objects.get(source_armature)
target = bpy.data.objects.get(target_armature)
if not source or not target:
logger.error("Source or target armature not found")
raise ValueError("Source or target armature not found")
if not source.animation_data or not source.animation_data.action:
logger.error("Source armature has no animation")
raise ValueError("Source armature has no animation")
# 타겟 아마추어 선택
bpy.context.view_layer.objects.active = target
target.select_set(True)
# Pose 모드로 전환
bpy.ops.object.mode_set(mode='POSE')
# 각 본에 대해 컨스트레인트 생성
constraints_added = 0
for source_bone_name, target_bone_name in bone_map.items():
if source_bone_name not in source.pose.bones:
logger.debug(f"Source bone not found: {source_bone_name}")
continue
if target_bone_name not in target.pose.bones:
logger.debug(f"Target bone not found: {target_bone_name}")
continue
target_bone = target.pose.bones[target_bone_name]
# Rotation constraint
if preserve_rotation:
constraint = target_bone.constraints.new('COPY_ROTATION')
constraint.target = source
constraint.subtarget = source_bone_name
constraints_added += 1
# Location constraint (일반적으로 루트 본만)
if preserve_location and source_bone_name == "Hips":
constraint = target_bone.constraints.new('COPY_LOCATION')
constraint.target = source
constraint.subtarget = source_bone_name
constraints_added += 1
logger.info(f"Added {constraints_added} constraints")
# 컨스트레인트를 키프레임으로 베이크
logger.info("Baking constraints to keyframes...")
bpy.ops.nla.bake(
frame_start=bpy.context.scene.frame_start,
frame_end=bpy.context.scene.frame_end,
only_selected=False,
visual_keying=True,
clear_constraints=True,
bake_types={'POSE'}
)
bpy.ops.object.mode_set(mode='OBJECT')
logger.info("Animation retargeting completed successfully")
return f"Animation retargeted to {target_armature}"
def get_preset_bone_mapping(preset: str) -> Dict[str, str]:
"""
미리 정의된 본 매핑 프리셋
Args:
preset: 프리셋 이름 (예: "mixamo_to_rigify")
Returns:
본 매핑 딕셔너리
"""
logger.debug(f"Getting bone mapping preset: {preset}")
presets = {
"mixamo_to_rigify": {
"Hips": "torso",
"Spine": "spine",
"Spine1": "spine.001",
"Spine2": "spine.002",
"Neck": "neck",
"Head": "head",
"LeftShoulder": "shoulder.L",
"LeftArm": "upper_arm.L",
"LeftForeArm": "forearm.L",
"LeftHand": "hand.L",
# ... 더 많은 매핑 추가 가능
}
}
bone_map = presets.get(preset, {})
logger.info(f"Preset '{preset}' loaded with {len(bone_map)} mappings")
return bone_map