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

409
skills/addon/.pylintrc Normal file
View File

@@ -0,0 +1,409 @@
[MASTER]
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code.
extension-pkg-allow-list=
# Specify a score threshold to be exceeded before program exits with error code.
fail-under=10.0
# Add files or directories to the blacklist. They should be base names, not
# paths.
ignore=CVS
# Add files or directories matching the regex patterns to the blacklist. The
# regex matches against base names and not paths.
ignore-patterns=
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
#init-hook=
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use.
jobs=1
# Control the amount of potential inferences PyLint can do when analyzing open
# files. Increasing this value might help you get proper information for your
# scripts, but can also result in longer computation time. This is a trade-off
# you can make as you see fit. It defaults to 0.
limit-inference-results=100
# List of plugins (as comma separated). Plugins should always be named after
# their package or modules names, not the filename in the plugins directory.
load-plugins=
# Minimum Python version to target. Used for version dependent checks and
# annotations parsing.
py-version=3.9
# Allow optimization of some simple Pylint rules at the cost of some lost
# message locations accuracy.
unsafe-load-any-extension=no
[MESSAGES CONTROL]
# Only show warnings with the listed confidence levels. Leave empty to show
# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED
confidence=
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once). You can also use "--disable=all" to
# disable everything first and then reenable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use "--disable=all --enable=classes
# --disable=W"
disable=
# Blender addon specific disables
wrong-import-position, # C0413: Blender requires bl_info before imports
too-many-lines, # C0302: Large addon files are acceptable
invalid-name, # C0103: Blender naming conventions (e.g., ADDON_OT_OperatorName)
too-few-public-methods, # R0903: Blender Operators have required structure
unused-argument, # W0613: Blender callbacks have required signatures
import-error, # E0401: bpy module not available in linting environment
import-outside-toplevel, # C0415: Lazy imports common in Blender addons
no-else-return, # R1705: elif after return (acceptable pattern)
too-many-return-statements,# R0911: Command routers need multiple returns
# Additional useful disables for development
fixme, # W0511: TODOs and FIXMEs in code
duplicate-code, # R0801: Similar lines in multiple locations
[REPORTS]
# Python expression which should return a score less than or equal to 10 (10 is
# the highest value). You have access to the variables 'fatal', 'error',
# 'warning', 'refactor', 'convention', and 'info' which represent the number of
# messages in each category, as well as 'statement' which represents the total
# number of statements analyzed. This score is used by the global evaluation
# report (RP0004). Evaluation of this score is skipped if it's value is "-1".
evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
# Set the output format. Available formats are text, parseable, colorized, json
# and msvs (visual studio). You can also give a reporter class e.g. mypackage.
# mymodule.MyReporterClass --output-format=parseable
output-format=text
# Tells whether to display a full report or only the messages
reports=no
# Tells whether to display a full report or only the messages
score=yes
[REFACTORING]
# Maximum number of nested blocks for function / method body
max-nested-blocks=5
# Complete name of functions that never returns. When checking for
# inconsistent-return-statements if a never returning function is called then
# it will be considered as an explicit return statement and no message will be
# printed.
never-returning-functions=sys.exit,argparse.ArgumentParser.error
[BASIC]
# Naming style matching correct argument names.
argument-naming-style=snake_case
# Regular expression matching correct argument names. Overrides argument-
# naming-style. If left empty, argument names will be checked with the set
# naming style.
argument-rgx=
# Naming style matching correct attribute names.
attr-naming-style=snake_case
# Regular expression matching correct attribute names. Overrides attr-naming-
# style. If left empty, attribute names will be checked with the set naming
# style.
attr-rgx=
# Bad variable names which should always be refused, separated by a comma.
bad-names=foo,bar,baz,toto,tutu,tata
# Naming style matching correct class attribute names.
class-attribute-naming-style=any
# Regular expression matching correct class attribute names. Overrides class-
# attribute-naming-style. If left empty, class attribute names will be checked
# with the set naming style.
class-attribute-rgx=
# Naming style matching correct class constant names.
class-const-naming-style=UPPER_CASE
# Regular expression matching correct class constant names. Overrides class-
# const-naming-style. If left empty, class constant names will be checked with
# the set naming style.
class-const-rgx=
# Naming style matching correct class names.
class-naming-style=PascalCase
# Regular expression matching correct class names. Overrides class-naming-
# style. If left empty, class names will be checked with the set naming style.
class-rgx=
# Naming style matching correct constant names.
const-naming-style=UPPER_CASE
# Regular expression matching correct constant names. Overrides const-naming-
# style. If left empty, constant names will be checked with the set naming
# style.
const-rgx=
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
# Naming style matching correct function names.
function-naming-style=snake_case
# Regular expression matching correct function names. Overrides function-
# naming-style. If left empty, function names will be checked with the set
# naming style.
function-rgx=
# Good variable names which should always be accepted, separated by a comma.
good-names=i,j,k,ex,Run,_,x,y,z,dx,dy,dz,bpy
# Naming style matching correct method names.
method-naming-style=snake_case
# Regular expression matching correct method names. Overrides method-naming-
# style. If left empty, method names will be checked with the set naming style.
method-rgx=
# Naming style matching correct module names.
module-naming-style=snake_case
# Regular expression matching correct module names. Overrides module-naming-
# style. If left empty, module names will be checked with the set naming style.
module-rgx=
# Colon-delimited sets of names that determine each other's naming style when
# the name regexes allow several styles. This is useful for enforcing naming
# consistency across properties, getters, and setters. Each named set should
# contain RegEx rule names to its own set. The RegEx rules in the same set must
# have compatible naming style. This is done in order to make sure they can
# interchangeably be used.
name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^_
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
# These decorators are taken in consideration only for invalid-name.
property-classes=abc.abstractproperty
# Naming style matching correct variable names.
variable-naming-style=snake_case
# Regular expression matching correct variable names. Overrides variable-
# naming-style. If left empty, variable names will be checked with the set
# naming style.
variable-rgx=
[FORMAT]
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
# Number of spaces of indent required inside a hanging or continued line.
indent-after-paren=4
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
# Maximum number of characters on a single line.
max-line-length=100
# Maximum number of lines in a module.
max-module-lines=1000
# Allow the body of a class to be on the same line as the declaration if body
# contains single statement.
single-line-class-stmt=no
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
[LOGGING]
# The type of string formatting that logging methods do. `old` for %
# formatting, `new` for {} formatting and `fstring` for f-strings.
logging-format-style=old
# Format template used to check logging format string.
logging-modules=logging
[MISCELLANEOUS]
# List of note tags to take in consideration, separated by a comma.
notes=FIXME,XXX,TODO
# Regular expression of note tags to take in consideration.
notes-rgx=
[SIMILARITIES]
# Comments are removed from the similarity computation
ignore-comments=yes
# Docstrings are removed from the similarity computation
ignore-docstrings=yes
# Imports are removed from the similarity computation
ignore-imports=no
# Signatures are removed from the similarity computation
ignore-signatures=no
# Minimum lines number d a similarity.
min-similarity-lines=4
[SPELLING]
# Limits count of emitted suggestions for spelling mistakes.
max-spelling-suggestions=4
# Path to a dictionary that some tests and fixers may parse and use.
spelling-dict=
# Tells whether to spell check word list when using the quiet mode
spelling-ignore-words=
# A path to a file with private dictionary; one word per line.
spelling-private-dict-file=
[VARIABLES]
# List of additional names supposed to be defined in builtins. Remember that
# you should avoid defining new builtins when possible.
additional-builtins=
# Tells whether unused global variables should be treated as a violation.
allow-global-unused-variables=yes
# List of names allowed to shadow builtins
allowed-redefined-builtins=
# List of strings which can identify a callback function by name. A callback
# name must start or end with one of those strings.
callbacks=cb_,_cb
# A regular expression matching the name of dummy variables (i.e. expected to
# not be used).
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|^ignored_|^unused_
# Argument names that match this expression will be ignored.
ignored-argument-names=_.*
# Tells whether we should check for unused import in __init__ files.
init-import=no
[CLASSES]
# Validate membership accesses on modules/namespaces based on the public API
# advertised by a module's all definition.
check-protected-access-in-special-methods=no
# List of method names used to declare an abstract method. The naming doesn't
# matter, as long as the method has the property decorated with
# "abstractmethod" anything else will be ignored.
defining-attr-methods=__init__,__new__,setUp,__post_init__
# List of member names, which should be excluded from the protected access
# warning.
exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=cls
[DESIGN]
# Maximum number of arguments for function / method
max-args=5
# Maximum number of attributes for a class
max-attributes=7
# Maximum number of boolean expressions in an if statement
max-bool-expr=5
# Maximum number of branch for function / method body
max-branches=12
# Maximum number of locals for function / method body
max-locals=15
# Maximum number of parents for a class (see R0901)
max-parents=7
# Maximum number of public methods for a class
max-public-methods=20
# Maximum number of return / yield for function / method body
max-returns=6
# Maximum number of statements in function / method body
max-statements=50
# Minimum number of public methods for a class
min-public-methods=2
[IMPORTS]
# Analyse import fallback blocks. This can be used to support both Python 2 and
# 3 compatible code, which means you may have duplicated imports (same
# imports in try except blocks). By default it set to False.
analyse-fallback-blocks=no
# Create a graph of external dependencies in the given file (report RP0402 must
# not be disabled)
ext-import-graph=
# Create a graph of every (i.e. non external) dependencies in the given file
# (report RP0402 must not be disabled)
import-graph=
# Create a graph of those files that have a dependency loop.
int-import-graph=
# Force import order to recognize a module as part of the standard
# compatibility libraries.
known-standard-library=
# Force import order to recognize a module as part of a third party library.
known-third-party=enchant
[EXCEPTIONS]
# Exceptions that will emit a warning when being caught. Defaults to
# "BaseException, Exception".
overgeneral-exceptions=builtins.BaseException,builtins.Exception

