feat: initial commit — orchestratore presa-mano con AI assistant + dual-robot

Pipeline completa per calibrazione hand-eye e grasp planning su robot
Unitree G1/H1 con camera depth Nuwa-HP60C. Esposta come FastAPI + GUI vanilla
JS, con assistente AI (OpenRouter + tool calling curato) per pilotare il
sistema in linguaggio naturale.

Architettura:
- FastAPI orchestrator (porta da .env, default 8890) che coordina 4
  microservizi upstream: Nuwa-HP60C, marker-finder, YOLO, robot bridge.
- Dual-robot: services.g1 e services.h1 in config.yaml; switchabili a
  runtime via PUT /api/home/robot.
- Calibrazione hand-eye eye-to-hand split per-side (right/left).
- Marker pose: marker-finder riceve gli intrinseci RGB della Nuwa al boot,
  PnP server-side con quei dati. Nessun re-PnP locale richiesto.
- Depth-to-RGB: registrazione iterativa IR↔RGB per back-projection 3D
  corretta (Nuwa /api/measure usa coords IR; il nostro /api/perception/
  measure_rgb prende pixel RGB e ritorna 3D nel frame RGB OpenCV).
- Correzione affine sul depth bias (calibrabile via marker come
  ground-truth) — riduce errore Z da ~6% a ~1.5% nei test.
- AI assistant: 16 tool curati (move_wrist_*, hand_action, get_marker[_3d],
  detect_object, calibration_*, set_active_robot…), system prompt
  dinamico che riflette robot attivo + marker config + addendum
  customizzabile da GUI. Temperature di default 0.1 per evitare invenzioni.

GUI (7 tab):
- Home: stato comunicazione device + selettore robot attivo G1/H1
- Assistente AI: chat con OpenRouter, history persistita lato browser,
  task riutilizzabili (prompt salvati), trace tool calls
- Impostazioni AI: modello da combo curata, temperature, max iterations,
  prompt addendum, anteprima system prompt
- Calibrazione, Auto-Calibrazione, TrovaUtensile, Servo Visuale, Vista 3D,
  Movimento EE — invariate dalle versioni precedenti

Setup:
- Variabili in presa-mano/.env (template in .env.example)
- OPENROUTER_API_KEY richiesta solo per il tab Assistente AI
- PRESA_MANO_PORT configurabile

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal PastroandClaude Opus 4.7 committed 2026-05-06 13:28:23 +02:00
commit ac573505b7
121 files changed
+67199

No files matched your search

