Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>3D STL & OBJ Editor v.0.0.5</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <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> | |
| <!-- Added OBJLoader script --> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/OBJLoader.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/exporters/STLExporter.js"></script> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" /> | |
| <style> | |
| /* Keep the render canvas full size */ | |
| #renderCanvas { | |
| width: 100%; | |
| height: 100%; | |
| display: block; | |
| } | |
| .transform-controls { | |
| transition: all 0.3s ease; | |
| } | |
| .transform-controls:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); | |
| } | |
| .sidebar { | |
| transition: transform 0.3s ease; | |
| } | |
| .sidebar.collapsed { | |
| transform: translateX(-90%); | |
| } | |
| .model-list-item { | |
| transition: all 0.2s ease; | |
| } | |
| .model-list-item:hover { | |
| background-color: rgba(59, 130, 246, 0.1); | |
| } | |
| .tab-button { | |
| transition: all 0.2s ease; | |
| } | |
| .tab-button.active { | |
| border-bottom: 2px solid #3b82f6; | |
| color: #3b82f6; | |
| } | |
| .color-picker { | |
| -webkit-appearance: none; | |
| -moz-appearance: none; | |
| appearance: none; | |
| width: 30px; | |
| height: 30px; | |
| border: none; | |
| cursor: pointer; | |
| border-radius: 50%; | |
| } | |
| .color-picker::-webkit-color-swatch { | |
| border-radius: 50%; | |
| border: 2px solid #fff; | |
| box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1); | |
| } | |
| .export-options { | |
| display: none; | |
| position: absolute; | |
| right: 10px; | |
| top: 40px; | |
| background: white; | |
| border-radius: 4px; | |
| box-shadow: 0 2px 10px rgba(0,0,0,0.1); | |
| z-index: 100; | |
| padding: 8px; | |
| min-width: 200px; | |
| } | |
| .export-options.show { | |
| display: block; | |
| } | |
| .export-option { | |
| padding: 8px 12px; | |
| cursor: pointer; | |
| border-radius: 4px; | |
| } | |
| .export-option:hover { | |
| background-color: #f3f4f6; | |
| } | |
| /* New CSS for the transform handle */ | |
| #transformHandle { | |
| width: 100%; | |
| height: 6px; | |
| background-color: #ccc; | |
| cursor: ns-resize; | |
| margin-bottom: 4px; | |
| } | |
| /* Make sure the transform controls have a default height and hidden overflow */ | |
| #transformControls { | |
| height: 40px; | |
| overflow: hidden; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-100 h-screen flex flex-col"> | |
| <!-- Header --> | |
| <header class="bg-white shadow-sm py-3 px-4 flex items-center justify-between"> | |
| <div class="flex items-center space-x-2"> | |
| <i class="fas fa-cube text-blue-500 text-2xl"></i> | |
| <h1 class="text-xl font-bold text-gray-800">3D STL & OBJ Editor v.0.0.5 (Broken, fix is being worked on.)</h1> | |
| </div> | |
| <div class="flex space-x-4"> | |
| <button id="toggleSidebar" class="p-2 rounded-full hover:bg-gray-200 transition"> | |
| <i class="fas fa-bars text-gray-600"></i> | |
| </button> | |
| </div> | |
| </header> | |
| <div class="flex flex-1 overflow-hidden"> | |
| <!-- Sidebar --> | |
| <div id="sidebar" class="sidebar w-64 bg-white shadow-md flex flex-col transition-all duration-300"> | |
| <div class="p-4 border-b"> | |
| <h2 class="font-semibold text-gray-700">Project</h2> | |
| <div class="mt-2 flex space-x-2"> | |
| <button id="loadModelBtn" class="flex-1 bg-blue-500 hover:bg-blue-600 text-white py-2 px-3 rounded text-sm flex items-center justify-center"> | |
| <i class="fas fa-folder-open mr-2"></i> Load Model | |
| </button> | |
| <!-- Updated accept attribute to support both .stl and .obj --> | |
| <input type="file" id="fileInput" accept=".stl,.obj" class="hidden" /> | |
| <div class="relative"> | |
| <button id="exportModelBtn" class="flex-1 bg-gray-200 hover:bg-gray-300 text-gray-700 py-2 px-3 rounded text-sm flex items-center justify-center"> | |
| <i class="fas fa-download mr-2"></i> Export | |
| </button> | |
| <div id="exportOptions" class="export-options"> | |
| <div class="export-option" id="exportSelectedBtn"> | |
| <i class="fas fa-cube mr-2"></i> Export Selected | |
| </div> | |
| <div class="export-option" id="exportAllBtn"> | |
| <i class="fas fa-cubes mr-2"></i> Export All | |
| </div> | |
| <div class="border-t my-1"></div> | |
| <div class="export-option" id="exportSceneBtn"> | |
| <i class="fas fa-scene mr-2"></i> Export Scene | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="flex border-b"> | |
| <button id="modelsTab" class="tab-button flex-1 py-2 text-sm font-medium active">Models</button> | |
| <button id="settingsTab" class="tab-button flex-1 py-2 text-sm font-medium">Settings</button> | |
| </div> | |
| <div id="modelsPanel" class="flex-1 overflow-y-auto p-4"> | |
| <div class="mb-4"> | |
| <h3 class="text-sm font-medium text-gray-700 mb-2">Loaded Models</h3> | |
| <div id="modelList" class="space-y-2"> | |
| <!-- Models will be added here dynamically --> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="settingsPanel" class="hidden flex-1 overflow-y-auto p-4"> | |
| <div class="mb-4"> | |
| <h3 class="text-sm font-medium text-gray-700 mb-2">Scene Settings</h3> | |
| <div class="space-y-3"> | |
| <div> | |
| <label class="block text-xs text-gray-500 mb-1">Background Color</label> | |
| <input type="color" id="bgColorPicker" class="color-picker" value="#f3f4f6" /> | |
| </div> | |
| <div> | |
| <label class="block text-xs text-gray-500 mb-1">Grid Visibility</label> | |
| <div class="flex items-center"> | |
| <input type="checkbox" id="gridToggle" class="mr-2" checked /> | |
| <span class="text-sm">Show Grid</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="mb-4"> | |
| <h3 class="text-sm font-medium text-gray-700 mb-2">Camera Controls</h3> | |
| <div class="space-y-3"> | |
| <div> | |
| <label class="block text-xs text-gray-500 mb-1">Rotation Speed</label> | |
| <input type="range" id="rotationSpeed" min="0.1" max="2" step="0.1" value="1" class="w-full" /> | |
| </div> | |
| <div> | |
| <label class="block text-xs text-gray-500 mb-1">Zoom Speed</label> | |
| <input type="range" id="zoomSpeed" min="0.1" max="2" step="0.1" value="1" class="w-full" /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Main Content --> | |
| <div class="flex-1 flex flex-col"> | |
| <!-- Toolbar --> | |
| <div class="bg-white border-b p-2 flex items-center justify-between"> | |
| <div class="flex space-x-2"> | |
| <button id="selectTool" class="p-2 rounded hover:bg-gray-200 text-blue-500"> | |
| <i class="fas fa-mouse-pointer"></i> | |
| </button> | |
| <button id="moveTool" class="p-2 rounded hover:bg-gray-200"> | |
| <i class="fas fa-arrows-alt"></i> | |
| </button> | |
| <button id="rotateTool" class="p-2 rounded hover:bg-gray-200"> | |
| <i class="fas fa-sync-alt"></i> | |
| </button> | |
| <button id="scaleTool" class="p-2 rounded hover:bg-gray-200"> | |
| <i class="fas fa-expand-alt"></i> | |
| </button> | |
| </div> | |
| <div class="flex space-x-2"> | |
| <button id="resetViewBtn" class="p-2 rounded hover:bg-gray-200"> | |
| <i class="fas fa-home"></i> Reset View | |
| </button> | |
| </div> | |
| </div> | |
| <!-- 3D Viewport --> | |
| <div id="viewportContainer" class="flex-1 relative"> | |
| <div id="renderCanvas"></div> | |
| <div id="loadingOverlay" class="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center hidden"> | |
| <div class="bg-white p-6 rounded-lg shadow-lg flex flex-col items-center"> | |
| <div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500 mb-4"></div> | |
| <p class="text-gray-700">Loading model...</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Transform Controls with draggable handle --> | |
| <div id="transformControls" class="bg-white border-t p-4 transform-controls hidden"> | |
| <!-- Draggable handle --> | |
| <div id="transformHandle"></div> | |
| <h3 class="text-sm font-medium text-gray-700 mb-3">Transform</h3> | |
| <div class="grid grid-cols-3 gap-4"> | |
| <div> | |
| <label class="block text-xs text-gray-500 mb-1">Position X</label> | |
| <input type="number" id="posX" class="w-full p-2 border rounded text-sm" step="0.1" /> | |
| </div> | |
| <div> | |
| <label class="block text-xs text-gray-500 mb-1">Position Y</label> | |
| <input type="number" id="posY" class="w-full p-2 border rounded text-sm" step="0.1" /> | |
| </div> | |
| <div> | |
| <label class="block text-xs text-gray-500 mb-1">Position Z</label> | |
| <input type="number" id="posZ" class="w-full p-2 border rounded text-sm" step="0.1" /> | |
| </div> | |
| <div> | |
| <label class="block text-xs text-gray-500 mb-1">Rotation X</label> | |
| <input type="number" id="rotX" class="w-full p-2 border rounded text-sm" step="1" /> | |
| </div> | |
| <div> | |
| <label class="block text-xs text-gray-500 mb-1">Rotation Y</label> | |
| <input type="number" id="rotY" class="w-full p-2 border rounded text-sm" step="1" /> | |
| </div> | |
| <div> | |
| <label class="block text-xs text-gray-500 mb-1">Rotation Z</label> | |
| <input type="number" id="rotZ" class="w-full p-2 border rounded text-sm" step="1" /> | |
| </div> | |
| <div> | |
| <label class="block text-xs text-gray-500 mb-1">Scale X</label> | |
| <input type="number" id="scaleX" class="w-full p-2 border rounded text-sm" value="1" step="0.1" /> | |
| </div> | |
| <div> | |
| <label class="block text-xs text-gray-500 mb-1">Scale Y</label> | |
| <input type="number" id="scaleY" class="w-full p-2 border rounded text-sm" value="1" step="0.1" /> | |
| </div> | |
| <div> | |
| <label class="block text-xs text-gray-500 mb-1">Scale Z</label> | |
| <input type="number" id="scaleZ" class="w-full p-2 border rounded text-sm" value="1" step="0.1" /> | |
| </div> | |
| </div> | |
| <div class="mt-3 flex justify-between"> | |
| <div> | |
| <label class="block text-xs text-gray-500 mb-1">Model Color</label> | |
| <input type="color" id="modelColorPicker" class="color-picker" value="#3b82f6" /> | |
| </div> | |
| <button id="deleteModelBtn" class="bg-red-500 hover:bg-red-600 text-white py-1 px-3 rounded text-sm"> | |
| <i class="fas fa-trash mr-1"></i> Delete | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Status Bar --> | |
| <div class="bg-gray-800 text-gray-300 text-xs p-1 px-3 flex justify-between"> | |
| <div> | |
| <span id="cameraPosition">Camera: (0, 0, 0)</span> | |
| </div> | |
| <div> | |
| <span id="fpsCounter">FPS: 0</span> | |
| </div> | |
| <div> | |
| <span id="selectedModelInfo">No model selected</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // Main application | |
| document.addEventListener('DOMContentLoaded', function() { | |
| // UI Elements | |
| const toggleSidebarBtn = document.getElementById('toggleSidebar'); | |
| const sidebar = document.getElementById('sidebar'); | |
| const loadModelBtn = document.getElementById('loadModelBtn'); | |
| const fileInput = document.getElementById('fileInput'); | |
| const exportModelBtn = document.getElementById('exportModelBtn'); | |
| const exportOptions = document.getElementById('exportOptions'); | |
| const exportSelectedBtn = document.getElementById('exportSelectedBtn'); | |
| const exportAllBtn = document.getElementById('exportAllBtn'); | |
| const exportSceneBtn = document.getElementById('exportSceneBtn'); | |
| const modelsTab = document.getElementById('modelsTab'); | |
| const settingsTab = document.getElementById('settingsTab'); | |
| const modelsPanel = document.getElementById('modelsPanel'); | |
| const settingsPanel = document.getElementById('settingsPanel'); | |
| const modelList = document.getElementById('modelList'); | |
| const transformControls = document.getElementById('transformControls'); | |
| const transformHandle = document.getElementById('transformHandle'); // New handle element | |
| const selectTool = document.getElementById('selectTool'); | |
| const moveTool = document.getElementById('moveTool'); | |
| const rotateTool = document.getElementById('rotateTool'); | |
| const scaleTool = document.getElementById('scaleTool'); | |
| const resetViewBtn = document.getElementById('resetViewBtn'); | |
| const loadingOverlay = document.getElementById('loadingOverlay'); | |
| const bgColorPicker = document.getElementById('bgColorPicker'); | |
| const gridToggle = document.getElementById('gridToggle'); | |
| const rotationSpeed = document.getElementById('rotationSpeed'); | |
| const zoomSpeed = document.getElementById('zoomSpeed'); | |
| const cameraPosition = document.getElementById('cameraPosition'); | |
| const fpsCounter = document.getElementById('fpsCounter'); | |
| const selectedModelInfo = document.getElementById('selectedModelInfo'); | |
| // Transform controls elements | |
| const posX = document.getElementById('posX'); | |
| const posY = document.getElementById('posY'); | |
| const posZ = document.getElementById('posZ'); | |
| const rotX = document.getElementById('rotX'); | |
| const rotY = document.getElementById('rotY'); | |
| const rotZ = document.getElementById('rotZ'); | |
| const scaleX = document.getElementById('scaleX'); | |
| const scaleY = document.getElementById('scaleY'); | |
| const scaleZ = document.getElementById('scaleZ'); | |
| const modelColorPicker = document.getElementById('modelColorPicker'); | |
| const deleteModelBtn = document.getElementById('deleteModelBtn'); | |
| // Three.js variables | |
| let scene, camera, renderer, controls, gridHelper; | |
| let stlLoader = new THREE.STLLoader(); | |
| let stlExporter = new THREE.STLExporter(); | |
| let selectedModel = null; | |
| let models = []; | |
| let lastTime = 0; | |
| let frameCount = 0; | |
| let fps = 0; | |
| // Get the viewport container element | |
| const viewportContainer = document.getElementById('viewportContainer'); | |
| // Initialize the 3D scene | |
| function initScene() { | |
| // Create scene | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0xf3f4f6); | |
| // Create camera | |
| camera = new THREE.PerspectiveCamera(75, viewportContainer.clientWidth / viewportContainer.clientHeight, 0.1, 1000); | |
| camera.position.set(0, 0, 50); | |
| // Create renderer | |
| renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(viewportContainer.clientWidth, viewportContainer.clientHeight); | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| document.getElementById('renderCanvas').appendChild(renderer.domElement); | |
| // Add lights | |
| const ambientLight = new THREE.AmbientLight(0x404040); | |
| scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 1); | |
| directionalLight.position.set(1, 1, 1); | |
| scene.add(directionalLight); | |
| // Add grid helper | |
| gridHelper = new THREE.GridHelper(100, 100); | |
| scene.add(gridHelper); | |
| // Add orbit controls | |
| controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.05; | |
| controls.rotateSpeed = 1.0; | |
| controls.zoomSpeed = 1.0; | |
| // Handle window resize | |
| window.addEventListener('resize', onWindowResize); | |
| // Start animation loop | |
| animate(); | |
| } | |
| // Animation loop | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| // Update controls | |
| controls.update(); | |
| // Update camera position display | |
| updateCameraPosition(); | |
| // Calculate FPS | |
| const now = performance.now(); | |
| frameCount++; | |
| if (now >= lastTime + 1000) { | |
| fps = Math.round((frameCount * 1000) / (now - lastTime)); | |
| fpsCounter.textContent = `FPS: ${fps}`; | |
| frameCount = 0; | |
| lastTime = now; | |
| } | |
| renderer.render(scene, camera); | |
| } | |
| // Handle window resize | |
| function onWindowResize() { | |
| camera.aspect = viewportContainer.clientWidth / viewportContainer.clientHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(viewportContainer.clientWidth, viewportContainer.clientHeight); | |
| } | |
| // Update camera position display | |
| function updateCameraPosition() { | |
| const pos = camera.position; | |
| cameraPosition.textContent = `Camera: (${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}, ${pos.z.toFixed(1)})`; | |
| } | |
| // Load model (STL or OBJ) | |
| function loadModel(file) { | |
| loadingOverlay.classList.remove('hidden'); | |
| const fileName = file.name.toLowerCase(); | |
| const url = URL.createObjectURL(file); | |
| if (fileName.endsWith('.stl')) { | |
| stlLoader.load( | |
| url, | |
| function (geometry) { | |
| const material = new THREE.MeshPhongMaterial({ | |
| color: 0x3b82f6, | |
| specular: 0x111111, | |
| shininess: 50, | |
| flatShading: true | |
| }); | |
| const mesh = new THREE.Mesh(geometry, material); | |
| // Center the model | |
| geometry.computeBoundingBox(); | |
| const boundingBox = geometry.boundingBox; | |
| const center = new THREE.Vector3(); | |
| boundingBox.getCenter(center); | |
| mesh.position.sub(center); | |
| // Scale the model to a reasonable size | |
| const size = boundingBox.getSize(new THREE.Vector3()); | |
| const maxDim = Math.max(size.x, size.y, size.z); | |
| const scale = 10 / maxDim; | |
| mesh.scale.set(scale, scale, scale); | |
| scene.add(mesh); | |
| const modelId = Date.now(); | |
| models.push({ | |
| id: modelId, | |
| name: file.name, | |
| mesh: mesh, | |
| originalColor: 0x3b82f6 | |
| }); | |
| addModelToList(modelId, file.name); | |
| // Automatically select the loaded model so the toolbar shows up | |
| selectModel(modelId); | |
| loadingOverlay.classList.add('hidden'); | |
| }, | |
| function (xhr) { | |
| console.log((xhr.loaded / xhr.total * 100) + '% loaded'); | |
| }, | |
| function (error) { | |
| console.error('Error loading STL file:', error); | |
| loadingOverlay.classList.add('hidden'); | |
| alert('Error loading STL file. Please try another file.'); | |
| } | |
| ); | |
| } else if (fileName.endsWith('.obj')) { | |
| const objLoader = new THREE.OBJLoader(); | |
| objLoader.load( | |
| url, | |
| function (object) { | |
| // Apply a default material to all mesh children | |
| object.traverse(function(child) { | |
| if (child instanceof THREE.Mesh) { | |
| child.material = new THREE.MeshPhongMaterial({ | |
| color: 0x3b82f6, | |
| specular: 0x111111, | |
| shininess: 50, | |
| flatShading: true | |
| }); | |
| } | |
| }); | |
| // Center the model | |
| const boundingBox = new THREE.Box3().setFromObject(object); | |
| const center = new THREE.Vector3(); | |
| boundingBox.getCenter(center); | |
| object.position.sub(center); | |
| // Scale the model to a reasonable size | |
| const size = boundingBox.getSize(new THREE.Vector3()); | |
| const maxDim = Math.max(size.x, size.y, size.z); | |
| const scale = 10 / maxDim; | |
| object.scale.set(scale, scale, scale); | |
| scene.add(object); | |
| const modelId = Date.now(); | |
| // Retrieve the original color (assumes at least one mesh child exists) | |
| let originalColor = 0x3b82f6; | |
| object.traverse(function(child) { | |
| if (child instanceof THREE.Mesh && child.material && child.material.color) { | |
| originalColor = child.material.color.getHex(); | |
| } | |
| }); | |
| models.push({ | |
| id: modelId, | |
| name: file.name, | |
| mesh: object, | |
| originalColor: originalColor | |
| }); | |
| addModelToList(modelId, file.name); | |
| // Automatically select the loaded model so the toolbar shows up | |
| selectModel(modelId); | |
| loadingOverlay.classList.add('hidden'); | |
| }, | |
| function (xhr) { | |
| console.log((xhr.loaded / xhr.total * 100) + '% loaded'); | |
| }, | |
| function (error) { | |
| console.error('Error loading OBJ file:', error); | |
| loadingOverlay.classList.add('hidden'); | |
| alert('Error loading OBJ file. Please try another file.'); | |
| } | |
| ); | |
| } else { | |
| loadingOverlay.classList.add('hidden'); | |
| alert('Unsupported file type'); | |
| } | |
| } | |
| // Add model to the UI list | |
| function addModelToList(id, name) { | |
| const listItem = document.createElement('div'); | |
| listItem.className = 'model-list-item bg-gray-50 p-2 rounded cursor-pointer flex items-center justify-between'; | |
| listItem.dataset.id = id; | |
| listItem.innerHTML = ` | |
| <div class="flex items-center"> | |
| <i class="fas fa-cube text-blue-500 mr-2"></i> | |
| <span class="text-sm truncate" style="max-width: 160px;">${name}</span> | |
| </div> | |
| <div class="flex items-center space-x-1"> | |
| <button class="p-1 text-gray-500 hover:text-blue-500 model-visibility-btn"> | |
| <i class="fas fa-eye"></i> | |
| </button> | |
| </div> | |
| `; | |
| listItem.addEventListener('click', () => selectModel(id)); | |
| const visibilityBtn = listItem.querySelector('.model-visibility-btn'); | |
| visibilityBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| toggleModelVisibility(id); | |
| }); | |
| modelList.appendChild(listItem); | |
| } | |
| // Select a model | |
| function selectModel(id) { | |
| const model = models.find(m => m.id === id); | |
| if (!model) return; | |
| // Deselect previous model | |
| if (selectedModel) { | |
| if (selectedModel.mesh.material && selectedModel.mesh.material.color) | |
| selectedModel.mesh.material.color.setHex(selectedModel.originalColor); | |
| else { | |
| selectedModel.mesh.traverse(child => { | |
| if (child instanceof THREE.Mesh && child.material && child.material.color) | |
| child.material.color.setHex(selectedModel.originalColor); | |
| }); | |
| } | |
| } | |
| // Select new model | |
| selectedModel = model; | |
| if (model.mesh.material && model.mesh.material.color) { | |
| selectedModel.originalColor = model.mesh.material.color.getHex(); | |
| model.mesh.material.color.setHex(0xff0000); | |
| } else { | |
| model.mesh.traverse(child => { | |
| if (child instanceof THREE.Mesh && child.material && child.material.color) { | |
| selectedModel.originalColor = child.material.color.getHex(); | |
| child.material.color.setHex(0xff0000); | |
| } | |
| }); | |
| } | |
| // Update UI | |
| updateTransformControls(); | |
| transformControls.classList.remove('hidden'); | |
| selectedModelInfo.textContent = `Selected: ${model.name}`; | |
| // Highlight in model list | |
| document.querySelectorAll('.model-list-item').forEach(item => { | |
| item.classList.remove('bg-blue-50', 'border-blue-200', 'border'); | |
| }); | |
| document.querySelector(`.model-list-item[data-id="${id}"]`).classList.add('bg-blue-50', 'border-blue-200', 'border'); | |
| } | |
| // Toggle model visibility | |
| function toggleModelVisibility(id) { | |
| const model = models.find(m => m.id === id); | |
| if (!model) return; | |
| model.mesh.visible = !model.mesh.visible; | |
| const btn = document.querySelector(`.model-list-item[data-id="${id}"] .model-visibility-btn i`); | |
| if (model.mesh.visible) { | |
| btn.classList.remove('fa-eye-slash'); | |
| btn.classList.add('fa-eye'); | |
| } else { | |
| btn.classList.remove('fa-eye'); | |
| btn.classList.add('fa-eye-slash'); | |
| } | |
| } | |
| // Update transform controls with selected model values | |
| function updateTransformControls() { | |
| if (!selectedModel) return; | |
| const mesh = selectedModel.mesh; | |
| posX.value = mesh.position.x.toFixed(2); | |
| posY.value = mesh.position.y.toFixed(2); | |
| posZ.value = mesh.position.z.toFixed(2); | |
| rotX.value = THREE.MathUtils.radToDeg(mesh.rotation.x).toFixed(1); | |
| rotY.value = THREE.MathUtils.radToDeg(mesh.rotation.y).toFixed(1); | |
| rotZ.value = THREE.MathUtils.radToDeg(mesh.rotation.z).toFixed(1); | |
| scaleX.value = mesh.scale.x.toFixed(2); | |
| scaleY.value = mesh.scale.y.toFixed(2); | |
| scaleZ.value = mesh.scale.z.toFixed(2); | |
| let colorHex = selectedModel.originalColor; | |
| if (mesh.material && mesh.material.color) | |
| colorHex = mesh.material.color.getHex(); | |
| else { | |
| mesh.traverse(child => { | |
| if (child instanceof THREE.Mesh && child.material && child.material.color) | |
| colorHex = child.material.color.getHex(); | |
| }); | |
| } | |
| modelColorPicker.value = `#${colorHex.toString(16).padStart(6, '0')}`; | |
| } | |
| // Apply transform changes to selected model | |
| function applyTransformChanges() { | |
| if (!selectedModel) return; | |
| const mesh = selectedModel.mesh; | |
| mesh.position.set( | |
| parseFloat(posX.value) || 0, | |
| parseFloat(posY.value) || 0, | |
| parseFloat(posZ.value) || 0 | |
| ); | |
| mesh.rotation.set( | |
| THREE.MathUtils.degToRad(parseFloat(rotX.value) || 0), | |
| THREE.MathUtils.degToRad(parseFloat(rotY.value) || 0), | |
| THREE.MathUtils.degToRad(parseFloat(rotZ.value) || 0) | |
| ); | |
| mesh.scale.set( | |
| parseFloat(scaleX.value) || 1, | |
| parseFloat(scaleY.value) || 1, | |
| parseFloat(scaleZ.value) || 1 | |
| ); | |
| } | |
| // Delete selected model | |
| function deleteSelectedModel() { | |
| if (!selectedModel) return; | |
| scene.remove(selectedModel.mesh); | |
| models = models.filter(m => m.id !== selectedModel.id); | |
| const listItem = document.querySelector(`.model-list-item[data-id="${selectedModel.id}"]`); | |
| if (listItem) listItem.remove(); | |
| selectedModel = null; | |
| transformControls.classList.add('hidden'); | |
| selectedModelInfo.textContent = 'No model selected'; | |
| } | |
| // Export functions | |
| function exportSTL(mesh, name) { | |
| const stlString = stlExporter.parse(mesh, { binary: true }); | |
| const blob = new Blob([stlString], { type: 'application/octet-stream' }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = name || 'model.stl'; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| setTimeout(() => URL.revokeObjectURL(url), 100); | |
| } | |
| function exportSelectedModel() { | |
| if (!selectedModel) { | |
| alert('No model selected'); | |
| return; | |
| } | |
| const mesh = selectedModel.mesh.clone(); | |
| mesh.traverse(child => { | |
| if (child instanceof THREE.Mesh && child.material) { | |
| child.material = child.material.clone(); | |
| } | |
| }); | |
| exportSTL(mesh, selectedModel.name); | |
| } | |
| function exportAllModels() { | |
| if (models.length === 0) { | |
| alert('No models to export'); | |
| return; | |
| } | |
| const group = new THREE.Group(); | |
| models.forEach(model => { | |
| const mesh = model.mesh.clone(); | |
| mesh.traverse(child => { | |
| if (child instanceof THREE.Mesh && child.material) { | |
| child.material = child.material.clone(); | |
| } | |
| }); | |
| group.add(mesh); | |
| }); | |
| exportSTL(group, 'all_models.stl'); | |
| } | |
| function exportScene() { | |
| const group = new THREE.Group(); | |
| scene.children.forEach(child => { | |
| if (child instanceof THREE.Mesh && child.visible) { | |
| const mesh = child.clone(); | |
| if (mesh.material) mesh.material = mesh.material.clone(); | |
| group.add(mesh); | |
| } | |
| }); | |
| if (group.children.length === 0) { | |
| alert('No visible objects to export'); | |
| return; | |
| } | |
| exportSTL(group, 'scene.stl'); | |
| } | |
| // Initialize UI event listeners | |
| function initUIListeners() { | |
| // Toggle sidebar | |
| toggleSidebarBtn.addEventListener('click', () => { | |
| sidebar.classList.toggle('collapsed'); | |
| toggleSidebarBtn.innerHTML = sidebar.classList.contains('collapsed') ? | |
| '<i class="fas fa-chevron-right"></i>' : '<i class="fas fa-bars"></i>'; | |
| }); | |
| // Load model button | |
| loadModelBtn.addEventListener('click', () => fileInput.click()); | |
| // File input change | |
| fileInput.addEventListener('change', (e) => { | |
| if (e.target.files.length > 0) { | |
| loadModel(e.target.files[0]); | |
| fileInput.value = ''; | |
| } | |
| }); | |
| // Export model button | |
| exportModelBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| exportOptions.classList.toggle('show'); | |
| }); | |
| // Export options | |
| exportSelectedBtn.addEventListener('click', exportSelectedModel); | |
| exportAllBtn.addEventListener('click', exportAllModels); | |
| exportSceneBtn.addEventListener('click', exportScene); | |
| document.addEventListener('click', (e) => { | |
| if (!exportModelBtn.contains(e.target) && !exportOptions.contains(e.target)) { | |
| exportOptions.classList.remove('show'); | |
| } | |
| }); | |
| // Tab switching | |
| modelsTab.addEventListener('click', () => { | |
| modelsTab.classList.add('active'); | |
| settingsTab.classList.remove('active'); | |
| modelsPanel.classList.remove('hidden'); | |
| settingsPanel.classList.add('hidden'); | |
| }); | |
| settingsTab.addEventListener('click', () => { | |
| settingsTab.classList.add('active'); | |
| modelsTab.classList.remove('active'); | |
| settingsPanel.classList.remove('hidden'); | |
| modelsPanel.classList.add('hidden'); | |
| }); | |
| // Tool buttons | |
| selectTool.addEventListener('click', () => { | |
| selectTool.classList.add('text-blue-500'); | |
| moveTool.classList.remove('text-blue-500'); | |
| rotateTool.classList.remove('text-blue-500'); | |
| scaleTool.classList.remove('text-blue-500'); | |
| }); | |
| moveTool.addEventListener('click', () => { | |
| moveTool.classList.add('text-blue-500'); | |
| selectTool.classList.remove('text-blue-500'); | |
| rotateTool.classList.remove('text-blue-500'); | |
| scaleTool.classList.remove('text-blue-500'); | |
| }); | |
| rotateTool.addEventListener('click', () => { | |
| rotateTool.classList.add('text-blue-500'); | |
| selectTool.classList.remove('text-blue-500'); | |
| moveTool.classList.remove('text-blue-500'); | |
| scaleTool.classList.remove('text-blue-500'); | |
| }); | |
| scaleTool.addEventListener('click', () => { | |
| scaleTool.classList.add('text-blue-500'); | |
| selectTool.classList.remove('text-blue-500'); | |
| moveTool.classList.remove('text-blue-500'); | |
| rotateTool.classList.remove('text-blue-500'); | |
| }); | |
| // Reset view | |
| resetViewBtn.addEventListener('click', () => { | |
| camera.position.set(0, 0, 50); | |
| camera.lookAt(0, 0, 0); | |
| controls.reset(); | |
| }); | |
| // Transform controls | |
| [posX, posY, posZ, rotX, rotY, rotZ, scaleX, scaleY, scaleZ].forEach(input => { | |
| input.addEventListener('change', applyTransformChanges); | |
| }); | |
| // Model color picker | |
| modelColorPicker.addEventListener('input', (e) => { | |
| if (!selectedModel) return; | |
| const color = new THREE.Color(e.target.value); | |
| if (selectedModel.mesh.material && selectedModel.mesh.material.color) { | |
| selectedModel.mesh.material.color.copy(color); | |
| } else { | |
| selectedModel.mesh.traverse(child => { | |
| if (child instanceof THREE.Mesh && child.material && child.material.color) | |
| child.material.color.copy(color); | |
| }); | |
| } | |
| selectedModel.originalColor = color.getHex(); | |
| }); | |
| // Delete model button | |
| deleteModelBtn.addEventListener('click', deleteSelectedModel); | |
| // Background color picker | |
| bgColorPicker.addEventListener('input', (e) => { | |
| scene.background = new THREE.Color(e.target.value); | |
| }); | |
| // Grid toggle | |
| gridToggle.addEventListener('change', (e) => { | |
| gridHelper.visible = e.target.checked; | |
| }); | |
| // Rotation and zoom speed | |
| rotationSpeed.addEventListener('input', (e) => { | |
| controls.rotateSpeed = parseFloat(e.target.value); | |
| }); | |
| zoomSpeed.addEventListener('input', (e) => { | |
| controls.zoomSpeed = parseFloat(e.target.value); | |
| }); | |
| // Draggable Transform Controls Handle | |
| transformHandle.addEventListener('mousedown', function(e) { | |
| let startY = e.clientY; | |
| let initialHeight = transformControls.clientHeight; | |
| function onMouseMove(e) { | |
| // Calculate how much the pointer has moved upward (dragging upward increases height) | |
| let delta = startY - e.clientY; | |
| let newHeight = initialHeight + delta; | |
| // Clamp the height between 40px and half of the viewport height | |
| newHeight = Math.max(newHeight, 40); | |
| newHeight = Math.min(newHeight, window.innerHeight * 0.5); | |
| transformControls.style.height = newHeight + 'px'; | |
| } | |
| function onMouseUp() { | |
| document.removeEventListener('mousemove', onMouseMove); | |
| document.removeEventListener('mouseup', onMouseUp); | |
| } | |
| document.addEventListener('mousemove', onMouseMove); | |
| document.addEventListener('mouseup', onMouseUp); | |
| }); | |
| } | |
| // Initialize everything | |
| function init() { | |
| initScene(); | |
| initUIListeners(); | |
| // Set initial tool selection | |
| selectTool.classList.add('text-blue-500'); | |
| } | |
| // Start the application | |
| init(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |