`;
// Event listeners for actions
if (isImage) {
itemEl.querySelector('.load-img-btn').addEventListener('click', () => {
const imgViewer = document.getElementById('image-viewer-container');
const fullImg = document.getElementById('full2DImageViewPC');
if (fullImg) fullImg.src = `${apiBase}${item.url}`;
if (imgViewer) imgViewer.style.display = 'flex';
window.current2DViewerItem = item;
});
} else {
itemEl.querySelector('.load-model-btn').addEventListener('click', () => {
load3DModel(`${apiBase}${item.url}`, `${apiBase}${item.url}`, item.fbxUrl, item.detectedCategory);
});
const downloadRawFbxBtn = itemEl.querySelector('.download-raw-fbx-btn');
if (downloadRawFbxBtn) {
downloadRawFbxBtn.addEventListener('click', async () => {
statusOverlay.classList.add('active');
updateLoader(50, 'Convirtiendo a FBX...', 'Blender está optimizando la malla y exportando el archivo FBX...');
try {
const response = await fetch(`${apiBase}/api/optimize-3d`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
modelUrl: item.url,
quad_target_faces: 60000,
remeshMethod: 'cleanup'
})
});
if (!response.ok) {
const errData = await response.json().catch(() => ({ error: 'Error al convertir.' }));
throw new Error(errData.error || 'Error interno del servidor.');
}
const data = await response.json();
if (data.fbxUrl) {
// Download FBX
const absoluteFbxUrl = data.fbxUrl.startsWith('http') ? data.fbxUrl : `${apiBase}${data.fbxUrl}`;
const link = document.createElement('a');
link.href = absoluteFbxUrl;
link.download = absoluteFbxUrl.split('/').pop();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Load clean model in viewer
sessionStorage.setItem('pendingModelToLoad', JSON.stringify({
gltfUrl: data.gltfUrl,
downloadUrl: data.glbUrl,
fbxUrl: data.fbxUrl,
detectedCategory: currentModelCategory
}));
window.location.reload();
} else {
throw new Error('No se generó el archivo FBX.');
}
} catch (error) {
console.error(error);
alert(`Error al descargar FBX: ${error.message}`);
} finally {
statusOverlay.classList.remove('active');
}
});
}
}
itemEl.querySelector('.delete-btn').addEventListener('click', async () => {
if (confirm(`¿Estás seguro de que quieres eliminar ${item.name} de forma permanente del disco?`)) {
try {
const savedUser = localStorage.getItem('23dfactory_user') || 'guest';
const response = await fetch(`${apiBase}/api/delete-gallery`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Session-User': savedUser
},
body: JSON.stringify({ name: item.name, type: item.type })
});
if (!response.ok) {
const errData = await response.json().catch(() => ({ error: 'Error al eliminar el archivo.' }));
throw new Error(errData.error || 'Error al eliminar el archivo.');
}
itemEl.remove();
if (typeof loadGallery === 'function') loadGallery();
} catch (e) {
alert('Error al eliminar item: ' + e.message);
}
}
});
galleryList.appendChild(itemEl);
});
lucide.createIcons();
}
// Bind gallery filter toggles
const filterBtns = document.querySelectorAll('.filter-group .toggle-btn');
filterBtns.forEach(btn => {
btn.addEventListener('click', () => {
filterBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentFilter = btn.getAttribute('data-filter');
renderGallery(cachedGalleryItems);
});
});
// Bind navigation click to refresh gallery
if (galleryTabBtn) {
galleryTabBtn.addEventListener('click', refreshGallery);
}
// 2D Image Viewer Overlay Handlers for PC Mode
const btnBackFrom2DViewerPC = document.getElementById('btnBackFrom2DViewerPC');
const btnCreate3DFrom2DViewerPC = document.getElementById('btnCreate3DFrom2DViewerPC');
if (btnBackFrom2DViewerPC) {
btnBackFrom2DViewerPC.addEventListener('click', () => {
const imgViewer = document.getElementById('image-viewer-container');
if (imgViewer) imgViewer.style.display = 'none';
});
}
if (btnCreate3DFrom2DViewerPC) {
btnCreate3DFrom2DViewerPC.addEventListener('click', async () => {
const imgViewer = document.getElementById('image-viewer-container');
if (imgViewer) imgViewer.style.display = 'none';
if (window.current2DViewerItem) {
try {
const item = window.current2DViewerItem;
const fileUrl = `${apiBase}${item.url}`;
const res = await fetch(fileUrl);
const blob = await res.blob();
const file = new File([blob], item.name, { type: blob.type });
handleImageFile(file);
// Activate upload-tab
const uploadTabBtn = document.querySelector('[data-tab="upload-tab"]');
if (uploadTabBtn) uploadTabBtn.click();
} catch (e) {
console.error("Error setting 3D source image:", e);
}
}
});
}
// Bind animation selection changes to toggle between model-viewer and Three.js viewer
if (animationSelect) {
animationSelect.addEventListener('change', () => {
const val = animationSelect.value;
if (!threeScene) {
initThreeViewer(currentGltfUrl);
}
if (val === 'manual') {
if (manualRigPanel) {
manualRigPanel.style.display = 'block';
setupManualBoneSelectOptions();
}
} else {
if (manualRigPanel) manualRigPanel.style.display = 'none';
}
});
}
// Manual Bone Rigging Controller Logic
const boneFriendlyNames = {
hips: "Pelvis / Cadera",
spine: "Columna Vertebral",
neck: "Cuello",
leftShoulder: "Hombro Izquierdo",
leftArm: "Brazo Izquierdo",
leftForearm: "Antebrazo Izquierdo",
leftHand: "Mano Izquierda",
rightShoulder: "Hombro Derecho",
rightArm: "Brazo Derecho",
rightForearm: "Antebrazo Derecho",
rightHand: "Mano Derecha",
leftUpLeg: "Muslo Izquierdo",
leftLeg: "Pierna Izquierda",
leftFoot: "Pie Izquierdo",
rightUpLeg: "Muslo Derecho",
rightLeg: "Pierna Derecha",
rightFoot: "Pie Derecho"
};
const extendedBoneFriendlyNames = {
// Quadruped bone names
Pelvis: "Pelvis / Cadera (Cuadrúpedo)",
Spine: "Columna Vertebral (Cuadrúpedo)",
Neck: "Cuello (Cuadrúpedo)",
Head: "Cabeza (Cuadrúpedo)",
Tail: "Cola",
LeftUpLegRear: "Pata Trasera Sup. Izquierda",
LeftLegRear: "Pata Trasera Inf. Izquierda",
LeftFootRear: "Pie Trasero Izquierdo",
RightUpLegRear: "Pata Trasera Sup. Derecha",
RightLegRear: "Pata Trasera Inf. Derecha",
RightFootRear: "Pie Trasero Derecho",
LeftUpLegFront: "Pata Delantera Sup. Izquierda",
LeftLegFront: "Pata Delantera Inf. Izquierda",
LeftFootFront: "Pie Delantero Izquierdo",
RightUpLegFront: "Pata Delantera Sup. Derecha",
RightLegFront: "Pata Delantera Inf. Derecha",
RightFootFront: "Pie Delantero Derecho",
};
function getFriendlyBoneName(name) {
if (boneFriendlyNames[name]) return boneFriendlyNames[name];
if (extendedBoneFriendlyNames[name]) return extendedBoneFriendlyNames[name];
const lower = name.toLowerCase();
// Friendly translation heuristics for custom rig systems (like spider, etc.)
if (lower.includes("hips") || lower.includes("pelvis")) return `Pelvis / Cadera (${name})`;
if (lower.includes("spine")) return `Columna Vertebral (${name})`;
if (lower.includes("neck")) return `Cuello (${name})`;
if (lower.includes("head")) return `Cabeza (${name})`;
if (lower.includes("tail")) return `Cola (${name})`;
if (lower.startsWith("left") || lower.includes("_l") || lower.includes(".l") || lower.includes("l_") || lower.includes("left")) {
if (lower.includes("shoulder")) return `Hombro Izquierdo (${name})`;
if (lower.includes("arm") || lower.includes("forearm")) return `Brazo/Antebrazo Izquierdo (${name})`;
if (lower.includes("hand") || lower.includes("wrist")) return `Mano/Muñeca Izquierda (${name})`;
if (lower.includes("leg") || lower.includes("thigh")) return `Pata/Pierna Izquierda (${name})`;
if (lower.includes("foot") || lower.includes("toe")) return `Pie Izquierdo (${name})`;
return `${name} (Izquierda)`;
}
if (lower.startsWith("right") || lower.includes("_r") || lower.includes(".r") || lower.includes("r_") || lower.includes("right")) {
if (lower.includes("shoulder")) return `Hombro Derecho (${name})`;
if (lower.includes("arm") || lower.includes("forearm")) return `Brazo/Antebrazo Derecho (${name})`;
if (lower.includes("hand") || lower.includes("wrist")) return `Mano/Muñeca Derecha (${name})`;
if (lower.includes("leg") || lower.includes("thigh")) return `Pata/Pierna Derecha (${name})`;
if (lower.includes("foot") || lower.includes("toe")) return `Pie Derecho (${name})`;
return `${name} (Derecha)`;
}
return name;
}
function setupManualBoneSelectOptions() {
if (!manualBoneSelect) return;
manualBoneSelect.innerHTML = '';
const allBones = [];
if (threeModel) {
threeModel.traverse(c => {
if (c.isBone) allBones.push(c);
});
}
if (allBones.length > 0) {
// Sort bones alphabetically by name
allBones.sort((a, b) => a.name.localeCompare(b.name)).forEach(bone => {
const opt = document.createElement('option');
opt.value = bone.name;
opt.textContent = getFriendlyBoneName(bone.name);
manualBoneSelect.appendChild(opt);
});
} else {
// Fallback to static roles if no model loaded
Object.keys(boneFriendlyNames).forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = boneFriendlyNames[key];
manualBoneSelect.appendChild(opt);
});
}
// Populate the 3D bone mapping selector with all bones in GLB
if (manualBoneMappingSelect && threeModel) {
manualBoneMappingSelect.innerHTML = '';
const noneOpt = document.createElement('option');
noneOpt.value = '';
noneOpt.textContent = '(Sin Asignar)';
manualBoneMappingSelect.appendChild(noneOpt);
const allBones = [];
threeModel.traverse(c => {
if (c.isBone) allBones.push(c.name);
});
allBones.sort().forEach(bName => {
const opt = document.createElement('option');
opt.value = bName;
opt.textContent = bName;
manualBoneMappingSelect.appendChild(opt);
});
}
// Trigger initial load of sliders
loadSelectedBoneRotations();
}
function loadSelectedBoneRotations() {
const bone = selectedBone;
updateWeightPaintSelectedBoneDisplay();
if (!bone) {
// Reset sliders if bone not assigned
boneRotX.value = 0;
boneRotY.value = 0;
boneRotZ.value = 0;
boneRotXVal.textContent = '0°';
boneRotYVal.textContent = '0°';
boneRotZVal.textContent = '0°';
if (manualBoneNameInput) manualBoneNameInput.value = '';
if (applyBoneRenameBtn) applyBoneRenameBtn.style.display = 'none';
return;
}
// Convert radians to degrees
const degX = Math.round(THREE.MathUtils.radToDeg(bone.rotation.x));
const degY = Math.round(THREE.MathUtils.radToDeg(bone.rotation.y));
const degZ = Math.round(THREE.MathUtils.radToDeg(bone.rotation.z));
boneRotX.value = degX;
boneRotY.value = degY;
boneRotZ.value = degZ;
boneRotXVal.textContent = degX + '°';
boneRotYVal.textContent = degY + '°';
boneRotZVal.textContent = degZ + '°';
}
// Renaming bone logic
if (manualBoneNameInput && applyBoneRenameBtn) {
manualBoneNameInput.addEventListener('input', () => {
if (selectedBone) {
const currentVal = manualBoneNameInput.value.trim();
if (currentVal && currentVal !== selectedBone.name) {
applyBoneRenameBtn.style.display = 'inline-block';
} else {
applyBoneRenameBtn.style.display = 'none';
}
}
});
applyBoneRenameBtn.addEventListener('click', () => {
if (selectedBone) {
const oldName = selectedBone.name;
const newName = manualBoneNameInput.value.trim();
if (!newName) {
showCustomAlert("El nombre del hueso no puede estar vacío.", "Error al renombrar");
return;
}
// Rename bone in Three.js
selectedBone.name = newName;
// Update threeBones mapping if it exists
if (threeBones[oldName]) {
threeBones[newName] = selectedBone;
delete threeBones[oldName];
}
applyBoneRenameBtn.style.display = 'none';
showCustomToast(`Hueso renombrado de ${oldName} a ${newName}`);
}
});
}
function updateBoneRotationFromSliders() {
const bone = selectedBone;
if (!bone) return;
const degX = parseFloat(boneRotX.value);
const degY = parseFloat(boneRotY.value);
const degZ = parseFloat(boneRotZ.value);
bone.rotation.x = THREE.MathUtils.degToRad(degX);
bone.rotation.y = THREE.MathUtils.degToRad(degY);
bone.rotation.z = THREE.MathUtils.degToRad(degZ);
boneRotXVal.textContent = degX + '°';
boneRotYVal.textContent = degY + '°';
boneRotZVal.textContent = degZ + '°';
}
if (boneRotX) boneRotX.addEventListener('input', updateBoneRotationFromSliders);
if (boneRotY) boneRotY.addEventListener('input', updateBoneRotationFromSliders);
if (boneRotZ) boneRotZ.addEventListener('input', updateBoneRotationFromSliders);
if (resetBoneRotBtn) {
resetBoneRotBtn.addEventListener('click', () => {
const bone = selectedBone;
if (!bone) return;
bone.rotation.set(0, 0, 0);
boneRotX.value = 0;
boneRotY.value = 0;
boneRotZ.value = 0;
boneRotXVal.textContent = '0°';
boneRotYVal.textContent = '0°';
boneRotZVal.textContent = '0°';
});
}
// Handle Skeleton Ray-X visualizer helper logic
function setSkeletonHelperActive(active) {
showSkeletonHelper = active;
if (showSkeletonHelper) {
if (threeModel && threeScene) {
if (threeSkeletonHelper) {
threeScene.remove(threeSkeletonHelper);
}
threeSkeletonHelper = new THREE.SkeletonHelper(threeModel);
threeScene.add(threeSkeletonHelper);
// Clear any existing spheres first
jointSpheres.forEach(item => {
try { threeScene.remove(item.mesh); } catch (e) { }
});
jointSpheres = [];
// Create glowing joint spheres at each bone
threeModel.traverse(c => {
if (c.isBone) {
// Create a larger, glowing cyan sphere for easy click and visibility
const sphereGeo = new THREE.SphereGeometry(0.015, 16, 16);
const sphereMat = new THREE.MeshBasicMaterial({
color: 0x00ffcc,
depthTest: false,
transparent: true,
opacity: 0.85
});
const sphere = new THREE.Mesh(sphereGeo, sphereMat);
sphere.renderOrder = 999; // draw on top of everything
threeScene.add(sphere);
jointSpheres.push({ bone: c, mesh: sphere });
}
});
if (toggleBonesHelperBtn) {
toggleBonesHelperBtn.classList.add('active');
const span = toggleBonesHelperBtn.querySelector('span');
if (span) span.textContent = "Ocultar Esqueleto (Rayos X)";
}
if (toggleBonesBtn) {
toggleBonesBtn.classList.add('active');
}
showCustomToast("Haz clic en las esferas del cuerpo para seleccionar el hueso");
}
} else {
if (threeSkeletonHelper && threeScene) {
threeScene.remove(threeSkeletonHelper);
}
threeSkeletonHelper = null;
jointSpheres.forEach(item => {
if (threeScene) {
try { threeScene.remove(item.mesh); } catch (e) { }
}
});
jointSpheres = [];
if (toggleBonesHelperBtn) {
toggleBonesHelperBtn.classList.remove('active');
const span = toggleBonesHelperBtn.querySelector('span');
if (span) span.textContent = "Ver Esqueleto (Rayos X)";
}
if (toggleBonesBtn) {
toggleBonesBtn.classList.remove('active');
}
}
}
if (toggleBonesHelperBtn) {
toggleBonesHelperBtn.addEventListener('click', () => {
if (toggleBonesHelperBtn.classList.contains('disabled-btn')) {
showCustomToast("Este modelo no posee huesos. Genéralos usando Rigging Automático.");
return;
}
setSkeletonHelperActive(!showSkeletonHelper);
});
}
if (toggleBonesBtn) {
toggleBonesBtn.addEventListener('click', () => {
if (toggleBonesBtn.classList.contains('disabled-btn')) {
showCustomToast("Este modelo no posee huesos. Genéralos usando Rigging Automático.");
return;
}
setSkeletonHelperActive(!showSkeletonHelper);
});
}
// Authentication & User Session Management
const authOverlay = document.getElementById('authOverlay');
const loginForm = document.getElementById('loginForm');
const registerForm = document.getElementById('registerForm');
const tabLogin = document.getElementById('tabLogin');
const tabRegister = document.getElementById('tabRegister');
const logoutBtn = document.getElementById('logoutBtn');
const userHeaderActions = document.getElementById('userHeaderActions');
const headerUsernameText = document.getElementById('headerUsernameText');
// 3D Floating Shapes Background for Auth Screen
function initAuthBackground3D() {
const canvas = document.getElementById('authBgCanvas');
if (!canvas) return;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.z = 25;
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.75);
scene.add(ambientLight);
const pointLight1 = new THREE.PointLight(0x9c27b0, 4.5, 60); // Purple light
pointLight1.position.set(15, 15, 12);
scene.add(pointLight1);
const pointLight2 = new THREE.PointLight(0x00bcd4, 4.5, 60); // Cyan light
pointLight2.position.set(-15, -15, 12);
scene.add(pointLight2);
const pointLight3 = new THREE.PointLight(0xffffff, 2.5, 45); // Center bright white light
pointLight3.position.set(0, 0, 15);
scene.add(pointLight3);
// Floating geometries
const geometries = [
new THREE.TorusKnotGeometry(1.5, 0.45, 100, 16),
new THREE.DodecahedronGeometry(1.8),
new THREE.OctahedronGeometry(1.5),
new THREE.IcosahedronGeometry(1.6, 1),
new THREE.TorusGeometry(1.6, 0.35, 16, 100),
new THREE.ConeGeometry(1.2, 2.2, 4),
new THREE.TetrahedronGeometry(1.7),
new THREE.CylinderGeometry(0.8, 0.8, 2.2, 6),
new THREE.BoxGeometry(1.5, 1.5, 1.5),
new THREE.RingGeometry(0.5, 1.6, 32),
new THREE.SphereGeometry(1.4, 16, 16),
new THREE.OctahedronGeometry(1.6, 1)
];
const meshes = [];
const colors = [0x9c27b0, 0x00bcd4, 0xe040fb, 0x00e5ff, 0xff4081, 0xffeb3b, 0x4caf50];
geometries.forEach((geom, idx) => {
const material = new THREE.MeshPhysicalMaterial({
color: colors[idx % colors.length],
roughness: 0.15,
metalness: 0.85,
clearcoat: 1.0,
clearcoatRoughness: 0.1,
wireframe: idx % 2 === 0
});
const mesh = new THREE.Mesh(geom, material);
// Random positions
mesh.position.set(
(Math.random() - 0.5) * 35,
(Math.random() - 0.5) * 20,
(Math.random() - 0.5) * 10
);
// Rotation speeds
mesh.userData = {
rotX: (Math.random() - 0.5) * 0.008,
rotY: (Math.random() - 0.5) * 0.008,
floatSpeed: 0.001 + Math.random() * 0.002,
floatOffset: Math.random() * Math.PI * 2,
initialY: mesh.position.y
};
scene.add(mesh);
meshes.push(mesh);
});
// Particle starfield
const particlesCount = 250;
const particlesGeometry = new THREE.BufferGeometry();
const positions = new Float32Array(particlesCount * 3);
for (let i = 0; i < particlesCount * 3; i += 3) {
positions[i] = (Math.random() - 0.5) * 60;
positions[i + 1] = (Math.random() - 0.5) * 40;
positions[i + 2] = (Math.random() - 0.5) * 20;
}
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const particlesMaterial = new THREE.PointsMaterial({
size: 0.12,
color: 0xffffff,
transparent: true,
opacity: 0.5
});
const starfield = new THREE.Points(particlesGeometry, particlesMaterial);
scene.add(starfield);
// Mouse Parallax movement
let mouseX = 0;
let mouseY = 0;
let targetX = 0;
let targetY = 0;
window.addEventListener('mousemove', (e) => {
mouseX = (e.clientX - window.innerWidth / 2) / 150;
mouseY = (e.clientY - window.innerHeight / 2) / 150;
});
// Resize handler
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// Animation Loop
let clock = new THREE.Clock();
function animate() {
const overlayActive = document.getElementById('authOverlay')?.classList.contains('active');
if (!overlayActive) {
requestAnimationFrame(animate);
return;
}
const elapsedTime = clock.getElapsedTime();
// Parallax camera lerp
targetX += (mouseX - targetX) * 0.05;
targetY += (mouseY - targetY) * 0.05;
camera.position.x = targetX;
camera.position.y = -targetY;
camera.lookAt(scene.position);
// Rotate and float meshes
meshes.forEach(mesh => {
mesh.rotation.x += mesh.userData.rotX;
mesh.rotation.y += mesh.userData.rotY;
mesh.position.y = mesh.userData.initialY + Math.sin(elapsedTime * 1.5 + mesh.userData.floatOffset) * 0.4;
});
// Rotate background starfield slightly
starfield.rotation.y = elapsedTime * 0.015;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
animate();
}
// Initialize Auth 3D background immediately
initAuthBackground3D();
// User Credits State & Display Snycronization
let userCredits = 0;
function updateUserCreditsDisplay(credits) {
userCredits = credits;
const creditsText = document.getElementById('headerCreditsText');
if (creditsText) creditsText.textContent = credits;
}
function updateUserHeaderAvatar(username, nick, avatar) {
const dropdownUsernameText = document.getElementById('dropdownUsernameText');
if (dropdownUsernameText) dropdownUsernameText.textContent = nick || username;
if (headerUsernameText) headerUsernameText.textContent = nick || username;
const avatarImg = document.getElementById('userHeaderAvatarImg');
const avatarInitial = document.getElementById('userHeaderAvatarInitial');
if (avatar && (avatar.startsWith('data:image') || avatar.startsWith('http') || avatar.startsWith('/'))) {
if (avatarImg) {
avatarImg.src = avatar;
avatarImg.style.display = 'block';
}
if (avatarInitial) avatarInitial.style.display = 'none';
} else {
if (avatarImg) avatarImg.style.display = 'none';
if (avatarInitial) {
avatarInitial.textContent = (nick || username || 'P').charAt(0).toUpperCase();
avatarInitial.style.display = 'block';
}
}
}
// Check Session on Start
async function checkAuthSession() {
const mainAppContainer = document.querySelector('.app-container');
try {
let savedUser = localStorage.getItem('session_user') || '';
let supabaseData = null;
if (window.supabase) {
try {
const supabaseUrl = 'https://hskkswijqervbpibwvfh.supabase.co';
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imhza2tzd2lqcWVydmJwaWJ3dmZoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODU4MzMyNDgsImV4cCI6MjEwMTQwOTI0OH0.MTHzcMyVwtq58QNHz5S-J3L6U2G-lySbyl3enA8buWU';
const supabaseClient = window.supabase.createClient(supabaseUrl, supabaseAnonKey);
const { data: { session } } = await supabaseClient.auth.getSession();
const user = session?.user;
if (user) {
const { data: profile } = await supabaseClient
.from('profiles')
.select('username, nick, full_name, avatar, avatar_url, credits')
.eq('id', user.id)
.maybeSingle();
if (profile) {
if (!profile.avatar && profile.avatar_url) profile.avatar = profile.avatar_url;
supabaseData = profile;
if (profile.username) savedUser = profile.username;
}
}
} catch (sbCheckErr) {
console.log('[Supabase Session Check Notice]', sbCheckErr);
}
}
const response = await fetch(`${apiBase}/api/me`, {
headers: { 'X-Session-User': savedUser }
});
let data = {};
if (response.ok) {
data = await response.json();
}
const finalUsername = (data.username && data.username !== 'guest') ? data.username : (supabaseData?.username || savedUser);
const finalNick = supabaseData?.nick || (data.nick && data.nick !== data.username ? data.nick : null) || finalUsername;
const finalAvatar = supabaseData?.avatar || supabaseData?.avatar_url || data.avatar || '';
const finalCredits = (supabaseData?.credits !== undefined && supabaseData?.credits !== null) ? supabaseData.credits : (data.credits || 20);
if (finalUsername && finalUsername !== 'guest') {
localStorage.setItem('session_user', finalUsername);
document.cookie = `session_user=${finalUsername}; path=/; max-age=2592000; SameSite=Lax`;
if (authOverlay) authOverlay.classList.remove('active');
if (mainAppContainer) mainAppContainer.style.display = '';
if (userHeaderActions) userHeaderActions.style.display = 'flex';
updateUserHeaderAvatar(finalUsername, finalNick, finalAvatar);
updateUserCreditsDisplay(finalCredits);
refreshGallery();
checkMobileRedirect();
return;
}
} catch (e) {
console.error("Error verifying session:", e);
}
// Show login overlay if not logged in
if (authOverlay) authOverlay.classList.add('active');
if (mainAppContainer) mainAppContainer.style.display = 'none';
if (userHeaderActions) userHeaderActions.style.display = 'none';
}
function fetchUserInfo() {
return checkAuthSession();
}
// Bind Authentication Event Listeners
const loginErrorMsg = document.getElementById('loginErrorMsg');
const registerErrorMsg = document.getElementById('registerErrorMsg');
function showRegisterError(msg) {
if (registerErrorMsg) {
registerErrorMsg.textContent = msg;
registerErrorMsg.style.display = 'block';
} else {
showCustomAlert(msg, 'Error de Registro');
}
}
function animateAuthBoxSwitch(showForm, hideForm) {
const authBox = document.querySelector('.auth-box');
if (!authBox) {
hideForm.classList.remove('active');
showForm.classList.add('active');
return;
}
const startHeight = authBox.offsetHeight;
authBox.style.height = `${startHeight}px`;
hideForm.classList.remove('active');
showForm.classList.add('active');
// Calculate target height
authBox.style.height = 'auto';
const targetHeight = authBox.offsetHeight;
authBox.style.height = `${startHeight}px`;
// Force browser reflow to register height transition start
void authBox.offsetHeight;
authBox.style.height = `${targetHeight}px`;
setTimeout(() => {
authBox.style.height = 'auto';
}, 360);
}
if (tabLogin && tabRegister && loginForm && registerForm) {
tabLogin.addEventListener('click', () => {
tabLogin.classList.add('active');
tabRegister.classList.remove('active');
animateAuthBoxSwitch(loginForm, registerForm);
if (loginErrorMsg) loginErrorMsg.style.display = 'none';
if (registerErrorMsg) registerErrorMsg.style.display = 'none';
});
tabRegister.addEventListener('click', () => {
tabRegister.classList.add('active');
tabLogin.classList.remove('active');
animateAuthBoxSwitch(registerForm, loginForm);
if (loginErrorMsg) loginErrorMsg.style.display = 'none';
if (registerErrorMsg) registerErrorMsg.style.display = 'none';
});
}
if (loginForm) {
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (loginErrorMsg) loginErrorMsg.style.display = 'none';
const username = document.getElementById('loginUser').value.trim();
const password = document.getElementById('loginPass').value;
try {
const supabaseUrl = 'https://hskkswijqervbpibwvfh.supabase.co';
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imhza2tzd2lqcWVydmJwaWJ3dmZoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODU4MzMyNDgsImV4cCI6MjEwMTQwOTI0OH0.MTHzcMyVwtq58QNHz5S-J3L6U2G-lySbyl3enA8buWU';
let supabaseClient = null;
if (window.supabase) {
supabaseClient = window.supabase.createClient(supabaseUrl, supabaseAnonKey);
}
let userData = { username: username, credits: 20 };
if (supabaseClient) {
const cleanUser = username.toLowerCase().replace(/[^a-z0-9]/g, '');
const email = username.includes('@') ? username : `${cleanUser}@gmail.com`;
const { data, error } = await supabaseClient.auth.signInWithPassword({
email: email,
password: password
});
if (error) throw error;
// Guardar token de sesión en localStorage
if (data.session) {
localStorage.setItem('sb_token', data.session.access_token);
}
// Obtener perfil completo
const { data: profile } = await supabaseClient
.from('profiles')
.select('username, nick, full_name, avatar, avatar_url, credits')
.eq('id', data.user.id)
.single();
if (profile) {
userData.username = profile.username || username;
userData.nick = profile.nick || profile.username || username;
userData.full_name = profile.full_name || '';
userData.avatar = profile.avatar || profile.avatar_url || '';
userData.credits = profile.credits !== undefined ? profile.credits : 20;
}
} else {
const response = await fetch(`${apiBase}/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Credenciales inválidas.');
}
userData = await response.json();
}
if (userData && userData.username) {
localStorage.setItem('session_user', userData.username);
document.cookie = `session_user=${userData.username}; path=/; max-age=2592000; SameSite=Lax`;
}
showCustomToast(`¡Bienvenido de nuevo, ${userData.username}!`);
// Hide overlay, update header, load gallery
if (authOverlay) authOverlay.classList.remove('active');
const mainAppContainer = document.querySelector('.app-container');
if (mainAppContainer) mainAppContainer.style.display = '';
if (userHeaderActions) userHeaderActions.style.display = 'flex';
updateUserHeaderAvatar(userData.username, userData.nick, userData.avatar);
updateUserCreditsDisplay(userData.credits || 0);
// Clear inputs
document.getElementById('loginUser').value = '';
document.getElementById('loginPass').value = '';
refreshGallery();
checkMobileRedirect();
} catch (err) {
if (loginErrorMsg) {
loginErrorMsg.textContent = err.message;
loginErrorMsg.style.display = 'block';
} else {
showCustomAlert(err.message, 'Error de Inicio de Sesión');
}
}
});
}
if (registerForm) {
registerForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (registerErrorMsg) registerErrorMsg.style.display = 'none';
const username = document.getElementById('registerUser').value.trim();
const password = document.getElementById('registerPass').value;
const confirmPass = document.getElementById('registerPassConfirm').value;
if (password !== confirmPass) {
showRegisterError('Las contraseñas no coinciden.');
return;
}
if (password.length < 4) {
showRegisterError('La contraseña debe tener al menos 4 caracteres.');
return;
}
try {
// Inicializar cliente Supabase si está disponible
const supabaseUrl = 'https://hskkswijqervbpibwvfh.supabase.co';
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imhza2tzd2lqcWVydmJwaWJ3dmZoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODU4MzMyNDgsImV4cCI6MjEwMTQwOTI0OH0.MTHzcMyVwtq58QNHz5S-J3L6U2G-lySbyl3enA8buWU';
let supabaseClient = null;
if (window.supabase) {
supabaseClient = window.supabase.createClient(supabaseUrl, supabaseAnonKey);
}
if (supabaseClient) {
const cleanUser = username.toLowerCase().replace(/[^a-z0-9]/g, '');
const email = username.includes('@') ? username : `${cleanUser}@gmail.com`;
const { data, error } = await supabaseClient.auth.signUp({
email: email,
password: password,
options: {
data: { username: username }
}
});
if (error) throw error;
} else {
const response = await fetch(`${apiBase}/api/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'No se pudo registrar el usuario.');
}
}
showCustomAlert('¡Registro exitoso! Ahora puedes iniciar sesión con tu cuenta.', 'Registro Completado', false, 'success');
// Switch to login tab
if (tabLogin) tabLogin.click();
// Clear inputs
document.getElementById('registerUser').value = '';
document.getElementById('registerPass').value = '';
document.getElementById('registerPassConfirm').value = '';
} catch (err) {
showRegisterError(err.message);
}
});
}
if (logoutBtn) {
logoutBtn.addEventListener('click', async () => {
try {
localStorage.removeItem('session_user');
document.cookie = `session_user=; path=/; max-age=0`;
await fetch(`${apiBase}/api/logout`, { method: 'POST' });
showCustomToast('Has cerrado sesión correctamente.');
// Show overlay, update header, reset model visualizer
if (authOverlay) authOverlay.classList.add('active');
const mainAppContainer = document.querySelector('.app-container');
if (mainAppContainer) mainAppContainer.style.display = 'none';
if (userHeaderActions) userHeaderActions.style.display = 'none';
// Clear current canvas model to protect session visual separation
if (threeModel && threeScene) {
threeScene.remove(threeModel);
threeModel = null;
}
if (threeSkeletonHelper && threeScene) {
threeScene.remove(threeSkeletonHelper);
threeSkeletonHelper = null;
}
if (jointSpheres) {
jointSpheres.forEach(item => {
if (threeScene) {
try { threeScene.remove(item.mesh); } catch (e) {}
}
});
jointSpheres = [];
}
// Reset gallery items list
const galleryList = document.getElementById('galleryList');
if (galleryList) galleryList.innerHTML = '';
} catch (e) {
console.error("Logout failed:", e);
}
});
}
// Bind Credits Store Modal
const creditsModal = document.getElementById('creditsModal');
const headerCreditsBtn = document.getElementById('headerCreditsBtn');
const closeCreditsModalBtn = document.getElementById('closeCreditsModalBtn');
function openCreditsModal(alertMessage = '') {
if (creditsModal) {
creditsModal.classList.add('active');
if (alertMessage) {
showCustomToast(alertMessage, 'warning');
}
}
}
if (headerCreditsBtn) {
headerCreditsBtn.addEventListener('click', () => openCreditsModal());
}
if (closeCreditsModalBtn) {
closeCreditsModalBtn.addEventListener('click', () => {
if (creditsModal) creditsModal.classList.remove('active');
});
}
if (creditsModal) {
creditsModal.addEventListener('click', (e) => {
if (e.target === creditsModal) {
creditsModal.classList.remove('active');
}
});
}
// User Profile Menu Dropdown Toggle
const userMenuBtn = document.getElementById('userMenuBtn');
const userDropdownMenu = document.getElementById('userDropdownMenu');
if (userMenuBtn && userDropdownMenu) {
userMenuBtn.addEventListener('click', (e) => {
e.stopPropagation();
userDropdownMenu.classList.toggle('active');
});
document.addEventListener('click', (e) => {
if (!userMenuBtn.contains(e.target) && !userDropdownMenu.contains(e.target)) {
userDropdownMenu.classList.remove('active');
}
});
}
// Bind Profile and Privacy Modals in Header Menu
const profileMenuBtn = document.getElementById('profileMenuBtn');
const privacyMenuBtn = document.getElementById('privacyMenuBtn');
const profileModal = document.getElementById('profileModal');
const privacyModal = document.getElementById('privacyModal');
const closeProfileModalBtn = document.getElementById('closeProfileModalBtn');
const cancelProfileBtn = document.getElementById('cancelProfileBtn');
const closePrivacyModalBtn = document.getElementById('closePrivacyModalBtn');
const cancelPrivacyBtn = document.getElementById('cancelPrivacyBtn');
const profileForm = document.getElementById('profileForm');
const privacyForm = document.getElementById('privacyForm');
if (profileMenuBtn) {
profileMenuBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (userDropdownMenu) userDropdownMenu.classList.remove('active');
const savedUser = localStorage.getItem('session_user') || '';
let profileData = { username: savedUser, nick: savedUser, full_name: '', avatar: '' };
if (window.supabase) {
try {
const supabaseUrl = 'https://hskkswijqervbpibwvfh.supabase.co';
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imhza2tzd2lqcWVydmJwaWJ3dmZoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODU4MzMyNDgsImV4cCI6MjEwMTQwOTI0OH0.MTHzcMyVwtq58QNHz5S-J3L6U2G-lySbyl3enA8buWU';
const supabaseClient = window.supabase.createClient(supabaseUrl, supabaseAnonKey);
const { data: { session } } = await supabaseClient.auth.getSession();
const user = session?.user;
if (user) {
const { data: sbProfile } = await supabaseClient
.from('profiles')
.select('username, nick, full_name, avatar, avatar_url')
.eq('id', user.id)
.maybeSingle();
if (sbProfile) {
if (sbProfile.nick) profileData.nick = sbProfile.nick;
if (sbProfile.full_name) profileData.full_name = sbProfile.full_name;
if (sbProfile.avatar || sbProfile.avatar_url) profileData.avatar = sbProfile.avatar || sbProfile.avatar_url;
}
}
} catch (sbErr) {
console.log('[Supabase Profile Menu Fetch Notice]', sbErr);
}
}
try {
const res = await fetch(`${apiBase}/api/me`, {
headers: { 'X-Session-User': savedUser }
});
if (res.ok) {
const data = await res.json();
if (data.nick && data.nick !== data.username) profileData.nick = data.nick;
if (data.full_name) profileData.full_name = data.full_name;
if (data.avatar && !profileData.avatar) profileData.avatar = data.avatar;
}
} catch (err) {}
const profileNickInput = document.getElementById('profileNickInput');
const profileFullNameInput = document.getElementById('profileFullNameInput');
const profileAvatarPreview = document.getElementById('profileAvatarPreview');
if (profileNickInput) profileNickInput.value = profileData.nick || savedUser;
if (profileFullNameInput) profileFullNameInput.value = profileData.full_name || '';
if (profileAvatarPreview && profileData.avatar) profileAvatarPreview.src = profileData.avatar;
if (profileModal) profileModal.classList.add('active');
});
}
if (privacyMenuBtn) {
privacyMenuBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (userDropdownMenu) userDropdownMenu.classList.remove('active');
const savedUser = localStorage.getItem('session_user') || '';
let emailVal = '';
if (window.supabase) {
try {
const supabaseUrl = 'https://hskkswijqervbpibwvfh.supabase.co';
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imhza2tzd2lqcWVydmJwaWJ3dmZoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODU4MzMyNDgsImV4cCI6MjEwMTQwOTI0OH0.MTHzcMyVwtq58QNHz5S-J3L6U2G-lySbyl3enA8buWU';
const supabaseClient = window.supabase.createClient(supabaseUrl, supabaseAnonKey);
const { data: { session } } = await supabaseClient.auth.getSession();
if (session?.user?.email) emailVal = session.user.email;
} catch (sbErr) {}
}
try {
const res = await fetch(`${apiBase}/api/me`, {
headers: { 'X-Session-User': savedUser }
});
if (res.ok) {
const data = await res.json();
if (data.email) emailVal = data.email;
}
} catch (err) {}
const privacyEmailInput = document.getElementById('privacyEmailInput');
if (privacyEmailInput) privacyEmailInput.value = emailVal;
if (privacyModal) privacyModal.classList.add('active');
});
}
const profileAvatarInput = document.getElementById('profileAvatarInput');
const profileAvatarPreview = document.getElementById('profileAvatarPreview');
if (profileAvatarInput && profileAvatarPreview) {
profileAvatarInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (evt) => {
profileAvatarPreview.src = evt.target.result;
};
reader.readAsDataURL(file);
}
});
}
[closeProfileModalBtn, cancelProfileBtn].forEach(btn => {
if (btn) btn.addEventListener('click', () => profileModal?.classList.remove('active'));
});
[closePrivacyModalBtn, cancelPrivacyBtn].forEach(btn => {
if (btn) btn.addEventListener('click', () => privacyModal?.classList.remove('active'));
});
// Profile Form Submit
if (profileForm) {
profileForm.addEventListener('submit', async (e) => {
e.preventDefault();
const nick = document.getElementById('profileNickInput')?.value.trim();
const full_name = document.getElementById('profileFullNameInput')?.value.trim();
const avatar = document.getElementById('profileAvatarPreview')?.src;
const savedUser = localStorage.getItem('session_user') || '';
try {
if (window.supabase) {
try {
const supabaseUrl = 'https://hskkswijqervbpibwvfh.supabase.co';
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imhza2tzd2lqcWVydmJwaWJ3dmZoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODU4MzMyNDgsImV4cCI6MjEwMTQwOTI0OH0.MTHzcMyVwtq58QNHz5S-J3L6U2G-lySbyl3enA8buWU';
const supabaseClient = window.supabase.createClient(supabaseUrl, supabaseAnonKey);
const { data: { session } } = await supabaseClient.auth.getSession();
const user = session?.user;
if (user) {
await supabaseClient.from('profiles').upsert({
id: user.id,
username: savedUser,
nick: nick,
full_name: full_name,
avatar: avatar
});
}
} catch (sbErr) {
console.log('[Supabase Profile Update Notice]', sbErr);
}
}
const res = await fetch(`${apiBase}/api/update-profile`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Session-User': savedUser
},
body: JSON.stringify({ nick, full_name, avatar })
});
if (res.ok) {
showCustomToast('¡Perfil actualizado con éxito!', 'success');
if (profileModal) profileModal.classList.remove('active');
updateUserHeaderAvatar(savedUser, nick, avatar);
fetchUserInfo();
} else {
const errData = await res.json().catch(() => ({}));
showCustomToast(errData.error || 'Error al actualizar el perfil.', 'error');
}
} catch (err) {
showCustomToast('Error de conexión al actualizar perfil.', 'error');
}
});
}
// Privacy Form Submit
if (privacyForm) {
privacyForm.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('privacyEmailInput')?.value.trim();
const current_password = document.getElementById('privacyCurrentPasswordInput')?.value;
const new_password = document.getElementById('privacyNewPasswordInput')?.value;
const confirm_password = document.getElementById('privacyConfirmPasswordInput')?.value;
const savedUser = localStorage.getItem('session_user') || '';
if (new_password && new_password !== confirm_password) {
showCustomToast('Las contraseñas no coinciden.', 'warning');
return;
}
try {
const res = await fetch(`${apiBase}/api/update-privacy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Session-User': savedUser
},
body: JSON.stringify({ email, current_password, new_password })
});
const data = await res.json();
if (res.ok && data.success) {
showCustomToast('¡Configuración de privacidad guardada!', 'success');
if (privacyModal) privacyModal.classList.remove('active');
} else {
showCustomToast(data.error || 'Error al guardar privacidad.', 'error');
}
} catch (err) {
showCustomToast('Error de conexión al guardar privacidad.', 'error');
}
});
}
// Mobile QR Viewer Modal handlers
if (viewOnMobileBtn) {
viewOnMobileBtn.addEventListener('click', () => {
if (!activeGltfUrl) {
showCustomToast('No hay ningún modelo 3D activo para visualizar en el celular.', 'warning');
return;
}
// Resolve absolute GLTF URL
const absoluteModelUrl = activeGltfUrl.startsWith('http') ? activeGltfUrl : `${window.location.origin}${activeGltfUrl}`;
const viewerUrl = `${window.location.origin}/viewer.html?v=123&model=${encodeURIComponent(absoluteModelUrl)}`;
// Generate QR code image url using public API
const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(viewerUrl)}`;
mobileQrImage.src = qrApiUrl;
mobileQrLink.href = viewerUrl;
mobileQrLink.textContent = viewerUrl;
mobileQrModal.classList.add('active');
});
}
if (closeMobileQrModalBtn) {
closeMobileQrModalBtn.addEventListener('click', () => {
mobileQrModal.classList.remove('active');
});
}
if (mobileQrModal) {
mobileQrModal.addEventListener('click', (e) => {
if (e.target === mobileQrModal) {
mobileQrModal.classList.remove('active');
}
});
}
document.querySelectorAll('.buy-pack-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const amount = btn.getAttribute('data-amount') || '25';
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Redirigiendo a Stripe...';
try {
const response = await fetch(`${apiBase}/api/create-checkout-session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pack_type: amount })
});
if (response.ok) {
const resData = await response.json();
if (resData.url) {
window.location.href = resData.url;
} else {
throw new Error('La respuesta de Stripe no contiene la URL de Checkout.');
}
} else {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.error || 'Error al iniciar Checkout de Stripe.');
}
} catch (err) {
showCustomAlert(err.message, 'Error de Pago');
btn.disabled = false;
btn.textContent = originalText;
}
});
});
// Dynamic 3D Credit Cost Calculator
function update3dCreditCostDisplay() {
if (!generate3dBtn) return;
let cost = 5; // Base cost
if (resolutionSelect && resolutionSelect.value === '1536') {
cost += 2; // Extra for 1536 High Resolution
}
if (textureSizeSelect && textureSizeSelect.value === '4096') {
cost += 3; // Extra for 4096 4K texture
}
const span = generate3dBtn.querySelector('span');
if (span) {
span.textContent = `Construir Malla 3D (Cuesta ${cost} créditos)`;
}
}
if (resolutionSelect) resolutionSelect.addEventListener('change', update3dCreditCostDisplay);
if (textureSizeSelect) textureSizeSelect.addEventListener('change', update3dCreditCostDisplay);
// Call initially
update3dCreditCostDisplay();
// Initial session check
checkAuthSession();
// Check for payment success callback parameter in URL query
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('payment') === 'success') {
showCustomToast('¡Pago completado! Tus nuevos créditos se acreditarán en segundos.', 'success');
const cleanUrl = window.location.protocol + "//" + window.location.host + window.location.pathname;
window.history.replaceState({ path: cleanUrl }, '', cleanUrl);
}
// Check if there is a pending model to load after page reload (F5)
const pendingModel = sessionStorage.getItem('pendingModelToLoad');
if (pendingModel) {
try {
const { gltfUrl, downloadUrl, fbxUrl, detectedCategory } = JSON.parse(pendingModel);
sessionStorage.removeItem('pendingModelToLoad');
// Load model directly using the stored category
load3DModel(gltfUrl, downloadUrl, fbxUrl, detectedCategory);
} catch (e) {
console.error('Error loading pending model after reload:', e);
}
}
// Manual Mesh Clean / Optimization Panel handlers
const manualQuadFacesInput = document.getElementById('manualQuadFacesInput');
const manualQuadFacesVal = document.getElementById('manualQuadFacesVal');
const manualRemeshMethodSelect = document.getElementById('manualRemeshMethodSelect');
const runManualCleanBtn = document.getElementById('runManualCleanBtn');
if (manualQuadFacesInput && manualQuadFacesVal) {
manualQuadFacesInput.addEventListener('input', () => {
manualQuadFacesVal.textContent = manualQuadFacesInput.value;
});
}
if (runManualCleanBtn) {
runManualCleanBtn.addEventListener('click', async () => {
if (!activeModelRelativeUrl) {
showCustomAlert('Por favor, selecciona un modelo de la biblioteca primero.', 'Modelo no seleccionado');
return;
}
statusOverlay.classList.add('active');
const quadTargetFaces = manualQuadFacesInput ? parseInt(manualQuadFacesInput.value) : 60000;
const remeshMethod = manualRemeshMethodSelect ? manualRemeshMethodSelect.value : 'tris_to_quads';
if (remeshMethod === 'cleanup') {
updateLoader(50, 'Limpieza rápida de malla...', 'Blender está fusionando vértices duplicados y eliminando componentes flotantes aislados...');
} else {
updateLoader(50, 'Optimización local...', 'Blender está limpiando la malla, remallando a quads y proyectando texturas...');
}
try {
const savedUser = localStorage.getItem('23dfactory_user') || 'guest';
const response = await fetch(`${apiBase}/api/optimize-3d`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Session-User': savedUser
},
body: JSON.stringify({
modelUrl: activeModelRelativeUrl ? activeModelRelativeUrl.split('?')[0] : '',
quad_target_faces: quadTargetFaces,
remeshMethod: remeshMethod
})
});
if (!response.ok) {
const errData = await response.json().catch(() => ({ error: 'Error al optimizar.' }));
throw new Error(errData.error || 'Error interno del servidor.');
}
const data = await response.json();
if (data.gltfUrl && data.glbUrl) {
const timestamp = `?t=${Date.now()}`;
const rawGltf = data.gltfUrl.startsWith('http') ? data.gltfUrl : `${apiBase}${data.gltfUrl}`;
const rawGlb = data.glbUrl.startsWith('http') ? data.glbUrl : `${apiBase}${data.glbUrl}`;
const cleanGltfUrl = rawGltf.includes('?') ? `${rawGltf}&t=${Date.now()}` : `${rawGltf}${timestamp}`;
const cleanGlbUrl = rawGlb.includes('?') ? `${rawGlb}&t=${Date.now()}` : `${rawGlb}${timestamp}`;
load3DModel(cleanGltfUrl, cleanGlbUrl, data.fbxUrl, currentModelCategory);
if (typeof loadGallery === 'function') {
try { loadGallery(); } catch (ge) { console.warn('Gallery refresh:', ge); }
}
showCustomAlert('La malla fue optimizada y guardada como un nuevo elemento en tu Biblioteca.', 'Optimización completada');
} else {
throw new Error('No se recibieron los URLs del modelo optimizado.');
}
} catch (error) {
statusOverlay.classList.remove('active');
showCustomAlert(error.message, 'Error de Optimización');
}
});
}
// ==================== WEIGHT PAINTING PROTOTYPE ====================
function getWeightVisualizerMaterial(boneIndex) {
return new THREE.ShaderMaterial({
vertexShader: `
#include
#include
uniform int selectedBoneIndex;
varying float vWeight;
void main() {
vWeight = 0.0;
if (skinWeight.x > 0.0 && int(skinIndex.x) == selectedBoneIndex) vWeight = skinWeight.x;
else if (skinWeight.y > 0.0 && int(skinIndex.y) == selectedBoneIndex) vWeight = skinWeight.y;
else if (skinWeight.z > 0.0 && int(skinIndex.z) == selectedBoneIndex) vWeight = skinWeight.z;
else if (skinWeight.w > 0.0 && int(skinIndex.w) == selectedBoneIndex) vWeight = skinWeight.w;
#include
#include
#include
#include
}
`,
fragmentShader: `
varying float vWeight;
void main() {
// Color map: 0.0 is blue, 0.5 is green, 1.0 is red
vec3 blue = vec3(0.0, 0.0, 0.8);
vec3 green = vec3(0.0, 0.8, 0.0);
vec3 red = vec3(1.0, 0.0, 0.0);
vec3 color;
if (vWeight < 0.5) {
color = mix(blue, green, vWeight * 2.0);
} else {
color = mix(green, red, (vWeight - 0.5) * 2.0);
}
gl_FragColor = vec4(color, 1.0);
}
`,
uniforms: THREE.UniformsUtils.merge([
THREE.ShaderLib.standard.uniforms,
{
selectedBoneIndex: { value: boneIndex }
}
]),
skinning: true,
wireframe: false,
depthTest: true,
depthWrite: true
});
}
// Enable/Disable Weight Visualizer Shader
function toggleWeightVisualizer(enable) {
if (!threeModel) return;
activeSkinnedMeshes = [];
spatialGrid = null;
// Get selected bone index
let boneIdx = -1;
const activeBone = selectedBone;
if (activeBone && threeModel) {
// Find bone's index in the skeleton
let allBones = [];
threeModel.traverse(c => {
if (c.isBone) allBones.push(c);
});
boneIdx = allBones.indexOf(activeBone);
}
targetBoneIdx = boneIdx;
threeModel.traverse(child => {
if (child.name && (child.name.includes("_wireframe_helper") || child.name.includes("_points_helper"))) return;
if (child.isSkinnedMesh) {
if (enable) {
activeSkinnedMeshes.push(child);
// Override raycast method to bypass expensive CPU skinning calculations
if (!child.originalRaycast) {
child.originalRaycast = child.raycast;
}
child.raycast = THREE.Mesh.prototype.raycast;
// Save original material if not already saved
if (!originalMaterialsMap.has(child)) {
originalMaterialsMap.set(child, child.material);
}
child.material = getWeightVisualizerMaterial(boneIdx);
} else {
// Restore original raycast
if (child.originalRaycast) {
child.raycast = child.originalRaycast;
}
// Restore original material
if (originalMaterialsMap.has(child)) {
child.material = originalMaterialsMap.get(child);
}
}
}
});
}
// Update Uniform on active material when selected bone changes
function updateWeightVisualizerBone() {
if (!isWeightPaintingActive || !threeModel) return;
let boneIdx = -1;
const activeBone = selectedBone;
if (activeBone) {
let allBones = [];
threeModel.traverse(c => {
if (c.isBone) allBones.push(c);
});
boneIdx = allBones.indexOf(activeBone);
}
targetBoneIdx = boneIdx;
threeModel.traverse(child => {
if (child.isSkinnedMesh && child.material && child.material.uniforms && child.material.uniforms.selectedBoneIndex) {
child.material.uniforms.selectedBoneIndex.value = boneIdx;
child.material.needsUpdate = true;
}
});
}
// UI Handlers for Weight Paint settings
if (activeWeightPaintCheck) {
activeWeightPaintCheck.addEventListener('change', () => {
isWeightPaintingActive = activeWeightPaintCheck.checked;
toggleWeightVisualizer(isWeightPaintingActive);
const card = document.getElementById('paintInstructionCard');
if (card) {
card.style.display = isWeightPaintingActive ? 'block' : 'none';
}
// Hide auto-rotation by default to avoid conflicts while painting
if (isWeightPaintingActive) {
isAutoRotating = false;
if (threeControls) threeControls.autoRotate = false;
if (toggleAutoRotateBtn) toggleAutoRotateBtn.classList.remove('active');
// Automatically enable skeleton helper/bones visualization for selection
setSkeletonHelperActive(true);
// Collapse other panels to make room
const rigPanel = document.getElementById('manualRigPanel');
if (rigPanel) rigPanel.classList.add('collapsed');
const cleanPanel = document.getElementById('manualCleanPanel');
if (cleanPanel) cleanPanel.classList.add('collapsed');
// Deactivate their toolbar buttons
const btnRig = document.getElementById('btnRigPanel');
if (btnRig) btnRig.classList.remove('active');
const btnClean = document.getElementById('btnCleanPanel');
if (btnClean) btnClean.classList.remove('active');
// Show/Create selected bone marker sphere on the mesh
updateSelectedBoneMarker();
// Add brush helper mesh to scene
if (!brushHelper && threeScene) {
const geom = new THREE.RingGeometry(0.095, 0.10, 32);
const mat = new THREE.MeshBasicMaterial({
color: 0xffaa00,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.8,
depthTest: false
});
brushHelper = new THREE.Mesh(geom, mat);
brushHelper.renderOrder = 1001;
threeScene.add(brushHelper);
}
} else {
if (brushHelper && threeScene) {
threeScene.remove(brushHelper);
brushHelper = null;
}
// Clean up selected bone marker when deactivated
updateSelectedBoneMarker();
}
});
}
if (weightPaintRadiusInput && weightPaintRadiusVal) {
setupSliderValueUpdate(weightPaintRadiusInput, weightPaintRadiusVal);
weightPaintRadiusInput.addEventListener('input', (e) => {
weightPaintRadius = parseFloat(e.target.value);
if (brushHelper) {
brushHelper.scale.setScalar(weightPaintRadius / 0.10); // Scale relative to default 0.1
}
});
}
if (weightPaintStrengthInput && weightPaintStrengthVal) {
setupSliderValueUpdate(weightPaintStrengthInput, weightPaintStrengthVal);
weightPaintStrengthInput.addEventListener('input', (e) => {
weightPaintStrength = parseFloat(e.target.value);
});
}
if (weightPaintModeSelect) {
weightPaintModeSelect.addEventListener('change', () => {
weightPaintMode = weightPaintModeSelect.value;
});
}
// Mouse events on canvas for painting weights
let isMouseDown = false;
const paintRaycaster = new THREE.Raycaster();
const paintMouse = new THREE.Vector2();
function setupWeightPaintingCanvasEvents(canvas) {
// Prevent right-click browser menu on canvas when weight painting is active
canvas.addEventListener('contextmenu', (e) => {
if (isWeightPaintingActive) {
e.preventDefault();
}
}, true);
canvas.addEventListener('pointerdown', (e) => {
if (!isWeightPaintingActive) return;
// Paint actions require holding the Ctrl key
if (e.ctrlKey) {
// Prevent OrbitControls from receiving this click and rotating the camera
e.stopImmediatePropagation();
e.preventDefault();
if (e.button === 0) { // Left Click + Ctrl -> Add weight
weightPaintMode = 'add';
if (weightPaintModeSelect) weightPaintModeSelect.value = 'add';
isMouseDown = true;
if (brushHelper) brushHelper.material.color.setHex(0x00ff66); // GREEN
if (threeControls) threeControls.enabled = false;
paintWeightsAtMouse(e, canvas);
} else if (e.button === 2) { // Right Click + Ctrl -> Sub weight
weightPaintMode = 'sub';
if (weightPaintModeSelect) weightPaintModeSelect.value = 'sub';
isMouseDown = true;
if (brushHelper) brushHelper.material.color.setHex(0xff3333); // RED
if (threeControls) threeControls.enabled = false;
paintWeightsAtMouse(e, canvas);
}
} else {
// Normal click (no Ctrl) -> Ensure camera controls are active
if (threeControls) threeControls.enabled = true;
}
}, true);
canvas.addEventListener('pointermove', (e) => {
if (!isWeightPaintingActive) return;
// Skip brush raycasting when actively rotating/panning the camera
if (e.buttons !== 0 && !e.ctrlKey) {
if (brushHelper) brushHelper.visible = false;
lastBrushHit = null;
return;
}
// Only show/update brush helper if Ctrl key is held!
if (!e.ctrlKey) {
if (brushHelper) brushHelper.visible = false;
lastBrushHit = null;
return;
}
// Set hover color if not active drawing
if (brushHelper && !isMouseDown) {
brushHelper.material.color.setHex(0xffaa00); // ORANGE/YELLOW
}
// Skip raycast/paint on micro-movements of less than 4 pixels in screen coordinates
const dx = e.clientX - lastMouseScreenX;
const dy = e.clientY - lastMouseScreenY;
if (Math.abs(dx) < 4 && Math.abs(dy) < 4) {
return;
}
lastMouseScreenX = e.clientX;
lastMouseScreenY = e.clientY;
// Position brush helper (throttled inside updateBrushHelperPosition)
updateBrushHelperPosition(e, canvas);
// Continue painting if mouse is down and Ctrl is held
if (isMouseDown && e.ctrlKey) {
const now = performance.now();
if (now - lastPaintTime < 80) return; // Throttle paint loops to ~12 FPS on heavy meshes to prevent UI freeze
lastPaintTime = now;
e.stopImmediatePropagation();
e.preventDefault();
if (e.buttons === 1) { // Left button held
weightPaintMode = 'add';
if (weightPaintModeSelect) weightPaintModeSelect.value = 'add';
if (brushHelper) brushHelper.material.color.setHex(0x00ff66); // GREEN
paintWeightsAtMouse(e, canvas);
} else if (e.buttons === 2) { // Right button held
weightPaintMode = 'sub';
if (weightPaintModeSelect) weightPaintModeSelect.value = 'sub';
if (brushHelper) brushHelper.material.color.setHex(0xff3333); // RED
paintWeightsAtMouse(e, canvas);
}
}
}, true);
window.addEventListener('pointerup', (e) => {
if (isWeightPaintingActive) {
if (isMouseDown) {
isMouseDown = false;
e.stopImmediatePropagation();
e.preventDefault();
}
if (brushHelper) brushHelper.material.color.setHex(0xffaa00); // ORANGE/YELLOW
// Always restore camera controls on pointerup
if (threeControls) {
threeControls.enabled = true;
}
}
}, true);
}
function updateBrushHelperPosition(event, canvas) {
if (!brushHelper || !threeCamera || !threeModel || activeSkinnedMeshes.length === 0) {
lastBrushHit = null;
if (brushHelper) brushHelper.visible = false;
return;
}
// Limit hover updates to ~40fps
const now = performance.now();
if (now - lastRaycastTime < 25) return;
lastRaycastTime = now;
const rect = canvas.getBoundingClientRect();
paintMouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
paintMouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
paintRaycaster.setFromCamera(paintMouse, threeCamera);
// Fast Bounding Sphere colision check (takes 0.001ms) for hover placement
const mesh = activeSkinnedMeshes[0];
if (!mesh.geometry.boundingSphere) {
mesh.geometry.computeBoundingSphere();
}
const sphere = new THREE.Sphere();
sphere.copy(mesh.geometry.boundingSphere).applyMatrix4(mesh.matrixWorld);
const intersectPoint = new THREE.Vector3();
if (paintRaycaster.ray.intersectSphere(sphere, intersectPoint)) {
brushHelper.position.copy(intersectPoint);
brushHelper.visible = true;
// Cache a basic reference for start of paint
lastBrushHit = {
object: mesh,
point: intersectPoint
};
} else {
brushHelper.visible = false;
lastBrushHit = null;
}
}
function buildSpatialGrid(mesh) {
const geometry = mesh.geometry;
const positionAttr = geometry.attributes.position;
if (!positionAttr) return null;
const posArray = positionAttr.array;
const vCount = positionAttr.count;
// Get bounding box of the geometry
if (!geometry.boundingBox) {
geometry.computeBoundingBox();
}
const bbox = geometry.boundingBox;
// We will use a grid of 25x25x25 cells for higher search density
const gridSize = 25;
const min = bbox.min;
const max = bbox.max;
const sizeX = (max.x - min.x) || 0.01;
const sizeY = (max.y - min.y) || 0.01;
const sizeZ = (max.z - min.z) || 0.01;
const cellWidth = sizeX / gridSize;
const cellHeight = sizeY / gridSize;
const cellDepth = sizeZ / gridSize;
// Initialize grid cells map
const cells = new Map();
for (let idx = 0; idx < vCount; idx++) {
const idx3 = idx * 3;
const vx = posArray[idx3];
const vy = posArray[idx3 + 1];
const vz = posArray[idx3 + 2];
// Compute cell coordinates (clamped to 0..gridSize-1)
const cx = Math.min(gridSize - 1, Math.max(0, Math.floor((vx - min.x) / cellWidth)));
const cy = Math.min(gridSize - 1, Math.max(0, Math.floor((vy - min.y) / cellHeight)));
const cz = Math.min(gridSize - 1, Math.max(0, Math.floor((vz - min.z) / cellDepth)));
// Create cell key
const key = (cx << 16) | (cy << 8) | cz;
let cellList = cells.get(key);
if (!cellList) {
cellList = [];
cells.set(key, cellList);
}
cellList.push(idx);
}
return {
cells: cells,
min: min,
cellWidth: cellWidth,
cellHeight: cellHeight,
cellDepth: cellDepth,
gridSize: gridSize
};
}
function paintWeightsAtMouse(event, canvas) {
if (!threeCamera || !threeModel || activeSkinnedMeshes.length === 0) return;
// Reuse the cached bounding sphere hit from updateBrushHelperPosition
// This avoids the extremely expensive triangle-level raycast (which was the #1 bottleneck)
if (!lastBrushHit) return;
const mesh = lastBrushHit.object;
const point = lastBrushHit.point;
// Transform bounding sphere intersection point to local geometry space
const approxLocal = mesh.worldToLocal(point.clone());
if (targetBoneIdx === -1) {
const now = performance.now();
if (now - lastNoBoneToastTime > 3000) {
showCustomToast("Por favor, selecciona un hueso haciendo clic en una articulación celeste primero.");
lastNoBoneToastTime = now;
}
return;
}
const geometry = mesh.geometry;
const positionAttr = geometry.attributes.position;
const skinIndexAttr = geometry.attributes.skinIndex;
const skinWeightAttr = geometry.attributes.skinWeight;
if (!positionAttr || !skinIndexAttr || !skinWeightAttr) return;
// Build spatial grid on demand if not already built
if (!spatialGrid) {
spatialGrid = buildSpatialGrid(mesh);
}
// Retrieve underlying Float32/Int arrays for raw access
const posArray = positionAttr.array;
const idxArray = skinIndexAttr.array;
const wtArray = skinWeightAttr.array;
// Compute camera ray in local mesh space for accurate vertex targeting
const rect = canvas.getBoundingClientRect();
const mx = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const my = -((event.clientY - rect.top) / rect.height) * 2 + 1;
paintRaycaster.setFromCamera({x: mx, y: my}, threeCamera);
// Transform ray origin & direction into mesh local space
const invMatrix = new THREE.Matrix4().copy(mesh.matrixWorld).invert();
const localRayOrigin = paintRaycaster.ray.origin.clone().applyMatrix4(invMatrix);
const localRayDir = paintRaycaster.ray.direction.clone().transformDirection(invMatrix).normalize();
// Search cells around the approximate sphere hit point to find the vertex
// closest to the camera ray (perpendicular distance), not to the sphere surface point
const searchRadius = Math.max(weightPaintRadius * 5, 1.0);
const srMinX = approxLocal.x - searchRadius;
const srMaxX = approxLocal.x + searchRadius;
const srMinY = approxLocal.y - searchRadius;
const srMaxY = approxLocal.y + searchRadius;
const srMinZ = approxLocal.z - searchRadius;
const srMaxZ = approxLocal.z + searchRadius;
const srMinCx = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((srMinX - spatialGrid.min.x) / spatialGrid.cellWidth)));
const srMaxCx = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((srMaxX - spatialGrid.min.x) / spatialGrid.cellWidth)));
const srMinCy = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((srMinY - spatialGrid.min.y) / spatialGrid.cellHeight)));
const srMaxCy = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((srMaxY - spatialGrid.min.y) / spatialGrid.cellHeight)));
const srMinCz = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((srMinZ - spatialGrid.min.z) / spatialGrid.cellDepth)));
const srMaxCz = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((srMaxZ - spatialGrid.min.z) / spatialGrid.cellDepth)));
let nearestRayDistSq = Infinity;
let nearestVx = approxLocal.x, nearestVy = approxLocal.y, nearestVz = approxLocal.z;
const rox = localRayOrigin.x, roy = localRayOrigin.y, roz = localRayOrigin.z;
const rdx = localRayDir.x, rdy = localRayDir.y, rdz = localRayDir.z;
for (let cx = srMinCx; cx <= srMaxCx; cx++) {
for (let cy = srMinCy; cy <= srMaxCy; cy++) {
for (let cz = srMinCz; cz <= srMaxCz; cz++) {
const key = (cx << 16) | (cy << 8) | cz;
const cellList = spatialGrid.cells.get(key);
if (!cellList) continue;
for (let i = 0; i < cellList.length; i++) {
const vi = cellList[i];
const vi3 = vi * 3;
// Vector from ray origin to vertex
const vx = posArray[vi3] - rox;
const vy = posArray[vi3 + 1] - roy;
const vz = posArray[vi3 + 2] - roz;
// Projection of vertex onto ray direction (t parameter)
const t = vx * rdx + vy * rdy + vz * rdz;
if (t < 0) continue; // behind camera
// Perpendicular distance² = |V|² - t²
const perpDistSq = (vx * vx + vy * vy + vz * vz) - t * t;
if (perpDistSq < nearestRayDistSq) {
nearestRayDistSq = perpDistSq;
nearestVx = posArray[vi3];
nearestVy = posArray[vi3 + 1];
nearestVz = posArray[vi3 + 2];
}
}
}
}
}
// Use the nearest vertex position as the paint center (snapped to mesh surface)
const lx = nearestVx;
const ly = nearestVy;
const lz = nearestVz;
const radius = weightPaintRadius;
const radiusSq = radius * radius;
// Query spatial grid for cells overlapping the brush bounding box around the snapped center
const minX = lx - radius;
const maxX = lx + radius;
const minY = ly - radius;
const maxY = ly + radius;
const minZ = lz - radius;
const maxZ = lz + radius;
const minCx = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((minX - spatialGrid.min.x) / spatialGrid.cellWidth)));
const maxCx = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((maxX - spatialGrid.min.x) / spatialGrid.cellWidth)));
const minCy = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((minY - spatialGrid.min.y) / spatialGrid.cellHeight)));
const maxCy = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((maxY - spatialGrid.min.y) / spatialGrid.cellHeight)));
const minCz = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((minZ - spatialGrid.min.z) / spatialGrid.cellDepth)));
const maxCz = Math.min(spatialGrid.gridSize - 1, Math.max(0, Math.floor((maxZ - spatialGrid.min.z) / spatialGrid.cellDepth)));
let modified = false;
let minModifiedIdx = Infinity;
let maxModifiedIdx = -Infinity;
// Loop through only the overlapping cells!
for (let cx = minCx; cx <= maxCx; cx++) {
for (let cy = minCy; cy <= maxCy; cy++) {
for (let cz = minCz; cz <= maxCz; cz++) {
const key = (cx << 16) | (cy << 8) | cz;
const cellList = spatialGrid.cells.get(key);
if (!cellList) continue;
const cCount = cellList.length;
for (let i = 0; i < cCount; i++) {
const idx = cellList[i];
const idx3 = idx * 3;
const vx = posArray[idx3];
const vy = posArray[idx3 + 1];
const vz = posArray[idx3 + 2];
const dx = vx - lx;
const dy = vy - ly;
const dz = vz - lz;
// Fast squared distance check to avoid Math.sqrt for 99% of vertices
const distSq = dx * dx + dy * dy + dz * dz;
if (distSq < radiusSq) {
// Calculate actual distance only for vertices inside the brush radius
const dist = Math.sqrt(distSq);
const falloff = 1.0 - (dist / radius);
const deltaWeight = falloff * weightPaintStrength * 0.15; // Scaled down for smooth paint
// Get current bones indices and weights directly using local variables (zero garbage collection)
const idx4 = idx * 4;
let idx0 = idxArray[idx4];
let idx1 = idxArray[idx4 + 1];
let idx2 = idxArray[idx4 + 2];
let idx3Val = idxArray[idx4 + 3];
let wt0 = wtArray[idx4];
let wt1 = wtArray[idx4 + 1];
let wt2 = wtArray[idx4 + 2];
let wt3 = wtArray[idx4 + 3];
// Find if target bone is already affecting this vertex (flat conditional check)
let boneSlot = -1;
if (wt0 > 0.0 && idx0 === targetBoneIdx) boneSlot = 0;
else if (wt1 > 0.0 && idx1 === targetBoneIdx) boneSlot = 1;
else if (wt2 > 0.0 && idx2 === targetBoneIdx) boneSlot = 2;
else if (wt3 > 0.0 && idx3Val === targetBoneIdx) boneSlot = 3;
// If not affecting, replace slot with lowest weight
if (boneSlot === -1) {
let minSlot = 0;
let minWt = wt0;
if (wt1 < minWt) { minWt = wt1; minSlot = 1; }
if (wt2 < minWt) { minWt = wt2; minSlot = 2; }
if (wt3 < minWt) { minWt = wt3; minSlot = 3; }
boneSlot = minSlot;
if (boneSlot === 0) { idx0 = targetBoneIdx; wt0 = 0.0; }
else if (boneSlot === 1) { idx1 = targetBoneIdx; wt1 = 0.0; }
else if (boneSlot === 2) { idx2 = targetBoneIdx; wt2 = 0.0; }
else if (boneSlot === 3) { idx3Val = targetBoneIdx; wt3 = 0.0; }
}
// Get current weight of the targeted slot
let oldWt = 0.0;
if (boneSlot === 0) oldWt = wt0;
else if (boneSlot === 1) oldWt = wt1;
else if (boneSlot === 2) oldWt = wt2;
else if (boneSlot === 3) oldWt = wt3;
let newWt = oldWt;
if (weightPaintMode === 'add') {
newWt = Math.min(1.0, oldWt + deltaWeight);
} else {
newWt = Math.max(0.0, oldWt - deltaWeight);
}
if (newWt !== oldWt) {
if (boneSlot === 0) wt0 = newWt;
else if (boneSlot === 1) wt1 = newWt;
else if (boneSlot === 2) wt2 = newWt;
else if (boneSlot === 3) wt3 = newWt;
// Normalize remaining slots
let otherSum = 0.0;
if (boneSlot !== 0) otherSum += wt0;
if (boneSlot !== 1) otherSum += wt1;
if (boneSlot !== 2) otherSum += wt2;
if (boneSlot !== 3) otherSum += wt3;
if (otherSum > 0.0) {
const scale = (1.0 - newWt) / otherSum;
if (boneSlot !== 0) wt0 *= scale;
if (boneSlot !== 1) wt1 *= scale;
if (boneSlot !== 2) wt2 *= scale;
if (boneSlot !== 3) wt3 *= scale;
} else {
const remaining = (1.0 - newWt) / 3.0;
if (boneSlot !== 0) wt0 = remaining;
if (boneSlot !== 1) wt1 = remaining;
if (boneSlot !== 2) wt2 = remaining;
if (boneSlot !== 3) wt3 = remaining;
}
// Write back directly to typed arrays
idxArray[idx4] = idx0;
idxArray[idx4 + 1] = idx1;
idxArray[idx4 + 2] = idx2;
idxArray[idx4 + 3] = idx3Val;
wtArray[idx4] = wt0;
wtArray[idx4 + 1] = wt1;
wtArray[idx4 + 2] = wt2;
wtArray[idx4 + 3] = wt3;
if (idx < minModifiedIdx) minModifiedIdx = idx;
if (idx > maxModifiedIdx) maxModifiedIdx = idx;
modified = true;
}
}
}
}
}
}
if (modified) {
// GPU Upload Optimization: upload only the slice of the array that was modified
const start = minModifiedIdx * 4;
const count = (maxModifiedIdx - minModifiedIdx + 1) * 4;
skinIndexAttr.updateRange.offset = start;
skinIndexAttr.updateRange.count = count;
skinIndexAttr.needsUpdate = true;
skinWeightAttr.updateRange.offset = start;
skinWeightAttr.updateRange.count = count;
skinWeightAttr.needsUpdate = true;
}
}
// Save Weight Painting Changes Handler
if (saveWeightPaintBtn) {
saveWeightPaintBtn.addEventListener('click', () => {
if (!threeModel || !activeModelRelativeUrl) return;
// Turn off painting shader first so we export original materials
const originalPaintingState = isWeightPaintingActive;
if (originalPaintingState) {
activeWeightPaintCheck.checked = false;
const card = document.getElementById('paintInstructionCard');
if (card) card.style.display = 'none';
isWeightPaintingActive = false;
toggleWeightVisualizer(false);
if (brushHelper && threeScene) {
threeScene.remove(brushHelper);
brushHelper = null;
}
}
statusOverlay.classList.add('active');
updateLoader(40, 'Guardando Influencias...', 'Exportando el modelo tridimensional y recalculando pesos...');
// Use THREE.GLTFExporter to export GLB
const exporter = new THREE.GLTFExporter();
// Export options
const exportOptions = {
binary: true,
animations: [],
truncateDrawRange: false
};
exporter.parse(threeModel, async (gltfBuffer) => {
try {
// Convert buffer array to base64
const blob = new Blob([gltfBuffer], { type: 'application/octet-stream' });
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = async () => {
const glbBase64 = reader.result;
updateLoader(70, 'Regenerando FBX...', 'Blender está compilando las nuevas influencias de huesos y texturas en el archivo FBX...');
const response = await fetch(`${apiBase}/api/save-weights`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
modelUrl: activeModelRelativeUrl,
glbBase64: glbBase64
})
});
if (!response.ok) {
const errData = await response.json().catch(() => ({ error: 'Error al actualizar pesos.' }));
throw new Error(errData.error || 'Error interno del servidor.');
}
const data = await response.json();
// Reload model with updated files
sessionStorage.setItem('pendingModelToLoad', JSON.stringify({
gltfUrl: activeModelRelativeUrl,
downloadUrl: activeModelRelativeUrl,
fbxUrl: data.fbxUrl,
detectedCategory: currentModelCategory
}));
window.location.reload();
};
} catch (error) {
console.error(error);
statusOverlay.classList.remove('active');
alert(`Error al guardar pesos: ${error.message}`);
// Restore painting state on failure
if (originalPaintingState) {
activeWeightPaintCheck.checked = true;
const card = document.getElementById('paintInstructionCard');
if (card) card.style.display = 'block';
isWeightPaintingActive = true;
toggleWeightVisualizer(true);
}
}
}, (error) => {
console.error('Error during GLTF export:', error);
statusOverlay.classList.remove('active');
alert('Error al exportar la malla para guardar.');
}, exportOptions);
});
}
// Generic function to toggle collapsible panels from the vertical toolbar buttons
function toggleToolbarPanel(panelId, buttonId) {
const panel = document.getElementById(panelId);
const button = document.getElementById(buttonId);
if (!panel) return;
const isCurrentlyCollapsed = panel.classList.contains('collapsed');
// 1. Collapse all panels
const allPanels = ['manualRigPanel', 'manualCleanPanel', 'rigTriggerPanel', 'manualWeightPaintPanel'];
allPanels.forEach(id => {
const p = document.getElementById(id);
if (p) p.classList.add('collapsed');
});
// 2. Deactivate all buttons
const allBtns = ['btnRigPanel', 'btnCleanPanel', 'btnRigTriggerPanel', 'btnWeightPaintPanel'];
allBtns.forEach(id => {
const b = document.getElementById(id);
if (b) b.classList.remove('active');
});
// 3. If it was collapsed, expand it and set button active
if (isCurrentlyCollapsed) {
panel.classList.remove('collapsed');
if (button) button.classList.add('active');
}
}
// Attach event listeners for the vertical toolbar buttons
const btnRigPanel = document.getElementById('btnRigPanel');
const btnCleanPanel = document.getElementById('btnCleanPanel');
const btnRigTriggerPanel = document.getElementById('btnRigTriggerPanel');
const btnWeightPaintPanel = document.getElementById('btnWeightPaintPanel');
if (btnRigPanel) {
btnRigPanel.addEventListener('click', () => toggleToolbarPanel('manualRigPanel', 'btnRigPanel'));
}
if (btnCleanPanel) {
btnCleanPanel.addEventListener('click', () => toggleToolbarPanel('manualCleanPanel', 'btnCleanPanel'));
}
if (btnRigTriggerPanel) {
btnRigTriggerPanel.addEventListener('click', () => toggleToolbarPanel('rigTriggerPanel', 'btnRigTriggerPanel'));
}
if (btnWeightPaintPanel) {
btnWeightPaintPanel.addEventListener('click', () => toggleToolbarPanel('manualWeightPaintPanel', 'btnWeightPaintPanel'));
}
function updateWeightPaintSelectedBoneDisplay() {
const textEl = document.getElementById('weightPaintSelectedBoneText');
if (textEl) {
textEl.textContent = selectedBone ? getFriendlyBoneName(selectedBone.name) : 'Ninguno';
}
}
function updateSelectedBoneMarker() {
if (!threeScene) return;
if (isWeightPaintingActive) {
if (selectedBone) {
if (!selectedBoneMarker) {
// Create a beautiful, glowing hot-orange/magenta pulsing sphere
const group = new THREE.Group();
const innerGeo = new THREE.SphereGeometry(0.025, 16, 16);
const innerMat = new THREE.MeshBasicMaterial({
color: 0xff0055, // Hot magenta/red to stand out against weight map blue
depthTest: false,
transparent: true,
opacity: 0.95
});
const innerMesh = new THREE.Mesh(innerGeo, innerMat);
innerMesh.renderOrder = 1000;
group.add(innerMesh);
selectedBoneMarker = group;
threeScene.add(selectedBoneMarker);
}
const pos = new THREE.Vector3();
selectedBone.getWorldPosition(pos);
selectedBoneMarker.position.copy(pos);
selectedBoneMarker.visible = true;
} else {
if (selectedBoneMarker) selectedBoneMarker.visible = false;
}
} else {
if (selectedBoneMarker) {
threeScene.remove(selectedBoneMarker);
selectedBoneMarker = null;
}
}
}
// Check mobile viewport and show interactive redirect dialog
function checkMobileRedirect() {
if (window.innerWidth < 768) {
if (sessionStorage.getItem('dismissedMobileWarning') === 'true') {
return;
}
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100vw';
overlay.style.height = '100vh';
overlay.style.backgroundColor = 'rgba(10, 10, 12, 0.96)';
overlay.style.zIndex = '999999';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.padding = '24px';
overlay.style.boxSizing = 'border-box';
overlay.style.backdropFilter = 'blur(10px)';
overlay.style.webkitBackdropFilter = 'blur(10px)';
overlay.innerHTML = `
📱
¿Estás en un Celular?
La creación, edición y limpieza de mallas está diseñada para pantallas de PC/Laptops. En celulares, te recomendamos usar el visor 3D táctil optimizado y proyectar en Realidad Aumentada (AR).