Add Blender Bridge for real-time USD scene streaming to browser

WebSocket-based bridge for streaming USD scenes from Blender to a
Three.js browser viewer with live parameter synchronization.

Features:
- WebSocket server (port 8090) for Blender-to-browser communication
- Scene upload with binary USDZ payload support
- Real-time parameter updates (materials, lights, camera, transforms)
- Blender viewport camera sync to browser
- Fit to Scene button in viewer UI
- TinyUSDZ WASM integration for USD parsing in browser
- Delta detection to minimize network traffic

Components:
- server.js: WebSocket server with session management
- lib/: Message protocol, connection manager, scene state
- client/: Browser WebSocket client and parameter sync
- viewer/: Three.js viewer with TinyUSDZ integration
- send-camera.js: Helper to send Blender camera to browser
- test-client.js: Test client for debugging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Syoyo Fujita
2025-12-30 13:59:04 +09:00
parent 64b909a8d3
commit b02434e826
16 changed files with 5497 additions and 0 deletions

View File

@@ -0,0 +1,189 @@
# Blender Bridge
WebSocket bridge for streaming Blender scenes to a browser viewer with real-time parameter synchronization.
## Architecture
```
Blender (MCP) ──WebSocket──> Node.js Server ──WebSocket──> Browser Viewer
(8090) (TinyUSDZ WASM + Three.js)
```
## Quick Start
### 1. Install Dependencies
```bash
cd web/blender-bridge
npm install
```
### 2. Start the WebSocket Server
```bash
npm run server
# Server runs at ws://localhost:8090
```
### 3. Start the Browser Viewer
```bash
npm run dev
# Opens http://localhost:5174
```
### 4. Connect from Blender
Using Blender MCP, execute:
```python
import bpy
import websocket
import os
import tempfile
# Export scene to USDZ
export_path = os.path.join(tempfile.gettempdir(), 'bridge_scene.usdz')
bpy.ops.wm.usd_export(
filepath=export_path,
export_materials=True,
generate_materialx_network=True
)
# Read binary data
with open(export_path, 'rb') as f:
usdz_data = f.read()
# Connect and send
ws = websocket.create_connection('ws://localhost:8090?type=blender')
# Session ID is returned in response
# Use session ID to connect browser viewers
```
## Message Protocol
### Scene Upload (Blender → Browser)
```json
{
"type": "scene_upload",
"messageId": "uuid",
"scene": {
"name": "SceneName",
"format": "usdz",
"byteLength": 1048576
}
}
// + Binary payload: USDZ data
```
### Parameter Update (Blender → Browser)
```json
{
"type": "parameter_update",
"target": {
"type": "material", // or "light", "camera", "transform"
"path": "/World/Sphere/Material"
},
"changes": {
"base_color": [0.9, 0.7, 0.3],
"base_metalness": 0.85
}
}
```
### Acknowledgment (Browser → Blender)
```json
{
"type": "ack",
"refMessageId": "uuid",
"status": "success"
}
```
## HTTP Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/status` | GET | Server status and stats |
| `/sessions` | GET | List active sessions |
| `/upload/:sessionId` | POST | Upload scene via HTTP (base64) |
## Supported Parameters
### Materials (OpenPBR / USD Preview Surface)
| Parameter | Three.js Mapping |
|-----------|-----------------|
| `base_color` | `material.color` |
| `base_metalness` | `material.metalness` |
| `specular_roughness` | `material.roughness` |
| `specular_ior` | `material.ior` |
| `coat_weight` | `material.clearcoat` |
| `transmission_weight` | `material.transmission` |
| `emission_color` | `material.emissive` |
### Lights
| Parameter | Three.js Mapping |
|-----------|-----------------|
| `color` | `light.color` |
| `intensity` | `light.intensity` |
| `position` | `light.position` |
| `angle` | `light.angle` (SpotLight) |
| `width/height` | `light.width/height` (RectAreaLight) |
### Camera
| Parameter | Three.js Mapping |
|-----------|-----------------|
| `position` | `camera.position` |
| `target` | `controls.target` |
| `fov` | `camera.fov` |
### Transform
| Parameter | Three.js Mapping |
|-----------|-----------------|
| `position` | `object.position` |
| `rotation` | `object.rotation` |
| `scale` | `object.scale` |
| `matrix` | `object.matrix` |
## File Structure
```
blender-bridge/
├── package.json
├── server.js # WebSocket server
├── vite.config.js # Viewer dev config
├── lib/
│ ├── connection-manager.js # Session management
│ ├── message-protocol.js # Message encode/decode
│ └── scene-state.js # Server-side state
├── client/
│ ├── bridge-client.js # Browser WebSocket client
│ └── parameter-sync.js # Parameter mapping
└── viewer/
├── index.html
├── viewer.js
└── viewer.css
```
## Development
### Testing Server
```bash
# Check server status
curl http://localhost:8090/status
# List sessions
curl http://localhost:8090/sessions
```
### Debugging
Enable verbose logging by checking the browser console and the message log panel in the viewer UI.

251
web/blender-bridge/SETUP.md Normal file
View File

