/* presa-mano GUI */ const $ = (id) => document.getElementById(id); // ---------- tab switching ---------- document.querySelectorAll('.tab').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.tab').forEach(b => b.classList.remove('active')); document.querySelectorAll('.panel').forEach(p => p.classList.remove('active')); btn.classList.add('active'); $('tab-' + btn.dataset.tab).classList.add('active'); }); }); function setStatus(msg, cls = '') { const strip = $('statusStrip'); strip.textContent = msg || ''; strip.style.color = cls === 'err' ? 'var(--danger)' : cls === 'ok' ? 'var(--ok)' : 'var(--dim)'; } // ---------- button flash + toast feedback ---------- // Used by every command-sending handler so the user sees an immediate, // unambiguous success/failure signal (beyond the log panels that require // scrolling). Call flashBtn(btn, ok) to flash the button, and toast(title, // body, ok) to show a corner notification. function flashBtn(btn, ok) { if (!btn || !btn.classList) return; btn.classList.remove('flash-ok', 'flash-err'); // restart animation void btn.offsetWidth; btn.classList.add(ok ? 'flash-ok' : 'flash-err'); setTimeout(() => btn.classList.remove(ok ? 'flash-ok' : 'flash-err'), ok ? 900 : 1300); } function toast(title, body, ok = true, ttlMs = 3500) { const host = $('toastHost'); if (!host) return; const el = document.createElement('div'); el.className = 'toast ' + (ok ? 'ok' : 'err'); const t = document.createElement('div'); t.className = 't-title'; t.textContent = (ok ? '✓ ' : '✗ ') + title; el.appendChild(t); if (body) { const b = document.createElement('div'); b.className = 't-body'; b.textContent = typeof body === 'string' ? body : JSON.stringify(body); el.appendChild(b); } host.appendChild(el); setTimeout(() => { el.classList.add('fade'); }, ttlMs - 400); setTimeout(() => { el.remove(); }, ttlMs); } // Short synthetic beep via Web Audio — no external asset. Used to audibly flag // new anomalies that flip the system from OK -> WARN/ERR. Gated by the // `diagBeep` checkbox so users can silence it. function beep(freq = 880, ms = 140, gain = 0.18) { if (!window.AudioContext && !window.webkitAudioContext) return; try { const ctx = beep._ctx || (beep._ctx = new (window.AudioContext || window.webkitAudioContext)()); const osc = ctx.createOscillator(); const g = ctx.createGain(); osc.type = 'sine'; osc.frequency.value = freq; g.gain.value = gain; osc.connect(g); g.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + ms / 1000); } catch {} } function beepWarn() { beep(880, 130); } function beepErr() { beep(440, 150); setTimeout(() => beep(330, 180), 170); } // Summarise a successful response in one short line — prefer "dry-run" hint // or response 'ok', else truncated JSON. function summarizeOk(resp) { if (!resp || typeof resp !== 'object') return ''; if (resp.dry_run) return 'dry-run (nessun comando reale inviato)'; if (resp.ok === true && Object.keys(resp).length <= 3) return 'ok'; const short = JSON.stringify(resp); return short.length > 140 ? short.slice(0, 140) + '…' : short; } function fmtTimings(t) { if (!t || typeof t !== 'object') return ''; // Put 'total' last; others in insertion order. const keys = Object.keys(t).filter(k => k !== 'total'); const parts = keys.map(k => `${k}=${Number(t[k]).toFixed(1)}ms`); if (t.total != null) parts.push(`TOTAL=${Number(t.total).toFixed(1)}ms`); return parts.join(' '); } async function api(method, path, body = null) { const opts = { method, headers: {} }; if (body !== null) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } const r = await fetch(path, opts); const text = await r.text(); let data; try { data = JSON.parse(text); } catch { data = text; } if (!r.ok) { const detail = (data && data.detail) || text || r.statusText; throw new Error(`${r.status} ${detail}`); } return data; } // ==================== TROVA UTENSILE ==================== const graspCanvas = $('graspCanvas'); const graspCtx = graspCanvas.getContext('2d'); let displayedGrasp = null; let graspRgbInFlight = false; // (rimossi cropCanvas/scatter — ora c'è findResultCanvas) let lastDetections = []; // all detections, sorted by confidence desc let lastSelectedClass = null; // class name whose point cloud is displayed let knownClasses = []; // names discovered from /api/perception/classes // Stable per-class color palette (Okabe-Ito, colour-blind safe). const CLASS_PALETTE = ['#4da3ff', '#f39c12', '#2ecc71', '#e74c3c', '#9b59b6', '#1abc9c', '#e67e22', '#fd79a8']; function colorForClass(name) { if (!name) return '#888'; let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0; return CLASS_PALETTE[Math.abs(h) % CLASS_PALETTE.length]; } function drawGraspFrame() { if (displayedGrasp) { if (graspCanvas.width !== displayedGrasp.naturalWidth) { graspCanvas.width = displayedGrasp.naturalWidth; graspCanvas.height = displayedGrasp.naturalHeight; } graspCtx.drawImage(displayedGrasp, 0, 0); } lastDetections.forEach(d => { const b = d.bbox; const color = colorForClass(d.class); const isSel = lastSelectedClass && d.class === lastSelectedClass; graspCtx.strokeStyle = color; graspCtx.lineWidth = isSel ? 4 : 2; graspCtx.setLineDash(isSel ? [] : [6, 4]); graspCtx.strokeRect(b.x1, b.y1, b.x2 - b.x1, b.y2 - b.y1); graspCtx.setLineDash([]); const label = `${d.class || '?'} ${(d.confidence*100).toFixed(0)}%${isSel ? ' ★' : ''}`; graspCtx.font = 'bold 13px sans-serif'; const tw = graspCtx.measureText(label).width + 8; const ty = b.y1 > 18 ? b.y1 - 4 : b.y1 + 16; graspCtx.fillStyle = color; graspCtx.fillRect(b.x1, ty - 14, tw, 18); graspCtx.fillStyle = '#0b0f14'; graspCtx.fillText(label, b.x1 + 4, ty); }); } function refreshGraspRgb() { if (graspRgbInFlight) return; graspRgbInFlight = true; const img = new Image(); img.onload = () => { displayedGrasp = img; graspRgbInFlight = false; drawGraspFrame(); }; img.onerror = () => { graspRgbInFlight = false; }; img.src = `/api/calibration/rgb.jpg?t=${Date.now()}`; } setInterval(() => { if ($('tab-grasp').classList.contains('active')) refreshGraspRgb(); }, 150); // click-to-measure on RGB graspCanvas.addEventListener('click', async (ev) => { const rect = graspCanvas.getBoundingClientRect(); const u = Math.round((ev.clientX - rect.left) * (graspCanvas.width / rect.width)); const v = Math.round((ev.clientY - rect.top) * (graspCanvas.height / rect.height)); $('measureInfo').textContent = `measuring (u=${u}, v=${v})…`; try { const r = await api('GET', `/api/perception/measure?u=${u}&v=${v}&radius=6`); const cam = r.camera || {}; let line = `(u=${u}, v=${v}) cam: `; const xyz = (o) => o && (o.x !== undefined ? `x=${(+o.x).toFixed(3)} y=${(+o.y).toFixed(3)} z=${(+o.z).toFixed(3)}` : JSON.stringify(o)); line += xyz(cam.point || cam.xyz || cam); const d = cam.distance_m ?? cam.distance ?? null; if (d != null) line += ` d=${(+d*100).toFixed(1)} cm`; if (r.base) line += ` base: ${xyz(r.base)}`; const t = fmtTimings(r.timings_ms); if (t) line += ` [${t}]`; $('measureInfo').textContent = line; // marker graspCtx.fillStyle = '#e74c3c'; graspCtx.beginPath(); graspCtx.arc(u, v, 5, 0, 2*Math.PI); graspCtx.fill(); } catch (e) { $('measureInfo').textContent = `measure error: ${e.message}`; } }); function pickedClass() { const v = $('graspObjectPick').value; return v ? v : null; } // Apply a /scan response to local state + canvases. function applyScanResponse(r) { lastDetections = Array.isArray(r.detections) ? r.detections : []; const sel = r.selected || null; lastSelectedClass = sel ? sel.class : null; refreshObjectPickOptions(lastDetections); renderGraspLegend(lastDetections); drawGraspFrame(); } $('detectBtn').addEventListener('click', async () => { try { setStatus('YOLO + pointcloud…'); const body = { conf: +$('graspConf').value || null, class_name: pickedClass(), include_points: true, }; const r = await api('POST', '/api/perception/scan', body); applyScanResponse(r); $('graspOut').textContent = JSON.stringify({ detections: r.detections, selected: r.selected, n_points: r.n_points, frame: r.points_base ? 'base' : 'camera', timings_ms: r.timings_ms, }, null, 2); const nDet = r.detections ? r.detections.length : 0; setStatus(`${nDet} detection · ${r.n_points || 0} punti · ${fmtTimings(r.timings_ms)}`, 'ok'); } catch (e) { setStatus(e.message, 'err'); } }); // --- continuous detection loop --- const STATS_WINDOW = 5; const MISS_LIMIT = 5; // consecutive frames without detection before clearing the view let loopTimer = null; let loopInFlight = false; let recentCentroids = []; // [[x,y,z], ...] in base frame if calibrated, else camera let missStreak = 0; function clearDetectionState(reason) { lastDetections = []; lastSelectedClass = null; recentCentroids = []; drawGraspFrame(); // redraws RGB without bbox overlay updateStats(); // resets table to dashes refreshObjectPickOptions([]); renderGraspLegend([]); $('graspOut').textContent = reason || '(nessuna detection)'; } function centroidOf(pts) { if (!pts || !pts.length) return null; let sx = 0, sy = 0, sz = 0; for (const p of pts) { sx += p[0]; sy += p[1]; sz += p[2]; } return [sx / pts.length, sy / pts.length, sz / pts.length]; } function updateStats() { const rows = $('statsTable').querySelectorAll('tbody tr'); const empty = ['—','—','—','—','—']; $('statsWindow').textContent = STATS_WINDOW; if (recentCentroids.length === 0) { rows.forEach(r => { r.children[1].textContent='—'; r.children[2].textContent='—'; r.children[3].textContent='—'; r.children[4].textContent='—'; r.children[5].textContent='—'; }); return; } const n = recentCentroids.length; for (let axis = 0; axis < 3; axis++) { const vals = recentCentroids.map(c => c[axis]); const mean = vals.reduce((a,b) => a+b, 0) / n; const vmin = Math.min(...vals), vmax = Math.max(...vals); const variance = n > 1 ? vals.reduce((s,v) => s + (v-mean)*(v-mean), 0) / (n - 1) : 0; const std = Math.sqrt(variance); const r = rows[axis]; r.children[1].textContent = mean.toFixed(4); r.children[2].textContent = (std * 1000).toFixed(2); r.children[3].textContent = ((vmax - vmin) * 1000).toFixed(2); r.children[4].textContent = vmin.toFixed(4); r.children[5].textContent = vmax.toFixed(4); } } async function loopTick() { if (loopInFlight) return; loopInFlight = true; const t0 = performance.now(); try { const body = { conf: +$('graspConf').value || null, class_name: pickedClass(), include_points: true, }; const r = await api('POST', '/api/perception/scan', body); const pts = r.points_base || r.points_camera || []; const frame = r.points_base ? 'base' : 'camera'; lastDetections = Array.isArray(r.detections) ? r.detections : []; lastSelectedClass = r.selected ? r.selected.class : null; refreshObjectPickOptions(lastDetections); renderGraspLegend(lastDetections); drawGraspFrame(); const c = centroidOf(pts); if (c) { recentCentroids.push(c); if (recentCentroids.length > STATS_WINDOW) recentCentroids.shift(); updateStats(); } const nDet = lastDetections.length; if (nDet === 0) throw new Error('404 no object detected'); missStreak = 0; const dt = performance.now() - t0; const selMsg = r.selected ? `sel=${lastSelectedClass}` : (pickedClass() ? `sel=${pickedClass()} NON rilevata` : 'sel=—'); $('statsMeta').textContent = `loop ON · ${nDet} detection · ${selMsg} · frame=${frame} · n=${r.n_points} · iter ${dt.toFixed(0)}ms · ${fmtTimings(r.timings_ms)}`; $('statsMeta').style.color = r.selected ? 'var(--ok)' : 'var(--warn)'; } catch (e) { const msg = e.message || ''; // Classify: // - true miss (404/422): object not detected → increments miss streak // - transient upstream (500/502/503 from YOLO/Nuwa): skip silently, no streak bump // - anything else: surface as real error const isMiss = /^404\b|no object|^422\b|too few/i.test(msg); const isTransient = /^(500|502|503)\b|bad gateway|service unavailable|temporarily/i.test(msg); if (isMiss) { missStreak++; if (missStreak >= MISS_LIMIT) { clearDetectionState(`nessun oggetto per ${MISS_LIMIT} frame consecutivi`); $('statsMeta').textContent = `loop ON · nessun oggetto per ${MISS_LIMIT} frame → dati azzerati`; $('statsMeta').style.color = 'var(--warn)'; } else { $('statsMeta').textContent = `loop ON · miss ${missStreak}/${MISS_LIMIT}`; $('statsMeta').style.color = 'var(--warn)'; } } else if (isTransient) { $('statsMeta').textContent = `loop ON · upstream busy (skip): ${msg}`; $('statsMeta').style.color = 'var(--warn)'; } else { $('statsMeta').textContent = `loop error: ${msg}`; $('statsMeta').style.color = 'var(--danger)'; } } finally { loopInFlight = false; } } function startLoop() { if (loopTimer) return; const rate = Math.max(100, +$('loopRateMs').value || 500); missStreak = 0; recentCentroids = []; updateStats(); loopTick(); loopTimer = setInterval(loopTick, rate); const b = $('loopBtn'); b.textContent = '■ Stop loop'; b.classList.add('running'); $('statsMeta').textContent = 'loop ON'; $('statsMeta').style.color = 'var(--ok)'; } function stopLoop() { if (loopTimer) { clearInterval(loopTimer); loopTimer = null; } const b = $('loopBtn'); b.textContent = '▶ Loop riconoscimento'; b.classList.remove('running'); $('statsMeta').textContent = 'loop inattivo'; $('statsMeta').style.color = 'var(--dim)'; } $('loopBtn').addEventListener('click', () => (loopTimer ? stopLoop() : startLoop())); // If user leaves the tab, pause to avoid background load document.querySelectorAll('.tab').forEach(btn => btn.addEventListener('click', () => { if (btn.dataset.tab !== 'grasp' && loopTimer) stopLoop(); })); // --- object pick select + legend + class discovery --- // Build per-class summary: best (highest-confidence) detection and instance count. function bestPerClass(dets) { const byClass = new Map(); const counts = new Map(); for (const d of dets) { if (!d.class) continue; counts.set(d.class, (counts.get(d.class) || 0) + 1); const cur = byClass.get(d.class); if (!cur || d.confidence > cur.confidence) byClass.set(d.class, d); } return { byClass, counts }; } function refreshObjectPickOptions(dets) { const sel = $('graspObjectPick'); const prev = sel.value; // known classes (from /classes, if any) keep a stable entry order; // detected-only classes are added after. const known = new Set(knownClasses); const ordered = []; knownClasses.forEach(n => ordered.push(n)); dets.forEach(d => { if (d.class && !known.has(d.class)) { ordered.push(d.class); known.add(d.class); } }); const { byClass, counts } = bestPerClass(dets); sel.innerHTML = ''; const opt0 = document.createElement('option'); opt0.value = ''; opt0.textContent = '(top overall)'; sel.appendChild(opt0); for (const name of ordered) { const d = byClass.get(name); const n = counts.get(name) || 0; const o = document.createElement('option'); o.value = name; const suffix = n > 1 ? ` ×${n}` : ''; o.textContent = d ? `${name} · ${(d.confidence*100).toFixed(0)}%${suffix}` : `${name} · —`; if (!d) o.style.color = '#888'; sel.appendChild(o); } // Preserve the previous selection if still valid, else fall back to '(top)'. const stillAvailable = prev === '' || ordered.includes(prev); sel.value = stillAvailable ? prev : ''; } function renderGraspLegend(dets) { const host = $('graspLegend'); if (!host) return; const known = new Set(knownClasses); const ordered = []; knownClasses.forEach(n => ordered.push(n)); dets.forEach(d => { if (d.class && !known.has(d.class)) { ordered.push(d.class); known.add(d.class); } }); const { byClass, counts } = bestPerClass(dets); host.innerHTML = ''; if (ordered.length === 0) { host.textContent = '(nessuna classe)'; return; } for (const name of ordered) { const d = byClass.get(name); const n = counts.get(name) || 0; const item = document.createElement('span'); item.className = 'item' + (name === lastSelectedClass ? ' selected' : '') + (!d ? ' missing' : ''); const sw = document.createElement('span'); sw.className = 'sw'; sw.style.background = colorForClass(name); item.appendChild(sw); const suffix = n > 1 ? ` ×${n}` : ''; item.appendChild(document.createTextNode( d ? `${name} ${(d.confidence*100).toFixed(0)}%${suffix}` : `${name} (non rilevata)` )); host.appendChild(item); } } async function loadKnownClasses() { try { const r = await api('GET', '/api/perception/classes'); knownClasses = (r.classes || []).map(c => c.name).filter(Boolean); } catch (_e) { knownClasses = []; } refreshObjectPickOptions(lastDetections); renderGraspLegend(lastDetections); } loadKnownClasses(); // Changing the pick while the loop is idle → fire a one-off scan so the cloud // updates immediately. While looping, the next tick picks it up automatically. $('graspObjectPick').addEventListener('change', () => { renderGraspLegend(lastDetections); drawGraspFrame(); if (!loopTimer) $('detectBtn').click(); }); // ==================== FIT MODELLO ==================== const findCanvas = $('findResultCanvas'); const findCtx = findCanvas.getContext('2d'); let findResult = null; let findRgbImg = null; async function loadRecipes() { const sel = $('findModelPick'); try { const r = await api('GET', '/api/models/recipes'); sel.innerHTML = ''; (r.recipes || []).forEach(rec => { const opt = document.createElement('option'); opt.value = rec.id; opt.textContent = `${rec.name} (${rec.symmetry_type})`; sel.appendChild(opt); }); sel.disabled = (r.recipes || []).length === 0; $('findBtn').disabled = !sel.value; } catch (e) { sel.innerHTML = ``; } } $('findModelPick').addEventListener('change', () => { $('findBtn').disabled = !$('findModelPick').value; }); $('findRecipesRefreshBtn').addEventListener('click', loadRecipes); $('findBtn').addEventListener('click', async () => { const recipeId = $('findModelPick').value; if (!recipeId) return; $('findBtn').disabled = true; $('findStatus').textContent = 'Cerco posa…'; $('findStatus').className = 'status-line'; try { const body = { recipe_id: recipeId, conf: +$('graspConf').value || null, class_name: pickedClass(), }; const r = await api('POST', '/api/grasp/find_model', body); findResult = r; findRgbImg = new Image(); findRgbImg.onload = drawFindResult; findRgbImg.src = 'data:image/jpeg;base64,' + r.rgb_jpeg_b64; const fit = r.fit_ok ? `OK · fitness=${(r.fitness ?? 0).toFixed(2)} · rmse=${(r.inlier_rmse_mm ?? 0).toFixed(2)}mm` : `Fit fallito: ${r.fit_reason}`; $('findStatus').textContent = `${fit} · ${fmtTimings(r.timings_ms)}`; $('findStatus').className = 'status-line ' + (r.fit_ok ? 'ok' : 'warn'); $('findDetails').textContent = JSON.stringify({ detection: r.detection, recipe: r.recipe, n_accepted: r.n_accepted, n_excluded: r.n_excluded, fitness: r.fitness, rmse_mm: r.inlier_rmse_mm, timings: r.timings_ms, }, null, 2); } catch (e) { $('findStatus').textContent = `errore: ${e.message}`; $('findStatus').className = 'status-line err'; } finally { $('findBtn').disabled = !$('findModelPick').value; } }); ['findShowModel', 'findShowAccepted', 'findShowExcluded', 'findShowSam21'].forEach(id => $(id).addEventListener('change', drawFindResult)); function drawFindResult() { if (!findResult || !findRgbImg || !findRgbImg.complete) return; const [W, H] = findResult.rgb_size; if (findCanvas.width !== W) { findCanvas.width = W; findCanvas.height = H; } findCtx.drawImage(findRgbImg, 0, 0); const dot = (pts, color, r) => { findCtx.fillStyle = color; for (const [u, v] of pts) findCtx.fillRect(u - r, v - r, r*2+1, r*2+1); }; // Draw order: SAM21 cluster (background) -> excluded -> accepted -> model // (foreground), so the Open3DModel STL projection wins over everything. if ($('findShowSam21').checked) dot(findResult.sam21_points_px || [], '#ffd83d', 1); if ($('findShowExcluded').checked) dot(findResult.excluded_points_px, '#e74c3c', 1); if ($('findShowAccepted').checked) dot(findResult.accepted_points_px, '#3ecf8e', 1); if ($('findShowModel').checked) dot(findResult.model_points_px, '#4da3ff', 1); } function loadRecipesIfNeeded() { if ($('tab-grasp').classList.contains('active') && !$('findModelPick').dataset.loaded) { loadRecipes(); $('findModelPick').dataset.loaded = '1'; } } document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => setTimeout(loadRecipesIfNeeded, 50))); loadRecipesIfNeeded(); // ==================== EE ==================== async function refreshWrist() { try { const r = await api('GET', '/api/wrist'); $('wristCurrent').textContent = JSON.stringify(r, null, 2); } catch (e) { $('wristCurrent').textContent = e.message; } } $('refreshWristBtn').addEventListener('click', refreshWrist); $('absSendBtn').addEventListener('click', async () => { const body = { side: $('eeSide').value, position: [+$('absPx').value, +$('absPy').value, +$('absPz').value], quaternion: [+$('absQx').value, +$('absQy').value, +$('absQz').value, +$('absQw').value], wait: true, timeout_s: 5.0, precise: false, dry_run: $('eeDryRun').checked, }; try { $('eeLog').textContent = JSON.stringify(await api('POST', '/api/wrist/absolute', body), null, 2); } catch (e) { $('eeLog').textContent = e.message; } }); $('relSendBtn').addEventListener('click', async () => { const body = { side: $('eeSide').value, delta_position: [+$('relDx').value, +$('relDy').value, +$('relDz').value], wait: true, timeout_s: 5.0, dry_run: $('eeDryRun').checked, }; try { $('eeLog').textContent = JSON.stringify(await api('POST', '/api/wrist/relative', body), null, 2); } catch (e) { $('eeLog').textContent = e.message; } }); const handBtn = (id, action) => $(id).addEventListener('click', async () => { try { const r = await api('POST', '/api/wrist/hand', { action, dry_run: $('eeDryRun').checked }); $('eeLog').textContent = JSON.stringify(r, null, 2); } catch (e) { $('eeLog').textContent = e.message; } }); handBtn('handOpenBtn', 'RIGHT_OPEN'); handBtn('handCloseBtn', 'RIGHT_CLOSE'); handBtn('handOpenLBtn', 'LEFT_OPEN'); handBtn('handCloseLBtn', 'LEFT_CLOSE'); $('estopBtn').addEventListener('click', async () => { if (!confirm('EMERGENCY STOP: confermi?')) return; try { const r = await api('POST', `/api/wrist/emergency_stop?dry_run=${$('eeDryRun').checked}`); $('eeLog').textContent = JSON.stringify(r, null, 2); } catch (e) { $('eeLog').textContent = e.message; } }); $('unlockBtn').addEventListener('click', async () => { try { const r = await api('POST', `/api/wrist/unlock?dry_run=${$('eeDryRun').checked}`); $('eeLog').textContent = JSON.stringify(r, null, 2); } catch (e) { $('eeLog').textContent = e.message; } }); // ==================== VISTA 3D ==================== // Minimal canvas-2D 3D renderer: orthographic camera with yaw/pitch around a // target point, painter's-algorithm depth sorting, mouse drag/zoom/pan. const view3dCanvas = $('view3dCanvas'); const view3dCtx = view3dCanvas.getContext('2d'); const view3d = { // camera yaw: -0.6, // around base Z pitch: 0.5, // 0 = horizontal, π/2 = top-down scale: 350, // pixels per meter target: [0.30, 0.0, 0.20], // world point at canvas center // scene data wrist: null, // { left:{position,quaternion}, right:{...} } object: null, // { points:[[x,y,z],...], centroid:[x,y,z], n_points } plan: null, // { grasp:{position,quaternion_xyzw}, pre_grasp:{position} } calibrated: true, // false → object data unavailable lastErr: null, }; // ---- math helpers ---- function quatToR(q) { const x=q[0], y=q[1], z=q[2], w=q[3]; const n = x*x + y*y + z*z + w*w; if (n === 0) return [[1,0,0],[0,1,0],[0,0,1]]; const s = 2/n; return [ [1 - s*(y*y + z*z), s*(x*y - z*w), s*(x*z + y*w)], [s*(x*y + z*w), 1 - s*(x*x + z*z), s*(y*z - x*w)], [s*(x*z - y*w), s*(y*z + x*w), 1 - s*(x*x + y*y)], ]; } // Project world point → {sx, sy, depth}. depth = camera-axis distance for sorting. function project(p) { const dx = p[0] - view3d.target[0]; const dy = p[1] - view3d.target[1]; const dz = p[2] - view3d.target[2]; const cy = Math.cos(view3d.yaw), sy = Math.sin(view3d.yaw); // Yaw around Z const x1 = cy*dx + sy*dy; const y1 = -sy*dx + cy*dy; const z1 = dz; // Pitch around X (camera looks roughly toward +Y after yaw, tilting down by pitch) const cp = Math.cos(view3d.pitch), sp = Math.sin(view3d.pitch); const y2 = cp*y1 + sp*z1; const z2 = -sp*y1 + cp*z1; // Orthographic: screen X = +x1; screen Y = -z2 (so +Z up on screen) const sx = view3dCanvas.width / 2 + view3d.scale * x1; const sxy = view3dCanvas.height / 2 - view3d.scale * z2; // depth: y2 increases away from viewer → larger y2 = farther return { sx, sy: sxy, depth: y2 }; } function drawSegment(items, p1, p2, color, width = 2) { const a = project(p1), b = project(p2); items.push({ depth: (a.depth + b.depth) / 2, draw: () => { view3dCtx.strokeStyle = color; view3dCtx.lineWidth = width; view3dCtx.beginPath(); view3dCtx.moveTo(a.sx, a.sy); view3dCtx.lineTo(b.sx, b.sy); view3dCtx.stroke(); }}); } function drawDot(items, p, color, radius = 3) { const a = project(p); items.push({ depth: a.depth, draw: () => { view3dCtx.fillStyle = color; view3dCtx.beginPath(); view3dCtx.arc(a.sx, a.sy, radius, 0, 2 * Math.PI); view3dCtx.fill(); }}); } function drawLabel(items, p, text, color, dx = 8, dy = -8) { const a = project(p); items.push({ depth: a.depth - 1e-6, draw: () => { view3dCtx.fillStyle = color; view3dCtx.font = 'bold 11px ui-monospace, monospace'; view3dCtx.fillText(text, a.sx + dx, a.sy + dy); }}); } function drawTriad(items, position, R, len = 0.06) { // Columns of R = axis directions in base frame. const axes = [ { col: 0, color: '#e74c3c' }, // X { col: 1, color: '#3ecf8e' }, // Y { col: 2, color: '#4da3ff' }, // Z ]; for (const a of axes) { const tip = [ position[0] + R[0][a.col] * len, position[1] + R[1][a.col] * len, position[2] + R[2][a.col] * len, ]; drawSegment(items, position, tip, a.color, 2); } } function drawGroundGrid(items) { if (!$('view3dShowGrid').checked) return; const half = 0.6, step = 0.05; const minor = '#1a1f27', major = '#2a2f37'; for (let x = -half; x <= half + 1e-6; x += step) { const isMajor = Math.abs(x % 0.10) < 1e-6; drawSegment(items, [x, -half, 0], [x, half, 0], isMajor ? major : minor, 1); } for (let y = -half; y <= half + 1e-6; y += step) { const isMajor = Math.abs(y % 0.10) < 1e-6; drawSegment(items, [-half, y, 0], [half, y, 0], isMajor ? major : minor, 1); } } function drawBaseAxes(items) { drawTriad(items, [0,0,0], [[1,0,0],[0,1,0],[0,0,1]], 0.12); drawLabel(items, [0.12, 0, 0], 'X', '#e74c3c'); drawLabel(items, [0, 0.12, 0], 'Y', '#3ecf8e'); drawLabel(items, [0, 0, 0.12], 'Z', '#4da3ff'); drawLabel(items, [0,0,0], 'BASE', '#9aa0a6', 8, 14); } function drawWrist(items, side, pose, color) { if (!pose || !pose.position || !pose.quaternion) return; const p = pose.position; const R = quatToR(pose.quaternion); drawTriad(items, p, R, 0.08); drawDot(items, p, color, 5); drawLabel(items, p, `polso ${side}`, color, 10, -6); } function drawObject(items) { if (!view3d.object) return; if ($('view3dShowCloud').checked && view3d.object.points) { // Cap render to avoid huge per-frame work. const max = 1500; const pts = view3d.object.points; const stride = Math.max(1, Math.ceil(pts.length / max)); for (let i = 0; i < pts.length; i += stride) { drawDot(items, pts[i], '#aaaaaa', 1.4); } } if (view3d.object.centroid) { drawDot(items, view3d.object.centroid, '#ffd54f', 6); drawLabel(items, view3d.object.centroid, 'oggetto', '#ffd54f', 10, -8); } } function drawPlan(items) { if (!view3d.plan) return; const g = view3d.plan.grasp, pg = view3d.plan.pre_grasp; if (!g || !g.position) return; const R = quatToR(g.quaternion_xyzw); // grasp pose triad + marker drawTriad(items, g.position, R, 0.07); drawDot(items, g.position, '#ff5e7e', 6); drawLabel(items, g.position, 'grasp', '#ff5e7e', 10, -8); // pre-grasp marker + approach line if (pg && pg.position) { drawDot(items, pg.position, '#ffffff', 4); drawLabel(items, pg.position, 'pre-grasp', '#ffffff', 10, -8); drawSegment(items, pg.position, g.position, '#ffffff', 1.5); } } function renderView3d() { const ctx = view3dCtx; ctx.fillStyle = '#0d1014'; ctx.fillRect(0, 0, view3dCanvas.width, view3dCanvas.height); const items = []; drawGroundGrid(items); drawBaseAxes(items); if (view3d.wrist) { drawWrist(items, 'right', view3d.wrist.right, '#f0ad4e'); drawWrist(items, 'left', view3d.wrist.left, '#9b59b6'); } drawObject(items); drawPlan(items); // Painter's algorithm: far first. items.sort((a, b) => b.depth - a.depth); items.forEach(it => it.draw()); // HUD ctx.fillStyle = '#9aa0a6'; ctx.font = '11px ui-monospace, monospace'; ctx.fillText(`yaw=${(view3d.yaw*180/Math.PI).toFixed(0)}° pitch=${(view3d.pitch*180/Math.PI).toFixed(0)}° scale=${view3d.scale.toFixed(0)} px/m`, 8, 14); ctx.fillText(`target=[${view3d.target.map(v => v.toFixed(2)).join(', ')}]`, 8, 28); if (!view3d.calibrated) { ctx.fillStyle = '#f0ad4e'; ctx.fillText('⚠ calibrazione mancante: punti oggetto non disponibili in frame base', 8, view3dCanvas.height - 8); } if (view3d.lastErr) { ctx.fillStyle = '#e74c3c'; ctx.fillText(view3d.lastErr, 8, view3dCanvas.height - 22); } } // ---- mouse interaction ---- let drag3d = null; view3dCanvas.addEventListener('mousedown', (ev) => { drag3d = { x: ev.clientX, y: ev.clientY, pan: ev.shiftKey }; }); window.addEventListener('mouseup', () => { drag3d = null; }); window.addEventListener('mousemove', (ev) => { if (!drag3d) return; const dx = ev.clientX - drag3d.x; const dy = ev.clientY - drag3d.y; drag3d.x = ev.clientX; drag3d.y = ev.clientY; if (drag3d.pan) { // Pan in screen plane → world XY (approx, ignoring pitch foreshortening on Z) const cy = Math.cos(view3d.yaw), sy = Math.sin(view3d.yaw); const wx = cy*dx - sy*(-dy) * Math.cos(view3d.pitch); const wy = sy*dx + cy*(-dy) * Math.cos(view3d.pitch); view3d.target[0] -= wx / view3d.scale; view3d.target[1] -= wy / view3d.scale; // also let vertical drag move target Z when looking near horizontal view3d.target[2] += (-dy) * Math.sin(view3d.pitch) / view3d.scale; } else { view3d.yaw += dx * 0.01; view3d.pitch += dy * 0.01; view3d.pitch = Math.max(-Math.PI/2 + 0.05, Math.min(Math.PI/2 - 0.05, view3d.pitch)); } renderView3d(); }); view3dCanvas.addEventListener('wheel', (ev) => { ev.preventDefault(); const f = Math.exp(-ev.deltaY * 0.001); view3d.scale = Math.max(50, Math.min(3000, view3d.scale * f)); renderView3d(); }, { passive: false }); view3dCanvas.addEventListener('dblclick', () => resetView3d()); function resetView3d() { view3d.yaw = -0.6; view3d.pitch = 0.5; view3d.scale = 350; view3d.target = [0.30, 0.0, 0.20]; renderView3d(); } $('view3dResetBtn').addEventListener('click', resetView3d); function autoFitView3d() { if (!$('view3dAutoFit').checked) return; const pts = []; if (view3d.wrist) { if (view3d.wrist.right && view3d.wrist.right.position) pts.push(view3d.wrist.right.position); if (view3d.wrist.left && view3d.wrist.left.position) pts.push(view3d.wrist.left.position); } if (view3d.object && view3d.object.centroid) pts.push(view3d.object.centroid); if (view3d.plan && view3d.plan.grasp && view3d.plan.grasp.position) pts.push(view3d.plan.grasp.position); if (pts.length === 0) return; const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity]; for (const p of pts) for (let i = 0; i < 3; i++) { if (p[i] < min[i]) min[i] = p[i]; if (p[i] > max[i]) max[i] = p[i]; } view3d.target = [(min[0]+max[0])/2, (min[1]+max[1])/2, (min[2]+max[2])/2]; const span = Math.max(0.15, max[0]-min[0], max[1]-min[1], max[2]-min[2]); // fit ~70% of canvas width view3d.scale = Math.min(2000, (view3dCanvas.width * 0.7) / (span * 2.5)); } // ---- data fetching ---- async function refreshView3d() { view3d.lastErr = null; const out = { wrist: null, object: null, plan: null, calibrated: true }; const summary = []; // Wrist (always; doesn't need calibration — already in base frame). try { const w = await api('GET', '/api/wrist'); // Server returns {left_wrist, right_wrist}; tolerate {left,right} too. const data = (w && w.data) || w || {}; out.wrist = { left: data.left_wrist || data.left || null, right: data.right_wrist || data.right || null, }; summary.push(`wrist: ${out.wrist.right ? 'R✓' : 'R—'} ${out.wrist.left ? 'L✓' : 'L—'}`); } catch (e) { summary.push(`wrist: err (${e.message})`); } // Object points + centroid in base frame. try { const body = { conf: +$('view3dConf').value || null, class_name: $('view3dClass').value || null, max_points: 3000, }; const r = await api('POST', '/api/perception/object_points', body); const pts = r.points_base || null; if (!pts) { out.calibrated = false; summary.push('oggetto: serve calibrazione'); } else { let cx=0, cy=0, cz=0; for (const p of pts) { cx+=p[0]; cy+=p[1]; cz+=p[2]; } const n = Math.max(1, pts.length); out.object = { points: pts, centroid: [cx/n, cy/n, cz/n], n_points: pts.length, detection: r.detection, }; summary.push(`oggetto: ${pts.length} pt`); } } catch (e) { if (/^412\b/.test(e.message)) out.calibrated = false; summary.push(`oggetto: ${e.message}`); } // Grasp plan (optional). if ($('view3dShowPlan').checked && out.calibrated && out.object) { try { const body = { conf: +$('view3dConf').value || null, class_name: $('view3dClass').value || null, }; const r = await api('POST', '/api/grasp/plan', body); out.plan = r.plan || null; if (out.plan && out.plan.grasp) { const p = out.plan.grasp.position; summary.push(`grasp: [${p.map(v => v.toFixed(2)).join(', ')}]`); } } catch (e) { summary.push(`plan: ${e.message}`); } } Object.assign(view3d, out); autoFitView3d(); renderView3d(); $('view3dStatusLine').textContent = summary.join(' · '); $('view3dStatusLine').style.color = view3d.lastErr ? 'var(--danger)' : 'var(--dim)'; $('view3dOut').textContent = JSON.stringify({ wrist: out.wrist, object: out.object && { n_points: out.object.n_points, centroid: out.object.centroid, detection: out.object.detection, }, plan: out.plan, }, null, 2); } $('view3dRefreshBtn').addEventListener('click', async () => { setStatus('Aggiorno scena 3D…'); try { await refreshView3d(); setStatus('Scena 3D aggiornata', 'ok'); } catch (e) { setStatus(e.message, 'err'); } }); // loop let view3dTimer = null; let view3dInFlight = false; async function view3dTick() { if (view3dInFlight) return; view3dInFlight = true; try { await refreshView3d(); } catch {} finally { view3dInFlight = false; } } function startView3dLoop() { if (view3dTimer) return; const rate = Math.max(200, +$('view3dRateMs').value || 800); view3dTick(); view3dTimer = setInterval(view3dTick, rate); const b = $('view3dLoopBtn'); b.textContent = '■ Stop loop'; b.classList.add('running'); } function stopView3dLoop() { if (view3dTimer) { clearInterval(view3dTimer); view3dTimer = null; } const b = $('view3dLoopBtn'); b.textContent = '▶ Loop'; b.classList.remove('running'); } $('view3dLoopBtn').addEventListener('click', () => view3dTimer ? stopView3dLoop() : startView3dLoop()); // Pause loop when leaving the tab (mirrors the grasp-tab behavior). document.querySelectorAll('.tab').forEach(btn => btn.addEventListener('click', () => { if (btn.dataset.tab !== 'view3d' && view3dTimer) stopView3dLoop(); if (btn.dataset.tab === 'view3d') renderView3d(); })); // Initial empty render so the canvas isn't blank when first shown. renderView3d(); // ==================== HOME ==================== // Shows reachability + latency for every upstream microservice and lets the // user switch the active robot (G1 ↔ H1). The orchestrator runs the actual // switch via PUT /api/home/robot, which deep-merges into config.yaml. let homePollTimer = null; let homePollInFlight = false; function fmtLatency(ms) { if (ms == null) return '—'; if (ms < 1) return '<1 ms'; return `${Math.round(ms)} ms`; } function renderHomeStatus(data) { const tbody = document.querySelector('#homeDeviceTable tbody'); if (!tbody) return; tbody.innerHTML = ''; if (!data || !Array.isArray(data.devices) || data.devices.length === 0) { tbody.innerHTML = '
${d.url}