134
skills/addon/__init__.py Normal file
View File

@@ -0,0 +1,134 @@
"""
Blender Toolkit WebSocket Server
Claude Code와 통신하기 위한 WebSocket 서버 애드온
설치 방법:
1. Blender > Edit > Preferences > Add-ons > Install
2. 이 파일 선택
3. "Blender Toolkit WebSocket Server" 활성화
"""
# flake8: noqa: E402
# Blender addon requires bl_info at top of file
bl_info = { # type: ignore[misc]
"name": "Blender Toolkit WebSocket Server",
"author": "Dev GOM",
"version": (1, 0, 0),
"blender": (4, 0, 0),
"location": "View3D > Sidebar > Blender Toolkit",
"description": (
"WebSocket server for Claude Code integration "
"with animation retargeting"
),
"category": "Animation",
}
# Add bundled dependencies to sys.path
import sys
import os
_addon_dir = os.path.dirname(os.path.realpath(__file__))
_libs_dir = os.path.join(_addon_dir, 'libs')
if os.path.exists(_libs_dir) and _libs_dir not in sys.path:
sys.path.insert(0, _libs_dir)
import bpy
# Logging utilities
from .utils.logger import get_logger
# WebSocket Server
from .websocket_server import BlenderWebSocketServer
# UI Classes
from .ui import (
BoneMappingItem,
BLENDERTOOLKIT_PT_Panel,
BLENDERTOOLKIT_PT_BoneMappingPanel,
BLENDERTOOLKIT_OT_StartServer,
BLENDERTOOLKIT_OT_StopServer,
BLENDERTOOLKIT_OT_AutoRemap,
BLENDERTOOLKIT_OT_ApplyRetargeting,
)
# 모듈 로거 초기화
logger = get_logger('addon')
# ============================================================================
# 등록/해제
# ============================================================================
def register():
"""Blender 애드온 클래스 및 속성 등록."""
# Register property groups first
bpy.utils.register_class(BoneMappingItem)
# Register UI panels
bpy.utils.register_class(BLENDERTOOLKIT_PT_Panel)
bpy.utils.register_class(BLENDERTOOLKIT_PT_BoneMappingPanel)
# Register operators
bpy.utils.register_class(BLENDERTOOLKIT_OT_StartServer)
bpy.utils.register_class(BLENDERTOOLKIT_OT_StopServer)
bpy.utils.register_class(BLENDERTOOLKIT_OT_AutoRemap)
bpy.utils.register_class(BLENDERTOOLKIT_OT_ApplyRetargeting)
# 포트 설정 속성
bpy.types.Scene.blender_toolkit_port = bpy.props.IntProperty(
name="Port",
description="WebSocket server port",
default=9400,
min=1024,
max=65535
)
# 본 매핑 속성
bpy.types.Scene.bone_mapping_items = bpy.props.CollectionProperty(
type=BoneMappingItem,
name="Bone Mapping Items"
)
bpy.types.Scene.bone_mapping_source_armature = bpy.props.StringProperty(
name="Source Armature",
description="Source armature name"
)
bpy.types.Scene.bone_mapping_target_armature = bpy.props.StringProperty(
name="Target Armature",
description="Target armature name"
)
bpy.types.Scene.bone_mapping_status = bpy.props.StringProperty(
name="Bone Mapping Status",
description="Current status of bone mapping operation",
default=""
)
print("✅ Blender Toolkit WebSocket Server registered")
def unregister():
"""Blender 애드온 클래스 및 속성 등록 해제."""
# Unregister operators
bpy.utils.unregister_class(BLENDERTOOLKIT_OT_ApplyRetargeting)
bpy.utils.unregister_class(BLENDERTOOLKIT_OT_AutoRemap)
bpy.utils.unregister_class(BLENDERTOOLKIT_OT_StopServer)
bpy.utils.unregister_class(BLENDERTOOLKIT_OT_StartServer)
# Unregister UI panels
bpy.utils.unregister_class(BLENDERTOOLKIT_PT_BoneMappingPanel)
bpy.utils.unregister_class(BLENDERTOOLKIT_PT_Panel)
# Unregister property groups
bpy.utils.unregister_class(BoneMappingItem)
# Delete properties
del bpy.types.Scene.bone_mapping_status
del bpy.types.Scene.bone_mapping_target_armature
del bpy.types.Scene.bone_mapping_source_armature
del bpy.types.Scene.bone_mapping_items
del bpy.types.Scene.blender_toolkit_port
print("🔌 Blender Toolkit WebSocket Server unregistered")
if __name__ == "__main__":
register()

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

View File

@@ -0,0 +1,22 @@
{
"include": [
"."
],
"exclude": [
"**/__pycache__",
".venv",
"commands"
],
"extraPaths": [
"D:/Blender/Blender_Launcher_v1.15.1_Windows_x64/stable/blender-4.5.4-lts.b3efe983cc58/4.5/scripts/modules",
"D:/Blender/Blender_Launcher_v1.15.1_Windows_x64/stable/blender-4.5.4-lts.b3efe983cc58/4.5/python/lib/site-packages"
],
"typeCheckingMode": "basic",
"reportMissingImports": false,
"reportMissingModuleSource": false,
"reportUnknownParameterType": "warning",
"reportUnknownMemberType": "warning",
"reportMissingParameterType": "warning",
"pythonVersion": "3.11",
"pythonPlatform": "Windows"
}

View File

@@ -0,0 +1 @@
aiohttp>=3.8,<4.0

532
skills/addon/retargeting.py Normal file
View File

