// Three.js GLB Generator // Generates GLB files using Three.js and GLTFExporter // Note: This requires three.js to be installed // If three.js is not available, the server will fallback to Blender export class ThreeJSGLBGenerator { constructor() { this.three = null; this.GLTFExporter = null; this.scene = null; this.objects = []; this.lights = []; } async loadThreeJS() { if (this.three && this.GLTFExporter) { return true; } try { const threeModule = await import('three'); const exporterModule = await import('three/examples/jsm/exporters/GLTFExporter.js'); this.three = threeModule; this.GLTFExporter = exporterModule.GLTFExporter; return true; } catch (error) { return false; } } async generate(sceneData, outputPath) { const fs = (await import('fs')).default; const loaded = await this.loadThreeJS(); if (!loaded) { throw new Error('Three.js is not available. Install with: npm install three'); } const THREE = this.three; const GLTFExporter = this.GLTFExporter; try { this.scene = new THREE.Scene(); this.objects = []; this.lights = []; this.setupEnvironment(sceneData, THREE); if (sceneData.ground) { this.createGround(sceneData.ground, THREE); } if (sceneData.objects) { sceneData.objects.forEach(obj => this.createObject(obj, THREE)); } if (sceneData.avatar && sceneData.avatar.present) { this.createAvatar(sceneData.avatar, THREE); } if (sceneData.lighting) { this.createLights(sceneData.lighting, THREE); } if (sceneData.camera) { this.createCamera(sceneData.camera, THREE); } await this.exportGLB(outputPath, THREE, GLTFExporter, fs); return { success: true, path: outputPath }; } catch (error) { return { success: false, error: error.message }; } } setupEnvironment(sceneData, THREE) { if (sceneData.lighting?.color) { this.scene.background = new THREE.Color( sceneData.lighting.color[0], sceneData.lighting.color[1], sceneData.lighting.color[2] ); } if (sceneData.effects?.fog) { this.scene.fog = new THREE.FogExp2( new THREE.Color(0.8, 0.8, 0.9), 0.05 ); } } createGround(groundData, THREE) { const size = groundData.size || 20; const geometry = new THREE.PlaneGeometry(size, size); const color = groundData.color || [0.5, 0.5, 0.5, 1]; const material = new THREE.MeshStandardMaterial({ color: new THREE.Color(color[0], color[1], color[2]), roughness: groundData.roughness || 0.8, metalness: groundData.metallic || 0.0 }); const mesh = new THREE.Mesh(geometry, material); mesh.rotation.x = -Math.PI / 2; mesh.name = "Ground"; this.scene.add(mesh); this.objects.push(mesh); } createObject(objData, THREE) { let geometry; switch (objData.type) { case 'cube': const size = objData.size || 1; geometry = new THREE.BoxGeometry(size, size, size); break; case 'sphere': geometry = new THREE.SphereGeometry(objData.radius || 0.5, 32, 32); break; case 'cylinder': geometry = new THREE.CylinderGeometry( objData.radius || 0.5, objData.radius || 0.5, objData.size || 1, 32 ); break; case 'cone': geometry = new THREE.ConeGeometry( objData.radius || 0.5, objData.size || 1, 32 ); break; case 'plane': geometry = new THREE.PlaneGeometry( objData.size || 10, objData.size || 10 ); break; default: geometry = new THREE.BoxGeometry(1, 1, 1); } const color = objData.color || [0.8, 0.8, 0.8, 1]; const material = new THREE.MeshStandardMaterial({ color: new THREE.Color(color[0], color[1], color[2]), roughness: objData.roughness || 0.5, metalness: objData.metallic || 0.0, transparent: color[3] < 1, opacity: color[3] || 1 }); if (objData.emission) { material.emissive = new THREE.Color( objData.emission[0], objData.emission[1], objData.emission[2] ); material.emissiveIntensity = objData.emissionStrength || 1.0; } const mesh = new THREE.Mesh(geometry, material); mesh.position.set( objData.location[0] || 0, objData.location[1] || 0, objData.location[2] || 0 ); mesh.rotation.set( objData.rotation[0] || 0, objData.rotation[1] || 0, objData.rotation[2] || 0 ); mesh.scale.set( objData.scale[0] || 1, objData.scale[1] || 1, objData.scale[2] || 1 ); mesh.name = objData.name || 'Object'; this.scene.add(mesh); this.objects.push(mesh); } createAvatar(avatarData, THREE) { const group = new THREE.Group(); group.name = avatarData.name || 'Avatar'; const torso = new THREE.Mesh( new THREE.CylinderGeometry(0.25, 0.25, 0.8, 32), new THREE.MeshStandardMaterial({ color: 0xffccaa }) ); torso.position.y = 1.0; group.add(torso); const head = new THREE.Mesh( new THREE.SphereGeometry(0.18, 32, 32), new THREE.MeshStandardMaterial({ color: 0xffccaa }) ); head.position.y = 1.9; group.add(head); const leftArm = new THREE.Mesh( new THREE.BoxGeometry(0.2, 0.4, 0.2), new THREE.MeshStandardMaterial({ color: 0xffccaa }) ); leftArm.position.set(-0.45, 1.3, 0); group.add(leftArm); const rightArm = new THREE.Mesh( new THREE.BoxGeometry(0.2, 0.4, 0.2), new THREE.MeshStandardMaterial({ color: 0xffccaa }) ); rightArm.position.set(0.45, 1.3, 0); group.add(rightArm); const leftLeg = new THREE.Mesh( new THREE.BoxGeometry(0.25, 0.75, 0.25), new THREE.MeshStandardMaterial({ color: 0x4444ff }) ); leftLeg.position.set(-0.18, 0.5, 0); group.add(leftLeg); const rightLeg = new THREE.Mesh( new THREE.BoxGeometry(0.25, 0.75, 0.25), new THREE.MeshStandardMaterial({ color: 0x4444ff }) ); rightLeg.position.set(0.18, 0.5, 0); group.add(rightLeg); group.position.set( avatarData.position[0] || 0, avatarData.position[1] || 0, avatarData.position[2] || 0 ); group.scale.set( avatarData.scale || 1, avatarData.scale || 1, avatarData.scale || 1 ); this.scene.add(group); this.objects.push(group); } createLights(lightingData, THREE) { const ambientLight = new THREE.AmbientLight( new THREE.Color(lightingData.color || [1, 1, 1]), lightingData.ambient_strength || 0.3 ); this.scene.add(ambientLight); this.lights.push(ambientLight); if (lightingData.lights && lightingData.lights.length > 0) { lightingData.lights.forEach(light => { let lightObj; switch (light.type) { case 'SUN': case 'DIRECTIONAL': lightObj = new THREE.DirectionalLight( new THREE.Color(light.color || lightingData.color || [1, 1, 1]), light.energy || 1 ); lightObj.position.set( light.location[0] || 10, light.location[1] || -10, light.location[2] || 10 ); break; case 'POINT': lightObj = new THREE.PointLight( new THREE.Color(light.color || lightingData.color || [1, 1, 1]), light.energy || 50, 100 ); lightObj.position.set( light.location[0] || 0, light.location[1] || 0, light.location[2] || 2 ); break; case 'SPOT': lightObj = new THREE.SpotLight( new THREE.Color(light.color || lightingData.color || [1, 1, 1]), light.energy || 50 ); lightObj.position.set( light.location[0] || 0, light.location[1] || 0, light.location[2] || 2 ); break; default: return; } if (lightObj) { this.scene.add(lightObj); this.lights.push(lightObj); } }); } } createCamera(cameraData, THREE) { const camera = new THREE.PerspectiveCamera( cameraData.fov || 50, 16 / 9, 0.1, 1000 ); camera.position.set( cameraData.position[0] || 4, cameraData.position[1] || -4, cameraData.position[2] || 2.2 ); camera.rotation.set( cameraData.rotation[0] || 1.05, cameraData.rotation[1] || 0, cameraData.rotation[2] || 0.78 ); this.scene.add(camera); } async exportGLB(outputPath, THREE, GLTFExporter, fs) { return new Promise((resolve, reject) => { // Polyfill FileReader for Node.js environment if (typeof global !== 'undefined' && !global.FileReader) { global.FileReader = class FileReader { constructor() { this.result = null; this.onload = null; this.onerror = null; } readAsArrayBuffer(blob) { // In Node.js, we'll handle this differently if (blob && blob.arrayBuffer) { blob.arrayBuffer().then(buffer => { this.result = buffer; if (this.onload) this.onload({ target: { result: buffer } }); }).catch(err => { if (this.onerror) this.onerror(err); }); } } }; } const exporter = new GLTFExporter(); const options = { binary: true, includeCustomExtensions: false, // Disable to avoid FileReader issues onlyVisible: false, truncateDrawRange: true, trs: false, animations: [] }; try { exporter.parse( this.scene, (result) => { try { // Handle both ArrayBuffer and Uint8Array let buffer; if (result instanceof ArrayBuffer) { buffer = Buffer.from(result); } else if (result instanceof Uint8Array) { buffer = Buffer.from(result); } else if (typeof result === 'string') { // If it's a string (JSON), convert to buffer buffer = Buffer.from(result, 'utf8'); } else if (result && result.buffer) { // Handle TypedArray buffer = Buffer.from(result.buffer); } else { // Try to convert directly buffer = Buffer.from(result); } if (!buffer || buffer.length === 0) { reject(new Error('GLB export resulted in empty buffer')); return; } fs.writeFileSync(outputPath, buffer); resolve(); } catch (error) { reject(new Error(`Failed to write GLB file: ${error.message}`)); } }, (error) => { // Check if error is about FileReader const errorMsg = error?.message || String(error); if (errorMsg.includes('FileReader')) { reject(new Error('GLTFExporter requires browser environment. Please install three.js properly or use Blender fallback.')); } else { reject(new Error(`GLTFExporter error: ${errorMsg}`)); } }, options ); } catch (error) { const errorMsg = error?.message || String(error); if (errorMsg.includes('FileReader')) { reject(new Error('GLTFExporter requires browser environment. Please install three.js properly.')); } else { reject(error); } } }); } }