@@ -0,0 +1,251 @@
# Blender Bridge Setup Guide
Step-by-step instructions to set up and run the Blender Bridge for streaming USD scenes to a browser viewer.
## Prerequisites
- Node.js v18+
- Blender 4.0+ with USD export support
- TinyUSDZ WASM build (in `web/js/src/tinyusdz/`)
## Directory Structure
```
web/blender-bridge/
├── package.json
├── server.js # WebSocket server (port 8090)
├── test-client.js # Test client for debugging
├── vite.config.js # Vite dev server config
├── lib/
│ ├── connection-manager.js
│ ├── message-protocol.js
│ └── scene-state.js
├── client/
│ ├── bridge-client.js
│ └── parameter-sync.js
└── viewer/
├── index.html
├── viewer.js
├── viewer.css
├── tinyusdz/ # Copied from web/js/src/tinyusdz/
└── client/ # Copied from ../client/
```
## Setup Steps
### 1. Install Dependencies
```bash
cd web/blender-bridge
npm install
```
### 2. Copy TinyUSDZ WASM Files
Copy the WASM files to the viewer directory (symlinks don't work in browsers):
```bash
cd viewer
mkdir -p tinyusdz
cp ../../js/src/tinyusdz/*.js tinyusdz/
cp ../../js/src/tinyusdz/*.wasm tinyusdz/
```
### 3. Copy Client Files
```bash
mkdir -p client
cp ../client/*.js client/
```
### 4. Start the WebSocket Server
```bash
cd web/blender-bridge
node server.js
```
Server runs at:
- HTTP: http://localhost:8090
- WebSocket: ws://localhost:8090
- Status endpoint: http://localhost:8090/status
### 5. Start the Viewer Dev Server
In a new terminal:
```bash
cd web/blender-bridge
npx vite viewer --port 5173
```
Viewer available at: http://localhost:5173
### 6. Export Scene from Blender
Using Blender MCP or Python console:
```python
import bpy
import os
import tempfile
# Export USDZ with MaterialX
export_path = os.path.join(tempfile.gettempdir(), 'blender_bridge_test.usdz')
bpy.ops.wm.usd_export(
filepath=export_path,
export_materials=True,
generate_materialx_network=True
)
print(f"Exported to: {export_path}")
```
### 7. Send Scene via Test Client
```bash
cd web/blender-bridge
node test-client.js
```
Output will show:
```
========================================
SESSION ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
========================================
```
### 8. Connect Browser Viewer
1. Open http://localhost:5173
2. Enter the Session ID from step 7
3. Click "Connect"
The scene should load and display in the viewer.
## Verification
Check server status:
```bash
curl http://localhost:8090/status
```
Check active sessions:
```bash
curl http://localhost:8090/sessions
```
Expected output when connected:
```json
{
"sessions": [{
"sessionId": "...",
"hasBlender": true,
"browserCount": 1,
"hasScene": true,
"sceneName": "BlenderScene"
}]
}
```
## Troubleshooting
### WASM MIME Type Error
If you see "Response has unsupported MIME type '' expected 'application/wasm'":
- Ensure WASM files are copied (not symlinked) to `viewer/tinyusdz/`
- The vite config includes the `wasmMimePlugin()`
### Port Already in Use
```bash
fuser -k 5173/tcp # Kill process on port 5173
fuser -k 8090/tcp # Kill process on port 8090
```
### Session Not Found
- Ensure the WebSocket server is running (`node server.js`)
- Ensure the test client is connected before browser connects
- Check session ID matches exactly
## Architecture
```
┌─────────────┐ WebSocket ┌─────────────┐ WebSocket ┌─────────────┐
│ Blender │ ──────────────────>│ Server │<─────────────────>│ Browser │
│ (test-client│ Scene Upload │ (8090) │ Scene Data │ Viewer │
│ or MCP) │ Param Updates │ │ Param Updates │ (5173) │
└─────────────┘ └─────────────┘ └─────────────┘
v
Scene State
(in memory)
```
## Headless Chrome Testing (via Chrome DevTools MCP)
You can automate browser interaction using Chrome DevTools MCP:
```javascript
// 1. Open viewer page
mcp__chrome-devtools__new_page({ url: "http://localhost:5173" })
// 2. Fill session ID
mcp__chrome-devtools__fill({ uid: "<session-id-input-uid>", value: "<SESSION_ID>" })
// 3. Click Connect
mcp__chrome-devtools__click({ uid: "<connect-button-uid>" })
// 4. Take screenshot to verify
mcp__chrome-devtools__take_screenshot()
```
## Camera Sync
Send Blender's viewport camera to the browser viewer:
### Get Camera from Blender (via MCP or Python console)
```python
import bpy
import json
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
space = area.spaces.active
region_3d = space.region_3d
view_matrix = region_3d.view_matrix.inverted()
cam_pos = view_matrix.translation
target = region_3d.view_location
camera_data = {
"position": [cam_pos.x, cam_pos.y, cam_pos.z],
"target": [target.x, target.y, target.z],
"lens": space.lens
}
print(json.dumps(camera_data))
break
```
### Send Camera to Browser
```bash
node send-camera.js "<SESSION_ID>" '{"position": [15.0, -6.7, 8.2], "target": [0, 0, 0], "lens": 50}'
```
The browser viewer will:
1. Convert Blender Z-up coordinates to Three.js Y-up
2. Apply position, target, and FOV to the camera
3. Update OrbitControls
### Fit to Scene Button
The browser UI includes a "Fit to Scene" button that:
- Calculates the scene bounding box
- Positions camera to view the entire scene
- Works independently of Blender camera sync
## Message Flow
1. **Blender connects** → Server creates session → Returns session ID
2. **Blender uploads scene** → Server stores scene → Broadcasts to browsers
3. **Browser connects** → Server syncs current scene → Browser renders
4. **Blender updates params** → Server broadcasts → Browser applies changes
5. **Camera sync** → send-camera.js joins session → Sends camera update → Browser applies

View File

@@ -0,0 +1,345 @@
// Blender Bridge WebSocket Client for Browser
// Connects to Blender Bridge server and handles scene/parameter updates
/**
* Message Types (must match server)
*/
export const MessageType = {
SCENE_UPLOAD: 'scene_upload',
PARAMETER_UPDATE: 'parameter_update',
ACK: 'ack',
STATUS: 'status',
ERROR: 'error',
PING: 'ping',
PONG: 'pong',
SESSION_CREATED: 'session_created'
};
/**
* Decode message from ArrayBuffer
* Format: [4 bytes header length (LE)] + [JSON header] + [binary payload]
*/
function decodeMessage(data) {
const buffer = data instanceof ArrayBuffer ? data : data.buffer;
const view = new DataView(buffer);
// Read header length (little-endian)
const headerLength = view.getUint32(0, true);
// Read header
const headerBytes = new Uint8Array(buffer, 4, headerLength);
const headerJson = new TextDecoder().decode(headerBytes);
const header = JSON.parse(headerJson);
// Read payload if present
const payloadOffset = 4 + headerLength;
const payloadLength = buffer.byteLength - payloadOffset;
const payload = payloadLength > 0 ?
new Uint8Array(buffer, payloadOffset, payloadLength) :
null;
return { header, payload };
}
/**
* Encode message with optional binary payload
*/
function encodeMessage(header, payload = null) {
if (!header.messageId) {
header.messageId = crypto.randomUUID();
}
if (!header.timestamp) {
header.timestamp = Date.now();
}
const headerJson = JSON.stringify(header);
const headerBytes = new TextEncoder().encode(headerJson);
const headerLength = headerBytes.length;
const payloadBytes = payload ?
(payload instanceof Uint8Array ? payload : new Uint8Array(payload)) :
new Uint8Array(0);
const totalSize = 4 + headerLength + payloadBytes.length;
const result = new ArrayBuffer(totalSize);
const view = new DataView(result);
view.setUint32(0, headerLength, true);
new Uint8Array(result, 4, headerLength).set(headerBytes);
if (payloadBytes.length > 0) {
new Uint8Array(result, 4 + headerLength).set(payloadBytes);
}
return result;
}
/**
* BridgeClient - WebSocket client for Blender Bridge
*/
export class BridgeClient extends EventTarget {
constructor(options = {}) {
super();
this.serverUrl = options.serverUrl || 'ws://localhost:8090';
this.sessionId = options.sessionId;
this.autoReconnect = options.autoReconnect !== false;
this.reconnectDelay = options.reconnectDelay || 2000;
this.maxReconnectAttempts = options.maxReconnectAttempts || 10;
this.ws = null;
this.connected = false;
this.reconnectAttempts = 0;
this._reconnectTimer = null;
// Callbacks for specific message types
this.onSceneUpload = null;
this.onParameterUpdate = null;
this.onError = null;
}
/**
* Connect to the Blender Bridge server
*
* @param {string} [sessionId] - Session ID to join
* @returns {Promise<void>}
*/
connect(sessionId = this.sessionId) {
return new Promise((resolve, reject) => {
if (this.connected) {
resolve();
return;
}
this.sessionId = sessionId;
const url = new URL(this.serverUrl);
url.searchParams.set('type', 'browser');
if (sessionId) {
url.searchParams.set('session', sessionId);
}
console.log(`Connecting to Blender Bridge: ${url}`);
this.ws = new WebSocket(url);
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
console.log('Connected to Blender Bridge');
this.connected = true;
this.reconnectAttempts = 0;
this.dispatchEvent(new CustomEvent('connected'));
resolve();
};
this.ws.onclose = (event) => {
console.log(`Disconnected from Blender Bridge: code=${event.code}`);
this.connected = false;
this.dispatchEvent(new CustomEvent('disconnected', {
detail: { code: event.code, reason: event.reason }
}));
// Auto reconnect
if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
this._scheduleReconnect();
}
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
this.dispatchEvent(new CustomEvent('error', { detail: error }));
reject(error);
};
this.ws.onmessage = (event) => {
this._handleMessage(event.data);
};
});
}
/**
* Disconnect from server
*/
disconnect() {
this.autoReconnect = false;
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
if (this.ws) {
this.ws.close(1000, 'Client disconnect');
this.ws = null;
}
this.connected = false;
}
/**
* Schedule reconnection attempt
*/
_scheduleReconnect() {
if (this._reconnectTimer) return;
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.min(this.reconnectAttempts, 5);
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null;
this.connect(this.sessionId).catch(err => {
console.error('Reconnect failed:', err);
});
}, delay);
}
/**
* Handle incoming message
*/
_handleMessage(data) {
try {
const { header, payload } = decodeMessage(data);
switch (header.type) {
case MessageType.SCENE_UPLOAD:
this._handleSceneUpload(header, payload);
break;
case MessageType.PARAMETER_UPDATE:
this._handleParameterUpdate(header);
break;
case MessageType.PING:
this._sendPong(header.messageId);
break;
case MessageType.ERROR:
console.error('Server error:', header.error);
this.dispatchEvent(new CustomEvent('server-error', { detail: header.error }));
if (this.onError) {
this.onError(header.error);
}
break;
case MessageType.SESSION_CREATED:
console.log('Session created:', header.sessionId);
this.sessionId = header.sessionId;
this.dispatchEvent(new CustomEvent('session-created', {
detail: { sessionId: header.sessionId }
}));
break;
default:
console.log('Unknown message type:', header.type);
}
} catch (err) {
console.error('Message handling error:', err);
}
}
/**
* Handle scene upload message
*/
_handleSceneUpload(header, payload) {
console.log('Scene received:', header.scene);
const sceneData = {
header: header,
binaryData: payload,
scene: header.scene,
metadata: header.metadata
};
this.dispatchEvent(new CustomEvent('scene-upload', { detail: sceneData }));
if (this.onSceneUpload) {
this.onSceneUpload(sceneData);
}
// Send acknowledgment
this._sendAck(header.messageId, 'success');
}
/**
* Handle parameter update message
*/
_handleParameterUpdate(header) {
const updateData = {
target: header.target,
changes: header.changes
};
this.dispatchEvent(new CustomEvent('parameter-update', { detail: updateData }));
if (this.onParameterUpdate) {
this.onParameterUpdate(updateData);
}
// Send acknowledgment
this._sendAck(header.messageId, 'success');
}
/**
* Send acknowledgment
*/
_sendAck(refMessageId, status) {
if (!this.connected || !this.ws) return;
const message = encodeMessage({
type: MessageType.ACK,
refMessageId: refMessageId,
status: status
});
this.ws.send(message);
}
/**
* Send pong response
*/
_sendPong(refMessageId) {
if (!this.connected || !this.ws) return;
const message = encodeMessage({
type: MessageType.PONG,
refMessageId: refMessageId
});
this.ws.send(message);
}
/**
* Send status update to server
*
* @param {Object} viewerState - Current viewer state
*/
sendStatus(viewerState) {
if (!this.connected || !this.ws) return;
const message = encodeMessage({
type: MessageType.STATUS,
viewer: viewerState
});
this.ws.send(message);
}
/**
* Send error to server
*
* @param {string} code - Error code
* @param {string} message - Error message
* @param {Object} [details] - Additional details
*/
sendError(code, message, details = {}) {
if (!this.connected || !this.ws) return;
const msg = encodeMessage({
type: MessageType.ERROR,
error: { code, message, details }
});
this.ws.send(msg);
}
}
export default BridgeClient;

View File

