mirror of
https://github.com/lighttransport/tinyusdz.git
synced 2026-01-18 01:11:17 +01:00
Replace polling with lightweight event-driven mechanisms:
- bpy.msgbus subscriptions for material/light property changes
- depsgraph_update_post handler for transform changes
- Timer only for viewport camera (100ms, unavoidable)
Add remote Blender support via HTTP endpoints:
- GET /blender/bridge.py - Full Python script
- GET /blender/bootstrap - Auto-connect one-liner
Remote Blender can now connect with single command:
import urllib.request; exec(urllib.request.urlopen("http://SERVER:8090/blender/bootstrap").read().decode())
Files:
- blender/bridge_simple.py - Standalone script (MCP compatible)
- blender/bridge_addon.py - Full Blender addon with UI panel
- server.js - Added HTTP endpoints for script serving
- SETUP.md - Updated with event architecture and remote setup
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
645 lines
20 KiB
Python
645 lines
20 KiB
Python
# Simple Blender Bridge Script - Run directly in Blender
|
|
# Event-driven parameter sync without polling (except viewport camera)
|
|
#
|
|
# Usage (in Blender Python console or via MCP):
|
|
# exec(open('/path/to/bridge_simple.py').read())
|
|
# bridge_connect() # Connect to server
|
|
# bridge_status() # Check status
|
|
# bridge_stop() # Disconnect
|
|
|
|
import bpy
|
|
import sys
|
|
|
|
# Register as persistent module so state survives between MCP calls
|
|
_MODULE_NAME = 'tinyusdz_bridge'
|
|
if _MODULE_NAME not in sys.modules:
|
|
import types
|
|
sys.modules[_MODULE_NAME] = types.ModuleType(_MODULE_NAME)
|
|
_module = sys.modules[_MODULE_NAME]
|
|
import json
|
|
import struct
|
|
import socket
|
|
import threading
|
|
import queue
|
|
from bpy.app.handlers import persistent
|
|
|
|
# ============================================================================
|
|
# Configuration
|
|
# ============================================================================
|
|
|
|
BRIDGE_SERVER = "localhost"
|
|
BRIDGE_PORT = 8090
|
|
VIEWPORT_CAMERA_INTERVAL = 0.1 # 100ms - only thing that uses polling
|
|
|
|
# ============================================================================
|
|
# Global State
|
|
# ============================================================================
|
|
|
|
_bridge_state = {
|
|
'socket': None,
|
|
'session_id': None,
|
|
'connected': False,
|
|
'send_queue': queue.Queue(),
|
|
'send_thread': None,
|
|
'msgbus_owners': [],
|
|
'depsgraph_handler': None,
|
|
'camera_timer_running': False,
|
|
'prev_camera_state': None,
|
|
'pending_updates': {},
|
|
'batch_scheduled': False,
|
|
}
|
|
|
|
# ============================================================================
|
|
# WebSocket-like Communication (using raw TCP for simplicity in Blender)
|
|
# ============================================================================
|
|
|
|
def _create_websocket_handshake(host, port, path):
|
|
"""Create WebSocket upgrade request"""
|
|
import base64
|
|
import hashlib
|
|
import os
|
|
|
|
key = base64.b64encode(os.urandom(16)).decode()
|
|
|
|
request = (
|
|
f"GET {path} HTTP/1.1\r\n"
|
|
f"Host: {host}:{port}\r\n"
|
|
f"Upgrade: websocket\r\n"
|
|
f"Connection: Upgrade\r\n"
|
|
f"Sec-WebSocket-Key: {key}\r\n"
|
|
f"Sec-WebSocket-Version: 13\r\n"
|
|
f"\r\n"
|
|
)
|
|
return request, key
|
|
|
|
def _send_websocket_frame(sock, data, opcode=0x02):
|
|
"""Send WebSocket binary frame"""
|
|
if isinstance(data, str):
|
|
data = data.encode('utf-8')
|
|
opcode = 0x01 # text
|
|
|
|
length = len(data)
|
|
frame = bytearray()
|
|
|
|
# FIN + opcode
|
|
frame.append(0x80 | opcode)
|
|
|
|
# Mask bit + length
|
|
if length < 126:
|
|
frame.append(0x80 | length)
|
|
elif length < 65536:
|
|
frame.append(0x80 | 126)
|
|
frame.extend(struct.pack('>H', length))
|
|
else:
|
|
frame.append(0x80 | 127)
|
|
frame.extend(struct.pack('>Q', length))
|
|
|
|
# Masking key
|
|
import os
|
|
mask = os.urandom(4)
|
|
frame.extend(mask)
|
|
|
|
# Masked data
|
|
masked = bytearray(data)
|
|
for i in range(len(masked)):
|
|
masked[i] ^= mask[i % 4]
|
|
frame.extend(masked)
|
|
|
|
sock.sendall(bytes(frame))
|
|
|
|
def _recv_websocket_frame(sock):
|
|
"""Receive WebSocket frame"""
|
|
# Read first 2 bytes
|
|
header = sock.recv(2)
|
|
if len(header) < 2:
|
|
return None
|
|
|
|
fin = (header[0] & 0x80) != 0
|
|
opcode = header[0] & 0x0F
|
|
masked = (header[1] & 0x80) != 0
|
|
length = header[1] & 0x7F
|
|
|
|
if length == 126:
|
|
length = struct.unpack('>H', sock.recv(2))[0]
|
|
elif length == 127:
|
|
length = struct.unpack('>Q', sock.recv(8))[0]
|
|
|
|
if masked:
|
|
mask = sock.recv(4)
|
|
|
|
data = sock.recv(length)
|
|
|
|
if masked:
|
|
data = bytearray(data)
|
|
for i in range(len(data)):
|
|
data[i] ^= mask[i % 4]
|
|
data = bytes(data)
|
|
|
|
return {'opcode': opcode, 'data': data}
|
|
|
|
# ============================================================================
|
|
# Connection Management
|
|
# ============================================================================
|
|
|
|
def bridge_connect(server=BRIDGE_SERVER, port=BRIDGE_PORT):
|
|
"""Connect to bridge server"""
|
|
global _bridge_state
|
|
|
|
if _bridge_state['connected']:
|
|
print("Already connected")
|
|
return True
|
|
|
|
try:
|
|
# Create socket
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.settimeout(5)
|
|
sock.connect((server, port))
|
|
|
|
# WebSocket handshake
|
|
path = "/?type=blender"
|
|
request, key = _create_websocket_handshake(server, port, path)
|
|
sock.sendall(request.encode())
|
|
|
|
# Read response
|
|
response = b""
|
|
while b"\r\n\r\n" not in response:
|
|
response += sock.recv(1024)
|
|
|
|
if b"101" not in response:
|
|
print("WebSocket handshake failed")
|
|
sock.close()
|
|
return False
|
|
|
|
sock.settimeout(0.1)
|
|
_bridge_state['socket'] = sock
|
|
_bridge_state['connected'] = True
|
|
|
|
# Receive session ID
|
|
try:
|
|
frame = _recv_websocket_frame(sock)
|
|
if frame:
|
|
msg = _decode_bridge_message(frame['data'])
|
|
if msg and msg.get('type') == 'session_created':
|
|
_bridge_state['session_id'] = msg.get('sessionId')
|
|
print(f"Connected! Session: {_bridge_state['session_id']}")
|
|
except socket.timeout:
|
|
pass
|
|
|
|
# Start send thread
|
|
_bridge_state['send_thread'] = threading.Thread(target=_send_loop, daemon=True)
|
|
_bridge_state['send_thread'].start()
|
|
|
|
# Start monitors
|
|
_setup_msgbus_subscriptions()
|
|
_setup_depsgraph_handler()
|
|
_start_camera_timer()
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"Connection failed: {e}")
|
|
return False
|
|
|
|
def bridge_stop():
|
|
"""Disconnect from bridge"""
|
|
global _bridge_state
|
|
|
|
_bridge_state['connected'] = False
|
|
|
|
# Stop camera timer
|
|
_stop_camera_timer()
|
|
|
|
# Clear msgbus subscriptions
|
|
for owner in _bridge_state['msgbus_owners']:
|
|
try:
|
|
bpy.msgbus.clear_by_owner(owner)
|
|
except:
|
|
pass
|
|
_bridge_state['msgbus_owners'] = []
|
|
|
|
# Remove depsgraph handler
|
|
handler = _bridge_state['depsgraph_handler']
|
|
if handler and handler in bpy.app.handlers.depsgraph_update_post:
|
|
bpy.app.handlers.depsgraph_update_post.remove(handler)
|
|
_bridge_state['depsgraph_handler'] = None
|
|
|
|
# Close socket
|
|
if _bridge_state['socket']:
|
|
try:
|
|
_bridge_state['socket'].close()
|
|
except:
|
|
pass
|
|
_bridge_state['socket'] = None
|
|
|
|
_bridge_state['session_id'] = None
|
|
print("Disconnected")
|
|
|
|
def bridge_status():
|
|
"""Print bridge status"""
|
|
if _bridge_state['connected']:
|
|
print(f"Connected - Session: {_bridge_state['session_id']}")
|
|
else:
|
|
print("Disconnected")
|
|
|
|
# ============================================================================
|
|
# Message Encoding/Decoding
|
|
# ============================================================================
|
|
|
|
def _encode_bridge_message(header, payload=None):
|
|
"""Encode message with header length prefix"""
|
|
header_json = json.dumps(header)
|
|
header_bytes = header_json.encode('utf-8')
|
|
|
|
if payload:
|
|
result = struct.pack('<I', len(header_bytes)) + header_bytes + payload
|
|
else:
|
|
result = struct.pack('<I', len(header_bytes)) + header_bytes
|
|
|
|
return result
|
|
|
|
def _decode_bridge_message(data):
|
|
"""Decode bridge message"""
|
|
if len(data) < 4:
|
|
return None
|
|
|
|
header_len = struct.unpack('<I', data[:4])[0]
|
|
header_json = data[4:4+header_len].decode('utf-8')
|
|
return json.loads(header_json)
|
|
|
|
# ============================================================================
|
|
# Send Thread
|
|
# ============================================================================
|
|
|
|
def _send_loop():
|
|
"""Background thread for sending messages"""
|
|
while _bridge_state['connected']:
|
|
try:
|
|
message = _bridge_state['send_queue'].get(timeout=0.1)
|
|
sock = _bridge_state['socket']
|
|
if sock and _bridge_state['connected']:
|
|
encoded = _encode_bridge_message(message)
|
|
_send_websocket_frame(sock, encoded)
|
|
except queue.Empty:
|
|
continue
|
|
except Exception as e:
|
|
print(f"Send error: {e}")
|
|
_bridge_state['connected'] = False
|
|
break
|
|
|
|
def _queue_send(message):
|
|
"""Queue message for sending"""
|
|
if _bridge_state['connected']:
|
|
_bridge_state['send_queue'].put(message)
|
|
|
|
# ============================================================================
|
|
# Update Batching
|
|
# ============================================================================
|
|
|
|
def _queue_update(target_type, path, changes):
|
|
"""Queue an update for batched sending"""
|
|
key = f"{target_type}:{path}"
|
|
|
|
if key not in _bridge_state['pending_updates']:
|
|
_bridge_state['pending_updates'][key] = {
|
|
'target': {'type': target_type, 'path': path},
|
|
'changes': {}
|
|
}
|
|
_bridge_state['pending_updates'][key]['changes'].update(changes)
|
|
|
|
# Schedule batch send
|
|
if not _bridge_state['batch_scheduled']:
|
|
_bridge_state['batch_scheduled'] = True
|
|
bpy.app.timers.register(_send_batched_updates, first_interval=0.016)
|
|
|
|
def _send_batched_updates():
|
|
"""Send all pending updates"""
|
|
import uuid
|
|
|
|
updates = _bridge_state['pending_updates']
|
|
_bridge_state['pending_updates'] = {}
|
|
_bridge_state['batch_scheduled'] = False
|
|
|
|
for key, update in updates.items():
|
|
message = {
|
|
'type': 'parameter_update',
|
|
'messageId': str(uuid.uuid4()),
|
|
'timestamp': 0,
|
|
'target': update['target'],
|
|
'changes': update['changes']
|
|
}
|
|
_queue_send(message)
|
|
|
|
return None # Don't repeat
|
|
|
|
# ============================================================================
|
|
# msgbus Subscriptions (Event-driven - NO polling)
|
|
# ============================================================================
|
|
|
|
def _setup_msgbus_subscriptions():
|
|
"""Subscribe to material and light property changes"""
|
|
|
|
# Subscribe to materials
|
|
for mat in bpy.data.materials:
|
|
_subscribe_material(mat)
|
|
|
|
# Subscribe to lights
|
|
for obj in bpy.data.objects:
|
|
if obj.type == 'LIGHT':
|
|
_subscribe_light(obj)
|
|
|
|
print(f"Subscribed to {len(bpy.data.materials)} materials, "
|
|
f"{sum(1 for o in bpy.data.objects if o.type == 'LIGHT')} lights")
|
|
|
|
def _subscribe_material(material):
|
|
"""Subscribe to material property changes"""
|
|
mat_path = f"/Materials/{material.name}"
|
|
owner = object()
|
|
_bridge_state['msgbus_owners'].append(owner)
|
|
|
|
# Direct material properties
|
|
for prop in ['diffuse_color', 'metallic', 'roughness', 'specular_intensity']:
|
|
if hasattr(material, prop):
|
|
try:
|
|
bpy.msgbus.subscribe_rna(
|
|
key=(material, prop),
|
|
owner=owner,
|
|
args=(material.name, prop, mat_path),
|
|
notify=_on_material_property_change,
|
|
)
|
|
except:
|
|
pass
|
|
|
|
# Node-based materials (Principled BSDF)
|
|
if material.use_nodes and material.node_tree:
|
|
for node in material.node_tree.nodes:
|
|
if node.type == 'BSDF_PRINCIPLED':
|
|
for inp in node.inputs:
|
|
if inp.name in ['Base Color', 'Metallic', 'Roughness', 'IOR',
|
|
'Alpha', 'Emission Color', 'Emission Strength']:
|
|
try:
|
|
bpy.msgbus.subscribe_rna(
|
|
key=(inp, 'default_value'),
|
|
owner=owner,
|
|
args=(material.name, inp.name, mat_path),
|
|
notify=_on_node_input_change,
|
|
)
|
|
except:
|
|
pass
|
|
|
|
def _on_material_property_change(mat_name, prop_name, mat_path):
|
|
"""Callback when material property changes (event-driven)"""
|
|
mat = bpy.data.materials.get(mat_name)
|
|
if not mat:
|
|
return
|
|
|
|
value = getattr(mat, prop_name, None)
|
|
if value is None:
|
|
return
|
|
|
|
if hasattr(value, '__iter__'):
|
|
value = [round(v, 4) for v in value]
|
|
|
|
_queue_update('material', mat_path, {prop_name: value})
|
|
|
|
def _on_node_input_change(mat_name, input_name, mat_path):
|
|
"""Callback when node input changes (event-driven)"""
|
|
mat = bpy.data.materials.get(mat_name)
|
|
if not mat or not mat.use_nodes:
|
|
return
|
|
|
|
for node in mat.node_tree.nodes:
|
|
if node.type == 'BSDF_PRINCIPLED':
|
|
inp = node.inputs.get(input_name)
|
|
if inp:
|
|
value = inp.default_value
|
|
if hasattr(value, '__iter__'):
|
|
value = [round(v, 4) for v in value]
|
|
else:
|
|
value = round(value, 4)
|
|
|
|
_queue_update('material', mat_path, {input_name: value})
|
|
break
|
|
|
|
def _subscribe_light(light_obj):
|
|
"""Subscribe to light property changes"""
|
|
light = light_obj.data
|
|
light_path = f"/Lights/{light_obj.name}"
|
|
owner = object()
|
|
_bridge_state['msgbus_owners'].append(owner)
|
|
|
|
for prop in ['energy', 'color', 'shadow_soft_size', 'spot_size']:
|
|
if hasattr(light, prop):
|
|
try:
|
|
bpy.msgbus.subscribe_rna(
|
|
key=(light, prop),
|
|
owner=owner,
|
|
args=(light_obj.name, prop, light_path),
|
|
notify=_on_light_property_change,
|
|
)
|
|
except:
|
|
pass
|
|
|
|
def _on_light_property_change(light_name, prop_name, light_path):
|
|
"""Callback when light property changes (event-driven)"""
|
|
obj = bpy.data.objects.get(light_name)
|
|
if not obj or obj.type != 'LIGHT':
|
|
return
|
|
|
|
light = obj.data
|
|
value = getattr(light, prop_name, None)
|
|
if value is None:
|
|
return
|
|
|
|
if hasattr(value, '__iter__'):
|
|
value = [round(v, 4) for v in value]
|
|
else:
|
|
value = round(value, 4)
|
|
|
|
_queue_update('light', light_path, {prop_name: value})
|
|
|
|
# ============================================================================
|
|
# Depsgraph Handler (Event-driven for transforms)
|
|
# ============================================================================
|
|
|
|
def _setup_depsgraph_handler():
|
|
"""Setup depsgraph update handler for transform changes"""
|
|
|
|
prev_transforms = {}
|
|
|
|
@persistent
|
|
def on_depsgraph_update(scene, depsgraph):
|
|
if not _bridge_state['connected']:
|
|
return
|
|
|
|
for update in depsgraph.updates:
|
|
if not isinstance(update.id, bpy.types.Object):
|
|
continue
|
|
|
|
obj_name = update.id.name
|
|
|
|
if update.is_updated_transform:
|
|
obj = bpy.data.objects.get(obj_name)
|
|
if obj:
|
|
loc = [round(v, 4) for v in obj.location]
|
|
rot = [round(v, 4) for v in obj.rotation_euler]
|
|
scale = [round(v, 4) for v in obj.scale]
|
|
|
|
current = {'location': loc, 'rotation': rot, 'scale': scale}
|
|
prev = prev_transforms.get(obj_name)
|
|
|
|
if current != prev:
|
|
_queue_update('transform', f"/Objects/{obj_name}", current)
|
|
prev_transforms[obj_name] = current
|
|
|
|
bpy.app.handlers.depsgraph_update_post.append(on_depsgraph_update)
|
|
_bridge_state['depsgraph_handler'] = on_depsgraph_update
|
|
print("Depsgraph handler registered (event-driven transforms)")
|
|
|
|
# ============================================================================
|
|
# Viewport Camera Timer (Only polling component - 100ms interval)
|
|
# ============================================================================
|
|
|
|
def _start_camera_timer():
|
|
"""Start viewport camera sync timer"""
|
|
if _bridge_state['camera_timer_running']:
|
|
return
|
|
|
|
_bridge_state['camera_timer_running'] = True
|
|
bpy.app.timers.register(_camera_timer_callback, first_interval=VIEWPORT_CAMERA_INTERVAL)
|
|
print(f"Viewport camera timer started ({VIEWPORT_CAMERA_INTERVAL*1000:.0f}ms interval)")
|
|
|
|
def _stop_camera_timer():
|
|
"""Stop viewport camera sync timer"""
|
|
_bridge_state['camera_timer_running'] = False
|
|
|
|
def _camera_timer_callback():
|
|
"""Timer callback for viewport camera (only polling component)"""
|
|
if not _bridge_state['connected'] or not _bridge_state['camera_timer_running']:
|
|
return None # Stop timer
|
|
|
|
# Find 3D viewport
|
|
for window in bpy.context.window_manager.windows:
|
|
for area in window.screen.areas:
|
|
if area.type == 'VIEW_3D':
|
|
space = area.spaces.active
|
|
region_3d = space.region_3d
|
|
|
|
view_matrix = region_3d.view_matrix.inverted()
|
|
position = view_matrix.translation
|
|
target = region_3d.view_location
|
|
|
|
current = {
|
|
'position': [round(position.x, 4), round(position.y, 4), round(position.z, 4)],
|
|
'target': [round(target.x, 4), round(target.y, 4), round(target.z, 4)],
|
|
'lens': round(space.lens, 2)
|
|
}
|
|
|
|
if current != _bridge_state['prev_camera_state']:
|
|
_queue_update('camera', '/BlenderViewport', current)
|
|
_bridge_state['prev_camera_state'] = current
|
|
|
|
break
|
|
break
|
|
|
|
return VIEWPORT_CAMERA_INTERVAL # Repeat
|
|
|
|
# ============================================================================
|
|
# Scene Upload
|
|
# ============================================================================
|
|
|
|
def bridge_upload_scene():
|
|
"""Export current scene as USDZ and upload to bridge"""
|
|
import tempfile
|
|
import os
|
|
import uuid
|
|
|
|
if not _bridge_state['connected']:
|
|
print("Not connected")
|
|
return False
|
|
|
|
# Export to temp file
|
|
filepath = os.path.join(tempfile.gettempdir(), 'bridge_export.usdz')
|
|
|
|
bpy.ops.wm.usd_export(
|
|
filepath=filepath,
|
|
export_materials=True,
|
|
generate_materialx_network=True
|
|
)
|
|
|
|
# Read and send
|
|
with open(filepath, 'rb') as f:
|
|
scene_data = f.read()
|
|
|
|
header = {
|
|
'type': 'scene_upload',
|
|
'messageId': str(uuid.uuid4()),
|
|
'timestamp': 0,
|
|
'scene': {
|
|
'name': 'BlenderScene',
|
|
'format': 'usdz',
|
|
'byteLength': len(scene_data)
|
|
}
|
|
}
|
|
|
|
# Send via socket directly (too large for queue)
|
|
encoded = _encode_bridge_message(header, scene_data)
|
|
_send_websocket_frame(_bridge_state['socket'], encoded)
|
|
|
|
print(f"Scene uploaded ({len(scene_data)} bytes)")
|
|
return True
|
|
|
|
# ============================================================================
|
|
# Convenience Functions
|
|
# ============================================================================
|
|
|
|
def bridge_refresh_subscriptions():
|
|
"""Refresh msgbus subscriptions (after adding new objects)"""
|
|
# Clear existing
|
|
for owner in _bridge_state['msgbus_owners']:
|
|
try:
|
|
bpy.msgbus.clear_by_owner(owner)
|
|
except:
|
|
pass
|
|
_bridge_state['msgbus_owners'] = []
|
|
|
|
# Re-subscribe
|
|
_setup_msgbus_subscriptions()
|
|
print("Subscriptions refreshed")
|
|
|
|
# ============================================================================
|
|
# Export to persistent module (survives MCP calls)
|
|
# ============================================================================
|
|
|
|
_module.bridge_connect = bridge_connect
|
|
_module.bridge_stop = bridge_stop
|
|
_module.bridge_status = bridge_status
|
|
_module.bridge_upload_scene = bridge_upload_scene
|
|
_module.bridge_refresh_subscriptions = bridge_refresh_subscriptions
|
|
_module._bridge_state = _bridge_state
|
|
|
|
# Also export to builtins for easier access
|
|
import builtins
|
|
builtins.bridge_connect = bridge_connect
|
|
builtins.bridge_stop = bridge_stop
|
|
builtins.bridge_status = bridge_status
|
|
builtins.bridge_upload_scene = bridge_upload_scene
|
|
builtins.bridge_refresh_subscriptions = bridge_refresh_subscriptions
|
|
|
|
# Print usage
|
|
print("""
|
|
TinyUSDZ Bridge - Event-Driven (Lightweight)
|
|
============================================
|
|
Commands:
|
|
bridge_connect() - Connect to server
|
|
bridge_stop() - Disconnect
|
|
bridge_status() - Check connection status
|
|
bridge_upload_scene() - Upload current scene
|
|
bridge_refresh_subscriptions() - Refresh after adding objects
|
|
|
|
Event Monitoring:
|
|
- Materials: msgbus (no polling)
|
|
- Lights: msgbus (no polling)
|
|
- Transforms: depsgraph handler (no polling)
|
|
- Viewport camera: timer (100ms polling - only polling component)
|
|
""")
|