Spaces:
Build error
Build error
LogicalTrue
fix: reemplazar materiales Phong de FBX por MeshStandardMaterial PBR para iluminacion y texturas vivas
5b5272a | 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 = ` | |
| <div class="library-thumb-empty"> | |
| No hay imágenes 2D en la biblioteca. Genera una primero en "Generar 2D AI". | |
| </div> | |
| `; | |
| 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 src="${apiBase}${img.url}" alt="${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 = ` | |
| <div style="display: flex; align-items: center; gap: 8px;"> | |
| <div class="spinner-small" style="width: 12px; height: 12px; border: 2px solid rgba(255,255,255,0.2); border-top-color: var(--primary-light); border-radius: 50%; animation: spin 1s linear infinite;"></div> | |
| <span>Generando imagen. Esto puede tardar 1-2 minutos si el modelo de Hugging Face está inactivo...</span> | |
| </div> | |
| `; | |
| 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 = ` | |
| <div style="display: flex; align-items: center; gap: 8px;"> | |
| <div class="spinner-small" style="width: 12px; height: 12px; border: 2px solid rgba(255,255,255,0.2); border-top-color: var(--primary-light); border-radius: 50%; animation: spin 1s linear infinite;"></div> | |
| <span>${job.message || 'Generando imagen...'} (${progress}%)</span> | |
| </div> | |
| `; | |
| 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: <strong>${modelUsed.split('/').pop()}</strong>!`; | |
| 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 <common> | |
| #include <skinning_pars_vertex> | |
| void main() { | |
| #include <begin_vertex> | |
| #include <skinbase_vertex> | |
| #include <skinning_vertex> | |
| 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 <common> | |
| #include <skinning_pars_vertex> | |
| void main() { | |
| #include <begin_vertex> | |
| #include <skinbase_vertex> | |
| #include <skinning_vertex> | |
| 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 = ` | |
| <div class="gallery-empty-state"> | |
| <i data-lucide="inbox" style="opacity: 0.5; margin-bottom: 0.5rem; display: block; margin: 0 auto; text-align: center;"></i> | |
| <p style="font-size: 0.8rem; color: var(--text-dark); text-align: center;">Biblioteca vacía</p> | |
| </div> | |
| `; | |
| 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 = ` | |
| <div class="gallery-empty-state"> | |
| <i data-lucide="inbox" style="opacity: 0.5; margin-bottom: 0.5rem; display: block; margin: 0 auto; text-align: center;"></i> | |
| <p style="font-size: 0.8rem; color: var(--text-dark); text-align: center;">No hay elementos que coincidan con el filtro</p> | |
| </div> | |
| `; | |
| 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 | |
| ? `<img src="${apiBase}${item.url}" alt="${item.name}">` | |
| : `<i data-lucide="box" style="width: 24px; height: 24px;"></i>`; | |
| const actionButtonHtml = isImage | |
| ? `<button class="gallery-btn load-img-btn" title="Visualizar Imagen 2D"> | |
| <i data-lucide="eye" style="width: 14px; height: 14px;"></i> | |
| </button>` | |
| : `<button class="gallery-btn load-model-btn" title="Visualizar modelo 3D"> | |
| <i data-lucide="eye" style="width: 14px; height: 14px;"></i> | |
| </button>`; | |
| itemEl.innerHTML = ` | |
| <div class="gallery-thumbnail">${thumbnailHtml}</div> | |
| <div class="gallery-info"> | |
| <span class="gallery-name" title="${item.name}">${item.name}</span> | |
| <span class="gallery-meta"> | |
| <i data-lucide="calendar" style="width: 10px; height: 10px;"></i> | |
| <span>${formattedTime}</span> | |
| </span> | |
| </div> | |
| <div class="gallery-actions"> | |
| ${actionButtonHtml} | |
| ${!isImage ? ` | |
| ${(item.fbxUrl && item.fbxUrl.toLowerCase().endsWith('.fbx')) ? ` | |
| <a href="${apiBase}${item.fbxUrl}" download="${item.name.replace('.glb', '.fbx')}" class="gallery-btn" title="Descargar archivo FBX (Quads)" style="text-decoration: none; background: rgba(0, 150, 136, 0.15); color: #009688; border-color: rgba(0, 150, 136, 0.3);"> | |
| <i data-lucide="download" style="width: 14px; height: 14px;"></i> | |
| </a>` : ` | |
| <button class="gallery-btn download-raw-fbx-btn" data-url="${item.url}" title="Optimizar y descargar como FBX (Quads)" style="background: rgba(0, 150, 136, 0.15); color: #009688; border-color: rgba(0, 150, 136, 0.3);"> | |
| <i data-lucide="download" style="width: 14px; height: 14px;"></i> | |
| </button> | |
| `} | |
| ` : ''} | |
| <button class="gallery-btn delete-btn" title="Eliminar del disco"> | |
| <i data-lucide="trash-2" style="width: 14px; height: 14px;"></i> | |
| </button> | |
| </div> | |
| `; | |
| // 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 <common> | |
| #include <skinning_pars_vertex> | |
| 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 <begin_vertex> | |
| #include <skinbase_vertex> | |
| #include <skinning_vertex> | |
| #include <project_vertex> | |
| } | |
| `, | |
| 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 = ` | |
| <div style="background: #18181c; border: 1px solid #7c3aed; border-radius: 16px; padding: 24px; text-align: center; max-width: 400px; width: 100%; box-shadow: 0 20px 45px rgba(0,0,0,0.5); font-family: sans-serif;"> | |
| <div style="font-size: 3.5rem; margin-bottom: 16px; animation: bounce 2s infinite;">📱</div> | |
| <h3 style="color: #ffffff; margin-top: 0; font-size: 1.35rem; font-weight: 700; margin-bottom: 12px; letter-spacing: -0.5px;">¿Estás en un Celular?</h3> | |
| <p style="color: #a1a1aa; font-size: 0.9rem; line-height: 1.5; margin-bottom: 24px;"> | |
| 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). | |
| </p> | |
| <div style="display: flex; flex-direction: column; gap: 12px; width: 100%;"> | |
| <a href="/viewer.html?v=123" style="background: #7c3aed; color: #ffffff; padding: 14px; border-radius: 10px; text-decoration: none; font-weight: 700; font-size: 0.95rem; display: block; box-shadow: 0 4px 12px rgba(124, 58, 237, 0.3);"> | |
| Abrir Visor Móvil (AR) | |
| </a> | |
| <button id="btnStayOnPCWeb" style="background: transparent; border: 1px solid rgba(255,255,255,0.15); color: #a1a1aa; padding: 12px; border-radius: 10px; font-weight: 600; font-size: 0.9rem; cursor: pointer; transition: all 0.2s;"> | |
| Seguir en Web de PC (No recomendado) | |
| </button> | |
| </div> | |
| </div> | |
| <style> | |
| @keyframes bounce { | |
| 0%, 100% { transform: translateY(0); } | |
| 50% { transform: translateY(-10px); } | |
| } | |
| #btnStayOnPCWeb:hover { | |
| background: rgba(255,255,255,0.05); | |
| color: #ffffff; | |
| } | |
| </style> | |
| `; | |
| 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 | |