@@ -0,0 +1,532 @@
"""
Animation Retargeting Module
Blender 애니메이션 리타게팅 관련 함수들
주요 기능:
- 아마추어 및 본 정보 조회
- 자동 본 매핑 (Fuzzy matching)
- 애니메이션 리타게팅
- 애니메이션 재생 및 NLA 트랙 관리
- FBX/DAE 임포트
- 본 매핑 저장/로드
"""
import os
from typing import Dict, List, Optional
import bpy
# Fuzzy bone matching utilities
from .utils.bone_matching import (
fuzzy_match_bones,
get_match_quality_report,
)
# Logging utilities
from .utils.logger import get_logger
# 모듈 로거 초기화
logger = get_logger('retargeting')
# ============================================================================
# Armature & Bone Query Functions
# ============================================================================
def list_armatures() -> List[str]:
"""
모든 아마추어 오브젝트 목록 반환
Returns:
아마추어 오브젝트 이름 리스트
"""
return [obj.name for obj in bpy.data.objects if obj.type == 'ARMATURE']
def get_bones(armature_name: str) -> List[Dict[str, Optional[str]]]:
"""
아마추어의 본 정보 가져오기
Args:
armature_name: 아마추어 오브젝트 이름
Returns:
본 정보 리스트 (name, parent, children)
Raises:
ValueError: 아마추어를 찾을 수 없는 경우
"""
armature = bpy.data.objects.get(armature_name)
if not armature or armature.type != 'ARMATURE':
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]
})
return bones
# ============================================================================
# Bone Mapping Functions
# ============================================================================
def auto_map_bones(source_armature: str, target_armature: str) -> Dict[str, str]:
"""
자동 본 매핑 (Mixamo -> 사용자 캐릭터)
Fuzzy matching 알고리즘 사용으로 정확도 개선
Args:
source_armature: 소스 아마추어 이름 (예: Mixamo)
target_armature: 타겟 아마추어 이름 (사용자 캐릭터)
Returns:
본 매핑 딕셔너리 {소스 본: 타겟 본}
Raises:
ValueError: 아마추어를 찾을 수 없는 경우
"""
source = bpy.data.objects.get(source_armature)
target = bpy.data.objects.get(target_armature)
if not source or not target:
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("Source bones: %d, Target bones: %d", len(source_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("Auto-mapped %d bones", quality_report['total_mappings'])
logger.info("Quality: %s", quality_report['quality'].upper())
logger.info("Critical bones: %s", quality_report['critical_bones_mapped'])
logger.debug("Bone mapping: %s", 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 get_preset_bone_mapping(preset: str) -> Dict[str, str]:
"""
미리 정의된 본 매핑 프리셋 반환
Args:
preset: 프리셋 이름 (예: "mixamo_to_rigify")
Returns:
본 매핑 딕셔너리
"""
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",
# ... 더 많은 매핑
}
}
return presets.get(preset, {})
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:
작업 결과 메시지
"""
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
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: 저장된 매핑이 없거나 아마추어가 일치하지 않는 경우
"""
scene = bpy.context.scene
# 아마추어 검증
if not scene.bone_mapping_source_armature:
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):
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:
raise ValueError("Bone mapping is empty. Please generate mapping first.")
print(f"✅ Loaded bone mapping: {len(bone_mapping)} bones")
return bone_mapping
# ============================================================================
# Animation Retargeting Functions
# ============================================================================
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: 아마추어를 찾을 수 없거나 애니메이션이 없는 경우
"""
source = bpy.data.objects.get(source_armature)
target = bpy.data.objects.get(target_armature)
if not source or not target:
raise ValueError("Source or target armature not found")
if not source.animation_data or not source.animation_data.action:
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')
# 각 본에 대해 컨스트레인트 생성
for source_bone_name, target_bone_name in bone_map.items():
if source_bone_name not in source.pose.bones:
continue
if target_bone_name not in target.pose.bones:
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
# 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
# 컨스트레인트를 키프레임으로 베이크
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')
return f"Animation retargeted to {target_armature}"
# ============================================================================
# Animation Playback Functions
# ============================================================================
def list_animations(armature_name: str) -> List[str]:
"""
아마추어의 애니메이션 액션 목록 반환
Args:
armature_name: 아마추어 이름
Returns:
액션 이름 리스트
Raises:
ValueError: 아마추어를 찾을 수 없는 경우
"""
armature = bpy.data.objects.get(armature_name)
if not armature:
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)
return actions
def play_animation(armature_name: str, action_name: str, loop: bool = True) -> str:
"""
애니메이션 재생
Args:
armature_name: 아마추어 이름
action_name: 액션 이름
loop: 루프 재생 여부
Returns:
작업 결과 메시지
Raises:
ValueError: 아마추어 또는 액션을 찾을 수 없는 경우
"""
armature = bpy.data.objects.get(armature_name)
if not armature:
raise ValueError(f"Armature '{armature_name}' not found")
action = bpy.data.actions.get(action_name)
if not action:
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()
return f"Playing {action_name}"
def stop_animation() -> str:
"""
애니메이션 중지
Returns:
작업 결과 메시지
"""
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: NLA 트랙 이름
Returns:
작업 결과 메시지
Raises:
ValueError: 아마추어 또는 액션을 찾을 수 없는 경우
"""
armature = bpy.data.objects.get(armature_name)
action = bpy.data.actions.get(action_name)
if not armature or not action:
raise ValueError("Armature or action not found")
if not armature.animation_data:
armature.animation_data_create()
nla_track = armature.animation_data.nla_tracks.new()
nla_track.name = track_name
nla_track.strips.new(action.name, int(action.frame_range[0]), action)
return f"Added {action_name} to NLA track {track_name}"
# ============================================================================
# Import Functions
# ============================================================================
def import_fbx(filepath: str) -> str:
"""
FBX 파일 임포트
Args:
filepath: FBX 파일 경로
Returns:
작업 결과 메시지
Raises:
ValueError: 파일을 찾을 수 없는 경우
"""
if not os.path.exists(filepath):
raise ValueError(f"File not found: {filepath}")
bpy.ops.import_scene.fbx(filepath=filepath)
return f"Imported {filepath}"
def import_dae(filepath: str) -> str:
"""
Collada (.dae) 파일 임포트
Args:
filepath: DAE 파일 경로
Returns:
작업 결과 메시지
Raises:
ValueError: 파일을 찾을 수 없는 경우
"""
if not os.path.exists(filepath):
raise ValueError(f"File not found: {filepath}")
bpy.ops.wm.collada_import(filepath=filepath)
return f"Imported {filepath}"

350
skills/addon/ui.py Normal file
View File

