Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1467 lines
53 KiB
JavaScript
1467 lines
53 KiB
JavaScript
/* 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 = '<option value="">— seleziona modello —</option>';
|
||
(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 = `<option value="">errore: ${e.message}</option>`;
|
||
}
|
||
}
|
||
|
||
$('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'].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);
|
||
};
|
||
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 = '<tr><td colspan="5" class="dim">nessun servizio configurato</td></tr>';
|
||
return;
|
||
}
|
||
for (const d of data.devices) {
|
||
const tr = document.createElement('tr');
|
||
const status = d.ok ? 'ok' : 'err';
|
||
const icon = d.ok ? '✓' : '✗';
|
||
const detail = d.ok
|
||
? (d.status_code != null ? `HTTP ${d.status_code}` : 'reachable')
|
||
: (d.error || (d.status_code != null ? `HTTP ${d.status_code}` : 'unreachable'));
|
||
tr.innerHTML = `
|
||
<td>${d.label}${d.active ? ' <span class="badge ok">attivo</span>' : ''}</td>
|
||
<td><code>${d.url}</code></td>
|
||
<td><span class="device-pill ${status}">${icon} ${d.ok ? 'online' : 'offline'}</span></td>
|
||
<td>${fmtLatency(d.latency_ms)}</td>
|
||
<td>${detail}</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
}
|
||
const pill = $('activeRobotPill');
|
||
if (pill) pill.textContent = `robot: ${(data.active_robot || '—').toUpperCase()}`;
|
||
}
|
||
|
||
async function homePollOnce() {
|
||
if (homePollInFlight) return;
|
||
homePollInFlight = true;
|
||
try {
|
||
const r = await api('GET', '/api/home/status');
|
||
renderHomeStatus(r);
|
||
} catch (e) {
|
||
const tbody = document.querySelector('#homeDeviceTable tbody');
|
||
if (tbody) tbody.innerHTML = `<tr><td colspan="5" class="err">errore: ${e.message}</td></tr>`;
|
||
} finally {
|
||
homePollInFlight = false;
|
||
}
|
||
}
|
||
|
||
function startHomePoll() {
|
||
stopHomePoll();
|
||
if (!$('homeAutoPoll').checked) return;
|
||
const rate = Math.max(500, +$('homePollMs').value || 2000);
|
||
homePollOnce();
|
||
homePollTimer = setInterval(homePollOnce, rate);
|
||
}
|
||
function stopHomePoll() {
|
||
if (homePollTimer) { clearInterval(homePollTimer); homePollTimer = null; }
|
||
}
|
||
|
||
$('homeAutoPoll').addEventListener('change', startHomePoll);
|
||
$('homePollMs').addEventListener('change', startHomePoll);
|
||
$('homeRefreshBtn').addEventListener('click', homePollOnce);
|
||
|
||
$('homeApplyRobotBtn').addEventListener('click', async (ev) => {
|
||
const btn = ev.currentTarget;
|
||
const target = $('homeRobotSelect').value;
|
||
try {
|
||
const r = await api('PUT', '/api/home/robot', { active: target });
|
||
$('homeLog').textContent = `robot attivo: ${r.active}`;
|
||
$('homeLog').classList.remove('err');
|
||
flashBtn(btn, true);
|
||
toast('Robot attivo', `${r.active.toUpperCase()}`, true);
|
||
homePollOnce();
|
||
} catch (e) {
|
||
$('homeLog').textContent = e.message;
|
||
$('homeLog').classList.add('err');
|
||
flashBtn(btn, false);
|
||
toast('Errore cambio robot', e.message, false);
|
||
}
|
||
});
|
||
|
||
// Sync the dropdown with whatever the backend currently reports as active.
|
||
async function refreshHomeRobotSelector() {
|
||
try {
|
||
const r = await api('GET', '/api/home/robot');
|
||
if (r && r.active) $('homeRobotSelect').value = r.active;
|
||
const pill = $('activeRobotPill');
|
||
if (pill) pill.textContent = `robot: ${(r.active || '—').toUpperCase()}`;
|
||
} catch {}
|
||
}
|
||
refreshHomeRobotSelector();
|
||
startHomePoll();
|
||
|
||
// ---------- init ----------
|
||
(async () => {
|
||
try {
|
||
const cfg = await api('GET', '/api/config');
|
||
if ($('eeSide')) $('eeSide').value = cfg.robot.wrist_side;
|
||
} catch {}
|
||
refreshWrist();
|
||
})();
|
||
|
||
// ==================== ASSISTENTE AI ====================
|
||
// Curated tool-use chat: l'utente parla in linguaggio naturale, il backend
|
||
// passa la richiesta a OpenRouter con i tool definiti in agent/tools.py.
|
||
// La cronologia è tenuta lato browser e ribaltata indietro a ogni POST.
|
||
const agentState = {
|
||
history: [], // OpenAI-format messages (escluso system, ricostruito server-side)
|
||
visualMessages: [], // {role, content, traceIdx?} per il render
|
||
busy: false,
|
||
health: null,
|
||
};
|
||
|
||
function agentEscape(s) {
|
||
return String(s == null ? '' : s)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
function renderAgentChat() {
|
||
const host = $('agentChat');
|
||
host.innerHTML = '';
|
||
if (agentState.visualMessages.length === 0) {
|
||
const sys = document.createElement('div');
|
||
sys.className = 'agent-msg system';
|
||
sys.textContent = 'Conversazione vuota. Scrivi una richiesta sotto.';
|
||
host.appendChild(sys);
|
||
return;
|
||
}
|
||
for (const m of agentState.visualMessages) {
|
||
const div = document.createElement('div');
|
||
div.className = `agent-msg ${m.role}`;
|
||
div.textContent = m.content || '';
|
||
if (m.tools && m.tools.length) {
|
||
const meta = document.createElement('div');
|
||
meta.className = 'meta';
|
||
for (const t of m.tools) {
|
||
const pill = document.createElement('span');
|
||
pill.className = 'agent-tool-pill' + (t.ok ? '' : ' err');
|
||
pill.textContent = `${t.name} · ${t.latency_ms}ms`;
|
||
pill.title = JSON.stringify(t.args || {}, null, 2);
|
||
meta.appendChild(pill);
|
||
}
|
||
div.appendChild(meta);
|
||
}
|
||
host.appendChild(div);
|
||
}
|
||
host.scrollTop = host.scrollHeight;
|
||
}
|
||
|
||
async function refreshAgentHealth() {
|
||
try {
|
||
const h = await api('GET', '/api/agent/health');
|
||
agentState.health = h;
|
||
const el = $('agentHealth');
|
||
if (h.configured) {
|
||
el.textContent = `pronto · modello ${h.model}`;
|
||
el.style.color = 'var(--ok)';
|
||
$('agentSendBtn').disabled = false;
|
||
} else {
|
||
el.textContent = `API key mancante (env ${h.api_key_env}): chat disabilitata`;
|
||
el.style.color = 'var(--danger)';
|
||
$('agentSendBtn').disabled = true;
|
||
}
|
||
} catch (e) {
|
||
$('agentHealth').textContent = `errore: ${e.message}`;
|
||
$('agentHealth').style.color = 'var(--danger)';
|
||
}
|
||
// mirror del robot attivo nella pill della tab
|
||
try {
|
||
const r = await api('GET', '/api/home/robot');
|
||
const pill = $('agentActiveRobotPill');
|
||
if (pill) pill.textContent = `robot: ${(r.active || '—').toUpperCase()}`;
|
||
} catch {}
|
||
}
|
||
|
||
async function sendAgentMessage() {
|
||
if (agentState.busy) return;
|
||
const input = $('agentInput');
|
||
const message = input.value.trim();
|
||
if (!message) return;
|
||
agentState.busy = true;
|
||
$('agentSendBtn').disabled = true;
|
||
$('agentStatus').textContent = 'invio…';
|
||
$('agentStatus').style.color = 'var(--dim)';
|
||
|
||
agentState.visualMessages.push({ role: 'user', content: message });
|
||
renderAgentChat();
|
||
input.value = '';
|
||
|
||
try {
|
||
const r = await api('POST', '/api/agent/chat', {
|
||
message,
|
||
history: agentState.history,
|
||
});
|
||
agentState.history = r.history || [];
|
||
const tools = (r.tool_calls || []).map(tc => ({
|
||
name: tc.name, ok: tc.result && tc.result.ok !== false,
|
||
latency_ms: tc.latency_ms, args: tc.arguments,
|
||
}));
|
||
agentState.visualMessages.push({
|
||
role: 'assistant', content: r.reply || '(nessuna risposta)',
|
||
tools,
|
||
});
|
||
renderAgentChat();
|
||
// Tool trace dettagliata nel pannello di destra.
|
||
if (r.tool_calls && r.tool_calls.length) {
|
||
$('agentTrace').textContent = JSON.stringify(r.tool_calls, null, 2);
|
||
} else {
|
||
$('agentTrace').textContent = '(nessuna chiamata)';
|
||
}
|
||
const usage = r.usage || {};
|
||
const u = usage.total_tokens != null ? `· ${usage.total_tokens} tokens` : '';
|
||
$('agentStatus').textContent = `ok · ${r.iterations} iterazioni · ${tools.length} tool ${u}`;
|
||
$('agentStatus').style.color = 'var(--ok)';
|
||
// se la risposta ha cambiato robot attivo, aggiorna pill
|
||
refreshAgentHealth().catch(() => {});
|
||
} catch (e) {
|
||
agentState.visualMessages.push({
|
||
role: 'error', content: e.message,
|
||
});
|
||
renderAgentChat();
|
||
$('agentStatus').textContent = `errore: ${e.message}`;
|
||
$('agentStatus').style.color = 'var(--danger)';
|
||
} finally {
|
||
agentState.busy = false;
|
||
$('agentSendBtn').disabled = !(agentState.health && agentState.health.configured);
|
||
}
|
||
}
|
||
|
||
$('agentSendBtn').addEventListener('click', sendAgentMessage);
|
||
$('agentInput').addEventListener('keydown', (e) => {
|
||
if (e.ctrlKey && e.key === 'Enter') {
|
||
e.preventDefault();
|
||
sendAgentMessage();
|
||
}
|
||
});
|
||
|
||
$('agentClearBtn').addEventListener('click', () => {
|
||
if (agentState.visualMessages.length === 0 && agentState.history.length === 0) return;
|
||
if (!confirm('Resetta la conversazione corrente?')) return;
|
||
agentState.history = [];
|
||
agentState.visualMessages = [];
|
||
renderAgentChat();
|
||
$('agentTrace').textContent = '(nessuna chiamata)';
|
||
$('agentStatus').textContent = 'conversazione svuotata';
|
||
});
|
||
|
||
// ----- Task salvati -----
|
||
async function loadAgentTasks() {
|
||
const ul = $('agentTaskList');
|
||
try {
|
||
const r = await api('GET', '/api/agent/tasks');
|
||
const items = r.tasks || [];
|
||
ul.innerHTML = '';
|
||
if (items.length === 0) {
|
||
ul.innerHTML = '<li class="dim">(nessun task salvato)</li>';
|
||
return;
|
||
}
|
||
for (const it of items) {
|
||
const li = document.createElement('li');
|
||
const name = document.createElement('span');
|
||
name.className = 'task-name';
|
||
name.textContent = it.name;
|
||
const desc = document.createElement('span');
|
||
desc.className = 'task-desc';
|
||
desc.textContent = it.description || it.prompt;
|
||
desc.title = it.prompt;
|
||
const useBtn = document.createElement('button');
|
||
useBtn.className = 'link-btn';
|
||
useBtn.textContent = '↥ usa';
|
||
useBtn.title = 'Carica questo prompt nella casella di input';
|
||
useBtn.addEventListener('click', () => {
|
||
$('agentInput').value = it.prompt;
|
||
$('agentInput').focus();
|
||
});
|
||
const runBtn = document.createElement('button');
|
||
runBtn.className = 'link-btn';
|
||
runBtn.textContent = '▶ esegui';
|
||
runBtn.addEventListener('click', () => {
|
||
$('agentInput').value = it.prompt;
|
||
sendAgentMessage();
|
||
});
|
||
const delBtn = document.createElement('button');
|
||
delBtn.className = 'link-btn';
|
||
delBtn.textContent = '✕';
|
||
delBtn.title = 'Elimina';
|
||
delBtn.addEventListener('click', async () => {
|
||
if (!confirm(`Eliminare task "${it.name}"?`)) return;
|
||
try {
|
||
await api('DELETE', `/api/agent/tasks/${encodeURIComponent(it.name)}`);
|
||
loadAgentTasks();
|
||
} catch (e) { toast('Eliminazione fallita', e.message, false); }
|
||
});
|
||
li.appendChild(name);
|
||
li.appendChild(desc);
|
||
li.appendChild(useBtn);
|
||
li.appendChild(runBtn);
|
||
li.appendChild(delBtn);
|
||
ul.appendChild(li);
|
||
}
|
||
} catch (e) {
|
||
ul.innerHTML = `<li class="err">errore: ${agentEscape(e.message)}</li>`;
|
||
}
|
||
}
|
||
|
||
$('agentTaskSaveBtn').addEventListener('click', async (ev) => {
|
||
const name = $('agentTaskName').value.trim();
|
||
const prompt = $('agentInput').value.trim();
|
||
const description = $('agentTaskDesc').value.trim() || null;
|
||
if (!name) { toast('Manca il nome', 'Indica un nome per il task', false); return; }
|
||
if (!prompt) { toast('Prompt vuoto', 'Scrivi prima il prompt nella casella', false); return; }
|
||
try {
|
||
await api('POST', '/api/agent/tasks', { name, prompt, description });
|
||
flashBtn(ev.currentTarget, true);
|
||
toast('Task salvato', name, true);
|
||
$('agentTaskName').value = '';
|
||
$('agentTaskDesc').value = '';
|
||
loadAgentTasks();
|
||
} catch (e) {
|
||
flashBtn(ev.currentTarget, false);
|
||
toast('Salvataggio fallito', e.message, false);
|
||
}
|
||
});
|
||
|
||
$('agentTaskReloadBtn').addEventListener('click', loadAgentTasks);
|
||
|
||
// Carica health + task quando si apre la tab Assistente AI.
|
||
document.querySelectorAll('.tab').forEach(btn => btn.addEventListener('click', () => {
|
||
if (btn.dataset.tab === 'agent') {
|
||
refreshAgentHealth();
|
||
loadAgentTasks();
|
||
}
|
||
}));
|
||
|
||
// Bootstrap iniziale (anche se la tab non è attiva, così la pill si popola).
|
||
refreshAgentHealth();
|
||
loadAgentTasks();
|
||
|
||
// ==================== IMPOSTAZIONI AI ====================
|
||
async function loadAiSettings() {
|
||
const select = $('aiModelSelect');
|
||
// Lista modelli (statica con override custom): si carica una sola volta.
|
||
if (select && select.options.length <= 1) {
|
||
try {
|
||
const r = await api('GET', '/api/agent/known_models');
|
||
select.innerHTML = '';
|
||
for (const m of r.models) {
|
||
const opt = document.createElement('option');
|
||
opt.value = m.id;
|
||
opt.textContent = `${m.id} — ${m.label}`;
|
||
select.appendChild(opt);
|
||
}
|
||
} catch (e) {
|
||
select.innerHTML = `<option value="">errore: ${e.message}</option>`;
|
||
}
|
||
}
|
||
// Carica config corrente
|
||
try {
|
||
const cfg = await api('GET', '/api/config');
|
||
const o = (cfg.chat && cfg.chat.openrouter) || {};
|
||
const model = o.model || 'anthropic/claude-sonnet-4.5';
|
||
if ([...$('aiModelSelect').options].some(opt => opt.value === model)) {
|
||
$('aiModelSelect').value = model;
|
||
$('aiModelCustom').value = '';
|
||
} else {
|
||
$('aiModelCustom').value = model;
|
||
}
|
||
$('aiTemperature').value = (o.temperature != null) ? o.temperature : 0.1;
|
||
$('aiMaxIterations').value = o.max_tool_iterations || 8;
|
||
$('aiTimeout').value = o.timeout_s || 60;
|
||
$('aiPromptAddendum').value = o.user_prompt_addendum || '';
|
||
$('aiSettingsStatus').textContent = 'caricato da config';
|
||
$('aiSettingsStatus').style.color = 'var(--dim)';
|
||
} catch (e) {
|
||
$('aiSettingsStatus').textContent = `errore: ${e.message}`;
|
||
$('aiSettingsStatus').style.color = 'var(--danger)';
|
||
}
|
||
}
|
||
|
||
async function saveAiSettings(btn) {
|
||
const customModel = $('aiModelCustom').value.trim();
|
||
const model = customModel || $('aiModelSelect').value;
|
||
if (!model) { toast('Modello mancante', 'Seleziona o scrivi un modello', false); return; }
|
||
const temperature = parseFloat($('aiTemperature').value);
|
||
const max_tool_iterations = parseInt($('aiMaxIterations').value, 10);
|
||
const timeout_s = parseFloat($('aiTimeout').value);
|
||
if (Number.isNaN(temperature) || temperature < 0 || temperature > 2) {
|
||
toast('Temperature non valido', '0..2', false); return;
|
||
}
|
||
if (Number.isNaN(max_tool_iterations) || max_tool_iterations < 1) {
|
||
toast('Max iterations non valido', '>=1', false); return;
|
||
}
|
||
const patch = {
|
||
chat: { openrouter: {
|
||
model,
|
||
temperature,
|
||
max_tool_iterations,
|
||
timeout_s,
|
||
user_prompt_addendum: $('aiPromptAddendum').value,
|
||
}},
|
||
};
|
||
try {
|
||
await api('PUT', '/api/config', patch);
|
||
flashBtn(btn, true);
|
||
toast('Salvato', `model=${model}, T=${temperature}`, true);
|
||
$('aiSettingsStatus').textContent = `salvato · model=${model} · T=${temperature}`;
|
||
$('aiSettingsStatus').style.color = 'var(--ok)';
|
||
refreshAgentHealth().catch(() => {});
|
||
} catch (e) {
|
||
flashBtn(btn, false);
|
||
toast('Errore salvataggio', e.message, false);
|
||
$('aiSettingsStatus').textContent = e.message;
|
||
$('aiSettingsStatus').style.color = 'var(--danger)';
|
||
}
|
||
}
|
||
|
||
async function previewSystemPrompt() {
|
||
const pre = $('aiPromptPreview');
|
||
pre.textContent = 'caricamento…';
|
||
try {
|
||
const r = await api('GET', '/api/agent/system_prompt_preview');
|
||
pre.textContent = r.system_prompt || '(vuoto)';
|
||
} catch (e) {
|
||
pre.textContent = `errore: ${e.message}`;
|
||
}
|
||
}
|
||
|
||
$('aiSaveBtn').addEventListener('click', (ev) => saveAiSettings(ev.currentTarget));
|
||
$('aiReloadBtn').addEventListener('click', loadAiSettings);
|
||
$('aiResetTempBtn').addEventListener('click', () => { $('aiTemperature').value = 0.1; });
|
||
$('aiPromptPreviewBtn').addEventListener('click', previewSystemPrompt);
|
||
$('aiPromptCopyBtn').addEventListener('click', async () => {
|
||
try {
|
||
await navigator.clipboard.writeText($('aiPromptPreview').textContent);
|
||
toast('Copiato', 'Prompt nel clipboard', true);
|
||
} catch (e) { toast('Copia fallita', e.message, false); }
|
||
});
|
||
// Quando l'utente seleziona un modello dalla combo, svuota il custom (priorità chiara).
|
||
$('aiModelSelect').addEventListener('change', () => { $('aiModelCustom').value = ''; });
|
||
|
||
// Carica i valori quando si apre la tab.
|
||
document.querySelectorAll('.tab').forEach(btn => btn.addEventListener('click', () => {
|
||
if (btn.dataset.tab === 'ai-settings') {
|
||
loadAiSettings();
|
||
previewSystemPrompt();
|
||
}
|
||
}));
|