Initial commit

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

View File

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

View File

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

View File

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

View File

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