@@ -0,0 +1,350 @@
"""
Blender Toolkit UI Components
UI 패널, 오퍼레이터, 속성 그룹 정의
"""
import asyncio
import threading
from typing import Any
import bpy
from .retargeting import auto_map_bones, retarget_animation
# ============================================================================
# Blender UI Panel
# ============================================================================
class BLENDERTOOLKIT_PT_Panel(bpy.types.Panel):
"""Blender Toolkit 사이드바 패널"""
bl_label = "Blender Toolkit"
bl_idname = "BLENDERTOOLKIT_PT_panel"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'Blender Toolkit'
def draw(self, context):
"""UI 패널 그리기."""
layout = self.layout
# 서버 상태 표시
layout.label(text="WebSocket Server", icon='NETWORK_DRIVE')
# 서버 시작/중지 버튼
row = layout.row()
row.operator("blendertoolkit.start_server", text="Start Server", icon='PLAY')
row.operator("blendertoolkit.stop_server", text="Stop Server", icon='PAUSE')
layout.separator()
# 포트 설정
layout.prop(context.scene, "blender_toolkit_port", text="Port")
class BLENDERTOOLKIT_OT_StartServer(bpy.types.Operator):
"""서버 시작 오퍼레이터"""
bl_idname = "blendertoolkit.start_server"
bl_label = "Start WebSocket Server"
def execute(self, context):
"""WebSocket 서버를 시작하는 오퍼레이터 실행."""
# Import here to avoid circular dependency
from . import BlenderWebSocketServer
port = context.scene.blender_toolkit_port
# Check if server is already running
if hasattr(bpy.types.Scene, '_blender_toolkit_server_thread'):
server_thread = bpy.types.Scene._blender_toolkit_server_thread
if server_thread and server_thread.is_alive():
self.report({'INFO'}, f"WebSocket server already running on port {port}")
return {'CANCELLED'}
# Start server in background thread
def run_server():
"""서버를 별도 스레드에서 실행"""
try:
server = BlenderWebSocketServer(port)
# Store server instance globally
bpy.types.Scene._blender_toolkit_server = server
# Create new event loop for this thread
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Store loop reference for graceful shutdown
bpy.types.Scene._blender_toolkit_event_loop = loop
# Run server until stopped
loop.run_until_complete(server.start())
# Keep loop running until stop() is called
loop.run_forever()
except (RuntimeError, OSError, ValueError) as e:
print(f"Blender Toolkit Server Error: {e}")
finally:
# Cleanup
loop.close()
# Start server thread
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
# Store thread reference
bpy.types.Scene._blender_toolkit_server_thread = server_thread
bpy.types.Scene._blender_toolkit_server_port = port
self.report({'INFO'}, f"✓ WebSocket server started on port {port}")
print(f"Blender Toolkit: WebSocket server started on ws://127.0.0.1:{port}")
return {'FINISHED'}
class BLENDERTOOLKIT_OT_StopServer(bpy.types.Operator):
"""서버 중지 오퍼레이터"""
bl_idname = "blendertoolkit.stop_server"
bl_label = "Stop WebSocket Server"
def execute(self, context):
"""WebSocket 서버를 중지하는 오퍼레이터 실행."""
# Check if server is running
if not hasattr(bpy.types.Scene, '_blender_toolkit_server_thread'):
self.report({'WARNING'}, "WebSocket server is not running")
return {'CANCELLED'}
server_thread = bpy.types.Scene._blender_toolkit_server_thread
if not server_thread or not server_thread.is_alive():
self.report({'WARNING'}, "WebSocket server is not running")
# Clean up stale references
self._cleanup_references()
return {'CANCELLED'}
# Get server instance and event loop
server = getattr(bpy.types.Scene, '_blender_toolkit_server', None)
loop = getattr(bpy.types.Scene, '_blender_toolkit_event_loop', None)
port = getattr(bpy.types.Scene, '_blender_toolkit_server_port', 'unknown')
if not server or not loop:
self.report({'WARNING'}, "Server instance not found. Please restart Blender to fully stop the server.")
self._cleanup_references()
return {'CANCELLED'}
try:
# Schedule server stop in the event loop thread
def stop_server():
"""이벤트 루프에서 서버를 안전하게 종료합니다."""
async def _shutdown():
try:
# 서버의 비동기 중지 메서드를 호출하고 완료될 때까지 기다립니다.
await server.stop()
except (RuntimeError, ValueError) as e:
print(f"Error stopping server: {e}")
finally:
# 서버가 완전히 중지된 후에 이벤트 루프를 멈춥니다.
loop.stop()
# 스레드 안전하게 비동기 종료 시퀀스를 스케줄링합니다.
asyncio.ensure_future(_shutdown())
# Call stop_server() in the event loop thread (thread-safe)
loop.call_soon_threadsafe(stop_server)
self.report({'INFO'}, f"✓ WebSocket server on port {port} stopped successfully")
print(f"Blender Toolkit: WebSocket server on port {port} stopped")
except (RuntimeError, ValueError, AttributeError) as e:
self.report({'ERROR'}, f"Failed to stop server: {str(e)}")
print(f"Blender Toolkit: Error stopping server: {e}")
return {'CANCELLED'}
finally:
# Clean up all references
self._cleanup_references()
return {'FINISHED'}
def _cleanup_references(self):
"""Clean up all server references"""
if hasattr(bpy.types.Scene, '_blender_toolkit_server'):
delattr(bpy.types.Scene, '_blender_toolkit_server')
if hasattr(bpy.types.Scene, '_blender_toolkit_event_loop'):
delattr(bpy.types.Scene, '_blender_toolkit_event_loop')
if hasattr(bpy.types.Scene, '_blender_toolkit_server_thread'):
delattr(bpy.types.Scene, '_blender_toolkit_server_thread')
if hasattr(bpy.types.Scene, '_blender_toolkit_server_port'):
delattr(bpy.types.Scene, '_blender_toolkit_server_port')
class BLENDERTOOLKIT_PT_BoneMappingPanel(bpy.types.Panel):
"""본 매핑 리뷰 패널"""
bl_label = "Bone Mapping Review"
bl_idname = "BLENDERTOOLKIT_PT_bone_mapping"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'Blender Toolkit'
bl_options = {'DEFAULT_CLOSED'}
def draw(self, context):
"""본 매핑 리뷰 패널 그리기."""
layout = self.layout
scene = context.scene
# Show armature info
if scene.bone_mapping_source_armature and scene.bone_mapping_target_armature:
layout.label(text=f"Source: {scene.bone_mapping_source_armature}", icon='ARMATURE_DATA')
layout.label(text=f"Target: {scene.bone_mapping_target_armature}", icon='ARMATURE_DATA')
layout.separator()
# Bone mapping table
if len(scene.bone_mapping_items) > 0:
mapping_count = len(scene.bone_mapping_items)
layout.label(
text=f"Bone Mappings ({mapping_count}):", icon='BONE_DATA'
)
# Header
box = layout.box()
row = box.row()
row.label(text="Source Bone")
row.label(text="")
row.label(text="Target Bone")
# Mapping items (scrollable)
for _, item in enumerate(scene.bone_mapping_items):
row = layout.row()
row.label(text=item.source_bone)
row.label(text="")
# Editable target bone dropdown
target_armature = bpy.data.objects.get(scene.bone_mapping_target_armature)
if target_armature and target_armature.type == 'ARMATURE':
row.prop_search(item, "target_bone", target_armature.data, "bones", text="")
else:
row.prop(item, "target_bone", text="")
layout.separator()
# Auto re-map button
layout.operator(
"blendertoolkit.auto_remap", text="Auto Re-map",
icon='FILE_REFRESH'
)
# Apply retargeting button
layout.separator()
# Show status
if hasattr(scene, 'bone_mapping_status'):
if scene.bone_mapping_status == "APPLYING":
layout.label(text="⏳ Applying retargeting...", icon='TIME')
elif scene.bone_mapping_status == "COMPLETED":
layout.label(text="✓ Retargeting completed!", icon='CHECKMARK')
elif scene.bone_mapping_status == "FAILED":
layout.label(text="✗ Retargeting failed", icon='ERROR')
layout.operator(
"blendertoolkit.apply_retargeting",
text="Apply Retargeting", icon='PLAY'
)
else:
layout.label(text="No bone mapping data", icon='INFO')
else:
layout.label(text="No bone mapping loaded", icon='INFO')
layout.label(text="Waiting for Claude Code...", icon='TIME')
class BLENDERTOOLKIT_OT_AutoRemap(bpy.types.Operator):
"""자동 재매핑 오퍼레이터"""
bl_idname = "blendertoolkit.auto_remap"
bl_label = "Auto Re-map Bones"
bl_description = "Re-generate bone mapping automatically"
def execute(self, context):
"""자동 본 재매핑 오퍼레이터 실행."""
scene = context.scene
source_armature = scene.bone_mapping_source_armature
target_armature = scene.bone_mapping_target_armature
if not source_armature or not target_armature:
self.report({'ERROR'}, "Source or target armature not set")
return {'CANCELLED'}
try:
# Auto-map bones
bone_map = auto_map_bones(source_armature, target_armature)
# Update scene properties
scene.bone_mapping_items.clear()
for source_bone, target_bone in bone_map.items():
item = scene.bone_mapping_items.add()
item.source_bone = source_bone
item.target_bone = target_bone
self.report({'INFO'}, f"Re-mapped {len(bone_map)} bones")
except (ValueError, KeyError, AttributeError) as e:
self.report({'ERROR'}, f"Auto re-mapping failed: {str(e)}")
return {'CANCELLED'}
return {'FINISHED'}
class BLENDERTOOLKIT_OT_ApplyRetargeting(bpy.types.Operator):
"""리타게팅 적용 오퍼레이터"""
bl_idname = "blendertoolkit.apply_retargeting"
bl_label = "Apply Retargeting"
bl_description = "Apply retargeting with current bone mapping"
def execute(self, context):
"""애니메이션 리타게팅을 적용하는 오퍼레이터 실행."""
scene = context.scene
source_armature = scene.bone_mapping_source_armature
target_armature = scene.bone_mapping_target_armature
if not source_armature or not target_armature:
self.report({'ERROR'}, "Source or target armature not set")
return {'CANCELLED'}
# Build bone map from items
bone_map = {}
for item in scene.bone_mapping_items:
if item.target_bone: # Skip empty mappings
bone_map[item.source_bone] = item.target_bone
if not bone_map:
self.report({'ERROR'}, "No bone mappings defined")
return {'CANCELLED'}
try:
# Show progress in UI
self.report({'INFO'}, f"Applying retargeting to {len(bone_map)} bones...")
# Set status flag
scene.bone_mapping_status = "APPLYING"
result = retarget_animation(
source_armature,
target_armature,
bone_map,
preserve_rotation=True,
preserve_location=True
)
# Update status
scene.bone_mapping_status = "COMPLETED"
self.report({'INFO'}, result)
except (ValueError, KeyError, AttributeError, RuntimeError) as e:
scene.bone_mapping_status = "FAILED"
self.report({'ERROR'}, f"Retargeting failed: {str(e)}")
return {'CANCELLED'}
return {'FINISHED'}
# ============================================================================
# Property Groups
# ============================================================================
class BoneMappingItem(bpy.types.PropertyGroup):
"""본 매핑 아이템"""
source_bone: bpy.props.StringProperty(name="Source Bone")
target_bone: bpy.props.StringProperty(name="Target Bone")

