351 lines
13 KiB
Python
351 lines
13 KiB
Python
"""
|
|
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")
|