Add event-driven Blender bridge with remote support

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>
This commit is contained in:
Syoyo Fujita
2025-12-31 02:13:08 +09:00
parent b02434e826
commit 73def407a8
4 changed files with 1464 additions and 0 deletions

View File

@@ -197,6 +197,84 @@ mcp__chrome-devtools__click({ uid: "<connect-button-uid>" })
mcp__chrome-devtools__take_screenshot()
```
## Blender-Side Event Monitoring (Lightweight)
The bridge includes event-driven Blender scripts that avoid polling for most property changes.
### Event Architecture
| Property Type | Mechanism | Polling? | Latency |
|---------------|-----------|----------|---------|
| Materials | `bpy.msgbus` | No | ~16ms |
| Lights | `bpy.msgbus` | No | ~16ms |
| Transforms | `depsgraph_update_post` | No | ~16ms |
| Viewport Camera | Timer | Yes (100ms) | 100ms |
### Quick Start (Local)
Run directly in Blender's Python console:
```python
exec(open('/path/to/web/blender-bridge/blender/bridge_simple.py').read())
bridge_connect() # Connect to server
bridge_upload_scene() # Upload current scene
# Changes now sync automatically!
bridge_stop() # Disconnect when done
```
### Quick Start (Remote - One-Liner!)
For Blender on a different PC, just run this in the Python console:
```python
import urllib.request; exec(urllib.request.urlopen("http://SERVER_IP:8090/blender/bootstrap").read().decode())
```
Replace `SERVER_IP` with your bridge server's IP address. This:
1. Fetches the bridge script from the server
2. Automatically connects to that server
3. Sets up all event monitors
**Server endpoints:**
- `GET /blender/bridge.py` - Full Python script
- `GET /blender/bootstrap` - Auto-connect bootstrap script
- `GET /blender/bootstrap?server=192.168.1.100:8090` - Custom server
### Addon Installation (Optional)
For a UI panel in the sidebar:
1. Copy `blender/bridge_addon.py` to Blender's addons folder
2. Enable "TinyUSDZ Bridge" in Preferences > Add-ons
3. Find the panel in View3D > Sidebar > TinyUSDZ
### How It Works
**msgbus (Materials & Lights):**
- Subscribes to RNA property changes
- Callbacks fire immediately when UI values change
- Zero CPU usage when idle
**depsgraph_update_post (Transforms):**
- Handler called once per frame when scene changes
- Only processes objects with `is_updated_transform` flag
- No polling - purely event-driven
**Timer (Viewport Camera Only):**
- 100ms interval timer (adjustable)
- Only component that uses polling
- Necessary because viewport navigation has no event hooks
### Commands
```python
bridge_connect(server="localhost", port=8090) # Connect
bridge_stop() # Disconnect
bridge_status() # Check status
bridge_upload_scene() # Upload USDZ
bridge_refresh_subscriptions() # After adding objects
```
## Camera Sync
Send Blender's viewport camera to the browser viewer:

View File

@@ -0,0 +1,683 @@
# Blender Bridge Addon - Event-Driven Parameter Sync
# Uses msgbus for UI changes, depsgraph for transforms, timer only for viewport camera
bl_info = {
"name": "TinyUSDZ Bridge",
"author": "TinyUSDZ",
"version": (1, 0, 0),
"blender": (4, 0, 0),
"location": "View3D > Sidebar > TinyUSDZ",
"description": "Real-time sync with browser viewer via WebSocket",
"category": "Import-Export",
}
import bpy
import json
import threading
import queue
from bpy.app.handlers import persistent
from mathutils import Matrix
# Optional WebSocket import (installed separately)
try:
import websocket
HAS_WEBSOCKET = True
except ImportError:
HAS_WEBSOCKET = False
print("Warning: websocket-client not installed. Run: pip install websocket-client")
# ============================================================================
# WebSocket Client
# ============================================================================
class BridgeWebSocket:
"""Thread-safe WebSocket client for bridge communication"""
def __init__(self):
self.ws = None
self.session_id = None
self.connected = False
self.send_queue = queue.Queue()
self.send_thread = None
self._lock = threading.Lock()
def connect(self, url="ws://localhost:8090"):
"""Connect to bridge server"""
if not HAS_WEBSOCKET:
return False
try:
self.ws = websocket.create_connection(
f"{url}?type=blender",
timeout=5
)
self.connected = True
# Receive session ID
response = self.ws.recv()
msg = self._decode_message(response)
if msg and msg.get('type') == 'session_created':
self.session_id = msg.get('sessionId')
print(f"Bridge connected. Session: {self.session_id}")
# Start send thread
self.send_thread = threading.Thread(target=self._send_loop, daemon=True)
self.send_thread.start()
return True
except Exception as e:
print(f"Bridge connection failed: {e}")
self.connected = False
return False
def disconnect(self):
"""Disconnect from bridge server"""
self.connected = False
if self.ws:
try:
self.ws.close()
except:
pass
self.ws = None
self.session_id = None
def send(self, message):
"""Queue message for sending (thread-safe)"""
if self.connected:
self.send_queue.put(message)
def _send_loop(self):
"""Background thread for sending messages"""
while self.connected:
try:
message = self.send_queue.get(timeout=0.1)
if self.ws and self.connected:
encoded = self._encode_message(message)
self.ws.send(encoded, opcode=websocket.ABNF.OPCODE_BINARY)
except queue.Empty:
continue
except Exception as e:
print(f"Send error: {e}")
self.connected = False
break
def _encode_message(self, header, payload=None):
"""Encode message with header length prefix"""
import struct
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_message(self, data):
"""Decode message from binary"""
import struct
if isinstance(data, str):
# Plain JSON for initial handshake
try:
return json.loads(data)
except:
pass
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)
# ============================================================================
# Event Monitor - msgbus subscriptions
# ============================================================================
class MaterialMonitor:
"""Monitor material property changes via msgbus"""
def __init__(self, bridge):
self.bridge = bridge
self.subscriptions = {} # owner objects for cleanup
def subscribe_all(self):
"""Subscribe to all materials in scene"""
self.clear()
for mat in bpy.data.materials:
self._subscribe_material(mat)
def _subscribe_material(self, material):
"""Subscribe to a single material's properties"""
mat_path = f"/Materials/{material.name}"
owner = object()
self.subscriptions[material.name] = owner
# Subscribe to common material properties
props_to_watch = [
'diffuse_color',
'metallic',
'roughness',
'specular_intensity',
]
for prop in props_to_watch:
if hasattr(material, prop):
try:
bpy.msgbus.subscribe_rna(
key=(material, prop),
owner=owner,
args=(material, prop, mat_path),
notify=self._on_property_change,
)
except:
pass
# Subscribe to node tree if using nodes
if material.use_nodes and material.node_tree:
self._subscribe_node_tree(material, owner, mat_path)
def _subscribe_node_tree(self, material, owner, mat_path):
"""Subscribe to shader node changes"""
for node in material.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED':
# Watch principled BSDF inputs
for inp in node.inputs:
if inp.type == 'RGBA' or inp.type == 'VALUE':
try:
bpy.msgbus.subscribe_rna(
key=(inp, 'default_value'),
owner=owner,
args=(material, inp.name, mat_path),
notify=self._on_node_input_change,
)
except:
pass
def _on_property_change(self, material, prop_name, mat_path):
"""Callback when material property changes"""
value = getattr(material, prop_name, None)
if value is None:
return
# Convert to serializable format
if hasattr(value, '__iter__'):
value = list(value)
self.bridge.queue_update('material', mat_path, {prop_name: value})
def _on_node_input_change(self, material, input_name, mat_path):
"""Callback when node input changes"""
if not material.use_nodes:
return
for node in material.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 = list(value)
self.bridge.queue_update('material', mat_path, {input_name: value})
break
def clear(self):
"""Clear all subscriptions"""
for owner in self.subscriptions.values():
bpy.msgbus.clear_by_owner(owner)
self.subscriptions.clear()
class LightMonitor:
"""Monitor light property changes via msgbus"""
def __init__(self, bridge):
self.bridge = bridge
self.subscriptions = {}
def subscribe_all(self):
"""Subscribe to all lights in scene"""
self.clear()
for obj in bpy.data.objects:
if obj.type == 'LIGHT':
self._subscribe_light(obj)
def _subscribe_light(self, light_obj):
"""Subscribe to light properties"""
light = light_obj.data
light_path = f"/Lights/{light_obj.name}"
owner = object()
self.subscriptions[light_obj.name] = owner
# Light data properties
light_props = ['energy', 'color', 'shadow_soft_size', 'spot_size', 'spot_blend']
for prop in light_props:
if hasattr(light, prop):
try:
bpy.msgbus.subscribe_rna(
key=(light, prop),
owner=owner,
args=(light_obj, prop),
notify=self._on_light_change,
)
except:
pass
def _on_light_change(self, light_obj, prop_name):
"""Callback when light property changes"""
light = light_obj.data
light_path = f"/Lights/{light_obj.name}"
value = getattr(light, prop_name, None)
if value is None:
return
if hasattr(value, '__iter__'):
value = list(value)
self.bridge.queue_update('light', light_path, {prop_name: value})
def clear(self):
"""Clear all subscriptions"""
for owner in self.subscriptions.values():
bpy.msgbus.clear_by_owner(owner)
self.subscriptions.clear()
# ============================================================================
# Depsgraph Handler - Transform changes
# ============================================================================
class DepsgraphMonitor:
"""Monitor transform/geometry changes via depsgraph handler"""
def __init__(self, bridge):
self.bridge = bridge
self.prev_transforms = {}
self._handler = None
def start(self):
"""Register depsgraph handler"""
if self._handler is not None:
return
@persistent
def on_depsgraph_update(scene, depsgraph):
self._process_updates(scene, depsgraph)
self._handler = on_depsgraph_update
bpy.app.handlers.depsgraph_update_post.append(self._handler)
def stop(self):
"""Unregister depsgraph handler"""
if self._handler and self._handler in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.remove(self._handler)
self._handler = None
self.prev_transforms.clear()
def _process_updates(self, scene, depsgraph):
"""Process depsgraph updates"""
for update in depsgraph.updates:
obj_id = update.id
# Skip non-object updates
if not isinstance(obj_id, bpy.types.Object):
continue
obj_name = obj_id.name
obj_path = f"/Objects/{obj_name}"
if update.is_updated_transform:
# Get current transform
obj = bpy.data.objects.get(obj_name)
if obj:
loc = list(obj.location)
rot = list(obj.rotation_euler)
scale = list(obj.scale)
current = {'location': loc, 'rotation': rot, 'scale': scale}
prev = self.prev_transforms.get(obj_name)
# Only send if changed
if current != prev:
self.bridge.queue_update('transform', obj_path, current)
self.prev_transforms[obj_name] = current
if update.is_updated_shading:
# Shading update - material assignment may have changed
self.bridge.queue_update('shading', obj_path, {'updated': True})
# ============================================================================
# Viewport Camera Timer - Only for viewport camera
# ============================================================================
class ViewportCameraOperator(bpy.types.Operator):
"""Modal operator for viewport camera sync (timer-based)"""
bl_idname = "tinyusdz.viewport_camera_sync"
bl_label = "Sync Viewport Camera"
bl_options = {'INTERNAL'}
_timer = None
_prev_state = None
def execute(self, context):
wm = context.window_manager
# 100ms interval - only for viewport camera
self._timer = wm.event_timer_add(0.1, window=context.window)
wm.modal_handler_add(self)
return {'RUNNING_MODAL'}
def modal(self, context, event):
bridge = get_bridge()
if not bridge or not bridge.is_connected():
return self.cancel(context)
if event.type == 'TIMER':
self._sync_viewport_camera(context)
return {'PASS_THROUGH'}
def cancel(self, context):
if self._timer:
context.window_manager.event_timer_remove(self._timer)
self._timer = None
return {'CANCELLED'}
def _sync_viewport_camera(self, context):
"""Extract and send viewport camera state"""
for area in context.screen.areas:
if area.type == 'VIEW_3D':
space = area.spaces.active
region_3d = space.region_3d
# Get camera position and target
view_matrix = region_3d.view_matrix.inverted()
position = view_matrix.translation
target = region_3d.view_location
lens = space.lens
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(lens, 2)
}
# Only send if changed (reduces traffic)
if current != self._prev_state:
bridge = get_bridge()
if bridge:
bridge.queue_update('camera', '/BlenderViewport', current)
self._prev_state = current
break
# ============================================================================
# Main Bridge Manager
# ============================================================================
class BlenderBridge:
"""Main bridge manager coordinating all monitors"""
def __init__(self):
self.ws = BridgeWebSocket()
self.material_monitor = MaterialMonitor(self)
self.light_monitor = LightMonitor(self)
self.depsgraph_monitor = DepsgraphMonitor(self)
# Update batching
self.pending_updates = {}
self.batch_timer = None
self._batch_lock = threading.Lock()
def connect(self, url="ws://localhost:8090"):
"""Connect to bridge server and start monitoring"""
if not self.ws.connect(url):
return False
# Start all monitors
self.material_monitor.subscribe_all()
self.light_monitor.subscribe_all()
self.depsgraph_monitor.start()
# Start viewport camera sync
bpy.ops.tinyusdz.viewport_camera_sync('INVOKE_DEFAULT')
return True
def disconnect(self):
"""Disconnect and stop all monitors"""
self.material_monitor.clear()
self.light_monitor.clear()
self.depsgraph_monitor.stop()
self.ws.disconnect()
def is_connected(self):
"""Check if connected"""
return self.ws.connected
def get_session_id(self):
"""Get current session ID"""
return self.ws.session_id
def queue_update(self, target_type, path, changes):
"""Queue an update for batched sending"""
with self._batch_lock:
key = f"{target_type}:{path}"
if key not in self.pending_updates:
self.pending_updates[key] = {
'target': {'type': target_type, 'path': path},
'changes': {}
}
self.pending_updates[key]['changes'].update(changes)
# Schedule batch send (debounce)
self._schedule_batch_send()
def _schedule_batch_send(self):
"""Schedule sending batched updates"""
# Use Blender's timer for thread safety
if not bpy.app.timers.is_registered(self._send_batched_updates):
bpy.app.timers.register(self._send_batched_updates, first_interval=0.016)
def _send_batched_updates(self):
"""Send all pending updates (called by timer)"""
with self._batch_lock:
updates = self.pending_updates
self.pending_updates = {}
for key, update in updates.items():
import uuid
message = {
'type': 'parameter_update',
'messageId': str(uuid.uuid4()),
'timestamp': int(bpy.context.scene.frame_current),
'target': update['target'],
'changes': update['changes']
}
self.ws.send(message)
return None # Don't repeat timer
def upload_scene(self, filepath):
"""Upload USDZ scene to bridge"""
if not self.is_connected():
return False
try:
with open(filepath, 'rb') as f:
scene_data = f.read()
import uuid
import os
message = {
'type': 'scene_upload',
'messageId': str(uuid.uuid4()),
'timestamp': 0,
'scene': {
'name': os.path.basename(filepath),
'format': 'usdz',
'byteLength': len(scene_data)
}
}
# Send with payload
self.ws.send((message, scene_data))
return True
except Exception as e:
print(f"Scene upload failed: {e}")
return False
# Global bridge instance
_bridge = None
def get_bridge():
global _bridge
return _bridge
# ============================================================================
# UI Panel
# ============================================================================
class TINYUSDZ_PT_bridge_panel(bpy.types.Panel):
"""TinyUSDZ Bridge Panel"""
bl_label = "TinyUSDZ Bridge"
bl_idname = "TINYUSDZ_PT_bridge_panel"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'TinyUSDZ'
def draw(self, context):
layout = self.layout
bridge = get_bridge()
if not HAS_WEBSOCKET:
layout.label(text="websocket-client not installed", icon='ERROR')
layout.label(text="pip install websocket-client")
return
if bridge and bridge.is_connected():
layout.label(text="Connected", icon='CHECKMARK')
layout.label(text=f"Session: {bridge.get_session_id()[:8]}...")
layout.operator("tinyusdz.disconnect", text="Disconnect")
layout.separator()
layout.operator("tinyusdz.export_and_upload", text="Upload Scene")
else:
layout.label(text="Disconnected", icon='X')
layout.prop(context.scene, "tinyusdz_server_url")
layout.operator("tinyusdz.connect", text="Connect")
class TINYUSDZ_OT_connect(bpy.types.Operator):
"""Connect to TinyUSDZ Bridge"""
bl_idname = "tinyusdz.connect"
bl_label = "Connect to Bridge"
def execute(self, context):
global _bridge
if _bridge is None:
_bridge = BlenderBridge()
url = context.scene.tinyusdz_server_url
if _bridge.connect(url):
self.report({'INFO'}, f"Connected to {url}")
else:
self.report({'ERROR'}, "Connection failed")
return {'FINISHED'}
class TINYUSDZ_OT_disconnect(bpy.types.Operator):
"""Disconnect from TinyUSDZ Bridge"""
bl_idname = "tinyusdz.disconnect"
bl_label = "Disconnect"
def execute(self, context):
global _bridge
if _bridge:
_bridge.disconnect()
self.report({'INFO'}, "Disconnected")
return {'FINISHED'}
class TINYUSDZ_OT_export_and_upload(bpy.types.Operator):
"""Export scene as USDZ and upload to bridge"""
bl_idname = "tinyusdz.export_and_upload"
bl_label = "Export and Upload"
def execute(self, context):
import tempfile
import os
bridge = get_bridge()
if not bridge or not bridge.is_connected():
self.report({'ERROR'}, "Not connected")
return {'CANCELLED'}
# 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
)
# Upload
if bridge.upload_scene(filepath):
self.report({'INFO'}, "Scene uploaded")
else:
self.report({'ERROR'}, "Upload failed")
return {'FINISHED'}
# ============================================================================
# Registration
# ============================================================================
classes = (
ViewportCameraOperator,
TINYUSDZ_PT_bridge_panel,
TINYUSDZ_OT_connect,
TINYUSDZ_OT_disconnect,
TINYUSDZ_OT_export_and_upload,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.tinyusdz_server_url = bpy.props.StringProperty(
name="Server URL",
default="ws://localhost:8090",
description="TinyUSDZ Bridge server URL"
)
def unregister():
global _bridge
if _bridge:
_bridge.disconnect()
_bridge = None
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
del bpy.types.Scene.tinyusdz_server_url
if __name__ == "__main__":
register()

View File

@@ -0,0 +1,644 @@
# 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)
""")

View File

@@ -4,6 +4,12 @@
import { WebSocketServer } from 'ws';
import express from 'express';
import { createServer } from 'http';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { ConnectionManager, ClientType } from './lib/connection-manager.js';
import { SceneState } from './lib/scene-state.js';
import {
@@ -62,6 +68,59 @@ app.get('/sessions', (req, res) => {
res.json({ sessions });
});
// HTTP endpoint: Serve Blender bridge Python script
app.get('/blender/bridge.py', (req, res) => {
const scriptPath = path.join(__dirname, 'blender', 'bridge_simple.py');
res.setHeader('Content-Type', 'text/x-python');
res.setHeader('Content-Disposition', 'inline; filename="bridge_simple.py"');
if (fs.existsSync(scriptPath)) {
res.send(fs.readFileSync(scriptPath, 'utf-8'));
} else {
res.status(404).send('# Script not found');
}
});
// HTTP endpoint: Bootstrap script (one-liner for remote Blender)
app.get('/blender/bootstrap', (req, res) => {
const serverUrl = req.query.server || req.headers.host || 'localhost:8090';
const [serverHost, serverPort] = serverUrl.includes(':')
? serverUrl.split(':')
: [serverUrl, '8090'];
res.setHeader('Content-Type', 'text/x-python');
// Generate minimal bootstrap script that fetches and runs the full script
const bootstrap = `# TinyUSDZ Bridge Bootstrap - Run this in Blender
# Fetches and executes the bridge script from the server
import urllib.request
import ssl
SERVER = "${serverHost}"
PORT = ${serverPort}
# Fetch the bridge script
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
url = f"http://{SERVER}:{PORT}/blender/bridge.py"
print(f"Fetching bridge script from {url}...")
try:
with urllib.request.urlopen(url, timeout=10, context=ctx) as response:
script = response.read().decode('utf-8')
exec(script)
print("Bridge script loaded!")
bridge_connect(server=SERVER, port=PORT)
except Exception as e:
print(f"Failed to fetch bridge script: {e}")
`;
res.send(bootstrap);
});
// HTTP endpoint: Upload scene (alternative to WebSocket for large files)
app.post('/upload/:sessionId', (req, res) => {
const { sessionId } = req.params;