View File

@@ -0,0 +1,33 @@
"""
Blender Toolkit Utilities
유틸리티 모듈
"""
from .bone_matching import (
normalize_bone_name,
calculate_similarity,
find_best_match,
fuzzy_match_bones,
get_match_quality_report,
)
from .logger import (
get_logger,
setup_logging,
log_function_call,
log_error,
)
__all__ = [
# Bone matching
'normalize_bone_name',
'calculate_similarity',
'find_best_match',
'fuzzy_match_bones',
'get_match_quality_report',
# Logging
'get_logger',
'setup_logging',
'log_function_call',
'log_error',
]

View File

@@ -0,0 +1,262 @@
"""
Fuzzy Bone Matching Utilities
본 이름 유사도 기반 자동 매칭 알고리즘
"""
import re
from difflib import SequenceMatcher
from typing import Any, Dict, List, Optional, Tuple, Union
def normalize_bone_name(name: str) -> str:
"""
본 이름 정규화
- 소문자 변환
- 특수문자를 언더스코어로 변환
- 연속된 언더스코어 제거
- 양쪽 공백 제거
Examples:
"Left_Arm" -> "left_arm"
"left-arm" -> "left_arm"
"LeftArm" -> "leftarm"
"Left Arm" -> "left_arm"
"""
# 소문자 변환
normalized = name.lower()
# 특수문자를 언더스코어로 변환 (알파벳, 숫자, 점만 유지)
normalized = re.sub(r'[^a-z0-9.]', '_', normalized)
# 연속된 언더스코어를 하나로
normalized = re.sub(r'_+', '_', normalized)
# 양쪽 언더스코어 제거
normalized = normalized.strip('_')
return normalized
def calculate_similarity(name1: str, name2: str) -> float:
"""
두 본 이름 간 유사도 계산 (0.0 ~ 1.0)
알고리즘:
1. 정규화된 이름으로 SequenceMatcher 사용 (기본 점수)
2. 부분 문자열 매칭 보너스
3. 접두사/접미사 매칭 보너스
4. 단어 포함 보너스
Args:
name1: 첫 번째 본 이름
name2: 두 번째 본 이름
Returns:
유사도 점수 (0.0 = 전혀 다름, 1.0 = 완전 일치)
"""
# 정규화
norm1 = normalize_bone_name(name1)
norm2 = normalize_bone_name(name2)
# 완전 일치
if norm1 == norm2:
return 1.0
# SequenceMatcher로 기본 유사도 계산
base_score = SequenceMatcher(None, norm1, norm2).ratio()
# 보너스 점수 계산
bonus = 0.0
# 1. 부분 문자열 매칭 보너스 (한쪽이 다른 쪽에 포함)
if norm1 in norm2 or norm2 in norm1:
bonus += 0.15
# 2. 접두사 매칭 보너스 (left, right 등)
common_prefixes = ['left', 'right', 'up', 'low', 'upper', 'lower']
for prefix in common_prefixes:
if norm1.startswith(prefix) and norm2.startswith(prefix):
bonus += 0.1
break
# 3. 접미사 매칭 보너스 (l, r 등)
underscore_l_match = norm1.endswith('_l') and norm2.endswith('_l')
underscore_r_match = norm1.endswith('_r') and norm2.endswith('_r')
dot_l_match = norm1.endswith('.l') and norm2.endswith('.l')
dot_r_match = norm1.endswith('.r') and norm2.endswith('.r')
if underscore_l_match or underscore_r_match or dot_l_match or dot_r_match:
bonus += 0.1
# 4. 숫자 매칭 보너스 (Spine1, Spine2 등)
digits1 = re.findall(r'\d+', norm1)
digits2 = re.findall(r'\d+', norm2)
if digits1 and digits2 and digits1 == digits2:
bonus += 0.1
# 5. 단어 포함 보너스 (arm, hand, leg, foot 등)
keywords = ['arm', 'hand', 'leg', 'foot', 'finger', 'thumb', 'index',
'middle', 'ring', 'pinky', 'shoulder', 'elbow', 'wrist',
'hip', 'knee', 'ankle', 'toe', 'spine', 'neck', 'head']
for keyword in keywords:
if keyword in norm1 and keyword in norm2:
bonus += 0.05
break
# 최종 점수 (최대 1.0)
final_score = min(base_score + bonus, 1.0)
return final_score
def find_best_match(
source_bone: str,
target_bones: List[str],
threshold: float = 0.6,
return_score: bool = False
) -> Union[Optional[str], Tuple[Optional[str], float]]:
"""
타겟 본 리스트에서 가장 유사한 본 찾기
Args:
source_bone: 매칭할 소스 본 이름
target_bones: 타겟 본 이름 리스트
threshold: 최소 유사도 임계값 (0.0 ~ 1.0)
return_score: True면 (본_이름, 점수) 튜플 반환
Returns:
가장 유사한 타겟 본 이름, 또는 None (임계값 미만)
return_score=True면 (본_이름, 점수) 튜플
"""
best_match = None
best_score = 0.0
for target_bone in target_bones:
score = calculate_similarity(source_bone, target_bone)
if score > best_score and score >= threshold:
best_score = score
best_match = target_bone
if return_score:
return (best_match, best_score)
return best_match
def fuzzy_match_bones(
source_bones: List[str],
target_bones: List[str],
known_aliases: Dict[str, List[str]] = None,
threshold: float = 0.6,
prefer_exact: bool = True
) -> Dict[str, str]:
"""
Fuzzy matching을 사용한 전체 본 매핑
알고리즘:
1. 정확한 매칭 우선 (known_aliases 사용)
2. Fuzzy matching으로 나머지 매칭
3. 임계값 이상인 것만 포함
Args:
source_bones: 소스 본 이름 리스트
target_bones: 타겟 본 이름 리스트
known_aliases: 알려진 별칭 딕셔너리 {source: [target_alias1, ...]}
threshold: 최소 유사도 임계값
prefer_exact: True면 정확한 매칭 우선
Returns:
본 매핑 딕셔너리 {source_bone: target_bone}
"""
bone_map = {}
target_bone_names_lower = [b.lower() for b in target_bones]
matched_targets = set() # 중복 매칭 방지
# 1단계: 정확한 매칭 (known_aliases 사용)
if prefer_exact and known_aliases:
for source_bone in source_bones:
if source_bone not in known_aliases:
continue
# 별칭 리스트에서 타겟에 있는 것 찾기
for alias in known_aliases[source_bone]:
alias_lower = alias.lower()
if alias_lower in target_bone_names_lower:
idx = target_bone_names_lower.index(alias_lower)
actual_name = target_bones[idx]
# 이미 매칭된 타겟이 아니면 추가
if actual_name not in matched_targets:
bone_map[source_bone] = actual_name
matched_targets.add(actual_name)
break
# 2단계: Fuzzy matching으로 나머지 매칭
for source_bone in source_bones:
# 이미 매칭된 소스 본은 건너뛰기
if source_bone in bone_map:
continue
# 아직 매칭되지 않은 타겟 본들만 대상으로
available_targets = [t for t in target_bones if t not in matched_targets]
if not available_targets:
continue
# 가장 유사한 타겟 찾기
best_match, _ = find_best_match(
source_bone,
available_targets,
threshold=threshold,
return_score=True
)
if best_match:
bone_map[source_bone] = best_match
matched_targets.add(best_match)
return bone_map
def get_match_quality_report(bone_map: Dict[str, str]) -> Dict[str, Any]:
"""
본 매핑 품질 보고서 생성
Args:
bone_map: 본 매핑 딕셔너리
Returns:
품질 보고서 딕셔너리
"""
if not bone_map:
return {
'total_mappings': 0,
'quality': 'none',
'summary': 'No bone mappings found'
}
total = len(bone_map)
# 주요 본 체크 (최소한 있어야 하는 본들)
critical_bones = ['Hips', 'Spine', 'Head', 'LeftArm', 'RightArm',
'LeftLeg', 'RightLeg', 'LeftHand', 'RightHand']
critical_mapped = sum(1 for bone in critical_bones if bone in bone_map)
# 품질 평가
if critical_mapped >= 8:
quality = 'excellent'
elif critical_mapped >= 6:
quality = 'good'
elif critical_mapped >= 4:
quality = 'fair'
else:
quality = 'poor'
return {
'total_mappings': total,
'critical_bones_mapped': f'{critical_mapped}/{len(critical_bones)}',
'quality': quality,
'summary': f'{total} bones mapped, {critical_mapped}/{len(critical_bones)} critical bones'
}

