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()
|
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
|
## Camera Sync
|
||||||
|
|
||||||
Send Blender's viewport camera to the browser viewer:
|
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 { WebSocketServer } from 'ws';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { createServer } from 'http';
|
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 { ConnectionManager, ClientType } from './lib/connection-manager.js';
|
||||||
import { SceneState } from './lib/scene-state.js';
|
import { SceneState } from './lib/scene-state.js';
|
||||||
import {
|
import {
|
||||||
@@ -62,6 +68,59 @@ app.get('/sessions', (req, res) => {
|
|||||||
res.json({ sessions });
|
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)
|
// HTTP endpoint: Upload scene (alternative to WebSocket for large files)
|
||||||
app.post('/upload/:sessionId', (req, res) => {
|
app.post('/upload/:sessionId', (req, res) => {
|
||||||
const { sessionId } = req.params;
|
const { sessionId } = req.params;
|
||||||
|
|||||||
Reference in New Issue
Block a user