+1732
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+506
View File
@@ -0,0 +1,506 @@
/* Auto-calibration tab: reuses G1Viewer for the 3D URDF, overlays the
* calibration waypoints as colored spheres + triads (target orientation),
* orchestrates Calcola / Verifica reale / Start / Stop flow, polls
* /api/calibration/auto/status during execution, shows a per-waypoint attempt
* bar chart, and persists form settings in localStorage so the user can
* re-run the same calibration without re-typing.
*/
import * as THREE from 'three';
import { G1Viewer } from './diagnostic3d.js';
const $ = (id) => document.getElementById(id);
const STATE_COLORS = {
pending: 0xaaaaaa,
unreach: 0xe74c3c,
skipped: 0x8a3a33,
current: 0x4da3ff,
done: 0x3ecf8e,
failed: 0xf0ad4e,
};
const LS_KEY = 'presaMano.autocalParams.v1';
let viewer = null;
let booted = false;
let pollTimer = null;
let waypointGroup = null;
let waypointItems = new Map(); // idx -> {sphere, triad}
let hoverTooltip = null;
let lastSnapshot = null;
let showTriads = true;
async function boot() {
if (booted) return;
booted = true;
const canvas = $('autocalCanvas');
if (!canvas) return;
$('autocalStatus').textContent = 'caricamento URDF…';
try {
viewer = new G1Viewer(canvas);
await viewer.load();
// Hide the per-motor spheres here — user doesn't want them in this tab.
viewer.setShowSpheres(false);
$('autocalStatus').textContent = 'URDF caricato · imposta parametri e clicca Calcola griglia';
waypointGroup = new THREE.Group();
waypointGroup.name = 'autocal:waypoints';
viewer.scene.add(waypointGroup);
installHoverTooltip();
// Expose hook so app.js powerPollOnce can push /diagnostics (live robot motion).
window.g1AutocalViewer = viewer;
await previewGrid().catch(() => {});
} catch (e) {
$('autocalStatus').textContent = 'errore: ' + e.message;
console.error(e);
}
}
function installHoverTooltip() {
hoverTooltip = document.createElement('div');
hoverTooltip.className = 'diag3d-tooltip hidden';
hoverTooltip.id = 'autocalTooltip';
viewer.canvas.parentElement.appendChild(hoverTooltip);
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
viewer.canvas.addEventListener('pointermove', (ev) => {
if (!waypointItems.size) { hoverTooltip.classList.add('hidden'); return; }
const rect = viewer.canvas.getBoundingClientRect();
mouse.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, viewer.camera);
const spheres = [...waypointItems.values()].map(it => it.sphere);
const hits = raycaster.intersectObjects(spheres, false);
if (!hits.length) { hoverTooltip.classList.add('hidden'); return; }
const w = hits[0].object.userData.waypoint;
hoverTooltip.className = 'diag3d-tooltip ' +
(w.state === 'failed' || !w.reachable ? 'err' : '');
const rows = [
['idx', '#' + w.idx],
['stato', w.state + (!w.reachable ? ' (unreach)' : '')],
['x (m)', Number(w.position[0]).toFixed(3)],
['y (m)', Number(w.position[1]).toFixed(3)],
['z (m)', Number(w.position[2]).toFixed(3)],
];
if (w.attempts) rows.push(['attempts', w.attempts]);
if (w.error) rows.push(['error', w.error.slice(0, 60)]);
if (w.reason) rows.push(['motivo', w.reason]);
hoverTooltip.innerHTML = `<div class="tt-title">waypoint #${w.idx}</div>` +
rows.map(([k, v]) => `<div class="tt-row"><span class="k">${k}</span><span>${v}</span></div>`).join('');
hoverTooltip.style.left = (ev.clientX - rect.left + 14) + 'px';
hoverTooltip.style.top = (ev.clientY - rect.top + 14) + 'px';
hoverTooltip.classList.remove('hidden');
});
}
function colorForWaypoint(w) {
if (!w.reachable) return STATE_COLORS.unreach;
switch (w.state) {
case 'current': return STATE_COLORS.current;
case 'done': return STATE_COLORS.done;
case 'failed': return STATE_COLORS.failed;
case 'skipped': return STATE_COLORS.skipped;
default: return STATE_COLORS.pending;
}
}
// Quaternion (xyzw) → 3-axis triad: 3 short coloured segments, length = 4 cm.
function makeTriad() {
const L = 0.04;
const geom = new THREE.BufferGeometry();
const verts = new Float32Array([
0,0,0, L,0,0, // X
0,0,0, 0,L,0, // Y
0,0,0, 0,0,L, // Z
]);
const colors = new Float32Array([
1,0.2,0.2, 1,0.2,0.2,
0.2,1,0.3, 0.2,1,0.3,
0.3,0.6,1, 0.3,0.6,1,
]);
geom.setAttribute('position', new THREE.BufferAttribute(verts, 3));
geom.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const mat = new THREE.LineBasicMaterial({ vertexColors: true, linewidth: 2, depthTest: false, transparent: true, opacity: 0.9 });
const lines = new THREE.LineSegments(geom, mat);
lines.renderOrder = 998;
return lines;
}
function renderWaypoints(waypoints) {
if (!waypointGroup) return;
const seen = new Set();
for (const w of waypoints) {
seen.add(w.idx);
let item = waypointItems.get(w.idx);
if (!item) {
const geom = new THREE.SphereGeometry(0.012, 18, 14);
const mat = new THREE.MeshStandardMaterial({
color: colorForWaypoint(w),
emissive: 0x111111,
depthTest: false,
transparent: true,
opacity: 0.85,
});
const sphere = new THREE.Mesh(geom, mat);
sphere.renderOrder = 999;
const triad = makeTriad();
const group = new THREE.Group();
group.add(sphere);
group.add(triad);
waypointGroup.add(group);
item = { group, sphere, triad };
waypointItems.set(w.idx, item);
}
item.group.position.set(w.position[0], w.position[1], w.position[2]);
const q = new THREE.Quaternion(w.quaternion[0], w.quaternion[1], w.quaternion[2], w.quaternion[3]);
item.triad.quaternion.copy(q);
item.triad.visible = showTriads;
item.sphere.material.color.setHex(colorForWaypoint(w));
item.sphere.userData.waypoint = w;
if (w.state === 'current') {
item.sphere.material.emissive.setHex(0x1e5a8a);
item.sphere.material.emissiveIntensity = 0.7;
const s = 1.0 + 0.2 * Math.sin(Date.now() / 120);
item.sphere.scale.setScalar(s);
} else {
item.sphere.material.emissive.setHex(0x000000);
item.sphere.material.emissiveIntensity = 0;
item.sphere.scale.setScalar(1.0);
}
}
// Remove stale.
for (const [idx, item] of waypointItems.entries()) {
if (!seen.has(idx)) {
waypointGroup.remove(item.group);
item.sphere.geometry.dispose();
item.sphere.material.dispose();
item.triad.geometry.dispose();
item.triad.material.dispose();
waypointItems.delete(idx);
}
}
}
function readParams() {
const side = $('acSide').value || null;
return {
delta_x_mm: +$('acDx').value,
delta_y_mm: +$('acDy').value,
delta_z_mm: +$('acDz').value,
offset_x_mm: +$('acOx').value,
offset_y_mm: +$('acOy').value,
offset_z_mm: +$('acOz').value,
n_x: +$('acNx').value,
n_y: +$('acNy').value,
n_z: +$('acNz').value,
wrist_rot_delta_deg: +$('acRotDeg').value,
min_z_m: +$('acMinZ').value,
settle_s: +$('acSettle').value,
side: side || undefined,
auto_solve: $('acAutoSolve').checked,
dry_run: $('acDryRun').checked,
order: $('acOrder').value,
};
}
function persistParams() {
try { localStorage.setItem(LS_KEY, JSON.stringify(readParams())); } catch {}
}
function restoreParams() {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return;
const p = JSON.parse(raw);
const setV = (id, v) => { if ($(id) && v != null) $(id).value = v; };
const setC = (id, v) => { if ($(id) && v != null) $(id).checked = !!v; };
setV('acDx', p.delta_x_mm); setV('acDy', p.delta_y_mm); setV('acDz', p.delta_z_mm);
setV('acOx', p.offset_x_mm); setV('acOy', p.offset_y_mm); setV('acOz', p.offset_z_mm);
setV('acNx', p.n_x); setV('acNy', p.n_y); setV('acNz', p.n_z);
setV('acRotDeg', p.wrist_rot_delta_deg); setV('acMinZ', p.min_z_m); setV('acSettle', p.settle_s);
if (p.side) setV('acSide', p.side);
setC('acAutoSolve', p.auto_solve);
setC('acDryRun', p.dry_run);
if (p.order) setV('acOrder', p.order);
} catch (e) { console.warn('restoreParams:', e); }
}
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) throw new Error(`${r.status} ${(data && data.detail) || text}`);
return data;
}
async function previewGrid() {
persistParams();
const body = readParams();
setStatus('Calcolo griglia…');
try {
const r = await api('POST', '/api/calibration/auto/feasibility', body);
renderWaypoints(r.waypoints);
drawChart(r.waypoints);
updateStats(r.waypoints, null);
const reach = r.waypoints.filter(w => w.reachable).length;
setStatus(`griglia: ${r.waypoints.length} punti · ${reach} raggiungibili · ${r.waypoints.length - reach} scartati`);
$('acStartBtn').disabled = reach === 0;
$('acVerifyBtn').disabled = reach === 0;
} catch (e) {
setStatus('errore: ' + e.message, 'err');
}
}
async function startProcedure() {
persistParams();
const body = readParams();
const reach = [...waypointItems.values()].filter(it => it.sphere.userData.waypoint.reachable).length;
if (!reach) return;
if (!body.dry_run && !confirm(`Avvio calibrazione su ${reach} punti. Il robot si muoverà. Confermi?`)) return;
try {
setStatus('avvio procedura…');
await api('POST', '/api/calibration/auto/start', body);
$('acStartBtn').disabled = true;
$('acVerifyBtn').disabled = true;
$('acStopBtn').disabled = false;
startPolling();
} catch (e) {
setStatus('start fallito: ' + e.message, 'err');
}
}
async function verifyReachability() {
persistParams();
const body = readParams();
const reach = [...waypointItems.values()].filter(it => it.sphere.userData.waypoint.reachable).length;
if (!reach) return;
if (!body.dry_run && !confirm(`Verifica raggiungibilità reale: il robot si muoverà a ${reach} punti senza acquisire campioni. Confermi?`)) return;
try {
setStatus('avvio verifica reachability…');
await api('POST', '/api/calibration/auto/verify_reachability', body);
$('acStartBtn').disabled = true;
$('acVerifyBtn').disabled = true;
$('acStopBtn').disabled = false;
startPolling();
} catch (e) {
setStatus('verify fallito: ' + e.message, 'err');
}
}
async function stopProcedure() {
try {
await api('POST', '/api/calibration/auto/stop');
setStatus('stop richiesto… il robot tornerà a home prima di fermarsi');
} catch (e) {
setStatus('stop fallito: ' + e.message, 'err');
}
}
function startPolling() {
stopPolling();
pollTick();
pollTimer = setInterval(pollTick, 500);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
async function pollTick() {
try {
const s = await api('GET', '/api/calibration/auto/status');
lastSnapshot = s;
if (s.waypoints && s.waypoints.length) {
renderWaypoints(s.waypoints);
drawChart(s.waypoints);
}
updateStats(s.waypoints, s);
if (s.status !== 'running') {
stopPolling();
$('acStartBtn').disabled = false;
$('acVerifyBtn').disabled = false;
$('acStopBtn').disabled = true;
if (s.calibration) {
$('autocalResult').textContent = JSON.stringify({
method: s.calibration.method,
n_samples: s.calibration.n_samples,
residual_translation_mm: s.calibration.residual_translation_mm,
residual_rotation_deg: s.calibration.residual_rotation_deg,
T_base_camera: s.calibration.T_base_camera,
sanity_check: s.calibration.sanity_check,
}, null, 2);
}
const mode = (s.params || {}).mode || 'calibration';
setStatus(`${mode}: ${s.status} · done=${s.stats.done} failed=${s.stats.failed} skipped=${s.stats.skipped}`,
s.status === 'done' ? 'ok' : s.status === 'error' ? 'err' : '');
}
} catch (e) {
console.warn('autocal poll:', e);
}
}
function updateStats(waypoints, snapshot) {
const host = $('autocalStats');
if (!host) return;
const total = waypoints.length;
const reach = waypoints.filter(w => w.reachable).length;
const unreach = total - reach;
const done = waypoints.filter(w => w.state === 'done').length;
const failed = waypoints.filter(w => w.state === 'failed').length;
const skipped = waypoints.filter(w => w.state === 'skipped').length;
const attempts = waypoints.filter(w => w.attempts > 0).map(w => w.attempts);
const avgAtt = attempts.length ? (attempts.reduce((a, b) => a + b, 0) / attempts.length).toFixed(2) : '—';
const maxAtt = attempts.length ? Math.max(...attempts) : '—';
const current = snapshot && snapshot.current_index >= 0 ? snapshot.current_index : null;
host.innerHTML = `
<table><tbody>
<tr><td>totale</td><td><strong>${total}</strong></td></tr>
<tr><td>raggiungibili</td><td>${reach}</td></tr>
<tr><td>non raggiungibili</td><td>${unreach}</td></tr>
<tr><td>campioni ok</td><td>${done}</td></tr>
<tr><td>falliti</td><td>${failed}</td></tr>
<tr><td>saltati</td><td>${skipped}</td></tr>
<tr><td>tentativi medi</td><td>${avgAtt}</td></tr>
<tr><td>tentativi max</td><td>${maxAtt}</td></tr>
<tr><td>corrente</td><td>${current != null ? '#' + current : '—'}</td></tr>
<tr><td>stato job</td><td>${snapshot ? snapshot.status : '—'}</td></tr>
</tbody></table>`;
}
// Per-waypoint bar chart: bar height = attempts, color by state.
function drawChart(waypoints) {
try {
_drawChartImpl(waypoints);
} catch (e) {
console.error('drawChart failed:', e);
setStatus('drawChart error: ' + (e && e.message || e), 'err');
}
}
function _drawChartImpl(waypoints) {
const canvas = $('autocalChart');
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth || canvas.width;
const h = canvas.height;
canvas.width = w * dpr;
canvas.height = h * dpr;
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#0d1014';
ctx.fillRect(0, 0, w, h);
if (!waypoints || !waypoints.length) {
ctx.fillStyle = '#666';
ctx.font = '11px ui-monospace, monospace';
ctx.fillText('nessun dato', 10, 20);
return;
}
const n = waypoints.length;
const maxA = Math.max(1, ...waypoints.map(x => x.attempts || 0));
const pad = { l: 26, r: 6, t: 8, b: 18 };
const plotW = w - pad.l - pad.r;
const plotH = h - pad.t - pad.b;
const barW = plotW / n;
// grid
ctx.strokeStyle = '#2a2f37';
ctx.lineWidth = 1;
const ticks = [1, 2, 3, 5, maxA].filter((v, i, a) => v <= maxA && a.indexOf(v) === i);
ctx.font = '10px ui-monospace, monospace';
ctx.fillStyle = '#666';
for (const t of ticks) {
const y = pad.t + plotH - (t / maxA) * plotH;
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(w - pad.r, y); ctx.stroke();
ctx.fillText(String(t), 2, y + 3);
}
// bars
const colorFor = (w) => {
if (!w.reachable) return '#e74c3c';
if (w.state === 'done' && w.attempts === 1) return '#3ecf8e';
if (w.state === 'done') return '#ffd54f';
if (w.state === 'failed') return '#f0ad4e';
if (w.state === 'current') return '#4da3ff';
if (w.state === 'skipped') return '#8a3a33';
return '#555';
};
for (let i = 0; i < n; i++) {
const wp = waypoints[i];
const a = wp.attempts || 0;
const bh = (a / maxA) * plotH;
const x = pad.l + i * barW + 1;
const y = pad.t + plotH - bh;
ctx.fillStyle = colorFor(wp);
ctx.fillRect(x, y, Math.max(1, barW - 2), bh);
}
// x label
ctx.fillStyle = '#9aa0a6';
ctx.fillText(`waypoints (${n})`, pad.l, h - 4);
ctx.fillText(`tentativi`, 2, 10);
}
function setStatus(msg, cls = '') {
const el = $('autocalStatus');
el.textContent = msg;
el.style.color = cls === 'err' ? 'var(--danger)' : cls === 'ok' ? 'var(--ok)' : 'var(--dim)';
}
async function solveNow() {
try {
setStatus('calcolo matrice…');
const side = $('acSide').value || undefined;
const r = await api('POST', '/api/calibration/solve', { method: 'TSAI', side });
$('autocalResult').textContent = JSON.stringify({
method: r.method, n_samples: r.n_samples,
residual_translation_mm: r.residual_translation_mm,
residual_rotation_deg: r.residual_rotation_deg,
T_base_camera: r.T_base_camera,
sanity_check: r.sanity_check,
}, null, 2);
setStatus(`matrice calcolata · residuo ${r.residual_translation_mm.toFixed(1)} mm / ${r.residual_rotation_deg.toFixed(2)}°`, 'ok');
} catch (e) { setStatus('solve: ' + e.message, 'err'); }
}
async function clearSamples() {
const side = $('acSide').value || 'right';
if (!confirm(`Rimuovere tutti i sample del braccio ${side}?`)) return;
try {
await api('DELETE', `/api/calibration/samples?side=${encodeURIComponent(side)}`);
setStatus('sample svuotati', 'ok');
} catch (e) { setStatus(e.message, 'err'); }
}
// Wire up events AFTER DOM is parsed.
document.addEventListener('DOMContentLoaded', () => {
if (!$('autocalCanvas')) return;
restoreParams();
// persist on any input change
['acDx','acDy','acDz','acOx','acOy','acOz','acNx','acNy','acNz','acRotDeg','acMinZ','acSettle',
'acSide','acAutoSolve','acDryRun','acOrder'].forEach(id => {
const el = $(id); if (el) el.addEventListener('change', persistParams);
});
$('acPreviewBtn').addEventListener('click', previewGrid);
$('acVerifyBtn').addEventListener('click', verifyReachability);
$('acStartBtn').addEventListener('click', startProcedure);
$('acStopBtn').addEventListener('click', stopProcedure);
$('acSolveBtn').addEventListener('click', solveNow);
$('acClearBtn').addEventListener('click', clearSamples);
$('acShowTriads').addEventListener('change', (e) => {
showTriads = e.target.checked;
for (const it of waypointItems.values()) it.triad.visible = showTriads;
});
$('acFitBtn').addEventListener('click', () => viewer && viewer.fitView());
$('acResetViewBtn').addEventListener('click', () => viewer && viewer.resetView());
});
// Lazy boot when tab first opened.
document.querySelectorAll('.tab').forEach(btn => btn.addEventListener('click', () => {
if (btn.dataset.tab === 'autocal') {
boot();
setTimeout(() => viewer && viewer.resize(), 50);
}
}));
if (document.getElementById('tab-autocal')?.classList.contains('active')) boot();
+484
View File
@@ -0,0 +1,484 @@
/* G1 3D diagnostic viewer.
*
* Loads the URDF, builds the scene graph with two-level groups per joint
* (outer = <origin>, inner = motion driven by diagnostic q), renders meshes
* tinted by per-motor temperature and colored spheres at joint origins.
* Exposes a tiny global API consumed by app.js powerPollOnce:
* window.g1Diag3d.update(diagData)
*
* Deliberately avoids depending on any URDF library — the URDF subset we
* need (fixed/revolute/continuous/prismatic joints, single mesh per visual,
* relative mesh paths) is small enough to parse in ~50 lines.
*/
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
const URDF_URL = '/static/assets/g1/g1_body29_hand14.urdf';
const MESH_BASE = '/static/assets/g1/';
const WARN_C_DEFAULT = 75;
const CRIT_C_DEFAULT = 85;
function parseOrigin(el) {
if (!el) return { xyz: [0, 0, 0], rpy: [0, 0, 0] };
const parse = (s) => s ? s.trim().split(/\s+/).map(Number) : [0, 0, 0];
return {
xyz: parse(el.getAttribute('xyz')),
rpy: parse(el.getAttribute('rpy')),
};
}
function parseAxis(el) {
if (!el) return [1, 0, 0];
const s = el.getAttribute('xyz');
return s ? s.trim().split(/\s+/).map(Number) : [1, 0, 0];
}
async function parseURDF(xmlText) {
const doc = new DOMParser().parseFromString(xmlText, 'application/xml');
const links = {};
for (const el of doc.querySelectorAll('link')) {
const name = el.getAttribute('name');
if (!name) continue;
const v = el.querySelector('visual');
let mesh = null, visualOrigin = { xyz: [0,0,0], rpy: [0,0,0] };
if (v) {
visualOrigin = parseOrigin(v.querySelector('origin'));
const me = v.querySelector('geometry mesh');
if (me) mesh = me.getAttribute('filename');
}
links[name] = { name, mesh, visualOrigin };
}
const joints = [];
for (const el of doc.querySelectorAll('joint')) {
const parent = el.querySelector('parent');
const child = el.querySelector('child');
if (!parent || !child) continue;
joints.push({
name: el.getAttribute('name'),
type: el.getAttribute('type') || 'fixed',
parent: parent.getAttribute('link'),
child: child.getAttribute('link'),
origin: parseOrigin(el.querySelector('origin')),
axis: parseAxis(el.querySelector('axis')),
});
}
return { links, joints };
}
function applyTransform(obj, o) {
obj.position.set(o.xyz[0], o.xyz[1], o.xyz[2]);
obj.rotation.order = 'ZYX'; // URDF RPY: intrinsic rotations yaw * pitch * roll
obj.rotation.set(o.rpy[0], o.rpy[1], o.rpy[2]);
}
function tempToColor(t, warn, crit) {
if (!Number.isFinite(t)) return 0x888888;
if (t >= crit) return 0xe74c3c;
if (t >= warn) return 0xf0ad4e;
// green->yellow interpolation below warn
const u = Math.max(0, Math.min(1, (t - 30) / (warn - 30)));
const r = Math.round(0x3e + u * (0xf0 - 0x3e));
const g = Math.round(0xcf - u * (0xcf - 0xad));
const b = Math.round(0x8e - u * (0x8e - 0x4e));
return (r << 16) | (g << 8) | b;
}
class G1Viewer {
constructor(canvas) {
this.canvas = canvas;
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
this.renderer.setPixelRatio(window.devicePixelRatio || 1);
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x0d1014);
// URDF is Z-up. Keep Z-up throughout for coherence with the robot base frame.
this.camera = new THREE.PerspectiveCamera(45, canvas.clientWidth / canvas.clientHeight, 0.05, 20);
this.camera.up.set(0, 0, 1);
this.camera.position.set(1.8, -1.8, 1.2);
this.camera.lookAt(0, 0, 0.4);
this.controls = new OrbitControls(this.camera, canvas);
this.controls.target.set(0, 0, 0.4);
this.controls.update();
const hemi = new THREE.HemisphereLight(0xffffff, 0x222233, 0.6);
this.scene.add(hemi);
const dir = new THREE.DirectionalLight(0xffffff, 0.8);
dir.position.set(2, 2, 4);
this.scene.add(dir);
const dir2 = new THREE.DirectionalLight(0xffffff, 0.3);
dir2.position.set(-2, -1, 2);
this.scene.add(dir2);
this.grid = new THREE.GridHelper(2, 20, 0x444444, 0x222233);
this.grid.rotation.x = Math.PI / 2; // grid is XY plane in Z-up
this.scene.add(this.grid);
this.robotRoot = new THREE.Group();
this.scene.add(this.robotRoot);
this.linkGroups = {};
this.linkMeshes = {};
this.jointGroups = {}; // static <origin> holders
this.jointMotions = {}; // inner motion-driven holders
this.jointIndicators = {}; // sphere at joint origin
this.motorByJointName = {}; // joint_name -> motor dict (from /diagnostics)
this.warnC = WARN_C_DEFAULT;
this.critC = CRIT_C_DEFAULT;
this.showMeshTint = true;
this.showSpheres = true;
this.pinnedJoint = null;
this.raycaster = new THREE.Raycaster();
this.mouseNDC = new THREE.Vector2();
this._onResize = () => this.resize();
window.addEventListener('resize', this._onResize);
this.canvas.addEventListener('pointermove', (ev) => this._onHover(ev));
this.canvas.addEventListener('click', (ev) => this._onClick(ev));
this._animate = this._animate.bind(this);
this.resize();
this._animate();
}
resize() {
const w = this.canvas.clientWidth || 720;
const h = this.canvas.clientHeight || 560;
this.renderer.setSize(w, h, false);
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
}
_animate() {
this._raf = requestAnimationFrame(this._animate);
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
async load() {
const res = await fetch(URDF_URL);
if (!res.ok) throw new Error(`URDF fetch ${res.status}`);
const xml = await res.text();
const urdf = await parseURDF(xml);
this._urdf = urdf;
this._build(urdf);
await this._loadMeshes(urdf);
this._placeFloor();
}
_build(urdf) {
const childSet = new Set(urdf.joints.map(j => j.child));
const rootName = Object.keys(urdf.links).find(n => !childSet.has(n));
const buildLink = (name) => {
const link = urdf.links[name];
const g = new THREE.Group();
g.name = `link:${name}`;
g.userData.linkName = name;
this.linkGroups[name] = g;
// child joints
for (const j of urdf.joints.filter(x => x.parent === name)) {
const origin = new THREE.Group();
applyTransform(origin, j.origin);
origin.userData.joint = j;
this.jointGroups[j.name] = origin;
const motion = new THREE.Group();
origin.add(motion);
this.jointMotions[j.name] = motion;
// sphere indicator at joint origin (in origin's local frame → origin(0,0,0))
const sphere = new THREE.Mesh(
new THREE.SphereGeometry(0.012, 16, 12),
new THREE.MeshBasicMaterial({ color: 0x888888, depthTest: false, transparent: true, opacity: 0.9 }),
);
sphere.renderOrder = 999;
sphere.userData.jointName = j.name;
origin.add(sphere);
this.jointIndicators[j.name] = sphere;
motion.add(buildLink(j.child));
g.add(origin);
}
return g;
};
this.robotRoot.add(buildLink(rootName));
}
async _loadMeshes(urdf) {
const loader = new STLLoader();
const tasks = [];
for (const name of Object.keys(urdf.links)) {
const link = urdf.links[name];
if (!link.mesh) continue;
const url = MESH_BASE + link.mesh;
tasks.push(new Promise((resolve) => {
loader.load(url, (geom) => {
geom.computeVertexNormals();
const mat = new THREE.MeshStandardMaterial({
color: 0x888888, metalness: 0.1, roughness: 0.6,
flatShading: false,
});
const mesh = new THREE.Mesh(geom, mat);
mesh.userData.linkName = name;
applyTransform(mesh, link.visualOrigin);
this.linkMeshes[name] = mesh;
this.linkGroups[name].add(mesh);
resolve();
}, undefined, (err) => {
console.warn('STL load failed:', url, err);
resolve();
});
}));
}
await Promise.all(tasks);
}
_placeFloor() {
// position grid at model's feet (min z of bounding box).
const box = new THREE.Box3().setFromObject(this.robotRoot);
if (isFinite(box.min.z)) this.grid.position.z = box.min.z;
}
setShowGrid(v) { this.grid.visible = v; }
setShowSpheres(v) {
this.showSpheres = v;
for (const s of Object.values(this.jointIndicators)) s.visible = v;
}
setShowMeshTint(v) {
this.showMeshTint = v;
this._applyColors();
}
fitView() {
const box = new THREE.Box3().setFromObject(this.robotRoot);
if (!isFinite(box.min.z)) return;
const c = new THREE.Vector3();
box.getCenter(c);
const s = new THREE.Vector3();
box.getSize(s);
const d = Math.max(s.x, s.y, s.z) * 1.8;
this.controls.target.copy(c);
const dir = new THREE.Vector3(1, -1, 0.4).normalize();
this.camera.position.copy(c).addScaledVector(dir, d);
this.controls.update();
}
resetView() {
this.controls.target.set(0, 0, 0.4);
this.camera.position.set(1.8, -1.8, 1.2);
this.controls.update();
}
update(diagData) {
if (!diagData || !Array.isArray(diagData.motors)) return;
this.warnC = Number(diagData.warn_temp_c ?? WARN_C_DEFAULT);
this.critC = Number(diagData.crit_temp_c ?? CRIT_C_DEFAULT);
this.motorByJointName = {};
for (const m of diagData.motors) {
const jn = m.name + '_joint';
this.motorByJointName[jn] = m;
// Apply joint angle.
const jg = this.jointMotions[jn];
if (jg) {
const joint = this.jointGroups[jn].userData.joint;
const ax = new THREE.Vector3(...joint.axis).normalize();
if (joint.type === 'revolute' || joint.type === 'continuous') {
jg.quaternion.setFromAxisAngle(ax, Number(m.q) || 0);
} else if (joint.type === 'prismatic') {
jg.position.copy(ax.multiplyScalar(Number(m.q) || 0));
}
}
}
this._applyColors();
if (this.pinnedJoint) this._renderPinned();
this._renderSummary(diagData);
}
_applyColors() {
for (const [jointName, motor] of Object.entries(this.motorByJointName)) {
const joint = this.jointGroups[jointName];
if (!joint) continue;
const temp = motor.temperature_motor;
const faulted = !!motor.faulted;
const enabled = motor.enabled !== false;
let color = tempToColor(temp, this.warnC, this.critC);
if (!enabled) color = 0x333333;
// sphere
const sph = this.jointIndicators[jointName];
if (sph) {
sph.material.color.setHex(color);
if (faulted) sph.material.color.setHex(0xe74c3c);
}
// mesh tint on child link
const childLink = joint.userData.joint.child;
const mesh = this.linkMeshes[childLink];
if (mesh) {
if (this.showMeshTint) {
mesh.material.color.setHex(color);
mesh.material.emissive.setHex(faulted ? 0x330000 : 0x000000);
mesh.material.emissiveIntensity = faulted ? 0.8 : 0;
} else {
mesh.material.color.setHex(0x888888);
mesh.material.emissiveIntensity = 0;
}
}
}
}
_pickJointAt(clientX, clientY) {
const rect = this.canvas.getBoundingClientRect();
this.mouseNDC.x = ((clientX - rect.left) / rect.width) * 2 - 1;
this.mouseNDC.y = -((clientY - rect.top) / rect.height) * 2 + 1;
this.raycaster.setFromCamera(this.mouseNDC, this.camera);
// Prefer hits on joint spheres first (small but always on top).
const sphHits = this.raycaster.intersectObjects(Object.values(this.jointIndicators), false);
if (sphHits.length) return sphHits[0].object.userData.jointName;
// Fall back to mesh hits (walk up the tree to find the joint whose motion group is the ancestor).
const meshHits = this.raycaster.intersectObjects(Object.values(this.linkMeshes), false);
if (!meshHits.length) return null;
const linkName = meshHits[0].object.userData.linkName;
// Find the joint whose child == linkName.
const joint = this._urdf.joints.find(j => j.child === linkName && this.motorByJointName[j.name]);
return joint ? joint.name : null;
}
_onHover(ev) {
const tt = document.getElementById('diag3dTooltip');
const joint = this._pickJointAt(ev.clientX, ev.clientY);
if (!joint) { tt.classList.add('hidden'); return; }
const m = this.motorByJointName[joint];
if (!m) { tt.classList.add('hidden'); return; }
const cls = m.faulted ? 'err' : (m.temperature_motor >= this.critC ? 'err' :
m.temperature_motor >= this.warnC ? 'warn' : '');
tt.className = 'diag3d-tooltip ' + cls;
tt.innerHTML = this._tooltipHTML(m);
const rect = this.canvas.getBoundingClientRect();
tt.style.left = (ev.clientX - rect.left + 14) + 'px';
tt.style.top = (ev.clientY - rect.top + 14) + 'px';
}
_onClick(ev) {
const joint = this._pickJointAt(ev.clientX, ev.clientY);
if (!joint) return;
this.pinnedJoint = joint;
this._renderPinned();
}
_tooltipHTML(m) {
const rows = [
['T motore', `${m.temperature_motor}°C`],
['T ctrl', `${m.temperature_ctrl}°C`],
['q', Number(m.q).toFixed(3) + ' rad'],
['dq', Number(m.dq).toFixed(3) + ' rad/s'],
['tau_est', Number(m.tau_est).toFixed(2) + ' Nm'],
['V', Number(m.voltage).toFixed(2) + ' V'],
['mode', m.mode + (m.enabled === false ? ' (OFF)' : '')],
['fault', m.faulted ? ('0x' + Number(m.motorstate).toString(16)) : 'no'],
];
const body = rows.map(([k, v]) => `<div class="tt-row"><span class="k">${k}</span><span>${v}</span></div>`).join('');
return `<div class="tt-title">#${m.index} · ${m.name}</div>${body}`;
}
_renderPinned() {
const host = document.getElementById('diag3dPinned');
if (!host) return;
if (!this.pinnedJoint) return;
const m = this.motorByJointName[this.pinnedJoint];
if (!m) {
host.innerHTML = `<div class="dim">giunto ${this.pinnedJoint}: nessun motore associato</div>`;
return;
}
const statusCls = m.faulted ? 'err' :
m.temperature_motor >= this.critC ? 'err' :
m.temperature_motor >= this.warnC ? 'warn' : '';
const rows = [
['nome', m.name],
['indice', m.index],
['T motore (°C)', m.temperature_motor],
['T ctrl (°C)', m.temperature_ctrl],
['q (rad)', Number(m.q).toFixed(4)],
['dq (rad/s)', Number(m.dq).toFixed(4)],
['tau_est (Nm)', Number(m.tau_est).toFixed(3)],
['voltaggio (V)', Number(m.voltage).toFixed(3)],
['mode', m.mode],
['enabled', m.enabled ? 'sì' : 'no'],
['motorstate raw', '0x' + Number(m.motorstate).toString(16)],
['faulted', m.faulted ? 'SÌ' : 'no'],
['derating', m.derating ? 'sì' : 'no'],
];
host.innerHTML =
`<div><strong>#${m.index} · ${m.name}</strong> <span class="dim">(giunto: ${this.pinnedJoint})</span></div>` +
`<table><tbody>${rows.map(([k,v]) =>
`<tr class="${statusCls}"><td>${k}</td><td>${v}</td></tr>`).join('')}</tbody></table>`;
}
_renderSummary(d) {
const host = document.getElementById('diag3dSummary');
if (!host) return;
const s = d.summary || {};
const fmt = (arr) => (arr && arr.length) ? arr.join(', ') : '—';
const rows = [
['lowstate', d.stale ? 'STALE' : (d.tick != null ? `tick ${d.tick}` : 'ok')],
['max temp (°C)', s.max_motor_temp_c != null ? `${s.max_motor_temp_c} @ ${s.max_motor_temp_joint}` : '—'],
['warn / crit (°C)', `${d.warn_temp_c} / ${d.crit_temp_c}`],
['derating', fmt(s.derating_joints)],
['critical', fmt(s.critical_joints)],
['faulted', fmt(s.faulted_joints)],
['disabled', fmt(s.disabled_joints)],
['min V', s.min_voltage != null ? Number(s.min_voltage).toFixed(2) + ' V' : '—'],
];
host.innerHTML = `<table><tbody>${rows.map(([k,v]) =>
`<tr><td>${k}</td><td>${v}</td></tr>`).join('')}</tbody></table>`;
}
}
// Expose the viewer class so other modules (Auto-Calibrazione) can
// instantiate a second canvas over the same URDF pipeline.
export { G1Viewer };
// ---- Boot and expose global hook. ----
const canvas = document.getElementById('diag3dCanvas');
const statusEl = document.getElementById('diag3dStatus');
let viewer = null;
async function boot() {
if (!canvas) return;
try {
statusEl.textContent = 'caricamento URDF + mesh…';
viewer = new G1Viewer(canvas);
await viewer.load();
statusEl.textContent = 'URDF caricato · in attesa di /diagnostics…';
// Wire up toolbar controls.
const by = (id) => document.getElementById(id);
by('diag3dMeshTint').addEventListener('change', (e) => viewer.setShowMeshTint(e.target.checked));
by('diag3dSpheres').addEventListener('change', (e) => viewer.setShowSpheres(e.target.checked));
by('diag3dGrid').addEventListener('change', (e) => viewer.setShowGrid(e.target.checked));
by('diag3dResetBtn').addEventListener('click', () => viewer.resetView());
by('diag3dFitBtn').addEventListener('click', () => viewer.fitView());
// Re-size when the diagnostic tab becomes active (canvas may have 0 size while hidden).
document.querySelectorAll('.tab').forEach(btn => btn.addEventListener('click', () => {
if (btn.dataset.tab === 'diagnostic') setTimeout(() => viewer && viewer.resize(), 50);
}));
// Global hook for app.js to push diagnostic updates.
window.g1Diag3d = {
update: (d) => {
try { viewer.update(d); statusEl.textContent =
d.stale ? 'lowstate STALE' :
d.available === false ? (d.reason || '/diagnostics non disponibile') :
`motori aggiornati · tick ${d.tick ?? '?'}`; }
catch (e) { console.warn('g1Diag3d.update:', e); }
},
};
} catch (e) {
console.error('diag3d boot failed:', e);
statusEl.textContent = 'errore caricamento: ' + e.message;
}
}
// Defer boot until the tab is first opened — avoids eager CDN fetch and STL load
// for users who never visit this tab.
let booted = false;
document.querySelectorAll('.tab').forEach(btn => btn.addEventListener('click', () => {
if (btn.dataset.tab === 'diagnostic' && !booted) { booted = true; boot(); }
}));
+579
View File
@@ -0,0 +1,579 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>presa-mano</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<header>
<h1>presa-mano</h1>
<nav class="tabs">
<button class="tab active" data-tab="home">Home</button>
<button class="tab" data-tab="agent">Assistente AI</button>
<button class="tab" data-tab="ai-settings">Impostazioni AI</button>
<button class="tab" data-tab="calibration">Calibrazione</button>
<button class="tab" data-tab="autocal">Auto-Calibrazione</button>
<button class="tab" data-tab="grasp">TrovaUtensile</button>
<button class="tab" data-tab="servo">Servo Visuale</button>
<button class="tab" data-tab="view3d">Vista 3D</button>
<button class="tab" data-tab="ee">Movimento EE</button>
</nav>
<div class="status-strip" id="statusStrip"></div>
<span class="active-robot-pill" id="activeRobotPill" title="Robot attualmente collegato">robot: —</span>
</header>
<main>
<!-- ============================== HOME ============================== -->
<section class="panel active" id="tab-home">
<div class="posture-toolbar">
<label class="inline">Robot attivo
<select id="homeRobotSelect">
<option value="g1">G1</option>
<option value="h1">H1</option>
</select>
</label>
<button id="homeApplyRobotBtn" class="primary">Applica</button>
<label class="inline"><input type="checkbox" id="homeAutoPoll" checked /> Auto-poll</label>
<label class="inline">ogni
<input type="number" id="homePollMs" value="2000" min="500" step="500" style="width:80px"> ms
</label>
<button id="homeRefreshBtn">Aggiorna ora</button>
</div>
<div class="info-box">
Stato di comunicazione con i microservizi upstream. Il selettore sopra cambia il robot attivo (G1 o H1) e aggiorna <code>config.yaml</code> a runtime; le chiamate <code>/api/wrist</code>, <code>/api/posture</code>, <code>/api/robot</code> useranno il robot selezionato.
</div>
<h2>Stato dispositivi</h2>
<table class="device-status-table" id="homeDeviceTable">
<thead>
<tr>
<th>Servizio</th>
<th>URL</th>
<th>Stato</th>
<th>Latenza</th>
<th>Dettagli</th>
</tr>
</thead>
<tbody>
<tr><td colspan="5" class="dim">caricamento…</td></tr>
</tbody>
</table>
<h3>Ultimo esito</h3>
<pre id="homeLog" class="log small">—</pre>
</section>
<!-- ============================== ASSISTENTE AI ============================== -->
<section class="panel" id="tab-agent">
<div class="posture-toolbar">
<span class="post-health" id="agentHealth">—</span>
<span class="active-robot-pill" id="agentActiveRobotPill" title="Robot attivo">robot: —</span>
<button id="agentClearBtn" title="Resetta la conversazione">Nuova conversazione</button>
</div>
<div class="grid-2 agent-layout">
<!-- LEFT: chat history + input -->
<div>
<h2>Chat</h2>
<div id="agentChat" class="agent-chat">
<div class="agent-msg system">Ciao. Dimmi cosa vuoi fare con il robot. Es: "muovi il braccio destro 200 mm in X+", "calibra il braccio destro", "mostra lo stato dei dispositivi".</div>
</div>
<div class="agent-input-row">
<textarea id="agentInput" rows="3" placeholder="Scrivi un comando o una richiesta in linguaggio naturale… (Ctrl+Invio per inviare)"></textarea>
<button id="agentSendBtn" class="primary">Invia ▸</button>
</div>
<div class="marker-info" id="agentStatus">—</div>
</div>
<!-- RIGHT: saved tasks + tool trace -->
<div>
<h2>Task salvati</h2>
<div class="info-box">
Salva un prompt usato spesso (es. "calibra braccio dx") per riutilizzarlo con un click. Il prompt corrente nella textarea viene salvato.
</div>
<div class="kv compact">
<label>Nome <input type="text" id="agentTaskName" placeholder="es. calibrazione_dx" maxlength="80"></label>
<label>Descrizione <input type="text" id="agentTaskDesc" placeholder="(opzionale)" maxlength="400"></label>
</div>
<div class="actions">
<button id="agentTaskSaveBtn" class="primary">💾 Salva prompt corrente</button>
<button id="agentTaskReloadBtn">⟳ Ricarica</button>
</div>
<ul id="agentTaskList" class="agent-task-list">
<li class="dim">caricamento…</li>
</ul>
<h2>Tool trace ultima risposta</h2>
<pre id="agentTrace" class="log small">(nessuna chiamata)</pre>
</div>
</div>
</section>
<!-- ============================== IMPOSTAZIONI AI ============================== -->
<section class="panel" id="tab-ai-settings">
<div class="info-box">
Parametri del client OpenRouter usato dall'Assistente AI. Le modifiche vengono persistite in <code>config.yaml</code> via <code>PUT /api/config</code> e applicate alla chat successiva (non c'è bisogno di restart). Il <strong>prompt aggiuntivo</strong> viene appeso al prompt di sistema dinamico — non sostituisce le regole base (frame, tool, sicurezza).
</div>
<div class="grid-2">
<div>
<h2>Modello e parametri</h2>
<div class="ctrl-block">
<div class="kv compact">
<label>Modello (combo)
<select id="aiModelSelect">
<option value="">caricamento…</option>
</select>
</label>
<label>Modello (custom — sovrascrive la combo)
<input type="text" id="aiModelCustom" placeholder="es. anthropic/claude-sonnet-4.5" />
</label>
<label title="0 = deterministico, 1 = creativo. Per orchestrazione robotica tieni basso (0.0–0.3).">
Temperature
<input type="number" id="aiTemperature" min="0" max="2" step="0.05" />
</label>
<label title="Massimo numero di iterazioni di tool calling per singolo messaggio">
Max tool iterations
<input type="number" id="aiMaxIterations" min="1" max="20" step="1" />
</label>
<label title="Timeout HTTP per la singola chiamata a OpenRouter (secondi)">
Timeout (s)
<input type="number" id="aiTimeout" min="5" max="300" step="5" />
</label>
</div>
<div class="actions">
<button id="aiSaveBtn" class="primary">💾 Salva impostazioni</button>
<button id="aiReloadBtn">⟳ Ricarica da config</button>
<button id="aiResetTempBtn" title="Riporta temperature a 0.1 (default safe)">Reset temp</button>
</div>
<div class="marker-info" id="aiSettingsStatus">—</div>
</div>
<h2>Prompt aggiuntivo</h2>
<div class="ctrl-block">
<div class="info-box">
Testo che viene appeso al system prompt dinamico (sezione "Istruzioni aggiuntive dell'operatore"). Usa per personalizzare lo stile, aggiungere contesto specifico al tuo setup, o regole custom. Lascia vuoto per non aggiungere nulla.
</div>
<textarea id="aiPromptAddendum" rows="10" style="width:100%;font-family:ui-monospace,monospace;font-size:12px;padding:8px;background:#0e1116;color:inherit;border:1px solid var(--border);border-radius:4px"></textarea>
</div>
</div>
<div>
<h2>Anteprima system prompt corrente</h2>
<div class="info-box">
Quello che il modello riceve davvero al prossimo messaggio (include lo stato corrente: robot attivo, marker, ecc., più l'eventuale addendum sopra dopo che lo salvi).
</div>
<div class="actions">
<button id="aiPromptPreviewBtn">⟳ Aggiorna anteprima</button>
<button id="aiPromptCopyBtn">📋 Copia</button>
</div>
<pre id="aiPromptPreview" class="log small" style="max-height:560px;overflow:auto">(premi 'Aggiorna anteprima')</pre>
</div>
</div>
</section>
<!-- ============================== CALIBRAZIONE ============================== -->
<section class="panel" id="tab-calibration">
<div class="grid-2">
<div>
<h2>Vista camera</h2>
<div class="canvas-wrap">
<canvas id="calibCanvas" width="640" height="480"></canvas>
</div>
<div class="kv">
<label>ArUco ID <input type="number" id="arucoId" value="10" min="0" /></label>
<label>Size (mm) <input type="number" id="arucoSize" value="40" step="1" /></label>
<label>Side
<select id="side"><option value="right" selected>right</option><option value="left">left</option></select>
</label>
<label>Metodo
<select id="method">
<option value="TSAI" selected>TSAI</option>
<option value="PARK">PARK</option>
<option value="HORAUD">HORAUD</option>
<option value="ANDREFF">ANDREFF</option>
<option value="DANIILIDIS">DANIILIDIS</option>
</select>
</label>
<label class="inline"><input type="checkbox" id="livePreview" checked /> Preview marker</label>
<button id="saveConfigBtn">Salva configurazione</button>
</div>
<div class="marker-info" id="markerInfo">marker: —</div>
</div>
<div>
<h2>Sample e matrice</h2>
<div class="actions">
<button id="sampleBtn" class="primary">Memorizza posizione (sample)</button>
<button id="solveBtn" class="primary">Calcola matrice</button>
<button id="clearSamplesBtn" class="danger">Rimuovi tutti i sample</button>
</div>
<div class="samples">
<div class="samples-head">
<strong>Sample acquisiti</strong>
<span id="sampleCount">0</span>
</div>
<ul id="sampleList"></ul>
</div>
<div class="result">
<strong>T<sub>base ← camera</sub></strong>
<pre id="calibMatrix">(non ancora calcolata)</pre>
<div id="calibMeta"></div>
</div>
</div>
</div>
<details class="auto-grid">
<summary>Auto-grid (future update) — 3×3 × 3 altezze</summary>
<div class="kv">
<label>Start X (m) <input type="number" id="gridX0" value="0.25" step="0.01" /></label>
<label>Start Y (m) <input type="number" id="gridY0" value="0.0" step="0.01" /></label>
<label>Start Z (m) <input type="number" id="gridZ0" value="0.15" step="0.01" /></label>
<label>Step X (m) <input type="number" id="gridDX" value="0.05" step="0.005" /></label>
<label>Step Y (m) <input type="number" id="gridDY" value="0.05" step="0.005" /></label>
<label>Step Z (m) <input type="number" id="gridDZ" value="0.05" step="0.005" /></label>
<label>min Z tavolo (m) <input type="number" id="gridMinZ" value="0.10" step="0.005" /></label>
<label>Settle (s) <input type="number" id="gridSettle" value="1.0" step="0.1" /></label>
<label>Quat xyzw <input type="text" id="gridQuat" value="0,0,0,1" /></label>
<label class="inline"><input type="checkbox" id="gridDryRun" checked /> Dry-run (consigliato finché G1 down)</label>
<button id="runGridBtn">Esegui auto-grid</button>
</div>
<pre id="gridLog" class="log"></pre>
</details>
</section>
<!-- ============================== AUTO-CALIBRAZIONE ============================== -->
<section class="panel" id="tab-autocal">
<div class="diag3d-toolbar">
<button id="acFitBtn">Auto-fit</button>
<button id="acResetViewBtn">Reset vista</button>
<label class="inline"><input type="checkbox" id="acShowTriads" checked /> Triad sui waypoint</label>
<span class="post-health" id="autocalStatus">—</span>
</div>
<div class="grid-2 diag3d-layout">
<div>
<div class="canvas-wrap">
<canvas id="autocalCanvas" width="720" height="560"></canvas>
</div>
<div class="marker-info">drag = ruota · wheel = zoom · shift+drag = pan</div>
<div class="diag3d-legend">
<div><span class="legend-sw" style="background:#aaaaaa;border-radius:50%"></span> raggiungibile</div>
<div><span class="legend-sw" style="background:var(--danger);border-radius:50%"></span> non raggiungibile (skip)</div>
<div><span class="legend-sw" style="background:var(--accent);border-radius:50%"></span> corrente</div>
<div><span class="legend-sw" style="background:var(--ok);border-radius:50%"></span> campione acquisito</div>
<div><span class="legend-sw" style="background:var(--warn);border-radius:50%"></span> fallito</div>
</div>
</div>
<div>
<h2>Parametri griglia</h2>
<div class="ctrl-block">
<div class="info-box">
Parti dalla posizione corrente del polso (letta dal G1). La griglia copre ±delta/2 su ciascun asse. Le divisioni sono i punti per asse: 3 significa [start-Δ/2, start, start+Δ/2].
</div>
<div class="kv compact">
<label>Δx (mm) <input type="number" id="acDx" value="100" step="10" min="0" max="500"></label>
<label>Δy (mm) <input type="number" id="acDy" value="100" step="10" min="0" max="500"></label>
<label>Δz (mm) <input type="number" id="acDz" value="80" step="10" min="0" max="500"></label>
<label title="Spostamento del centro griglia rispetto alla posa polso attuale">offset x (mm) <input type="number" id="acOx" value="0" step="10" min="-500" max="500"></label>
<label title="Spostamento del centro griglia rispetto alla posa polso attuale">offset y (mm) <input type="number" id="acOy" value="0" step="10" min="-500" max="500"></label>
<label title="Spostamento del centro griglia rispetto alla posa polso attuale">offset z (mm) <input type="number" id="acOz" value="0" step="10" min="-500" max="500"></label>
<label>n_x <input type="number" id="acNx" value="3" step="1" min="1" max="7"></label>
<label>n_y <input type="number" id="acNy" value="3" step="1" min="1" max="7"></label>
<label>n_z <input type="number" id="acNz" value="3" step="1" min="1" max="7"></label>
<label>rot polso (°) <input type="number" id="acRotDeg" value="10" step="1" min="0" max="45"></label>
<label>min z (m) <input type="number" id="acMinZ" value="0.05" step="0.01"></label>
<label>settle (s) <input type="number" id="acSettle" value="1.0" step="0.1" min="0.1" max="5"></label>
<label>side
<select id="acSide">
<option value="">(config)</option>
<option value="right">right</option>
<option value="left">left</option>
</select>
</label>
<label class="inline"><input type="checkbox" id="acAutoSolve" checked /> auto-solve finale</label>
<label class="inline"><input type="checkbox" id="acDryRun" /> dry-run (preview, nessun moto reale)</label>
</div>
<div class="actions">
<button id="acPreviewBtn" class="primary">Calcola griglia</button>
<button id="acVerifyBtn" title="muove a ogni punto senza acquisire — testa raggiungibilità reale">Verifica reale</button>
<button id="acStartBtn" disabled>▶ Start calibrazione</button>
<button id="acStopBtn" class="danger" disabled>■ Stop + torna a home</button>
<button id="acSolveBtn">Calcola matrice</button>
<button id="acClearBtn" class="danger">Rimuovi sample</button>
</div>
<div class="kv compact">
<label>Ordine di visita
<select id="acOrder">
<option value="serpentine" selected>serpentina (distanza min.)</option>
<option value="nested">nested (xyz semplice)</option>
</select>
</label>
</div>
</div>
<h2>Stato</h2>
<div id="autocalStats" class="ctrl-block">
<div class="dim">nessuna griglia calcolata</div>
</div>
<h2>Tentativi per punto</h2>
<canvas id="autocalChart" width="520" height="140" class="autocal-chart"></canvas>
<h2>Risultato calibrazione</h2>
<pre id="autocalResult" class="log small">—</pre>
</div>
</div>
</section>
<!-- ============================== TROVA UTENSILE ============================== -->
<section class="panel" id="tab-grasp">
<div class="grid-2">
<div>
<h2>RGB + detection — click per misurare</h2>
<div class="canvas-wrap">
<canvas id="graspCanvas" width="640" height="480"></canvas>
</div>
<div class="measure-strip" id="measureInfo">click sull'immagine per la distanza</div>
<div class="kv">
<label>Nuvola di <select id="graspObjectPick"><option value="">(top overall)</option></select></label>
<label>Confidenza <input type="number" id="graspConf" value="0.5" step="0.05" min="0" max="1" /></label>
<label class="inline"><input type="checkbox" id="graspDryRun" checked /> Dry-run</label>
</div>
<div id="graspLegend" class="grasp-legend"></div>
<div class="actions">
<button id="detectBtn">Rileva oggetto</button>
<button id="loopBtn">▶ Loop riconoscimento</button>
<label class="inline" style="margin-left:8px"><input type="number" id="loopRateMs" value="500" min="100" step="50" style="width:80px"> ms</label>
<button id="planBtn" class="primary">Pianifica grasp</button>
<button id="executeBtn" class="primary">Esegui presa</button>
</div>
</div>
<div>
<h2>Crop utensile</h2>
<div class="canvas-wrap small">
<canvas id="cropCanvas" width="320" height="240"></canvas>
</div>
<h2>Nuvola punti oggetto (base frame)</h2>
<div class="scatter-row">
<div>
<div class="scatter-title">Top-down (X-Y)</div>
<canvas id="scatterXY" width="260" height="260"></canvas>
</div>
<div>
<div class="scatter-title">Laterale (X-Z)</div>
<canvas id="scatterXZ" width="260" height="260"></canvas>
</div>
</div>
<h3>Precisione (ultime <span id="statsWindow">5</span> detection)</h3>
<table class="stats" id="statsTable">
<thead>
<tr><th>asse</th><th>media [m]</th><th>σ [mm]</th><th>range [mm]</th><th>min</th><th>max</th></tr>
</thead>
<tbody>
<tr><td>x</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td></tr>
<tr><td>y</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td></tr>
<tr><td>z</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td></tr>
</tbody>
</table>
<div id="statsMeta" class="marker-info">loop: inattivo</div>
<h3>Dettagli</h3>
<pre id="graspOut" class="log">(nessuna detection)</pre>
</div>
</div>
</section>
<!-- ============================== SERVO VISUALE ============================== -->
<section class="panel" id="tab-servo">
<div class="diag3d-toolbar">
<span class="post-health" id="servoStatus">—</span>
</div>
<div class="grid-2 diag3d-layout">
<div>
<div id="servoViewport" style="position:relative;width:min(100%,720px);height:440px;overflow:hidden;background:#000;border:1px solid var(--border);border-radius:4px;cursor:grab;user-select:none">
<div id="servoPanZoom" style="position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform">
<img id="servoImg" alt="Nuwa RGB" style="display:block;max-width:none;pointer-events:none" draggable="false" />
<canvas id="servoOverlay" style="position:absolute;left:0;top:0;pointer-events:none"></canvas>
</div>
</div>
<div class="marker-info">RGB live · ciano = marker · giallo = oggetto · wheel zoom · drag pan · doppio click reset</div>
<h3 style="margin-top:14px">Vista 3D (frame camera)</h3>
<div class="canvas-wrap" style="width:min(100%,720px)">
<canvas id="servo3dCanvas" width="720" height="360" style="display:block;background:#0a0d11;border:1px solid var(--border);border-radius:4px"></canvas>
</div>
<div class="marker-info">origine = camera · piano viola · marker ciano · oggetto giallo · proiezioni più chiare · drag ruota · wheel zoom</div>
<div class="diag3d-legend">
<div><span class="legend-sw" style="background:#4dd0e1;border-radius:50%"></span> marker polso</div>
<div><span class="legend-sw" style="background:#ffd54f;border-radius:50%"></span> oggetto (centro bbox)</div>
</div>
</div>
<div>
<h2>Parametri</h2>
<div class="ctrl-block">
<div class="info-box">
Avvicinamento marker-relativo senza hand-eye calibration: la camera vede marker e oggetto nello stesso frame, ogni step sposta il polso della frazione indicata della distanza rimanente. Si ferma a <code>min_distance_m</code>.
</div>
<div class="kv compact">
<label>Classe (filtro) <input type="text" id="svClass" placeholder="(tutte)" /></label>
<label>Confidenza <input type="number" id="svConf" value="0.5" step="0.05" min="0" max="1" /></label>
<label>min dist (mm) <input type="number" id="svMinDist" value="100" step="10" min="20" max="500" /></label>
<label>frazione step <input type="number" id="svStepFrac" value="0.5" step="0.05" min="0.05" max="1.0" /></label>
<label>timeout (s) <input type="number" id="svTimeout" value="5" step="0.5" min="1" max="30" /></label>
<label>side
<select id="svSide">
<option value="">(config)</option>
<option value="right">right</option>
<option value="left">left</option>
</select>
</label>
<label class="inline"><input type="checkbox" id="svDryRun" checked /> dry-run (nessun moto reale)</label>
</div>
<div class="info-box" style="margin-top:8px">
<strong>Orientazione marker rispetto al polso</strong> (rvec in gradi). Se non allineato al frame polso il target risulta spostato: tuna finché marker, oggetto e target sono coerenti.
</div>
<div class="kv compact">
<label>rx (°) <input type="number" id="svRx" value="0" step="5" min="-180" max="180" /></label>
<label>ry (°) <input type="number" id="svRy" value="0" step="5" min="-180" max="180" /></label>
<label>rz (°) <input type="number" id="svRz" value="0" step="5" min="-180" max="180" /></label>
<button id="svSaveRvecBtn" title="Salva l'rvec corrente in config.yaml">Salva rvec in config</button>
</div>
<div class="actions">
<button id="svPlanBtn" class="primary">Rileva / ricalcola</button>
<button id="svStepBtn">▶ Vai al prossimo</button>
<button id="svResetBtn">Reset</button>
</div>
<div class="info-box" style="margin-top:8px">
<strong>Piano di lavoro</strong>. Clicca "Seleziona area", poi traccia un rettangolo sull'immagine con shift+drag: il backend calcola il piano dalla depth in quell'area. Marker e oggetto vengono poi proiettati su questo piano.
</div>
<div class="actions">
<button id="svPlaneSelBtn">Seleziona area</button>
<button id="svPlaneFitBtn">Calcola piano</button>
<button id="svPlaneClearBtn" class="danger">Rimuovi piano</button>
</div>
<div id="svPlaneInfo" class="marker-info">piano: —</div>
</div>
<h2>Stato</h2>
<div id="servoStats" class="ctrl-block">
<div class="dim">nessun piano calcolato</div>
</div>
<h2>Dettagli piano</h2>
<pre id="servoResult" class="log small">—</pre>
</div>
</div>
</section>
<!-- ============================== VISTA 3D ============================== -->
<section class="panel" id="tab-view3d">
<div class="grid-2">
<div>
<h2>Scena 3D — frame base robot</h2>
<div class="canvas-wrap">
<canvas id="view3dCanvas" width="720" height="540"></canvas>
</div>
<div class="marker-info" id="view3dHint">drag = ruota · wheel = zoom · doppio click = reset vista · shift+drag = pan</div>
<div class="marker-info" id="view3dStatusLine">—</div>
</div>
<div>
<h2>Dati</h2>
<div class="kv">
<label>Classe (filtro) <input type="text" id="view3dClass" placeholder="(tutte)" /></label>
<label>Confidenza <input type="number" id="view3dConf" value="0.5" step="0.05" min="0" max="1" /></label>
<label class="inline"><input type="checkbox" id="view3dShowCloud" checked /> Punti oggetto</label>
<label class="inline"><input type="checkbox" id="view3dShowGrid" checked /> Griglia tavolo</label>
<label class="inline"><input type="checkbox" id="view3dShowPlan" checked /> Pianifica grasp</label>
<label class="inline"><input type="checkbox" id="view3dAutoFit" checked /> Auto-fit vista</label>
</div>
<div class="actions">
<button id="view3dRefreshBtn" class="primary">Aggiorna scena</button>
<button id="view3dLoopBtn">▶ Loop</button>
<label class="inline" style="margin-left:8px"><input type="number" id="view3dRateMs" value="800" min="200" step="50" style="width:80px"> ms</label>
<button id="view3dResetBtn">Reset vista</button>
</div>
<div class="legend">
<div class="legend-row"><span class="dot" style="background:#e74c3c"></span> X (rosso) — base</div>
<div class="legend-row"><span class="dot" style="background:#3ecf8e"></span> Y (verde) — base</div>
<div class="legend-row"><span class="dot" style="background:#4da3ff"></span> Z (blu) — base</div>
<div class="legend-row"><span class="dot" style="background:#f0ad4e"></span> Polso destro</div>
<div class="legend-row"><span class="dot" style="background:#9b59b6"></span> Polso sinistro</div>
<div class="legend-row"><span class="dot" style="background:#aaaaaa"></span> Punti oggetto</div>
<div class="legend-row"><span class="dot" style="background:#ffd54f"></span> Centroide oggetto</div>
<div class="legend-row"><span class="dot" style="background:#ff5e7e"></span> Punto di presa</div>
<div class="legend-row"><span class="dot" style="background:#ffffff;border:1px solid #888"></span> Pre-grasp + traiettoria</div>
</div>
<h3>Stato</h3>
<pre id="view3dOut" class="log">(nessun dato — premi "Aggiorna scena")</pre>
</div>
</div>
</section>
<!-- ============================== EE ============================== -->
<section class="panel" id="tab-ee">
<div class="grid-2">
<div>
<h2>Polso corrente</h2>
<pre id="wristCurrent" class="log">—</pre>
<button id="refreshWristBtn">Aggiorna pose</button>
<h3>Posa assoluta</h3>
<div class="kv">
<label>Side <select id="eeSide"><option value="right">right</option><option value="left">left</option></select></label>
<label>Px <input type="number" id="absPx" step="0.01" value="0.30"></label>
<label>Py <input type="number" id="absPy" step="0.01" value="0.10"></label>
<label>Pz <input type="number" id="absPz" step="0.01" value="0.20"></label>
<label>Qx <input type="number" id="absQx" step="0.01" value="0"></label>
<label>Qy <input type="number" id="absQy" step="0.01" value="0"></label>
<label>Qz <input type="number" id="absQz" step="0.01" value="0"></label>
<label>Qw <input type="number" id="absQw" step="0.01" value="1"></label>
<label class="inline"><input type="checkbox" id="eeDryRun" /> Dry-run</label>
</div>
<button id="absSendBtn" class="primary">Invia assoluta</button>
<h3>Delta relativo</h3>
<div class="kv">
<label>ΔX <input type="number" id="relDx" step="0.01" value="0"></label>
<label>ΔY <input type="number" id="relDy" step="0.01" value="0"></label>
<label>ΔZ <input type="number" id="relDz" step="0.01" value="0.05"></label>
</div>
<button id="relSendBtn" class="primary">Invia relativa</button>
</div>
<div>
<h2>Mano + sicurezza</h2>
<div class="actions">
<button id="handOpenBtn">RIGHT_OPEN</button>
<button id="handCloseBtn">RIGHT_CLOSE</button>
<button id="handOpenLBtn">LEFT_OPEN</button>
<button id="handCloseLBtn">LEFT_CLOSE</button>
</div>
<div class="actions">
<button id="estopBtn" class="danger">EMERGENCY STOP</button>
<button id="unlockBtn">Unlock</button>
</div>
<h3>Ultimo esito</h3>
<pre id="eeLog" class="log">—</pre>
</div>
</div>
</section>
</main>
<div id="toastHost" class="toast-host" aria-live="polite"></div>
<script type="importmap">
{
"imports": {
"three": "/static/vendor/three/build/three.module.js",
"three/addons/": "/static/vendor/three/addons/"
}
}
</script>
<script src="/static/app.js"></script>
<script type="module" src="/static/autocal3d.js"></script>
<script type="module" src="/static/servo.js"></script>
</body>
</html>
+669
View File
@@ -0,0 +1,669 @@
/* Visual-servo tab: live Nuwa RGB with marker + object-centre overlays.
* Each click: plan → render overlay + stats → user confirms → step → wait →
* re-plan → render. No 3D viewer in this tab — it's an image-space tool.
*/
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const $ = (id) => document.getElementById(id);
const LS_KEY = 'presaMano.servoParams.v1';
const DEG = Math.PI / 180;
// ----- 3D scene (camera-frame) ----------------------------------------------
let scene3d = null, cam3d = null, renderer3d = null, controls3d = null;
let scene3dObjs = {}; // named overlays to clear/update on each plan
function boot3d() {
if (scene3d) return;
const canvas = $('servo3dCanvas');
if (!canvas) return;
scene3d = new THREE.Scene();
scene3d.background = new THREE.Color(0x0a0d11);
// Camera looks from outside the scene; world frame == Nuwa camera frame
// (OpenCV: +X right, +Y down, +Z forward). We orbit around the origin.
cam3d = new THREE.PerspectiveCamera(45, canvas.width / canvas.height, 0.05, 20);
cam3d.position.set(0.8, -0.6, -0.2);
cam3d.up.set(0, -1, 0); // Y-down so CV axes match visually
renderer3d = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer3d.setPixelRatio(window.devicePixelRatio || 1);
renderer3d.setSize(canvas.width, canvas.height, false);
controls3d = new OrbitControls(cam3d, canvas);
controls3d.target.set(0, 0, 1.2);
controls3d.update();
scene3d.add(new THREE.AmbientLight(0xffffff, 0.6));
const dir = new THREE.DirectionalLight(0xffffff, 0.6);
dir.position.set(1, -1, 1);
scene3d.add(dir);
// Camera-origin marker (small axes triad)
const ax = new THREE.AxesHelper(0.2);
scene3d.add(ax);
// Small grid at Z=1.5 to anchor the eye when there's no plane yet.
const grid = new THREE.GridHelper(2, 10, 0x222a33, 0x1a2029);
grid.rotation.x = Math.PI / 2;
grid.position.z = 1.5;
scene3d.add(grid);
// Frustum wireframe (approximate FOV lines up to 2 m depth).
const fh = 0.7, fw = 1.0, fz = 2.0;
const frPts = [
0,0,0, fw, fh, fz,
0,0,0, -fw, fh, fz,
0,0,0, fw, -fh, fz,
0,0,0, -fw, -fh, fz,
fw, fh, fz, fw, -fh, fz,
fw, -fh, fz, -fw, -fh, fz,
-fw, -fh, fz, -fw, fh, fz,
-fw, fh, fz, fw, fh, fz,
];
const frGeo = new THREE.BufferGeometry();
frGeo.setAttribute('position', new THREE.Float32BufferAttribute(frPts, 3));
const frMat = new THREE.LineBasicMaterial({ color: 0x2a3340 });
scene3d.add(new THREE.LineSegments(frGeo, frMat));
animate3d();
}
function animate3d() {
requestAnimationFrame(animate3d);
if (controls3d) controls3d.update();
if (renderer3d && scene3d && cam3d) renderer3d.render(scene3d, cam3d);
}
function clear3dOverlays() {
for (const k of Object.keys(scene3dObjs)) {
const o = scene3dObjs[k];
if (!o) continue;
scene3d.remove(o);
if (o.geometry) o.geometry.dispose();
if (o.material) (Array.isArray(o.material) ? o.material : [o.material]).forEach(m => m.dispose());
scene3dObjs[k] = null;
}
}
function addSphere3d(key, xyz, color, radius = 0.025) {
const geom = new THREE.SphereGeometry(radius, 24, 16);
const mat = new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.25 });
const m = new THREE.Mesh(geom, mat);
m.position.set(xyz[0], xyz[1], xyz[2]);
scene3d.add(m);
scene3dObjs[key] = m;
}
function addLine3d(key, a, b, color) {
const g = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(...a), new THREE.Vector3(...b),
]);
const m = new THREE.Line(g, new THREE.LineBasicMaterial({ color }));
scene3d.add(m);
scene3dObjs[key] = m;
}
function addPlaneMesh3d(key, normal, offset, anchor, size = 1.2, color = 0x9b6dff) {
const n = new THREE.Vector3(...normal).normalize();
// Build two in-plane orthonormal vectors.
const up = Math.abs(n.y) < 0.9 ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0);
const u = new THREE.Vector3().crossVectors(up, n).normalize();
const v = new THREE.Vector3().crossVectors(n, u).normalize();
// Centre the mesh on the anchor point (foot of camera onto plane if given).
const anchorVec = new THREE.Vector3(...anchor);
// Pull anchor onto the plane to be safe.
const s = anchorVec.dot(n) + offset;
anchorVec.addScaledVector(n, -s);
const geom = new THREE.BufferGeometry();
const h = size / 2;
const corners = [
anchorVec.clone().addScaledVector(u, -h).addScaledVector(v, -h),
anchorVec.clone().addScaledVector(u, h).addScaledVector(v, -h),
anchorVec.clone().addScaledVector(u, h).addScaledVector(v, h),
anchorVec.clone().addScaledVector(u, -h).addScaledVector(v, h),
];
const pos = [];
for (const c of corners) pos.push(c.x, c.y, c.z);
geom.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geom.setIndex([0, 1, 2, 0, 2, 3]);
geom.computeVertexNormals();
const mat = new THREE.MeshStandardMaterial({
color, opacity: 0.35, transparent: true, side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(geom, mat);
scene3d.add(mesh);
scene3dObjs[key] = mesh;
// Edges
const edgeGeom = new THREE.EdgesGeometry(geom);
const edgeMat = new THREE.LineBasicMaterial({ color });
const edges = new THREE.LineSegments(edgeGeom, edgeMat);
scene3d.add(edges);
scene3dObjs[key + '_edges'] = edges;
}
function redraw3d() {
if (!scene3d) return;
clear3dOverlays();
const p = lastPlan && lastPlan.plan;
// Plane (from stored planeInfo, independent of current plan)
if (planeInfo) {
const anchor = planeInfo.centroid || [0, 0, 1.5];
addPlaneMesh3d('plane', planeInfo.normal, planeInfo.offset, anchor);
}
if (!p) return;
addSphere3d('marker', p.marker_position_cam, 0x4dd0e1, 0.022);
addSphere3d('object', p.object_position_cam, 0xffd54f, 0.028);
addLine3d('mo_line', p.marker_position_cam, p.object_position_cam, 0xbbbbbb);
if (p.plane) {
addSphere3d('marker_foot', p.plane.marker_foot_cam, 0x80ecf7, 0.018);
addSphere3d('object_foot', p.plane.object_foot_cam, 0xffe998, 0.020);
addLine3d('m_drop', p.marker_position_cam, p.plane.marker_foot_cam, 0x4dd0e1);
addLine3d('o_drop', p.object_position_cam, p.plane.object_foot_cam, 0xffd54f);
addLine3d('foot_line', p.plane.marker_foot_cam, p.plane.object_foot_cam, 0x9b6dff);
}
}
let lastPlan = null;
let busy = false;
let imgRefreshTimer = null;
// Image native dimensions (from /api/intrinsics or from the first load).
let imgW = 0, imgH = 0;
// Pan/zoom state: scale and translate applied to the inner pan-zoom wrapper.
const view = { scale: 1, tx: 0, ty: 0 };
let drag = null;
// Work plane selection state.
let planeInfo = null; // last response from /plane/fit or /plane GET
let roiMode = false; // true when user clicked "Seleziona area"
let roi = null; // {u0,v0,u1,v1} in image coords (committed)
let roiDrag = null; // in-progress drag
function setStatus(msg, cls) {
const el = $('servoStatus');
if (!el) return;
el.textContent = msg;
el.style.color = cls === 'err' ? 'var(--danger)' : cls === 'ok' ? 'var(--ok)' : 'var(--dim)';
}
function readParams() {
const side = $('svSide').value || null;
const cls = $('svClass').value.trim();
const conf = $('svConf').value.trim();
const body = {
min_distance_m: (+$('svMinDist').value) / 1000,
step_fraction: +$('svStepFrac').value,
};
if (cls) body.class_name = cls;
if (conf) body.conf = +conf;
if (side) body.side = side;
const rx = +$('svRx').value, ry = +$('svRy').value, rz = +$('svRz').value;
if (rx || ry || rz) {
body.wrist_marker_rvec = [rx * DEG, ry * DEG, rz * DEG];
}
return body;
}
function persistParams() {
try {
localStorage.setItem(LS_KEY, JSON.stringify({
cls: $('svClass').value, conf: $('svConf').value,
minDist: $('svMinDist').value, step: $('svStepFrac').value,
timeout: $('svTimeout').value, side: $('svSide').value,
dryRun: $('svDryRun').checked,
rx: $('svRx').value, ry: $('svRy').value, rz: $('svRz').value,
}));
} catch {}
}
function restoreParams() {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return;
const p = JSON.parse(raw);
const setV = (id, v) => { if ($(id) && v != null) $(id).value = v; };
setV('svClass', p.cls); setV('svConf', p.conf);
setV('svMinDist', p.minDist); setV('svStepFrac', p.step);
setV('svTimeout', p.timeout); setV('svSide', p.side);
setV('svRx', p.rx); setV('svRy', p.ry); setV('svRz', p.rz);
if ($('svDryRun') && p.dryRun != null) $('svDryRun').checked = !!p.dryRun;
} catch {}
}
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) throw new Error(`${r.status} ${(data && data.detail) || text}`);
return data;
}
async function saveRvecToConfig() {
const rx = +$('svRx').value, ry = +$('svRy').value, rz = +$('svRz').value;
const rvec = [rx * DEG, ry * DEG, rz * DEG];
try {
await api('PUT', '/api/config', {
grasp: { visual_servo: { wrist_marker_rvec: rvec } },
});
setStatus(`rvec salvato: [${rx}°, ${ry}°, ${rz}°]`, 'ok');
} catch (e) {
setStatus('salvataggio rvec fallito: ' + e.message, 'err');
}
}
function refreshImg() {
const img = $('servoImg');
if (!img) return;
// Cache-bust so we always see the current frame.
img.src = `/api/calibration/rgb.jpg?t=${Date.now()}`;
}
function resizeOverlay() {
const img = $('servoImg');
const cvs = $('servoOverlay');
if (!img || !cvs) return;
if (img.naturalWidth) { imgW = img.naturalWidth; imgH = img.naturalHeight; }
if (!imgW || !imgH) return;
// Match canvas pixel size to the native image size; CSS transform on the
// wrapper handles zoom/pan, keeping overlay and image perfectly aligned.
const dpr = window.devicePixelRatio || 1;
cvs.width = Math.round(imgW * dpr);
cvs.height = Math.round(imgH * dpr);
cvs.style.width = imgW + 'px';
cvs.style.height = imgH + 'px';
img.style.width = imgW + 'px';
img.style.height = imgH + 'px';
// Fit-to-viewport on first sizing.
if (view.scale === 1 && view.tx === 0 && view.ty === 0) fitView();
else applyTransform();
redrawOverlay();
}
function applyTransform() {
const wrap = $('servoPanZoom');
if (!wrap) return;
wrap.style.transform = `translate(${view.tx}px, ${view.ty}px) scale(${view.scale})`;
}
function fitView() {
const vp = $('servoViewport');
if (!vp || !imgW || !imgH) return;
const r = vp.getBoundingClientRect();
const s = Math.min(r.width / imgW, r.height / imgH);
view.scale = s;
view.tx = (r.width - imgW * s) / 2;
view.ty = (r.height - imgH * s) / 2;
applyTransform();
}
function zoomAt(clientX, clientY, factor) {
const vp = $('servoViewport');
const r = vp.getBoundingClientRect();
const px = clientX - r.left, py = clientY - r.top;
// Image coords under the cursor BEFORE scaling:
const ix = (px - view.tx) / view.scale;
const iy = (py - view.ty) / view.scale;
const newScale = Math.min(16, Math.max(0.1, view.scale * factor));
// Keep (ix, iy) under the cursor after the new scale.
view.tx = px - ix * newScale;
view.ty = py - iy * newScale;
view.scale = newScale;
applyTransform();
}
function clientToImage(clientX, clientY) {
const vp = $('servoViewport');
const r = vp.getBoundingClientRect();
const px = clientX - r.left, py = clientY - r.top;
return [(px - view.tx) / view.scale, (py - view.ty) / view.scale];
}
function wireViewport() {
const vp = $('servoViewport');
if (!vp) return;
vp.addEventListener('wheel', (e) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
zoomAt(e.clientX, e.clientY, factor);
}, { passive: false });
vp.addEventListener('pointerdown', (e) => {
// ROI selection takes priority when enabled, OR when user holds Shift.
if (roiMode || e.shiftKey) {
const [ix, iy] = clientToImage(e.clientX, e.clientY);
roiDrag = { u0: ix, v0: iy, u1: ix, v1: iy, id: e.pointerId };
vp.setPointerCapture(e.pointerId);
redrawOverlay();
return;
}
drag = { x: e.clientX, y: e.clientY, tx: view.tx, ty: view.ty };
vp.setPointerCapture(e.pointerId);
vp.style.cursor = 'grabbing';
});
vp.addEventListener('pointermove', (e) => {
if (roiDrag) {
const [ix, iy] = clientToImage(e.clientX, e.clientY);
roiDrag.u1 = ix; roiDrag.v1 = iy;
redrawOverlay();
return;
}
if (!drag) return;
view.tx = drag.tx + (e.clientX - drag.x);
view.ty = drag.ty + (e.clientY - drag.y);
applyTransform();
});
const endDrag = (e) => {
if (roiDrag) {
try { vp.releasePointerCapture(e.pointerId); } catch {}
const r = roiDrag;
const u0 = Math.max(0, Math.min(r.u0, r.u1));
const v0 = Math.max(0, Math.min(r.v0, r.v1));
const u1 = Math.min(imgW || r.u1, Math.max(r.u0, r.u1));
const v1 = Math.min(imgH || r.v1, Math.max(r.v0, r.v1));
if (u1 - u0 > 5 && v1 - v0 > 5) {
roi = { u0: u0|0, v0: v0|0, u1: u1|0, v1: v1|0 };
}
roiDrag = null;
roiMode = false;
vp.style.cursor = 'grab';
setStatus(roi ? `ROI ${roi.u1-roi.u0}x${roi.v1-roi.v0} px · premi "Calcola piano"` : 'ROI troppo piccola', roi ? 'ok' : 'err');
redrawOverlay();
return;
}
if (drag) { try { vp.releasePointerCapture(e.pointerId); } catch {} }
drag = null;
vp.style.cursor = 'grab';
};
vp.addEventListener('pointerup', endDrag);
vp.addEventListener('pointercancel', endDrag);
vp.addEventListener('dblclick', () => fitView());
}
function drawCross(ctx, x, y, color) {
// Sizes expressed in IMAGE pixels so they stay legible after CSS zoom.
const r = 18;
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x - r - 6, y); ctx.lineTo(x + r + 6, y);
ctx.moveTo(x, y - r - 6); ctx.lineTo(x, y + r + 6);
ctx.stroke();
}
function drawRect(ctx, u0, v0, u1, v1, color, label) {
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.strokeRect(u0, v0, u1 - u0, v1 - v0);
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.fillRect(u0, v0, u1 - u0, v1 - v0);
if (label) {
ctx.font = 'bold 16px ui-monospace, monospace';
const w = ctx.measureText(label).width + 10;
ctx.fillStyle = color;
ctx.fillRect(u0, v0, w, 20);
ctx.fillStyle = '#000';
ctx.fillText(label, u0 + 5, v0 + 15);
}
}
function redrawOverlay() {
const cvs = $('servoOverlay');
if (!cvs) return;
const ctx = cvs.getContext('2d');
const dpr = window.devicePixelRatio || 1;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, imgW || cvs.width, imgH || cvs.height);
if (!imgW || !imgH) return;
// Committed ROI
if (roi) drawRect(ctx, roi.u0, roi.v0, roi.u1, roi.v1, '#9b6dff', 'ROI piano');
// In-progress drag
if (roiDrag) {
const u0 = Math.min(roiDrag.u0, roiDrag.u1);
const v0 = Math.min(roiDrag.v0, roiDrag.v1);
const u1 = Math.max(roiDrag.u0, roiDrag.u1);
const v1 = Math.max(roiDrag.v0, roiDrag.v1);
drawRect(ctx, u0, v0, u1, v1, '#9b6dff', null);
}
if (!lastPlan) return;
const p = lastPlan.plan;
const mx = p.marker_pixel[0], my = p.marker_pixel[1];
const ox = p.object_pixel[0], oy = p.object_pixel[1];
ctx.strokeStyle = 'rgba(255,255,255,0.6)';
ctx.setLineDash([8, 6]);
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(mx, my); ctx.lineTo(ox, oy); ctx.stroke();
ctx.setLineDash([]);
drawCross(ctx, mx, my, '#4dd0e1');
drawCross(ctx, ox, oy, '#ffd54f');
}
async function planeSelectStart() {
roiMode = true;
setStatus('traccia un rettangolo sull\'immagine (shift+drag o dopo questo click, drag)', 'ok');
}
async function planeFit() {
if (!roi) { setStatus('seleziona prima un\'area', 'err'); return; }
try {
setStatus('fit piano…');
const body = {
u_min: roi.u0, v_min: roi.v0,
u_max: roi.u1, v_max: roi.v1,
};
const r = await api('POST', '/api/grasp/visual_servo/plane/fit', body);
planeInfo = r.plane;
updatePlaneInfo();
redraw3d();
setStatus(`piano OK · rms=${r.plane.fit_rms_mm.toFixed(1)} mm · ${r.plane.n_inliers} punti`, 'ok');
} catch (e) {
setStatus('fit fallito: ' + e.message, 'err');
}
}
async function planeClear() {
try {
await api('DELETE', '/api/grasp/visual_servo/plane');
planeInfo = null;
roi = null;
updatePlaneInfo();
redrawOverlay();
redraw3d();
setStatus('piano rimosso', 'ok');
} catch (e) {
setStatus('rimozione piano fallita: ' + e.message, 'err');
}
}
function updatePlaneInfo() {
const el = $('svPlaneInfo');
if (!el) return;
if (!planeInfo) { el.textContent = 'piano: —'; return; }
const n = planeInfo.normal.map(v => v.toFixed(3)).join(', ');
el.innerHTML = `piano: n=[${n}] · offset=${planeInfo.offset.toFixed(3)} m · rms=${planeInfo.fit_rms_mm.toFixed(1)} mm · ${planeInfo.n_inliers} pts`;
}
async function planeLoadCurrent() {
try {
const r = await api('GET', '/api/grasp/visual_servo/plane');
if (r.set) {
planeInfo = r.plane;
roi = { u0: r.plane.roi[0], v0: r.plane.roi[1], u1: r.plane.roi[2], v1: r.plane.roi[3] };
updatePlaneInfo();
redrawOverlay();
redraw3d();
}
} catch {}
}
function fmtMm(m) { return (m * 1000).toFixed(1) + ' mm'; }
function renderStats(resp) {
const p = resp.plan;
const cls = p.detection && p.detection.class;
const conf = p.detection && p.detection.confidence;
const reproj = p.marker_reprojection_error_px;
const doneBadge = p.done
? '<span style="color:var(--ok);font-weight:600">RAGGIUNTO</span>'
: '<span>in avvicinamento</span>';
$('servoStats').innerHTML = `
<table class="kvtable">
<tbody>
<tr><td>stato</td><td>${doneBadge}</td></tr>
<tr><td>marker · pixel</td><td>(${p.marker_pixel[0].toFixed(0)}, ${p.marker_pixel[1].toFixed(0)})</td></tr>
<tr><td>marker · Z / dist / scala</td><td>${fmtMm(p.marker_depth_z_m)} · ${fmtMm(p.marker_distance_cam_m)} · ${p.marker_mm_per_px ? p.marker_mm_per_px.toFixed(2) + ' mm/px' : '—'}</td></tr>
<tr><td>oggetto · pixel</td><td>(${p.object_pixel[0].toFixed(0)}, ${p.object_pixel[1].toFixed(0)})</td></tr>
<tr><td>oggetto · Z / dist / scala</td><td>${fmtMm(p.object_depth_z_m)} · ${fmtMm(p.object_distance_cam_m)} · ${p.object_mm_per_px ? p.object_mm_per_px.toFixed(2) + ' mm/px' : '—'}</td></tr>
<tr><td>marker ↔ oggetto (3D)</td><td><strong>${fmtMm(p.distance_m)}</strong></td></tr>
${p.plane ? `
<tr><td>piano · rms / punti</td><td>${p.plane.fit_rms_mm.toFixed(1)} mm · ${planeInfo ? planeInfo.n_inliers : '—'}</td></tr>
<tr><td>marker · altezza dal piano</td><td><strong>${(p.plane.marker_signed_distance_m >= 0 ? '+' : '−')}${fmtMm(Math.abs(p.plane.marker_signed_distance_m))}</strong> <span class="dim">(${p.plane.marker_signed_distance_m >= 0 ? 'sopra' : 'sotto'})</span></td></tr>
<tr><td>oggetto · altezza dal piano</td><td><strong>${(p.plane.object_signed_distance_m >= 0 ? '+' : '−')}${fmtMm(Math.abs(p.plane.object_signed_distance_m))}</strong> <span class="dim">(${p.plane.object_signed_distance_m >= 0 ? 'sopra' : 'sotto'})</span></td></tr>
<tr><td>Δ altezza marker−oggetto</td><td>${fmtMm(Math.abs(p.plane.marker_signed_distance_m - p.plane.object_signed_distance_m))}</td></tr>
<tr><td>in-piano (perp-foot)</td><td><strong>${fmtMm(p.plane.in_plane_distance_m)}</strong></td></tr>
<tr><td>in-piano (pixel-hit)</td><td>${fmtMm(p.plane.in_plane_pixel_distance_m)}</td></tr>
` : ''}
<tr><td>prossimo step (base)</td><td>${p.target_position_base ? fmtMm(p.planned_step_m) : '—'}</td></tr>
<tr><td>limite stop</td><td>${fmtMm(resp.min_distance_m)}</td></tr>
<tr><td>frazione step</td><td>${resp.step_fraction}</td></tr>
<tr><td>lato</td><td>${resp.side}</td></tr>
<tr><td>oggetto · classe</td><td>${cls || '—'} ${conf != null ? `(${(conf*100).toFixed(0)}%)` : ''}</td></tr>
<tr><td>punti bbox</td><td>${p.n_object_points}</td></tr>
<tr><td>reproj err marker</td><td>${reproj != null ? reproj.toFixed(2) + ' px' : '—'}</td></tr>
<tr><td>sorgente posizione marker</td><td>${p.marker_translation_source || '—'}</td></tr>
<tr><td>sorgente punto oggetto</td><td>${p.object_point_source || '—'}</td></tr>
<tr><td>note</td><td>${p.reason || '—'}</td></tr>
</tbody>
</table>`;
$('servoResult').textContent = JSON.stringify({
wrist_position_base: p.wrist_position_base,
marker_position_cam: p.marker_position_cam,
object_position_cam: p.object_position_cam,
target_position_base: p.target_position_base,
distance_m: p.distance_m,
}, null, 2);
}
async function doPlan() {
if (busy) return;
busy = true;
persistParams();
try {
setStatus('rilevamento…');
refreshImg();
const body = readParams();
const resp = await api('POST', '/api/grasp/visual_servo/plan', body);
lastPlan = resp;
renderStats(resp);
redrawOverlay();
redraw3d();
setStatus(resp.plan.done ? 'target raggiunto' : 'piano pronto · premi "Vai al prossimo"', 'ok');
} catch (e) {
setStatus('errore plan: ' + e.message, 'err');
} finally { busy = false; }
}
async function doStep() {
if (busy) return;
busy = true;
persistParams();
const dryRun = $('svDryRun').checked;
try {
setStatus(dryRun ? 'step (dry-run)…' : 'movimento in corso…');
refreshImg();
const body = readParams();
body.dry_run = dryRun;
body.timeout_s = +$('svTimeout').value;
const resp = await api('POST', '/api/grasp/visual_servo/step', body);
lastPlan = resp;
renderStats(resp);
redrawOverlay();
if (!resp.moved) {
setStatus('target raggiunto, nessun moto', 'ok');
return;
}
setStatus('ricalcolo post-moto…');
refreshImg();
const replan = await api('POST', '/api/grasp/visual_servo/plan', readParams());
lastPlan = replan;
renderStats(replan);
redrawOverlay();
redraw3d();
setStatus(replan.plan.done ? 'target raggiunto' : 'pronto per il prossimo step', 'ok');
} catch (e) {
setStatus('errore step: ' + e.message, 'err');
} finally { busy = false; }
}
function doReset() {
lastPlan = null;
const ctx = $('servoOverlay').getContext('2d');
ctx.clearRect(0, 0, $('servoOverlay').width, $('servoOverlay').height);
$('servoStats').innerHTML = '<div class="dim">nessun piano calcolato</div>';
$('servoResult').textContent = '—';
setStatus('reset');
}
function startLiveImg() {
stopLiveImg();
imgRefreshTimer = setInterval(refreshImg, 500);
refreshImg();
}
function stopLiveImg() {
if (imgRefreshTimer) { clearInterval(imgRefreshTimer); imgRefreshTimer = null; }
}
document.addEventListener('DOMContentLoaded', () => {
if (!$('servoImg')) return;
restoreParams();
['svClass','svConf','svMinDist','svStepFrac','svTimeout','svSide','svDryRun',
'svRx','svRy','svRz']
.forEach(id => { const el = $(id); if (el) el.addEventListener('change', persistParams); });
$('svPlanBtn').addEventListener('click', doPlan);
$('svStepBtn').addEventListener('click', doStep);
$('svResetBtn').addEventListener('click', doReset);
$('svSaveRvecBtn').addEventListener('click', saveRvecToConfig);
$('svPlaneSelBtn').addEventListener('click', planeSelectStart);
$('svPlaneFitBtn').addEventListener('click', planeFit);
$('svPlaneClearBtn').addEventListener('click', planeClear);
boot3d();
planeLoadCurrent();
const img = $('servoImg');
img.addEventListener('load', () => {
imgW = img.naturalWidth; imgH = img.naturalHeight;
resizeOverlay();
});
wireViewport();
window.addEventListener('resize', () => { resizeOverlay(); });
});
function resize3d() {
const canvas = $('servo3dCanvas');
if (!canvas || !renderer3d || !cam3d) return;
const rect = canvas.getBoundingClientRect();
const w = Math.max(100, Math.round(rect.width));
const h = Math.max(60, Math.round(rect.height));
renderer3d.setSize(w, h, false);
cam3d.aspect = w / h;
cam3d.updateProjectionMatrix();
}
// Only stream the RGB while the servo tab is active (avoid hammering Nuwa).
document.querySelectorAll('.tab').forEach(btn => btn.addEventListener('click', () => {
const active = btn.dataset.tab === 'servo';
if (active) {
startLiveImg();
// The 3D canvas may have rendered at 0x0 while the panel was display:none.
// Re-boot (no-op if already ready) and resize once visible.
boot3d();
setTimeout(() => { resizeOverlay(); resize3d(); redraw3d(); }, 100);
} else { stopLiveImg(); }
}));
window.addEventListener('resize', () => { resize3d(); });
+745
View File
@@ -0,0 +1,745 @@
:root {
--bg: #111418;
--panel: #191d23;
--border: #2a2f37;
--fg: #e6e6e6;
--dim: #9aa0a6;
--accent: #4da3ff;
--ok: #3ecf8e;
--warn: #f0ad4e;
--danger: #e74c3c;
}
* { box-sizing: border-box; }
body {
margin: 0;
font: 14px/1.4 -apple-system, "Segoe UI", system-ui, sans-serif;
background: var(--bg);
color: var(--fg);
}
header {
padding: 12px 20px 0 20px;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
header h1 {
margin: 0 0 10px 0;
font-size: 18px;
letter-spacing: 0.5px;
}
.tabs { display: flex; gap: 4px; }
.tab {
background: none;
border: 1px solid transparent;
border-bottom: 0;
color: var(--dim);
padding: 8px 14px;
cursor: pointer;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
font-weight: 600;
}
.tab.active {
color: var(--fg);
background: var(--bg);
border-color: var(--border);
}
.status-strip { min-height: 22px; padding: 4px 0 8px 0; color: var(--dim); font-size: 12px; }
main { padding: 20px; }
.panel { display: none; }
.panel.active { display: block; }
h2 { font-size: 15px; margin: 0 0 10px 0; }
h3 { font-size: 13px; margin: 16px 0 6px 0; color: var(--dim); }
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 1100px) {
.grid-2 { grid-template-columns: 1fr; }
}
.canvas-wrap {
background: #000;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
display: flex;
justify-content: center;
}
canvas { display: block; max-width: 100%; height: auto; }
.kv {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 8px;
margin: 8px 0 10px 0;
}
.kv label {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--dim);
font-size: 12px;
}
.kv label.inline {
flex-direction: row;
align-items: center;
gap: 6px;
color: var(--fg);
}
.kv input, .kv select {
background: var(--bg);
border: 1px solid var(--border);
color: var(--fg);
padding: 6px;
border-radius: 4px;
}
button {
background: var(--border);
border: 1px solid var(--border);
color: var(--fg);
padding: 7px 14px;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
}
button:hover { background: #343a43; }
button.primary { background: var(--accent); border-color: var(--accent); color: #000; }
button.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
.marker-info { margin-top: 6px; font-size: 12px; color: var(--dim); }
.samples {
border: 1px solid var(--border);
background: var(--panel);
border-radius: 4px;
padding: 10px;
max-height: 260px;
overflow: auto;
}
.samples-head { display: flex; justify-content: space-between; margin-bottom: 6px; }
.samples ul { list-style: none; padding: 0; margin: 0; font-size: 12px; }
.samples li {
padding: 4px 0;
border-top: 1px solid var(--border);
font-family: ui-monospace, monospace;
color: var(--dim);
}
.samples li.sample-dropped {
color: var(--danger);
background: rgba(231, 76, 60, 0.08);
}
.result { margin-top: 16px; }
pre.log, #calibMatrix, #graspOut {
background: #0d1014;
border: 1px solid var(--border);
padding: 10px;
border-radius: 4px;
font-size: 12px;
overflow: auto;
max-height: 380px;
white-space: pre;
color: var(--fg);
}
details.auto-grid {
margin-top: 20px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
}
details.auto-grid summary { cursor: pointer; font-weight: 600; }
.canvas-wrap.small { max-width: 340px; }
.measure-strip {
margin-top: 6px;
font-size: 12px;
font-family: ui-monospace, monospace;
color: var(--dim);
}
.scatter-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.scatter-row canvas {
background: #0d1014;
border: 1px solid var(--border);
border-radius: 4px;
}
.scatter-title { font-size: 12px; color: var(--dim); margin-bottom: 4px; }
#graspCanvas { cursor: crosshair; }
table.stats {
width: 100%;
border-collapse: collapse;
font-size: 12px;
font-family: ui-monospace, monospace;
margin: 6px 0 4px 0;
}
table.stats th, table.stats td {
border: 1px solid var(--border);
padding: 4px 8px;
text-align: right;
}
table.stats th { background: var(--panel); color: var(--dim); font-weight: 600; }
table.stats td:first-child, table.stats th:first-child { text-align: center; color: var(--dim); }
button.running { background: var(--danger); border-color: var(--danger); color: #fff; }
.legend {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 12px;
font-size: 12px;
color: var(--dim);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
padding: 8px 10px;
margin-top: 8px;
}
.legend-row { display: flex; align-items: center; gap: 6px; }
.legend .dot {
display: inline-block;
width: 10px; height: 10px;
border-radius: 50%;
}
#view3dCanvas { cursor: grab; background: #0d1014; }
#view3dCanvas:active { cursor: grabbing; }
.grasp-legend {
display: flex; flex-wrap: wrap; gap: 10px 14px;
margin-top: 6px; font-size: 12px; color: var(--dim);
}
.grasp-legend .item { display: inline-flex; align-items: center; gap: 6px; }
.grasp-legend .sw {
display: inline-block; width: 12px; height: 12px;
border-radius: 2px; border: 1px solid rgba(255,255,255,.15);
}
.grasp-legend .item.selected { color: var(--fg); font-weight: 600; }
.grasp-legend .item.missing { opacity: 0.4; }
/* ---- Posture & Stati ---- */
.posture-toolbar {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 16px;
}
.post-health {
margin-left: auto;
font-family: ui-monospace, monospace;
font-size: 12px;
color: var(--dim);
}
.pose-table {
width: 100%;
border-collapse: collapse;
font: 12px ui-monospace, monospace;
margin-bottom: 4px;
}
.pose-table th {
text-align: left;
color: var(--dim);
font-weight: 600;
padding: 4px 8px;
border: 1px solid var(--border);
background: var(--panel);
width: 90px;
}
.pose-table td {
padding: 4px 8px;
border: 1px solid var(--border);
text-align: right;
background: #0d1014;
}
.link-btn {
background: none;
border: 0;
color: var(--accent);
cursor: pointer;
font-size: 12px;
padding: 2px 0;
margin: 0 0 8px 0;
}
.finger-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 12px;
margin-bottom: 8px;
}
.finger-row {
display: grid;
grid-template-columns: 100px 1fr 40px;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--dim);
}
.finger-row input[type="range"] { width: 100%; }
.finger-row .v {
font-family: ui-monospace, monospace;
text-align: right;
color: var(--fg);
}
.ctrl-block {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 14px;
}
.kv.compact {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 6px;
}
.info-box {
background: #1e2530;
border: 1px solid var(--border);
border-left: 3px solid var(--warn);
border-radius: 4px;
padding: 8px 10px;
font-size: 12px;
color: var(--dim);
margin-bottom: 8px;
}
.info-box code {
background: #0d1014;
padding: 1px 4px;
border-radius: 3px;
color: var(--fg);
}
pre.log.small { max-height: 180px; font-size: 11px; }
.preset-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
margin-top: 10px;
}
.preset-table th, .preset-table td {
border: 1px solid var(--border);
padding: 6px 8px;
text-align: left;
}
.preset-table th { background: var(--panel); color: var(--dim); font-weight: 600; }
.preset-table td.actions-cell { text-align: right; white-space: nowrap; }
.preset-table td.dim { color: var(--dim); text-align: center; }
.preset-table .pose-cell {
font-family: ui-monospace, monospace;
font-size: 11px;
color: var(--dim);
}
/* ---- Accensione e Stati ---- */
.power-state-table {
width: 100%;
border-collapse: collapse;
font: 12px ui-monospace, monospace;
margin-bottom: 14px;
}
.power-state-table th {
text-align: left;
color: var(--dim);
font-weight: 600;
padding: 6px 10px;
border: 1px solid var(--border);
background: var(--panel);
width: 110px;
}
.power-state-table td {
padding: 6px 10px;
border: 1px solid var(--border);
background: #0d1014;
}
.badge {
display: inline-block;
background: var(--border);
color: var(--fg);
padding: 2px 8px;
border-radius: 10px;
font-weight: 700;
margin-right: 8px;
}
.badge.ok { background: var(--ok); color: #000; }
.badge.warn { background: var(--warn); color: #000; }
.badge.danger { background: var(--danger); color: #fff; }
.state-desc { color: var(--dim); font-size: 11px; }
.wiz-step { position: relative; }
.wiz-step.done { border-left: 3px solid var(--ok); }
.wiz-step.current { border-left: 3px solid var(--accent); }
.wiz-step.blocked { opacity: 0.55; }
.wiz-hint { font-size: 11px; color: var(--dim); margin-left: 8px; }
.wiz-hint.warn { color: var(--warn); }
.cmd-desc { font-size: 12px; color: var(--dim); margin-bottom: 8px; }
.cmd-desc.warn { color: var(--warn); }
.cmd-desc code, .info-box code {
background: #0d1014;
padding: 1px 5px;
border-radius: 3px;
color: var(--fg);
}
.cmd-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 6px 0;
border-top: 1px solid var(--border);
}
.cmd-row:first-child { border-top: 0; }
.cmd-row > div:first-child { flex: 1; min-width: 0; }
.cmd-row strong { display: block; margin-bottom: 2px; }
.cmd-row-right { display: flex; gap: 6px; align-items: center; }
.cmd-row-right input {
background: var(--bg);
border: 1px solid var(--border);
color: var(--fg);
padding: 5px 6px;
border-radius: 4px;
}
.fsm-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 8px;
margin-bottom: 14px;
}
.fsm-cell {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
}
.fsm-cell .fsm-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
}
.fsm-cell .fsm-head strong { font-size: 13px; }
.fsm-cell .fsm-id { font-family: ui-monospace, monospace; color: var(--dim); font-size: 11px; }
.fsm-cell .cmd-desc { margin: 4px 0 6px 0; }
.fsm-cell button { width: 100%; }
.fsm-cell.current { border-color: var(--ok); }
.fsm-cell.current .fsm-id::before { content: "● "; color: var(--ok); }
.gated.disabled-group {
opacity: 0.45;
pointer-events: none;
position: relative;
}
.gated.disabled-group::after {
content: "🔒 Disponibile solo con FSM = start";
position: absolute;
top: 4px; right: 8px;
font-size: 11px;
color: var(--warn);
pointer-events: none;
}
pre.log.err {
border-color: var(--danger);
color: var(--danger);
}
/* Diagnostic 3D tab */
.diag3d-toolbar {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 16px;
}
#diag3dStatus { margin-left: auto; font: 12px ui-monospace, monospace; color: var(--dim); }
.diag3d-layout .canvas-wrap { position: relative; }
#diag3dCanvas { cursor: grab; background: #0d1014; width: 100%; height: auto; }
#diag3dCanvas:active { cursor: grabbing; }
.diag3d-tooltip {
position: absolute;
background: rgba(13,16,20,.95);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--fg);
padding: 6px 8px;
font: 11px ui-monospace, monospace;
pointer-events: none;
z-index: 10;
min-width: 160px;
max-width: 260px;
}
.diag3d-tooltip.hidden { display: none; }
.diag3d-tooltip .tt-title { font-weight: 700; margin-bottom: 4px; color: var(--accent); }
.diag3d-tooltip .tt-row { display: flex; justify-content: space-between; gap: 10px; }
.diag3d-tooltip .tt-row .k { color: var(--dim); }
.diag3d-tooltip.warn .tt-title { color: var(--warn); }
.diag3d-tooltip.err .tt-title { color: var(--danger); }
.diag3d-legend {
display: flex;
flex-wrap: wrap;
gap: 10px 18px;
margin-top: 10px;
font-size: 12px;
color: var(--dim);
}
.diag3d-legend .legend-sw {
display: inline-block;
width: 14px; height: 14px;
border-radius: 3px;
margin-right: 6px;
vertical-align: middle;
}
#diag3dPinned table, #diag3dSummary table {
width: 100%;
border-collapse: collapse;
font: 12px ui-monospace, monospace;
}
#diag3dPinned td, #diag3dSummary td {
padding: 3px 8px;
border-top: 1px solid var(--border);
}
#diag3dPinned td:first-child, #diag3dSummary td:first-child { color: var(--dim); width: 130px; }
#diag3dPinned tr.warn td { color: var(--warn); }
#diag3dPinned tr.err td { color: var(--danger); }
.autocal-chart {
width: 100%;
background: #0d1014;
border: 1px solid var(--border);
border-radius: 4px;
display: block;
margin-bottom: 10px;
}
/* Button feedback flash */
button.flash-ok {
animation: flashOk 0.8s ease-out;
}
button.flash-err {
animation: flashErr 1.2s ease-out;
}
@keyframes flashOk {
0% { background: var(--ok); color: #000; box-shadow: 0 0 0 3px rgba(62,207,142,.6); }
100% {}
}
@keyframes flashErr {
0%, 30% { background: var(--danger); color: #fff; box-shadow: 0 0 0 3px rgba(231,76,60,.6); }
100% {}
}
/* Global alert banner (visible across all tabs) */
.global-alert {
display: block;
width: 100%;
text-align: left;
background: var(--panel);
color: var(--dim);
border: 0;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
padding: 6px 20px;
font: 600 12px ui-monospace, monospace;
cursor: pointer;
margin: 0 -20px;
}
.global-alert.hidden { display: none; }
.global-alert.warn { background: rgba(240,173,78,.18); color: var(--warn); border-color: var(--warn); }
.global-alert.err { background: rgba(231,76,60,.22); color: var(--danger); border-color: var(--danger); animation: alertPulse 1.4s infinite; }
@keyframes alertPulse { 50% { background: rgba(231,76,60,.40); } }
/* Diagnostica panel on Accensione tab */
.diag-banner {
background: var(--panel);
border: 1px solid var(--border);
border-left: 4px solid var(--dim);
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
margin-bottom: 10px;
}
.diag-banner.hidden { display: none; }
.diag-banner.warn { border-left-color: var(--warn); color: var(--warn); }
.diag-banner.err { border-left-color: var(--danger); color: var(--danger); }
.diag-banner.ok { border-left-color: var(--ok); color: var(--ok); }
.diag-table {
display: grid;
grid-template-columns: 28px 170px 1fr auto;
gap: 4px 10px;
align-items: center;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-size: 12px;
margin-bottom: 14px;
}
.diag-row {
display: contents;
}
.diag-row > * {
padding: 3px 0;
border-top: 1px solid var(--border);
}
.diag-row:first-child > * { border-top: 0; }
.diag-pill {
display: inline-block;
width: 18px; height: 18px;
border-radius: 50%;
text-align: center;
line-height: 18px;
font-size: 11px;
background: var(--border);
color: var(--dim);
font-weight: 700;
}
.diag-row.ok .diag-pill { background: var(--ok); color: #000; }
.diag-row.warn .diag-pill { background: var(--warn); color: #000; }
.diag-row.err .diag-pill { background: var(--danger); color: #fff; }
.diag-row.na .diag-pill { background: transparent; color: var(--dim); border: 1px dashed var(--border); }
.diag-label { color: var(--fg); font-weight: 600; }
.diag-value { font-family: ui-monospace, monospace; color: var(--dim); overflow: hidden; text-overflow: ellipsis; }
.diag-row.ok .diag-value { color: var(--ok); }
.diag-row.warn .diag-value { color: var(--warn); }
.diag-row.err .diag-value { color: var(--danger); }
.diag-hint { color: var(--dim); font-size: 11px; text-align: right; }
/* Per-joint temperature grid */
details.joint-temps {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 14px;
}
details.joint-temps summary { cursor: pointer; font-weight: 600; font-size: 12px; color: var(--fg); }
.joint-temps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 4px 14px;
margin-top: 10px;
font: 11px ui-monospace, monospace;
}
.joint-row {
display: grid;
grid-template-columns: 28px 1fr 44px 90px;
align-items: center;
gap: 6px;
padding: 2px 0;
}
.joint-row .j-idx { color: var(--dim); text-align: right; }
.joint-row .j-name { color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.joint-row .j-temp { color: var(--dim); text-align: right; }
.joint-row .j-bar {
background: #0d1014;
border: 1px solid var(--border);
border-radius: 3px;
height: 12px;
position: relative;
overflow: hidden;
}
.joint-row .j-fill {
position: absolute; top: 0; left: 0; bottom: 0;
background: var(--ok);
transition: width .2s ease, background .2s ease;
}
.joint-row .j-mark {
position: absolute; top: -1px; bottom: -1px;
width: 2px;
margin-left: -1px;
background: var(--fg);
opacity: 0.85;
pointer-events: none;
transition: left .2s ease;
}
.joint-row.warn .j-fill { background: var(--warn); }
.joint-row.err .j-fill { background: var(--danger); }
.joint-row.warn .j-temp { color: var(--warn); }
.joint-row.err .j-temp { color: var(--danger); }
.joint-row.faulted { outline: 1px solid var(--danger); border-radius: 3px; }
.joint-row.disabled .j-name { color: var(--dim); }
/* Toast shown globally on each command outcome */
.toast-host {
position: fixed;
right: 16px;
bottom: 16px;
display: flex;
flex-direction: column;
gap: 6px;
z-index: 10000;
pointer-events: none;
}
.toast {
background: var(--panel);
color: var(--fg);
border: 1px solid var(--border);
border-left: 4px solid var(--dim);
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
min-width: 260px;
max-width: 520px;
box-shadow: 0 4px 14px rgba(0,0,0,.4);
animation: toastIn 0.2s ease-out;
}
.toast.ok { border-left-color: var(--ok); }
.toast.err { border-left-color: var(--danger); color: var(--danger); }
.toast.fade { transition: opacity 0.4s ease-in; opacity: 0; }
.toast .t-title { font-weight: 700; margin-bottom: 2px; }
.toast .t-body { font-family: ui-monospace, monospace; color: var(--dim); white-space: pre-wrap; }
@keyframes toastIn { from { transform: translateY(8px); opacity: 0; } to { transform: none; opacity: 1; } }
/* ---------- Home tab ---------- */
.device-status-table { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: 13px; }
.device-status-table th, .device-status-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: middle; }
.device-status-table th { font-weight: 600; color: var(--dim); background: rgba(255,255,255,0.02); }
.device-status-table code { font-size: 12px; color: var(--dim); }
.device-pill { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.device-pill.ok { background: rgba(62,207,142,0.15); color: var(--ok); border: 1px solid var(--ok); }
.device-pill.err { background: rgba(231,76,60,0.15); color: var(--danger); border: 1px solid var(--danger); }
.active-robot-pill { font-size: 12px; padding: 4px 10px; border-radius: 999px; background: rgba(77,163,255,0.15); color: var(--accent); border: 1px solid var(--accent); margin-left: 12px; }
/* ---------- Assistente AI tab ---------- */
.agent-layout > div { display: flex; flex-direction: column; }
.agent-chat {
border: 1px solid var(--border);
border-radius: 4px;
padding: 10px;
height: 420px;
overflow-y: auto;
background: rgba(0,0,0,0.15);
display: flex;
flex-direction: column;
gap: 8px;
}
.agent-msg { padding: 8px 12px; border-radius: 6px; max-width: 90%; line-height: 1.4; font-size: 13px; white-space: pre-wrap; word-wrap: break-word; }
.agent-msg.user { align-self: flex-end; background: rgba(77,163,255,0.15); border: 1px solid var(--accent); }
.agent-msg.assistant { align-self: flex-start; background: rgba(255,255,255,0.04); border: 1px solid var(--border); }
.agent-msg.system { align-self: stretch; background: rgba(240,173,78,0.08); border: 1px dashed var(--warn); color: var(--dim); font-style: italic; }
.agent-msg.error { align-self: stretch; background: rgba(231,76,60,0.10); border: 1px solid var(--danger); color: var(--danger); }
.agent-msg .meta { font-size: 10px; color: var(--dim); margin-top: 4px; }
.agent-tool-pill { display: inline-block; padding: 1px 8px; margin: 2px 2px 0 0; font-size: 11px; border-radius: 999px; background: rgba(62,207,142,0.12); border: 1px solid var(--ok); color: var(--ok); }
.agent-tool-pill.err { background: rgba(231,76,60,0.12); border-color: var(--danger); color: var(--danger); }
.agent-input-row { display: flex; gap: 8px; align-items: stretch; margin-top: 8px; }
.agent-input-row textarea { flex: 1; resize: vertical; font-family: ui-monospace, monospace; font-size: 13px; padding: 8px; background: #0e1116; color: inherit; border: 1px solid var(--border); border-radius: 4px; }
.agent-input-row button { white-space: nowrap; }
.agent-task-list { list-style: none; padding: 0; margin: 8px 0; max-height: 260px; overflow-y: auto; }
.agent-task-list li { display: flex; gap: 6px; align-items: center; padding: 6px 8px; border-bottom: 1px solid var(--border); font-size: 12px; }
.agent-task-list li:hover { background: rgba(255,255,255,0.03); }
.agent-task-list .task-name { font-weight: 600; flex: 0 0 auto; }
.agent-task-list .task-desc { color: var(--dim); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-task-list button.link-btn { font-size: 11px; padding: 2px 6px; }
File diff suppressed because it is too large. Load diff
+410
View File
@@ -0,0 +1,410 @@
import {
BufferAttribute,
BufferGeometry,
Color,
FileLoader,
Float32BufferAttribute,
Loader,
Vector3
} from 'three';
/**
* Description: A THREE loader for STL ASCII files, as created by Solidworks and other CAD programs.
*
* Supports both binary and ASCII encoded files, with automatic detection of type.
*
* The loader returns a non-indexed buffer geometry.
*
* Limitations:
* Binary decoding supports "Magics" color format (http://en.wikipedia.org/wiki/STL_(file_format)#Color_in_binary_STL).
* There is perhaps some question as to how valid it is to always assume little-endian-ness.
* ASCII decoding assumes file is UTF-8.
*
* Usage:
* const loader = new STLLoader();
* loader.load( './models/stl/slotted_disk.stl', function ( geometry ) {
* scene.add( new THREE.Mesh( geometry ) );
* });
*
* For binary STLs geometry might contain colors for vertices. To use it:
* // use the same code to load STL as above
* if (geometry.hasColors) {
* material = new THREE.MeshPhongMaterial({ opacity: geometry.alpha, vertexColors: true });
* } else { .... }
* const mesh = new THREE.Mesh( geometry, material );
*
* For ASCII STLs containing multiple solids, each solid is assigned to a different group.
* Groups can be used to assign a different color by defining an array of materials with the same length of
* geometry.groups and passing it to the Mesh constructor:
*
* const mesh = new THREE.Mesh( geometry, material );
*
* For example:
*
* const materials = [];
* const nGeometryGroups = geometry.groups.length;
*
* const colorMap = ...; // Some logic to index colors.
*
* for (let i = 0; i < nGeometryGroups; i++) {
*
* const material = new THREE.MeshPhongMaterial({
* color: colorMap[i],
* wireframe: false
* });
*
* }
*
* materials.push(material);
* const mesh = new THREE.Mesh(geometry, materials);
*/
class STLLoader extends Loader {
constructor( manager ) {
super( manager );
}
load( url, onLoad, onProgress, onError ) {
const scope = this;
const loader = new FileLoader( this.manager );
loader.setPath( this.path );
loader.setResponseType( 'arraybuffer' );
loader.setRequestHeader( this.requestHeader );
loader.setWithCredentials( this.withCredentials );
loader.load( url, function ( text ) {
try {
onLoad( scope.parse( text ) );
} catch ( e ) {
if ( onError ) {
onError( e );
} else {
console.error( e );
}
scope.manager.itemError( url );
}
}, onProgress, onError );
}
parse( data ) {
function isBinary( data ) {
const reader = new DataView( data );
const face_size = ( 32 / 8 * 3 ) + ( ( 32 / 8 * 3 ) * 3 ) + ( 16 / 8 );
const n_faces = reader.getUint32( 80, true );
const expect = 80 + ( 32 / 8 ) + ( n_faces * face_size );
if ( expect === reader.byteLength ) {
return true;
}
// An ASCII STL data must begin with 'solid ' as the first six bytes.
// However, ASCII STLs lacking the SPACE after the 'd' are known to be
// plentiful. So, check the first 5 bytes for 'solid'.
// Several encodings, such as UTF-8, precede the text with up to 5 bytes:
// https://en.wikipedia.org/wiki/Byte_order_mark#Byte_order_marks_by_encoding
// Search for "solid" to start anywhere after those prefixes.
// US-ASCII ordinal values for 's', 'o', 'l', 'i', 'd'
const solid = [ 115, 111, 108, 105, 100 ];
for ( let off = 0; off < 5; off ++ ) {
// If "solid" text is matched to the current offset, declare it to be an ASCII STL.
if ( matchDataViewAt( solid, reader, off ) ) return false;
}
// Couldn't find "solid" text at the beginning; it is binary STL.
return true;
}
function matchDataViewAt( query, reader, offset ) {
// Check if each byte in query matches the corresponding byte from the current offset
for ( let i = 0, il = query.length; i < il; i ++ ) {
if ( query[ i ] !== reader.getUint8( offset + i ) ) return false;
}
return true;
}
function parseBinary( data ) {
const reader = new DataView( data );
const faces = reader.getUint32( 80, true );
let r, g, b, hasColors = false, colors;
let defaultR, defaultG, defaultB, alpha;
// process STL header
// check for default color in header ("COLOR=rgba" sequence).
for ( let index = 0; index < 80 - 10; index ++ ) {
if ( ( reader.getUint32( index, false ) == 0x434F4C4F /*COLO*/ ) &&
( reader.getUint8( index + 4 ) == 0x52 /*'R'*/ ) &&
( reader.getUint8( index + 5 ) == 0x3D /*'='*/ ) ) {
hasColors = true;
colors = new Float32Array( faces * 3 * 3 );
defaultR = reader.getUint8( index + 6 ) / 255;
defaultG = reader.getUint8( index + 7 ) / 255;
defaultB = reader.getUint8( index + 8 ) / 255;
alpha = reader.getUint8( index + 9 ) / 255;
}
}
const dataOffset = 84;
const faceLength = 12 * 4 + 2;
const geometry = new BufferGeometry();
const vertices = new Float32Array( faces * 3 * 3 );
const normals = new Float32Array( faces * 3 * 3 );
const color = new Color();
for ( let face = 0; face < faces; face ++ ) {
const start = dataOffset + face * faceLength;
const normalX = reader.getFloat32( start, true );
const normalY = reader.getFloat32( start + 4, true );
const normalZ = reader.getFloat32( start + 8, true );
if ( hasColors ) {
const packedColor = reader.getUint16( start + 48, true );
if ( ( packedColor & 0x8000 ) === 0 ) {
// facet has its own unique color
r = ( packedColor & 0x1F ) / 31;
g = ( ( packedColor >> 5 ) & 0x1F ) / 31;
b = ( ( packedColor >> 10 ) & 0x1F ) / 31;
} else {
r = defaultR;
g = defaultG;
b = defaultB;
}
}
for ( let i = 1; i <= 3; i ++ ) {
const vertexstart = start + i * 12;
const componentIdx = ( face * 3 * 3 ) + ( ( i - 1 ) * 3 );
vertices[ componentIdx ] = reader.getFloat32( vertexstart, true );
vertices[ componentIdx + 1 ] = reader.getFloat32( vertexstart + 4, true );
vertices[ componentIdx + 2 ] = reader.getFloat32( vertexstart + 8, true );
normals[ componentIdx ] = normalX;
normals[ componentIdx + 1 ] = normalY;
normals[ componentIdx + 2 ] = normalZ;
if ( hasColors ) {
color.set( r, g, b ).convertSRGBToLinear();
colors[ componentIdx ] = color.r;
colors[ componentIdx + 1 ] = color.g;
colors[ componentIdx + 2 ] = color.b;
}
}
}
geometry.setAttribute( 'position', new BufferAttribute( vertices, 3 ) );
geometry.setAttribute( 'normal', new BufferAttribute( normals, 3 ) );
if ( hasColors ) {
geometry.setAttribute( 'color', new BufferAttribute( colors, 3 ) );
geometry.hasColors = true;
geometry.alpha = alpha;
}
return geometry;
}
function parseASCII( data ) {
const geometry = new BufferGeometry();
const patternSolid = /solid([\s\S]*?)endsolid/g;
const patternFace = /facet([\s\S]*?)endfacet/g;
const patternName = /solid\s(.+)/;
let faceCounter = 0;
const patternFloat = /[\s]+([+-]?(?:\d*)(?:\.\d*)?(?:[eE][+-]?\d+)?)/.source;
const patternVertex = new RegExp( 'vertex' + patternFloat + patternFloat + patternFloat, 'g' );
const patternNormal = new RegExp( 'normal' + patternFloat + patternFloat + patternFloat, 'g' );
const vertices = [];
const normals = [];
const groupNames = [];
const normal = new Vector3();
let result;
let groupCount = 0;
let startVertex = 0;
let endVertex = 0;
while ( ( result = patternSolid.exec( data ) ) !== null ) {
startVertex = endVertex;
const solid = result[ 0 ];
const name = ( result = patternName.exec( solid ) ) !== null ? result[ 1 ] : '';
groupNames.push( name );
while ( ( result = patternFace.exec( solid ) ) !== null ) {
let vertexCountPerFace = 0;
let normalCountPerFace = 0;
const text = result[ 0 ];
while ( ( result = patternNormal.exec( text ) ) !== null ) {
normal.x = parseFloat( result[ 1 ] );
normal.y = parseFloat( result[ 2 ] );
normal.z = parseFloat( result[ 3 ] );
normalCountPerFace ++;
}
while ( ( result = patternVertex.exec( text ) ) !== null ) {
vertices.push( parseFloat( result[ 1 ] ), parseFloat( result[ 2 ] ), parseFloat( result[ 3 ] ) );
normals.push( normal.x, normal.y, normal.z );
vertexCountPerFace ++;
endVertex ++;
}
// every face have to own ONE valid normal
if ( normalCountPerFace !== 1 ) {
console.error( 'THREE.STLLoader: Something isn\'t right with the normal of face number ' + faceCounter );
}
// each face have to own THREE valid vertices
if ( vertexCountPerFace !== 3 ) {
console.error( 'THREE.STLLoader: Something isn\'t right with the vertices of face number ' + faceCounter );
}
faceCounter ++;
}
const start = startVertex;
const count = endVertex - startVertex;
geometry.userData.groupNames = groupNames;
geometry.addGroup( start, count, groupCount );
groupCount ++;
}
geometry.setAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) );
geometry.setAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) );
return geometry;
}
function ensureString( buffer ) {
if ( typeof buffer !== 'string' ) {
return new TextDecoder().decode( buffer );
}
return buffer;
}
function ensureBinary( buffer ) {
if ( typeof buffer === 'string' ) {
const array_buffer = new Uint8Array( buffer.length );
for ( let i = 0; i < buffer.length; i ++ ) {
array_buffer[ i ] = buffer.charCodeAt( i ) & 0xff; // implicitly assumes little-endian
}
return array_buffer.buffer || array_buffer;
} else {
return buffer;
}
}
// start
const binData = ensureBinary( data );
return isBinary( binData ) ? parseBinary( binData ) : parseASCII( ensureString( data ) );
}
}
export { STLLoader };
File diff suppressed because it is too large. Load diff