import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client@latest/dist/index.js"; // DOM Elements const openSettingsBtn = null; const closeSettingsBtn = null; const settingsModal = null; const saveSettingsBtn = null; const hfTokenInput = null; const tabBtns = document.querySelectorAll('.tab-btn'); const tabContents = document.querySelectorAll('.tab-content'); const dropzone = document.getElementById('dropzone'); const fileInput = document.getElementById('fileInput'); const previewContainer = document.getElementById('previewContainer'); const imagePreview = document.getElementById('imagePreview'); const removePreviewBtn = document.getElementById('removePreviewBtn'); // Source selector elements const sourceUploadBtn = document.getElementById('sourceUploadBtn'); const sourceLibraryBtn = document.getElementById('sourceLibraryBtn'); const uploadSourceContainer = document.getElementById('uploadSourceContainer'); const librarySourceContainer = document.getElementById('librarySourceContainer'); const libraryThumbnailGrid = document.getElementById('libraryThumbnailGrid'); const promptInput = document.getElementById('promptInput'); const negativePromptInput = document.getElementById('negativePromptInput'); const generatorModelSelect = document.getElementById('generatorModelSelect'); const generate2dBtn = document.getElementById('generate2dBtn'); const generate3dBtn = document.getElementById('generate3dBtn'); const statusOverlay = document.getElementById('statusOverlay'); const loaderPercentage = document.getElementById('loaderPercentage'); const statusTitle = document.getElementById('statusTitle'); const statusDesc = document.getElementById('statusDesc'); const viewerDisplay = document.getElementById('viewerDisplay'); const emptyState = document.getElementById('emptyState'); const viewerOverlay = document.getElementById('viewerOverlay'); const toggleAutoRotateBtn = document.getElementById('toggleAutoRotate'); const resetCameraBtn = document.getElementById('resetCameraBtn'); const toggleWireframeBtn = document.getElementById('toggleWireframeBtn'); const toggleNodesBtn = document.getElementById('toggleNodesBtn'); const toggleTextureBtn = document.getElementById('toggleTextureBtn'); const downloadGlbBtn = document.getElementById('downloadGlbBtn'); const downloadFbxBtn = document.getElementById('downloadFbxBtn'); const viewOnMobileBtn = document.getElementById('viewOnMobileBtn'); const mobileQrModal = document.getElementById('mobileQrModal'); const closeMobileQrModalBtn = document.getElementById('closeMobileQrModalBtn'); const mobileQrImage = document.getElementById('mobileQrImage'); const mobileQrLink = document.getElementById('mobileQrLink'); let activeGltfUrl = null; const runTriggerRigBtn = document.getElementById('runTriggerRigBtn'); const rigTriggerPanel = document.getElementById('rigTriggerPanel'); const manualWeightPaintPanel = document.getElementById('manualWeightPaintPanel'); const activeWeightPaintCheck = document.getElementById('activeWeightPaintCheck'); const weightPaintRadiusInput = document.getElementById('weightPaintRadiusInput'); const weightPaintRadiusVal = document.getElementById('weightPaintRadiusVal'); const weightPaintStrengthInput = document.getElementById('weightPaintStrengthInput'); const weightPaintStrengthVal = document.getElementById('weightPaintStrengthVal'); const weightPaintModeSelect = document.getElementById('weightPaintModeSelect'); const saveWeightPaintBtn = document.getElementById('saveWeightPaintBtn'); const animationControlGroup = document.getElementById('animationControlGroup'); const animationSelect = document.getElementById('animationSelect'); const manualRigPanel = document.getElementById('manualRigPanel'); const manualBoneNameInput = document.getElementById('manualBoneNameInput'); const applyBoneRenameBtn = document.getElementById('applyBoneRenameBtn'); let selectedBone = null; let currentModelCategory = 'unknown'; let lastNoBoneToastTime = 0; const manualBoneSelect = null; const aiClassificationBadge = document.getElementById('aiClassificationBadge'); const aiClassificationText = document.getElementById('aiClassificationText'); const boneRotX = document.getElementById('boneRotX'); const boneRotY = document.getElementById('boneRotY'); const boneRotZ = document.getElementById('boneRotZ'); const boneRotXVal = document.getElementById('boneRotXVal'); const boneRotYVal = document.getElementById('boneRotYVal'); const boneRotZVal = document.getElementById('boneRotZVal'); const resetBoneRotBtn = document.getElementById('resetBoneRotBtn'); const manualBoneMappingSelect = document.getElementById('manualBoneMappingSelect'); const toggleBonesHelperBtn = document.getElementById('toggleBonesHelperBtn'); const toggleBonesBtn = document.getElementById('toggleBonesBtn'); let threeSkeletonHelper = null; let showSkeletonHelper = false; let jointSpheres = []; let showWireframe = false; let showNodes = false; let isWeightPaintingActive = false; let selectedBoneMarker = null; let weightPaintRadius = 0.10; let weightPaintStrength = 0.50; let weightPaintMode = 'add'; let weightVisualizerMaterial = null; let originalMaterialsMap = new Map(); let brushHelper = null; let lastBrushHit = null; let activeSkinnedMeshes = []; let spatialGrid = null; let targetBoneIdx = -1; let lastRaycastTime = 0; let lastPaintTime = 0; let lastMouseScreenX = 0; let lastMouseScreenY = 0; let showTextures = true; let isAutoRotating = false; // Trellis Parameters const resolutionSelect = document.getElementById('resolutionSelect'); const seedInput = document.getElementById('seedInput'); const randomSeedCheck = document.getElementById('randomSeedCheck'); const decimateInput = document.getElementById('decimateInput'); const decimateVal = document.getElementById('decimateVal'); const textureSizeSelect = document.getElementById('textureSizeSelect'); const ssGuidanceInput = document.getElementById('ssGuidanceInput'); const ssGuidanceVal = document.getElementById('ssGuidanceVal'); const ssStepsInput = document.getElementById('ssStepsInput'); const ssStepsVal = document.getElementById('ssStepsVal'); const slatGuidanceInput = document.getElementById('slatGuidanceInput'); const slatGuidanceVal = document.getElementById('slatGuidanceVal'); const slatStepsInput = document.getElementById('slatStepsInput'); const slatStepsVal = document.getElementById('slatStepsVal'); // App State let active2dImageBlob = null; let active2dImageURL = null; let currentGltfUrl = null; let activeModelRelativeUrl = null; localStorage.removeItem('hf_token'); let hfToken = ''; const apiBase = window.location.protocol === 'file:' ? 'http://localhost:8000' : ''; // Custom Alert Modal Setup const alertModal = document.getElementById('alertModal'); const alertModalTitle = document.getElementById('alertModalTitle'); const alertModalMessage = document.getElementById('alertModalMessage'); const closeAlertBtn = document.getElementById('closeAlertBtn'); const goConfigBtn = document.getElementById('goConfigBtn'); const alertModalIconContainer = document.getElementById('alertModalIconContainer'); const alertModalIcon = document.getElementById('alertModalIcon'); function showCustomAlert(message, title = 'Atención', showConfigBtn = false, type = 'error') { alertModalTitle.textContent = title; alertModalMessage.textContent = message; if (showConfigBtn) { goConfigBtn.style.display = 'inline-flex'; } else { goConfigBtn.style.display = 'none'; } if (alertModalIconContainer && alertModalIcon) { if (type === 'success') { alertModalIconContainer.style.background = 'rgba(76, 175, 80, 0.1)'; alertModalIconContainer.style.color = '#4caf50'; alertModalIcon.setAttribute('data-lucide', 'check-circle'); } else { alertModalIconContainer.style.background = 'rgba(244, 67, 54, 0.1)'; alertModalIconContainer.style.color = '#f44336'; alertModalIcon.setAttribute('data-lucide', 'alert-triangle'); } } alertModal.classList.add('active'); if (window.lucide) { window.lucide.createIcons(); } } if (closeAlertBtn) { closeAlertBtn.addEventListener('click', () => { alertModal.classList.remove('active'); }); } if (goConfigBtn) { goConfigBtn.addEventListener('click', () => { alertModal.classList.remove('active'); settingsModal.classList.add('active'); }); } // Slider live value update helper function setupSliderValueUpdate(slider, labelEl) { slider.addEventListener('input', (e) => { labelEl.textContent = e.target.value; }); } setupSliderValueUpdate(decimateInput, decimateVal); setupSliderValueUpdate(ssGuidanceInput, ssGuidanceVal); setupSliderValueUpdate(ssStepsInput, ssStepsVal); setupSliderValueUpdate(slatGuidanceInput, slatGuidanceVal); setupSliderValueUpdate(slatStepsInput, slatStepsVal); // Settings modal functionality has been deprecated in favor of server-side .env configuration // Tab Switching tabBtns.forEach(btn => { btn.addEventListener('click', () => { const targetTab = btn.getAttribute('data-tab'); tabBtns.forEach(b => b.classList.remove('active')); tabContents.forEach(c => c.classList.remove('active')); btn.classList.add('active'); document.getElementById(targetTab).classList.add('active'); // Toggle visibility of the Main 3D Build Button at the bottom if (targetTab === 'upload-tab') { generate3dBtn.style.display = 'flex'; } else { generate3dBtn.style.display = 'none'; } }); }); // Image Source Selection Toggles if (sourceUploadBtn && sourceLibraryBtn) { sourceUploadBtn.addEventListener('click', () => { sourceUploadBtn.classList.add('active'); sourceLibraryBtn.classList.remove('active'); uploadSourceContainer.style.display = 'block'; librarySourceContainer.style.display = 'none'; dropzone.style.display = active2dImageBlob ? 'none' : 'flex'; }); sourceLibraryBtn.addEventListener('click', () => { sourceUploadBtn.classList.remove('active'); sourceLibraryBtn.classList.add('active'); uploadSourceContainer.style.display = 'none'; librarySourceContainer.style.display = 'block'; refreshLibraryThumbnails(); }); } let cachedGalleryItems = []; async function refreshLibraryThumbnails() { try { const response = await fetch(`${apiBase}/api/gallery`); if (!response.ok) throw new Error('Error al cargar la galería.'); const data = await response.json(); cachedGalleryItems = data.items || []; renderLibraryThumbnails(cachedGalleryItems); checkActiveUserJob(); } catch (e) { console.error('Error loading library thumbnails:', e); } } let isPollingActiveJobPC = false; let bgJobCheckTimerPC = null; async function checkActiveUserJob() { if (isPollingActiveJobPC) return; try { const res = await fetch(`${apiBase}/api/user-active-job`); if (!res.ok) return; const data = await res.json(); if (data.has_active && data.job_id) { isPollingActiveJobPC = true; resumeActiveJobPollingPC(data.job_id, data.type); } } catch (e) { console.warn('[PC] Error checking active job:', e); } } if (!bgJobCheckTimerPC) { bgJobCheckTimerPC = setInterval(() => { if (!isPollingActiveJobPC) { checkActiveUserJob(); } }, 3000); } function resumeActiveJobPollingPC(jobId, jobType) { const bgTaskBanner = document.getElementById('bgTaskBanner'); const bgTaskText = document.getElementById('bgTaskText'); const typeLabel = jobType === '2d' ? '2D' : '3D'; if (bgTaskBanner && bgTaskText) { bgTaskText.textContent = `Generando ${typeLabel}... 0%`; bgTaskBanner.style.display = 'flex'; } if (jobType === '3d' && typeof statusOverlay !== 'undefined' && statusOverlay) { statusOverlay.classList.add('active'); updateLoader(10, 'Procesando en segundo plano...', 'Generando modelo 3D desde tu dispositivo...'); } const pollInterval = setInterval(async () => { try { const statusRes = await fetch(`${apiBase}/api/job-status?job_id=${jobId}`); if (!statusRes.ok) return; const job = await statusRes.json(); if (job.status === 'processing' || job.status === 'pending') { const progress = job.progress || 10; if (bgTaskBanner && bgTaskText) { bgTaskText.textContent = `Generando ${typeLabel}... ${progress}%`; bgTaskBanner.style.display = 'flex'; } if (jobType === '3d' && typeof updateLoader === 'function') { updateLoader(progress, job.status === 'pending' ? 'En cola de espera...' : 'Procesando modelo 3D...', job.message || 'La IA está generando tu modelo 3D...'); } } else if (job.status === 'completed') { clearInterval(pollInterval); isPollingActiveJobPC = false; if (bgTaskBanner) bgTaskBanner.style.display = 'none'; if (jobType === '3d' && typeof statusOverlay !== 'undefined' && statusOverlay) { statusOverlay.classList.remove('active'); } try { refreshLibraryThumbnails(); } catch (e) {} try { fetchUserInfo(); } catch (e) {} const resData = job.result; if (jobType === '3d' && resData && (resData.gltfUrl || resData.glbUrl)) { showCustomToast('¡Modelo 3D generado y cargado con éxito!', 'success'); load3DModel(resData.gltfUrl, resData.glbUrl, resData.fbxUrl, resData.detectedCategory); } else if (jobType === '2d' && resData && resData.image) { showCustomToast('¡Imagen 2D generada con éxito!', 'success'); fetch(resData.image) .then(r => r.blob()) .then(blob => { const file = new File([blob], "generated.png", { type: "image/png" }); handleImageFile(file); const uploadTabBtn = document.querySelector('[data-tab="upload-tab"]'); if (uploadTabBtn) uploadTabBtn.click(); }).catch(e => console.error("Error loading generated 2D image:", e)); } } else if (job.status === 'failed') { clearInterval(pollInterval); isPollingActiveJobPC = false; if (bgTaskBanner) bgTaskBanner.style.display = 'none'; if (jobType === '3d' && typeof statusOverlay !== 'undefined' && statusOverlay) { statusOverlay.classList.remove('active'); } } } catch (e) { console.warn('[PC] Active job poll warning:', e); } }, 2500); } function renderLibraryThumbnails(items) { if (!libraryThumbnailGrid) return; // Filter only 2D images const images = items.filter(item => item.type === 'image'); if (images.length === 0) { libraryThumbnailGrid.innerHTML = `
No hay imágenes 2D en la biblioteca. Genera una primero en "Generar 2D AI".
`; return; } libraryThumbnailGrid.innerHTML = ''; images.forEach(img => { const thumb = document.createElement('div'); thumb.className = 'library-thumb-item'; thumb.title = img.name; thumb.dataset.filename = img.name; thumb.dataset.url = img.url; // Determine if this is the active preview const isCurrentActive = active2dImageBlob && active2dImageBlob.name === img.name; if (isCurrentActive) { thumb.classList.add('active'); } thumb.innerHTML = `${img.name}`; thumb.addEventListener('click', async () => { document.querySelectorAll('.library-thumb-item').forEach(el => el.classList.remove('active')); thumb.classList.add('active'); try { const res = await fetch(`${apiBase}${img.url}`); const blob = await res.blob(); const file = new File([blob], img.name, { type: blob.type }); handleImageFile(file); } catch (error) { console.error('Error setting library image:', error); } }); libraryThumbnailGrid.appendChild(thumb); }); } // 2D Image Upload Handlers function handleImageFile(file) { if (!file || !file.type.startsWith('image/')) return; active2dImageBlob = file; if (active2dImageURL) { URL.revokeObjectURL(active2dImageURL); } active2dImageURL = URL.createObjectURL(file); imagePreview.src = active2dImageURL; dropzone.style.display = 'none'; previewContainer.style.display = 'block'; generate3dBtn.disabled = false; } dropzone.addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', (e) => { if (e.target.files.length > 0) { handleImageFile(e.target.files[0]); } }); // Drag and Drop dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('dragover'); }); dropzone.addEventListener('dragleave', () => { dropzone.classList.remove('dragover'); }); dropzone.addEventListener('drop', (e) => { e.preventDefault(); dropzone.classList.remove('dragover'); if (e.dataTransfer.files.length > 0) { handleImageFile(e.dataTransfer.files[0]); } }); removePreviewBtn.addEventListener('click', (e) => { e.stopPropagation(); active2dImageBlob = null; if (active2dImageURL) { URL.revokeObjectURL(active2dImageURL); active2dImageURL = null; } imagePreview.src = ''; previewContainer.style.display = 'none'; dropzone.style.display = 'flex'; fileInput.value = ''; generate3dBtn.disabled = true; }); // Cropper.js Integration for PC Mode let pcCropper = null; const btnOpenCropPC = document.getElementById('btnOpenCropPC'); const cropModalOverlay = document.getElementById('cropModalOverlay'); const cropImageTarget = document.getElementById('cropImageTarget'); const btnCancelCrop = document.getElementById('btnCancelCrop'); const btnApplyCrop = document.getElementById('btnApplyCrop'); const btnCropAspectFree = document.getElementById('btnCropAspectFree'); const btnCropAspectSquare = document.getElementById('btnCropAspectSquare'); const btnCropReset = document.getElementById('btnCropReset'); function initCropperPC(imageUrl) { if (!cropModalOverlay || !cropImageTarget) return; cropImageTarget.src = imageUrl; cropModalOverlay.style.display = 'flex'; if (pcCropper) { pcCropper.destroy(); pcCropper = null; } setTimeout(() => { pcCropper = new Cropper(cropImageTarget, { aspectRatio: 1, viewMode: 1, autoCropArea: 0.85, responsive: true, background: false }); }, 150); } if (btnOpenCropPC) { btnOpenCropPC.addEventListener('click', () => { if (active2dImageURL) { initCropperPC(active2dImageURL); } }); } if (btnCancelCrop) { btnCancelCrop.addEventListener('click', () => { if (cropModalOverlay) cropModalOverlay.style.display = 'none'; if (pcCropper) { pcCropper.destroy(); pcCropper = null; } }); } if (btnCropAspectFree) { btnCropAspectFree.addEventListener('click', () => { if (pcCropper) pcCropper.setAspectRatio(NaN); }); } if (btnCropAspectSquare) { btnCropAspectSquare.addEventListener('click', () => { if (pcCropper) pcCropper.setAspectRatio(1); }); } if (btnCropReset) { btnCropReset.addEventListener('click', () => { if (pcCropper) pcCropper.reset(); }); } if (btnApplyCrop) { btnApplyCrop.addEventListener('click', () => { if (!pcCropper) return; const croppedCanvas = pcCropper.getCroppedCanvas({ width: 1024, height: 1024, imageSmoothingEnabled: true, imageSmoothingQuality: 'high' }); if (croppedCanvas) { croppedCanvas.toBlob((blob) => { if (blob) { const croppedFile = new File([blob], 'cropped_image.png', { type: 'image/png' }); handleImageFile(croppedFile); showCustomToast('Imagen recortada con éxito.'); } if (cropModalOverlay) cropModalOverlay.style.display = 'none'; if (pcCropper) { pcCropper.destroy(); pcCropper = null; } }, 'image/png'); } }); } // 2D Image Generation generate2dBtn.addEventListener('click', async () => { const prompt = promptInput.value.trim(); if (!prompt) { alert('Por favor introduce un prompt para la imagen.'); return; } generate2dBtn.disabled = true; const originalText = generate2dBtn.querySelector('span').textContent; generate2dBtn.querySelector('span').textContent = 'Generando 2D...'; const selectedModel = generatorModelSelect.value; // Create or get inline feedback element let feedbackEl = document.getElementById('generate2dFeedback'); if (!feedbackEl) { feedbackEl = document.createElement('div'); feedbackEl.id = 'generate2dFeedback'; feedbackEl.style.fontSize = '0.75rem'; feedbackEl.style.marginTop = '0.75rem'; feedbackEl.style.padding = '0.6rem'; feedbackEl.style.borderRadius = '6px'; feedbackEl.style.background = 'rgba(255, 255, 255, 0.04)'; feedbackEl.style.border = '1px solid var(--border)'; feedbackEl.style.color = 'var(--text-muted)'; feedbackEl.style.lineHeight = '1.4'; generate2dBtn.parentNode.appendChild(feedbackEl); } feedbackEl.style.display = 'block'; feedbackEl.style.background = 'rgba(255, 255, 255, 0.04)'; feedbackEl.style.borderColor = 'var(--border)'; feedbackEl.style.color = 'var(--text-muted)'; feedbackEl.innerHTML = `
Generando imagen. Esto puede tardar 1-2 minutos si el modelo de Hugging Face está inactivo...
`; try { const endpointUrl = window.location.hostname.includes('vercel.app') ? '/api/generate-2d' : `${apiBase}/api/generate-2d`; const response = await fetch(endpointUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: prompt, model: selectedModel, negative_prompt: negativePromptInput.value, token: hfToken }) }); if (!response.ok) { const errJson = await response.json().catch(() => ({})); throw new Error(errJson.error || `Error del servidor HTTP ${response.status}`); } if (response.status === 200) { const data = await response.json(); if (data.image) { feedbackEl.style.display = 'none'; // La API retorna un data URL (string). handleImageFile espera un File object. // Convertimos el data URL a Blob → File antes de pasarlo. const imageDataUrl = data.image; if (typeof imageDataUrl === 'string' && imageDataUrl.startsWith('data:')) { const [header, b64] = imageDataUrl.split(','); const mimeMatch = header.match(/:(.*?);/); const mime = mimeMatch ? mimeMatch[1] : 'image/jpeg'; const byteChars = atob(b64); const byteArr = new Uint8Array(byteChars.length); for (let i = 0; i < byteChars.length; i++) byteArr[i] = byteChars.charCodeAt(i); const blob = new Blob([byteArr], { type: mime }); const file = new File([blob], 'generated.jpg', { type: mime }); handleImageFile(file); } else { handleImageFile(imageDataUrl); } showCustomToast('¡Imagen 2D generada con éxito!'); generate2dBtn.disabled = false; generate2dBtn.querySelector('span').textContent = originalText; return; } } if (response.status === 402) { const errJson = await response.json().catch(() => ({})); feedbackEl.style.display = 'none'; openCreditsModal(errJson.error || 'Créditos insuficientes. Por favor, compra más créditos.'); generate2dBtn.disabled = false; generate2dBtn.querySelector('span').textContent = originalText; return; } if (response.status === 202) { const initJson = await response.json(); const jobId = initJson.job_id; if (initJson.credits !== undefined) { updateUserCreditsDisplay(initJson.credits); } // Show background task banner in header const bgTaskBanner = document.getElementById('bgTaskBanner'); const bgTaskText = document.getElementById('bgTaskText'); if (bgTaskBanner && bgTaskText) { bgTaskText.textContent = `Generando 2D... 0%`; bgTaskBanner.style.display = 'flex'; } // Start Polling for 2D job status const pollInterval = setInterval(async () => { try { const statusRes = await fetch(`${apiBase}/api/job-status?job_id=${jobId}`); if (!statusRes.ok) { throw new Error('Error al verificar el estado del trabajo.'); } const job = await statusRes.json(); if (job.status === 'processing' || job.status === 'pending') { const progress = job.progress || 10; feedbackEl.innerHTML = `
${job.message || 'Generando imagen...'} (${progress}%)
`; if (bgTaskBanner && bgTaskText) { bgTaskText.textContent = `Generando 2D... ${progress}%`; bgTaskBanner.style.display = 'flex'; } } else if (job.status === 'completed') { clearInterval(pollInterval); if (bgTaskBanner) bgTaskBanner.style.display = 'none'; const resJson = job.result; const base64Data = resJson.image; const modelUsed = resJson.model_used || selectedModel; const imgFetchRes = await fetch(base64Data); const imageBlob = await imgFetchRes.blob(); const generatedFile = new File([imageBlob], "generated.png", { type: "image/png" }); handleImageFile(generatedFile); feedbackEl.style.background = 'rgba(76, 175, 80, 0.1)'; feedbackEl.style.borderColor = 'rgba(76, 175, 80, 0.3)'; feedbackEl.style.color = '#81c784'; feedbackEl.innerHTML = `¡Éxito usando: ${modelUsed.split('/').pop()}!`; document.querySelector('[data-tab="upload-tab"]').click(); refreshGallery(); showCustomToast('¡Imagen 2D generada en segundo plano con éxito!', 'success'); setTimeout(() => { feedbackEl.style.display = 'none'; }, 5000); generate2dBtn.disabled = false; generate2dBtn.querySelector('span').textContent = originalText; } else if (job.status === 'failed') { clearInterval(pollInterval); if (bgTaskBanner) bgTaskBanner.style.display = 'none'; throw new Error(job.message || 'Fallo en la generación de imagen.'); } } catch (pollErr) { clearInterval(pollInterval); if (bgTaskBanner) bgTaskBanner.style.display = 'none'; console.error(pollErr); feedbackEl.style.background = 'rgba(244, 67, 54, 0.1)'; feedbackEl.style.borderColor = 'rgba(244, 67, 54, 0.3)'; feedbackEl.style.color = '#e57373'; feedbackEl.innerHTML = `Error: ${pollErr.message}`; showCustomAlert(pollErr.message, 'Error al generar imagen 2D'); generate2dBtn.disabled = false; generate2dBtn.querySelector('span').textContent = originalText; } }, 2000); return; } if (!response.ok) { const errJson = await response.json().catch(() => ({})); throw new Error(errJson.error || 'Error en la respuesta del servidor de generación de imágenes.'); } } catch (err) { console.error(err); feedbackEl.style.display = 'block'; feedbackEl.style.background = 'rgba(244, 67, 54, 0.1)'; feedbackEl.style.borderColor = 'rgba(244, 67, 54, 0.3)'; feedbackEl.style.color = '#ef5350'; feedbackEl.innerHTML = `Error: ${err.message || 'Error en la respuesta del servidor de generación de imágenes.'}`; } finally { generate2dBtn.disabled = false; generate2dBtn.querySelector('span').textContent = originalText; } }); // 3D Generation (Trellis) generate3dBtn.addEventListener('click', async () => { if (!active2dImageBlob) return; const version = 'v2'; // Show Loading state statusOverlay.classList.add('active'); updateLoader(10, 'Preparando imagen...', 'Convirtiendo la imagen 2D para enviar al servidor.'); // Resize and compress base64 image to prevent HTTP 413 Entity Too Large const imgEl = new Image(); imgEl.onload = () => { const canvas = document.createElement('canvas'); const maxDim = 512; let w = imgEl.width; let h = imgEl.height; if (w > maxDim || h > maxDim) { if (w > h) { h = Math.round((h * maxDim) / w); w = maxDim; } else { w = Math.round((w * maxDim) / h); h = maxDim; } } canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.drawImage(imgEl, 0, 0, w, h); const base64Image = canvas.toDataURL('image/jpeg', 0.8); (async () => { try { // Check Hugging Face Space status first updateLoader(15, 'Verificando estado del servidor de IA...', 'Consultando disponibilidad del servidor Hugging Face...'); let spaceSleeping = false; try { const statusRes = await fetch(`${apiBase}/api/space-status?type=3d&token=${encodeURIComponent(hfToken)}`); if (statusRes.ok) { const statusData = await statusRes.json(); if (statusData.stage === 'SLEEPING') { spaceSleeping = true; } } } catch (statusErr) { console.warn('Failed to check space status, continuing anyway:', statusErr); } if (spaceSleeping) { updateLoader(20, 'Encendiendo servidor de IA...', 'El servidor en Hugging Face está hibernando por inactividad. Lo estamos encendiendo, esto puede tardar de 2 a 3 minutos en iniciar...'); } else { updateLoader(25, 'Conectando con el servidor...', 'Enviando datos al motor Trellis a través de tu servidor local. Esto evita problemas de CORS.'); } let seedValNum = parseInt(seedInput.value) || 0; if (randomSeedCheck.checked) { seedValNum = Math.floor(Math.random() * 2147483647); seedInput.value = seedValNum; // display the seed used } const resolution = resolutionSelect.value; const decimate = parseInt(decimateInput.value) || 300000; const textureSize = parseInt(textureSizeSelect.value) || 2048; const ssGuidanceNum = parseFloat(ssGuidanceInput.value); const ssStepsNum = parseInt(ssStepsInput.value); const slatGuidanceNum = parseFloat(slatGuidanceInput.value); const slatStepsNum = parseInt(slatStepsInput.value); const autoOptimize = false; const quadTargetFaces = 60000; const remeshMethod = 'cleanup'; const endpoint3dUrl = window.location.hostname.includes('vercel.app') ? '/api/generate-3d' : `${apiBase}/api/generate-3d`; const response = await fetch(endpoint3dUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: version, image: base64Image, token: hfToken, seed: seedValNum, resolution: resolution, decimation_target: decimate, texture_size: textureSize, ss_guidance: ssGuidanceNum, ss_steps: ssStepsNum, slat_guidance: slatGuidanceNum, slat_steps: slatStepsNum, auto_optimize: autoOptimize, quad_target_faces: quadTargetFaces, remeshMethod: remeshMethod, prompt: promptInput ? promptInput.value.trim() : '' }) }); if (response.status === 402) { const errData = await response.json().catch(() => ({})); statusOverlay.classList.remove('active'); openCreditsModal(errData.error || 'Créditos insuficientes. La generación 3D cuesta 5 créditos.'); return; } if (response.status === 200) { const data = await response.json(); statusOverlay.classList.remove('active'); if (data.result && (data.result.gltfUrl || data.result.glbUrl)) { refreshGallery(); showCustomToast('¡Modelo 3D generado con éxito!', 'success'); load3DModel(data.result.gltfUrl || data.result.glbUrl, data.result.glbUrl, data.result.fbxUrl, data.result.detectedCategory); return; } } if (response.status === 202) { const initData = await response.json(); const jobId = initData.job_id; if (initData.credits !== undefined) { updateUserCreditsDisplay(initData.credits); } let consecutiveFailures = 0; const maxAllowedFailures = 120; // Permitir hasta 5 minutos de polling para modelos 3D complejos en GPU const bgTaskBanner = document.getElementById('bgTaskBanner'); const bgTaskText = document.getElementById('bgTaskText'); // Start Polling for 3D job status const pollInterval = setInterval(async () => { try { const encodedJobId = encodeURIComponent(jobId); const statusUrl = window.location.hostname.includes('vercel.app') ? `/api/job-status?job_id=${encodedJobId}&_t=${Date.now()}` : `${apiBase}/api/job-status?job_id=${encodedJobId}&_t=${Date.now()}`; const statusRes = await fetch(statusUrl); if (!statusRes.ok) { consecutiveFailures++; console.warn(`[3D Status Poll Retry ${consecutiveFailures}/${maxAllowedFailures}] HTTP ${statusRes.status}`); if (consecutiveFailures < maxAllowedFailures) return; throw new Error('No se pudo obtener el estado de la GPU tras varios intentos.'); } consecutiveFailures = 0; const job = await statusRes.json(); if (job.status === 'processing' || job.status === 'pending') { const progress = job.progress || 10; updateLoader(progress, job.status === 'pending' ? 'En cola de espera...' : 'Procesando en segundo plano...', job.message || 'Generando modelo 3D...'); if (bgTaskBanner && bgTaskText) { bgTaskText.textContent = `Generando 3D... ${progress}%`; bgTaskBanner.style.display = 'flex'; } } else if (job.status === 'completed') { clearInterval(pollInterval); statusOverlay.classList.remove('active'); if (bgTaskBanner) bgTaskBanner.style.display = 'none'; const data = job.result; if (data.gltfUrl && data.glbUrl) { refreshGallery(); showCustomToast('¡Modelo 3D generado y cargado con éxito!', 'success'); load3DModel(data.gltfUrl, data.glbUrl, data.fbxUrl, data.detectedCategory); } else { throw new Error('El resultado de la tarea no contiene las URLs de modelo esperadas.'); } } else if (job.status === 'failed') { clearInterval(pollInterval); if (bgTaskBanner) bgTaskBanner.style.display = 'none'; throw new Error(job.message || 'Error durante la generación de la malla 3D.'); } } catch (pollErr) { if (pollErr.name === 'TypeError' || pollErr.message.includes('Failed to fetch') || pollErr.message.includes('NetworkError')) { consecutiveFailures++; if (consecutiveFailures < maxAllowedFailures) { console.warn(`[Network Retry ${consecutiveFailures}/${maxAllowedFailures}] Reconectando a la tarea 3D...`); return; } } clearInterval(pollInterval); if (bgTaskBanner) bgTaskBanner.style.display = 'none'; console.error(pollErr); showCustomAlert(pollErr.message, 'Error al generar modelo 3D'); statusOverlay.classList.remove('active'); } }, 2500); return; } if (!response.ok) { const errData = await response.json().catch(() => ({ error: 'Error de red o timeout del servidor.' })); let fullErrMsg = errData.error || 'Error interno en el servidor.'; if (errData.diagnostics && Array.isArray(errData.diagnostics) && errData.diagnostics.length > 0) { console.group('🔍 [3D-DIAGNOSTICS TRACE]'); errData.diagnostics.forEach(item => { console.log(`[${item.stage}] ${item.message}`, item.data || ''); }); console.groupEnd(); const formattedLogs = errData.diagnostics.map(d => `• [${d.stage}] ${d.message} ${d.data ? JSON.stringify(d.data) : ''}`).join('\n'); fullErrMsg += '\n\nTraza de Diagnóstico:\n' + formattedLogs; } throw new Error(fullErrMsg); } } catch (error) { console.error(error); showCustomAlert(error.message, 'Error al generar modelo 3D'); statusOverlay.classList.remove('active'); } })(); }; imgEl.src = active2dImageURL; }); function updateLoader(percent, title, desc) { loaderPercentage.textContent = `${percent}%`; statusTitle.textContent = title; statusDesc.textContent = desc; } // Three.js State for Skeletal Animations let threeScene = null; let threeCamera = null; let threeRenderer = null; let threeControls = null; let threeModel = null; let threeBones = {}; let threeAnimationFrameId = null; let threeClock = new THREE.Clock(); function cleanupThreeViewer() { if (threeAnimationFrameId) { cancelAnimationFrame(threeAnimationFrameId); threeAnimationFrameId = null; } if (threeSkeletonHelper && threeScene) { try { threeScene.remove(threeSkeletonHelper); } catch (e) { } } threeSkeletonHelper = null; if (jointSpheres && jointSpheres.length > 0) { jointSpheres.forEach(item => { if (threeScene) { try { threeScene.remove(item.mesh); } catch (e) { } } }); jointSpheres = []; } showSkeletonHelper = false; if (toggleBonesHelperBtn) { toggleBonesHelperBtn.classList.remove('active'); const span = toggleBonesHelperBtn.querySelector('span'); if (span) span.textContent = "Ver Esqueleto (Rayos X)"; } if (threeRenderer) { try { threeRenderer.dispose(); } catch (e) { } threeRenderer = null; } threeScene = null; threeCamera = null; threeControls = null; threeModel = null; threeBones = {}; } // Show toast feedback for actions (e.g. bone clicking) function showCustomToast(msg) { let toast = document.getElementById('customToast'); if (!toast) { toast = document.createElement('div'); toast.id = 'customToast'; toast.style.position = 'absolute'; toast.style.bottom = '100px'; toast.style.left = '50%'; toast.style.transform = 'translateX(-50%)'; toast.style.background = 'rgba(18, 18, 24, 0.95)'; toast.style.border = '1px solid var(--primary)'; toast.style.color = '#fff'; toast.style.padding = '8px 16px'; toast.style.borderRadius = '20px'; toast.style.fontSize = '0.85rem'; toast.style.zIndex = '1000'; toast.style.transition = 'opacity 0.3s ease'; toast.style.pointerEvents = 'none'; viewerDisplay.appendChild(toast); } toast.textContent = msg; toast.style.opacity = '1'; clearTimeout(toast.timeoutId); toast.timeoutId = setTimeout(() => { toast.style.opacity = '0'; }, 2000); } function setupCanvasClickRaycaster(canvas) { let pointerDownX = 0; let pointerDownY = 0; canvas.addEventListener('pointerdown', (event) => { pointerDownX = event.clientX; pointerDownY = event.clientY; }); canvas.addEventListener('pointerup', (event) => { if (!showSkeletonHelper || !threeCamera || !threeModel) return; // Calculate drag distance in screen pixels const moveX = event.clientX - pointerDownX; const moveY = event.clientY - pointerDownY; const dragDistance = Math.sqrt(moveX * moveX + moveY * moveY); // If the user dragged more than 5 pixels, they are rotating the camera - discard the click! if (dragDistance > 5) return; const rect = canvas.getBoundingClientRect(); const mouse = new THREE.Vector2( ((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1 ); const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(mouse, threeCamera); // Intersect only the joint sphere meshes to guarantee the user specifically clicks the sphere const meshesToIntersect = jointSpheres.map(item => item.mesh).filter(Boolean); const intersects = raycaster.intersectObjects(meshesToIntersect); if (intersects.length > 0) { const hitMesh = intersects[0].object; const jointInfo = jointSpheres.find(item => item.mesh === hitMesh); if (jointInfo) { const closestBone = jointInfo.bone; console.log("[Raycast Click] Selected bone:", closestBone.name); selectedBone = closestBone; if (manualBoneNameInput) { manualBoneNameInput.value = selectedBone.name; } if (applyBoneRenameBtn) { applyBoneRenameBtn.style.display = 'none'; } loadSelectedBoneRotations(); updateWeightVisualizerBone(); updateSelectedBoneMarker(); showCustomToast(`Hueso seleccionado: ${closestBone.name}`); } } }); let hoveredJoint = null; const originalColor = 0x00ffcc; canvas.addEventListener('pointermove', (event) => { if (!showSkeletonHelper || !threeCamera || !threeModel) { if (hoveredJoint) { hoveredJoint.material.color.setHex(originalColor); hoveredJoint.scale.setScalar(1.0); hoveredJoint = null; } return; } // Skip hover highlight if we are dragging/orbiting the camera if (event.buttons !== 0) { if (hoveredJoint) { hoveredJoint.material.color.setHex(originalColor); hoveredJoint.scale.setScalar(1.0); hoveredJoint = null; } return; } const rect = canvas.getBoundingClientRect(); const mouse = new THREE.Vector2( ((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1 ); const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(mouse, threeCamera); const meshesToIntersect = jointSpheres.map(item => item.mesh).filter(Boolean); const intersects = raycaster.intersectObjects(meshesToIntersect); if (intersects.length > 0) { const hitMesh = intersects[0].object; if (hoveredJoint !== hitMesh) { // Restore old one if (hoveredJoint) { hoveredJoint.material.color.setHex(originalColor); hoveredJoint.scale.setScalar(1.0); } // Highlight new one hoveredJoint = hitMesh; hoveredJoint.material.color.setHex(0xffff00); // Yellow highlight hoveredJoint.scale.setScalar(1.3); // Scale up slightly for feedback } } else { // Restore old one if no hit if (hoveredJoint) { hoveredJoint.material.color.setHex(originalColor); hoveredJoint.scale.setScalar(1.0); hoveredJoint = null; } } }); } function initThreeViewer(gltfUrl) { cleanupThreeViewer(); activeGltfUrl = gltfUrl; // Clear display viewerDisplay.innerHTML = ''; // Create canvas const canvas = document.createElement('canvas'); canvas.style.width = '100%'; canvas.style.height = '100%'; viewerDisplay.appendChild(canvas); // Setup click selection for bones raycasting setupCanvasClickRaycaster(canvas); setupWeightPaintingCanvasEvents(canvas); // Setup Scene threeScene = new THREE.Scene(); threeScene.background = new THREE.Color(0x0a0a0c); // Add custom styled grid helper const gridHelper = new THREE.GridHelper(10, 20, 0x9c27b0, 0x222230); // purple matching UI gridHelper.position.y = -0.5; threeScene.add(gridHelper); // Setup Camera const rect = viewerDisplay.getBoundingClientRect(); threeCamera = new THREE.PerspectiveCamera(45, rect.width / rect.height, 0.1, 100); threeCamera.position.set(0, 0.3, 2.5); // Setup Renderer threeRenderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true }); threeRenderer.setSize(rect.width, rect.height); threeRenderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); threeRenderer.shadowMap.enabled = true; threeRenderer.outputEncoding = THREE.sRGBEncoding; threeRenderer.physicallyCorrectLights = true; // Setup Controls threeControls = new THREE.OrbitControls(threeCamera, threeRenderer.domElement); threeControls.enableDamping = true; threeControls.dampingFactor = 0.05; threeControls.target.set(0, 0.3, 0); threeControls.autoRotate = isAutoRotating; threeControls.autoRotateSpeed = 1.2; // Setup Lighting - Brightened and balanced const ambientLight = new THREE.AmbientLight(0xffffff, 2.5); threeScene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xffffff, 3.5); dirLight.position.set(2, 5, 3); dirLight.castShadow = true; threeScene.add(dirLight); const fillLight = new THREE.DirectionalLight(0xffffff, 2.0); fillLight.position.set(-2, 1, -2); threeScene.add(fillLight); const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 2.0); hemiLight.position.set(0, 20, 0); threeScene.add(hemiLight); updateLoader(60, 'Cargando modelo 3D...', 'Inicializando visor Three.js e interpretando geometría...'); statusOverlay.classList.add('active'); const absoluteUrl = gltfUrl.startsWith('http') ? gltfUrl : `${apiBase}${gltfUrl}`; const isFbx = absoluteUrl.includes('.fbx'); const handleLoadedModel = (modelObj) => { threeModel = modelObj; threeModel.updateMatrixWorld(true); // Adjust scaling and positions to fit the screen nicely regardless of FBX vs GLB scale units const box = new THREE.Box3().setFromObject(threeModel); const size = box.getSize(new THREE.Vector3()); // Normalize size if it's too big or small const maxDim = Math.max(size.x, size.y, size.z); if (maxDim > 0) { const scaleFactor = 1.6 / maxDim; threeModel.scale.multiplyScalar(scaleFactor); threeModel.updateMatrixWorld(true); } // Stand model on the grid floor const updatedBox = new THREE.Box3().setFromObject(threeModel); const updatedCenter = updatedBox.getCenter(new THREE.Vector3()); threeModel.position.x = -updatedCenter.x; threeModel.position.y = -updatedBox.min.y - 0.5; threeModel.position.z = -updatedCenter.z; threeScene.add(threeModel); // Find and map bones using a coordinate-based spatial classifier threeBones = {}; const allBonesList = []; threeModel.traverse((child) => { if (child.isSkinnedMesh || child.isMesh) { child.castShadow = true; child.receiveShadow = true; if (child.material) { let mats = Array.isArray(child.material) ? child.material : [child.material]; mats = mats.map(mat => { // Replace legacy FBX Phong materials with PBR MeshStandardMaterial const texMap = mat.map || null; const stdMat = new THREE.MeshStandardMaterial({ map: texMap, color: 0xffffff, roughness: 0.5, metalness: 0.1, side: THREE.DoubleSide }); if (texMap) texMap.needsUpdate = true; return stdMat; }); child.material = Array.isArray(child.material) ? mats : mats[0]; } } if (child.isBone) { allBonesList.push(child); threeBones[child.name] = child; } }); // Apply wireframe, nodes, and texture view if active applyWireframeState(threeModel, showWireframe); applyNodesState(threeModel, showNodes); applyTextureVisibility(threeModel, showTextures); // Ensure world matrices are fully computed in the initial T-pose threeModel.updateMatrixWorld(true); // Fetch absolute world positions const boneData = allBonesList.map(bone => { const pos = new THREE.Vector3(); bone.getWorldPosition(pos); return { bone, pos, name: bone.name }; }); // Map bones spatially if present if (boneData.length > 0) { const rootBoneData = boneData.reduce((prev, curr) => curr.pos.y < prev.pos.y ? curr : prev, boneData[0]); threeBones.hips = rootBoneData.bone; const hipsY = rootBoneData.pos.y; const leftBones = []; const rightBones = []; const spineBones = []; boneData.forEach(d => { if (d.bone === threeBones.hips) return; const relX = d.pos.x - rootBoneData.pos.x; const relY = d.pos.y; if (relY < hipsY - 0.1) { if (relX < -0.04) leftBones.push(d); else if (relX > 0.04) rightBones.push(d); } else { if (Math.abs(relX) < 0.08) { spineBones.push(d); } else { if (relX < -0.08) leftBones.push(d); else if (relX > 0.08) rightBones.push(d); } } }); const leftLegBones = leftBones.filter(d => d.pos.y < hipsY - 0.1).sort((a, b) => b.pos.y - a.pos.y); const rightLegBones = rightBones.filter(d => d.pos.y < hipsY - 0.1).sort((a, b) => b.pos.y - a.pos.y); if (leftLegBones.length >= 1) threeBones.leftUpLeg = leftLegBones[0].bone; if (leftLegBones.length >= 2) threeBones.leftLeg = leftLegBones[1].bone; if (leftLegBones.length >= 3) threeBones.leftFoot = leftLegBones[2].bone; if (rightLegBones.length >= 1) threeBones.rightUpLeg = rightLegBones[0].bone; if (rightLegBones.length >= 2) threeBones.rightLeg = rightLegBones[1].bone; if (rightLegBones.length >= 3) threeBones.rightFoot = rightLegBones[2].bone; const leftArmBones = leftBones.filter(d => d.pos.y >= hipsY - 0.1).sort((a, b) => Math.abs(a.pos.x - rootBoneData.pos.x) - Math.abs(b.pos.x - rootBoneData.pos.x)); const rightArmBones = rightBones.filter(d => d.pos.y >= hipsY - 0.1).sort((a, b) => Math.abs(a.pos.x - rootBoneData.pos.x) - Math.abs(b.pos.x - rootBoneData.pos.x)); let leftArmStart = leftArmBones.length >= 4 ? 1 : 0; let rightArmStart = rightArmBones.length >= 4 ? 1 : 0; if (leftArmBones.length > leftArmStart) threeBones.leftArm = leftArmBones[leftArmStart].bone; if (leftArmBones.length > leftArmStart + 1) threeBones.leftForearm = leftArmBones[leftArmStart + 1].bone; if (leftArmBones.length > leftArmStart + 2) threeBones.leftHand = leftArmBones[leftArmStart + 2].bone; if (rightArmBones.length > rightArmStart) threeBones.rightArm = rightArmBones[rightArmStart].bone; if (rightArmBones.length > rightArmStart + 1) threeBones.rightForearm = rightArmBones[rightArmStart + 1].bone; if (rightArmBones.length > rightArmStart + 2) threeBones.rightHand = rightArmBones[rightArmStart + 2].bone; const sortedSpine = spineBones.sort((a, b) => a.pos.y - b.pos.y); if (sortedSpine.length >= 1) threeBones.spine = sortedSpine[0].bone; if (sortedSpine.length >= 2) threeBones.neck = sortedSpine[sortedSpine.length - 1].bone; } console.log("[ThreeViewer] Bones mapped successfully:", Object.keys(threeBones)); window.threeBones = threeBones; window.threeModel = threeModel; setupManualBoneSelectOptions(); const hasBones = allBonesList.length > 0; if (toggleBonesBtn) { if (!hasBones) { toggleBonesBtn.classList.add('disabled-btn'); toggleBonesBtn.style.opacity = '0.35'; toggleBonesBtn.style.cursor = 'not-allowed'; } else { toggleBonesBtn.classList.remove('disabled-btn'); toggleBonesBtn.style.opacity = ''; toggleBonesBtn.style.cursor = ''; } } statusOverlay.classList.remove('active'); threeClock.start(); threeClock.getDelta(); threeAnimate(); }; const onProgress = (xhr) => { if (xhr.total) { const percent = Math.round((xhr.loaded / xhr.total) * 100); updateLoader(60 + Math.round(percent * 0.4), 'Cargando modelo 3D...', `Descargando geometría: ${percent}%`); } else if (xhr.loaded) { updateLoader(80, 'Cargando modelo 3D...', 'Procesando textura y geometría...'); } }; const onError = (error) => { console.error('[ThreeViewer] Error loading model:', error); statusOverlay.classList.remove('active'); showCustomAlert('No se pudo cargar el modelo 3D especificado.', 'Error de visualización'); }; if (isFbx && typeof THREE.FBXLoader !== 'undefined') { console.log("[ThreeViewer] Loading FBX model natively:", absoluteUrl); const fbxLoader = new THREE.FBXLoader(); fbxLoader.load(absoluteUrl, (fbx) => handleLoadedModel(fbx), onProgress, onError); } else { console.log("[ThreeViewer] Loading GLTF/GLB model:", absoluteUrl); const gltfLoader = new THREE.GLTFLoader(); gltfLoader.load(absoluteUrl, (gltf) => handleLoadedModel(gltf.scene), onProgress, onError); } window.addEventListener('resize', onThreeResize); } function onThreeResize() { if (!threeCamera || !threeRenderer || !viewerDisplay) return; const rect = viewerDisplay.getBoundingClientRect(); threeCamera.aspect = rect.width / rect.height; threeCamera.updateProjectionMatrix(); threeRenderer.setSize(rect.width, rect.height); // Update all wireframe passes screenSize dynamically if (threeModel) { threeModel.traverse(child => { if (child.name.endsWith("_wireframe_helper")) { child.traverse(subChild => { if (subChild.material && subChild.material.uniforms && subChild.material.uniforms.screenSize) { subChild.material.uniforms.screenSize.value.set(rect.width, rect.height); } }); } }); } } function threeAnimate() { threeAnimationFrameId = requestAnimationFrame(threeAnimate); if (threeControls) threeControls.update(); // Apply bones rotation updateSkeletalAnimation(); // Update glowing joint spheres positions in real-time if (showSkeletonHelper && jointSpheres && jointSpheres.length > 0) { jointSpheres.forEach(item => { const pos = new THREE.Vector3(); item.bone.getWorldPosition(pos); item.mesh.position.copy(pos); }); } // Update selected bone marker position in real-time if weight painting is active if (isWeightPaintingActive && selectedBoneMarker) { if (selectedBone) { const pos = new THREE.Vector3(); selectedBone.getWorldPosition(pos); selectedBoneMarker.position.copy(pos); selectedBoneMarker.visible = true; } else { selectedBoneMarker.visible = false; } } else if (selectedBoneMarker) { selectedBoneMarker.visible = false; } if (isWeightPaintingActive && brushHelper && threeCamera) { brushHelper.lookAt(threeCamera.position); } if (threeRenderer && threeScene && threeCamera) { threeRenderer.render(threeScene, threeCamera); } } function updateSkeletalAnimation() { // Only manual adjustment is supported; bypass all procedural ticks. } // 3D Model Render and Viewer Interactions function load3DModel(gltfUrl, downloadUrl, fbxUrl, detectedCategory = 'unknown') { currentModelCategory = detectedCategory; let categoryName = "Desconocido"; const cat = String(detectedCategory).toLowerCase(); if (cat === 'ai' || cat === 'humanoid') { categoryName = "Humanoide (Bípedo)"; } else if (cat === 'local_quadruped' || cat === 'quadruped') { categoryName = "Cuadrúpedo (4 patas)"; } else if (cat === 'unsupported' || cat === 'spider') { categoryName = "Arácnido / Insecto / Objeto"; } else if (cat === 'object') { categoryName = "Objeto Estático"; } else if (detectedCategory && detectedCategory !== 'unknown') { categoryName = detectedCategory; } if (aiClassificationText && aiClassificationBadge) { aiClassificationText.textContent = categoryName; aiClassificationBadge.style.display = 'flex'; } else if (aiClassificationBadge) { aiClassificationBadge.style.display = 'none'; } const rigTextEl = document.getElementById('rigDetectedCategoryText'); if (rigTextEl) { rigTextEl.textContent = categoryName; } // Resolve URLs using apiBase for file:// compatibility const absoluteGltfUrl = gltfUrl.startsWith('http') ? gltfUrl : `${apiBase}${gltfUrl}`; const absoluteDownloadUrl = downloadUrl ? (downloadUrl.startsWith('http') ? downloadUrl : `${apiBase}${downloadUrl}`) : absoluteGltfUrl; currentGltfUrl = absoluteDownloadUrl; // Store the high-quality GLB download url activeModelRelativeUrl = gltfUrl; // Store relative path for rigging // Check if the loaded model is a rigged model const isRigged = gltfUrl.includes('_rigged.glb') || gltfUrl.includes('_rigged.fbx'); const isClean = gltfUrl.includes('_clean.glb'); if (animationControlGroup) animationControlGroup.style.display = 'none'; viewerOverlay.classList.add('active'); // FBX button is always visible as the main download action downloadFbxBtn.style.display = 'inline-flex'; downloadFbxBtn.onclick = async () => { let targetFbx = fbxUrl; if (!targetFbx && activeModelRelativeUrl) { targetFbx = activeModelRelativeUrl.replace('.glb', '.fbx'); } if (targetFbx) { const absoluteFbxUrl = targetFbx.startsWith('http') ? targetFbx : `${apiBase}${targetFbx}`; const link = document.createElement('a'); link.href = absoluteFbxUrl; link.download = absoluteFbxUrl.split('/').pop(); document.body.appendChild(link); link.click(); document.body.removeChild(link); return; } }; // Show vertical toolbar const toolbar = document.getElementById('rightVerticalToolbar'); if (toolbar) toolbar.style.display = 'flex'; // Set buttons and panels visibility based on model state const btnClean = document.getElementById('btnCleanPanel'); const btnRigTrigger = document.getElementById('btnRigTriggerPanel'); const btnRig = document.getElementById('btnRigPanel'); const btnWeightPaint = document.getElementById('btnWeightPaintPanel'); // Clean Panel / Button if (manualCleanPanel) { manualCleanPanel.classList.add('collapsed'); if (!isClean) { manualCleanPanel.style.display = 'block'; if (btnClean) btnClean.style.display = 'flex'; } else { manualCleanPanel.style.display = 'none'; if (btnClean) btnClean.style.display = 'none'; } } // Rig Trigger Panel / Button (AI Rigging) if (rigTriggerPanel) { rigTriggerPanel.classList.add('collapsed'); if (gltfUrl && !isRigged) { rigTriggerPanel.style.display = 'block'; if (btnRigTrigger) btnRigTrigger.style.display = 'flex'; } else { rigTriggerPanel.style.display = 'none'; if (btnRigTrigger) btnRigTrigger.style.display = 'none'; } } // Prefer FBX for native quad mesh display if available, otherwise use GLB const targetViewerUrl = (fbxUrl && fbxUrl.endsWith('.fbx')) ? (fbxUrl.startsWith('http') ? fbxUrl : `${apiBase}${fbxUrl}`) : absoluteDownloadUrl; initThreeViewer(targetViewerUrl); // Manual Rig & Weight Paint (Rigged models only) if (isRigged) { if (manualRigPanel) { manualRigPanel.style.display = 'block'; manualRigPanel.classList.add('collapsed'); setupManualBoneSelectOptions(); } if (manualWeightPaintPanel) { manualWeightPaintPanel.style.display = 'block'; manualWeightPaintPanel.classList.add('collapsed'); } if (btnRig) btnRig.style.display = 'flex'; if (btnWeightPaint) btnWeightPaint.style.display = 'flex'; } else { if (manualRigPanel) manualRigPanel.style.display = 'none'; if (manualWeightPaintPanel) manualWeightPaintPanel.style.display = 'none'; if (btnRig) btnRig.style.display = 'none'; if (btnWeightPaint) btnWeightPaint.style.display = 'none'; } // Reset toolbar button active states on load const toolbarButtons = [btnClean, btnRigTrigger, btnRig, btnWeightPaint]; toolbarButtons.forEach(btn => { if (btn) btn.classList.remove('active'); }); // Reset weight painting state on load isWeightPaintingActive = false; if (activeWeightPaintCheck) { activeWeightPaintCheck.checked = false; const card = document.getElementById('paintInstructionCard'); if (card) card.style.display = 'none'; } updateSelectedBoneMarker(); originalMaterialsMap.clear(); if (brushHelper && threeScene) { threeScene.remove(brushHelper); brushHelper = null; } } // Toolbar actions if (toggleAutoRotateBtn) { // Sync UI indicator with state on load if (isAutoRotating) { toggleAutoRotateBtn.classList.add('active'); } else { toggleAutoRotateBtn.classList.remove('active'); } toggleAutoRotateBtn.addEventListener('click', () => { isAutoRotating = !isAutoRotating; if (isAutoRotating) { toggleAutoRotateBtn.classList.add('active'); } else { toggleAutoRotateBtn.classList.remove('active'); } if (threeControls) { threeControls.autoRotate = isAutoRotating; } }); } if (resetCameraBtn) { resetCameraBtn.addEventListener('click', () => { if (threeCamera && threeControls) { threeCamera.position.set(0, 0.3, 2.5); threeControls.target.set(0, 0.3, 0); threeControls.update(); } // Temporary flash class on button for visual feedback resetCameraBtn.style.color = 'var(--primary-light)'; setTimeout(() => { resetCameraBtn.style.color = ''; }, 300); }); } class SkinnedPoints extends THREE.Points { constructor(geometry, material) { super(geometry, material); this.isSkinnedMesh = true; this.bindMatrix = new THREE.Matrix4(); this.bindMatrixInverse = new THREE.Matrix4(); } bind(skeleton, bindMatrix) { this.skeleton = skeleton; this.bindMatrix.copy(bindMatrix || this.matrixWorld); this.bindMatrixInverse.copy(this.bindMatrix).invert(); } } const skinnedLineVertexShader = ` uniform vec2 offset; uniform vec2 screenSize; #include #include void main() { #include #include #include vec4 mvPosition = modelViewMatrix * vec4(transformed, 1.0); gl_Position = projectionMatrix * mvPosition; gl_Position.xy += (offset / screenSize) * gl_Position.w * 2.0; } `; const staticLineVertexShader = ` uniform vec2 offset; uniform vec2 screenSize; void main() { vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * mvPosition; gl_Position.xy += (offset / screenSize) * gl_Position.w * 2.0; } `; const staticLineFragmentShader = ` uniform vec3 color; void main() { gl_FragColor = vec4(color, 0.95); } `; const skinnedLineFragmentShader = ` uniform vec3 color; void main() { gl_FragColor = vec4(color, 0.95); } `; function createQuadWireframeGeometry(sourceGeometry) { const indices = sourceGeometry.index ? sourceGeometry.index.array : null; if (!indices) { return new THREE.EdgesGeometry(sourceGeometry, 10); } const quadEdges = []; const addedEdges = new Set(); function addEdge(i1, i2) { const key = i1 < i2 ? `${i1}-${i2}` : `${i2}-${i1}`; if (!addedEdges.has(key)) { addedEdges.add(key); quadEdges.push(i1, i2); } } const numIndices = indices.length; let i = 0; while (i < numIndices) { if (i + 5 < numIndices) { const a = indices[i]; const b = indices[i+1]; const c = indices[i+2]; const d = indices[i+3]; const e = indices[i+4]; const f = indices[i+5]; const t1 = [a, b, c]; const t2 = [d, e, f]; const shared = t1.filter(v => t2.includes(v)); if (shared.length === 2) { const s1 = shared[0]; const s2 = shared[1]; const o1 = t1.find(v => v !== s1 && v !== s2); const o2 = t2.find(v => v !== s1 && v !== s2); addEdge(s1, o1); addEdge(o1, s2); addEdge(s2, o2); addEdge(o2, s1); i += 6; continue; } } const a = indices[i]; const b = indices[i+1]; const c = indices[i+2]; addEdge(a, b); addEdge(b, c); addEdge(c, a); i += 3; } const lineGeometry = new THREE.BufferGeometry(); lineGeometry.setAttribute('position', sourceGeometry.attributes.position.clone()); if (sourceGeometry.attributes.skinIndex) { lineGeometry.setAttribute('skinIndex', sourceGeometry.attributes.skinIndex.clone()); } if (sourceGeometry.attributes.skinWeight) { lineGeometry.setAttribute('skinWeight', sourceGeometry.attributes.skinWeight.clone()); } lineGeometry.setIndex(new THREE.BufferAttribute(new Uint32Array(quadEdges), 1)); return lineGeometry; } class SkinnedLineSegments extends THREE.LineSegments { constructor(geometry, material) { super(geometry, material); this.isSkinnedMesh = true; this.bindMatrix = new THREE.Matrix4(); this.bindMatrixInverse = new THREE.Matrix4(); } bind(skeleton, bindMatrix) { this.skeleton = skeleton; this.bindMatrix.copy(bindMatrix || this.matrixWorld); this.bindMatrixInverse.copy(this.bindMatrix).invert(); } } const skinnedPointsVertexShader = ` uniform float size; #include #include void main() { #include #include #include vec4 mvPosition = modelViewMatrix * vec4(transformed, 1.0); gl_Position = projectionMatrix * mvPosition; gl_PointSize = size; } `; const staticPointsVertexShader = ` uniform float size; void main() { vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * mvPosition; gl_PointSize = size; } `; const skinnedPointsFragmentShader = ` uniform vec3 color; void main() { // Draw round circular dots vec2 coord = gl_PointCoord - vec2(0.5); if (length(coord) > 0.5) discard; gl_FragColor = vec4(color, 1.0); } `; function applyWireframeState(model, active) { if (!model) return; const rect = viewerDisplay ? viewerDisplay.getBoundingClientRect() : null; const w = rect ? rect.width : window.innerWidth; const h = rect ? rect.height : window.innerHeight; model.traverse((child) => { if (child.name && (child.name.includes("_wireframe_helper") || child.name.includes("_points_helper"))) return; if (child.isMesh) { let wfGroup = child.getObjectByName(child.name + "_wireframe_helper"); if (active) { if (!wfGroup) { wfGroup = new THREE.Group(); wfGroup.name = child.name + "_wireframe_helper"; child.add(wfGroup); if (child.isSkinnedMesh) { // Render natively as a wireframe SkinnedMesh to align perfectly with bone deformations const wfMat = new THREE.MeshBasicMaterial({ color: 0xffeb3b, wireframe: true, transparent: true, opacity: 0.85, depthWrite: false, skinning: true }); const lineMesh = new THREE.SkinnedMesh(child.geometry, wfMat); lineMesh.name = child.name + "_wireframe_helper_skinned"; wfGroup.add(lineMesh); lineMesh.bind(child.skeleton, child.bindMatrix); } else { // Static mesh helper - thick line segment passes const edgesGeo = createQuadWireframeGeometry(child.geometry); const offsets = [ new THREE.Vector2(0, 0), new THREE.Vector2(-0.6, 0.6), new THREE.Vector2(0.6, -0.6), new THREE.Vector2(0.6, 0.6), new THREE.Vector2(-0.6, -0.6) ]; offsets.forEach((offsetVal, index) => { const wfMat = new THREE.ShaderMaterial({ vertexShader: staticLineVertexShader, fragmentShader: staticLineFragmentShader, uniforms: { color: { value: new THREE.Color(0xffeb3b) }, offset: { value: offsetVal }, screenSize: { value: new THREE.Vector2(w, h) } }, transparent: true, depthWrite: false }); const lineMesh = new THREE.LineSegments(edgesGeo, wfMat); lineMesh.name = child.name + "_wireframe_helper_pass_" + index; wfGroup.add(lineMesh); }); } } wfGroup.visible = true; } else { if (wfGroup) { wfGroup.visible = false; } } } }); } function applyNodesState(model, active) { if (!model) return; model.traverse((child) => { if (child.name && (child.name.includes("_wireframe_helper") || child.name.includes("_points_helper"))) return; if (child.isMesh) { let ptsMesh = child.getObjectByName(child.name + "_points_helper"); if (active) { if (!ptsMesh) { if (child.isSkinnedMesh) { const ptsMat = new THREE.ShaderMaterial({ vertexShader: skinnedPointsVertexShader, fragmentShader: skinnedPointsFragmentShader, uniforms: THREE.UniformsUtils.merge([ THREE.ShaderLib.standard.uniforms, { size: { value: 9.0 }, // large 9px dots color: { value: new THREE.Color(0x00ff88) } } ]), transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -4, skinning: true }); ptsMesh = new SkinnedPoints(child.geometry, ptsMat); child.add(ptsMesh); ptsMesh.bind(child.skeleton, child.bindMatrix); } else { const ptsMat = new THREE.ShaderMaterial({ vertexShader: staticPointsVertexShader, fragmentShader: skinnedPointsFragmentShader, uniforms: { size: { value: 9.0 }, color: { value: new THREE.Color(0x00ff88) } }, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -4 }); ptsMesh = new THREE.Points(child.geometry, ptsMat); child.add(ptsMesh); } ptsMesh.name = child.name + "_points_helper"; } ptsMesh.visible = true; } else { if (ptsMesh) { ptsMesh.visible = false; } } } }); } if (toggleWireframeBtn) { toggleWireframeBtn.addEventListener('click', () => { showWireframe = !showWireframe; if (showWireframe) { toggleWireframeBtn.classList.add('active'); } else { toggleWireframeBtn.classList.remove('active'); } applyWireframeState(threeModel, showWireframe); }); } if (toggleNodesBtn) { if (showNodes) { toggleNodesBtn.classList.add('active'); } else { toggleNodesBtn.classList.remove('active'); } toggleNodesBtn.addEventListener('click', () => { showNodes = !showNodes; if (showNodes) { toggleNodesBtn.classList.add('active'); } else { toggleNodesBtn.classList.remove('active'); } applyNodesState(threeModel, showNodes); }); } function applyTextureVisibility(model, visible) { if (!model) return; model.traverse((child) => { if (child.name && (child.name.includes("_wireframe_helper") || child.name.includes("_points_helper"))) return; if (child.isMesh && child.material) { const mats = Array.isArray(child.material) ? child.material : [child.material]; mats.forEach(mat => { // Ensure mesh material is always visible mat.visible = true; if (!visible) { // Backup original map and color if not already backed up if (mat._originalMap === undefined) { mat._originalMap = mat.map; } if (mat._originalColor === undefined) { mat._originalColor = mat.color ? mat.color.clone() : new THREE.Color(0xffffff); } // Swap to grey clay style mat.map = null; if (mat.color) mat.color.setHex(0x1a1a1c); // Dark charcoal grey mat.needsUpdate = true; } else { // Restore original texture and color if (mat._originalMap !== undefined) { mat.map = mat._originalMap; } if (mat._originalColor !== undefined) { if (mat.color) mat.color.copy(mat._originalColor); } mat.needsUpdate = true; } }); } }); } if (toggleTextureBtn) { toggleTextureBtn.addEventListener('click', () => { showTextures = !showTextures; if (showTextures) { toggleTextureBtn.classList.add('active'); } else { toggleTextureBtn.classList.remove('active'); } applyTextureVisibility(threeModel, showTextures); }); } // Rigging action from main viewer const rigMethodSelect = document.getElementById('rigMethodSelect'); const rigDescriptionText = null; if (runTriggerRigBtn) { runTriggerRigBtn.addEventListener('click', async () => { if (!activeModelRelativeUrl) return; const method = 'ai'; statusOverlay.classList.add('active'); const loaderTitle = 'Rigging por IA (Nube)...'; const loaderDesc = 'Conectando con la IA de Hugging Face para calcular los pesos y el esqueleto...'; try { if (method === 'ai') { updateLoader(15, 'Verificando estado del servidor de Rigging...', 'Consultando disponibilidad de UniRig en Hugging Face...'); let spaceSleeping = false; try { const statusRes = await fetch(`${apiBase}/api/space-status?type=rig&token=${encodeURIComponent(hfToken)}`); if (statusRes.ok) { const statusData = await statusRes.json(); if (statusData.stage === 'SLEEPING') { spaceSleeping = true; } } } catch (statusErr) { console.warn('Failed to check rig space status, continuing anyway:', statusErr); } if (spaceSleeping) { updateLoader(20, 'Encendiendo servidor de Rigging...', 'El servidor UniRig en Hugging Face está hibernando por inactividad. Lo estamos encendiendo, esto puede tardar de 2 a 3 minutos en iniciar...'); } else { updateLoader(40, loaderTitle, loaderDesc); } } else { updateLoader(40, loaderTitle, loaderDesc); } const response = await fetch(`${apiBase}/api/rig-3d`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ modelUrl: activeModelRelativeUrl, rigMethod: method, token: hfToken }) }); if (!response.ok) { const errData = await response.json().catch(() => ({ error: 'Error al riggear.' })); throw new Error(errData.error || 'Error interno del servidor.'); } const data = await response.json(); if (data.riggedFbxUrl) { // Load the newly rigged model directly in the viewer load3DModel(data.riggedFbxUrl, data.riggedFbxUrl, null, currentModelCategory); // Refresh gallery to show the rigged file in the library list refreshGallery(); showCustomAlert('¡Esqueleto y pesos de influencia generados con éxito! Tu modelo ahora está listo para ser animado. Usa el selector de animación que apareció en la barra inferior.', 'Rigging Completado', false, 'success'); } else { throw new Error('No se devolvió la URL del modelo riggeado.'); } } catch (error) { console.error(error); showCustomAlert(error.message, 'Error al riggear el modelo', false); } finally { statusOverlay.classList.remove('active'); } }); } // Gallery / Library Management const galleryList = document.getElementById('galleryList'); const galleryTabBtn = document.getElementById('galleryTabBtn'); let cachedGalleryJSON = ""; async function refreshGallery() { try { let newItems = []; if (window.supabase) { try { const supabaseUrl = 'https://hskkswijqervbpibwvfh.supabase.co'; const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imhza2tzd2lqcWVydmJwaWJ3dmZoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODU4MzMyNDgsImV4cCI6MjEwMTQwOTI0OH0.MTHzcMyVwtq58QNHz5S-J3L6U2G-lySbyl3enA8buWU'; const supabaseClient = window.supabase.createClient(supabaseUrl, supabaseAnonKey); const sessionToken = localStorage.getItem('sb_token'); if (sessionToken) { const { data: { user } } = await supabaseClient.auth.getUser(sessionToken); if (user) { const { data: sbModels } = await supabaseClient .from('models') .select('*') .eq('user_id', user.id) .order('created_at', { ascending: false }); if (sbModels && sbModels.length > 0) { newItems = sbModels.map(m => ({ name: m.name, type: 'model', url: m.glb_url, glbUrl: m.glb_url, fbxUrl: m.fbx_url || m.fbxUrl, mtime: new Date(m.created_at).getTime() / 1000 })); } } } } catch (sbErr) { console.log('[Supabase Gallery Fetch Notice]', sbErr); } } if (newItems.length === 0) { const savedUser = localStorage.getItem('session_user') || ''; const response = await fetch(`${apiBase}/api/gallery`, { headers: { 'X-Session-User': savedUser } }); if (response.ok) { const data = await response.json(); newItems = data.items || []; } } const newJSON = JSON.stringify(newItems); // Render control: avoid flickering if items didn't change if (newJSON !== cachedGalleryJSON) { cachedGalleryJSON = newJSON; cachedGalleryItems = newItems; renderGallery(cachedGalleryItems); } } catch (error) { console.error('Error refreshing gallery:', error); } } let currentFilter = 'all'; function renderGallery(items) { if (!galleryList) return; if (items.length === 0) { galleryList.innerHTML = ` `; lucide.createIcons(); return; } // Filter items based on active filter state const filteredItems = items.filter(item => { if (currentFilter === 'all') return true; return item.type === currentFilter; }); if (filteredItems.length === 0) { galleryList.innerHTML = ` `; lucide.createIcons(); return; } galleryList.innerHTML = ''; filteredItems.forEach((item, index) => { const itemEl = document.createElement('div'); itemEl.className = 'gallery-item'; itemEl.style.animationDelay = `${index * 0.03}s`; const isImage = item.type === 'image'; const formattedTime = new Date(item.mtime * 1000).toLocaleString('es-ES', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); const thumbnailHtml = isImage ? `${item.name}` : ``; const actionButtonHtml = isImage ? `` : ``; itemEl.innerHTML = ` `; // Event listeners for actions if (isImage) { itemEl.querySelector('.load-img-btn').addEventListener('click', () => { const imgViewer = document.getElementById('image-viewer-container'); const fullImg = document.getElementById('full2DImageViewPC'); if (fullImg) fullImg.src = `${apiBase}${item.url}`; if (imgViewer) imgViewer.style.display = 'flex'; window.current2DViewerItem = item; }); } else { itemEl.querySelector('.load-model-btn').addEventListener('click', () => { load3DModel(`${apiBase}${item.url}`, `${apiBase}${item.url}`, item.fbxUrl, item.detectedCategory); }); const downloadRawFbxBtn = itemEl.querySelector('.download-raw-fbx-btn'); if (downloadRawFbxBtn) { downloadRawFbxBtn.addEventListener('click', async () => { statusOverlay.classList.add('active'); updateLoader(50, 'Convirtiendo a FBX...', 'Blender está optimizando la malla y exportando el archivo FBX...'); try { const response = await fetch(`${apiBase}/api/optimize-3d`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ modelUrl: item.url, quad_target_faces: 60000, remeshMethod: 'cleanup' }) }); if (!response.ok) { const errData = await response.json().catch(() => ({ error: 'Error al convertir.' })); throw new Error(errData.error || 'Error interno del servidor.'); } const data = await response.json(); if (data.fbxUrl) { // Download FBX const absoluteFbxUrl = data.fbxUrl.startsWith('http') ? data.fbxUrl : `${apiBase}${data.fbxUrl}`; const link = document.createElement('a'); link.href = absoluteFbxUrl; link.download = absoluteFbxUrl.split('/').pop(); document.body.appendChild(link); link.click(); document.body.removeChild(link); // Load clean model in viewer sessionStorage.setItem('pendingModelToLoad', JSON.stringify({ gltfUrl: data.gltfUrl, downloadUrl: data.glbUrl, fbxUrl: data.fbxUrl, detectedCategory: currentModelCategory })); window.location.reload(); } else { throw new Error('No se generó el archivo FBX.'); } } catch (error) { console.error(error); alert(`Error al descargar FBX: ${error.message}`); } finally { statusOverlay.classList.remove('active'); } }); } } itemEl.querySelector('.delete-btn').addEventListener('click', async () => { if (confirm(`¿Estás seguro de que quieres eliminar ${item.name} de forma permanente del disco?`)) { try { const savedUser = localStorage.getItem('23dfactory_user') || 'guest'; const response = await fetch(`${apiBase}/api/delete-gallery`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Session-User': savedUser }, body: JSON.stringify({ name: item.name, type: item.type }) }); if (!response.ok) { const errData = await response.json().catch(() => ({ error: 'Error al eliminar el archivo.' })); throw new Error(errData.error || 'Error al eliminar el archivo.'); } itemEl.remove(); if (typeof loadGallery === 'function') loadGallery(); } catch (e) { alert('Error al eliminar item: ' + e.message); } } }); galleryList.appendChild(itemEl); }); lucide.createIcons(); } // Bind gallery filter toggles const filterBtns = document.querySelectorAll('.filter-group .toggle-btn'); filterBtns.forEach(btn => { btn.addEventListener('click', () => { filterBtns.forEach(b => b.classList.remove('active')); btn.classList.add('active'); currentFilter = btn.getAttribute('data-filter'); renderGallery(cachedGalleryItems); }); }); // Bind navigation click to refresh gallery if (galleryTabBtn) { galleryTabBtn.addEventListener('click', refreshGallery); } // 2D Image Viewer Overlay Handlers for PC Mode const btnBackFrom2DViewerPC = document.getElementById('btnBackFrom2DViewerPC'); const btnCreate3DFrom2DViewerPC = document.getElementById('btnCreate3DFrom2DViewerPC'); if (btnBackFrom2DViewerPC) { btnBackFrom2DViewerPC.addEventListener('click', () => { const imgViewer = document.getElementById('image-viewer-container'); if (imgViewer) imgViewer.style.display = 'none'; }); } if (btnCreate3DFrom2DViewerPC) { btnCreate3DFrom2DViewerPC.addEventListener('click', async () => { const imgViewer = document.getElementById('image-viewer-container'); if (imgViewer) imgViewer.style.display = 'none'; if (window.current2DViewerItem) { try { const item = window.current2DViewerItem; const fileUrl = `${apiBase}${item.url}`; const res = await fetch(fileUrl); const blob = await res.blob(); const file = new File([blob], item.name, { type: blob.type }); handleImageFile(file); // Activate upload-tab const uploadTabBtn = document.querySelector('[data-tab="upload-tab"]'); if (uploadTabBtn) uploadTabBtn.click(); } catch (e) { console.error("Error setting 3D source image:", e); } } }); } // Bind animation selection changes to toggle between model-viewer and Three.js viewer if (animationSelect) { animationSelect.addEventListener('change', () => { const val = animationSelect.value; if (!threeScene) { initThreeViewer(currentGltfUrl); } if (val === 'manual') { if (manualRigPanel) { manualRigPanel.style.display = 'block'; setupManualBoneSelectOptions(); } } else { if (manualRigPanel) manualRigPanel.style.display = 'none'; } }); } // Manual Bone Rigging Controller Logic const boneFriendlyNames = { hips: "Pelvis / Cadera", spine: "Columna Vertebral", neck: "Cuello", leftShoulder: "Hombro Izquierdo", leftArm: "Brazo Izquierdo", leftForearm: "Antebrazo Izquierdo", leftHand: "Mano Izquierda", rightShoulder: "Hombro Derecho", rightArm: "Brazo Derecho", rightForearm: "Antebrazo Derecho", rightHand: "Mano Derecha", leftUpLeg: "Muslo Izquierdo", leftLeg: "Pierna Izquierda", leftFoot: "Pie Izquierdo", rightUpLeg: "Muslo Derecho", rightLeg: "Pierna Derecha", rightFoot: "Pie Derecho" }; const extendedBoneFriendlyNames = { // Quadruped bone names Pelvis: "Pelvis / Cadera (Cuadrúpedo)", Spine: "Columna Vertebral (Cuadrúpedo)", Neck: "Cuello (Cuadrúpedo)", Head: "Cabeza (Cuadrúpedo)", Tail: "Cola", LeftUpLegRear: "Pata Trasera Sup. Izquierda", LeftLegRear: "Pata Trasera Inf. Izquierda", LeftFootRear: "Pie Trasero Izquierdo", RightUpLegRear: "Pata Trasera Sup. Derecha", RightLegRear: "Pata Trasera Inf. Derecha", RightFootRear: "Pie Trasero Derecho", LeftUpLegFront: "Pata Delantera Sup. Izquierda", LeftLegFront: "Pata Delantera Inf. Izquierda", LeftFootFront: "Pie Delantero Izquierdo", RightUpLegFront: "Pata Delantera Sup. Derecha", RightLegFront: "Pata Delantera Inf. Derecha", RightFootFront: "Pie Delantero Derecho", }; function getFriendlyBoneName(name) { if (boneFriendlyNames[name]) return boneFriendlyNames[name]; if (extendedBoneFriendlyNames[name]) return extendedBoneFriendlyNames[name]; const lower = name.toLowerCase(); // Friendly translation heuristics for custom rig systems (like spider, etc.) if (lower.includes("hips") || lower.includes("pelvis")) return `Pelvis / Cadera (${name})`; if (lower.includes("spine")) return `Columna Vertebral (${name})`; if (lower.includes("neck")) return `Cuello (${name})`; if (lower.includes("head")) return `Cabeza (${name})`; if (lower.includes("tail")) return `Cola (${name})`; if (lower.startsWith("left") || lower.includes("_l") || lower.includes(".l") || lower.includes("l_") || lower.includes("left")) { if (lower.includes("shoulder")) return `Hombro Izquierdo (${name})`; if (lower.includes("arm") || lower.includes("forearm")) return `Brazo/Antebrazo Izquierdo (${name})`; if (lower.includes("hand") || lower.includes("wrist")) return `Mano/Muñeca Izquierda (${name})`; if (lower.includes("leg") || lower.includes("thigh")) return `Pata/Pierna Izquierda (${name})`; if (lower.includes("foot") || lower.includes("toe")) return `Pie Izquierdo (${name})`; return `${name} (Izquierda)`; } if (lower.startsWith("right") || lower.includes("_r") || lower.includes(".r") || lower.includes("r_") || lower.includes("right")) { if (lower.includes("shoulder")) return `Hombro Derecho (${name})`; if (lower.includes("arm") || lower.includes("forearm")) return `Brazo/Antebrazo Derecho (${name})`; if (lower.includes("hand") || lower.includes("wrist")) return `Mano/Muñeca Derecha (${name})`; if (lower.includes("leg") || lower.includes("thigh")) return `Pata/Pierna Derecha (${name})`; if (lower.includes("foot") || lower.includes("toe")) return `Pie Derecho (${name})`; return `${name} (Derecha)`; } return name; } function setupManualBoneSelectOptions() { if (!manualBoneSelect) return; manualBoneSelect.innerHTML = ''; const allBones = []; if (threeModel) { threeModel.traverse(c => { if (c.isBone) allBones.push(c); }); } if (allBones.length > 0) { // Sort bones alphabetically by name allBones.sort((a, b) => a.name.localeCompare(b.name)).forEach(bone => { const opt = document.createElement('option'); opt.value = bone.name; opt.textContent = getFriendlyBoneName(bone.name); manualBoneSelect.appendChild(opt); }); } else { // Fallback to static roles if no model loaded Object.keys(boneFriendlyNames).forEach(key => { const opt = document.createElement('option'); opt.value = key; opt.textContent = boneFriendlyNames[key]; manualBoneSelect.appendChild(opt); }); } // Populate the 3D bone mapping selector with all bones in GLB if (manualBoneMappingSelect && threeModel) { manualBoneMappingSelect.innerHTML = ''; const noneOpt = document.createElement('option'); noneOpt.value = ''; noneOpt.textContent = '(Sin Asignar)'; manualBoneMappingSelect.appendChild(noneOpt); const allBones = []; threeModel.traverse(c => { if (c.isBone) allBones.push(c.name); }); allBones.sort().forEach(bName => { const opt = document.createElement('option'); opt.value = bName; opt.textContent = bName; manualBoneMappingSelect.appendChild(opt); }); } // Trigger initial load of sliders loadSelectedBoneRotations(); } function loadSelectedBoneRotations() { const bone = selectedBone; updateWeightPaintSelectedBoneDisplay(); if (!bone) { // Reset sliders if bone not assigned boneRotX.value = 0; boneRotY.value = 0; boneRotZ.value = 0; boneRotXVal.textContent = '0°'; boneRotYVal.textContent = '0°'; boneRotZVal.textContent = '0°'; if (manualBoneNameInput) manualBoneNameInput.value = ''; if (applyBoneRenameBtn) applyBoneRenameBtn.style.display = 'none'; return; } // Convert radians to degrees const degX = Math.round(THREE.MathUtils.radToDeg(bone.rotation.x)); const degY = Math.round(THREE.MathUtils.radToDeg(bone.rotation.y)); const degZ = Math.round(THREE.MathUtils.radToDeg(bone.rotation.z)); boneRotX.value = degX; boneRotY.value = degY; boneRotZ.value = degZ; boneRotXVal.textContent = degX + '°'; boneRotYVal.textContent = degY + '°'; boneRotZVal.textContent = degZ + '°'; } // Renaming bone logic if (manualBoneNameInput && applyBoneRenameBtn) { manualBoneNameInput.addEventListener('input', () => { if (selectedBone) { const currentVal = manualBoneNameInput.value.trim(); if (currentVal && currentVal !== selectedBone.name) { applyBoneRenameBtn.style.display = 'inline-block'; } else { applyBoneRenameBtn.style.display = 'none'; } } }); applyBoneRenameBtn.addEventListener('click', () => { if (selectedBone) { const oldName = selectedBone.name; const newName = manualBoneNameInput.value.trim(); if (!newName) { showCustomAlert("El nombre del hueso no puede estar vacío.", "Error al renombrar"); return; } // Rename bone in Three.js selectedBone.name = newName; // Update threeBones mapping if it exists if (threeBones[oldName]) { threeBones[newName] = selectedBone; delete threeBones[oldName]; } applyBoneRenameBtn.style.display = 'none'; showCustomToast(`Hueso renombrado de ${oldName} a ${newName}`); } }); } function updateBoneRotationFromSliders() { const bone = selectedBone; if (!bone) return; const degX = parseFloat(boneRotX.value); const degY = parseFloat(boneRotY.value); const degZ = parseFloat(boneRotZ.value); bone.rotation.x = THREE.MathUtils.degToRad(degX); bone.rotation.y = THREE.MathUtils.degToRad(degY); bone.rotation.z = THREE.MathUtils.degToRad(degZ); boneRotXVal.textContent = degX + '°'; boneRotYVal.textContent = degY + '°'; boneRotZVal.textContent = degZ + '°'; } if (boneRotX) boneRotX.addEventListener('input', updateBoneRotationFromSliders); if (boneRotY) boneRotY.addEventListener('input', updateBoneRotationFromSliders); if (boneRotZ) boneRotZ.addEventListener('input', updateBoneRotationFromSliders); if (resetBoneRotBtn) { resetBoneRotBtn.addEventListener('click', () => { const bone = selectedBone; if (!bone) return; bone.rotation.set(0, 0, 0); boneRotX.value = 0; boneRotY.value = 0; boneRotZ.value = 0; boneRotXVal.textContent = '0°'; boneRotYVal.textContent = '0°'; boneRotZVal.textContent = '0°'; }); } // Handle Skeleton Ray-X visualizer helper logic function setSkeletonHelperActive(active) { showSkeletonHelper = active; if (showSkeletonHelper) { if (threeModel && threeScene) { if (threeSkeletonHelper) { threeScene.remove(threeSkeletonHelper); } threeSkeletonHelper = new THREE.SkeletonHelper(threeModel); threeScene.add(threeSkeletonHelper); // Clear any existing spheres first jointSpheres.forEach(item => { try { threeScene.remove(item.mesh); } catch (e) { } }); jointSpheres = []; // Create glowing joint spheres at each bone threeModel.traverse(c => { if (c.isBone) { // Create a larger, glowing cyan sphere for easy click and visibility const sphereGeo = new THREE.SphereGeometry(0.015, 16, 16); const sphereMat = new THREE.MeshBasicMaterial({ color: 0x00ffcc, depthTest: false, transparent: true, opacity: 0.85 }); const sphere = new THREE.Mesh(sphereGeo, sphereMat); sphere.renderOrder = 999; // draw on top of everything threeScene.add(sphere); jointSpheres.push({ bone: c, mesh: sphere }); } }); if (toggleBonesHelperBtn) { toggleBonesHelperBtn.classList.add('active'); const span = toggleBonesHelperBtn.querySelector('span'); if (span) span.textContent = "Ocultar Esqueleto (Rayos X)"; } if (toggleBonesBtn) { toggleBonesBtn.classList.add('active'); } showCustomToast("Haz clic en las esferas del cuerpo para seleccionar el hueso"); } } else { if (threeSkeletonHelper && threeScene) { threeScene.remove(threeSkeletonHelper); } threeSkeletonHelper = null; jointSpheres.forEach(item => { if (threeScene) { try { threeScene.remove(item.mesh); } catch (e) { } } }); jointSpheres = []; if (toggleBonesHelperBtn) { toggleBonesHelperBtn.classList.remove('active'); const span = toggleBonesHelperBtn.querySelector('span'); if (span) span.textContent = "Ver Esqueleto (Rayos X)"; } if (toggleBonesBtn) { toggleBonesBtn.classList.remove('active'); } } } if (toggleBonesHelperBtn) { toggleBonesHelperBtn.addEventListener('click', () => { if (toggleBonesHelperBtn.classList.contains('disabled-btn')) { showCustomToast("Este modelo no posee huesos. Genéralos usando Rigging Automático."); return; } setSkeletonHelperActive(!showSkeletonHelper); }); } if (toggleBonesBtn) { toggleBonesBtn.addEventListener('click', () => { if (toggleBonesBtn.classList.contains('disabled-btn')) { showCustomToast("Este modelo no posee huesos. Genéralos usando Rigging Automático."); return; } setSkeletonHelperActive(!showSkeletonHelper); }); } // Authentication & User Session Management const authOverlay = document.getElementById('authOverlay'); const loginForm = document.getElementById('loginForm'); const registerForm = document.getElementById('registerForm'); const tabLogin = document.getElementById('tabLogin'); const tabRegister = document.getElementById('tabRegister'); const logoutBtn = document.getElementById('logoutBtn'); const userHeaderActions = document.getElementById('userHeaderActions'); const headerUsernameText = document.getElementById('headerUsernameText'); // 3D Floating Shapes Background for Auth Screen function initAuthBackground3D() { const canvas = document.getElementById('authBgCanvas'); if (!canvas) return; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100); camera.position.z = 25; const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // Lights const ambientLight = new THREE.AmbientLight(0xffffff, 0.75); scene.add(ambientLight); const pointLight1 = new THREE.PointLight(0x9c27b0, 4.5, 60); // Purple light pointLight1.position.set(15, 15, 12); scene.add(pointLight1); const pointLight2 = new THREE.PointLight(0x00bcd4, 4.5, 60); // Cyan light pointLight2.position.set(-15, -15, 12); scene.add(pointLight2); const pointLight3 = new THREE.PointLight(0xffffff, 2.5, 45); // Center bright white light pointLight3.position.set(0, 0, 15); scene.add(pointLight3); // Floating geometries const geometries = [ new THREE.TorusKnotGeometry(1.5, 0.45, 100, 16), new THREE.DodecahedronGeometry(1.8), new THREE.OctahedronGeometry(1.5), new THREE.IcosahedronGeometry(1.6, 1), new THREE.TorusGeometry(1.6, 0.35, 16, 100), new THREE.ConeGeometry(1.2, 2.2, 4), new THREE.TetrahedronGeometry(1.7), new THREE.CylinderGeometry(0.8, 0.8, 2.2, 6), new THREE.BoxGeometry(1.5, 1.5, 1.5), new THREE.RingGeometry(0.5, 1.6, 32), new THREE.SphereGeometry(1.4, 16, 16), new THREE.OctahedronGeometry(1.6, 1) ]; const meshes = []; const colors = [0x9c27b0, 0x00bcd4, 0xe040fb, 0x00e5ff, 0xff4081, 0xffeb3b, 0x4caf50]; geometries.forEach((geom, idx) => { const material = new THREE.MeshPhysicalMaterial({ color: colors[idx % colors.length], roughness: 0.15, metalness: 0.85, clearcoat: 1.0, clearcoatRoughness: 0.1, wireframe: idx % 2 === 0 }); const mesh = new THREE.Mesh(geom, material); // Random positions mesh.position.set( (Math.random() - 0.5) * 35, (Math.random() - 0.5) * 20, (Math.random() - 0.5) * 10 ); // Rotation speeds mesh.userData = { rotX: (Math.random() - 0.5) * 0.008, rotY: (Math.random() - 0.5) * 0.008, floatSpeed: 0.001 + Math.random() * 0.002, floatOffset: Math.random() * Math.PI * 2, initialY: mesh.position.y }; scene.add(mesh); meshes.push(mesh); }); // Particle starfield const particlesCount = 250; const particlesGeometry = new THREE.BufferGeometry(); const positions = new Float32Array(particlesCount * 3); for (let i = 0; i < particlesCount * 3; i += 3) { positions[i] = (Math.random() - 0.5) * 60; positions[i + 1] = (Math.random() - 0.5) * 40; positions[i + 2] = (Math.random() - 0.5) * 20; } particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); const particlesMaterial = new THREE.PointsMaterial({ size: 0.12, color: 0xffffff, transparent: true, opacity: 0.5 }); const starfield = new THREE.Points(particlesGeometry, particlesMaterial); scene.add(starfield); // Mouse Parallax movement let mouseX = 0; let mouseY = 0; let targetX = 0; let targetY = 0; window.addEventListener('mousemove', (e) => { mouseX = (e.clientX - window.innerWidth / 2) / 150; mouseY = (e.clientY - window.innerHeight / 2) / 150; }); // Resize handler window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); // Animation Loop let clock = new THREE.Clock(); function animate() { const overlayActive = document.getElementById('authOverlay')?.classList.contains('active'); if (!overlayActive) { requestAnimationFrame(animate); return; } const elapsedTime = clock.getElapsedTime(); // Parallax camera lerp targetX += (mouseX - targetX) * 0.05; targetY += (mouseY - targetY) * 0.05; camera.position.x = targetX; camera.position.y = -targetY; camera.lookAt(scene.position); // Rotate and float meshes meshes.forEach(mesh => { mesh.rotation.x += mesh.userData.rotX; mesh.rotation.y += mesh.userData.rotY; mesh.position.y = mesh.userData.initialY + Math.sin(elapsedTime * 1.5 + mesh.userData.floatOffset) * 0.4; }); // Rotate background starfield slightly starfield.rotation.y = elapsedTime * 0.015; renderer.render(scene, camera); requestAnimationFrame(animate); } animate(); } // Initialize Auth 3D background immediately initAuthBackground3D(); // User Credits State & Display Snycronization let userCredits = 0; function updateUserCreditsDisplay(credits) { userCredits = credits; const creditsText = document.getElementById('headerCreditsText'); if (creditsText) creditsText.textContent = credits; } function updateUserHeaderAvatar(username, nick, avatar) { const dropdownUsernameText = document.getElementById('dropdownUsernameText'); if (dropdownUsernameText) dropdownUsernameText.textContent = nick || username; if (headerUsernameText) headerUsernameText.textContent = nick || username; const avatarImg = document.getElementById('userHeaderAvatarImg'); const avatarInitial = document.getElementById('userHeaderAvatarInitial'); if (avatar && (avatar.startsWith('data:image') || avatar.startsWith('http') || avatar.startsWith('/'))) { if (avatarImg) { avatarImg.src = avatar; avatarImg.style.display = 'block'; } if (avatarInitial) avatarInitial.style.display = 'none'; } else { if (avatarImg) avatarImg.style.display = 'none'; if (avatarInitial) { avatarInitial.textContent = (nick || username || 'P').charAt(0).toUpperCase(); avatarInitial.style.display = 'block'; } } } // Check Session on Start async function checkAuthSession() { const mainAppContainer = document.querySelector('.app-container'); try { let savedUser = localStorage.getItem('session_user') || ''; let supabaseData = null; if (window.supabase) { try { const supabaseUrl = 'https://hskkswijqervbpibwvfh.supabase.co'; const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imhza2tzd2lqcWVydmJwaWJ3dmZoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODU4MzMyNDgsImV4cCI6MjEwMTQwOTI0OH0.MTHzcMyVwtq58QNHz5S-J3L6U2G-lySbyl3enA8buWU'; const supabaseClient = window.supabase.createClient(supabaseUrl, supabaseAnonKey); const { data: { session } } = await supabaseClient.auth.getSession(); const user = session?.user; if (user) { const { data: profile } = await supabaseClient .from('profiles') .select('username, nick, full_name, avatar, avatar_url, credits') .eq('id', user.id) .maybeSingle(); if (profile) { if (!profile.avatar && profile.avatar_url) profile.avatar = profile.avatar_url; supabaseData = profile; if (profile.username) savedUser = profile.username; } } } catch (sbCheckErr) { console.log('[Supabase Session Check Notice]', sbCheckErr); } } const response = await fetch(`${apiBase}/api/me`, { headers: { 'X-Session-User': savedUser } }); let data = {}; if (response.ok) { data = await response.json(); } const finalUsername = (data.username && data.username !== 'guest') ? data.username : (supabaseData?.username || savedUser); const finalNick = supabaseData?.nick || (data.nick && data.nick !== data.username ? data.nick : null) || finalUsername; const finalAvatar = supabaseData?.avatar || supabaseData?.avatar_url || data.avatar || ''; const finalCredits = (supabaseData?.credits !== undefined && supabaseData?.credits !== null) ? supabaseData.credits : (data.credits || 20); if (finalUsername && finalUsername !== 'guest') { localStorage.setItem('session_user', finalUsername); document.cookie = `session_user=${finalUsername}; path=/; max-age=2592000; SameSite=Lax`; if (authOverlay) authOverlay.classList.remove('active'); if (mainAppContainer) mainAppContainer.style.display = ''; if (userHeaderActions) userHeaderActions.style.display = 'flex'; updateUserHeaderAvatar(finalUsername, finalNick, finalAvatar); updateUserCreditsDisplay(finalCredits); refreshGallery(); checkMobileRedirect(); return; } } catch (e) { console.error("Error verifying session:", e); } // Show login overlay if not logged in if (authOverlay) authOverlay.classList.add('active'); if (mainAppContainer) mainAppContainer.style.display = 'none'; if (userHeaderActions) userHeaderActions.style.display = 'none'; } function fetchUserInfo() { return checkAuthSession(); } // Bind Authentication Event Listeners const loginErrorMsg = document.getElementById('loginErrorMsg'); const registerErrorMsg = document.getElementById('registerErrorMsg'); function showRegisterError(msg) { if (registerErrorMsg) { registerErrorMsg.textContent = msg; registerErrorMsg.style.display = 'block'; } else { showCustomAlert(msg, 'Error de Registro'); } } function animateAuthBoxSwitch(showForm, hideForm) { const authBox = document.querySelector('.auth-box'); if (!authBox) { hideForm.classList.remove('active'); showForm.classList.add('active'); return; } const startHeight = authBox.offsetHeight; authBox.style.height = `${startHeight}px`; hideForm.classList.remove('active'); showForm.classList.add('active'); // Calculate target height authBox.style.height = 'auto'; const targetHeight = authBox.offsetHeight; authBox.style.height = `${startHeight}px`; // Force browser reflow to register height transition start void authBox.offsetHeight; authBox.style.height = `${targetHeight}px`; setTimeout(() => { authBox.style.height = 'auto'; }, 360); } if (tabLogin && tabRegister && loginForm && registerForm) { tabLogin.addEventListener('click', () => { tabLogin.classList.add('active'); tabRegister.classList.remove('active'); animateAuthBoxSwitch(loginForm, registerForm); if (loginErrorMsg) loginErrorMsg.style.display = 'none'; if (registerErrorMsg) registerErrorMsg.style.display = 'none'; }); tabRegister.addEventListener('click', () => { tabRegister.classList.add('active'); tabLogin.classList.remove('active'); animateAuthBoxSwitch(registerForm, loginForm); if (loginErrorMsg) loginErrorMsg.style.display = 'none'; if (registerErrorMsg) registerErrorMsg.style.display = 'none'; }); } if (loginForm) { loginForm.addEventListener('submit', async (e) => { e.preventDefault(); if (loginErrorMsg) loginErrorMsg.style.display = 'none'; const username = document.getElementById('loginUser').value.trim(); const password = document.getElementById('loginPass').value; try { const supabaseUrl = 'https://hskkswijqervbpibwvfh.supabase.co'; const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imhza2tzd2lqcWVydmJwaWJ3dmZoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODU4MzMyNDgsImV4cCI6MjEwMTQwOTI0OH0.MTHzcMyVwtq58QNHz5S-J3L6U2G-lySbyl3enA8buWU'; let supabaseClient = null; if (window.supabase) { supabaseClient = window.supabase.createClient(supabaseUrl, supabaseAnonKey); } let userData = { username: username, credits: 20 }; if (supabaseClient) { const cleanUser = username.toLowerCase().replace(/[^a-z0-9]/g, ''); const email = username.includes('@') ? username : `${cleanUser}@gmail.com`; const { data, error } = await supabaseClient.auth.signInWithPassword({ email: email, password: password }); if (error) throw error; // Guardar token de sesión en localStorage if (data.session) { localStorage.setItem('sb_token', data.session.access_token); } // Obtener perfil completo const { data: profile } = await supabaseClient .from('profiles') .select('username, nick, full_name, avatar, avatar_url, credits') .eq('id', data.user.id) .single(); if (profile) { userData.username = profile.username || username; userData.nick = profile.nick || profile.username || username; userData.full_name = profile.full_name || ''; userData.avatar = profile.avatar || profile.avatar_url || ''; userData.credits = profile.credits !== undefined ? profile.credits : 20; } } else { const response = await fetch(`${apiBase}/api/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.error || 'Credenciales inválidas.'); } userData = await response.json(); } if (userData && userData.username) { localStorage.setItem('session_user', userData.username); document.cookie = `session_user=${userData.username}; path=/; max-age=2592000; SameSite=Lax`; } showCustomToast(`¡Bienvenido de nuevo, ${userData.username}!`); // Hide overlay, update header, load gallery if (authOverlay) authOverlay.classList.remove('active'); const mainAppContainer = document.querySelector('.app-container'); if (mainAppContainer) mainAppContainer.style.display = ''; if (userHeaderActions) userHeaderActions.style.display = 'flex'; updateUserHeaderAvatar(userData.username, userData.nick, userData.avatar); updateUserCreditsDisplay(userData.credits || 0); // Clear inputs document.getElementById('loginUser').value = ''; document.getElementById('loginPass').value = ''; refreshGallery(); checkMobileRedirect(); } catch (err) { if (loginErrorMsg) { loginErrorMsg.textContent = err.message; loginErrorMsg.style.display = 'block'; } else { showCustomAlert(err.message, 'Error de Inicio de Sesión'); } } }); } if (registerForm) { registerForm.addEventListener('submit', async (e) => { e.preventDefault(); if (registerErrorMsg) registerErrorMsg.style.display = 'none'; const username = document.getElementById('registerUser').value.trim(); const password = document.getElementById('registerPass').value; const confirmPass = document.getElementById('registerPassConfirm').value; if (password !== confirmPass) { showRegisterError('Las contraseñas no coinciden.'); return; } if (password.length < 4) { showRegisterError('La contraseña debe tener al menos 4 caracteres.'); return; } try { // Inicializar cliente Supabase si está disponible const supabaseUrl = 'https://hskkswijqervbpibwvfh.supabase.co'; const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imhza2tzd2lqcWVydmJwaWJ3dmZoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODU4MzMyNDgsImV4cCI6MjEwMTQwOTI0OH0.MTHzcMyVwtq58QNHz5S-J3L6U2G-lySbyl3enA8buWU'; let supabaseClient = null; if (window.supabase) { supabaseClient = window.supabase.createClient(supabaseUrl, supabaseAnonKey); } if (supabaseClient) { const cleanUser = username.toLowerCase().replace(/[^a-z0-9]/g, ''); const email = username.includes('@') ? username : `${cleanUser}@gmail.com`; const { data, error } = await supabaseClient.auth.signUp({ email: email, password: password, options: { data: { username: username } } }); if (error) throw error; } else { const response = await fetch(`${apiBase}/api/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.error || 'No se pudo registrar el usuario.'); } } showCustomAlert('¡Registro exitoso! Ahora puedes iniciar sesión con tu cuenta.', 'Registro Completado', false, 'success'); // Switch to login tab if (tabLogin) tabLogin.click(); // Clear inputs document.getElementById('registerUser').value = ''; document.getElementById('registerPass').value = ''; document.getElementById('registerPassConfirm').value = ''; } catch (err) { showRegisterError(err.message); } }); } if (logoutBtn) { logoutBtn.addEventListener('click', async () => { try { localStorage.removeItem('session_user'); document.cookie = `session_user=; path=/; max-age=0`; await fetch(`${apiBase}/api/logout`, { method: 'POST' }); showCustomToast('Has cerrado sesión correctamente.'); // Show overlay, update header, reset model visualizer if (authOverlay) authOverlay.classList.add('active'); const mainAppContainer = document.querySelector('.app-container'); if (mainAppContainer) mainAppContainer.style.display = 'none'; if (userHeaderActions) userHeaderActions.style.display = 'none'; // Clear current canvas model to protect session visual separation if (threeModel && threeScene) { threeScene.remove(threeModel); threeModel = null; } if (threeSkeletonHelper && threeScene) { threeScene.remove(threeSkeletonHelper); threeSkeletonHelper = null; } if (jointSpheres) { jointSpheres.forEach(item => { if (threeScene) { try { threeScene.remove(item.mesh); } catch (e) {} } }); jointSpheres = []; } // Reset gallery items list const galleryList = document.getElementById('galleryList'); if (galleryList) galleryList.innerHTML = ''; } catch (e) { console.error("Logout failed:", e); } }); } // Bind Credits Store Modal const creditsModal = document.getElementById('creditsModal'); const headerCreditsBtn = document.getElementById('headerCreditsBtn'); const closeCreditsModalBtn = document.getElementById('closeCreditsModalBtn'); function openCreditsModal(alertMessage = '') { if (creditsModal) { creditsModal.classList.add('active'); if (alertMessage) { showCustomToast(alertMessage, 'warning'); } } } if (headerCreditsBtn) { headerCreditsBtn.addEventListener('click', () => openCreditsModal()); } if (closeCreditsModalBtn) { closeCreditsModalBtn.addEventListener('click', () => { if (creditsModal) creditsModal.classList.remove('active'); }); } if (creditsModal) { creditsModal.addEventListener('click', (e) => { if (e.target === creditsModal) { creditsModal.classList.remove('active'); } }); } // User Profile Menu Dropdown Toggle const userMenuBtn = document.getElementById('userMenuBtn'); const userDropdownMenu = document.getElementById('userDropdownMenu'); if (userMenuBtn && userDropdownMenu) { userMenuBtn.addEventListener('click', (e) => { e.stopPropagation(); userDropdownMenu.classList.toggle('active'); }); document.addEventListener('click', (e) => { if (!userMenuBtn.contains(e.target) && !userDropdownMenu.contains(e.target)) { userDropdownMenu.classList.remove('active'); } }); } // Bind Profile and Privacy Modals in Header Menu const profileMenuBtn = document.getElementById('profileMenuBtn'); const privacyMenuBtn = document.getElementById('privacyMenuBtn'); const profileModal = document.getElementById('profileModal'); const privacyModal = document.getElementById('privacyModal'); const closeProfileModalBtn = document.getElementById('closeProfileModalBtn'); const cancelProfileBtn = document.getElementById('cancelProfileBtn'); const closePrivacyModalBtn = document.getElementById('closePrivacyModalBtn'); const cancelPrivacyBtn = document.getElementById('cancelPrivacyBtn'); const profileForm = document.getElementById('profileForm'); const privacyForm = document.getElementById('privacyForm'); if (profileMenuBtn) { profileMenuBtn.addEventListener('click', async (e) => { e.stopPropagation(); if (userDropdownMenu) userDropdownMenu.classList.remove('active'); const savedUser = localStorage.getItem('session_user') || ''; let profileData = { username: savedUser, nick: savedUser, full_name: '', avatar: '' }; if (window.supabase) { try { const supabaseUrl = 'https://hskkswijqervbpibwvfh.supabase.co'; const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imhza2tzd2lqcWVydmJwaWJ3dmZoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODU4MzMyNDgsImV4cCI6MjEwMTQwOTI0OH0.MTHzcMyVwtq58QNHz5S-J3L6U2G-lySbyl3enA8buWU'; const supabaseClient = window.supabase.createClient(supabaseUrl, supabaseAnonKey); const { data: { session } } = await supabaseClient.auth.getSession(); const user = session?.user; if (user) { const { data: sbProfile } = await supabaseClient .from('profiles') .select('username, nick, full_name, avatar, avatar_url') .eq('id', user.id) .maybeSingle(); if (sbProfile) { if (sbProfile.nick) profileData.nick = sbProfile.nick; if (sbProfile.full_name) profileData.full_name = sbProfile.full_name; if (sbProfile.avatar || sbProfile.avatar_url) profileData.avatar = sbProfile.avatar || sbProfile.avatar_url; } } } catch (sbErr) { console.log('[Supabase Profile Menu Fetch Notice]', sbErr); } } try { const res = await fetch(`${apiBase}/api/me`, { headers: { 'X-Session-User': savedUser } }); if (res.ok) { const data = await res.json(); if (data.nick && data.nick !== data.username) profileData.nick = data.nick; if (data.full_name) profileData.full_name = data.full_name; if (data.avatar && !profileData.avatar) profileData.avatar = data.avatar; } } catch (err) {} const profileNickInput = document.getElementById('profileNickInput'); const profileFullNameInput = document.getElementById('profileFullNameInput'); const profileAvatarPreview = document.getElementById('profileAvatarPreview'); if (profileNickInput) profileNickInput.value = profileData.nick || savedUser; if (profileFullNameInput) profileFullNameInput.value = profileData.full_name || ''; if (profileAvatarPreview && profileData.avatar) profileAvatarPreview.src = profileData.avatar; if (profileModal) profileModal.classList.add('active'); }); } if (privacyMenuBtn) { privacyMenuBtn.addEventListener('click', async (e) => { e.stopPropagation(); if (userDropdownMenu) userDropdownMenu.classList.remove('active'); const savedUser = localStorage.getItem('session_user') || ''; let emailVal = ''; if (window.supabase) { try { const supabaseUrl = 'https://hskkswijqervbpibwvfh.supabase.co'; const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imhza2tzd2lqcWVydmJwaWJ3dmZoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODU4MzMyNDgsImV4cCI6MjEwMTQwOTI0OH0.MTHzcMyVwtq58QNHz5S-J3L6U2G-lySbyl3enA8buWU'; const supabaseClient = window.supabase.createClient(supabaseUrl, supabaseAnonKey); const { data: { session } } = await supabaseClient.auth.getSession(); if (session?.user?.email) emailVal = session.user.email; } catch (sbErr) {} } try { const res = await fetch(`${apiBase}/api/me`, { headers: { 'X-Session-User': savedUser } }); if (res.ok) { const data = await res.json(); if (data.email) emailVal = data.email; } } catch (err) {} const privacyEmailInput = document.getElementById('privacyEmailInput'); if (privacyEmailInput) privacyEmailInput.value = emailVal; if (privacyModal) privacyModal.classList.add('active'); }); } const profileAvatarInput = document.getElementById('profileAvatarInput'); const profileAvatarPreview = document.getElementById('profileAvatarPreview'); if (profileAvatarInput && profileAvatarPreview) { profileAvatarInput.addEventListener('change', (e) => { const file = e.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = (evt) => { profileAvatarPreview.src = evt.target.result; }; reader.readAsDataURL(file); } }); } [closeProfileModalBtn, cancelProfileBtn].forEach(btn => { if (btn) btn.addEventListener('click', () => profileModal?.classList.remove('active')); }); [closePrivacyModalBtn, cancelPrivacyBtn].forEach(btn => { if (btn) btn.addEventListener('click', () => privacyModal?.classList.remove('active')); }); // Profile Form Submit if (profileForm) { profileForm.addEventListener('submit', async (e) => { e.preventDefault(); const nick = document.getElementById('profileNickInput')?.value.trim(); const full_name = document.getElementById('profileFullNameInput')?.value.trim(); const avatar = document.getElementById('profileAvatarPreview')?.src; const savedUser = localStorage.getItem('session_user') || ''; try { if (window.supabase) { try { const supabaseUrl = 'https://hskkswijqervbpibwvfh.supabase.co'; const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imhza2tzd2lqcWVydmJwaWJ3dmZoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODU4MzMyNDgsImV4cCI6MjEwMTQwOTI0OH0.MTHzcMyVwtq58QNHz5S-J3L6U2G-lySbyl3enA8buWU'; const supabaseClient = window.supabase.createClient(supabaseUrl, supabaseAnonKey); const { data: { session } } = await supabaseClient.auth.getSession(); const user = session?.user; if (user) { await supabaseClient.from('profiles').upsert({ id: user.id, username: savedUser, nick: nick, full_name: full_name, avatar: avatar }); } } catch (sbErr) { console.log('[Supabase Profile Update Notice]', sbErr); } } const res = await fetch(`${apiBase}/api/update-profile`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Session-User': savedUser }, body: JSON.stringify({ nick, full_name, avatar }) }); if (res.ok) { showCustomToast('¡Perfil actualizado con éxito!', 'success'); if (profileModal) profileModal.classList.remove('active'); updateUserHeaderAvatar(savedUser, nick, avatar); fetchUserInfo(); } else { const errData = await res.json().catch(() => ({})); showCustomToast(errData.error || 'Error al actualizar el perfil.', 'error'); } } catch (err) { showCustomToast('Error de conexión al actualizar perfil.', 'error'); } }); } // Privacy Form Submit if (privacyForm) { privacyForm.addEventListener('submit', async (e) => { e.preventDefault(); const email = document.getElementById('privacyEmailInput')?.value.trim(); const current_password = document.getElementById('privacyCurrentPasswordInput')?.value; const new_password = document.getElementById('privacyNewPasswordInput')?.value; const confirm_password = document.getElementById('privacyConfirmPasswordInput')?.value; const savedUser = localStorage.getItem('session_user') || ''; if (new_password && new_password !== confirm_password) { showCustomToast('Las contraseñas no coinciden.', 'warning'); return; } try { const res = await fetch(`${apiBase}/api/update-privacy`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Session-User': savedUser }, body: JSON.stringify({ email, current_password, new_password }) }); const data = await res.json(); if (res.ok && data.success) { showCustomToast('¡Configuración de privacidad guardada!', 'success'); if (privacyModal) privacyModal.classList.remove('active'); } else { showCustomToast(data.error || 'Error al guardar privacidad.', 'error'); } } catch (err) { showCustomToast('Error de conexión al guardar privacidad.', 'error'); } }); } // Mobile QR Viewer Modal handlers if (viewOnMobileBtn) { viewOnMobileBtn.addEventListener('click', () => { if (!activeGltfUrl) { showCustomToast('No hay ningún modelo 3D activo para visualizar en el celular.', 'warning'); return; } // Resolve absolute GLTF URL const absoluteModelUrl = activeGltfUrl.startsWith('http') ? activeGltfUrl : `${window.location.origin}${activeGltfUrl}`; const viewerUrl = `${window.location.origin}/viewer.html?v=123&model=${encodeURIComponent(absoluteModelUrl)}`; // Generate QR code image url using public API const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(viewerUrl)}`; mobileQrImage.src = qrApiUrl; mobileQrLink.href = viewerUrl; mobileQrLink.textContent = viewerUrl; mobileQrModal.classList.add('active'); }); } if (closeMobileQrModalBtn) { closeMobileQrModalBtn.addEventListener('click', () => { mobileQrModal.classList.remove('active'); }); } if (mobileQrModal) { mobileQrModal.addEventListener('click', (e) => { if (e.target === mobileQrModal) { mobileQrModal.classList.remove('active'); } }); } document.querySelectorAll('.buy-pack-btn').forEach(btn => { btn.addEventListener('click', async () => { const amount = btn.getAttribute('data-amount') || '25'; const originalText = btn.textContent; btn.disabled = true; btn.textContent = 'Redirigiendo a Stripe...'; try { const response = await fetch(`${apiBase}/api/create-checkout-session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pack_type: amount }) }); if (response.ok) { const resData = await response.json(); if (resData.url) { window.location.href = resData.url; } else { throw new Error('La respuesta de Stripe no contiene la URL de Checkout.'); } } else { const errData = await response.json().catch(() => ({})); throw new Error(errData.error || 'Error al iniciar Checkout de Stripe.'); } } catch (err) { showCustomAlert(err.message, 'Error de Pago'); btn.disabled = false; btn.textContent = originalText; } }); }); // Dynamic 3D Credit Cost Calculator function update3dCreditCostDisplay() { if (!generate3dBtn) return; let cost = 5; // Base cost if (resolutionSelect && resolutionSelect.value === '1536') { cost += 2; // Extra for 1536 High Resolution } if (textureSizeSelect && textureSizeSelect.value === '4096') { cost += 3; // Extra for 4096 4K texture } const span = generate3dBtn.querySelector('span'); if (span) { span.textContent = `Construir Malla 3D (Cuesta ${cost} créditos)`; } } if (resolutionSelect) resolutionSelect.addEventListener('change', update3dCreditCostDisplay); if (textureSizeSelect) textureSizeSelect.addEventListener('change', update3dCreditCostDisplay); // Call initially update3dCreditCostDisplay(); // Initial session check checkAuthSession(); // Check for payment success callback parameter in URL query const urlParams = new URLSearchParams(window.location.search); if (urlParams.get('payment') === 'success') { showCustomToast('¡Pago completado! Tus nuevos créditos se acreditarán en segundos.', 'success'); const cleanUrl = window.location.protocol + "//" + window.location.host + window.location.pathname; window.history.replaceState({ path: cleanUrl }, '', cleanUrl); } // Check if there is a pending model to load after page reload (F5) const pendingModel = sessionStorage.getItem('pendingModelToLoad'); if (pendingModel) { try { const { gltfUrl, downloadUrl, fbxUrl, detectedCategory } = JSON.parse(pendingModel); sessionStorage.removeItem('pendingModelToLoad'); // Load model directly using the stored category load3DModel(gltfUrl, downloadUrl, fbxUrl, detectedCategory); } catch (e) { console.error('Error loading pending model after reload:', e); } } // Manual Mesh Clean / Optimization Panel handlers const manualQuadFacesInput = document.getElementById('manualQuadFacesInput'); const manualQuadFacesVal = document.getElementById('manualQuadFacesVal'); const manualRemeshMethodSelect = document.getElementById('manualRemeshMethodSelect'); const runManualCleanBtn = document.getElementById('runManualCleanBtn'); if (manualQuadFacesInput && manualQuadFacesVal) { manualQuadFacesInput.addEventListener('input', () => { manualQuadFacesVal.textContent = manualQuadFacesInput.value; }); } if (runManualCleanBtn) { runManualCleanBtn.addEventListener('click', async () => { if (!activeModelRelativeUrl) { showCustomAlert('Por favor, selecciona un modelo de la biblioteca primero.', 'Modelo no seleccionado'); return; } statusOverlay.classList.add('active'); const quadTargetFaces = manualQuadFacesInput ? parseInt(manualQuadFacesInput.value) : 60000; const remeshMethod = manualRemeshMethodSelect ? manualRemeshMethodSelect.value : 'tris_to_quads'; if (remeshMethod === 'cleanup') { updateLoader(50, 'Limpieza rápida de malla...', 'Blender está fusionando vértices duplicados y eliminando componentes flotantes aislados...'); } else { updateLoader(50, 'Optimización local...', 'Blender está limpiando la malla, remallando a quads y proyectando texturas...'); } try { const savedUser = localStorage.getItem('23dfactory_user') || 'guest'; const response = await fetch(`${apiBase}/api/optimize-3d`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Session-User': savedUser }, body: JSON.stringify({ modelUrl: activeModelRelativeUrl ? activeModelRelativeUrl.split('?')[0] : '', quad_target_faces: quadTargetFaces, remeshMethod: remeshMethod }) }); if (!response.ok) { const errData = await response.json().catch(() => ({ error: 'Error al optimizar.' })); throw new Error(errData.error || 'Error interno del servidor.'); } const data = await response.json(); if (data.gltfUrl && data.glbUrl) { const timestamp = `?t=${Date.now()}`; const rawGltf = data.gltfUrl.startsWith('http') ? data.gltfUrl : `${apiBase}${data.gltfUrl}`; const rawGlb = data.glbUrl.startsWith('http') ? data.glbUrl : `${apiBase}${data.glbUrl}`; const cleanGltfUrl = rawGltf.includes('?') ? `${rawGltf}&t=${Date.now()}` : `${rawGltf}${timestamp}`; const cleanGlbUrl = rawGlb.includes('?') ? `${rawGlb}&t=${Date.now()}` : `${rawGlb}${timestamp}`; load3DModel(cleanGltfUrl, cleanGlbUrl, data.fbxUrl, currentModelCategory); if (typeof loadGallery === 'function') { try { loadGallery(); } catch (ge) { console.warn('Gallery refresh:', ge); } } showCustomAlert('La malla fue optimizada y guardada como un nuevo elemento en tu Biblioteca.', 'Optimización completada'); } else { throw new Error('No se recibieron los URLs del modelo optimizado.'); } } catch (error) { statusOverlay.classList.remove('active'); showCustomAlert(error.message, 'Error de Optimización'); } }); } // ==================== WEIGHT PAINTING PROTOTYPE ==================== function getWeightVisualizerMaterial(boneIndex) { return new THREE.ShaderMaterial({ vertexShader: ` #include #include uniform int selectedBoneIndex; varying float vWeight; void main() { vWeight = 0.0; if (skinWeight.x > 0.0 && int(skinIndex.x) == selectedBoneIndex) vWeight = skinWeight.x; else if (skinWeight.y > 0.0 && int(skinIndex.y) == selectedBoneIndex) vWeight = skinWeight.y; else if (skinWeight.z > 0.0 && int(skinIndex.z) == selectedBoneIndex) vWeight = skinWeight.z; else if (skinWeight.w > 0.0 && int(skinIndex.w) == selectedBoneIndex) vWeight = skinWeight.w; #include #include #include #include } `, fragmentShader: ` varying float vWeight; void main() { // Color map: 0.0 is blue, 0.5 is green, 1.0 is red vec3 blue = vec3(0.0, 0.0, 0.8); vec3 green = vec3(0.0, 0.8, 0.0); vec3 red = vec3(1.0, 0.0, 0.0); vec3 color; if (vWeight < 0.5) { color = mix(blue, green, vWeight * 2.0); } else { color = mix(green, red, (vWeight - 0.5) * 2.0); } gl_FragColor = vec4(color, 1.0); } `, uniforms: THREE.UniformsUtils.merge([ THREE.ShaderLib.standard.uniforms, { selectedBoneIndex: { value: boneIndex } } ]), skinning: true, wireframe: false, depthTest: true, depthWrite: true }); } // Enable/Disable Weight Visualizer Shader function toggleWeightVisualizer(enable) { if (!threeModel) return; activeSkinnedMeshes = []; spatialGrid = null; // Get selected bone index let boneIdx = -1; const activeBone = selectedBone; if (activeBone && threeModel) { // Find bone's index in the skeleton let allBones = []; threeModel.traverse(c => { if (c.isBone) allBones.push(c); }); boneIdx = allBones.indexOf(activeBone); } targetBoneIdx = boneIdx; threeModel.traverse(child => { if (child.name && (child.name.includes("_wireframe_helper") || child.name.includes("_points_helper"))) return; if (child.isSkinnedMesh) { if (enable) { activeSkinnedMeshes.push(child); // Override raycast method to bypass expensive CPU skinning calculations if (!child.originalRaycast) { child.originalRaycast = child.raycast; } child.raycast = THREE.Mesh.prototype.raycast; // Save original material if not already saved if (!originalMaterialsMap.has(child)) { originalMaterialsMap.set(child, child.material); } child.material = getWeightVisualizerMaterial(boneIdx); } else { // Restore original raycast if (child.originalRaycast) { child.raycast = child.originalRaycast; } // Restore original material if (originalMaterialsMap.has(child)) { child.material = originalMaterialsMap.get(child); } } } }); } // Update Uniform on active material when selected bone changes function updateWeightVisualizerBone() { if (!isWeightPaintingActive || !threeModel) return; let boneIdx = -1; const activeBone = selectedBone; if (activeBone) { let allBones = []; threeModel.traverse(c => { if (c.isBone) allBones.push(c); }); boneIdx = allBones.indexOf(activeBone); } targetBoneIdx = boneIdx; threeModel.traverse(child => { if (child.isSkinnedMesh && child.material && child.material.uniforms && child.material.uniforms.selectedBoneIndex) { child.material.uniforms.selectedBoneIndex.value = boneIdx; child.material.needsUpdate = true; } }); } // UI Handlers for Weight Paint settings if (activeWeightPaintCheck) { activeWeightPaintCheck.addEventListener('change', () => { isWeightPaintingActive = activeWeightPaintCheck.checked; toggleWeightVisualizer(isWeightPaintingActive); const card = document.getElementById('paintInstructionCard'); if (card) { card.style.display = isWeightPaintingActive ? 'block' : 'none'; } // Hide auto-rotation by default to avoid conflicts while painting if (isWeightPaintingActive) { isAutoRotating = false; if (threeControls) threeControls.autoRotate = false; if (toggleAutoRotateBtn) toggleAutoRotateBtn.classList.remove('active'); // Automatically enable skeleton helper/bones visualization for selection setSkeletonHelperActive(true); // Collapse other panels to make room const rigPanel = document.getElementById('manualRigPanel'); if (rigPanel) rigPanel.classList.add('collapsed'); const cleanPanel = document.getElementById('manualCleanPanel'); if (cleanPanel) cleanPanel.classList.add('collapsed'); // Deactivate their toolbar buttons const btnRig = document.getElementById('btnRigPanel'); if (btnRig) btnRig.classList.remove('active'); const btnClean = document.getElementById('btnCleanPanel'); if (btnClean) btnClean.classList.remove('active'); // Show/Create selected bone marker sphere on the mesh updateSelectedBoneMarker(); // Add brush helper mesh to scene if (!brushHelper && threeScene) { const geom = new THREE.RingGeometry(0.095, 0.10, 32); const mat = new THREE.MeshBasicMaterial({ color: 0xffaa00, side: THREE.DoubleSide, transparent: true, opacity: 0.8, depthTest: false }); brushHelper = new THREE.Mesh(geom, mat); brushHelper.renderOrder = 1001; threeScene.add(brushHelper); } } else { if (brushHelper && threeScene) { threeScene.remove(brushHelper); brushHelper = null; } // Clean up selected bone marker when deactivated updateSelectedBoneMarker(); } }); } if (weightPaintRadiusInput && weightPaintRadiusVal) { setupSliderValueUpdate(weightPaintRadiusInput, weightPaintRadiusVal); weightPaintRadiusInput.addEventListener('input', (e) => { weightPaintRadius = parseFloat(e.target.value); if (brushHelper) { brushHelper.scale.setScalar(weightPaintRadius / 0.10); // Scale relative to default 0.1 } }); } if (weightPaintStrengthInput && weightPaintStrengthVal) { setupSliderValueUpdate(weightPaintStrengthInput, weightPaintStrengthVal); weightPaintStrengthInput.addEventListener('input', (e) => { weightPaintStrength = parseFloat(e.target.value); }); } if (weightPaintModeSelect) { weightPaintModeSelect.addEventListener('change', () => { weightPaintMode = weightPaintModeSelect.value; }); } // Mouse events on canvas for painting weights let isMouseDown = false; const paintRaycaster = new THREE.Raycaster(); const paintMouse = new THREE.Vector2(); function setupWeightPaintingCanvasEvents(canvas) { // Prevent right-click browser menu on canvas when weight painting is active canvas.addEventListener('contextmenu', (e) => { if (isWeightPaintingActive) { e.preventDefault(); } }, true); canvas.addEventListener('pointerdown', (e) => { if (!isWeightPaintingActive) return; // Paint actions require holding the Ctrl key if (e.ctrlKey) { // Prevent OrbitControls from receiving this click and rotating the camera e.stopImmediatePropagation(); e.preventDefault(); if (e.button === 0) { // Left Click + Ctrl -> Add weight weightPaintMode = 'add'; if (weightPaintModeSelect) weightPaintModeSelect.value = 'add'; isMouseDown = true; if (brushHelper) brushHelper.material.color.setHex(0x00ff66); // GREEN if (threeControls) threeControls.enabled = false; paintWeightsAtMouse(e, canvas); } else if (e.button === 2) { // Right Click + Ctrl -> Sub weight weightPaintMode = 'sub'; if (weightPaintModeSelect) weightPaintModeSelect.value = 'sub'; isMouseDown = true; if (brushHelper) brushHelper.material.color.setHex(0xff3333); // RED if (threeControls) threeControls.enabled = false; paintWeightsAtMouse(e, canvas); } } else { // Normal click (no Ctrl) -> Ensure camera controls are active if (threeControls) threeControls.enabled = true; } }, true); canvas.addEventListener('pointermove', (e) => { if (!isWeightPaintingActive) return; // Skip brush raycasting when actively rotating/panning the camera if (e.buttons !== 0 && !e.ctrlKey) { if (brushHelper) brushHelper.visible = false; lastBrushHit = null; return; } // Only show/update brush helper if Ctrl key is held! if (!e.ctrlKey) { if (brushHelper) brushHelper.visible = false; lastBrushHit = null; return; } // Set hover color if not active drawing if (brushHelper && !isMouseDown) { brushHelper.material.color.setHex(0xffaa00); // ORANGE/YELLOW } // Skip raycast/paint on micro-movements of less than 4 pixels in screen coordinates const dx = e.clientX - lastMouseScreenX; const dy = e.clientY - lastMouseScreenY; if (Math.abs(dx) < 4 && Math.abs(dy) < 4) { return; } lastMouseScreenX = e.clientX; lastMouseScreenY = e.clientY; // Position brush helper (throttled inside updateBrushHelperPosition) updateBrushHelperPosition(e, canvas); // Continue painting if mouse is down and Ctrl is held if (isMouseDown && e.ctrlKey) { const now = performance.now(); if (now - lastPaintTime < 80) return; // Throttle paint loops to ~12 FPS on heavy meshes to prevent UI freeze lastPaintTime = now; e.stopImmediatePropagation(); e.preventDefault(); if (e.buttons === 1) { // Left button held weightPaintMode = 'add'; if (weightPaintModeSelect) weightPaintModeSelect.value = 'add'; if (brushHelper) brushHelper.material.color.setHex(0x00ff66); // GREEN paintWeightsAtMouse(e, canvas); } else if (e.buttons === 2) { // Right button held weightPaintMode = 'sub'; if (weightPaintModeSelect) weightPaintModeSelect.value = 'sub'; if (brushHelper) brushHelper.material.color.setHex(0xff3333); // RED paintWeightsAtMouse(e, canvas); } } }, true); window.addEventListener('pointerup', (e) => { if (isWeightPaintingActive) { if (isMouseDown) { isMouseDown = false; e.stopImmediatePropagation(); e.preventDefault(); } if (brushHelper) brushHelper.material.color.setHex(0xffaa00); // ORANGE/YELLOW // Always restore camera controls on pointerup if (threeControls) { threeControls.enabled = true; } } }, true); } function updateBrushHelperPosition(event, canvas) { if (!brushHelper || !threeCamera || !threeModel || activeSkinnedMeshes.length === 0) { lastBrushHit = null; if (brushHelper) brushHelper.visible = false; return; } // Limit hover updates to ~40fps const now = performance.now(); if (now - lastRaycastTime < 25) return; lastRaycastTime = now; const rect = canvas.getBoundingClientRect(); paintMouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; paintMouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; paintRaycaster.setFromCamera(paintMouse, threeCamera); // Fast Bounding Sphere colision check (takes 0.001ms) for hover placement const mesh = activeSkinnedMeshes[0]; if (!mesh.geometry.boundingSphere) { mesh.geometry.computeBoundingSphere(); } const sphere = new THREE.Sphere(); sphere.copy(mesh.geometry.boundingSphere).applyMatrix4(mesh.matrixWorld); const intersectPoint = new THREE.Vector3(); if (paintRaycaster.ray.intersectSphere(sphere, intersectPoint)) { brushHelper.position.copy(intersectPoint); brushHelper.visible = true; // Cache a basic reference for start of paint lastBrushHit = { object: mesh, point: intersectPoint }; } else { brushHelper.visible = false; lastBrushHit = null; } } function buildSpatialGrid(mesh) { const geometry = mesh.geometry; const positionAttr = geometry.attributes.position; if (!positionAttr) return null; const posArray = positionAttr.array; const vCount = positionAttr.count; // Get bounding box of the geometry if (!geometry.boundingBox) { geometry.computeBoundingBox(); } const bbox = geometry.boundingBox; // We will use a grid of 25x25x25 cells for higher search density const gridSize = 25; const min = bbox.min; const max = bbox.max; const sizeX = (max.x - min.x) || 0.01; const sizeY = (max.y - min.y) || 0.01; const sizeZ = (max.z - min.z) || 0.01; const cellWidth = sizeX / gridSize; const cellHeight = sizeY / gridSize; const cellDepth = sizeZ / gridSize; // Initialize grid cells map const cells = new Map(); for (let idx = 0; idx < vCount; idx++) { const idx3 = idx * 3; const vx = posArray[idx3]; const vy = posArray[idx3 + 1]; const vz = posArray[idx3 + 2]; // Compute cell coordinates (clamped to 0..gridSize-1) const cx = Math.min(gridSize - 1, Math.max(0, Math.floor((vx - min.x) / cellWidth))); const cy = Math.min(gridSize - 1, Math.max(0, Math.floor((vy - min.y) / cellHeight))); const cz = Math.min(gridSize - 1, Math.max(0, Math.floor((vz - min.z) / cellDepth))); // Create cell key const key = (cx << 16) | (cy << 8) | cz; let cellList = cells.get(key); if (!cellList) { cellList = []; cells.set(key, cellList); } cellList.push(idx); } return { cells: cells, min: min, cellWidth: cellWidth, cellHeight: cellHeight, cellDepth: cellDepth, gridSize: gridSize }; } function paintWeightsAtMouse(event, canvas) { if (!threeCamera || !threeModel || activeSkinnedMeshes.length === 0) return; // Reuse the cached bounding sphere hit from updateBrushHelperPosition // This avoids the extremely expensive triangle-level raycast (which was the #1 bottleneck) if (!lastBrushHit) return; const mesh = lastBrushHit.object; const point = lastBrushHit.point; // Transform bounding sphere intersection point to local geometry space const approxLocal = mesh.worldToLocal(point.clone()); if (targetBoneIdx === -1) { const now = performance.now(); if (now - lastNoBoneToastTime > 3000) { showCustomToast("Por favor, selecciona un hueso haciendo clic en una articulación celeste primero."); lastNoBoneToastTime = now; } return; } const geometry = mesh.geometry; const positionAttr = geometry.attributes.position; const skinIndexAttr = geometry.attributes.skinIndex; const skinWeightAttr = geometry.attributes.skinWeight; if (!positionAttr || !skinIndexAttr || !skinWeightAttr) return; // Build spatial grid on demand if not already built if (!spatialGrid) { spatialGrid = buildSpatialGrid(mesh); } // Retrieve underlying Float32/Int arrays for raw access const posArray = positionAttr.array; const idxArray = skinIndexAttr.array; const wtArray = skinWeightAttr.array; // Compute camera ray in local mesh space for accurate vertex targeting const rect = canvas.getBoundingClientRect(); const mx = ((event.clientX - rect.left) / rect.width) * 2 - 1; const my = -((event.clientY - rect.top) / rect.height) * 2 + 1; paintRaycaster.setFromCamera({x: mx, y: my}, threeCamera); // Transform ray origin & direction into mesh local space const invMatrix = new THREE.Matrix4().copy(mesh.matrixWorld).invert(); const localRayOrigin = paintRaycaster.ray.origin.clone().applyMatrix4(invMatrix); const localRayDir = paintRaycaster.ray.direction.clone().transformDirection(invMatrix).normalize(); // Search cells around the approximate sphere hit point to find the vertex // closest to the camera ray (perpendicular distance), not to the sphere surface point const searchRadius = Math.max(weightPaintRadius * 5, 1.0); const srMinX = approxLocal.x - searchRadius; const srMaxX = approxLocal.x + searchRadius; const srMinY = approxLocal.y - searchRadius; const srMaxY = approxLocal.y + searchRadius; const srMinZ = approxLocal.z - searchRadius; const srMaxZ = approxLocal.z + searchRadius; const srMinCx = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((srMinX - spatialGrid.min.x) / spatialGrid.cellWidth))); const srMaxCx = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((srMaxX - spatialGrid.min.x) / spatialGrid.cellWidth))); const srMinCy = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((srMinY - spatialGrid.min.y) / spatialGrid.cellHeight))); const srMaxCy = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((srMaxY - spatialGrid.min.y) / spatialGrid.cellHeight))); const srMinCz = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((srMinZ - spatialGrid.min.z) / spatialGrid.cellDepth))); const srMaxCz = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((srMaxZ - spatialGrid.min.z) / spatialGrid.cellDepth))); let nearestRayDistSq = Infinity; let nearestVx = approxLocal.x, nearestVy = approxLocal.y, nearestVz = approxLocal.z; const rox = localRayOrigin.x, roy = localRayOrigin.y, roz = localRayOrigin.z; const rdx = localRayDir.x, rdy = localRayDir.y, rdz = localRayDir.z; for (let cx = srMinCx; cx <= srMaxCx; cx++) { for (let cy = srMinCy; cy <= srMaxCy; cy++) { for (let cz = srMinCz; cz <= srMaxCz; cz++) { const key = (cx << 16) | (cy << 8) | cz; const cellList = spatialGrid.cells.get(key); if (!cellList) continue; for (let i = 0; i < cellList.length; i++) { const vi = cellList[i]; const vi3 = vi * 3; // Vector from ray origin to vertex const vx = posArray[vi3] - rox; const vy = posArray[vi3 + 1] - roy; const vz = posArray[vi3 + 2] - roz; // Projection of vertex onto ray direction (t parameter) const t = vx * rdx + vy * rdy + vz * rdz; if (t < 0) continue; // behind camera // Perpendicular distance² = |V|² - t² const perpDistSq = (vx * vx + vy * vy + vz * vz) - t * t; if (perpDistSq < nearestRayDistSq) { nearestRayDistSq = perpDistSq; nearestVx = posArray[vi3]; nearestVy = posArray[vi3 + 1]; nearestVz = posArray[vi3 + 2]; } } } } } // Use the nearest vertex position as the paint center (snapped to mesh surface) const lx = nearestVx; const ly = nearestVy; const lz = nearestVz; const radius = weightPaintRadius; const radiusSq = radius * radius; // Query spatial grid for cells overlapping the brush bounding box around the snapped center const minX = lx - radius; const maxX = lx + radius; const minY = ly - radius; const maxY = ly + radius; const minZ = lz - radius; const maxZ = lz + radius; const minCx = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((minX - spatialGrid.min.x) / spatialGrid.cellWidth))); const maxCx = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((maxX - spatialGrid.min.x) / spatialGrid.cellWidth))); const minCy = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((minY - spatialGrid.min.y) / spatialGrid.cellHeight))); const maxCy = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((maxY - spatialGrid.min.y) / spatialGrid.cellHeight))); const minCz = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((minZ - spatialGrid.min.z) / spatialGrid.cellDepth))); const maxCz = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((maxZ - spatialGrid.min.z) / spatialGrid.cellDepth))); let modified = false; let minModifiedIdx = Infinity; let maxModifiedIdx = -Infinity; // Loop through only the overlapping cells! for (let cx = minCx; cx <= maxCx; cx++) { for (let cy = minCy; cy <= maxCy; cy++) { for (let cz = minCz; cz <= maxCz; cz++) { const key = (cx << 16) | (cy << 8) | cz; const cellList = spatialGrid.cells.get(key); if (!cellList) continue; const cCount = cellList.length; for (let i = 0; i < cCount; i++) { const idx = cellList[i]; const idx3 = idx * 3; const vx = posArray[idx3]; const vy = posArray[idx3 + 1]; const vz = posArray[idx3 + 2]; const dx = vx - lx; const dy = vy - ly; const dz = vz - lz; // Fast squared distance check to avoid Math.sqrt for 99% of vertices const distSq = dx * dx + dy * dy + dz * dz; if (distSq < radiusSq) { // Calculate actual distance only for vertices inside the brush radius const dist = Math.sqrt(distSq); const falloff = 1.0 - (dist / radius); const deltaWeight = falloff * weightPaintStrength * 0.15; // Scaled down for smooth paint // Get current bones indices and weights directly using local variables (zero garbage collection) const idx4 = idx * 4; let idx0 = idxArray[idx4]; let idx1 = idxArray[idx4 + 1]; let idx2 = idxArray[idx4 + 2]; let idx3Val = idxArray[idx4 + 3]; let wt0 = wtArray[idx4]; let wt1 = wtArray[idx4 + 1]; let wt2 = wtArray[idx4 + 2]; let wt3 = wtArray[idx4 + 3]; // Find if target bone is already affecting this vertex (flat conditional check) let boneSlot = -1; if (wt0 > 0.0 && idx0 === targetBoneIdx) boneSlot = 0; else if (wt1 > 0.0 && idx1 === targetBoneIdx) boneSlot = 1; else if (wt2 > 0.0 && idx2 === targetBoneIdx) boneSlot = 2; else if (wt3 > 0.0 && idx3Val === targetBoneIdx) boneSlot = 3; // If not affecting, replace slot with lowest weight if (boneSlot === -1) { let minSlot = 0; let minWt = wt0; if (wt1 < minWt) { minWt = wt1; minSlot = 1; } if (wt2 < minWt) { minWt = wt2; minSlot = 2; } if (wt3 < minWt) { minWt = wt3; minSlot = 3; } boneSlot = minSlot; if (boneSlot === 0) { idx0 = targetBoneIdx; wt0 = 0.0; } else if (boneSlot === 1) { idx1 = targetBoneIdx; wt1 = 0.0; } else if (boneSlot === 2) { idx2 = targetBoneIdx; wt2 = 0.0; } else if (boneSlot === 3) { idx3Val = targetBoneIdx; wt3 = 0.0; } } // Get current weight of the targeted slot let oldWt = 0.0; if (boneSlot === 0) oldWt = wt0; else if (boneSlot === 1) oldWt = wt1; else if (boneSlot === 2) oldWt = wt2; else if (boneSlot === 3) oldWt = wt3; let newWt = oldWt; if (weightPaintMode === 'add') { newWt = Math.min(1.0, oldWt + deltaWeight); } else { newWt = Math.max(0.0, oldWt - deltaWeight); } if (newWt !== oldWt) { if (boneSlot === 0) wt0 = newWt; else if (boneSlot === 1) wt1 = newWt; else if (boneSlot === 2) wt2 = newWt; else if (boneSlot === 3) wt3 = newWt; // Normalize remaining slots let otherSum = 0.0; if (boneSlot !== 0) otherSum += wt0; if (boneSlot !== 1) otherSum += wt1; if (boneSlot !== 2) otherSum += wt2; if (boneSlot !== 3) otherSum += wt3; if (otherSum > 0.0) { const scale = (1.0 - newWt) / otherSum; if (boneSlot !== 0) wt0 *= scale; if (boneSlot !== 1) wt1 *= scale; if (boneSlot !== 2) wt2 *= scale; if (boneSlot !== 3) wt3 *= scale; } else { const remaining = (1.0 - newWt) / 3.0; if (boneSlot !== 0) wt0 = remaining; if (boneSlot !== 1) wt1 = remaining; if (boneSlot !== 2) wt2 = remaining; if (boneSlot !== 3) wt3 = remaining; } // Write back directly to typed arrays idxArray[idx4] = idx0; idxArray[idx4 + 1] = idx1; idxArray[idx4 + 2] = idx2; idxArray[idx4 + 3] = idx3Val; wtArray[idx4] = wt0; wtArray[idx4 + 1] = wt1; wtArray[idx4 + 2] = wt2; wtArray[idx4 + 3] = wt3; if (idx < minModifiedIdx) minModifiedIdx = idx; if (idx > maxModifiedIdx) maxModifiedIdx = idx; modified = true; } } } } } } if (modified) { // GPU Upload Optimization: upload only the slice of the array that was modified const start = minModifiedIdx * 4; const count = (maxModifiedIdx - minModifiedIdx + 1) * 4; skinIndexAttr.updateRange.offset = start; skinIndexAttr.updateRange.count = count; skinIndexAttr.needsUpdate = true; skinWeightAttr.updateRange.offset = start; skinWeightAttr.updateRange.count = count; skinWeightAttr.needsUpdate = true; } } // Save Weight Painting Changes Handler if (saveWeightPaintBtn) { saveWeightPaintBtn.addEventListener('click', () => { if (!threeModel || !activeModelRelativeUrl) return; // Turn off painting shader first so we export original materials const originalPaintingState = isWeightPaintingActive; if (originalPaintingState) { activeWeightPaintCheck.checked = false; const card = document.getElementById('paintInstructionCard'); if (card) card.style.display = 'none'; isWeightPaintingActive = false; toggleWeightVisualizer(false); if (brushHelper && threeScene) { threeScene.remove(brushHelper); brushHelper = null; } } statusOverlay.classList.add('active'); updateLoader(40, 'Guardando Influencias...', 'Exportando el modelo tridimensional y recalculando pesos...'); // Use THREE.GLTFExporter to export GLB const exporter = new THREE.GLTFExporter(); // Export options const exportOptions = { binary: true, animations: [], truncateDrawRange: false }; exporter.parse(threeModel, async (gltfBuffer) => { try { // Convert buffer array to base64 const blob = new Blob([gltfBuffer], { type: 'application/octet-stream' }); const reader = new FileReader(); reader.readAsDataURL(blob); reader.onloadend = async () => { const glbBase64 = reader.result; updateLoader(70, 'Regenerando FBX...', 'Blender está compilando las nuevas influencias de huesos y texturas en el archivo FBX...'); const response = await fetch(`${apiBase}/api/save-weights`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ modelUrl: activeModelRelativeUrl, glbBase64: glbBase64 }) }); if (!response.ok) { const errData = await response.json().catch(() => ({ error: 'Error al actualizar pesos.' })); throw new Error(errData.error || 'Error interno del servidor.'); } const data = await response.json(); // Reload model with updated files sessionStorage.setItem('pendingModelToLoad', JSON.stringify({ gltfUrl: activeModelRelativeUrl, downloadUrl: activeModelRelativeUrl, fbxUrl: data.fbxUrl, detectedCategory: currentModelCategory })); window.location.reload(); }; } catch (error) { console.error(error); statusOverlay.classList.remove('active'); alert(`Error al guardar pesos: ${error.message}`); // Restore painting state on failure if (originalPaintingState) { activeWeightPaintCheck.checked = true; const card = document.getElementById('paintInstructionCard'); if (card) card.style.display = 'block'; isWeightPaintingActive = true; toggleWeightVisualizer(true); } } }, (error) => { console.error('Error during GLTF export:', error); statusOverlay.classList.remove('active'); alert('Error al exportar la malla para guardar.'); }, exportOptions); }); } // Generic function to toggle collapsible panels from the vertical toolbar buttons function toggleToolbarPanel(panelId, buttonId) { const panel = document.getElementById(panelId); const button = document.getElementById(buttonId); if (!panel) return; const isCurrentlyCollapsed = panel.classList.contains('collapsed'); // 1. Collapse all panels const allPanels = ['manualRigPanel', 'manualCleanPanel', 'rigTriggerPanel', 'manualWeightPaintPanel']; allPanels.forEach(id => { const p = document.getElementById(id); if (p) p.classList.add('collapsed'); }); // 2. Deactivate all buttons const allBtns = ['btnRigPanel', 'btnCleanPanel', 'btnRigTriggerPanel', 'btnWeightPaintPanel']; allBtns.forEach(id => { const b = document.getElementById(id); if (b) b.classList.remove('active'); }); // 3. If it was collapsed, expand it and set button active if (isCurrentlyCollapsed) { panel.classList.remove('collapsed'); if (button) button.classList.add('active'); } } // Attach event listeners for the vertical toolbar buttons const btnRigPanel = document.getElementById('btnRigPanel'); const btnCleanPanel = document.getElementById('btnCleanPanel'); const btnRigTriggerPanel = document.getElementById('btnRigTriggerPanel'); const btnWeightPaintPanel = document.getElementById('btnWeightPaintPanel'); if (btnRigPanel) { btnRigPanel.addEventListener('click', () => toggleToolbarPanel('manualRigPanel', 'btnRigPanel')); } if (btnCleanPanel) { btnCleanPanel.addEventListener('click', () => toggleToolbarPanel('manualCleanPanel', 'btnCleanPanel')); } if (btnRigTriggerPanel) { btnRigTriggerPanel.addEventListener('click', () => toggleToolbarPanel('rigTriggerPanel', 'btnRigTriggerPanel')); } if (btnWeightPaintPanel) { btnWeightPaintPanel.addEventListener('click', () => toggleToolbarPanel('manualWeightPaintPanel', 'btnWeightPaintPanel')); } function updateWeightPaintSelectedBoneDisplay() { const textEl = document.getElementById('weightPaintSelectedBoneText'); if (textEl) { textEl.textContent = selectedBone ? getFriendlyBoneName(selectedBone.name) : 'Ninguno'; } } function updateSelectedBoneMarker() { if (!threeScene) return; if (isWeightPaintingActive) { if (selectedBone) { if (!selectedBoneMarker) { // Create a beautiful, glowing hot-orange/magenta pulsing sphere const group = new THREE.Group(); const innerGeo = new THREE.SphereGeometry(0.025, 16, 16); const innerMat = new THREE.MeshBasicMaterial({ color: 0xff0055, // Hot magenta/red to stand out against weight map blue depthTest: false, transparent: true, opacity: 0.95 }); const innerMesh = new THREE.Mesh(innerGeo, innerMat); innerMesh.renderOrder = 1000; group.add(innerMesh); selectedBoneMarker = group; threeScene.add(selectedBoneMarker); } const pos = new THREE.Vector3(); selectedBone.getWorldPosition(pos); selectedBoneMarker.position.copy(pos); selectedBoneMarker.visible = true; } else { if (selectedBoneMarker) selectedBoneMarker.visible = false; } } else { if (selectedBoneMarker) { threeScene.remove(selectedBoneMarker); selectedBoneMarker = null; } } } // Check mobile viewport and show interactive redirect dialog function checkMobileRedirect() { if (window.innerWidth < 768) { if (sessionStorage.getItem('dismissedMobileWarning') === 'true') { return; } const overlay = document.createElement('div'); overlay.style.position = 'fixed'; overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.width = '100vw'; overlay.style.height = '100vh'; overlay.style.backgroundColor = 'rgba(10, 10, 12, 0.96)'; overlay.style.zIndex = '999999'; overlay.style.display = 'flex'; overlay.style.alignItems = 'center'; overlay.style.justifyContent = 'center'; overlay.style.padding = '24px'; overlay.style.boxSizing = 'border-box'; overlay.style.backdropFilter = 'blur(10px)'; overlay.style.webkitBackdropFilter = 'blur(10px)'; overlay.innerHTML = `
📱

¿Estás en un Celular?

La creación, edición y limpieza de mallas está diseñada para pantallas de PC/Laptops. En celulares, te recomendamos usar el visor 3D táctil optimizado y proyectar en Realidad Aumentada (AR).

Abrir Visor Móvil (AR)
`; document.body.appendChild(overlay); overlay.querySelector('#btnStayOnPCWeb').addEventListener('click', () => { sessionStorage.setItem('dismissedMobileWarning', 'true'); sessionStorage.setItem('forceDesktop', 'true'); document.body.removeChild(overlay); }); } } // Execute check on page load - only via auth functions