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 = `

Modelo no encontrado

El modelo que buscas no existe o ha sido eliminado.

Volver a la galeria
`; } } 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 = `
${modelFiles.map((f, i) => `
${f.part_name || f.filename}
`).join('')}
`; } function renderImages(m) { const container = document.getElementById('images-gallery'); if (!container) return; const images = (m.files || []).filter(f => f.file_type === 'image'); if (images.length === 0) { container.innerHTML = ''; return; } container.innerHTML = `
${images.map(img => ` ${img.filename} `).join('')}
`; } function renderRatings(m) { const container = document.getElementById('ratings-section'); if (!container) return; const ratings = m.ratings || []; container.innerHTML = `
${renderStars(m.avg_rating, ratings.length)}
${ratings.length > 0 ? `
${ratings.slice(0, 5).map(r => `
${'★'.repeat(r.stars)}${'☆'.repeat(5 - r.stars)} ${new Date(r.created_at).toLocaleDateString()}
`).join('')}
` : '

Aun no hay valoraciones

'}
`; } function renderComments(m) { const container = document.getElementById('comments-list'); if (!container) return; const comments = m.comments || []; if (comments.length === 0) { container.innerHTML = '

Aun no hay comentarios. Se el primero!

'; return; } container.innerHTML = comments.map(c => `
${c.author_name || 'Anonimo'} ${new Date(c.created_at).toLocaleDateString()}

${c.text}

`).join(''); } async function loadCollections() { const select = document.getElementById('collection-select'); if (!select) return; try { const collections = await apiGet('/models/collections/all'); const modelId = getModelId(); select.innerHTML = '' + collections.map(c => ``).join(''); select.onchange = async () => { if (!select.value) return; try { await apiPostForm(`/models/collections/${select.value}/add/${modelId}`, new FormData()); showToast('Agregado a coleccion', 'success'); select.value = ''; } catch (err) { showToast('Error: ' + err.message, 'error'); } }; } catch (e) { console.error(e); } } function fillEditForm() { if (!currentModel) return; const fields = ['title', 'description', 'author', 'license', 'category']; fields.forEach(f => { const el = document.getElementById('edit-' + f); if (el) el.value = currentModel[f] || ''; }); const tagsEl = document.getElementById('edit-tags'); if (tagsEl) tagsEl.value = (currentModel.tags || []).map(t => t.name).join(', '); } async function saveEdit() { if (!currentModel) return; const data = {}; ['title', 'description', 'author', 'license', 'category'].forEach(f => { const el = document.getElementById('edit-' + f); if (el) data[f] = el.value || null; }); const tagsEl = document.getElementById('edit-tags'); if (tagsEl) { data.tag_names = tagsEl.value.split(',').map(t => t.trim()).filter(Boolean); } try { const updated = await apiPut('/models/' + currentModel.id, data); currentModel = updated; renderMeta(updated); document.getElementById('edit-modal').classList.add('hidden'); showToast('Modelo actualizado', 'success'); } catch (e) { showToast('Error al guardar: ' + e.message, 'error'); } } function initViewer(model) { const container = document.getElementById('viewer'); const loadingEl = document.getElementById('viewer-loading'); const statusEl = document.getElementById('viewer-status'); if (!container) return; const isLight = document.documentElement.classList.contains('light-mode'); scene = new THREE.Scene(); scene.background = new THREE.Color(isLight ? 0xf8fafc : 0x0f172a); const width = container.clientWidth; const height = container.clientHeight; camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000); camera.position.set(0, 0, 50); renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); renderer.setSize(width, height); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.localClippingEnabled = true; container.appendChild(renderer.domElement); controls = new THREE.OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; controls.autoRotate = true; controls.autoRotateSpeed = 1.0; const ambient = new THREE.AmbientLight(0xffffff, 0.5); scene.add(ambient); const mainLight = new THREE.DirectionalLight(0xffffff, 0.8); mainLight.position.set(10, 20, 15); mainLight.castShadow = true; mainLight.shadow.mapSize.width = 1024; mainLight.shadow.mapSize.height = 1024; scene.add(mainLight); const fillLight = new THREE.DirectionalLight(0x60a5fa, 0.3); fillLight.position.set(-10, 5, -10); scene.add(fillLight); const rimLight = new THREE.DirectionalLight(0xf472b6, 0.2); rimLight.position.set(0, -10, -5); scene.add(rimLight); window.gridHelper = new THREE.GridHelper(60, 60, isLight ? 0xcbd5e1 : 0x1e293b, isLight ? 0xcbd5e1 : 0x1e293b); window.gridHelper.position.y = -15; scene.add(window.gridHelper); axesHelper = new THREE.AxesHelper(20); axesHelper.visible = false; scene.add(axesHelper); const colors = [0x22d3ee, 0xf472b6, 0xa3e635, 0xfbbf24, 0xa78bfa, 0xfb923c]; const modelFiles = (model.files || []).filter(f => f.file_type === 'stl' || f.file_type === '3mf'); let loadedCount = 0; let totalFaces = 0; let globalBox = new THREE.Box3(); modelFiles.forEach((mf, idx) => { const loader = new THREE.STLLoader(); const fileUrl = '/uploads/' + encodeURIComponent(mf.filename); loader.load(fileUrl, (geometry) => { geometry.computeVertexNormals(); const material = new THREE.MeshStandardMaterial({ color: colors[idx % colors.length], metalness: 0.1, roughness: 0.4, emissive: colors[idx % colors.length], emissiveIntensity: 0.05, side: THREE.DoubleSide, clippingPlanes: [], }); const mesh = new THREE.Mesh(geometry, material); mesh.castShadow = true; mesh.receiveShadow = true; mesh.userData.fileId = mf.id; mesh.userData.originalMaterial = material.clone(); scene.add(mesh); viewerMeshes.push(mesh); geometry.computeBoundingBox(); const center = new THREE.Vector3(); geometry.boundingBox.getCenter(center); mesh.position.sub(center); globalBox.expandByObject(mesh); const size = new THREE.Vector3(); geometry.boundingBox.getSize(size); const maxDim = Math.max(size.x, size.y, size.z); if (maxDim > 0) { const scale = 25 / maxDim; mesh.scale.setScalar(scale); } totalFaces += geometry.attributes.position.count / 3; loadedCount++; if (loadedCount === modelFiles.length) { if (loadingEl) loadingEl.style.display = 'none'; if (statusEl) statusEl.textContent = `${Math.round(totalFaces)} caras · ${formatSize(model.file_size)}`; const boxCenter = new THREE.Vector3(); globalBox.getCenter(boxCenter); controls.target.copy(boxCenter); camera.position.set(boxCenter.x, boxCenter.y, boxCenter.z + 50); controls.update(); const bottom = globalBox.min.y; window.gridHelper.position.y = bottom - 2; } }, undefined, (err) => { console.error('Error cargando STL:', err); loadedCount++; }); }); if (modelFiles.length === 0) { if (loadingEl) loadingEl.innerHTML = '

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 = `
Watertight: ${data.is_watertight ? 'Si' : 'No'}
Volumen: ${data.volume_cm3} cm³
Area: ${data.surface_area_cm2} cm²
Caras: ${data.face_count}
Vertices: ${data.vertex_count}
Euler: ${data.euler_number}
Agujeros estimados: ${data.estimated_holes}
`; } catch (err) { 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 = `
Volumen: ${data.volume_cm3} cm³
Peso: ${data.grams} g
Costo: $${data.cost}
Tiempo: ${data.estimated_time}
`; } catch (err) { 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 = `
${results.map((r, i) => r.error ? `
${r.part.filename}: Error al calcular
` : `
${r.part_name} ${r.fitText}
${r.width_mm}x${r.depth_mm}
220x220mm
Volumen: ${r.volume_cm3} cm³
Peso: ${r.grams} g
Alto: ${r.height_mm} mm
Costo: $${r.cost}
Tiempo: ${r.estimated_time}
`).join('')}

Total estimado (${results.length} placa${results.length > 1 ? 's' : ''})

Peso: ${totalGrams.toFixed(2)} g
Costo: $${totalCost.toFixed(2)}
Tiempo: ${totalHours}h ${totalMins}m
`; } // ====== COMPARISON ====== async function openCompareModal() { const modal = document.getElementById('compare-modal'); const list = document.getElementById('compare-list'); if (!modal || !list) return; modal.classList.remove('hidden'); list.innerHTML = '

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'}

`).join(''); } catch (e) { list.innerHTML = '

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(); }