modelado-3d / index.html
thepipis's picture
Quiero reemplazar la l贸gica de localStorage por una conexi贸n real a backend (por ejemplo, con Flask o Django REST). El objetivo es permitir que los modelos cargados (UUID, nombre, posici贸n, color, etc.) se almacenen en una API backend y se restauren autom谩ticamente al iniciar la aplicaci贸n. 馃幆 Objetivo: - Enviar los metadatos de cada modelo al backend cuando se carga. - Consultar todos los modelos guardados al iniciar para restaurar la sesi贸n. - Eliminar todos los modelos del backend al hacer reset. 馃З Backend: - Ruta POST `/api/models/` para guardar modelos. - Ruta GET `/api/models/` para listar modelos guardados. - Ruta DELETE `/api/models/` para eliminar todos. 馃З Cambios en el frontend: 1. Al final de `loadModel(file)`, justo despu茅s de `models.push(model);`, agrega: ```js fetch('/api/models/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uuid: model.uuid, name: file.name, position: { x: model.position.x, y: model.position.y, z: model.position.z }, url: URL.createObjectURL(file) // opcional, si quieres usar ObjectURL temporal }) }); Al inicio de init(), justo antes de animate();, agrega: js Copiar Editar fetch('/api/models/') .then(res => res.json()) .then(modelsData => { modelsData.forEach(data => { const loader = new THREE.GLTFLoader(); loader.load(data.url, (gltf) => { const model = gltf.scene; model.uuid = data.uuid; model.traverse(child => { if (child.isMesh) { child.castShadow = true; child.receiveShadow = true; originalMaterials.set(child.uuid, child.material.clone()); } }); const box = new THREE.Box3().setFromObject(model); const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); const maxDim = Math.max(size.x, size.y, size.z); const scale = 5 / maxDim; model.scale.multiplyScalar(scale); model.position.sub(center.multiplyScalar(scale)); model.position.x = data.position.x; model.position.y = data.position.y; model.position.z = data.position.z; scene.add(model); models.push(model); const selector = document.getElementById('model-selector'); const option = document.createElement('option'); option.value = model.uuid; option.textContent = data.name; selector.appendChild(option); document.getElementById('color-tools').classList.remove('opacity-50', 'pointer-events-none'); }); }); }); En setupResetButton(), reemplaza el contenido por: js Copiar Editar document.getElementById('reset-btn').addEventListener('click', () => { models.forEach(m => scene.remove(m)); models = []; originalMaterials.clear(); paintMode = false; document.getElementById('paint-mode').textContent = 'Activar pintura'; document.getElementById('paint-mode').classList.remove('bg-blue-600'); document.getElementById('paint-mode').classList.add('bg-slate-700'); document.getElementById('color-tools').classList.add('opacity-50', 'pointer-events-none'); document.body.style.cursor = 'default'; // Borrar del backend fetch('/api/models/', { method: 'DELETE' }); // Limpiar selector const selector = document.getElementById('model-selector'); selector.innerHTML = '<option value="">Selecciona un modelo</option>'; showToast('Modelos reseteados'); }); - Follow Up Deployment
81359da verified
<!DOCTYPE html>
<html lang="es" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visualizador 3D - Editor GLB</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.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/GLTFLoader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/exporters/GLTFExporter.js"></script>
<style>
#canvas-container {
position: relative;
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
}
.glass-panel {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.drag-active {
border-color: #3b82f6;
background-color: rgba(59, 130, 246, 0.1);
}
.color-picker {
transition: all 0.2s ease;
}
.color-picker:hover {
transform: scale(1.1);
}
.color-picker.active {
transform: scale(1.2);
box-shadow: 0 0 20px rgba(59, 130, 246, 0.5);
}
.fade-in {
animation: fadeIn 0.5s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.loading-spinner {
border: 4px solid rgba(255, 255, 255, 0.1);
border-left-color: #3b82f6;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body class="bg-slate-900 text-white min-h-full flex flex-col">
<!-- Header -->
<header class="glass-panel p-4 border-b border-slate-700">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<h1 class="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
Visualizador 3D GLB
</h1>
<button id="reset-btn" class="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg transition-colors duration-200 flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Resetear Modelo
</button>
</div>
</header>
<div class="flex-1 flex flex-col lg:flex-row">
<!-- Sidebar -->
<aside class="w-full lg:w-80 glass-panel p-6 space-y-6 overflow-y-auto">
<!-- Upload Section -->
<div class="space-y-4">
<h2 class="text-lg font-semibold text-slate-200">Cargar Modelo</h2>
<div id="drop-zone" class="border-2 border-dashed border-slate-600 rounded-lg p-8 text-center transition-all duration-200 cursor-pointer">
<svg class="w-12 h-12 mx-auto mb-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p class="text-slate-400 mb-2">Arrastra y suelta tu archivo .glb aqu铆</p>
<p class="text-sm text-slate-500">o</p>
<input type="file" id="file-input" accept=".glb" class="hidden">
<button onclick="document.getElementById('file-input').click()" class="mt-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors duration-200">
Seleccionar archivo
</button>
</div>
</div>
<!-- Color Tools -->
<div id="color-tools" class="space-y-4 opacity-50 pointer-events-none">
<h2 class="text-lg font-semibold text-slate-200">Herramientas de Color</h2>
<div>
<label for="model-selector" class="block text-sm text-slate-400 mb-2">Modelo activo para pintar</label>
<select id="model-selector" class="w-full px-3 py-2 rounded-lg bg-slate-800 text-white border border-slate-600">
<option value="">Selecciona un modelo</option>
</select>
<button id="delete-model" class="w-full mt-2 px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg transition-colors duration-200">
Eliminar modelo
</button>
</div>
<div>
<label class="block text-sm text-slate-400 mb-2">Modo pintura</label>
<button id="paint-mode" class="w-full px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg transition-colors duration-200">
Activar pintura
</button>
</div>
<div>
<label class="block text-sm text-slate-400 mb-2">Color seleccionado</label>
<div class="grid grid-cols-6 gap-2">
<div class="color-picker w-10 h-10 rounded-lg cursor-pointer bg-red-500" data-color="#ef4444"></div>
<div class="color-picker w-10 h-10 rounded-lg cursor-pointer bg-blue-500" data-color="#3b82f6"></div>
<div class="color-picker w-10 h-10 rounded-lg cursor-pointer bg-green-500" data-color="#22c55e"></div>
<div class="color-picker w-10 h-10 rounded-lg cursor-pointer bg-yellow-500" data-color="#eab308"></div>
<div class="color-picker w-10 h-10 rounded-lg cursor-pointer bg-purple-500" data-color="#8b5cf6"></div>
<div class="color-picker w-10 h-10 rounded-lg cursor-pointer bg-pink-500" data-color="#ec4899"></div>
</div>
<input type="color" id="color-input" class="mt-2 w-full h-10 rounded-lg cursor-pointer" value="#3b82f6">
</div>
<button id="download-btn" class="w-full px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg transition-colors duration-200">
Descargar modelo
</button>
<button id="restore-btn" class="w-full px-4 py-2 bg-yellow-600 hover:bg-yellow-700 rounded-lg transition-colors duration-200">
Restaurar colores
</button>
<button id="delete-btn" class="w-full px-4 py-2 bg-red-700 hover:bg-red-800 rounded-lg transition-colors duration-200">
Eliminar modelo
</button>
<button id="rotate-btn" class="w-full px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg transition-colors duration-200">
Detener rotaci贸n
</button>
</div>
<!-- Instructions -->
<div class="space-y-4">
<h2 class="text-lg font-semibold text-slate-200">Instrucciones</h2>
<div class="text-sm text-slate-400 space-y-2">
<p><strong>Rotar:</strong> Click izquierdo + arrastrar</p>
<p><strong>Zoom:</strong> Scroll del mouse</p>
<p><strong>Pan:</strong> Click derecho + arrastrar</p>
<p><strong>Pintar:</strong> Activar modo pintura y hacer clic en el modelo</p>
</div>
</div>
</aside>
<!-- Main Canvas -->
<main class="flex-1 relative" id="canvas-container">
<canvas id="canvas" class="w-full h-full"></canvas>
<div id="loading" class="absolute inset-0 flex items-center justify-center bg-slate-900 bg-opacity-75 hidden">
<div class="text-center">
<div class="loading-spinner w-12 h-12 rounded-full mx-auto mb-4"></div>
<p class="text-slate-300">Cargando modelo...</p>
</div>
</div>
</main>
</div>
<!-- Toast -->
<div id="toast" class="fixed bottom-4 right-4 bg-green-600 text-white px-6 py-3 rounded-lg shadow-lg transform translate-y-full transition-transform duration-300">
<span id="toast-message"></span>
</div>
<script>
// Variables globales
let scene, camera, renderer, controls, raycaster, mouse;
let models = [];
let paintMode = false;
let selectedColor = '#3b82f6';
let originalMaterials = new Map();
let autoRotate = true;
// Inicializaci贸n
function init() {
// Escena
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0f172a);
// C谩maras
const container = document.getElementById('canvas-container');
const aspect = container.clientWidth / container.clientHeight;
perspectiveCamera = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000);
perspectiveCamera.position.set(5, 5, 5);
orthographicCamera = new THREE.OrthographicCamera(
-aspect * 5, aspect * 5, 5, -5, 0.1, 1000
);
orthographicCamera.position.set(5, 5, 5);
orthographicCamera.zoom = 50;
orthographicCamera.updateProjectionMatrix();
camera = perspectiveCamera;
// Renderer
renderer = new THREE.WebGLRenderer({
canvas: document.getElementById('canvas'),
antialias: true
});
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// Controles
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
// Bot贸n para cambiar c谩mara
const toggleBtn = document.createElement('button');
toggleBtn.textContent = 'Vista ortogr谩fica';
toggleBtn.className = 'absolute top-4 right-4 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 rounded-lg z-50';
toggleBtn.style.position = 'absolute';
toggleBtn.style.zIndex = 999;
toggleBtn.addEventListener('click', () => {
usingOrtho = !usingOrtho;
camera = usingOrtho ? orthographicCamera : perspectiveCamera;
controls.object = camera;
toggleBtn.textContent = usingOrtho ? 'Vista perspectiva' : 'Vista ortogr谩fica';
camera.updateProjectionMatrix();
});
document.getElementById('canvas-container').appendChild(toggleBtn);
// Luces
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(10, 10, 5);
directionalLight.castShadow = true;
scene.add(directionalLight);
// Raycaster para interacciones
raycaster = new THREE.Raycaster();
mouse = new THREE.Vector2();
// Grid helper
const gridHelper = new THREE.GridHelper(10, 10, 0x334155, 0x334155);
scene.add(gridHelper);
// Event listeners
window.addEventListener('resize', onWindowResize);
renderer.domElement.addEventListener('click', onCanvasClick);
setupDragAndDrop();
setupFileInput();
setupColorTools();
setupResetButton();
setupDownloadButton();
setupRestoreButton();
setupDeleteButton();
setupDeleteModelButton();
setupToggleVisibilityButton();
setupCameraToggle();
// Restaurar modelos desde backend
fetch('/api/models/')
.then(res => res.json())
.then(modelsData => {
modelsData.forEach(data => {
const loader = new THREE.GLTFLoader();
loader.load(data.url, (gltf) => {
const model = gltf.scene;
model.uuid = data.uuid;
model.traverse(child => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
originalMaterials.set(child.uuid, child.material.clone());
}
});
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 5 / maxDim;
model.scale.multiplyScalar(scale);
model.position.sub(center.multiplyScalar(scale));
model.position.x = data.position.x;
model.position.y = data.position.y;
model.position.z = data.position.z;
scene.add(model);
models.push(model);
const selector = document.getElementById('model-selector');
const option = document.createElement('option');
option.value = model.uuid;
option.textContent = data.name;
selector.appendChild(option);
document.getElementById('color-tools').classList.remove('opacity-50', 'pointer-events-none');
});
});
});
// Animaci贸n
animate();
}
function animate() {
requestAnimationFrame(animate);
if (autoRotate) {
models.forEach(m => m.rotation.y += 0.003);
}
controls.update();
renderer.render(scene, camera);
}
function onWindowResize() {
const container = document.getElementById('canvas-container');
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
}
// Carga de modelo
function loadModel(file) {
if (file.type !== 'model/gltf-binary') {
showToast('Archivo inv谩lido. Solo se permiten archivos .glb');
return;
}
const loader = new THREE.GLTFLoader();
const url = URL.createObjectURL(file);
document.getElementById('loading').classList.remove('hidden');
loader.load(url, (gltf) => {
const model = gltf.scene;
model.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
// Guardar material original
originalMaterials.set(child.uuid, child.material.clone());
}
});
// Centrar y escalar modelo
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 5 / maxDim;
// Forzar UUID persistente
model.uuid = crypto.randomUUID();
model.scale.multiplyScalar(scale);
model.position.sub(center.multiplyScalar(scale));
model.position.x = 5 * models.length;
scene.add(model);
models.push(model);
const boxHelper = new THREE.BoxHelper(model, 0xffff00);
scene.add(boxHelper);
const axesHelper = new THREE.AxesHelper(1);
model.add(axesHelper);
// Guardar en backend
fetch('/api/models/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
uuid: model.uuid,
name: file.name,
position: { x: model.position.x, y: model.position.y, z: model.position.z },
url: URL.createObjectURL(file)
})
});
const selector = document.getElementById('model-selector');
const option = document.createElement('option');
option.value = model.uuid;
option.textContent = file.name;
selector.appendChild(option);
URL.revokeObjectURL(url);
// Activar herramientas
document.getElementById('color-tools').classList.remove('opacity-50', 'pointer-events-none');
document.getElementById('loading').classList.add('hidden');
showToast('Modelo cargado exitosamente');
});
}
// Drag and Drop
function setupDragAndDrop() {
const dropZone = document.getElementById('drop-zone');
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.add('drag-active');
});
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.remove('drag-active');
});
});
dropZone.addEventListener('drop', (e) => {
const files = e.dataTransfer.files;
if (files.length > 0 && files[0].name.endsWith('.glb')) {
loadModel(files[0]);
}
});
}
// File input
function setupFileInput() {
document.getElementById('file-input').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file && file.name.endsWith('.glb')) {
loadModel(file);
}
});
}
// Herramientas de color
function setupColorTools() {
// Color picker
document.querySelectorAll('.color-picker').forEach(picker => {
picker.addEventListener('click', () => {
document.querySelectorAll('.color-picker').forEach(p => p.classList.remove('active'));
picker.classList.add('active');
selectedColor = picker.dataset.color;
document.getElementById('color-input').value = selectedColor;
});
});
document.getElementById('color-input').addEventListener('change', (e) => {
selectedColor = e.target.value;
document.querySelectorAll('.color-picker').forEach(p => p.classList.remove('active'));
});
// Paint mode
document.getElementById('paint-mode').addEventListener('click', (e) => {
paintMode = !paintMode;
e.target.textContent = paintMode ? 'Desactivar pintura' : 'Activar pintura';
e.target.classList.toggle('bg-blue-600');
e.target.classList.toggle('bg-slate-700');
document.body.style.cursor = paintMode ? 'crosshair' : 'default';
});
// Control de rotaci贸n
document.getElementById('rotate-btn').addEventListener('click', (e) => {
autoRotate = !autoRotate;
e.target.textContent = autoRotate ? 'Detener rotaci贸n' : 'Reanudar rotaci贸n';
});
}
function onCanvasClick(event) {
if (!paintMode) return;
const selectedId = document.getElementById('model-selector').value;
const model = models.find(m => m.uuid === selectedId);
if (!model) return;
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(model, true);
if (intersects.length > 0) {
const point = intersects[0].point;
const radius = 0.3; // Radio del pincel
model.traverse(child => {
if (child.isMesh) {
const pos = child.getWorldPosition(new THREE.Vector3());
const distance = pos.distanceTo(point);
if (distance <= radius) {
const newMat = child.material.clone();
newMat.color.set(selectedColor);
child.material = newMat;
}
}
});
}
}
// Reset
function setupResetButton() {
document.getElementById('reset-btn').addEventListener('click', () => {
models.forEach(m => scene.remove(m));
models = [];
originalMaterials.clear();
paintMode = false;
document.getElementById('paint-mode').textContent = 'Activar pintura';
document.getElementById('paint-mode').classList.remove('bg-blue-600');
document.getElementById('paint-mode').classList.add('bg-slate-700');
document.getElementById('color-tools').classList.add('opacity-50', 'pointer-events-none');
document.body.style.cursor = 'default';
// Borrar del backend
fetch('/api/models/', { method: 'DELETE' });
// Limpiar selector
const selector = document.getElementById('model-selector');
selector.innerHTML = '<option value="">Selecciona un modelo</option>';
showToast('Modelos reseteados');
});
}
function setupDownloadButton() {
document.getElementById('download-btn').addEventListener('click', () => {
const selectedId = document.getElementById('model-selector').value;
const model = models.find(m => m.uuid === selectedId);
if (!model) {
showToast('No hay modelo seleccionado para descargar');
return;
}
const exporter = new THREE.GLTFExporter();
exporter.parse(
model,
(gltf) => {
const blob = new Blob([gltf], { type: 'model/gltf-binary' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'modelo_modificado.glb';
a.click();
URL.revokeObjectURL(url);
showToast('Modelo descargado');
},
{ binary: true }
);
});
}
function setupRestoreButton() {
const restoreBtn = document.getElementById('restore-btn');
restoreBtn.addEventListener('click', () => {
const selectedId = document.getElementById('model-selector').value;
const model = models.find(m => m.uuid === selectedId);
if (!model) return;
model.traverse(child => {
if (child.isMesh && originalMaterials.has(child.uuid)) {
child.material = originalMaterials.get(child.uuid).clone();
}
});
showToast('Colores originales restaurados');
});
}
// Toast
function setupDeleteModelButton() {
const deleteBtn = document.getElementById('delete-model');
deleteBtn.addEventListener('click', () => {
const selectedId = document.getElementById('model-selector').value;
const model = models.find(m => m.uuid === selectedId);
if (!model) return;
scene.remove(model);
models = models.filter(m => m.uuid !== selectedId);
document.getElementById('model-selector').querySelector(`option[value="${selectedId}"]`)?.remove();
document.getElementById('model-selector').value = "";
showToast("Modelo eliminado");
});
}
function setupCameraToggle() {
const camBtn = document.createElement('button');
camBtn.id = 'toggle-camera';
camBtn.textContent = 'Cambiar a c谩mara ortogr谩fica';
camBtn.className = 'w-full px-4 py-2 bg-teal-600 hover:bg-teal-700 rounded-lg transition-colors duration-200 mt-2';
document.getElementById('color-tools').appendChild(camBtn);
let isOrtho = false;
camBtn.addEventListener('click', () => {
const container = document.getElementById('canvas-container');
const aspect = container.clientWidth / container.clientHeight;
if (!isOrtho) {
const frustumSize = 10;
const orthoCam = new THREE.OrthographicCamera(
(frustumSize * aspect) / -2,
(frustumSize * aspect) / 2,
frustumSize / 2,
frustumSize / -2,
0.1,
1000
);
orthoCam.position.copy(camera.position);
orthoCam.lookAt(scene.position);
camera = orthoCam;
controls.object = camera;
camBtn.textContent = 'Cambiar a c谩mara perspectiva';
} else {
const perspCam = new THREE.PerspectiveCamera(
75,
aspect,
0.1,
1000
);
perspCam.position.copy(camera.position);
perspCam.lookAt(scene.position);
camera = perspCam;
controls.object = camera;
camBtn.textContent = 'Cambiar a c谩mara ortogr谩fica';
}
isOrtho = !isOrtho;
camera.updateProjectionMatrix();
});
}
function setupToggleVisibilityButton() {
const toggleBtn = document.createElement('button');
toggleBtn.id = 'toggle-visibility';
toggleBtn.textContent = 'Ocultar modelo';
toggleBtn.className = 'w-full px-4 py-2 bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors duration-200 mt-2';
document.getElementById('color-tools').appendChild(toggleBtn);
toggleBtn.addEventListener('click', () => {
const selectedId = document.getElementById('model-selector').value;
const model = models.find(m => m.uuid === selectedId);
if (!model) return;
model.visible = !model.visible;
toggleBtn.textContent = model.visible ? 'Ocultar modelo' : 'Mostrar modelo';
});
}
function showToast(message) {
const toast = document.getElementById('toast');
const toastMessage = document.getElementById('toast-message');
toastMessage.textContent = message;
toast.classList.remove('translate-y-full');
setTimeout(() => {
toast.classList.add('translate-y-full');
}, 3000);
}
// Inicializar cuando el DOM est茅 listo
document.addEventListener('DOMContentLoaded', 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=thepipis/modelado-3d" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body>
</html>