@@ -0,0 +1,525 @@
// Parameter Sync for Blender Bridge
// Maps OpenPBR/USD parameters to Three.js objects
import * as THREE from 'three';
/**
* Target types for parameter updates
*/
export const TargetType = {
MATERIAL: 'material',
LIGHT: 'light',
CAMERA: 'camera',
TRANSFORM: 'transform'
};
/**
* Color space conversion: sRGB to linear
*/
function sRGBToLinear(value) {
if (value <= 0.04045) {
return value / 12.92;
}
return Math.pow((value + 0.055) / 1.055, 2.4);
}
/**
* Convert sRGB color array to linear
*/
function sRGBArrayToLinear(arr) {
return arr.map(v => sRGBToLinear(v));
}
/**
* Material parameter mappings
* Maps OpenPBR/USD parameter names to Three.js MeshPhysicalMaterial setters
*/
const MATERIAL_PARAM_MAP = {
// Base layer
'base_color': (mat, value) => {
const linear = sRGBArrayToLinear(value);
mat.color.setRGB(linear[0], linear[1], linear[2]);
},
'base_weight': (mat, value) => {
// OpenPBR base weight affects overall color
// Multiply with existing color
},
'base_metalness': (mat, value) => {
mat.metalness = value;
},
'base_diffuse_roughness': (mat, value) => {
// OpenPBR diffuse roughness - no direct Three.js equivalent
// Could use for custom shader
},
// Specular layer
'specular_roughness': (mat, value) => {
mat.roughness = value;
},
'specular_color': (mat, value) => {
const linear = sRGBArrayToLinear(value);
mat.specularColor.setRGB(linear[0], linear[1], linear[2]);
},
'specular_ior': (mat, value) => {
mat.ior = value;
},
'specular_weight': (mat, value) => {
mat.specularIntensity = value;
},
'specular_anisotropy': (mat, value) => {
mat.anisotropy = value;
},
'specular_anisotropy_rotation': (mat, value) => {
mat.anisotropyRotation = value * Math.PI * 2; // 0-1 to radians
},
// Coat layer (clearcoat)
'coat_weight': (mat, value) => {
mat.clearcoat = value;
},
'coat_roughness': (mat, value) => {
mat.clearcoatRoughness = value;
},
'coat_ior': (mat, value) => {
// Three.js doesn't have separate clearcoat IOR
// Could affect reflectivity calculation
},
'coat_color': (mat, value) => {
// Three.js doesn't have clearcoat color
// Would need custom shader
},
// Transmission
'transmission_weight': (mat, value) => {
mat.transmission = value;
},
'transmission_color': (mat, value) => {
const linear = sRGBArrayToLinear(value);
mat.attenuationColor.setRGB(linear[0], linear[1], linear[2]);
},
'transmission_depth': (mat, value) => {
mat.attenuationDistance = value;
},
// Subsurface
'subsurface_weight': (mat, value) => {
// Three.js has limited SSS support
// Could use thickness for approximation
},
'subsurface_color': (mat, value) => {
// Would need custom shader for proper SSS
},
'subsurface_radius': (mat, value) => {
// SSS radius per channel
},
// Emission
'emission_color': (mat, value) => {
const linear = sRGBArrayToLinear(value);
mat.emissive.setRGB(linear[0], linear[1], linear[2]);
},
'emission_luminance': (mat, value) => {
// Convert luminance to intensity
mat.emissiveIntensity = value / 1000;
},
// Geometry
'geometry_opacity': (mat, value) => {
mat.opacity = value;
mat.transparent = value < 1.0;
},
'geometry_thin_walled': (mat, value) => {
mat.thickness = value ? 0 : 0.5;
mat.side = value ? THREE.DoubleSide : THREE.FrontSide;
},
// Sheen
'sheen_weight': (mat, value) => {
mat.sheen = value;
},
'sheen_color': (mat, value) => {
const linear = sRGBArrayToLinear(value);
mat.sheenColor.setRGB(linear[0], linear[1], linear[2]);
},
'sheen_roughness': (mat, value) => {
mat.sheenRoughness = value;
},
// USD Preview Surface compatibility
'diffuseColor': (mat, value) => {
const linear = sRGBArrayToLinear(value);
mat.color.setRGB(linear[0], linear[1], linear[2]);
},
'metallic': (mat, value) => {
mat.metalness = value;
},
'roughness': (mat, value) => {
mat.roughness = value;
},
'opacity': (mat, value) => {
mat.opacity = value;
mat.transparent = value < 1.0;
},
'ior': (mat, value) => {
mat.ior = value;
},
'clearcoat': (mat, value) => {
mat.clearcoat = value;
},
'clearcoatRoughness': (mat, value) => {
mat.clearcoatRoughness = value;
},
'emissiveColor': (mat, value) => {
const linear = sRGBArrayToLinear(value);
mat.emissive.setRGB(linear[0], linear[1], linear[2]);
}
};
/**
* Light parameter mappings
*/
const LIGHT_PARAM_MAP = {
'color': (light, value) => {
const linear = sRGBArrayToLinear(value);
light.color.setRGB(linear[0], linear[1], linear[2]);
},
'intensity': (light, value) => {
light.intensity = value;
},
'exposure': (light, value) => {
// Exposure affects intensity: intensity * 2^exposure
light.intensity = light.userData.baseIntensity * Math.pow(2, value);
},
'position': (light, value) => {
light.position.set(value[0], value[1], value[2]);
},
'direction': (light, value) => {
// For directional lights, set target position
if (light.target) {
const pos = light.position;
light.target.position.set(
pos.x + value[0],
pos.y + value[1],
pos.z + value[2]
);
}
},
'radius': (light, value) => {
// For point/spot lights
if (light.isPointLight || light.isSpotLight) {
light.distance = value * 10; // Scale factor
}
},
'angle': (light, value) => {
// For spot lights (value in degrees)
if (light.isSpotLight) {
light.angle = THREE.MathUtils.degToRad(value);
}
},
'penumbra': (light, value) => {
if (light.isSpotLight) {
light.penumbra = value;
}
},
'width': (light, value) => {
// For rect area lights
if (light.isRectAreaLight) {
light.width = value;
}
},
'height': (light, value) => {
// For rect area lights
if (light.isRectAreaLight) {
light.height = value;
}
},
'castShadow': (light, value) => {
light.castShadow = value;
},
'shadowBias': (light, value) => {
if (light.shadow) {
light.shadow.bias = value;
}
},
'shadowRadius': (light, value) => {
if (light.shadow) {
light.shadow.radius = value;
}
}
};
/**
* Camera parameter mappings
*/
const CAMERA_PARAM_MAP = {
'position': (camera, value, controls) => {
camera.position.set(value[0], value[1], value[2]);
if (controls) controls.update();
},
'target': (camera, value, controls) => {
if (controls) {
controls.target.set(value[0], value[1], value[2]);
controls.update();
}
},
'fov': (camera, value) => {
if (camera.isPerspectiveCamera) {
camera.fov = value;
camera.updateProjectionMatrix();
}
},
'near': (camera, value) => {
camera.near = value;
camera.updateProjectionMatrix();
},
'far': (camera, value) => {
camera.far = value;
camera.updateProjectionMatrix();
},
'zoom': (camera, value) => {
camera.zoom = value;
camera.updateProjectionMatrix();
},
'focalLength': (camera, value) => {
if (camera.isPerspectiveCamera) {
camera.setFocalLength(value);
}
}
};
/**
* Transform parameter mappings
*/
const TRANSFORM_PARAM_MAP = {
'position': (object, value) => {
object.position.set(value[0], value[1], value[2]);
},
'rotation': (object, value) => {
// Euler angles in radians
object.rotation.set(value[0], value[1], value[2]);
},
'scale': (object, value) => {
object.scale.set(value[0], value[1], value[2]);
},
'matrix': (object, value) => {
// 4x4 matrix as flat array
const matrix = new THREE.Matrix4();
matrix.fromArray(value);
object.matrix.copy(matrix);
object.matrix.decompose(object.position, object.quaternion, object.scale);
},
'quaternion': (object, value) => {
object.quaternion.set(value[0], value[1], value[2], value[3]);
},
'visible': (object, value) => {
object.visible = value;
}
};
/**
* ParameterSync - Applies parameter updates to Three.js objects
*/
export class ParameterSync {
constructor() {
// Maps USD paths to Three.js objects
this.pathToMaterial = new Map();
this.pathToLight = new Map();
this.pathToCamera = new Map();
this.pathToObject = new Map();
// Reference to orbit controls for camera updates
this.controls = null;
}
/**
* Set the orbit controls reference
*/
setControls(controls) {
this.controls = controls;
}
/**
* Register a material with its USD path
*/
registerMaterial(path, material) {
this.pathToMaterial.set(path, material);
}
/**
* Register a light with its USD path
*/
registerLight(path, light) {
// Store base intensity for exposure calculations
light.userData.baseIntensity = light.intensity;
this.pathToLight.set(path, light);
}
/**
* Register a camera with its USD path
*/
registerCamera(path, camera) {
this.pathToCamera.set(path, camera);
}
/**
* Register an object (for transforms) with its USD path
*/
registerObject(path, object) {
this.pathToObject.set(path, object);
}
/**
* Clear all registrations
*/
clear() {
this.pathToMaterial.clear();
this.pathToLight.clear();
this.pathToCamera.clear();
this.pathToObject.clear();
}
/**
* Apply parameter update
*
* @param {Object} update - Update from BridgeClient
* @param {Object} update.target - Target info (type, path)
* @param {Object} update.changes - Changed parameters
* @returns {boolean} True if update was applied
*/
applyUpdate(update) {
const { target, changes } = update;
switch (target.type) {
case TargetType.MATERIAL:
return this.applyMaterialUpdate(target.path, changes);
case TargetType.LIGHT:
return this.applyLightUpdate(target.path, changes);
case TargetType.CAMERA:
return this.applyCameraUpdate(target.path, changes);
case TargetType.TRANSFORM:
return this.applyTransformUpdate(target.path, changes);
default:
console.warn(`Unknown target type: ${target.type}`);
return false;
}
}
/**
* Apply material parameter changes
*/
applyMaterialUpdate(path, changes) {
const material = this.pathToMaterial.get(path);
if (!material) {
console.warn(`Material not found: ${path}`);
return false;
}
for (const [param, value] of Object.entries(changes)) {
const setter = MATERIAL_PARAM_MAP[param];
if (setter) {
try {
setter(material, value);
} catch (err) {
console.error(`Failed to apply material param ${param}:`, err);
}
} else {
console.warn(`Unknown material parameter: ${param}`);
}
}
material.needsUpdate = true;
return true;
}
/**
* Apply light parameter changes
*/
applyLightUpdate(path, changes) {
const light = this.pathToLight.get(path);
if (!light) {
console.warn(`Light not found: ${path}`);
return false;
}
for (const [param, value] of Object.entries(changes)) {
const setter = LIGHT_PARAM_MAP[param];
if (setter) {
try {
setter(light, value);
} catch (err) {
console.error(`Failed to apply light param ${param}:`, err);
}
} else {
console.warn(`Unknown light parameter: ${param}`);
}
}
return true;
}
/**
* Apply camera parameter changes
*/
applyCameraUpdate(path, changes) {
const camera = this.pathToCamera.get(path);
if (!camera) {
console.warn(`Camera not found: ${path}`);
return false;
}
for (const [param, value] of Object.entries(changes)) {
const setter = CAMERA_PARAM_MAP[param];
if (setter) {
try {
setter(camera, value, this.controls);
} catch (err) {
console.error(`Failed to apply camera param ${param}:`, err);
}
} else {
console.warn(`Unknown camera parameter: ${param}`);
}
}
return true;
}
/**
* Apply transform parameter changes
*/
applyTransformUpdate(path, changes) {
const object = this.pathToObject.get(path);
if (!object) {
console.warn(`Object not found: ${path}`);
return false;
}
for (const [param, value] of Object.entries(changes)) {
const setter = TRANSFORM_PARAM_MAP[param];
if (setter) {
try {
setter(object, value);
} catch (err) {
console.error(`Failed to apply transform param ${param}:`, err);
}
} else {
console.warn(`Unknown transform parameter: ${param}`);
}
}
return true;
}
/**
* Get statistics about registered objects
*/
getStats() {
return {
materials: this.pathToMaterial.size,
lights: this.pathToLight.size,
cameras: this.pathToCamera.size,
objects: this.pathToObject.size
};
}
}
export default ParameterSync;

View File

@@ -0,0 +1,337 @@
// Connection Manager for Blender Bridge WebSocket Server
// Handles session management, heartbeat, and client grouping
import { v4 as uuidv4 } from 'uuid';
import { createPingMessage, createPongMessage, decodeMessage, MessageType } from './message-protocol.js';
/**
* Client types
*/
export const ClientType = {
BLENDER: 'blender',
BROWSER: 'browser'
};
/**
* Connection info for a single client
*/
class ClientConnection {
constructor(ws, sessionId, clientType) {
this.ws = ws;
this.sessionId = sessionId;
this.clientType = clientType;
this.connectedAt = Date.now();
this.lastPing = Date.now();
this.lastPong = Date.now();
this.isAlive = true;
this.metadata = {};
}
}
/**
* Session groups Blender source with browser viewers
*/
class Session {
constructor(sessionId) {
this.sessionId = sessionId;
this.blenderClient = null;
this.browserClients = new Map(); // clientId -> ClientConnection
this.sceneState = null;
this.createdAt = Date.now();
}
/**
* Broadcast message to all browser clients in this session
*/
broadcast(data, excludeClientId = null) {
for (const [clientId, client] of this.browserClients) {
if (clientId !== excludeClientId && client.ws.readyState === 1) { // WebSocket.OPEN
client.ws.send(data);
}
}
}
/**
* Get number of connected browsers
*/
get browserCount() {
return this.browserClients.size;
}
}
/**
* ConnectionManager handles all WebSocket connections
*/
export class ConnectionManager {
constructor(options = {}) {
this.sessions = new Map(); // sessionId -> Session
this.clientToSession = new Map(); // clientId -> sessionId
// Heartbeat configuration
this.pingInterval = options.pingInterval || 30000; // 30 seconds
this.pingTimeout = options.pingTimeout || 10000; // 10 seconds timeout
this._heartbeatTimer = null;
}
/**
* Start heartbeat monitoring
*/
startHeartbeat() {
if (this._heartbeatTimer) return;
this._heartbeatTimer = setInterval(() => {
this._checkHeartbeats();
}, this.pingInterval);
}
/**
* Stop heartbeat monitoring
*/
stopHeartbeat() {
if (this._heartbeatTimer) {
clearInterval(this._heartbeatTimer);
this._heartbeatTimer = null;
}
}
/**
* Check all connections and send pings
*/
_checkHeartbeats() {
const now = Date.now();
for (const session of this.sessions.values()) {
// Check Blender client
if (session.blenderClient) {
this._pingClient(session.blenderClient, now);
}
// Check browser clients
for (const client of session.browserClients.values()) {
this._pingClient(client, now);
}
}
}
/**
* Ping a single client
*/
_pingClient(client, now) {
if (!client.isAlive) {
// Client didn't respond to last ping, terminate
console.log(`Client ${client.sessionId} timed out, closing connection`);
client.ws.terminate();
return;
}
// Mark as not alive until pong received
client.isAlive = false;
client.lastPing = now;
// Send ping
if (client.ws.readyState === 1) { // WebSocket.OPEN
try {
const pingMsg = createPingMessage();
client.ws.send(pingMsg);
} catch (err) {
console.error(`Failed to ping client: ${err.message}`);
}
}
}
/**
* Handle pong response from client
*/
handlePong(clientId) {
const sessionId = this.clientToSession.get(clientId);
if (!sessionId) return;
const session = this.sessions.get(sessionId);
if (!session) return;
// Find client and mark as alive
let client = null;
if (session.blenderClient && session.blenderClient.sessionId === clientId) {
client = session.blenderClient;
} else {
client = session.browserClients.get(clientId);
}
if (client) {
client.isAlive = true;
client.lastPong = Date.now();
}
}
/**
* Register a new Blender client (creates a new session)
*
* @param {WebSocket} ws - WebSocket connection
* @param {Object} metadata - Connection metadata
* @returns {string} Session ID
*/
registerBlender(ws, metadata = {}) {
const sessionId = uuidv4();
const clientId = sessionId; // Blender client ID is same as session ID
const session = new Session(sessionId);
const client = new ClientConnection(ws, clientId, ClientType.BLENDER);
client.metadata = metadata;
session.blenderClient = client;
this.sessions.set(sessionId, session);
this.clientToSession.set(clientId, sessionId);
console.log(`Blender client registered: session=${sessionId}`);
return sessionId;
}
/**
* Register a browser client to an existing session
*
* @param {WebSocket} ws - WebSocket connection
* @param {string} sessionId - Session to join
* @param {Object} metadata - Connection metadata
* @returns {string|null} Client ID or null if session not found
*/
registerBrowser(ws, sessionId, metadata = {}) {
const session = this.sessions.get(sessionId);
if (!session) {
console.log(`Session not found: ${sessionId}`);
return null;
}
const clientId = uuidv4();
const client = new ClientConnection(ws, clientId, ClientType.BROWSER);
client.metadata = metadata;
session.browserClients.set(clientId, client);
this.clientToSession.set(clientId, sessionId);
console.log(`Browser client registered: session=${sessionId}, clientId=${clientId}`);
return clientId;
}
/**
* Unregister a client
*
* @param {string} clientId - Client to unregister
*/
unregister(clientId) {
const sessionId = this.clientToSession.get(clientId);
if (!sessionId) return;
const session = this.sessions.get(sessionId);
if (!session) {
this.clientToSession.delete(clientId);
return;
}
// Check if it's the Blender client
if (session.blenderClient && session.blenderClient.sessionId === clientId) {
console.log(`Blender client disconnected: session=${sessionId}`);
session.blenderClient = null;
// If no Blender and no browsers, remove session
if (session.browserClients.size === 0) {
this.sessions.delete(sessionId);
console.log(`Session removed: ${sessionId}`);
}
} else {
// It's a browser client
session.browserClients.delete(clientId);
console.log(`Browser client disconnected: session=${sessionId}, clientId=${clientId}`);
// If no Blender and no browsers, remove session
if (!session.blenderClient && session.browserClients.size === 0) {
this.sessions.delete(sessionId);
console.log(`Session removed: ${sessionId}`);
}
}
this.clientToSession.delete(clientId);
}
/**
* Get session by ID
*
* @param {string} sessionId
* @returns {Session|null}
*/
getSession(sessionId) {
return this.sessions.get(sessionId) || null;
}
/**
* Get session ID for a client
*
* @param {string} clientId
* @returns {string|null}
*/
getSessionIdForClient(clientId) {
return this.clientToSession.get(clientId) || null;
}
/**
* Broadcast from Blender to all browsers in session
*
* @param {string} sessionId
* @param {ArrayBuffer|Buffer} data
*/
broadcastToBrowsers(sessionId, data) {
const session = this.sessions.get(sessionId);
if (!session) return;
session.broadcast(data);
}
/**
* Send to Blender client in session
*
* @param {string} sessionId
* @param {ArrayBuffer|Buffer} data
*/
sendToBlender(sessionId, data) {
const session = this.sessions.get(sessionId);
if (!session || !session.blenderClient) return;
if (session.blenderClient.ws.readyState === 1) {
session.blenderClient.ws.send(data);
}
}
/**
* Get all session IDs
*
* @returns {string[]}
*/
getAllSessionIds() {
return Array.from(this.sessions.keys());
}
/**
* Get session statistics
*
* @returns {Object}
*/
getStats() {
const stats = {
totalSessions: this.sessions.size,
totalClients: this.clientToSession.size,
sessions: []
};
for (const [sessionId, session] of this.sessions) {
stats.sessions.push({
sessionId,
hasBlender: !!session.blenderClient,
browserCount: session.browserClients.size,
createdAt: session.createdAt
});
}
return stats;
}
}
export default ConnectionManager;

View File

@@ -0,0 +1,253 @@
// Message Protocol for Blender Bridge
// Supports hybrid JSON header + binary payload format
import { v4 as uuidv4 } from 'uuid';
/**
* Message Types
*/
export const MessageType = {
// Blender -> Browser
SCENE_UPLOAD: 'scene_upload',
PARAMETER_UPDATE: 'parameter_update',
// Browser -> Blender
ACK: 'ack',
STATUS: 'status',
ERROR: 'error',
// Bidirectional
PING: 'ping',
PONG: 'pong'
};
/**
* Parameter Target Types
*/
export const TargetType = {
MATERIAL: 'material',
LIGHT: 'light',
CAMERA: 'camera',
TRANSFORM: 'transform'
};
/**
* Encode a message with optional binary payload
* Format: [4 bytes header length (LE)] + [JSON header] + [binary payload]
*
* @param {Object} header - JSON header object
* @param {Uint8Array|ArrayBuffer} [payload] - Optional binary payload
* @returns {ArrayBuffer} Encoded message
*/
export function encodeMessage(header, payload = null) {
// Ensure messageId and timestamp
if (!header.messageId) {
header.messageId = uuidv4();
}
if (!header.timestamp) {
header.timestamp = Date.now();
}
const headerJson = JSON.stringify(header);
const headerBytes = new TextEncoder().encode(headerJson);
const headerLength = headerBytes.length;
const payloadBytes = payload ?
(payload instanceof Uint8Array ? payload : new Uint8Array(payload)) :
new Uint8Array(0);
// Total size: 4 bytes (header length) + header + payload
const totalSize = 4 + headerLength + payloadBytes.length;
const result = new ArrayBuffer(totalSize);
const view = new DataView(result);
// Write header length (little-endian)
view.setUint32(0, headerLength, true);
// Write header
new Uint8Array(result, 4, headerLength).set(headerBytes);
// Write payload
if (payloadBytes.length > 0) {
new Uint8Array(result, 4 + headerLength).set(payloadBytes);
}
return result;
}
/**
* Decode a message with optional binary payload
*
* @param {ArrayBuffer|Buffer} data - Raw message data
* @returns {{ header: Object, payload: Uint8Array|null }} Decoded message
*/
export function decodeMessage(data) {
// Convert Buffer to ArrayBuffer if needed (Node.js)
let buffer;
if (data instanceof ArrayBuffer) {
buffer = data;
} else if (Buffer.isBuffer(data)) {
buffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
} else {
throw new Error('Invalid message data type');
}
const view = new DataView(buffer);
// Read header length (little-endian)
const headerLength = view.getUint32(0, true);
// Read header
const headerBytes = new Uint8Array(buffer, 4, headerLength);
const headerJson = new TextDecoder().decode(headerBytes);
const header = JSON.parse(headerJson);
// Read payload if present
const payloadOffset = 4 + headerLength;
const payloadLength = buffer.byteLength - payloadOffset;
const payload = payloadLength > 0 ?
new Uint8Array(buffer, payloadOffset, payloadLength) :
null;
return { header, payload };
}
/**
* Create a scene upload message
*
* @param {Uint8Array|ArrayBuffer} usdzData - USDZ binary data
* @param {Object} options - Scene options
* @returns {ArrayBuffer} Encoded message
*/
export function createSceneUploadMessage(usdzData, options = {}) {
const payload = usdzData instanceof Uint8Array ? usdzData : new Uint8Array(usdzData);
const header = {
type: MessageType.SCENE_UPLOAD,
scene: {
name: options.name || 'BlenderScene',
format: 'usdz',
byteLength: payload.length,
hasAnimation: options.hasAnimation || false,
exportOptions: {
materialx: options.materialx !== false,
rootPrimPath: options.rootPrimPath || '/World'
}
},
metadata: {
blenderVersion: options.blenderVersion || 'unknown',
exportPlugin: 'native',
upAxis: options.upAxis || 'Z'
}
};
return encodeMessage(header, payload);
}
/**
* Create a parameter update message
*
* @param {string} targetType - One of TargetType values
* @param {string} path - USD prim path
* @param {Object} changes - Changed parameters
* @param {Object} [extra] - Extra target info (e.g., lightType, name)
* @returns {ArrayBuffer} Encoded message
*/
export function createParameterUpdateMessage(targetType, path, changes, extra = {}) {
const header = {
type: MessageType.PARAMETER_UPDATE,
target: {
type: targetType,
path: path,
...extra
},
changes: changes
};
return encodeMessage(header);
}
/**
* Create an acknowledgment message
*
* @param {string} refMessageId - ID of the message being acknowledged
* @param {string} status - 'success' or 'failed'
* @param {Object} [extra] - Extra info (e.g., renderTime)
* @returns {ArrayBuffer} Encoded message
*/
export function createAckMessage(refMessageId, status = 'success', extra = {}) {
const header = {
type: MessageType.ACK,
refMessageId: refMessageId,
status: status,
...extra
};
return encodeMessage(header);
}
/**
* Create a status message
*
* @param {Object} viewerState - Viewer state info
* @returns {ArrayBuffer} Encoded message
*/
export function createStatusMessage(viewerState) {
const header = {
type: MessageType.STATUS,
viewer: viewerState
};
return encodeMessage(header);
}
/**
* Create an error message
*
* @param {string} refMessageId - ID of the message that caused the error
* @param {string} code - Error code
* @param {string} message - Error message
* @param {Object} [details] - Additional error details
* @returns {ArrayBuffer} Encoded message
*/
export function createErrorMessage(refMessageId, code, message, details = {}) {
const header = {
type: MessageType.ERROR,
refMessageId: refMessageId,
error: {
code: code,
message: message,
details: details
}
};
return encodeMessage(header);
}
/**
* Create ping/pong messages for heartbeat
*/
export function createPingMessage() {
return encodeMessage({ type: MessageType.PING });
}
export function createPongMessage(refMessageId) {
return encodeMessage({ type: MessageType.PONG, refMessageId });
}
// Export utility for browser compatibility
export const MessageProtocol = {
MessageType,
TargetType,
encode: encodeMessage,
decode: decodeMessage,
createSceneUpload: createSceneUploadMessage,
createParameterUpdate: createParameterUpdateMessage,
createAck: createAckMessage,
createStatus: createStatusMessage,
createError: createErrorMessage,
createPing: createPingMessage,
createPong: createPongMessage
};
export default MessageProtocol;

