feat: galeria muestra foto impresa + estimacion por placa en detalle

- 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
This commit is contained in:
Consultoria AS
2026-05-01 08:23:03 +00:00
parent 7a66cc1d6e
commit 0764be4945
4 changed files with 129 additions and 5 deletions

View File

@@ -171,6 +171,17 @@
<div id="validation-result" class="hidden p-4 rounded-xl bg-slate-900/50 border border-white/5 text-sm space-y-1"></div>
<div id="estimation-result" class="hidden p-4 rounded-xl bg-slate-900/50 border border-white/5 text-sm space-y-1"></div>
<!-- Plates Estimation -->
<div class="glass rounded-2xl p-5 border border-white/5">
<div class="flex items-center justify-between mb-3">
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider block">Placas de impresion</label>
<button onclick="runPlateEstimation()" class="px-3 py-1.5 rounded-lg bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30 text-xs font-medium transition-colors">
Calcular placas
</button>
</div>
<div id="plates-result" class="space-y-3"></div>
</div>
<div class="pt-4 flex gap-3">
<a id="btn-download" href="#" class="flex-1 px-5 py-3 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-sm shadow-lg shadow-cyan-500/20 transition-all hover:scale-[1.02] text-center flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>

View File

@@ -58,6 +58,13 @@ function renderGrid(models, append = false) {
`<span class="px-1.5 py-0.5 rounded bg-cyan-500/10 text-cyan-400 text-[10px] font-medium">${t.name}</span>`
).join('');
// Use reference image if available, fallback to 3D thumbnail
const refImage = (m.files || []).find(f => f.file_type === 'image');
const imgUrl = refImage
? `/images/${encodeURIComponent(refImage.filename)}`
: `/api/models/${m.id}/thumbnail`;
const imgLabel = refImage ? 'Foto' : 'Vista 3D';
card.innerHTML = `
<div class="relative h-52 bg-slate-900 overflow-hidden">
${selectionMode ? `
@@ -67,7 +74,7 @@ function renderGrid(models, append = false) {
</div>
</div>
` : ''}
<img src="/api/models/${m.id}/thumbnail" alt="${m.title}" loading="lazy" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" onerror="this.style.display='none'; this.parentElement.innerHTML='<div class=\'w-full h-full flex items-center justify-center text-slate-600\'><svg class=\'w-10 h-10\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'1.5\' d=\'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4\'/></svg></div>';">
<img src="${imgUrl}" alt="${m.title}" loading="lazy" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" onerror="this.onerror=null; this.src='/api/models/${m.id}/thumbnail';">
<div class="absolute top-3 right-3">
<span class="px-2.5 py-1 rounded-lg bg-black/50 backdrop-blur text-xs font-medium text-white border border-white/10">${m.faces ?? '?'} caras</span>
</div>

View File

@@ -900,6 +900,101 @@ async function runEstimation() {
}
}
// ====== 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');