View File

@@ -0,0 +1,165 @@
"""
Python Logging Configuration
Blender addon용 로깅 시스템
"""
import logging
import os
from pathlib import Path
from datetime import datetime
# 로그 디렉토리 경로
def get_log_dir() -> Path:
"""로그 디렉토리 경로 가져오기"""
# CLAUDE_PROJECT_DIR 환경변수 또는 현재 작업 디렉토리 사용
project_dir = os.environ.get('CLAUDE_PROJECT_DIR', os.getcwd())
log_dir = Path(project_dir) / '.blender-toolkit' / 'logs'
# 디렉토리 생성
log_dir.mkdir(parents=True, exist_ok=True)
return log_dir
# 로그 포맷 정의
LOG_FORMAT = '[%(asctime)s] [%(levelname)-8s] [%(name)s] %(message)s'
DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
# 전역 로거 설정 완료 여부
_logger_initialized = False
def setup_logging(level: int = logging.INFO) -> None:
"""
전역 로깅 설정 초기화
Args:
level: 로그 레벨 (logging.DEBUG, INFO, WARNING, ERROR)
"""
global _logger_initialized
if _logger_initialized:
return
# 로그 디렉토리
log_dir = get_log_dir()
# 루트 로거 설정
root_logger = logging.getLogger('blender_toolkit')
root_logger.setLevel(level)
# 기존 핸들러 제거 (중복 방지)
root_logger.handlers.clear()
# 파일 핸들러 (모든 로그)
file_handler = logging.FileHandler(
log_dir / 'blender-addon.log',
mode='a',
encoding='utf-8'
)
file_handler.setLevel(level)
file_handler.setFormatter(logging.Formatter(LOG_FORMAT, DATE_FORMAT))
# 파일 핸들러 (에러만)
error_handler = logging.FileHandler(
log_dir / 'error.log',
mode='a',
encoding='utf-8'
)
error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(logging.Formatter(LOG_FORMAT, DATE_FORMAT))
# 콘솔 핸들러 (개발 모드)
if os.environ.get('DEBUG'):
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG)
console_handler.setFormatter(
logging.Formatter('[%(levelname)-8s] %(message)s')
)
root_logger.addHandler(console_handler)
# 핸들러 추가
root_logger.addHandler(file_handler)
root_logger.addHandler(error_handler)
_logger_initialized = True
# 초기화 메시지
root_logger.info('=' * 70)
root_logger.info(f'Blender Toolkit Logger initialized')
root_logger.info(f'Log directory: {log_dir}')
root_logger.info(f'Log level: {logging.getLevelName(level)}')
root_logger.info('=' * 70)
def get_logger(name: str = None) -> logging.Logger:
"""
모듈별 로거 가져오기
Args:
name: 로거 이름 (보통 __name__ 사용)
Returns:
Logger 인스턴스
Example:
```python
from .utils.logger import get_logger
logger = get_logger(__name__)
logger.info("Hello, world!")
logger.error("An error occurred", exc_info=True)
```
"""
# 로깅 시스템이 초기화되지 않았으면 초기화
if not _logger_initialized:
# DEBUG 환경변수가 있으면 DEBUG 레벨 사용
level = logging.DEBUG if os.environ.get('DEBUG') else logging.INFO
setup_logging(level)
# 모듈별 로거 반환
logger_name = f'blender_toolkit.{name}' if name else 'blender_toolkit'
return logging.getLogger(logger_name)
# 편의 함수들
def log_function_call(logger: logging.Logger):
"""
함수 호출 로깅 데코레이터
Example:
```python
@log_function_call(logger)
def my_function(arg1, arg2):
return arg1 + arg2
```
"""
def decorator(func):
def wrapper(*args, **kwargs):
logger.debug(f'Calling {func.__name__}() with args={args}, kwargs={kwargs}')
try:
result = func(*args, **kwargs)
logger.debug(f'{func.__name__}() returned: {result}')
return result
except Exception as e:
logger.error(f'{func.__name__}() raised {type(e).__name__}: {e}', exc_info=True)
raise
return wrapper
return decorator
def log_error(logger: logging.Logger, error: Exception, context: str = None):
"""
에러 로깅 헬퍼
Args:
logger: Logger 인스턴스
error: Exception 객체
context: 에러 발생 컨텍스트 설명
"""
message = f'{type(error).__name__}: {error}'
if context:
message = f'{context} - {message}'
logger.error(message, exc_info=True)

View File

