Initial commit
This commit is contained in:
409
skills/addon/.pylintrc
Normal file
409
skills/addon/.pylintrc
Normal 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
134
skills/addon/__init__.py
Normal 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()
|
||||
91
skills/addon/commands/__init__.py
Normal file
91
skills/addon/commands/__init__.py
Normal 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',
|
||||
]
|
||||
136
skills/addon/commands/animation.py
Normal file
136
skills/addon/commands/animation.py
Normal 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}"
|
||||
55
skills/addon/commands/armature.py
Normal file
55
skills/addon/commands/armature.py
Normal 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
|
||||
90
skills/addon/commands/bone_mapping.py
Normal file
90
skills/addon/commands/bone_mapping.py
Normal 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
|
||||
88
skills/addon/commands/collection.py
Normal file
88
skills/addon/commands/collection.py
Normal 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"}
|
||||
552
skills/addon/commands/geometry.py
Normal file
552
skills/addon/commands/geometry.py
Normal 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)
|
||||
}
|
||||
79
skills/addon/commands/import_.py
Normal file
79
skills/addon/commands/import_.py
Normal 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)}")
|
||||
361
skills/addon/commands/material.py
Normal file
361
skills/addon/commands/material.py
Normal 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
|
||||
388
skills/addon/commands/modifier.py
Normal file
388
skills/addon/commands/modifier.py
Normal 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
|
||||
}
|
||||
260
skills/addon/commands/retargeting.py
Normal file
260
skills/addon/commands/retargeting.py
Normal 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
|
||||
22
skills/addon/pyrightconfig.json
Normal file
22
skills/addon/pyrightconfig.json
Normal 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"
|
||||
}
|
||||
1
skills/addon/requirements.txt
Normal file
1
skills/addon/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
aiohttp>=3.8,<4.0
|
||||
532
skills/addon/retargeting.py
Normal file
532
skills/addon/retargeting.py
Normal 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
350
skills/addon/ui.py
Normal 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")
|
||||
33
skills/addon/utils/__init__.py
Normal file
33
skills/addon/utils/__init__.py
Normal 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',
|
||||
]
|
||||
262
skills/addon/utils/bone_matching.py
Normal file
262
skills/addon/utils/bone_matching.py
Normal 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'
|
||||
}
|
||||
165
skills/addon/utils/logger.py
Normal file
165
skills/addon/utils/logger.py
Normal 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)
|
||||
129
skills/addon/utils/security.py
Normal file
129
skills/addon/utils/security.py
Normal 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
|
||||
485
skills/addon/websocket_server.py
Normal file
485
skills/addon/websocket_server.py
Normal 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")
|
||||
Reference in New Issue
Block a user