mirror of
https://github.com/lighttransport/tinyusdz.git
synced 2026-01-18 01:11:17 +01:00
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:
@@ -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:
|
||||
|
||||
683
web/blender-bridge/blender/bridge_addon.py
Normal file
683
web/blender-bridge/blender/bridge_addon.py
Normal 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()
|
||||
644
web/blender-bridge/blender/bridge_simple.py
Normal file
644
web/blender-bridge/blender/bridge_simple.py
Normal 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)
|
||||
""")
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user