@@ -0,0 +1,129 @@
"""
Security utilities for Blender Toolkit addon
"""
import os
from pathlib import Path
from typing import Optional
def validate_file_path(file_path: str, allowed_root: Optional[str] = None) -> str:
"""
Validate file path to prevent path traversal attacks.
Args:
file_path: Path to validate
allowed_root: Optional allowed root directory. If None, only checks for dangerous patterns.
Returns:
Validated absolute path
Raises:
ValueError: If path is invalid or outside allowed directory
"""
if not file_path:
raise ValueError("File path cannot be empty")
# Resolve to absolute path
try:
abs_path = os.path.abspath(os.path.expanduser(file_path))
except Exception as e:
raise ValueError(f"Invalid file path: {e}")
# Check for null bytes (security risk)
if '\0' in file_path:
raise ValueError("File path contains null bytes")
# If allowed_root is specified, ensure path is within it
if allowed_root:
allowed_abs = os.path.abspath(os.path.expanduser(allowed_root))
# Resolve symlinks to prevent bypass
try:
real_path = os.path.realpath(abs_path)
real_root = os.path.realpath(allowed_abs)
except Exception:
# If realpath fails, use absolute paths
real_path = abs_path
real_root = allowed_abs
# Check if path is within allowed root
try:
Path(real_path).relative_to(real_root)
except ValueError:
raise ValueError(f"Path outside allowed directory: {file_path}")
return abs_path
def validate_port(port: int) -> int:
"""
Validate WebSocket port number.
Args:
port: Port number to validate
Returns:
Validated port number
Raises:
ValueError: If port is invalid
"""
if not isinstance(port, int):
raise ValueError("Port must be an integer")
if port < 1024 or port > 65535:
raise ValueError("Port must be between 1024 and 65535")
return port
# Whitelist for safe object attributes
ALLOWED_OBJECT_ATTRIBUTES = {
'location',
'rotation_euler',
'rotation_quaternion',
'rotation_axis_angle',
'scale',
'name',
'hide',
'hide_viewport',
'hide_render',
'hide_select',
}
# Whitelist for safe armature bone attributes
ALLOWED_BONE_ATTRIBUTES = {
'name',
'head',
'tail',
'roll',
'use_connect',
'use_deform',
'use_inherit_rotation',
'use_inherit_scale',
'use_local_location',
}
def validate_attribute_name(attr_name: str, allowed_attributes: set) -> str:
"""
Validate attribute name against whitelist.
Args:
attr_name: Attribute name to validate
allowed_attributes: Set of allowed attribute names
Returns:
Validated attribute name
Raises:
ValueError: If attribute is not allowed
"""
if not attr_name:
raise ValueError("Attribute name cannot be empty")
if attr_name not in allowed_attributes:
raise ValueError(f"Attribute '{attr_name}' is not allowed")
return attr_name

View File

