Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>3D Scene Editor with WebGPU Raytracing</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/STLLoader.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/OBJLoader.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/exporters/OBJExporter.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/exporters/STLExporter.min.js"></script> | |
| <style> | |
| #renderCanvas { | |
| width: 100%; | |
| height: 100%; | |
| display: block; | |
| } | |
| .dragover { | |
| border: 2px dashed #3b82f6 ; | |
| background-color: rgba(59, 130, 246, 0.1) ; | |
| } | |
| .sidebar { | |
| transition: all 0.3s ease; | |
| } | |
| .sidebar.collapsed { | |
| transform: translateX(-100%); | |
| } | |
| .render-mode { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| background: rgba(0,0,0,0.5); | |
| padding: 5px 10px; | |
| border-radius: 5px; | |
| color: white; | |
| font-size: 12px; | |
| } | |
| .instruction-step { | |
| counter-increment: step-counter; | |
| position: relative; | |
| padding-left: 2rem; | |
| margin-bottom: 0.5rem; | |
| } | |
| .instruction-step::before { | |
| content: counter(step-counter); | |
| position: absolute; | |
| left: 0; | |
| background-color: #3b82f6; | |
| color: white; | |
| width: 1.5rem; | |
| height: 1.5rem; | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 0.8rem; | |
| } | |
| .feature-disabled { | |
| opacity: 0.5; | |
| pointer-events: none; | |
| cursor: not-allowed; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-900 text-white h-screen flex overflow-hidden"> | |
| <!-- WebGPU Modal --> | |
| <div id="webgpuModal" class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 hidden"> | |
| <div class="bg-gray-800 rounded-lg shadow-xl p-6 max-w-2xl w-full mx-4"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h2 class="text-xl font-bold text-blue-400"> | |
| <i class="fas fa-info-circle mr-2"></i> WebGPU Setup Required | |
| </h2> | |
| <button id="closeWebgpuModal" class="text-gray-400 hover:text-white"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| <div class="bg-gray-700 p-4 rounded mb-4"> | |
| <p class="text-yellow-300 mb-2"> | |
| <i class="fas fa-exclamation-triangle mr-2"></i> | |
| Raytracing requires WebGPU which is currently experimental in Chrome/Edge | |
| </p> | |
| <p class="text-sm text-gray-300"> | |
| Follow these steps to enable WebGPU in Chromium browsers (Chrome/Edge): | |
| </p> | |
| </div> | |
| <div class="space-y-3 mb-6 pl-4" style="counter-reset: step-counter;"> | |
| <div class="instruction-step"> | |
| <strong>Type this in your address bar:</strong> | |
| <code class="block bg-gray-900 p-2 rounded mt-1 text-sm">chrome://flags/#enable-unsafe-webgpu</code> | |
| </div> | |
| <div class="instruction-step"> | |
| <strong>Find "Unsafe WebGPU"</strong> in the list and change it from "Default" to <span class="bg-blue-600 px-2 py-1 rounded">Enabled</span> | |
| </div> | |
| <div class="instruction-step"> | |
| <strong>Restart your browser</strong> when prompted | |
| </div> | |
| <div class="instruction-step"> | |
| <strong>Reload this page</strong> after restarting | |
| </div> | |
| </div> | |
| <div class="bg-gray-700 p-4 rounded"> | |
| <p class="text-sm text-gray-300"> | |
| <i class="fas fa-info-circle mr-2 text-blue-400"></i> | |
| WebGPU is only available in Chrome 113+ and Edge 113+ on Windows/Linux/Mac. | |
| Other browsers will fall back to traditional rasterization. | |
| </p> | |
| </div> | |
| <div class="flex justify-end mt-4 space-x-2"> | |
| <button id="dontShowAgain" class="bg-gray-600 hover:bg-gray-500 px-4 py-2 rounded"> | |
| Don't Show Again | |
| </button> | |
| <button id="gotItButton" class="bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded"> | |
| Got It! | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- WebGPU Error Modal --> | |
| <div id="webgpuErrorModal" class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 hidden"> | |
| <div class="bg-gray-800 rounded-lg shadow-xl p-6 max-w-2xl w-full mx-4"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h2 class="text-xl font-bold text-red-400"> | |
| <i class="fas fa-exclamation-triangle mr-2"></i> WebGPU Raytracing Unavailable | |
| </h2> | |
| <button id="closeWebgpuErrorModal" class="text-gray-400 hover:text-white"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| <div class="bg-gray-700 p-4 rounded mb-4"> | |
| <p class="text-yellow-300 mb-2"> | |
| <i class="fas fa-exclamation-circle mr-2"></i> | |
| Failed to load WebGPU raytracing library | |
| </p> | |
| <p class="text-sm text-gray-300"> | |
| The application will continue with traditional rasterization rendering. | |
| </p> | |
| </div> | |
| <div class="space-y-3 mb-6"> | |
| <p class="text-sm"> | |
| Possible reasons for this error: | |
| </p> | |
| <ul class="list-disc pl-5 space-y-1 text-sm text-gray-300"> | |
| <li>WebGPU is not enabled in your browser</li> | |
| <li>Your browser doesn't support WebGPU</li> | |
| <li>Network issues prevented loading the raytracing library</li> | |
| <li>The raytracing library is temporarily unavailable</li> | |
| </ul> | |
| </div> | |
| <div class="bg-gray-700 p-4 rounded"> | |
| <p class="text-sm text-gray-300"> | |
| <i class="fas fa-info-circle mr-2 text-blue-400"></i> | |
| You can still use all other features of the 3D editor. | |
| The raytracing option has been disabled. | |
| </p> | |
| </div> | |
| <div class="flex justify-end mt-4"> | |
| <button id="continueButton" class="bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded"> | |
| Continue | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Sidebar --> | |
| <div id="sidebar" class="sidebar w-64 bg-gray-800 h-full flex flex-col border-r border-gray-700"> | |
| <div class="p-4 border-b border-gray-700 flex justify-between items-center"> | |
| <h1 class="text-xl font-bold">3D Scene Editor</h1> | |
| <button id="toggleSidebar" class="text-gray-400 hover:text-white"> | |
| <i class="fas fa-chevron-left"></i> | |
| </button> | |
| </div> | |
| <div class="p-4 space-y-4 overflow-y-auto flex-grow"> | |
| <!-- Scene Controls --> | |
| <div class="space-y-2"> | |
| <h2 class="font-semibold text-blue-400 flex items-center"> | |
| <i class="fas fa-cube mr-2"></i> Scene Controls | |
| </h2> | |
| <div class="flex space-x-2"> | |
| <button id="toggleRaytracing" class="bg-blue-600 hover:bg-blue-700 px-3 py-1 rounded flex items-center"> | |
| <i class="fas fa-lightbulb mr-1"></i> Toggle Raytracing | |
| </button> | |
| <button id="clearScene" class="bg-red-600 hover:bg-red-700 px-3 py-1 rounded flex items-center"> | |
| <i class="fas fa-trash mr-1"></i> Clear | |
| </button> | |
| </div> | |
| <div id="raytraceSettings" class="bg-gray-700 p-2 rounded mt-2 hidden"> | |
| <h3 class="text-sm font-medium mb-1">Raytrace Settings</h3> | |
| <div class="space-y-2 text-xs"> | |
| <div> | |
| <label>Bounces: <span id="bounceValue">3</span></label> | |
| <input type="range" id="bounceSlider" min="1" max="10" value="3" class="w-full"> | |
| </div> | |
| <div> | |
| <label>Samples: <span id="sampleValue">4</span></label> | |
| <input type="range" id="sampleSlider" min="1" max="16" value="4" class="w-full"> | |
| </div> | |
| <div class="flex items-center"> | |
| <input type="checkbox" id="denoiseCheck" class="mr-2" checked> | |
| <label for="denoiseCheck">Denoise</label> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Lighting Controls --> | |
| <div class="space-y-2"> | |
| <h2 class="font-semibold text-yellow-400 flex items-center"> | |
| <i class="fas fa-lightbulb mr-2"></i> Lighting | |
| </h2> | |
| <div class="grid grid-cols-2 gap-2"> | |
| <button id="addAmbientLight" class="bg-gray-700 hover:bg-gray-600 px-2 py-1 rounded text-sm flex items-center"> | |
| <i class="fas fa-sun mr-1"></i> Ambient | |
| </button> | |
| <button id="addDirectionalLight" class="bg-gray-700 hover:bg-gray-600 px-2 py-1 rounded text-sm flex items-center"> | |
| <i class="fas fa-sun mr-1"></i> Directional | |
| </button> | |
| <button id="addPointLight" class="bg-gray-700 hover:bg-gray-600 px-2 py-1 rounded text-sm flex items-center"> | |
| <i class="fas fa-lightbulb mr-1"></i> Point | |
| </button> | |
| <button id="addSpotLight" class="bg-gray-700 hover:bg-gray-600 px-2 py-1 rounded text-sm flex items-center"> | |
| <i class="fas fa-lightbulb mr-1"></i> Spot | |
| </button> | |
| </div> | |
| <div id="lightControls" class="space-y-2 mt-2"> | |
| <!-- Light controls will be added here dynamically --> | |
| </div> | |
| </div> | |
| <!-- Import/Export --> | |
| <div class="space-y-2"> | |
| <h2 class="font-semibold text-green-400 flex items-center"> | |
| <i class="fas fa-file-import mr-2"></i> Import/Export | |
| </h2> | |
| <div class="grid grid-cols-2 gap-2"> | |
| <button id="importSTL" class="bg-gray-700 hover:bg-gray-600 px-2 py-1 rounded text-sm flex items-center"> | |
| <i class="fas fa-file-upload mr-1"></i> Import STL | |
| </button> | |
| <button id="importOBJ" class="bg-gray-700 hover:bg-gray-600 px-2 py-1 rounded text-sm flex items-center"> | |
| <i class="fas fa-file-upload mr-1"></i> Import OBJ | |
| </button> | |
| <button id="exportSTL" class="bg-gray-700 hover:bg-gray-600 px-2 py-1 rounded text-sm flex items-center"> | |
| <i class="fas fa-file-download mr-1"></i> Export STL | |
| </button> | |
| <button id="exportOBJ" class="bg-gray-700 hover:bg-gray-600 px-2 py-1 rounded text-sm flex items-center"> | |
| <i class="fas fa-file-download mr-1"></i> Export OBJ | |
| </button> | |
| </div> | |
| <div id="dropZone" class="border-2 border-dashed border-gray-600 rounded p-4 text-center mt-2 cursor-pointer hover:border-blue-500 transition"> | |
| <i class="fas fa-cloud-upload-alt text-3xl mb-2 text-gray-400"></i> | |
| <p class="text-sm text-gray-400">Drop 3D files here</p> | |
| <p class="text-xs text-gray-500">(STL, OBJ)</p> | |
| </div> | |
| </div> | |
| <!-- Object List --> | |
| <div class="space-y-2"> | |
| <h2 class="font-semibold text-purple-400 flex items-center"> | |
| <i class="fas fa-shapes mr-2"></i> Scene Objects | |
| </h2> | |
| <div id="objectList" class="space-y-1 max-h-40 overflow-y-auto"> | |
| <!-- Objects will be listed here --> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="p-4 border-t border-gray-700 text-xs text-gray-500"> | |
| <p>3D Scene Editor v1.0</p> | |
| <p>With WebGPU Raytracing</p> | |
| </div> | |
| </div> | |
| <!-- Main Content --> | |
| <div class="flex-grow relative"> | |
| <div id="renderCanvas"></div> | |
| <div id="renderMode" class="render-mode">Rasterization</div> | |
| <!-- Stats Panel --> | |
| <div id="stats" class="absolute top-2 right-2 bg-gray-800 bg-opacity-70 p-2 rounded text-xs"> | |
| <div>FPS: <span id="fps">0</span></div> | |
| <div>Objects: <span id="objectCount">0</span></div> | |
| <div>Vertices: <span id="vertexCount">0</span></div> | |
| </div> | |
| <!-- Loading Indicator --> | |
| <div id="loadingIndicator" class="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center hidden"> | |
| <div class="bg-gray-800 p-6 rounded-lg shadow-lg text-center"> | |
| <i class="fas fa-spinner fa-spin text-4xl text-blue-500 mb-4"></i> | |
| <p class="text-xl">Loading...</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- File Input (hidden) --> | |
| <input type="file" id="fileInput" accept=".stl,.obj" class="hidden" multiple> | |
| <script> | |
| // Initialize Three.js scene | |
| let scene, camera, renderer, controls; | |
| let raytracingEnabled = false; | |
| let raytracingRenderer = null; | |
| let objects = []; | |
| let lights = []; | |
| let clock = new THREE.Clock(); | |
| let stats = { | |
| fps: 0, | |
| objectCount: 0, | |
| vertexCount: 0 | |
| }; | |
| // Check if WebGPU is supported | |
| function isWebGPUSupported() { | |
| return navigator.gpu !== undefined; | |
| } | |
| // Check if this is a Chromium browser | |
| function isChromium() { | |
| return /Chrome|Edg/.test(navigator.userAgent) && !/Opera|OPR/.test(navigator.userAgent); | |
| } | |
| // Show WebGPU instructions if needed | |
| function checkWebGPUSupport() { | |
| // Check if user has previously dismissed the modal | |
| if (localStorage.getItem('dontShowWebGPUModal') === 'true') { | |
| return; | |
| } | |
| // Only show for Chromium browsers without WebGPU support | |
| if (isChromium() && !isWebGPUSupported()) { | |
| document.getElementById('webgpuModal').classList.remove('hidden'); | |
| } | |
| } | |
| // Disable raytracing UI when not available | |
| function disableRaytracingUI() { | |
| const toggleBtn = document.getElementById('toggleRaytracing'); | |
| toggleBtn.disabled = true; | |
| toggleBtn.classList.add('feature-disabled'); | |
| toggleBtn.title = "WebGPU raytracing not available"; | |
| document.getElementById('raytraceSettings').classList.add('hidden'); | |
| // Show error modal | |
| document.getElementById('webgpuErrorModal').classList.remove('hidden'); | |
| } | |
| // Initialize the application | |
| async function init() { | |
| // Create scene | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x111111); | |
| // Create camera | |
| camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| camera.position.set(5, 5, 5); | |
| camera.lookAt(0, 0, 0); | |
| // Create renderer | |
| renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| document.getElementById('renderCanvas').appendChild(renderer.domElement); | |
| // Try to initialize WebGPU raytracing if supported | |
| if (isWebGPUSupported()) { | |
| try { | |
| // Dynamically load the WebGPU raytracing library | |
| const script = document.createElement('script'); | |
| script.src = 'https://cdn.jsdelivr.net/npm/webgpu-raytracing@latest/dist/webgpu-raytracing.umd.js'; | |
| // Set timeout for library loading | |
| const loadTimeout = setTimeout(() => { | |
| console.warn("WebGPU raytracing library loading timed out"); | |
| disableRaytracingUI(); | |
| }, 5000); | |
| script.onload = async () => { | |
| clearTimeout(loadTimeout); | |
| try { | |
| if (typeof WebGPURaytracingRenderer !== 'undefined') { | |
| raytracingRenderer = new WebGPURaytracingRenderer({ | |
| canvas: document.createElement('canvas'), | |
| width: window.innerWidth, | |
| height: window.innerHeight | |
| }); | |
| await raytracingRenderer.init(); | |
| // Configure raytracing settings | |
| raytracingRenderer.setBounces(3); | |
| raytracingRenderer.setSamples(4); | |
| raytracingRenderer.setDenoise(true); | |
| console.log("WebGPU Raytracing initialized successfully"); | |
| } else { | |
| console.warn("WebGPU Raytracing library not available"); | |
| disableRaytracingUI(); | |
| } | |
| } catch (e) { | |
| console.error("Failed to initialize WebGPU raytracing:", e); | |
| disableRaytracingUI(); | |
| } | |
| }; | |
| script.onerror = () => { | |
| clearTimeout(loadTimeout); | |
| console.warn("Failed to load WebGPU raytracing library"); | |
| disableRaytracingUI(); | |
| }; | |
| document.head.appendChild(script); | |
| } catch (e) { | |
| console.error("Error loading WebGPU raytracing:", e); | |
| disableRaytracingUI(); | |
| } | |
| } else { | |
| console.warn("WebGPU not supported in this browser"); | |
| disableRaytracingUI(); | |
| } | |
| // Add orbit controls | |
| controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.05; | |
| // Add axes helper | |
| const axesHelper = new THREE.AxesHelper(5); | |
| scene.add(axesHelper); | |
| // Add grid helper | |
| const gridHelper = new THREE.GridHelper(20, 20); | |
| scene.add(gridHelper); | |
| // Add default ambient light | |
| addAmbientLight(); | |
| // Add default directional light | |
| addDirectionalLight(); | |
| // Event listeners | |
| setupEventListeners(); | |
| // Check WebGPU support | |
| checkWebGPUSupport(); | |
| // Start animation loop | |
| animate(); | |
| } | |
| // Animation loop | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| // Update controls | |
| controls.update(); | |
| // Calculate FPS | |
| const delta = clock.getDelta(); | |
| stats.fps = Math.round(1 / delta); | |
| // Update stats | |
| updateStats(); | |
| // Render scene | |
| if (raytracingEnabled && raytracingRenderer) { | |
| try { | |
| // Update raytracing renderer with current scene | |
| raytracingRenderer.setCamera(camera); | |
| raytracingRenderer.setScene(scene); | |
| // Render with raytracing | |
| raytracingRenderer.render(); | |
| // Display the raytraced result | |
| renderer.domElement.style.display = 'none'; | |
| raytracingRenderer.canvas.style.display = 'block'; | |
| raytracingRenderer.canvas.style.width = '100%'; | |
| raytracingRenderer.canvas.style.height = '100%'; | |
| if (!document.getElementById('renderCanvas').contains(raytracingRenderer.canvas)) { | |
| document.getElementById('renderCanvas').appendChild(raytracingRenderer.canvas); | |
| } | |
| } catch (e) { | |
| console.error("Raytracing render error:", e); | |
| raytracingEnabled = false; | |
| document.getElementById('renderMode').textContent = 'Rasterization'; | |
| renderer.domElement.style.display = 'block'; | |
| if (raytracingRenderer && raytracingRenderer.canvas) { | |
| raytracingRenderer.canvas.style.display = 'none'; | |
| } | |
| } | |
| } else { | |
| // Regular rasterization rendering | |
| renderer.render(scene, camera); | |
| // Hide raytracing canvas if visible | |
| if (raytracingRenderer && raytracingRenderer.canvas) { | |
| raytracingRenderer.canvas.style.display = 'none'; | |
| } | |
| renderer.domElement.style.display = 'block'; | |
| } | |
| } | |
| // Update statistics display | |
| function updateStats() { | |
| document.getElementById('fps').textContent = stats.fps; | |
| document.getElementById('objectCount').textContent = objects.length; | |
| // Calculate total vertices | |
| let vertexCount = 0; | |
| objects.forEach(obj => { | |
| if (obj.geometry) { | |
| vertexCount += obj.geometry.attributes.position.count; | |
| } | |
| }); | |
| document.getElementById('vertexCount').textContent = vertexCount; | |
| } | |
| // Setup event listeners | |
| function setupEventListeners() { | |
| // Window resize | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| if (raytracingRenderer) { | |
| raytracingRenderer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| }); | |
| // Sidebar toggle | |
| document.getElementById('toggleSidebar').addEventListener('click', () => { | |
| document.getElementById('sidebar').classList.toggle('collapsed'); | |
| document.getElementById('toggleSidebar').innerHTML = | |
| document.getElementById('sidebar').classList.contains('collapsed') ? | |
| '<i class="fas fa-chevron-right"></i>' : '<i class="fas fa-chevron-left"></i>'; | |
| }); | |
| // Raytracing toggle | |
| document.getElementById('toggleRaytracing').addEventListener('click', () => { | |
| if (!isWebGPUSupported() || !raytracingRenderer) { | |
| document.getElementById('webgpuModal').classList.remove('hidden'); | |
| return; | |
| } | |
| raytracingEnabled = !raytracingEnabled; | |
| document.getElementById('raytraceSettings').classList.toggle('hidden', !raytracingEnabled); | |
| document.getElementById('renderMode').textContent = raytracingEnabled ? 'Raytracing' : 'Rasterization'; | |
| if (raytracingEnabled && !raytracingRenderer) { | |
| alert('WebGPU raytracing is not available in your browser'); | |
| raytracingEnabled = false; | |
| document.getElementById('raytraceSettings').classList.add('hidden'); | |
| document.getElementById('renderMode').textContent = 'Rasterization'; | |
| } | |
| }); | |
| // Raytracing settings | |
| document.getElementById('bounceSlider').addEventListener('input', (e) => { | |
| const value = parseInt(e.target.value); | |
| document.getElementById('bounceValue').textContent = value; | |
| if (raytracingRenderer) raytracingRenderer.setBounces(value); | |
| }); | |
| document.getElementById('sampleSlider').addEventListener('input', (e) => { | |
| const value = parseInt(e.target.value); | |
| document.getElementById('sampleValue').textContent = value; | |
| if (raytracingRenderer) raytracingRenderer.setSamples(value); | |
| }); | |
| document.getElementById('denoiseCheck').addEventListener('change', (e) => { | |
| if (raytracingRenderer) raytracingRenderer.setDenoise(e.target.checked); | |
| }); | |
| // Clear scene | |
| document.getElementById('clearScene').addEventListener('click', () => { | |
| clearScene(); | |
| }); | |
| // Light buttons | |
| document.getElementById('addAmbientLight').addEventListener('click', addAmbientLight); | |
| document.getElementById('addDirectionalLight').addEventListener('click', addDirectionalLight); | |
| document.getElementById('addPointLight').addEventListener('click', addPointLight); | |
| document.getElementById('addSpotLight').addEventListener('click', addSpotLight); | |
| // Import/Export buttons | |
| document.getElementById('importSTL').addEventListener('click', () => { | |
| document.getElementById('fileInput').accept = '.stl'; | |
| document.getElementById('fileInput').click(); | |
| }); | |
| document.getElementById('importOBJ').addEventListener('click', () => { | |
| document.getElementById('fileInput').accept = '.obj'; | |
| document.getElementById('fileInput').click(); | |
| }); | |
| document.getElementById('exportSTL').addEventListener('click', exportSTL); | |
| document.getElementById('exportOBJ').addEventListener('click', exportOBJ); | |
| // File input handling | |
| document.getElementById('fileInput').addEventListener('change', handleFileSelect); | |
| // Drag and drop | |
| const dropZone = document.getElementById('dropZone'); | |
| dropZone.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| dropZone.classList.add('dragover'); | |
| }); | |
| dropZone.addEventListener('dragleave', () => { | |
| dropZone.classList.remove('dragover'); | |
| }); | |
| dropZone.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| dropZone.classList.remove('dragover'); | |
| if (e.dataTransfer.files.length > 0) { | |
| handleFileSelect({ target: { files: e.dataTransfer.files } }); | |
| } | |
| }); | |
| dropZone.addEventListener('click', () => { | |
| document.getElementById('fileInput').accept = '.stl,.obj'; | |
| document.getElementById('fileInput').click(); | |
| }); | |
| // WebGPU modal controls | |
| document.getElementById('closeWebgpuModal').addEventListener('click', () => { | |
| document.getElementById('webgpuModal').classList.add('hidden'); | |
| }); | |
| document.getElementById('gotItButton').addEventListener('click', () => { | |
| document.getElementById('webgpuModal').classList.add('hidden'); | |
| }); | |
| document.getElementById('dontShowAgain').addEventListener('click', () => { | |
| localStorage.setItem('dontShowWebGPUModal', 'true'); | |
| document.getElementById('webgpuModal').classList.add('hidden'); | |
| }); | |
| // WebGPU error modal controls | |
| document.getElementById('closeWebgpuErrorModal').addEventListener('click', () => { | |
| document.getElementById('webgpuErrorModal').classList.add('hidden'); | |
| }); | |
| document.getElementById('continueButton').addEventListener('click', () => { | |
| document.getElementById('webgpuErrorModal').classList.add('hidden'); | |
| }); | |
| } | |
| // Add ambient light | |
| function addAmbientLight(color = 0x404040, intensity = 0.5) { | |
| const light = new THREE.AmbientLight(color, intensity); | |
| scene.add(light); | |
| lights.push(light); | |
| addLightControl(light, 'Ambient Light'); | |
| } | |
| // Add directional light | |
| function addDirectionalLight(color = 0xffffff, intensity = 1, x = 5, y = 5, z = 5) { | |
| const light = new THREE.DirectionalLight(color, intensity); | |
| light.position.set(x, y, z); | |
| light.castShadow = true; | |
| light.shadow.mapSize.width = 1024; | |
| light.shadow.mapSize.height = 1024; | |
| scene.add(light); | |
| lights.push(light); | |
| // Add helper | |
| const helper = new THREE.DirectionalLightHelper(light, 1); | |
| scene.add(helper); | |
| addLightControl(light, 'Directional Light', helper); | |
| } | |
| // Add point light | |
| function addPointLight(color = 0xffffff, intensity = 1, distance = 10, x = 0, y = 5, z = 0) { | |
| const light = new THREE.PointLight(color, intensity, distance); | |
| light.position.set(x, y, z); | |
| light.castShadow = true; | |
| scene.add(light); | |
| lights.push(light); | |
| // Add helper | |
| const helper = new THREE.PointLightHelper(light, 1); | |
| scene.add(helper); | |
| addLightControl(light, 'Point Light', helper); | |
| } | |
| // Add spot light | |
| function addSpotLight(color = 0xffffff, intensity = 1, distance = 10, angle = Math.PI / 4, penumbra = 0.1, x = 0, y = 5, z = 0) { | |
| const light = new THREE.SpotLight(color, intensity, distance, angle, penumbra); | |
| light.position.set(x, y, z); | |
| light.castShadow = true; | |
| scene.add(light); | |
| lights.push(light); | |
| // Add helper | |
| const helper = new THREE.SpotLightHelper(light); | |
| scene.add(helper); | |
| addLightControl(light, 'Spot Light', helper); | |
| } | |
| // Add light control UI | |
| function addLightControl(light, name, helper = null) { | |
| const lightControls = document.getElementById('lightControls'); | |
| const lightId = lights.length - 1; | |
| const controlDiv = document.createElement('div'); | |
| controlDiv.className = 'bg-gray-700 p-2 rounded'; | |
| controlDiv.id = `lightControl-${lightId}`; | |
| controlDiv.innerHTML = ` | |
| <div class="flex justify-between items-center mb-1"> | |
| <span class="text-sm font-medium">${name}</span> | |
| <button class="text-red-400 hover:text-red-300 text-xs" data-light-id="${lightId}"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| <div class="grid grid-cols-2 gap-1 text-xs"> | |
| <div> | |
| <label>Intensity</label> | |
| <input type="range" min="0" max="2" step="0.1" value="${light.intensity}" | |
| class="w-full" data-light-id="${lightId}" data-property="intensity"> | |
| </div> | |
| <div> | |
| <label>Color</label> | |
| <input type="color" value="#${light.color.getHexString()}" | |
| class="w-full" data-light-id="${lightId}" data-property="color"> | |
| </div> | |
| </div> | |
| `; | |
| lightControls.appendChild(controlDiv); | |
| // Add event listeners for the controls | |
| controlDiv.querySelectorAll('input').forEach(input => { | |
| input.addEventListener('input', (e) => { | |
| const id = parseInt(e.target.dataset.lightId); | |
| const property = e.target.dataset.property; | |
| const value = e.target.value; | |
| if (property === 'color') { | |
| lights[id].color.setHex(parseInt(value.substring(1), 16)); | |
| } else if (property === 'intensity') { | |
| lights[id].intensity = parseFloat(value); | |
| } | |
| if (helper) helper.update(); | |
| }); | |
| }); | |
| // Add delete button event | |
| controlDiv.querySelector('button').addEventListener('click', (e) => { | |
| const id = parseInt(e.target.dataset.lightId || e.target.closest('button').dataset.lightId); | |
| scene.remove(lights[id]); | |
| if (helper) scene.remove(helper); | |
| lights.splice(id, 1); | |
| controlDiv.remove(); | |
| // Update remaining controls | |
| document.querySelectorAll('[data-light-id]').forEach(el => { | |
| const currentId = parseInt(el.dataset.lightId); | |
| if (currentId > id) { | |
| el.dataset.lightId = currentId - 1; | |
| } | |
| }); | |
| }); | |
| } | |
| // Clear the scene | |
| function clearScene() { | |
| // Remove all objects | |
| objects.forEach(obj => { | |
| scene.remove(obj); | |
| }); | |
| objects = []; | |
| // Remove all lights except the first two (ambient and directional) | |
| while (lights.length > 2) { | |
| const light = lights.pop(); | |
| scene.remove(light); | |
| // Remove helper if exists | |
| light.children.forEach(child => { | |
| if (child instanceof THREE.LightHelper) { | |
| scene.remove(child); | |
| } | |
| }); | |
| } | |
| // Clear light controls except the first two | |
| const lightControls = document.getElementById('lightControls'); | |
| while (lightControls.children.length > 2) { | |
| lightControls.removeChild(lightControls.lastChild); | |
| } | |
| // Reset the first two lights to default | |
| if (lights.length > 0) { | |
| lights[0].color.setHex(0x404040); | |
| lights[0].intensity = 0.5; | |
| } | |
| if (lights.length > 1) { | |
| lights[1].color.setHex(0xffffff); | |
| lights[1].intensity = 1; | |
| lights[1].position.set(5, 5, 5); | |
| } | |
| // Update UI controls | |
| document.querySelectorAll('#lightControls input[data-property="color"]').forEach((input, index) => { | |
| if (index === 0) input.value = '#404040'; | |
| if (index === 1) input.value = '#ffffff'; | |
| }); | |
| document.querySelectorAll('#lightControls input[data-property="intensity"]').forEach((input, index) => { | |
| if (index === 0) input.value = 0.5; | |
| if (index === 1) input.value = 1; | |
| }); | |
| // Update object list | |
| updateObjectList(); | |
| } | |
| // Handle file selection | |
| function handleFileSelect(event) { | |
| const files = event.target.files; | |
| if (!files || files.length === 0) return; | |
| showLoading(true); | |
| // Process each file | |
| Array.from(files).forEach(file => { | |
| const fileName = file.name.toLowerCase(); | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| if (fileName.endsWith('.stl')) { | |
| loadSTL(e.target.result, file.name); | |
| } else if (fileName.endsWith('.obj')) { | |
| loadOBJ(e.target.result, file.name); | |
| } | |
| }; | |
| reader.onerror = function() { | |
| console.error('Error reading file'); | |
| showLoading(false); | |
| }; | |
| if (fileName.endsWith('.stl') || fileName.endsWith('.obj')) { | |
| reader.readAsArrayBuffer(file); | |
| } else { | |
| alert('Unsupported file format. Please upload STL or OBJ files.'); | |
| showLoading(false); | |
| } | |
| }); | |
| // Reset file input | |
| event.target.value = ''; | |
| } | |
| // Load STL file | |
| function loadSTL(buffer, name) { | |
| try { | |
| const loader = new THREE.STLLoader(); | |
| const geometry = loader.parse(buffer); | |
| const material = new THREE.MeshStandardMaterial({ | |
| color: 0xaaaaaa, | |
| metalness: 0.5, | |
| roughness: 0.5 | |
| }); | |
| const mesh = new THREE.Mesh(geometry, material); | |
| mesh.castShadow = true; | |
| mesh.receiveShadow = true; | |
| mesh.name = name; | |
| // Center and scale the model | |
| geometry.computeBoundingBox(); | |
| const boundingBox = geometry.boundingBox; | |
| const center = new THREE.Vector3(); | |
| boundingBox.getCenter(center); | |
| mesh.position.sub(center); | |
| // Scale to reasonable size | |
| const size = boundingBox.getSize(new THREE.Vector3()); | |
| const maxDim = Math.max(size.x, size.y, size.z); | |
| const scale = 5 / maxDim; | |
| mesh.scale.set(scale, scale, scale); | |
| scene.add(mesh); | |
| objects.push(mesh); | |
| updateObjectList(); | |
| showLoading(false); | |
| } catch (e) { | |
| console.error('Error loading STL:', e); | |
| alert('Error loading STL file'); | |
| showLoading(false); | |
| } | |
| } | |
| // Load OBJ file | |
| function loadOBJ(buffer, name) { | |
| try { | |
| const loader = new THREE.OBJLoader(); | |
| const obj = loader.parse(buffer); | |
| // Apply material to all children | |
| obj.traverse(child => { | |
| if (child instanceof THREE.Mesh) { | |
| child.material = new THREE.MeshStandardMaterial({ | |
| color: 0xaaaaaa, | |
| metalness: 0.5, | |
| roughness: 0.5 | |
| }); | |
| child.castShadow = true; | |
| child.receiveShadow = true; | |
| } | |
| }); | |
| obj.name = name; | |
| // Center and scale the model | |
| const box = new THREE.Box3().setFromObject(obj); | |
| const center = new THREE.Vector3(); | |
| box.getCenter(center); | |
| obj.position.sub(center); | |
| // Scale to reasonable size | |
| const size = box.getSize(new THREE.Vector3()); | |
| const maxDim = Math.max(size.x, size.y, size.z); | |
| const scale = 5 / maxDim; | |
| obj.scale.set(scale, scale, scale); | |
| scene.add(obj); | |
| objects.push(obj); | |
| updateObjectList(); | |
| showLoading(false); | |
| } catch (e) { | |
| console.error('Error loading OBJ:', e); | |
| alert('Error loading OBJ file'); | |
| showLoading(false); | |
| } | |
| } | |
| // Export STL | |
| function exportSTL() { | |
| if (objects.length === 0) { | |
| alert('No objects to export'); | |
| return; | |
| } | |
| const exporter = new THREE.STLExporter(); | |
| let stlString = ''; | |
| // Export all objects | |
| objects.forEach(obj => { | |
| stlString += exporter.parse(obj); | |
| }); | |
| // Create download link | |
| const blob = new Blob([stlString], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = 'scene.stl'; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| URL.revokeObjectURL(url); | |
| } | |
| // Export OBJ | |
| function exportOBJ() { | |
| if (objects.length === 0) { | |
| alert('No objects to export'); | |
| return; | |
| } | |
| const exporter = new THREE.OBJExporter(); | |
| let objString = ''; | |
| // Export all objects | |
| objects.forEach(obj => { | |
| objString += exporter.parse(obj); | |
| }); | |
| // Create download link | |
| const blob = new Blob([objString], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = 'scene.obj'; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| URL.revokeObjectURL(url); | |
| } | |
| // Update object list in UI | |
| function updateObjectList() { | |
| const objectList = document.getElementById('objectList'); | |
| objectList.innerHTML = ''; | |
| if (objects.length === 0) { | |
| objectList.innerHTML = '<p class="text-gray-500 text-sm">No objects in scene</p>'; | |
| return; | |
| } | |
| objects.forEach((obj, index) => { | |
| const item = document.createElement('div'); | |
| item.className = 'flex justify-between items-center bg-gray-700 px-2 py-1 rounded text-sm'; | |
| item.innerHTML = ` | |
| <span class="truncate">${obj.name || 'Unnamed Object'}</span> | |
| <div class="flex space-x-1"> | |
| <button class="text-blue-400 hover:text-blue-300" data-action="focus" data-index="${index}"> | |
| <i class="fas fa-crosshairs"></i> | |
| </button> | |
| <button class="text-red-400 hover:text-red-300" data-action="remove" data-index="${index}"> | |
| <i class="fas fa-trash"></i> | |
| </button> | |
| </div> | |
| `; | |
| objectList.appendChild(item); | |
| }); | |
| // Add event listeners | |
| document.querySelectorAll('[data-action="focus"]').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| const index = parseInt(e.target.dataset.index || e.target.closest('button').dataset.index); | |
| focusObject(objects[index]); | |
| }); | |
| }); | |
| document.querySelectorAll('[data-action="remove"]').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| const index = parseInt(e.target.dataset.index || e.target.closest('button').dataset.index); | |
| removeObject(index); | |
| }); | |
| }); | |
| } | |
| // Focus on object | |
| function focusObject(obj) { | |
| const box = new THREE.Box3().setFromObject(obj); | |
| const size = box.getSize(new THREE.Vector3()); | |
| const center = box.getCenter(new THREE.Vector3()); | |
| // Position camera to view the entire object | |
| const maxDim = Math.max(size.x, size.y, size.z); | |
| const cameraDistance = maxDim * 2; | |
| camera.position.copy(center); | |
| camera.position.z += cameraDistance; | |
| camera.lookAt(center); | |
| controls.target.copy(center); | |
| controls.update(); | |
| } | |
| // Remove object | |
| function removeObject(index) { | |
| if (index >= 0 && index < objects.length) { | |
| scene.remove(objects[index]); | |
| objects.splice(index, 1); | |
| updateObjectList(); | |
| } | |
| } | |
| // Show/hide loading indicator | |
| function showLoading(show) { | |
| document.getElementById('loadingIndicator').classList.toggle('hidden', !show); | |
| } | |
| // Initialize the app when DOM is loaded | |
| document.addEventListener('DOMContentLoaded', init); | |
| </script> | |
| </body> | |
| </html> |