- Galeria: cards muestran imagen de referencia si existe, fallback a thumbnail 3D - Detalle: nueva seccion 'Placas de impresion' con estimacion individual por parte - Endpoint /estimate ahora acepta file_id para calcular por archivo - Visualizacion de placa 220x220mm con proporcion de pieza y totales acumulados
1037 lines
39 KiB
JavaScript
1037 lines
39 KiB
JavaScript
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 = `
|
|
<div class="max-w-xl mx-auto text-center py-20">
|
|
<div class="w-20 h-20 rounded-full bg-slate-900 flex items-center justify-center mx-auto mb-6">
|
|
<svg class="w-10 h-10 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
|
</div>
|
|
<h2 class="text-2xl font-bold mb-2">Modelo no encontrado</h2>
|
|
<p class="text-slate-400 mb-6">El modelo que buscas no existe o ha sido eliminado.</p>
|
|
<a href="/" class="px-6 py-3 rounded-xl bg-cyan-500 hover:bg-cyan-400 text-white font-bold transition-colors">Volver a la galeria</a>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
function renderStars(avg, count) {
|
|
if (!avg) return '<span class="text-xs text-slate-500">Sin valoraciones</span>';
|
|
const full = Math.floor(avg);
|
|
const half = avg - full >= 0.5;
|
|
let html = '<span class="flex items-center gap-1">';
|
|
for (let i = 0; i < 5; i++) {
|
|
if (i < full) {
|
|
html += '<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>';
|
|
} else if (i === full && half) {
|
|
html += '<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 20 20"><defs><linearGradient id="half"><stop offset="50%" stop-color="currentColor"/><stop offset="50%" stop-color="transparent"/></linearGradient></defs><path fill="url(#half)" d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>';
|
|
} else {
|
|
html += '<svg class="w-4 h-4 text-slate-600" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>';
|
|
}
|
|
}
|
|
html += `<span class="text-xs text-slate-500 ml-1">${avg.toFixed(1)} (${count})</span></span>`;
|
|
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 =>
|
|
`<a href="/?tag=${encodeURIComponent(t.name)}" class="px-2.5 py-1 rounded-lg bg-cyan-500/10 text-cyan-400 text-xs font-medium border border-cyan-500/20 hover:bg-cyan-500/20 transition-colors">${t.name}</a>`
|
|
).join('');
|
|
} else {
|
|
tagsEl.innerHTML = '<span class="text-slate-500 text-sm">—</span>';
|
|
}
|
|
}
|
|
|
|
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 = `
|
|
<div class="mb-3">
|
|
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-2">Partes (${modelFiles.length})</label>
|
|
<div class="space-y-2">
|
|
${modelFiles.map((f, i) => `
|
|
<div class="flex items-center gap-3 p-2.5 rounded-xl bg-slate-900/50 border border-white/5">
|
|
<input type="checkbox" id="part-${f.id}" checked onchange="togglePart(${f.id})" class="w-4 h-4 rounded border-slate-600 text-cyan-500 focus:ring-cyan-500/20 bg-slate-800">
|
|
<div class="w-3 h-3 rounded-full" style="background:${colors[i % colors.length]}"></div>
|
|
<span class="text-sm flex-1 truncate">${f.part_name || f.filename}</span>
|
|
<a href="/api/models/${m.id}/download?file_id=${f.id}" class="text-xs text-cyan-400 hover:text-cyan-300 transition-colors" title="Descargar">⬇</a>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 = `
|
|
<div class="mb-3">
|
|
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-2">Imagenes de referencia</label>
|
|
<div class="grid grid-cols-2 gap-2">
|
|
${images.map(img => `
|
|
<a href="/images/${encodeURIComponent(img.filename)}" target="_blank" class="rounded-xl overflow-hidden border border-white/5 hover:border-cyan-500/30 transition-colors">
|
|
<img src="/images/${encodeURIComponent(img.filename)}" alt="${img.filename}" class="w-full h-24 object-cover hover:scale-105 transition-transform">
|
|
</a>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderRatings(m) {
|
|
const container = document.getElementById('ratings-section');
|
|
if (!container) return;
|
|
const ratings = m.ratings || [];
|
|
container.innerHTML = `
|
|
<div class="mb-4">
|
|
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-2">Valoraciones</label>
|
|
<div class="flex items-center gap-3 mb-3">
|
|
${renderStars(m.avg_rating, ratings.length)}
|
|
</div>
|
|
${ratings.length > 0 ? `
|
|
<div class="space-y-2 max-h-32 overflow-y-auto pr-1">
|
|
${ratings.slice(0, 5).map(r => `
|
|
<div class="flex items-center gap-2 text-sm">
|
|
<span class="text-yellow-400">${'★'.repeat(r.stars)}${'☆'.repeat(5 - r.stars)}</span>
|
|
<span class="text-slate-500 text-xs">${new Date(r.created_at).toLocaleDateString()}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
` : '<p class="text-sm text-slate-500">Aun no hay valoraciones</p>'}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderComments(m) {
|
|
const container = document.getElementById('comments-list');
|
|
if (!container) return;
|
|
const comments = m.comments || [];
|
|
if (comments.length === 0) {
|
|
container.innerHTML = '<p class="text-sm text-slate-500">Aun no hay comentarios. Se el primero!</p>';
|
|
return;
|
|
}
|
|
container.innerHTML = comments.map(c => `
|
|
<div class="p-3 rounded-xl bg-slate-900/50 border border-white/5">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-sm font-medium">${c.author_name || 'Anonimo'}</span>
|
|
<span class="text-xs text-slate-500">${new Date(c.created_at).toLocaleDateString()}</span>
|
|
</div>
|
|
<p class="text-sm text-slate-300">${c.text}</p>
|
|
</div>
|
|
`).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 = '<option value="">Seleccionar coleccion...</option>' +
|
|
collections.map(c => `<option value="${c.id}">${c.name} (${c.model_count})</option>`).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 = '<p class="text-red-400">No hay archivos 3D para mostrar</p>';
|
|
}
|
|
|
|
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 = '<p class="text-slate-400">Analizando malla...</p>';
|
|
try {
|
|
const data = await apiGet(`/models/${getModelId()}/validate`);
|
|
container.innerHTML = `
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<div><span class="text-slate-500">Watertight:</span> <span class="${data.is_watertight ? 'text-green-400' : 'text-red-400'}">${data.is_watertight ? 'Si' : 'No'}</span></div>
|
|
<div><span class="text-slate-500">Volumen:</span> <span class="text-slate-200">${data.volume_cm3} cm³</span></div>
|
|
<div><span class="text-slate-500">Area:</span> <span class="text-slate-200">${data.surface_area_cm2} cm²</span></div>
|
|
<div><span class="text-slate-500">Caras:</span> <span class="text-slate-200">${data.face_count}</span></div>
|
|
<div><span class="text-slate-500">Vertices:</span> <span class="text-slate-200">${data.vertex_count}</span></div>
|
|
<div><span class="text-slate-500">Euler:</span> <span class="text-slate-200">${data.euler_number}</span></div>
|
|
<div><span class="text-slate-500">Agujeros estimados:</span> <span class="text-slate-200">${data.estimated_holes}</span></div>
|
|
</div>
|
|
`;
|
|
} catch (err) {
|
|
container.innerHTML = `<p class="text-red-400">Error: ${err.message}</p>`;
|
|
}
|
|
}
|
|
|
|
// ====== ESTIMATION ======
|
|
async function runEstimation() {
|
|
const container = document.getElementById('estimation-result');
|
|
if (!container) return;
|
|
container.classList.remove('hidden');
|
|
container.innerHTML = '<p class="text-slate-400">Calculando...</p>';
|
|
try {
|
|
const data = await apiGet(`/models/${getModelId()}/estimate`);
|
|
container.innerHTML = `
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<div><span class="text-slate-500">Volumen:</span> <span class="text-slate-200">${data.volume_cm3} cm³</span></div>
|
|
<div><span class="text-slate-500">Peso:</span> <span class="text-slate-200">${data.grams} g</span></div>
|
|
<div><span class="text-slate-500">Costo:</span> <span class="text-cyan-400 font-bold">$${data.cost}</span></div>
|
|
<div><span class="text-slate-500">Tiempo:</span> <span class="text-slate-200">${data.estimated_time}</span></div>
|
|
</div>
|
|
`;
|
|
} catch (err) {
|
|
container.innerHTML = `<p class="text-red-400">Error: ${err.message}</p>`;
|
|
}
|
|
}
|
|
|
|
// ====== 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 = '<p class="text-sm text-slate-500">No hay partes para estimar.</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = '<p class="text-sm text-slate-400">Calculando placas...</p>';
|
|
|
|
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 = `
|
|
<div class="space-y-4">
|
|
${results.map((r, i) => r.error ? `
|
|
<div class="p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-sm text-red-400">
|
|
${r.part.filename}: Error al calcular
|
|
</div>
|
|
` : `
|
|
<div class="p-4 rounded-xl bg-slate-900/50 border border-white/5">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="text-sm font-medium">${r.part_name}</span>
|
|
<span class="text-[10px] px-2 py-0.5 rounded-full ${r.fitColor} text-white">${r.fitText}</span>
|
|
</div>
|
|
<div class="flex gap-4">
|
|
<div class="shrink-0">
|
|
<div class="relative w-[200px] h-[200px] border border-slate-600 rounded bg-slate-800/50" style="background-image: linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px); background-size: 18.18px 18.18px;">
|
|
<div class="absolute rounded bg-cyan-500/30 border border-cyan-400/60 flex items-center justify-center text-[10px] text-cyan-300" style="width:${r.rectW}px; height:${r.rectH}px; left:4px; top:4px;">
|
|
${r.width_mm}x${r.depth_mm}
|
|
</div>
|
|
<span class="absolute bottom-1 right-1 text-[9px] text-slate-500">220x220mm</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex-1 grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
|
<div><span class="text-slate-500">Volumen:</span> <span class="text-slate-200">${r.volume_cm3} cm³</span></div>
|
|
<div><span class="text-slate-500">Peso:</span> <span class="text-slate-200">${r.grams} g</span></div>
|
|
<div><span class="text-slate-500">Alto:</span> <span class="text-slate-200">${r.height_mm} mm</span></div>
|
|
<div><span class="text-slate-500">Costo:</span> <span class="text-cyan-400 font-bold">$${r.cost}</span></div>
|
|
<div class="col-span-2"><span class="text-slate-500">Tiempo:</span> <span class="text-slate-200">${r.estimated_time}</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
|
|
<div class="p-4 rounded-xl bg-cyan-500/10 border border-cyan-500/20">
|
|
<p class="text-sm font-bold text-cyan-400 mb-1">Total estimado (${results.length} placa${results.length > 1 ? 's' : ''})</p>
|
|
<div class="grid grid-cols-3 gap-3 text-xs">
|
|
<div><span class="text-slate-500">Peso:</span> <span class="text-slate-200">${totalGrams.toFixed(2)} g</span></div>
|
|
<div><span class="text-slate-500">Costo:</span> <span class="text-cyan-400 font-bold">$${totalCost.toFixed(2)}</span></div>
|
|
<div><span class="text-slate-500">Tiempo:</span> <span class="text-slate-200">${totalHours}h ${totalMins}m</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ====== 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 = '<p class="text-sm text-slate-500">Cargando...</p>';
|
|
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 = '<p class="text-sm text-slate-500">No hay otros modelos para comparar.</p>';
|
|
return;
|
|
}
|
|
list.innerHTML = others.map(m => `
|
|
<div class="flex items-center gap-3 p-2.5 rounded-xl bg-slate-900/50 border border-white/5 hover:border-cyan-500/30 cursor-pointer transition-all" onclick="compareWith(${m.id})">
|
|
<img src="/api/models/${m.id}/thumbnail" class="w-10 h-10 rounded-lg object-cover bg-slate-800" onerror="this.style.display='none'">
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-medium truncate">${m.title}</p>
|
|
<p class="text-xs text-slate-500">${m.faces ?? '?'} caras · ${m.author || 'Anonimo'}</p>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
} catch (e) {
|
|
list.innerHTML = '<p class="text-sm text-red-400">Error cargando modelos.</p>';
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|