@@ -0,0 +1,485 @@
"""
WebSocket Server for Blender Toolkit
Claude Code와 통신하기 위한 WebSocket 서버
이 모듈은 Blender 내부에서 WebSocket 서버를 실행하여
외부 클라이언트(Claude Code)와 JSON-RPC 스타일 통신을 제공합니다.
"""
import asyncio
import json
from typing import Any, Dict, Union
import bpy
from aiohttp import web
from aiohttp.web import Request, WebSocketResponse
from .utils.logger import get_logger
from .utils.security import validate_port
# 모듈 로거 초기화
logger = get_logger('websocket_server')
# 보안 상수
MAX_CONNECTIONS = 5 # 최대 동시 연결 수 (로컬 환경)
class BlenderWebSocketServer:
"""WebSocket 서버 메인 클래스"""
def __init__(self, port: int = 9400):
self.port = validate_port(port)
self.app = None
self.runner = None
self.site = None
self.clients = []
async def handle_command(
self, request: Request
) -> Union[WebSocketResponse, web.Response]:
"""WebSocket 연결 핸들러"""
# 로컬호스트만 허용 (보안)
peername = request.transport.get_extra_info('peername')
if peername:
host = peername[0]
if host not in ('127.0.0.1', '::1', 'localhost'):
logger.warning(
"Rejected connection from non-localhost: %s", host
)
return web.Response(
status=403, text="Only localhost connections allowed"
)
# 최대 연결 수 제한 (DoS 방지)
if len(self.clients) >= MAX_CONNECTIONS:
logger.warning(
"Connection limit reached (%d)", MAX_CONNECTIONS
)
return web.Response(status=503, text="Too many connections")
ws = web.WebSocketResponse()
await ws.prepare(request)
self.clients.append(ws)
logger.info("Client connected (total: %d)", len(self.clients))
print(f"✅ Client connected (total: {len(self.clients)})")
async for msg in ws:
if msg.type == web.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
logger.debug("Received message: %s", data)
response = await self.process_command(data)
await ws.send_json(response)
except (json.JSONDecodeError, KeyError, ValueError) as e:
logger.error("Error handling message: %s", e, exc_info=True)
await ws.send_json({
"id": data.get("id") if 'data' in locals() else None,
"error": {
"code": -1,
"message": str(e)
}
})
elif msg.type == web.WSMsgType.ERROR:
logger.error('WebSocket error: %s', ws.exception())
print(f'❌ WebSocket error: {ws.exception()}')
self.clients.remove(ws)
logger.info("Client disconnected (total: %d)", len(self.clients))
print(f"🔌 Client disconnected (total: {len(self.clients)})")
return ws
async def process_command(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""명령 처리"""
method = data.get("method")
params = data.get("params", {})
msg_id = data.get("id")
logger.info("Processing command: %s", method)
logger.debug("Command params: %s", params)
print(f"📨 Received command: {method}")
try:
# 메서드 라우팅
if method.startswith("Armature."):
result = await self.handle_armature_command(method, params)
elif method.startswith("Retargeting."):
result = await self.handle_retargeting_command(method, params)
elif method.startswith("BoneMapping."):
result = await self.handle_bonemapping_command(method, params)
elif method.startswith("Animation."):
result = await self.handle_animation_command(method, params)
elif method.startswith("Import."):
result = await self.handle_import_command(method, params)
elif method.startswith("Geometry."):
result = await self.handle_geometry_command(method, params)
elif method.startswith("Object."):
result = await self.handle_object_command(method, params)
elif method.startswith("Modifier."):
result = await self.handle_modifier_command(method, params)
elif method.startswith("Material."):
result = await self.handle_material_command(method, params)
elif method.startswith("Collection."):
result = await self.handle_collection_command(method, params)
else:
raise ValueError(f"Unknown method: {method}")
logger.info("Command %s completed successfully", method)
return {"id": msg_id, "result": result}
except (ValueError, KeyError, AttributeError, RuntimeError) as e:
logger.error("Error processing %s: %s", method, str(e), exc_info=True)
print(f"❌ Error processing {method}: {str(e)}")
return {
"id": msg_id,
"error": {"code": -1, "message": str(e)}
}
async def handle_armature_command(self, method: str, params: Dict) -> Any:
"""아마추어 관련 명령 처리"""
from .retargeting import get_bones, list_armatures
if method == "Armature.getBones":
armature_name = params.get("armatureName")
return get_bones(armature_name)
elif method == "Armature.list":
return list_armatures()
else:
raise ValueError(f"Unknown armature method: {method}")
async def handle_retargeting_command(self, method: str, params: Dict) -> Any:
"""리타게팅 명령 처리"""
from .retargeting import auto_map_bones, retarget_animation, get_preset_bone_mapping
if method == "Retargeting.autoMapBones":
return auto_map_bones(
params.get("sourceArmature"),
params.get("targetArmature")
)
elif method == "Retargeting.retargetAnimation":
return retarget_animation(
params.get("sourceArmature"),
params.get("targetArmature"),
params.get("boneMap"),
params.get("preserveRotation", True),
params.get("preserveLocation", False)
)
elif method == "Retargeting.getPresetMapping":
preset = params.get("preset")
return get_preset_bone_mapping(preset)
else:
raise ValueError(f"Unknown retargeting method: {method}")
async def handle_bonemapping_command(self, method: str, params: Dict) -> Any:
"""본 매핑 명령 처리"""
from .retargeting import store_bone_mapping, load_bone_mapping
if method == "BoneMapping.show":
return store_bone_mapping(
params.get("sourceArmature"),
params.get("targetArmature"),
params.get("boneMapping")
)
elif method == "BoneMapping.get":
return load_bone_mapping(
params.get("sourceArmature"),
params.get("targetArmature")
)
else:
raise ValueError(f"Unknown bone mapping method: {method}")
async def handle_animation_command(self, method: str, params: Dict) -> Any:
"""애니메이션 명령 처리"""
from .retargeting import list_animations, play_animation, stop_animation, add_to_nla
if method == "Animation.list":
armature_name = params.get("armatureName")
return list_animations(armature_name)
elif method == "Animation.play":
return play_animation(
params.get("armatureName"),
params.get("actionName"),
params.get("loop", True)
)
elif method == "Animation.stop":
return stop_animation()
elif method == "Animation.addToNLA":
return add_to_nla(
params.get("armatureName"),
params.get("actionName"),
params.get("trackName")
)
else:
raise ValueError(f"Unknown animation method: {method}")
async def handle_import_command(self, method: str, params: Dict) -> Any:
"""임포트 명령 처리"""
from .retargeting import import_fbx, import_dae
if method == "Import.fbx":
return import_fbx(params.get("filepath"))
elif method == "Import.dae":
return import_dae(params.get("filepath"))
else:
raise ValueError(f"Unknown import method: {method}")
async def handle_geometry_command(self, method: str, params: Dict) -> Any:
"""도형 생성 명령 처리"""
from .commands.geometry import (
create_cube, create_sphere, create_cylinder,
create_plane, create_cone, create_torus,
get_vertices, move_vertex, subdivide_mesh, extrude_face
)
if method == "Geometry.createCube":
return create_cube(
location=tuple(params.get("location", [0, 0, 0])),
size=params.get("size", 2.0),
name=params.get("name")
)
elif method == "Geometry.createSphere":
return create_sphere(
location=tuple(params.get("location", [0, 0, 0])),
radius=params.get("radius", 1.0),
segments=params.get("segments", 32),
ring_count=params.get("ringCount", 16),
name=params.get("name")
)
elif method == "Geometry.createCylinder":
return create_cylinder(
location=tuple(params.get("location", [0, 0, 0])),
radius=params.get("radius", 1.0),
depth=params.get("depth", 2.0),
vertices=params.get("vertices", 32),
name=params.get("name")
)
elif method == "Geometry.createPlane":
return create_plane(
location=tuple(params.get("location", [0, 0, 0])),
size=params.get("size", 2.0),
name=params.get("name")
)
elif method == "Geometry.createCone":
return create_cone(
location=tuple(params.get("location", [0, 0, 0])),
radius1=params.get("radius1", 1.0),
depth=params.get("depth", 2.0),
vertices=params.get("vertices", 32),
name=params.get("name")
)
elif method == "Geometry.createTorus":
return create_torus(
location=tuple(params.get("location", [0, 0, 0])),
major_radius=params.get("majorRadius", 1.0),
minor_radius=params.get("minorRadius", 0.25),
major_segments=params.get("majorSegments", 48),
minor_segments=params.get("minorSegments", 12),
name=params.get("name")
)
elif method == "Geometry.getVertices":
return get_vertices(params.get("name"))
elif method == "Geometry.moveVertex":
return move_vertex(
object_name=params.get("objectName"),
vertex_index=params.get("vertexIndex"),
new_position=tuple(params.get("newPosition"))
)
elif method == "Geometry.subdivideMesh":
return subdivide_mesh(
name=params.get("name"),
cuts=params.get("cuts", 1)
)
elif method == "Geometry.extrudeFace":
return extrude_face(
object_name=params.get("objectName"),
face_index=params.get("faceIndex"),
offset=params.get("offset", 1.0)
)
else:
raise ValueError(f"Unknown geometry method: {method}")
async def handle_object_command(self, method: str, params: Dict) -> Any:
"""오브젝트 명령 처리"""
from .commands.geometry import (
delete_object, transform_object, duplicate_object, list_objects
)
if method == "Object.delete":
return delete_object(params.get("name"))
elif method == "Object.transform":
location = params.get("location")
rotation = params.get("rotation")
scale = params.get("scale")
return transform_object(
name=params.get("name"),
location=tuple(location) if location else None,
rotation=tuple(rotation) if rotation else None,
scale=tuple(scale) if scale else None
)
elif method == "Object.duplicate":
location = params.get("location")
return duplicate_object(
name=params.get("name"),
new_name=params.get("newName"),
location=tuple(location) if location else None
)
elif method == "Object.list":
return list_objects(params.get("type"))
else:
raise ValueError(f"Unknown object method: {method}")
async def handle_modifier_command(self, method: str, params: Dict) -> Any:
"""모디파이어 명령 처리"""
from .commands.modifier import (
add_modifier, apply_modifier, list_modifiers, remove_modifier,
toggle_modifier, modify_modifier_properties, get_modifier_info, reorder_modifier
)
if method == "Modifier.add":
properties = params.get("properties", {})
return add_modifier(
object_name=params.get("objectName"),
modifier_type=params.get("modifierType"),
name=params.get("name")
)
elif method == "Modifier.apply":
return apply_modifier(
object_name=params.get("objectName"),
modifier_name=params.get("modifierName")
)
elif method == "Modifier.list":
return list_modifiers(
object_name=params.get("objectName")
)
elif method == "Modifier.remove":
return remove_modifier(
object_name=params.get("objectName"),
modifier_name=params.get("modifierName")
)
elif method == "Modifier.toggle":
return toggle_modifier(
object_name=params.get("objectName"),
modifier_name=params.get("modifierName"),
viewport=params.get("viewport"),
render=params.get("render")
)
elif method == "Modifier.modify":
properties = params.get("properties", {})
return modify_modifier_properties(
object_name=params.get("objectName"),
modifier_name=params.get("modifierName"),
**properties
)
elif method == "Modifier.getInfo":
return get_modifier_info(
object_name=params.get("objectName"),
modifier_name=params.get("modifierName")
)
elif method == "Modifier.reorder":
return reorder_modifier(
object_name=params.get("objectName"),
modifier_name=params.get("modifierName"),
direction=params.get("direction")
)
else:
raise ValueError(f"Unknown modifier method: {method}")
async def handle_material_command(self, method: str, params: Dict) -> Any:
"""머티리얼 명령 처리"""
from .commands.material import (
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
)
if method == "Material.create":
return create_material(
name=params.get("name"),
use_nodes=params.get("useNodes", True)
)
elif method == "Material.list":
return list_materials()
elif method == "Material.delete":
return delete_material(name=params.get("name"))
elif method == "Material.assign":
return assign_material(
object_name=params.get("objectName"),
material_name=params.get("materialName"),
slot_index=params.get("slotIndex", 0)
)
elif method == "Material.listObjectMaterials":
return list_object_materials(object_name=params.get("objectName"))
elif method == "Material.setBaseColor":
color = params.get("color")
return set_material_base_color(
material_name=params.get("materialName"),
color=tuple(color) if isinstance(color, list) else color
)
elif method == "Material.setMetallic":
return set_material_metallic(
material_name=params.get("materialName"),
metallic=params.get("metallic")
)
elif method == "Material.setRoughness":
return set_material_roughness(
material_name=params.get("materialName"),
roughness=params.get("roughness")
)
elif method == "Material.setEmission":
color = params.get("color")
return set_material_emission(
material_name=params.get("materialName"),
color=tuple(color) if isinstance(color, list) else color,
strength=params.get("strength", 1.0)
)
elif method == "Material.getProperties":
return get_material_properties(material_name=params.get("materialName"))
else:
raise ValueError(f"Unknown material method: {method}")
async def handle_collection_command(self, method: str, params: Dict) -> Any:
"""컬렉션 명령 처리"""
from .commands.collection import (
create_collection, list_collections, add_to_collection,
remove_from_collection, delete_collection
)
if method == "Collection.create":
return create_collection(name=params.get("name"))
elif method == "Collection.list":
return list_collections()
elif method == "Collection.addObject":
return add_to_collection(
object_name=params.get("objectName"),
collection_name=params.get("collectionName")
)
elif method == "Collection.removeObject":
return remove_from_collection(
object_name=params.get("objectName"),
collection_name=params.get("collectionName")
)
elif method == "Collection.delete":
return delete_collection(name=params.get("name"))
else:
raise ValueError(f"Unknown collection method: {method}")
async def start(self):
"""서버 시작"""
self.app = web.Application()
self.app.router.add_get('/ws', self.handle_command)
self.runner = web.AppRunner(self.app)
await self.runner.setup()
self.site = web.TCPSite(self.runner, '127.0.0.1', self.port)
await self.site.start()
print(f"✅ Blender WebSocket Server started on port {self.port}")
async def stop(self):
"""서버 중지"""
if self.site:
await self.site.stop()
if self.runner:
await self.runner.cleanup()
print("🛑 Blender WebSocket Server stopped")