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/[email protected]/examples/js/controls/OrbitControls.min.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/loaders/STLLoader.min.js"></script> | |
<!-- Added OBJLoader script --> | |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/loaders/OBJLoader.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/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> |