View File

@@ -0,0 +1,276 @@
// Scene State Manager for Blender Bridge
// Tracks server-side scene state for delta detection and state sync
/**
* SceneState maintains the current state of a scene
* for computing deltas and syncing new browser clients
*/
export class SceneState {
constructor() {
// Last uploaded scene binary info
this.scene = null;
// Current parameter values by path
this.materials = new Map(); // path -> material params
this.lights = new Map(); // path -> light params
this.cameras = new Map(); // path -> camera params
this.transforms = new Map(); // path -> transform params
// Scene metadata
this.metadata = {
name: null,
format: null,
uploadedAt: null,
blenderVersion: null
};
// For tracking changes
this.lastUpdateTime = null;
}
/**
* Update scene data (full scene upload)
*
* @param {Object} sceneInfo - Scene info from message header
* @param {Uint8Array} binaryData - USDZ binary data
*/
setScene(sceneInfo, binaryData) {
this.scene = {
data: binaryData,
byteLength: binaryData.length
};
this.metadata.name = sceneInfo.name;
this.metadata.format = sceneInfo.format;
this.metadata.uploadedAt = Date.now();
// Clear parameter caches on new scene
this.materials.clear();
this.lights.clear();
this.cameras.clear();
this.transforms.clear();
this.lastUpdateTime = Date.now();
}
/**
* Update material parameters
*
* @param {string} path - Material path
* @param {Object} changes - Changed parameters
* @returns {Object} Delta (only actually changed values)
*/
updateMaterial(path, changes) {
const current = this.materials.get(path) || {};
const delta = {};
for (const [key, value] of Object.entries(changes)) {
if (!deepEqual(current[key], value)) {
delta[key] = value;
current[key] = value;
}
}
this.materials.set(path, current);
this.lastUpdateTime = Date.now();
return delta;
}
/**
* Update light parameters
*
* @param {string} path - Light path
* @param {Object} changes - Changed parameters
* @returns {Object} Delta
*/
updateLight(path, changes) {
const current = this.lights.get(path) || {};
const delta = {};
for (const [key, value] of Object.entries(changes)) {
if (!deepEqual(current[key], value)) {
delta[key] = value;
current[key] = value;
}
}
this.lights.set(path, current);
this.lastUpdateTime = Date.now();
return delta;
}
/**
* Update camera parameters
*
* @param {string} path - Camera path
* @param {Object} changes - Changed parameters
* @returns {Object} Delta
*/
updateCamera(path, changes) {
const current = this.cameras.get(path) || {};
const delta = {};
for (const [key, value] of Object.entries(changes)) {
if (!deepEqual(current[key], value)) {
delta[key] = value;
current[key] = value;
}
}
this.cameras.set(path, current);
this.lastUpdateTime = Date.now();
return delta;
}
/**
* Update transform parameters
*
* @param {string} path - Object path
* @param {Object} changes - Changed parameters
* @returns {Object} Delta
*/
updateTransform(path, changes) {
const current = this.transforms.get(path) || {};
const delta = {};
for (const [key, value] of Object.entries(changes)) {
if (!deepEqual(current[key], value)) {
delta[key] = value;
current[key] = value;
}
}
this.transforms.set(path, current);
this.lastUpdateTime = Date.now();
return delta;
}
/**
* Get current state for a material
*
* @param {string} path
* @returns {Object|null}
*/
getMaterial(path) {
return this.materials.get(path) || null;
}
/**
* Get current state for a light
*
* @param {string} path
* @returns {Object|null}
*/
getLight(path) {
return this.lights.get(path) || null;
}
/**
* Get current state for a camera
*
* @param {string} path
* @returns {Object|null}
*/
getCamera(path) {
return this.cameras.get(path) || null;
}
/**
* Get current state for a transform
*
* @param {string} path
* @returns {Object|null}
*/
getTransform(path) {
return this.transforms.get(path) || null;
}
/**
* Check if scene data is available
*
* @returns {boolean}
*/
hasScene() {
return this.scene !== null;
}
/**
* Get scene data for sending to new browser clients
*
* @returns {{ sceneInfo: Object, binaryData: Uint8Array }|null}
*/
getSceneForSync() {
if (!this.scene) return null;
return {
sceneInfo: {
name: this.metadata.name,
format: this.metadata.format,
byteLength: this.scene.byteLength
},
binaryData: this.scene.data
};
}
/**
* Get all current parameter states for syncing a new client
*
* @returns {Object}
*/
getAllParametersForSync() {
return {
materials: Object.fromEntries(this.materials),
lights: Object.fromEntries(this.lights),
cameras: Object.fromEntries(this.cameras),
transforms: Object.fromEntries(this.transforms)
};
}
/**
* Clear all state
*/
clear() {
this.scene = null;
this.materials.clear();
this.lights.clear();
this.cameras.clear();
this.transforms.clear();
this.metadata = {
name: null,
format: null,
uploadedAt: null,
blenderVersion: null
};
this.lastUpdateTime = null;
}
}
/**
* Deep equality check for arrays and objects
*/
function deepEqual(a, b) {
if (a === b) return true;
if (a == null || b == null) return false;
if (typeof a !== typeof b) return false;
if (Array.isArray(a)) {
if (!Array.isArray(b)) return false;
if (a.length !== b.length) return false;
return a.every((val, i) => deepEqual(val, b[i]));
}
if (typeof a === 'object') {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every(key => deepEqual(a[key], b[key]));
}
return false;
}
export default SceneState;

1800
web/blender-bridge/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"name": "blender-bridge",
"version": "0.1.0",
"description": "WebSocket bridge for streaming Blender scenes to browser via TinyUSDZ",
"type": "module",
"main": "server.js",
"scripts": {
"server": "node server.js",
"dev": "vite viewer",
"build": "vite build viewer"
},
"dependencies": {
"express": "^4.21.0",
"three": "^0.182.0",
"uuid": "^9.0.1",
"ws": "^8.18.0"
},
"devDependencies": {
"vite": "^5.4.0"
}
}

View File

@@ -0,0 +1,47 @@
// Send camera info to connected browsers
import { WebSocket } from 'ws';
import crypto from 'crypto';
const sessionId = process.argv[2];
const cameraJson = process.argv[3];
if (!sessionId || !cameraJson) {
console.log('Usage: node send-camera.js <session-id> <camera-json>');
process.exit(1);
}
const camera = JSON.parse(cameraJson);
const ws = new WebSocket(`ws://localhost:8090?type=blender&session=${sessionId}`);
ws.on('open', () => {
// Send camera parameter update
const msgHeader = {
type: 'parameter_update',
messageId: crypto.randomUUID(),
timestamp: Date.now(),
target: {
type: 'camera',
path: '/BlenderViewport'
},
changes: {
position: camera.position,
target: camera.target,
fov: camera.lens ? (2 * Math.atan(18 / camera.lens) * 180 / Math.PI) : 45
}
};
const headerJson = JSON.stringify(msgHeader);
const headerBytes = Buffer.from(headerJson);
const msg = Buffer.alloc(4 + headerBytes.length);
msg.writeUInt32LE(headerBytes.length, 0);
headerBytes.copy(msg, 4);
ws.send(msg);
console.log('Camera update sent:', msgHeader.changes);
setTimeout(() => ws.close(), 500);
});
ws.on('error', (err) => console.error('Error:', err.message));
ws.on('close', () => process.exit(0));

View File

