Initial commit
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user