Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>3D Model Viewer</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.132.2/build/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/loaders/OBJLoader.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/loaders/GLTFLoader.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/controls/OrbitControls.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/controls/DragControls.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/controls/TrackballControls.js"></script> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <style> | |
| .model-container { | |
| position: relative; | |
| width: 100%; | |
| height: 70vh; | |
| background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); | |
| border-radius: 10px; | |
| overflow: hidden; | |
| box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); | |
| } | |
| .loading-overlay { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0, 0, 0, 0.7); | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| z-index: 100; | |
| color: white; | |
| font-size: 1.5rem; | |
| flex-direction: column; | |
| } | |
| .spinner { | |
| border: 5px solid rgba(255, 255, 255, 0.3); | |
| border-radius: 50%; | |
| border-top: 5px solid #4f46e5; | |
| width: 50px; | |
| height: 50px; | |
| animation: spin 1s linear infinite; | |
| margin-bottom: 20px; | |
| } | |
| @keyframes spin { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| .control-panel { | |
| position: absolute; | |
| top: 20px; | |
| right: 20px; | |
| background: rgba(30, 30, 60, 0.8); | |
| backdrop-filter: blur(10px); | |
| border-radius: 8px; | |
| padding: 15px; | |
| z-index: 10; | |
| box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); | |
| max-height: 80vh; | |
| overflow-y: auto; | |
| } | |
| .control-panel::-webkit-scrollbar { | |
| width: 6px; | |
| } | |
| .control-panel::-webkit-scrollbar-thumb { | |
| background: #4f46e5; | |
| border-radius: 3px; | |
| } | |
| .control-panel h3 { | |
| color: white; | |
| margin-bottom: 15px; | |
| font-weight: 600; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .control-section { | |
| margin-bottom: 20px; | |
| } | |
| .control-section:last-child { | |
| margin-bottom: 0; | |
| } | |
| .control-label { | |
| color: #d1d5db; | |
| margin-bottom: 8px; | |
| display: block; | |
| font-size: 0.9rem; | |
| } | |
| .slider-container { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .slider { | |
| flex-grow: 1; | |
| -webkit-appearance: none; | |
| height: 6px; | |
| background: #4b5563; | |
| border-radius: 3px; | |
| outline: none; | |
| } | |
| .slider::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| background: #4f46e5; | |
| cursor: pointer; | |
| } | |
| .slider-value { | |
| color: white; | |
| width: 40px; | |
| text-align: center; | |
| font-size: 0.85rem; | |
| } | |
| .toggle-switch { | |
| position: relative; | |
| display: inline-block; | |
| width: 50px; | |
| height: 24px; | |
| } | |
| .toggle-switch input { | |
| opacity: 0; | |
| width: 0; | |
| height: 0; | |
| } | |
| .toggle-slider { | |
| position: absolute; | |
| cursor: pointer; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background-color: #4b5563; | |
| transition: .4s; | |
| border-radius: 24px; | |
| } | |
| .toggle-slider:before { | |
| position: absolute; | |
| content: ""; | |
| height: 16px; | |
| width: 16px; | |
| left: 4px; | |
| bottom: 4px; | |
| background-color: white; | |
| transition: .4s; | |
| border-radius: 50%; | |
| } | |
| input:checked + .toggle-slider { | |
| background-color: #4f46e5; | |
| } | |
| input:checked + .toggle-slider:before { | |
| transform: translateX(26px); | |
| } | |
| .btn { | |
| background: #4f46e5; | |
| color: white; | |
| border: none; | |
| padding: 8px 15px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 0.9rem; | |
| transition: all 0.3s ease; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .btn:hover { | |
| background: #4338ca; | |
| transform: translateY(-1px); | |
| } | |
| .btn-secondary { | |
| background: #374151; | |
| } | |
| .btn-secondary:hover { | |
| background: #4b5563; | |
| } | |
| .btn-group { | |
| display: flex; | |
| gap: 10px; | |
| margin-top: 10px; | |
| } | |
| .file-input { | |
| display: none; | |
| } | |
| .file-label { | |
| display: block; | |
| background: #4f46e5; | |
| color: white; | |
| padding: 10px 15px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| text-align: center; | |
| transition: all 0.3s ease; | |
| margin-bottom: 15px; | |
| } | |
| .file-label:hover { | |
| background: #4338ca; | |
| } | |
| .model-info { | |
| position: absolute; | |
| bottom: 20px; | |
| left: 20px; | |
| background: rgba(30, 30, 60, 0.8); | |
| backdrop-filter: blur(10px); | |
| border-radius: 8px; | |
| padding: 12px 15px; | |
| color: white; | |
| font-size: 0.85rem; | |
| z-index: 10; | |
| box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); | |
| } | |
| .model-info h4 { | |
| margin-bottom: 5px; | |
| font-weight: 600; | |
| color: #d1d5db; | |
| } | |
| .model-info p { | |
| margin: 3px 0; | |
| } | |
| .controls-hint { | |
| position: absolute; | |
| bottom: 20px; | |
| right: 20px; | |
| background: rgba(30, 30, 60, 0.8); | |
| backdrop-filter: blur(10px); | |
| border-radius: 8px; | |
| padding: 12px 15px; | |
| color: white; | |
| font-size: 0.85rem; | |
| z-index: 10; | |
| box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); | |
| } | |
| .controls-hint h4 { | |
| margin-bottom: 8px; | |
| font-weight: 600; | |
| color: #d1d5db; | |
| } | |
| .controls-hint ul { | |
| list-style-type: none; | |
| padding: 0; | |
| margin: 0; | |
| } | |
| .controls-hint li { | |
| margin-bottom: 5px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .controls-hint li:last-child { | |
| margin-bottom: 0; | |
| } | |
| .tab-container { | |
| display: flex; | |
| border-bottom: 1px solid #374151; | |
| margin-bottom: 15px; | |
| } | |
| .tab { | |
| padding: 8px 15px; | |
| cursor: pointer; | |
| color: #9ca3af; | |
| font-size: 0.9rem; | |
| border-bottom: 2px solid transparent; | |
| transition: all 0.3s ease; | |
| } | |
| .tab.active { | |
| color: white; | |
| border-bottom: 2px solid #4f46e5; | |
| } | |
| .tab-content { | |
| display: none; | |
| } | |
| .tab-content.active { | |
| display: block; | |
| } | |
| .preset-btn { | |
| background: #374151; | |
| color: white; | |
| border: none; | |
| padding: 6px 12px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 0.8rem; | |
| transition: all 0.3s ease; | |
| margin-right: 8px; | |
| margin-bottom: 8px; | |
| } | |
| .preset-btn:hover { | |
| background: #4b5563; | |
| } | |
| .preset-btn.active { | |
| background: #4f46e5; | |
| } | |
| .color-picker { | |
| width: 30px; | |
| height: 30px; | |
| border-radius: 50%; | |
| border: 2px solid #4b5563; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| } | |
| .color-picker:hover { | |
| transform: scale(1.1); | |
| } | |
| .color-picker.active { | |
| border-color: white; | |
| box-shadow: 0 0 0 2px #4f46e5; | |
| } | |
| .color-palette { | |
| display: flex; | |
| gap: 10px; | |
| margin-top: 10px; | |
| } | |
| .dropdown { | |
| position: relative; | |
| display: inline-block; | |
| width: 100%; | |
| } | |
| .dropdown-btn { | |
| background: #374151; | |
| color: white; | |
| border: none; | |
| padding: 8px 15px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 0.9rem; | |
| width: 100%; | |
| text-align: left; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .dropdown-content { | |
| display: none; | |
| position: absolute; | |
| background: #1f2937; | |
| min-width: 100%; | |
| box-shadow: 0 8px 16px rgba(0,0,0,0.2); | |
| z-index: 1; | |
| border-radius: 6px; | |
| overflow: hidden; | |
| margin-top: 5px; | |
| } | |
| .dropdown-content a { | |
| color: white; | |
| padding: 10px 15px; | |
| text-decoration: none; | |
| display: block; | |
| font-size: 0.85rem; | |
| transition: background 0.3s; | |
| } | |
| .dropdown-content a:hover { | |
| background: #374151; | |
| } | |
| .dropdown:hover .dropdown-content { | |
| display: block; | |
| } | |
| .checkbox-container { | |
| display: flex; | |
| align-items: center; | |
| margin-bottom: 10px; | |
| } | |
| .checkbox-container input { | |
| margin-right: 10px; | |
| } | |
| .checkbox-label { | |
| color: #d1d5db; | |
| font-size: 0.9rem; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-900 text-white min-h-screen"> | |
| <div class="container mx-auto px-4 py-8"> | |
| <header class="mb-8"> | |
| <h1 class="text-3xl font-bold text-center mb-2 bg-gradient-to-r from-purple-500 to-blue-500 bg-clip-text text-transparent">3D Model Viewer</h1> | |
| <p class="text-center text-gray-400 max-w-2xl mx-auto">Upload and interact with your 3D models. Supports OBJ, GLTF, and more formats with advanced rendering controls.</p> | |
| </header> | |
| <div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> | |
| <div class="lg:col-span-2"> | |
| <div class="model-container" id="model-container"> | |
| <div class="loading-overlay" id="loading-overlay"> | |
| <div class="spinner"></div> | |
| <p>Loading model...</p> | |
| </div> | |
| <div class="model-info" id="model-info"> | |
| <h4>Model Information</h4> | |
| <p id="model-name">No model loaded</p> | |
| <p id="model-vertices">Vertices: 0</p> | |
| <p id="model-faces">Faces: 0</p> | |
| </div> | |
| <div class="controls-hint"> | |
| <h4>Controls</h4> | |
| <ul> | |
| <li><i class="fas fa-arrows-alt"></i> Left click + drag: Rotate</li> | |
| <li><i class="fas fa-hand-paper"></i> Right click + drag: Pan</li> | |
| <li><i class="fas fa-search-plus"></i> Scroll: Zoom</li> | |
| </ul> | |
| </div> | |
| </div> | |
| <div class="mt-4 flex flex-wrap gap-3"> | |
| <label class="file-label"> | |
| <i class="fas fa-upload mr-2"></i> Upload Model | |
| <input type="file" id="file-input" class="file-input" accept=".obj,.gltf,.glb,.fbx,.dae,.stl,.ply" multiple> | |
| </label> | |
| <button id="reset-view" class="btn btn-secondary"> | |
| <i class="fas fa-sync-alt mr-2"></i> Reset View | |
| </button> | |
| <button id="screenshot" class="btn btn-secondary"> | |
| <i class="fas fa-camera mr-2"></i> Screenshot | |
| </button> | |
| <button id="fullscreen" class="btn btn-secondary"> | |
| <i class="fas fa-expand mr-2"></i> Fullscreen | |
| </button> | |
| </div> | |
| </div> | |
| <div class="control-panel"> | |
| <div class="tab-container"> | |
| <div class="tab active" data-tab="display">Display</div> | |
| <div class="tab" data-tab="lighting">Lighting</div> | |
| <div class="tab" data-tab="materials">Materials</div> | |
| </div> | |
| <div class="tab-content active" id="display-tab"> | |
| <div class="control-section"> | |
| <h3><i class="fas fa-eye"></i> Rendering Mode</h3> | |
| <div class="btn-group"> | |
| <button class="preset-btn active" data-mode="solid">Solid</button> | |
| <button class="preset-btn" data-mode="wireframe">Wireframe</button> | |
| <button class="preset-btn" data-mode="points">Points</button> | |
| </div> | |
| </div> | |
| <div class="control-section"> | |
| <h3><i class="fas fa-palette"></i> Background</h3> | |
| <div class="color-palette"> | |
| <div class="color-picker bg-gray-900 active" data-color="#111827"></div> | |
| <div class="color-picker bg-gray-800" data-color="#1f2937"></div> | |
| <div class="color-picker bg-gray-700" data-color="#374151"></div> | |
| <div class="color-picker bg-black" data-color="#000000"></div> | |
| <div class="color-picker bg-white" data-color="#ffffff"></div> | |
| </div> | |
| </div> | |
| <div class="control-section"> | |
| <label class="control-label">Model Opacity</label> | |
| <div class="slider-container"> | |
| <input type="range" min="0" max="100" value="100" class="slider" id="opacity-slider"> | |
| <span class="slider-value" id="opacity-value">100%</span> | |
| </div> | |
| </div> | |
| <div class="control-section"> | |
| <label class="control-label">Model Scale</label> | |
| <div class="slider-container"> | |
| <input type="range" min="10" max="500" value="100" class="slider" id="scale-slider"> | |
| <span class="slider-value" id="scale-value">100%</span> | |
| </div> | |
| </div> | |
| <div class="control-section"> | |
| <div class="checkbox-container"> | |
| <input type="checkbox" id="grid-toggle" checked> | |
| <label class="checkbox-label">Show Grid</label> | |
| </div> | |
| <div class="checkbox-container"> | |
| <input type="checkbox" id="axes-toggle" checked> | |
| <label class="checkbox-label">Show Axes</label> | |
| </div> | |
| <div class="checkbox-container"> | |
| <input type="checkbox" id="bounding-box-toggle"> | |
| <label class="checkbox-label">Show Bounding Box</label> | |
| </div> | |
| </div> | |
| <div class="control-section"> | |
| <h3><i class="fas fa-sliders-h"></i> Quality Presets</h3> | |
| <div> | |
| <button class="preset-btn active" data-quality="high">High</button> | |
| <button class="preset-btn" data-quality="medium">Medium</button> | |
| <button class="preset-btn" data-quality="low">Low</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="tab-content" id="lighting-tab"> | |
| <div class="control-section"> | |
| <h3><i class="fas fa-lightbulb"></i> Lighting</h3> | |
| <div class="checkbox-container"> | |
| <input type="checkbox" id="ambient-light-toggle" checked> | |
| <label class="checkbox-label">Ambient Light</label> | |
| </div> | |
| <div class="checkbox-container"> | |
| <input type="checkbox" id="directional-light-toggle" checked> | |
| <label class="checkbox-label">Directional Light</label> | |
| </div> | |
| <div class="checkbox-container"> | |
| <input type="checkbox" id="hemisphere-light-toggle"> | |
| <label class="checkbox-label">Hemisphere Light</label> | |
| </div> | |
| </div> | |
| <div class="control-section"> | |
| <label class="control-label">Light Intensity</label> | |
| <div class="slider-container"> | |
| <input type="range" min="0" max="200" value="100" class="slider" id="light-intensity-slider"> | |
| <span class="slider-value" id="light-intensity-value">100%</span> | |
| </div> | |
| </div> | |
| <div class="control-section"> | |
| <label class="control-label">Light Color</label> | |
| <input type="color" id="light-color-picker" value="#ffffff" class="w-full h-10"> | |
| </div> | |
| <div class="control-section"> | |
| <label class="control-label">Light Position X</label> | |
| <div class="slider-container"> | |
| <input type="range" min="-10" max="10" value="1" step="0.1" class="slider" id="light-x-slider"> | |
| <span class="slider-value" id="light-x-value">1.0</span> | |
| </div> | |
| </div> | |
| <div class="control-section"> | |
| <label class="control-label">Light Position Y</label> | |
| <div class="slider-container"> | |
| <input type="range" min="-10" max="10" value="1" step="0.1" class="slider" id="light-y-slider"> | |
| <span class="slider-value" id="light-y-value">1.0</span> | |
| </div> | |
| </div> | |
| <div class="control-section"> | |
| <label class="control-label">Light Position Z</label> | |
| <div class="slider-container"> | |
| <input type="range" min="-10" max="10" value="1" step="0.1" class="slider" id="light-z-slider"> | |
| <span class="slider-value" id="light-z-value">1.0</span> | |
| </div> | |
| </div> | |
| <div class="control-section"> | |
| <h3><i class="fas fa-moon"></i> Shadows</h3> | |
| <div class="checkbox-container"> | |
| <input type="checkbox" id="shadow-toggle"> | |
| <label class="checkbox-label">Enable Shadows</label> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="tab-content" id="materials-tab"> | |
| <div class="control-section"> | |
| <h3><i class="fas fa-paint-brush"></i> Material Type</h3> | |
| <div class="dropdown"> | |
| <button class="dropdown-btn"> | |
| <span id="current-material">Standard</span> | |
| <i class="fas fa-chevron-down"></i> | |
| </button> | |
| <div class="dropdown-content"> | |
| <a href="#" data-material="standard">Standard</a> | |
| <a href="#" data-material="phong">Phong</a> | |
| <a href="#" data-material="lambert">Lambert</a> | |
| <a href="#" data-material="basic">Basic</a> | |
| <a href="#" data-material="physical">Physical</a> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="control-section"> | |
| <label class="control-label">Material Color</label> | |
| <input type="color" id="material-color-picker" value="#888888" class="w-full h-10"> | |
| </div> | |
| <div class="control-section"> | |
| <label class="control-label">Roughness</label> | |
| <div class="slider-container"> | |
| <input type="range" min="0" max="100" value="50" class="slider" id="roughness-slider"> | |
| <span class="slider-value" id="roughness-value">50%</span> | |
| </div> | |
| </div> | |
| <div class="control-section"> | |
| <label class="control-label">Metalness</label> | |
| <div class="slider-container"> | |
| <input type="range" min="0" max="100" value="0" class="slider" id="metalness-slider"> | |
| <span class="slider-value" id="metalness-value">0%</span> | |
| </div> | |
| </div> | |
| <div class="control-section"> | |
| <label class="control-label">Emissive Intensity</label> | |
| <div class="slider-container"> | |
| <input type="range" min="0" max="100" value="0" class="slider" id="emissive-slider"> | |
| <span class="slider-value" id="emissive-value">0%</span> | |
| </div> | |
| </div> | |
| <div class="control-section"> | |
| <h3><i class="fas fa-texture"></i> Textures</h3> | |
| <label for="texture-upload" class="file-label"> | |
| <i class="fas fa-image mr-2"></i> Upload Texture | |
| <input type="file" id="texture-upload" class="file-input" accept="image/*"> | |
| </label> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // Initialize Three.js scene | |
| let scene, camera, renderer, controls, model, gridHelper, axesHelper, boundingBox; | |
| let ambientLight, directionalLight, hemisphereLight; | |
| let isModelLoaded = false; | |
| let currentFileType = ''; | |
| // Initialize the 3D viewer | |
| function init() { | |
| // Get container dimensions | |
| const container = document.getElementById('model-container'); | |
| const width = container.clientWidth; | |
| const height = container.clientHeight; | |
| // Create scene | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x111827); | |
| // Create camera | |
| camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000); | |
| camera.position.set(5, 5, 5); | |
| // Create renderer | |
| renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(width, height); | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| container.appendChild(renderer.domElement); | |
| // Add controls | |
| controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.05; | |
| controls.screenSpacePanning = false; | |
| controls.minDistance = 1; | |
| controls.maxDistance = 100; | |
| // Add lights | |
| setupLights(); | |
| // Add helpers | |
| setupHelpers(); | |
| // Event listeners | |
| setupEventListeners(); | |
| // Start animation loop | |
| animate(); | |
| } | |
| function setupLights() { | |
| // Ambient light | |
| ambientLight = new THREE.AmbientLight(0x404040, 0.5); | |
| scene.add(ambientLight); | |
| // Directional light | |
| directionalLight = new THREE.DirectionalLight(0xffffff, 1); | |
| directionalLight.position.set(1, 1, 1); | |
| directionalLight.castShadow = true; | |
| directionalLight.shadow.mapSize.width = 1024; | |
| directionalLight.shadow.mapSize.height = 1024; | |
| scene.add(directionalLight); | |
| // Hemisphere light | |
| hemisphereLight = new THREE.HemisphereLight(0xffffbb, 0x080820, 0.5); | |
| hemisphereLight.visible = false; | |
| scene.add(hemisphereLight); | |
| } | |
| function setupHelpers() { | |
| // Grid helper | |
| gridHelper = new THREE.GridHelper(10, 10); | |
| gridHelper.position.y = -0.5; | |
| scene.add(gridHelper); | |
| // Axes helper | |
| axesHelper = new THREE.AxesHelper(5); | |
| scene.add(axesHelper); | |
| // Bounding box (initially hidden) | |
| boundingBox = new THREE.Box3Helper(new THREE.Box3(), 0xffff00); | |
| boundingBox.visible = false; | |
| scene.add(boundingBox); | |
| } | |
| function setupEventListeners() { | |
| // File input | |
| const fileInput = document.getElementById('file-input'); | |
| fileInput.addEventListener('change', handleFileUpload); | |
| // Texture upload | |
| const textureUpload = document.getElementById('texture-upload'); | |
| textureUpload.addEventListener('change', handleTextureUpload); | |
| // Reset view | |
| document.getElementById('reset-view').addEventListener('click', resetView); | |
| // Screenshot | |
| document.getElementById('screenshot').addEventListener('click', takeScreenshot); | |
| // Fullscreen | |
| document.getElementById('fullscreen').addEventListener('click', toggleFullscreen); | |
| // Tabs | |
| document.querySelectorAll('.tab').forEach(tab => { | |
| tab.addEventListener('click', () => { | |
| document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); | |
| document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); | |
| tab.classList.add('active'); | |
| document.getElementById(`${tab.dataset.tab}-tab`).classList.add('active'); | |
| }); | |
| }); | |
| // Rendering mode buttons | |
| document.querySelectorAll('[data-mode]').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| document.querySelectorAll('[data-mode]').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| changeRenderingMode(btn.dataset.mode); | |
| }); | |
| }); | |
| // Background color pickers | |
| document.querySelectorAll('.color-picker').forEach(picker => { | |
| picker.addEventListener('click', () => { | |
| document.querySelectorAll('.color-picker').forEach(p => p.classList.remove('active')); | |
| picker.classList.add('active'); | |
| scene.background = new THREE.Color(picker.dataset.color); | |
| }); | |
| }); | |
| // Quality presets | |
| document.querySelectorAll('[data-quality]').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| document.querySelectorAll('[data-quality]').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| applyQualityPreset(btn.dataset.quality); | |
| }); | |
| }); | |
| // Material dropdown | |
| document.querySelectorAll('.dropdown-content a').forEach(item => { | |
| item.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| document.getElementById('current-material').textContent = item.textContent; | |
| changeMaterialType(item.dataset.material); | |
| }); | |
| }); | |
| // Sliders | |
| document.getElementById('opacity-slider').addEventListener('input', updateOpacity); | |
| document.getElementById('scale-slider').addEventListener('input', updateScale); | |
| document.getElementById('light-intensity-slider').addEventListener('input', updateLightIntensity); | |
| document.getElementById('light-x-slider').addEventListener('input', updateLightPosition); | |
| document.getElementById('light-y-slider').addEventListener('input', updateLightPosition); | |
| document.getElementById('light-z-slider').addEventListener('input', updateLightPosition); | |
| document.getElementById('roughness-slider').addEventListener('input', updateMaterialProperties); | |
| document.getElementById('metalness-slider').addEventListener('input', updateMaterialProperties); | |
| document.getElementById('emissive-slider').addEventListener('input', updateMaterialProperties); | |
| // Color pickers | |
| document.getElementById('light-color-picker').addEventListener('input', updateLightColor); | |
| document.getElementById('material-color-picker').addEventListener('input', updateMaterialColor); | |
| // Toggles | |
| document.getElementById('grid-toggle').addEventListener('change', toggleGrid); | |
| document.getElementById('axes-toggle').addEventListener('change', toggleAxes); | |
| document.getElementById('bounding-box-toggle').addEventListener('change', toggleBoundingBox); | |
| document.getElementById('ambient-light-toggle').addEventListener('change', toggleAmbientLight); | |
| document.getElementById('directional-light-toggle').addEventListener('change', toggleDirectionalLight); | |
| document.getElementById('hemisphere-light-toggle').addEventListener('change', toggleHemisphereLight); | |
| document.getElementById('shadow-toggle').addEventListener('change', toggleShadows); | |
| // Window resize | |
| window.addEventListener('resize', onWindowResize); | |
| } | |
| function handleFileUpload(event) { | |
| const files = event.target.files; | |
| if (files.length === 0) return; | |
| const file = files[0]; | |
| const fileName = file.name; | |
| const fileExtension = fileName.split('.').pop().toLowerCase(); | |
| // Show loading overlay | |
| const loadingOverlay = document.getElementById('loading-overlay'); | |
| loadingOverlay.style.display = 'flex'; | |
| // Remove previous model if exists | |
| if (model) { | |
| scene.remove(model); | |
| if (boundingBox) { | |
| boundingBox.box = new THREE.Box3(); | |
| } | |
| } | |
| // Create file reader | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| const contents = e.target.result; | |
| try { | |
| // Load based on file type | |
| switch(fileExtension) { | |
| case 'obj': | |
| loadOBJModel(contents, fileName); | |
| currentFileType = 'obj'; | |
| break; | |
| case 'gltf': | |
| case 'glb': | |
| loadGLTFModel(contents, fileName); | |
| currentFileType = 'gltf'; | |
| break; | |
| default: | |
| alert('Unsupported file format. Please upload an OBJ or GLTF file.'); | |
| loadingOverlay.style.display = 'none'; | |
| return; | |
| } | |
| } catch (error) { | |
| console.error('Error loading model:', error); | |
| alert('Error loading model. Please check the console for details.'); | |
| loadingOverlay.style.display = 'none'; | |
| } | |
| }; | |
| reader.onerror = function() { | |
| alert('Error reading file'); | |
| loadingOverlay.style.display = 'none'; | |
| }; | |
| if (fileExtension === 'glb') { | |
| reader.readAsArrayBuffer(file); | |
| } else { | |
| reader.readAsText(file); | |
| } | |
| } | |
| function loadOBJModel(objContents, fileName) { | |
| const loader = new THREE.OBJLoader(); | |
| try { | |
| model = loader.parse(objContents); | |
| // Center and scale the model | |
| centerAndScaleModel(model); | |
| // Add to scene | |
| scene.add(model); | |
| // Update model info | |
| updateModelInfo(model, fileName); | |
| // Hide loading overlay | |
| document.getElementById('loading-overlay').style.display = 'none'; | |
| isModelLoaded = true; | |
| // Update bounding box | |
| updateBoundingBox(model); | |
| } catch (error) { | |
| console.error('Error parsing OBJ:', error); | |
| document.getElementById('loading-overlay').style.display = 'none'; | |
| alert('Error parsing OBJ file. Please check the console for details.'); | |
| } | |
| } | |
| function loadGLTFModel(gltfContents, fileName) { | |
| const loader = new THREE.GLTFLoader(); | |
| try { | |
| let data; | |
| if (currentFileType === 'glb') { | |
| data = new Uint8Array(gltfContents); | |
| } else { | |
| data = gltfContents; | |
| } | |
| loader.parse(data, '', (gltf) => { | |
| model = gltf.scene; | |
| // Center and scale the model | |
| centerAndScaleModel(model); | |
| // Add to scene | |
| scene.add(model); | |
| // Update model info | |
| updateModelInfo(model, fileName); | |
| // Hide loading overlay | |
| document.getElementById('loading-overlay').style.display = 'none'; | |
| isModelLoaded = true; | |
| // Update bounding box | |
| updateBoundingBox(model); | |
| }, (error) => { | |
| console.error('Error loading GLTF:', error); | |
| document.getElementById('loading-overlay').style.display = 'none'; | |
| alert('Error loading GLTF file. Please check the console for details.'); | |
| }); | |
| } catch (error) { | |
| console.error('Error parsing GLTF:', error); | |
| document.getElementById('loading-overlay').style.display = 'none'; | |
| alert('Error parsing GLTF file. Please check the console for details.'); | |
| } | |
| } | |
| function centerAndScaleModel(model) { | |
| // Compute bounding box | |
| const box = new THREE.Box3().setFromObject(model); | |
| const center = box.getCenter(new THREE.Vector3()); | |
| const size = box.getSize(new THREE.Vector3()); | |
| // Center the model | |
| model.position.x += (model.position.x - center.x); | |
| model.position.y += (model.position.y - center.y); | |
| model.position.z += (model.position.z - center.z); | |
| // Scale to fit in view | |
| const maxDim = Math.max(size.x, size.y, size.z); | |
| const scale = 5 / maxDim; | |
| model.scale.set(scale, scale, scale); | |
| } | |
| function updateModelInfo(model, fileName) { | |
| let vertices = 0; | |
| let faces = 0; | |
| model.traverse(function(child) { | |
| if (child.isMesh) { | |
| if (child.geometry) { | |
| vertices += child.geometry.attributes.position.count; | |
| if (child.geometry.index) { | |
| faces += child.geometry.index.count / 3; | |
| } else { | |
| faces += child.geometry.attributes.position.count / 3; | |
| } | |
| } | |
| } | |
| }); | |
| document.getElementById('model-name').textContent = fileName; | |
| document.getElementById('model-vertices').textContent = `Vertices: ${vertices.toLocaleString()}`; | |
| document.getElementById('model-faces').textContent = `Faces: ${faces.toLocaleString()}`; | |
| } | |
| function updateBoundingBox(model) { | |
| if (!model || !boundingBox) return; | |
| const box = new THREE.Box3().setFromObject(model); | |
| boundingBox.box = box; | |
| if (document.getElementById('bounding-box-toggle').checked) { | |
| boundingBox.visible = true; | |
| } | |
| } | |
| function handleTextureUpload(event) { | |
| if (!isModelLoaded) { | |
| alert('Please load a model first'); | |
| return; | |
| } | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| const texture = new THREE.TextureLoader().load(e.target.result, () => { | |
| // Apply texture to all materials in the model | |
| model.traverse(function(child) { | |
| if (child.isMesh && child.material) { | |
| child.material.map = texture; | |
| child.material.needsUpdate = true; | |
| } | |
| }); | |
| }); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| function resetView() { | |
| if (!isModelLoaded) return; | |
| // Reset camera position | |
| camera.position.set(5, 5, 5); | |
| controls.target.set(0, 0, 0); | |
| controls.update(); | |
| } | |
| function takeScreenshot() { | |
| if (!isModelLoaded) { | |
| alert('Please load a model first'); | |
| return; | |
| } | |
| // Render the scene to a data URL | |
| renderer.render(scene, camera); | |
| const dataURL = renderer.domElement.toDataURL('image/png'); | |
| // Create a download link | |
| const link = document.createElement('a'); | |
| link.href = dataURL; | |
| link.download = '3d-model-screenshot.png'; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| } | |
| function toggleFullscreen() { | |
| const container = document.getElementById('model-container'); | |
| if (!document.fullscreenElement) { | |
| container.requestFullscreen().catch(err => { | |
| alert(`Error attempting to enable fullscreen: ${err.message}`); | |
| }); | |
| } else { | |
| document.exitFullscreen(); | |
| } | |
| } | |
| function changeRenderingMode(mode) { | |
| if (!isModelLoaded) return; | |
| model.traverse(function(child) { | |
| if (child.isMesh) { | |
| switch(mode) { | |
| case 'solid': | |
| child.material.wireframe = false; | |
| child.material.pointCloud = false; | |
| break; | |
| case 'wireframe': | |
| child.material.wireframe = true; | |
| child.material.pointCloud = false; | |
| break; | |
| case 'points': | |
| child.material.wireframe = false; | |
| child.material.pointCloud = true; | |
| break; | |
| } | |
| } | |
| }); | |
| } | |
| function applyQualityPreset(quality) { | |
| switch(quality) { | |
| case 'high': | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| renderer.antialias = true; | |
| break; | |
| case 'medium': | |
| renderer.setPixelRatio(1); | |
| renderer.antialias = true; | |
| break; | |
| case 'low': | |
| renderer.setPixelRatio(1); | |
| renderer.antialias = false; | |
| break; | |
| } | |
| // Force resize to apply changes | |
| onWindowResize(); | |
| } | |
| function changeMaterialType(type) { | |
| if (!isModelLoaded) return; | |
| model.traverse(function(child) { | |
| if (child.isMesh) { | |
| const currentMaterial = child.material; | |
| let newMaterial; | |
| // Preserve some properties | |
| const color = currentMaterial.color || new THREE.Color(0x888888); | |
| const map = currentMaterial.map || null; | |
| const opacity = currentMaterial.opacity || 1; | |
| switch(type) { | |
| case 'standard': | |
| newMaterial = new THREE.MeshStandardMaterial({ | |
| color: color, | |
| map: map, | |
| roughness: 0.5, | |
| metalness: 0, | |
| opacity: opacity, | |
| transparent: opacity < 1 | |
| }); | |
| break; | |
| case 'phong': | |
| newMaterial = new THREE.MeshPhongMaterial({ | |
| color: color, | |
| map: map, | |
| shininess: 30, | |
| opacity: opacity, | |
| transparent: opacity < 1 | |
| }); | |
| break; | |
| case 'lambert': | |
| newMaterial = new THREE.MeshLambertMaterial({ | |
| color: color, | |
| map: map, | |
| opacity: opacity, | |
| transparent: opacity < 1 | |
| }); | |
| break; | |
| case 'basic': | |
| newMaterial = new THREE.MeshBasicMaterial({ | |
| color: color, | |
| map: map, | |
| opacity: opacity, | |
| transparent: opacity < 1 | |
| }); | |
| break; | |
| case 'physical': | |
| newMaterial = new THREE.MeshPhysicalMaterial({ | |
| color: color, | |
| map: map, | |
| roughness: 0.5, | |
| metalness: 0, | |
| clearcoat: 1, | |
| clearcoatRoughness: 0.1, | |
| opacity: opacity, | |
| transparent: opacity < 1 | |
| }); | |
| break; | |
| } | |
| child.material = newMaterial; | |
| } | |
| }); | |
| // Update material controls to match new material | |
| updateMaterialControls(); | |
| } | |
| function updateMaterialControls() { | |
| if (!isModelLoaded) return; | |
| // Get the first material in the model to sync controls | |
| let firstMaterial = null; | |
| model.traverse(function(child) { | |
| if (child.isMesh && !firstMaterial) { | |
| firstMaterial = child.material; | |
| } | |
| }); | |
| if (!firstMaterial) return; | |
| // Update color picker | |
| if (firstMaterial.color) { | |
| document.getElementById('material-color-picker').value = `#${firstMaterial.color.getHexString()}`; | |
| } | |
| // Update sliders based on material type | |
| if (firstMaterial.isMeshStandardMaterial || firstMaterial.isMeshPhysicalMaterial) { | |
| document.getElementById('roughness-slider').value = firstMaterial.roughness * 100; | |
| document.getElementById('roughness-value').textContent = `${Math.round(firstMaterial.roughness * 100)}%`; | |
| document.getElementById('metalness-slider').value = firstMaterial.metalness * 100; | |
| document.getElementById('metalness-value').textContent = `${Math.round(firstMaterial.metalness * 100)}%`; | |
| } | |
| if (firstMaterial.emissive) { | |
| document.getElementById('emissive-slider').value = firstMaterial.emissiveIntensity * 100; | |
| document.getElementById('emissive-value').textContent = `${Math.round(firstMaterial.emissiveIntensity * 100)}%`; | |
| } | |
| } | |
| function updateOpacity() { | |
| if (!isModelLoaded) return; | |
| const opacity = document.getElementById('opacity-slider').value / 100; | |
| document.getElementById('opacity-value').textContent = `${document.getElementById('opacity-slider').value}%`; | |
| model.traverse(function(child) { | |
| if (child.isMesh) { | |
| child.material.opacity = opacity; | |
| child.material.transparent = opacity < 1; | |
| } | |
| }); | |
| } | |
| function updateScale() { | |
| if (!isModelLoaded) return; | |
| const scale = document.getElementById('scale-slider').value / 100; | |
| document.getElementById('scale-value').textContent = `${document.getElementById('scale-slider').value}%`; | |
| model.scale.set(scale, scale, scale); | |
| updateBoundingBox(model); | |
| } | |
| function updateLightIntensity() { | |
| const intensity = document.getElementById('light-intensity-slider').value / 100; | |
| document.getElementById('light-intensity-value').textContent = `${document.getElementById('light-intensity-slider').value}%`; | |
| if (ambientLight) ambientLight.intensity = intensity * 0.5; | |
| if (directionalLight) directionalLight.intensity = intensity; | |
| if (hemisphereLight) hemisphereLight.intensity = intensity * 0.5; | |
| } | |
| function updateLightPosition() { | |
| const x = parseFloat(document.getElementById('light-x-slider').value); | |
| const y = parseFloat(document.getElementById('light-y-slider').value); | |
| const z = parseFloat(document.getElementById('light-z-slider').value); | |
| document.getElementById('light-x-value').textContent = x.toFixed(1); | |
| document.getElementById('light-y-value').textContent = y.toFixed(1); | |
| document.getElementById('light-z-value').textContent = z.toFixed(1); | |
| if (directionalLight) directionalLight.position.set(x, y, z); | |
| } | |
| function updateLightColor() { | |
| const color = new THREE.Color(document.getElementById('light-color-picker').value); | |
| if (directionalLight) directionalLight.color = color; | |
| if (hemisphereLight) hemisphereLight.color = color; | |
| } | |
| function updateMaterialColor() { | |
| if (!isModelLoaded) return; | |
| const color = new THREE.Color(document.getElementById('material-color-picker').value); | |
| model.traverse(function(child) { | |
| if (child.isMesh) { | |
| child.material.color = color; | |
| } | |
| }); | |
| } | |
| function updateMaterialProperties() { | |
| if (!isModelLoaded) return; | |
| const roughness = document.getElementById('roughness-slider').value / 100; | |
| const metalness = document.getElementById('metalness-slider').value / 100; | |
| const emissive = document.getElementById('emissive-slider').value / 100; | |
| document.getElementById('roughness-value').textContent = `${document.getElementById('roughness-slider').value}%`; | |
| document.getElementById('metalness-value').textContent = `${document.getElementById('metalness-slider').value}%`; | |
| document.getElementById('emissive-value').textContent = `${document.getElementById('emissive-slider').value}%`; | |
| model.traverse(function(child) { | |
| if (child.isMesh) { | |
| if (child.material.isMeshStandardMaterial || child.material.isMeshPhysicalMaterial) { | |
| child.material.roughness = roughness; | |
| child.material.metalness = metalness; | |
| } | |
| if (child.material.emissive) { | |
| child.material.emissiveIntensity = emissive; | |
| } | |
| } | |
| }); | |
| } | |
| function toggleGrid() { | |
| gridHelper.visible = document.getElementById('grid-toggle').checked; | |
| } | |
| function toggleAxes() { | |
| axesHelper.visible = document.getElementById('axes-toggle').checked; | |
| } | |
| function toggleBoundingBox() { | |
| boundingBox.visible = document.getElementById('bounding-box-toggle').checked; | |
| } | |
| function toggleAmbientLight() { | |
| ambientLight.visible = document.getElementById('ambient-light-toggle').checked; | |
| } | |
| function toggleDirectionalLight() { | |
| directionalLight.visible = document.getElementById('directional-light-toggle').checked; | |
| } | |
| function toggleHemisphereLight() { | |
| hemisphereLight.visible = document.getElementById('hemisphere-light-toggle').checked; | |
| } | |
| function toggleShadows() { | |
| renderer.shadowMap.enabled = document.getElementById('shadow-toggle').checked; | |
| if (directionalLight) directionalLight.castShadow = document.getElementById('shadow-toggle').checked; | |
| model.traverse(function(child) { | |
| if (child.isMesh) { | |
| child.castShadow = document.getElementById('shadow-toggle').checked; | |
| child.receiveShadow = document.getElementById('shadow-toggle').checked; | |
| } | |
| }); | |
| } | |
| function onWindowResize() { | |
| const container = document.getElementById('model-container'); | |
| const width = container.clientWidth; | |
| const height = container.clientHeight; | |
| camera.aspect = width / height; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(width, height); | |
| } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| } | |
| // Initialize the app when the page loads | |
| window.addEventListener('load', init); | |
| </script> | |
| <p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=Sal-ONE/3d-model-viewer" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> | |
| </html> |