@@ -0,0 +1,374 @@
// Blender Bridge WebSocket Server
// Streams Blender scenes to browser viewers via TinyUSDZ
import { WebSocketServer } from 'ws';
import express from 'express';
import { createServer } from 'http';
import { ConnectionManager, ClientType } from './lib/connection-manager.js';
import { SceneState } from './lib/scene-state.js';
import {
decodeMessage,
encodeMessage,
createSceneUploadMessage,
createParameterUpdateMessage,
createAckMessage,
createErrorMessage,
createPongMessage,
MessageType,
TargetType
} from './lib/message-protocol.js';
// Configuration
const PORT = process.env.BLENDER_BRIDGE_PORT || 8090;
const HOST = process.env.BLENDER_BRIDGE_HOST || '0.0.0.0';
// Initialize managers
const connectionManager = new ConnectionManager();
const sceneStates = new Map(); // sessionId -> SceneState
// Express app for HTTP endpoints
const app = express();
app.use(express.json({ limit: '50mb' }));
// CORS headers
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
next();
});
// HTTP endpoint: Get server status
app.get('/status', (req, res) => {
res.json({
status: 'running',
...connectionManager.getStats()
});
});
// HTTP endpoint: List sessions
app.get('/sessions', (req, res) => {
const sessions = connectionManager.getAllSessionIds().map(sessionId => {
const session = connectionManager.getSession(sessionId);
const sceneState = sceneStates.get(sessionId);
return {
sessionId,
hasBlender: !!session?.blenderClient,
browserCount: session?.browserCount || 0,
hasScene: sceneState?.hasScene() || false,
sceneName: sceneState?.metadata?.name || null
};
});
res.json({ sessions });
});
// HTTP endpoint: Upload scene (alternative to WebSocket for large files)
app.post('/upload/:sessionId', (req, res) => {
const { sessionId } = req.params;
const session = connectionManager.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
// Handle base64 encoded USDZ
const { data, options } = req.body;
if (!data) {
return res.status(400).json({ error: 'Missing data field' });
}
try {
const binaryData = Buffer.from(data, 'base64');
handleSceneUpload(sessionId, { scene: options || {} }, binaryData);
res.json({ success: true, byteLength: binaryData.length });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Create HTTP server
const server = createServer(app);
// Create WebSocket server
const wss = new WebSocketServer({ server });
console.log(`Blender Bridge Server starting on ${HOST}:${PORT}`);
// WebSocket connection handler
wss.on('connection', (ws, req) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const clientType = url.searchParams.get('type') || 'browser';
const sessionId = url.searchParams.get('session');
let clientId = null;
let assignedSessionId = null;
if (clientType === 'blender') {
// Blender client - join existing session or create new one
if (sessionId) {
// Join existing session as Blender client
const session = connectionManager.getSession(sessionId);
if (session) {
assignedSessionId = sessionId;
clientId = sessionId;
session.blender = ws;
console.log(`Blender joined existing session: ${sessionId}`);
} else {
// Session doesn't exist, close connection
ws.close(4002, 'Session not found');
return;
}
} else {
// Create new session
assignedSessionId = connectionManager.registerBlender(ws, {
userAgent: req.headers['user-agent']
});
clientId = assignedSessionId;
// Initialize scene state for this session
sceneStates.set(assignedSessionId, new SceneState());
// Send session ID to Blender
ws.send(encodeMessage({
type: 'session_created',
sessionId: assignedSessionId
}));
}
console.log(`Blender connected: session=${assignedSessionId}`);
} else {
// Browser client joins existing session
if (!sessionId) {
ws.close(4001, 'Missing session parameter');
return;
}
clientId = connectionManager.registerBrowser(ws, sessionId, {
userAgent: req.headers['user-agent']
});
if (!clientId) {
ws.close(4002, 'Session not found');
return;
}
assignedSessionId = sessionId;
console.log(`Browser connected: session=${sessionId}, client=${clientId}`);
// Sync current scene state to new browser
syncSceneToClient(ws, sessionId);
}
// Message handler
ws.on('message', (data, isBinary) => {
try {
const { header, payload } = decodeMessage(data);
handleMessage(ws, clientId, assignedSessionId, clientType, header, payload);
} catch (err) {
console.error(`Message decode error: ${err.message}`);
ws.send(createErrorMessage(null, 'DECODE_ERROR', err.message));
}
});
// Close handler
ws.on('close', (code, reason) => {
console.log(`Client disconnected: ${clientId}, code=${code}`);
connectionManager.unregister(clientId);
// Clean up scene state if session is removed
const session = connectionManager.getSession(assignedSessionId);
if (!session) {
sceneStates.delete(assignedSessionId);
}
});
// Error handler
ws.on('error', (err) => {
console.error(`WebSocket error for ${clientId}: ${err.message}`);
});
});
/**
* Handle incoming message
*/
function handleMessage(ws, clientId, sessionId, clientType, header, payload) {
const messageType = header.type;
switch (messageType) {
case MessageType.SCENE_UPLOAD:
if (clientType === 'blender') {
handleSceneUpload(sessionId, header, payload);
ws.send(createAckMessage(header.messageId, 'success'));
}
break;
case MessageType.PARAMETER_UPDATE:
if (clientType === 'blender') {
handleParameterUpdate(sessionId, header);
ws.send(createAckMessage(header.messageId, 'success'));
}
break;
case MessageType.ACK:
// Browser acknowledged a message
console.log(`ACK received from ${clientId}: ref=${header.refMessageId}, status=${header.status}`);
break;
case MessageType.STATUS:
// Browser status update
console.log(`Status from ${clientId}:`, header.viewer);
// Forward to Blender if needed
if (header.forwardToBlender) {
connectionManager.sendToBlender(sessionId, encodeMessage(header));
}
break;
case MessageType.ERROR:
console.error(`Error from ${clientId}:`, header.error);
// Forward to Blender
connectionManager.sendToBlender(sessionId, encodeMessage(header));
break;
case MessageType.PING:
ws.send(createPongMessage(header.messageId));
break;
case MessageType.PONG:
connectionManager.handlePong(clientId);
break;
default:
console.warn(`Unknown message type: ${messageType}`);
}
}
/**
* Handle scene upload from Blender
*/
function handleSceneUpload(sessionId, header, payload) {
console.log(`Scene upload: session=${sessionId}, size=${payload.length}`);
// Store scene state
const sceneState = sceneStates.get(sessionId);
if (sceneState) {
sceneState.setScene(header.scene, payload);
}
// Create message with binary payload
const message = createSceneUploadMessage(payload, {
name: header.scene?.name,
hasAnimation: header.scene?.hasAnimation,
materialx: header.scene?.exportOptions?.materialx,
rootPrimPath: header.scene?.exportOptions?.rootPrimPath,
blenderVersion: header.metadata?.blenderVersion,
upAxis: header.metadata?.upAxis
});
// Broadcast to all browsers
connectionManager.broadcastToBrowsers(sessionId, message);
}
/**
* Handle parameter update from Blender
*/
function handleParameterUpdate(sessionId, header) {
const { target, changes } = header;
console.log(`Parameter update: session=${sessionId}, target=${target.path}, type=${target.type}`);
// Update scene state and compute delta
const sceneState = sceneStates.get(sessionId);
let delta = changes;
if (sceneState) {
switch (target.type) {
case TargetType.MATERIAL:
delta = sceneState.updateMaterial(target.path, changes);
break;
case TargetType.LIGHT:
delta = sceneState.updateLight(target.path, changes);
break;
case TargetType.CAMERA:
delta = sceneState.updateCamera(target.path, changes);
break;
case TargetType.TRANSFORM:
delta = sceneState.updateTransform(target.path, changes);
break;
}
}
// Only broadcast if there are actual changes
if (Object.keys(delta).length > 0) {
const message = createParameterUpdateMessage(target.type, target.path, delta, {
name: target.name,
lightType: target.lightType
});
connectionManager.broadcastToBrowsers(sessionId, message);
}
}
/**
* Sync scene state to a newly connected browser
*/
function syncSceneToClient(ws, sessionId) {
const sceneState = sceneStates.get(sessionId);
if (!sceneState || !sceneState.hasScene()) {
console.log(`No scene to sync for session ${sessionId}`);
return;
}
console.log(`Syncing scene to new browser client: session=${sessionId}`);
// Send scene
const { sceneInfo, binaryData } = sceneState.getSceneForSync();
const sceneMessage = createSceneUploadMessage(binaryData, sceneInfo);
ws.send(sceneMessage);
// Send all parameter updates
const params = sceneState.getAllParametersForSync();
// Sync materials
for (const [path, changes] of Object.entries(params.materials)) {
const msg = createParameterUpdateMessage(TargetType.MATERIAL, path, changes);
ws.send(msg);
}
// Sync lights
for (const [path, changes] of Object.entries(params.lights)) {
const msg = createParameterUpdateMessage(TargetType.LIGHT, path, changes);
ws.send(msg);
}
// Sync cameras
for (const [path, changes] of Object.entries(params.cameras)) {
const msg = createParameterUpdateMessage(TargetType.CAMERA, path, changes);
ws.send(msg);
}
// Sync transforms
for (const [path, changes] of Object.entries(params.transforms)) {
const msg = createParameterUpdateMessage(TargetType.TRANSFORM, path, changes);
ws.send(msg);
}
}
// Start server
server.listen(PORT, HOST, () => {
console.log(`Blender Bridge Server running at http://${HOST}:${PORT}`);
console.log(`WebSocket endpoint: ws://${HOST}:${PORT}`);
console.log(`HTTP status: http://${HOST}:${PORT}/status`);
// Start heartbeat monitoring
connectionManager.startHeartbeat();
});
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\nShutting down...');
connectionManager.stopHeartbeat();
wss.close(() => {
server.close(() => {
console.log('Server closed');
process.exit(0);
});
});
});

View File

@@ -0,0 +1,69 @@
// Test client for Blender Bridge
import { WebSocket } from 'ws';
import fs from 'fs';
import crypto from 'crypto';
const USDZ_PATH = '/tmp/blender_bridge_test.usdz';
const ws = new WebSocket('ws://localhost:8090?type=blender');
let sessionId = null;
ws.on('open', () => console.log('Blender client connected'));
ws.on('message', (data) => {
const buffer = data instanceof Buffer ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) : data;
const view = new DataView(buffer);
const headerLength = view.getUint32(0, true);
const header = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, 4, headerLength)));
if (header.type === 'session_created') {
sessionId = header.sessionId;
console.log('');
console.log('========================================');
console.log('SESSION ID:', sessionId);
console.log('========================================');
console.log('');
console.log('Open browser: http://localhost:5173');
console.log('Enter Session ID:', sessionId);
console.log('');
// Send scene
const usdzData = fs.readFileSync(USDZ_PATH);
const msgHeader = {
type: 'scene_upload',
messageId: crypto.randomUUID(),
timestamp: Date.now(),
scene: { name: 'BlenderScene', format: 'usdz', byteLength: usdzData.length },
metadata: { blenderVersion: '5.0.1' }
};
const hdrBytes = Buffer.from(JSON.stringify(msgHeader));
const result = Buffer.alloc(4 + hdrBytes.length + usdzData.length);
result.writeUInt32LE(hdrBytes.length, 0);
hdrBytes.copy(result, 4);
usdzData.copy(result, 4 + hdrBytes.length);
ws.send(result);
console.log('Scene uploaded (' + usdzData.length + ' bytes)');
} else if (header.type === 'ping') {
const pong = JSON.stringify({
type: 'pong',
refMessageId: header.messageId,
messageId: crypto.randomUUID(),
timestamp: Date.now()
});
const pongBytes = Buffer.from(pong);
const pongMsg = Buffer.alloc(4 + pongBytes.length);
pongMsg.writeUInt32LE(pongBytes.length, 0);
pongBytes.copy(pongMsg, 4);
ws.send(pongMsg);
} else if (header.type === 'ack') {
console.log('ACK:', header.status);
} else if (header.type === 'status') {
console.log('Browser status:', JSON.stringify(header.viewer));
}
});
ws.on('error', (err) => console.error('Error:', err.message));
ws.on('close', () => { console.log('Disconnected'); process.exit(0); });
// Keep alive for 10 minutes
setTimeout(() => { console.log('Timeout, closing...'); ws.close(); }, 600000);
console.log('Test client will stay connected for 10 minutes...');

