mirror of
https://github.com/lighttransport/tinyusdz.git
synced 2026-01-18 01:11:17 +01:00
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:
189
web/blender-bridge/README.md
Normal file
189
web/blender-bridge/README.md
Normal 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
251
web/blender-bridge/SETUP.md
Normal 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
|
||||
345
web/blender-bridge/client/bridge-client.js
Normal file
345
web/blender-bridge/client/bridge-client.js
Normal 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;
|
||||
525
web/blender-bridge/client/parameter-sync.js
Normal file
525
web/blender-bridge/client/parameter-sync.js
Normal 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;
|
||||
337
web/blender-bridge/lib/connection-manager.js
Normal file
337
web/blender-bridge/lib/connection-manager.js
Normal 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;
|
||||
253
web/blender-bridge/lib/message-protocol.js
Normal file
253
web/blender-bridge/lib/message-protocol.js
Normal 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;
|
||||
276
web/blender-bridge/lib/scene-state.js
Normal file
276
web/blender-bridge/lib/scene-state.js
Normal 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
1800
web/blender-bridge/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
web/blender-bridge/package.json
Normal file
21
web/blender-bridge/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
47
web/blender-bridge/send-camera.js
Normal file
47
web/blender-bridge/send-camera.js
Normal 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));
|
||||
374
web/blender-bridge/server.js
Normal file
374
web/blender-bridge/server.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
69
web/blender-bridge/test-client.js
Normal file
69
web/blender-bridge/test-client.js
Normal 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...');
|
||||
75
web/blender-bridge/viewer/index.html
Normal file
75
web/blender-bridge/viewer/index.html
Normal 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>
|
||||
275
web/blender-bridge/viewer/viewer.css
Normal file
275
web/blender-bridge/viewer/viewer.css
Normal 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;
|
||||
}
|
||||
}
|
||||
614
web/blender-bridge/viewer/viewer.js
Normal file
614
web/blender-bridge/viewer/viewer.js
Normal 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);
|
||||
});
|
||||
46
web/blender-bridge/vite.config.js
Normal file
46
web/blender-bridge/vite.config.js
Normal 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']
|
||||
});
|
||||
Reference in New Issue
Block a user