let currentModel = null; let viewerMeshes = []; let scene, camera, renderer, controls, axesHelper, bboxHelper; let currentViewMode = 'solid'; let axesVisible = false; let bboxVisible = false; let clipPlane = null; let clipEnabled = false; let measureMode = false; let measurePoints = []; let measureLine = null; let measureLabel = null; let overhangMode = false; let layerAnimating = false; let layerAnimationId = null; let originalColors = new Map(); function getModelId() { const parts = window.location.pathname.split('/'); const id = parts.pop(); return parseInt(id) || null; } document.addEventListener('DOMContentLoaded', () => { const modelId = getModelId(); if (!modelId) { showToast('ID de modelo invalido', 'error'); return; } loadDetail(modelId); const editBtn = document.getElementById('btn-edit'); const editModal = document.getElementById('edit-modal'); const editClose = document.getElementById('edit-close'); const editForm = document.getElementById('edit-form'); const editCancel = document.getElementById('edit-cancel'); if (editBtn && editModal) { editBtn.addEventListener('click', () => { fillEditForm(); editModal.classList.remove('hidden'); }); } if (editClose && editModal) { editClose.addEventListener('click', () => editModal.classList.add('hidden')); editModal.addEventListener('click', (e) => { if (e.target === editModal) editModal.classList.add('hidden'); }); } if (editCancel && editModal) { editCancel.addEventListener('click', () => editModal.classList.add('hidden')); } if (editForm) { editForm.addEventListener('submit', async (e) => { e.preventDefault(); await saveEdit(); }); } // QR modal const shareBtn = document.getElementById('btn-share'); const qrModal = document.getElementById('qr-modal'); const qrClose = document.getElementById('qr-close'); if (shareBtn && qrModal) { shareBtn.addEventListener('click', () => { const qrImg = document.getElementById('qr-image'); const qrUrl = document.getElementById('qr-url'); const url = `${window.location.origin}/model/${modelId}`; if (qrImg) qrImg.src = `/api/models/${modelId}/qr`; if (qrUrl) qrUrl.textContent = url; qrModal.classList.remove('hidden'); }); } if (qrClose && qrModal) { qrClose.addEventListener('click', () => qrModal.classList.add('hidden')); qrModal.addEventListener('click', (e) => { if (e.target === qrModal) qrModal.classList.add('hidden'); }); } // Rating form const ratingForm = document.getElementById('rating-form'); if (ratingForm) { ratingForm.addEventListener('submit', async (e) => { e.preventDefault(); const stars = parseInt(document.getElementById('rating-stars').value); if (!stars || stars < 1 || stars > 5) return; try { await apiPostForm(`/models/${modelId}/ratings?stars=${stars}`, new FormData()); showToast('Valoracion enviada', 'success'); loadDetail(modelId); } catch (err) { showToast('Error: ' + err.message, 'error'); } }); } // Comment form const commentForm = document.getElementById('comment-form'); if (commentForm) { commentForm.addEventListener('submit', async (e) => { e.preventDefault(); const text = document.getElementById('comment-text').value.trim(); const author = document.getElementById('comment-author').value.trim(); if (!text) return; try { const params = new URLSearchParams({ text }); if (author) params.append('author_name', author); await apiPostForm(`/models/${modelId}/comments?${params.toString()}`, new FormData()); showToast('Comentario agregado', 'success'); document.getElementById('comment-text').value = ''; loadDetail(modelId); } catch (err) { showToast('Error: ' + err.message, 'error'); } }); } // Collection form const collForm = document.getElementById('collection-form'); if (collForm) { collForm.addEventListener('submit', async (e) => { e.preventDefault(); const name = document.getElementById('collection-name').value.trim(); const desc = document.getElementById('collection-desc').value.trim(); if (!name) return; try { await apiPut('/models/collections', { name, description: desc }); showToast('Coleccion creada', 'success'); document.getElementById('collection-name').value = ''; document.getElementById('collection-desc').value = ''; loadCollections(); } catch (err) { showToast('Error: ' + err.message, 'error'); } }); } // Theme listener window.addEventListener('themechange', (e) => { if (scene) { scene.background = new THREE.Color(e.detail === 'light' ? 0xf8fafc : 0x0f172a); if (window.gridHelper) window.gridHelper.material.color.setHex(e.detail === 'light' ? 0xcbd5e1 : 0x1e293b); } }); // Clip slider const clipSlider = document.getElementById('clip-slider'); if (clipSlider) { clipSlider.addEventListener('input', () => { if (clipPlane) { clipPlane.constant = parseFloat(clipSlider.value); renderer.render(scene, camera); } }); } }); async function loadDetail(modelId) { try { const model = await apiGet('/models/' + modelId); currentModel = model; renderMeta(model); renderParts(model); renderImages(model); renderRatings(model); renderComments(model); loadCollections(); initViewer(model); } catch (e) { console.error(e); document.querySelector('main').innerHTML = `
`; } } function renderStars(avg, count) { if (!avg) return 'Sin valoraciones'; const full = Math.floor(avg); const half = avg - full >= 0.5; let html = ''; for (let i = 0; i < 5; i++) { if (i < full) { html += ''; } else if (i === full && half) { html += ''; } else { html += ''; } } html += `${avg.toFixed(1)} (${count})`; return html; } function renderMeta(m) { const setText = (id, text) => { const el = document.getElementById(id); if (el) el.textContent = text || '—'; }; setText('meta-title', m.title); setText('meta-desc', m.description); setText('meta-author', m.author ? 'Por ' + m.author : 'Autor desconocido'); setText('meta-license', m.license); setText('meta-category', m.category); setText('meta-faces', m.faces ?? '—'); const ratingEl = document.getElementById('meta-rating'); if (ratingEl) ratingEl.innerHTML = renderStars(m.avg_rating, (m.ratings || []).length); const dimsEl = document.getElementById('meta-dims'); if (dimsEl) { dimsEl.textContent = (m.width && m.height && m.depth) ? `${m.width.toFixed(1)} x ${m.height.toFixed(1)} x ${m.depth.toFixed(1)} mm` : '—'; } const tagsEl = document.getElementById('meta-tags'); if (tagsEl) { if (m.tags && m.tags.length > 0) { tagsEl.innerHTML = m.tags.map(t => `${t.name}` ).join(''); } else { tagsEl.innerHTML = '—'; } } const dlBtn = document.getElementById('btn-download'); if (dlBtn) dlBtn.href = '/api/models/' + m.id + '/download'; const delBtn = document.getElementById('btn-delete'); if (delBtn) { delBtn.onclick = async () => { if (!confirm('Eliminar este modelo permanentemente?')) return; try { await apiDelete('/models/' + m.id); showToast('Modelo eliminado', 'success'); setTimeout(() => window.location.href = '/', 800); } catch (e) { showToast('Error al eliminar: ' + e.message, 'error'); } }; } const dlAllBtn = document.getElementById('btn-download-all'); if (dlAllBtn) { const modelFiles = (m.files || []).filter(f => f.file_type === 'stl' || f.file_type === '3mf'); if (modelFiles.length > 1) { dlAllBtn.classList.remove('hidden'); dlAllBtn.onclick = () => { window.location.href = '/api/models/' + m.id + '/download-all'; }; } else { dlAllBtn.classList.add('hidden'); } } } function renderParts(m) { const container = document.getElementById('parts-list'); if (!container) return; const modelFiles = (m.files || []).filter(f => f.file_type === 'stl' || f.file_type === '3mf'); if (modelFiles.length <= 1) { container.innerHTML = ''; return; } const colors = ['#22d3ee', '#f472b6', '#a3e635', '#fbbf24', '#a78bfa', '#fb923c']; container.innerHTML = `Aun no hay valoraciones
'}Aun no hay comentarios. Se el primero!
'; return; } container.innerHTML = comments.map(c => `${c.text}
No hay archivos 3D para mostrar
'; } controls.addEventListener('start', () => { controls.autoRotate = false; }); // Raycaster for measurement renderer.domElement.addEventListener('pointerdown', onViewerPointerDown); function animate() { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); } animate(); window.addEventListener('resize', () => { const w = container.clientWidth; const h = container.clientHeight; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); }); } function togglePart(fileId) { const mesh = viewerMeshes.find(m => m.userData.fileId === fileId); if (mesh) { mesh.visible = !mesh.visible; } } function setViewMode(mode) { currentViewMode = mode; viewerMeshes.forEach(mesh => { mesh.material.wireframe = mode === 'wireframe'; }); const btnSolid = document.getElementById('btn-solid'); const btnWire = document.getElementById('btn-wireframe'); if (btnSolid) { btnSolid.classList.toggle('bg-cyan-500/20', mode === 'solid'); btnSolid.classList.toggle('text-cyan-400', mode === 'solid'); } if (btnWire) { btnWire.classList.toggle('bg-cyan-500/20', mode === 'wireframe'); btnWire.classList.toggle('text-cyan-400', mode === 'wireframe'); } } function toggleAxes() { axesVisible = !axesVisible; if (axesHelper) axesHelper.visible = axesVisible; const btn = document.getElementById('btn-axes'); if (btn) { btn.classList.toggle('bg-cyan-500/20', axesVisible); btn.classList.toggle('text-cyan-400', axesVisible); } } function toggleBoundingBox() { bboxVisible = !bboxVisible; if (!bboxHelper && viewerMeshes.length > 0) { const box = new THREE.Box3(); viewerMeshes.forEach(m => { if (m.visible) box.expandByObject(m); }); bboxHelper = new THREE.Box3Helper(box, 0x06b6d4); scene.add(bboxHelper); } if (bboxHelper) bboxHelper.visible = bboxVisible; const btn = document.getElementById('btn-bbox'); if (btn) { btn.classList.toggle('bg-cyan-500/20', bboxVisible); btn.classList.toggle('text-cyan-400', bboxVisible); } } function setCameraView(view) { if (!controls || !camera) return; const target = controls.target.clone(); const dist = camera.position.distanceTo(target); switch(view) { case 'front': camera.position.set(target.x, target.y, target.z + dist); break; case 'top': camera.position.set(target.x, target.y + dist, target.z); break; case 'side': camera.position.set(target.x + dist, target.y, target.z); break; case 'iso': camera.position.set(target.x + dist * 0.7, target.y + dist * 0.7, target.z + dist * 0.7); break; } camera.lookAt(target); controls.update(); } // ====== MEASUREMENT TOOL ====== function toggleMeasure() { measureMode = !measureMode; const btn = document.getElementById('btn-measure'); if (btn) { btn.classList.toggle('bg-cyan-500/20', measureMode); btn.classList.toggle('text-cyan-400', measureMode); } if (!measureMode) { clearMeasurement(); } else { showToast('Haz clic en dos puntos del modelo para medir', 'info'); } } function clearMeasurement() { measurePoints = []; if (measureLine) { scene.remove(measureLine); measureLine = null; } if (measureLabel) { scene.remove(measureLabel); measureLabel = null; } } function onViewerPointerDown(event) { if (!measureMode || !renderer || !camera) return; const rect = renderer.domElement.getBoundingClientRect(); const mouse = new THREE.Vector2(); mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(mouse, camera); const visibleMeshes = viewerMeshes.filter(m => m.visible); const intersects = raycaster.intersectObjects(visibleMeshes); if (intersects.length === 0) return; const point = intersects[0].point; measurePoints.push(point); // Add small sphere marker const marker = new THREE.Mesh( new THREE.SphereGeometry(0.3, 8, 8), new THREE.MeshBasicMaterial({ color: 0xff0000 }) ); marker.position.copy(point); scene.add(marker); measurePoints.push(marker); // store marker at odd indices if (measurePoints.length >= 4) { // 2 points + 2 markers const p1 = measurePoints[0]; const p2 = measurePoints[2]; const dist = p1.distanceTo(p2); if (measureLine) scene.remove(measureLine); const lineGeo = new THREE.BufferGeometry().setFromPoints([p1, p2]); measureLine = new THREE.Line(lineGeo, new THREE.LineBasicMaterial({ color: 0xff0000, linewidth: 2 })); scene.add(measureLine); if (measureLabel) scene.remove(measureLabel); const mid = new THREE.Vector3().addVectors(p1, p2).multiplyScalar(0.5); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); canvas.width = 256; canvas.height = 64; ctx.fillStyle = 'rgba(0,0,0,0.7)'; ctx.roundRect(0, 0, 256, 64, 8); ctx.fill(); ctx.fillStyle = '#ffffff'; ctx.font = 'bold 24px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(dist.toFixed(2) + ' mm', 128, 32); const tex = new THREE.CanvasTexture(canvas); const spriteMat = new THREE.SpriteMaterial({ map: tex }); measureLabel = new THREE.Sprite(spriteMat); measureLabel.position.copy(mid); measureLabel.scale.set(4, 1, 1); scene.add(measureLabel); showToast(`Distancia: ${dist.toFixed(2)} mm`, 'success'); measureMode = false; const btn = document.getElementById('btn-measure'); if (btn) { btn.classList.remove('bg-cyan-500/20', 'text-cyan-400'); } } } // ====== CLIPPING ====== function toggleClip() { clipEnabled = !clipEnabled; const controlsDiv = document.getElementById('clip-controls'); const btn = document.getElementById('btn-clip'); if (controlsDiv) controlsDiv.classList.toggle('hidden', !clipEnabled); if (btn) { btn.classList.toggle('bg-cyan-500/20', clipEnabled); btn.classList.toggle('text-cyan-400', clipEnabled); } if (clipEnabled) { if (!clipPlane) { clipPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0); } viewerMeshes.forEach(mesh => { mesh.material.clippingPlanes = [clipPlane]; }); const slider = document.getElementById('clip-slider'); if (slider) clipPlane.constant = parseFloat(slider.value); } else { viewerMeshes.forEach(mesh => { mesh.material.clippingPlanes = []; }); } } // ====== OVERHANG HEATMAP ====== function toggleOverhang() { overhangMode = !overhangMode; const btn = document.getElementById('btn-overhang'); if (btn) { btn.classList.toggle('bg-cyan-500/20', overhangMode); btn.classList.toggle('text-cyan-400', overhangMode); } viewerMeshes.forEach(mesh => { const geo = mesh.geometry; if (!geo.attributes.position) return; if (!overhangMode) { // Restore original if (mesh.userData.originalMaterial) { mesh.material = mesh.userData.originalMaterial.clone(); if (clipEnabled) mesh.material.clippingPlanes = [clipPlane]; } return; } const pos = geo.attributes.position; const count = pos.count; const colors = new Float32Array(count * 3); const colorAttr = new THREE.BufferAttribute(colors, 3); // Compute face normals and assign per-vertex const up = new THREE.Vector3(0, 0, 1); const p1 = new THREE.Vector3(), p2 = new THREE.Vector3(), p3 = new THREE.Vector3(); const n = new THREE.Vector3(); for (let i = 0; i < count; i += 3) { p1.fromBufferAttribute(pos, i); p2.fromBufferAttribute(pos, i + 1); p3.fromBufferAttribute(pos, i + 2); n.crossVectors( new THREE.Vector3().subVectors(p2, p1), new THREE.Vector3().subVectors(p3, p1) ).normalize(); const angle = Math.acos(Math.abs(n.dot(up))) * (180 / Math.PI); let r, g, b; if (angle > 60) { r = 1; g = 0; b = 0; } else if (angle > 45) { r = 1; g = 1; b = 0; } else { r = 0; g = 1; b = 0; } colors[i * 3] = r; colors[i * 3 + 1] = g; colors[i * 3 + 2] = b; colors[(i + 1) * 3] = r; colors[(i + 1) * 3 + 1] = g; colors[(i + 1) * 3 + 2] = b; colors[(i + 2) * 3] = r; colors[(i + 2) * 3 + 1] = g; colors[(i + 2) * 3 + 2] = b; } geo.setAttribute('color', colorAttr); mesh.material = new THREE.MeshStandardMaterial({ vertexColors: true, metalness: 0.1, roughness: 0.5, side: THREE.DoubleSide, clippingPlanes: clipEnabled ? [clipPlane] : [], }); }); } // ====== LAYER BUILD ANIMATION ====== function toggleLayerAnimation() { layerAnimating = !layerAnimating; const btn = document.getElementById('btn-layers'); if (btn) { btn.classList.toggle('bg-cyan-500/20', layerAnimating); btn.classList.toggle('text-cyan-400', layerAnimating); } if (layerAnimating) { if (!clipPlane) { clipPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0); } viewerMeshes.forEach(mesh => { mesh.material.clippingPlanes = [clipPlane]; }); const box = new THREE.Box3(); viewerMeshes.forEach(m => { if (m.visible) box.expandByObject(m); }); const minY = box.min.y; const maxY = box.max.y; let current = minY; const step = (maxY - minY) / 100; if (layerAnimationId) cancelAnimationFrame(layerAnimationId); function stepAnim() { if (!layerAnimating) return; current += step; if (current > maxY) current = minY; clipPlane.constant = current; layerAnimationId = requestAnimationFrame(stepAnim); } stepAnim(); showToast('Animacion de capas iniciada', 'success'); } else { if (layerAnimationId) cancelAnimationFrame(layerAnimationId); if (!clipEnabled) { viewerMeshes.forEach(mesh => { mesh.material.clippingPlanes = []; }); } } } // ====== VALIDATION ====== async function runValidation() { const container = document.getElementById('validation-result'); if (!container) return; container.classList.remove('hidden'); container.innerHTML = 'Analizando malla...
'; try { const data = await apiGet(`/models/${getModelId()}/validate`); container.innerHTML = `Error: ${err.message}
`; } } // ====== ESTIMATION ====== async function runEstimation() { const container = document.getElementById('estimation-result'); if (!container) return; container.classList.remove('hidden'); container.innerHTML = 'Calculando...
'; try { const data = await apiGet(`/models/${getModelId()}/estimate`); container.innerHTML = `Error: ${err.message}
`; } } // ====== PLATE ESTIMATION ====== async function runPlateEstimation() { const container = document.getElementById('plates-result'); if (!container || !currentModel) return; const parts = (currentModel.files || []).filter(f => f.file_type === 'stl' || f.file_type === '3mf'); if (parts.length === 0) { container.innerHTML = 'No hay partes para estimar.
'; return; } container.innerHTML = 'Calculando placas...
'; const PLATE_SIZE = 220; // mm (standard bed size) let totalCost = 0; let totalTime = 0; let totalGrams = 0; const results = []; for (const part of parts) { try { const data = await apiGet(`/models/${getModelId()}/estimate?file_id=${part.id}`); totalCost += data.cost; totalTime += data.estimated_seconds; totalGrams += data.grams; // Determine fit color const maxDim = Math.max(data.width_mm || 0, data.depth_mm || 0); const fitColor = maxDim > PLATE_SIZE ? 'bg-red-500' : maxDim > PLATE_SIZE * 0.8 ? 'bg-yellow-500' : 'bg-cyan-500'; const fitText = maxDim > PLATE_SIZE ? 'No cabe en placa estandar' : 'Cabe en placa'; // Scale for visual (200px = 220mm) const scale = 200 / PLATE_SIZE; const rectW = Math.min((data.width_mm || 10) * scale, 200); const rectH = Math.min((data.depth_mm || 10) * scale, 200); results.push({ ...data, part, fitColor, fitText, rectW, rectH, }); } catch (err) { results.push({ part, error: err.message }); } } const totalHours = Math.floor(totalTime / 3600); const totalMins = Math.floor((totalTime % 3600) / 60); container.innerHTML = `Total estimado (${results.length} placa${results.length > 1 ? 's' : ''})
Cargando...
'; try { const models = await apiGet('/models/?limit=100'); const currentId = getModelId(); const others = models.filter(m => m.id !== currentId); if (others.length === 0) { list.innerHTML = 'No hay otros modelos para comparar.
'; return; } list.innerHTML = others.map(m => `${m.title}
${m.faces ?? '?'} caras · ${m.author || 'Anonimo'}
Error cargando modelos.
'; } } function closeCompareModal() { const modal = document.getElementById('compare-modal'); if (modal) modal.classList.add('hidden'); } function compareWith(otherId) { const currentId = getModelId(); window.open(`/model/${otherId}?compare=${currentId}`, '_blank'); closeCompareModal(); }