View File

@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blender Bridge Viewer</title>
<link rel="stylesheet" href="viewer.css">
</head>
<body>
<div id="app">
<!-- Connection panel -->
<div id="connection-panel" class="panel">
<h2>Blender Bridge</h2>
<div class="form-group">
<label for="server-url">Server URL</label>
<input type="text" id="server-url" value="ws://localhost:8090" />
</div>
<div class="form-group">
<label for="session-id">Session ID</label>
<input type="text" id="session-id" placeholder="Enter session ID from Blender" />
</div>
<button id="connect-btn" class="btn primary">Connect</button>
<button id="disconnect-btn" class="btn" disabled>Disconnect</button>
<div id="connection-status" class="status disconnected">Disconnected</div>
</div>
<!-- Stats panel -->
<div id="stats-panel" class="panel hidden">
<h3>Scene Info</h3>
<div id="scene-info">
<div class="stat-row">
<span class="stat-label">Status:</span>
<span id="scene-status" class="stat-value">No scene</span>
</div>
<div class="stat-row">
<span class="stat-label">Meshes:</span>
<span id="mesh-count" class="stat-value">0</span>
</div>
<div class="stat-row">
<span class="stat-label">Materials:</span>
<span id="material-count" class="stat-value">0</span>
</div>
<div class="stat-row">
<span class="stat-label">Lights:</span>
<span id="light-count" class="stat-value">0</span>
</div>
<div class="stat-row">
<span class="stat-label">FPS:</span>
<span id="fps-value" class="stat-value">0</span>
</div>
</div>
<div class="button-row">
<button id="fit-scene-btn" class="btn">Fit to Scene</button>
</div>
</div>
<!-- Loading indicator -->
<div id="loading-overlay" class="hidden">
<div class="loading-spinner"></div>
<div id="loading-message">Loading scene...</div>
</div>
<!-- Three.js canvas container -->
<div id="canvas-container"></div>
<!-- Message log -->
<div id="message-log" class="panel hidden">
<h3>Messages</h3>
<div id="log-content"></div>
</div>
</div>
<script type="module" src="viewer.js"></script>
</body>
</html>

View File

@@ -0,0 +1,275 @@
/* Blender Bridge Viewer Styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #1a1a1a;
color: #e0e0e0;
overflow: hidden;
}
#app {
width: 100vw;
height: 100vh;
position: relative;
}
#canvas-container {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
#canvas-container canvas {
display: block;
}
/* Panels */
.panel {
position: absolute;
z-index: 100;
background: rgba(30, 30, 30, 0.95);
border: 1px solid #404040;
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.panel h2 {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
color: #fff;
}
.panel h3 {
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
color: #aaa;
}
/* Connection panel */
#connection-panel {
top: 20px;
left: 20px;
width: 280px;
}
.form-group {
margin-bottom: 12px;
}
.form-group label {
display: block;
font-size: 12px;
color: #888;
margin-bottom: 4px;
}
.form-group input {
width: 100%;
padding: 8px 10px;
font-size: 13px;
background: #2a2a2a;
border: 1px solid #404040;
border-radius: 4px;
color: #e0e0e0;
outline: none;
transition: border-color 0.2s;
}
.form-group input:focus {
border-color: #5c9aff;
}
.form-group input::placeholder {
color: #666;
}
/* Buttons */
.btn {
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
border: 1px solid #404040;
border-radius: 4px;
background: #2a2a2a;
color: #e0e0e0;
cursor: pointer;
transition: all 0.2s;
margin-right: 8px;
}
.btn:hover:not(:disabled) {
background: #3a3a3a;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn.primary {
background: #2d5a8a;
border-color: #3d7ab0;
color: #fff;
}
.btn.primary:hover:not(:disabled) {
background: #3d7ab0;
}
/* Status indicator */
.status {
margin-top: 12px;
padding: 6px 10px;
font-size: 12px;
border-radius: 4px;
text-align: center;
}
.status.connected {
background: rgba(46, 160, 67, 0.2);
color: #4ade80;
border: 1px solid rgba(46, 160, 67, 0.4);
}
.status.disconnected {
background: rgba(248, 81, 73, 0.2);
color: #f87171;
border: 1px solid rgba(248, 81, 73, 0.4);
}
.status.connecting {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
border: 1px solid rgba(251, 191, 36, 0.4);
}
/* Stats panel */
#stats-panel {
top: 20px;
right: 20px;
width: 200px;
}
.stat-row {
display: flex;
justify-content: space-between;
padding: 4px 0;
font-size: 13px;
}
.stat-label {
color: #888;
}
.stat-value {
color: #e0e0e0;
font-weight: 500;
}
.button-row {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #404040;
}
.button-row .btn {
width: 100%;
margin-right: 0;
}
/* Loading overlay */
#loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(26, 26, 26, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 200;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 3px solid #404040;
border-top-color: #5c9aff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
#loading-message {
margin-top: 16px;
font-size: 14px;
color: #888;
}
/* Message log */
#message-log {
bottom: 20px;
left: 20px;
width: 400px;
max-height: 200px;
}
#log-content {
max-height: 150px;
overflow-y: auto;
font-family: monospace;
font-size: 11px;
line-height: 1.5;
}
#log-content .log-entry {
padding: 2px 0;
border-bottom: 1px solid #333;
}
#log-content .log-entry.info { color: #8b8b8b; }
#log-content .log-entry.success { color: #4ade80; }
#log-content .log-entry.warning { color: #fbbf24; }
#log-content .log-entry.error { color: #f87171; }
/* Utility classes */
.hidden {
display: none !important;
}
/* Responsive adjustments */
@media (max-width: 768px) {
#connection-panel {
width: calc(100% - 40px);
left: 20px;
right: 20px;
}
#stats-panel {
top: auto;
bottom: 20px;
right: 20px;
width: 180px;
}
#message-log {
display: none;
}
}

View File

@@ -0,0 +1,614 @@
// Blender Bridge Viewer
// Three.js viewer with TinyUSDZ WASM integration and WebSocket bridge
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js';
import { TinyUSDZLoader } from './tinyusdz/TinyUSDZLoader.js';
import { TinyUSDZLoaderUtils } from './tinyusdz/TinyUSDZLoaderUtils.js';
import { BridgeClient } from './client/bridge-client.js';
import { ParameterSync } from './client/parameter-sync.js';
// ============================================================================
// Constants
// ============================================================================
const DEFAULT_BACKGROUND_COLOR = 0x1a1a1a;
const CAMERA_PADDING = 1.2;
// ============================================================================
// Main Application
// ============================================================================
class BlenderBridgeViewer {
constructor() {
// Three.js state
this.scene = null;
this.camera = null;
this.renderer = null;
this.controls = null;
this.pmremGenerator = null;
// TinyUSDZ loader
this.loader = null;
this.loaderReady = false;
// Scene content
this.sceneRoot = null;
this.envMap = null;
// Bridge components
this.bridgeClient = null;
this.parameterSync = null;
// Stats
this.frameCount = 0;
this.lastFpsUpdate = 0;
this.fps = 0;
// DOM elements
this.elements = {
container: document.getElementById('canvas-container'),
connectBtn: document.getElementById('connect-btn'),
disconnectBtn: document.getElementById('disconnect-btn'),
serverUrl: document.getElementById('server-url'),
sessionId: document.getElementById('session-id'),
connectionStatus: document.getElementById('connection-status'),
statsPanel: document.getElementById('stats-panel'),
loadingOverlay: document.getElementById('loading-overlay'),
loadingMessage: document.getElementById('loading-message'),
messageLog: document.getElementById('message-log'),
logContent: document.getElementById('log-content'),
sceneStatus: document.getElementById('scene-status'),
meshCount: document.getElementById('mesh-count'),
materialCount: document.getElementById('material-count'),
lightCount: document.getElementById('light-count'),
fpsValue: document.getElementById('fps-value'),
fitSceneBtn: document.getElementById('fit-scene-btn')
};
}
/**
* Initialize the viewer
*/
async init() {
this.log('Initializing viewer...', 'info');
// Initialize Three.js
this.initThreeJS();
// Initialize TinyUSDZ loader
await this.initLoader();
// Initialize bridge components
this.initBridge();
// Setup event listeners
this.setupEventListeners();
// Start render loop
this.animate();
this.log('Viewer ready', 'success');
}
/**
* Initialize Three.js scene
*/
initThreeJS() {
// Scene
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(DEFAULT_BACKGROUND_COLOR);
// Camera
const aspect = window.innerWidth / window.innerHeight;
this.camera = new THREE.PerspectiveCamera(45, aspect, 0.01, 1000);
this.camera.position.set(5, 3, 5);
// Renderer
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true
});
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.0;
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
this.elements.container.appendChild(this.renderer.domElement);
// Controls
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
// PMREM for environment maps
this.pmremGenerator = new THREE.PMREMGenerator(this.renderer);
this.pmremGenerator.compileEquirectangularShader();
// Default lighting
this.setupDefaultLighting();
// Handle resize
window.addEventListener('resize', () => this.onResize());
}
/**
* Setup default lighting
*/
setupDefaultLighting() {
// Ambient
const ambient = new THREE.AmbientLight(0xffffff, 0.3);
this.scene.add(ambient);
// Key light
const keyLight = new THREE.DirectionalLight(0xffffff, 1.0);
keyLight.position.set(5, 10, 5);
keyLight.castShadow = true;
this.scene.add(keyLight);
// Fill light
const fillLight = new THREE.DirectionalLight(0x8888ff, 0.3);
fillLight.position.set(-5, 5, -5);
this.scene.add(fillLight);
}
/**
* Initialize TinyUSDZ loader
*/
async initLoader() {
this.showLoading('Initializing TinyUSDZ WASM...');
try {
this.loader = new TinyUSDZLoader();
await this.loader.init({ useMemory64: false });
this.loaderReady = true;
this.log('TinyUSDZ loader ready', 'success');
} catch (err) {
this.log(`Failed to initialize loader: ${err.message}`, 'error');
throw err;
} finally {
this.hideLoading();
}
}
/**
* Initialize bridge components
*/
initBridge() {
this.bridgeClient = new BridgeClient();
this.parameterSync = new ParameterSync();
this.parameterSync.setControls(this.controls);
// Setup bridge event handlers
this.bridgeClient.onSceneUpload = (data) => this.handleSceneUpload(data);
this.bridgeClient.onParameterUpdate = (data) => this.handleParameterUpdate(data);
this.bridgeClient.onError = (error) => this.log(`Error: ${error.message}`, 'error');
this.bridgeClient.addEventListener('connected', () => {
this.setConnectionStatus('connected');
this.elements.statsPanel.classList.remove('hidden');
this.elements.messageLog.classList.remove('hidden');
});
this.bridgeClient.addEventListener('disconnected', () => {
this.setConnectionStatus('disconnected');
});
}
/**
* Setup UI event listeners
*/
setupEventListeners() {
this.elements.connectBtn.addEventListener('click', () => this.connect());
this.elements.disconnectBtn.addEventListener('click', () => this.disconnect());
this.elements.fitSceneBtn.addEventListener('click', () => this.fitCameraToScene());
// Allow Enter key to connect
this.elements.sessionId.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.connect();
});
}
/**
* Connect to bridge server
*/
async connect() {
const serverUrl = this.elements.serverUrl.value;
const sessionId = this.elements.sessionId.value.trim();
if (!sessionId) {
this.log('Please enter a session ID', 'warning');
return;
}
this.setConnectionStatus('connecting');
this.log(`Connecting to ${serverUrl} (session: ${sessionId})...`, 'info');
try {
this.bridgeClient.serverUrl = serverUrl;
await this.bridgeClient.connect(sessionId);
this.elements.connectBtn.disabled = true;
this.elements.disconnectBtn.disabled = false;
this.log('Connected successfully', 'success');
} catch (err) {
this.log(`Connection failed: ${err.message}`, 'error');
this.setConnectionStatus('disconnected');
}
}
/**
* Disconnect from bridge server
*/
disconnect() {
this.bridgeClient.disconnect();
this.elements.connectBtn.disabled = false;
this.elements.disconnectBtn.disabled = true;
this.log('Disconnected', 'info');
}
/**
* Handle scene upload from Blender
*/
async handleSceneUpload(data) {
this.log(`Receiving scene: ${data.scene?.name || 'unknown'} (${data.binaryData.length} bytes)`, 'info');
this.showLoading('Loading scene...');
try {
// Clear previous scene
this.clearScene();
// Parse USD data
const result = await this.parseUSD(data.binaryData);
if (result) {
this.sceneRoot = result;
this.scene.add(this.sceneRoot);
// Register objects with parameter sync
this.registerSceneObjects(this.sceneRoot);
// Fit camera to scene
this.fitCameraToScene();
// Update stats
this.updateSceneStats();
this.log('Scene loaded successfully', 'success');
this.elements.sceneStatus.textContent = 'Loaded';
}
} catch (err) {
this.log(`Failed to load scene: ${err.message}`, 'error');
this.bridgeClient.sendError('PARSE_ERROR', err.message);
} finally {
this.hideLoading();
}
}
/**
* Parse USD binary data and build Three.js scene
*/
async parseUSD(binaryData) {
// Create blob URL for loader
const blob = new Blob([binaryData], { type: 'model/vnd.usdz+zip' });
const url = URL.createObjectURL(blob);
try {
// Load USD and get native loader
const nativeLoader = await new Promise((resolve, reject) => {
this.loader.load(
url,
(usd) => {
console.log('loaded');
resolve(usd);
},
(progress) => {
if (progress.percentage !== undefined) {
this.elements.loadingMessage.textContent =
`Loading: ${Math.round(progress.percentage)}%`;
}
},
(error) => {
reject(error);
}
);
});
// Store native loader for parameter sync
this.nativeLoader = nativeLoader;
// Get USD root node
const usdRootNode = nativeLoader.getDefaultRootNode();
// Create default material
const defaultMaterial = new THREE.MeshPhysicalMaterial({
color: 0x808080,
roughness: 0.5,
metalness: 0.0
});
// Build Three.js scene from USD
const options = {
envMap: this.envMap,
envMapIntensity: 1.0,
preferredMaterialType: 'auto'
};
const root = await TinyUSDZLoaderUtils.buildThreeNode(
usdRootNode,
defaultMaterial,
nativeLoader,
options
);
return root;
} finally {
URL.revokeObjectURL(url);
}
}
/**
* Clear current scene content
*/
clearScene() {
if (this.sceneRoot) {
this.scene.remove(this.sceneRoot);
this.disposeObject(this.sceneRoot);
this.sceneRoot = null;
}
this.parameterSync.clear();
}
/**
* Recursively dispose Three.js objects
*/
disposeObject(object) {
object.traverse((child) => {
if (child.geometry) {
child.geometry.dispose();
}
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach(m => this.disposeMaterial(m));
} else {
this.disposeMaterial(child.material);
}
}
});
}
/**
* Dispose material and its textures
*/
disposeMaterial(material) {
for (const key of Object.keys(material)) {
const value = material[key];
if (value && value.isTexture) {
value.dispose();
}
}
material.dispose();
}
/**
* Register scene objects with parameter sync
*/
registerSceneObjects(root) {
root.traverse((object) => {
// Get USD path from userData if available
const path = object.userData?.usdPath || object.name;
if (object.isMesh && object.material) {
const materials = Array.isArray(object.material) ? object.material : [object.material];
materials.forEach((mat, i) => {
const matPath = mat.userData?.usdPath || `${path}/material_${i}`;
this.parameterSync.registerMaterial(matPath, mat);
});
}
if (object.isLight) {
this.parameterSync.registerLight(path, object);
}
if (object.isCamera) {
this.parameterSync.registerCamera(path, object);
}
// Register all objects for transforms
this.parameterSync.registerObject(path, object);
});
}
/**
* Handle parameter update from Blender
*/
handleParameterUpdate(data) {
const { target, changes } = data;
this.log(`Parameter update: ${target.type} ${target.path}`, 'info');
// Handle Blender viewport camera directly
if (target.type === 'camera' && target.path === '/BlenderViewport') {
this.applyBlenderCamera(changes);
return;
}
const applied = this.parameterSync.applyUpdate(data);
if (!applied) {
this.log(`Failed to apply update to ${target.path}`, 'warning');
}
}
/**
* Apply Blender viewport camera to Three.js camera
*/
applyBlenderCamera(changes) {
if (changes.position) {
// Blender uses Z-up, Three.js uses Y-up
// Swap Y and Z for coordinate conversion
this.camera.position.set(
changes.position[0],
changes.position[2],
-changes.position[1]
);
}
if (changes.target) {
this.controls.target.set(
changes.target[0],
changes.target[2],
-changes.target[1]
);
}
if (changes.fov) {
this.camera.fov = changes.fov;
this.camera.updateProjectionMatrix();
}
this.controls.update();
this.log('Applied Blender camera', 'success');
}
/**
* Fit camera to scene bounding box
*/
fitCameraToScene() {
if (!this.sceneRoot) return;
const box = new THREE.Box3().setFromObject(this.sceneRoot);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const fov = this.camera.fov * (Math.PI / 180);
const distance = (maxDim * CAMERA_PADDING) / (2 * Math.tan(fov / 2));
this.camera.position.copy(center);
this.camera.position.z += distance;
this.camera.lookAt(center);
this.controls.target.copy(center);
this.controls.update();
}
/**
* Update scene statistics display
*/
updateSceneStats() {
if (!this.sceneRoot) return;
let meshCount = 0;
let materialCount = 0;
let lightCount = 0;
const materials = new Set();
this.sceneRoot.traverse((object) => {
if (object.isMesh) {
meshCount++;
const mats = Array.isArray(object.material) ? object.material : [object.material];
mats.forEach(m => materials.add(m));
}
if (object.isLight) lightCount++;
});
materialCount = materials.size;
this.elements.meshCount.textContent = meshCount;
this.elements.materialCount.textContent = materialCount;
this.elements.lightCount.textContent = lightCount;
}
/**
* Animation loop
*/
animate() {
requestAnimationFrame(() => this.animate());
this.controls.update();
this.renderer.render(this.scene, this.camera);
// Update FPS
this.frameCount++;
const now = performance.now();
if (now - this.lastFpsUpdate >= 1000) {
this.fps = this.frameCount;
this.frameCount = 0;
this.lastFpsUpdate = now;
this.elements.fpsValue.textContent = this.fps;
}
}
/**
* Handle window resize
*/
onResize() {
const width = window.innerWidth;
const height = window.innerHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}
/**
* Set connection status UI
*/
setConnectionStatus(status) {
const el = this.elements.connectionStatus;
el.className = 'status ' + status;
switch (status) {
case 'connected':
el.textContent = 'Connected';
break;
case 'connecting':
el.textContent = 'Connecting...';
break;
case 'disconnected':
el.textContent = 'Disconnected';
break;
}
}
/**
* Show loading overlay
*/
showLoading(message) {
this.elements.loadingMessage.textContent = message;
this.elements.loadingOverlay.classList.remove('hidden');
}
/**
* Hide loading overlay
*/
hideLoading() {
this.elements.loadingOverlay.classList.add('hidden');
}
/**
* Log message to UI
*/
log(message, level = 'info') {
console.log(`[${level.toUpperCase()}] ${message}`);
const entry = document.createElement('div');
entry.className = `log-entry ${level}`;
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
this.elements.logContent.appendChild(entry);
this.elements.logContent.scrollTop = this.elements.logContent.scrollHeight;
// Keep only last 100 entries
while (this.elements.logContent.children.length > 100) {
this.elements.logContent.removeChild(this.elements.logContent.firstChild);
}
}
}
// ============================================================================
// Initialize
// ============================================================================
const viewer = new BlenderBridgeViewer();
viewer.init().catch(err => {
console.error('Failed to initialize viewer:', err);
});

View File

@@ -0,0 +1,46 @@
import { defineConfig } from 'vite';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Plugin to set correct MIME type for WASM files
function wasmMimePlugin() {
return {
name: 'wasm-mime',
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url?.endsWith('.wasm')) {
res.setHeader('Content-Type', 'application/wasm');
}
next();
});
}
};
}
export default defineConfig({
root: 'viewer',
base: './',
plugins: [wasmMimePlugin()],
resolve: {
alias: {
'three': path.resolve(__dirname, 'node_modules/three'),
}
},
server: {
port: 5173,
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp'
}
},
build: {
outDir: '../dist',
emptyOutDir: true
},
optimizeDeps: {
exclude: ['tinyusdz']
},
assetsInclude: ['**/*.wasm']
});