refactor: cleanup GUI + AI movement controls + depth-correction polish

GUI:
- Rimossa tab "Calibrazione" (sostituita dalla calibrazione via AI con saved
  task `calibrazione_dx`/`calibrazione_sx`, oppure dalla tab "Auto-
  Calibrazione" per il flusso a griglia automatica). Backend
  calibration_routes invariato — tool AI continuano a usarlo.

AI tools:
- Esposto `precise` su `move_wrist_absolute` e `move_wrist_relative`
  (forwarded al campo omonimo del bridge robot, controlla la modalità di
  controllo: false=veloce/tolleranza ampia, true=lento/posa accurata).
- Aggiunto `arms_go_neutral` come tool dedicato (POST /arms/go_neutral del
  bridge): usato dall'AI per "torna a casa / posizione di riposo". Niente
  side, agisce su entrambe le braccia.
- Fix `device_status`: ora wrappa la risposta con `_ok(...)` così il
  dispatcher la classifica correttamente.

Prompt:
- System prompt aggiornato: l'AI ora passa SEMPRE `precise=true` di default
  sui movimenti, escluso quando l'utente chiede esplicitamente velocità.
  Sezione dedicata su come tornare a riposo (arms_go_neutral).
- Documentato che `measured_side_mm` (ritornato da get_marker_3d) viene
  distorto dalla correzione affine sul depth — non usarlo per validare la
  size_mm dichiarata.

Saved tasks:
- `calibrazione_dx` e `calibrazione_sx` ora partono e finiscono con
  `arms_go_neutral`, cancellano i sample preesistenti per quel lato, e
  acquisiscono ≥10 sample con `precise=true` su una griglia ±80mm + ±15° di
  rotazione attorno alla posa home.

Config:
- Marker corrente: id=10, size=40mm (cambiato il tag fisico sul polso).
- Addendum prompt allentato: niente più "max 3 righe", ora chiede di
  riportare il risultato di OGNI tool chiamato.

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 17:05:52 +02:00
1 parent ac573505b7
commit f6197488f9
6 files changed
+80 -344

No files matched your search

+5 -4
View File
@@ -20,8 +20,8 @@ services:
aruco:
dictionary: DICT_4X4_50
wrist_marker:
id: 20
size_mm: 103.0
id: 10
size_mm: 40.0
robot:
wrist_side: right
active: h1
@@ -53,5 +53,6 @@ chat:
api_key_env: OPENROUTER_API_KEY
referer: http://localhost:8890
title: presa-mano
user_prompt_addendum: L'operatore preferisce risposte molto telegrafiche, max
3 righe se possibile. Misure sempre in mm interi.
user_prompt_addendum: Misure sempre in mm interi. Sii conciso ma riporta i risultati
di OGNI tool che hai chiamato (anche solo in una riga compatta), non saltare
informazioni rilevanti.
+14
View File
@@ -52,11 +52,25 @@ un robot umanoide (Unitree G1 o H1) con una camera depth Nuwa-HP60C fissa.
- Movimenti **relativi**: `move_wrist_relative(side, delta_position)` —
delta in metri, frame base. Se l'utente dice "muovi di 200 mm in X+", passa
delta=[0.2, 0, 0]. Conferma sempre il risultato leggendo la nuova posa.
- **Modalità precise**: per default passa SEMPRE `precise=true` su
`move_wrist_absolute` e `move_wrist_relative`. Imposta `precise=false`
solo se l'utente lo chiede esplicitamente (es. "muovi veloce",
"avvicinati grossomodo"). Lo `precise=true` garantisce che il controller
raggiunga la posa target con accuratezza fine — necessario per
acquisizione sample di calibrazione, avvicinamento al grasp, e in generale
ogni movimento dove la posa finale conta.
- Default `dry_run=true` su tutte le operazioni di motion: NON fai mai partire
un movimento reale senza che l'utente l'abbia chiesto esplicitamente.
- Le mani: `hand_action(side, action)` con `action ∈
{{LEFT_OPEN, LEFT_CLOSE, RIGHT_OPEN, RIGHT_CLOSE}}`.
## Posizione di riposo (home) delle braccia
- Quando l'operatore dice **"torna a casa"**, **"home"**, **"posizione di
riposo"** o simili, chiama `arms_go_neutral()`. Agisce su entrambe le
braccia simultaneamente (è un comando del bridge, niente side da scegliere).
- Default `dry_run=true` come per gli altri movimenti — chiedi conferma
prima di mandarlo dal vivo.
## Calibrazione hand-eye (eye-to-hand)
La calibrazione lega `T_base_camera`. È **specifica per braccio**: dx e sx
hanno file separati. Procedura completa quando l'utente dice "calibra":
+55 -7
View File
@@ -89,7 +89,10 @@ def _h_set_active_robot(args: dict[str, Any]) -> dict[str, Any]:
def _h_device_status(_: dict[str, Any]) -> dict[str, Any]:
"""Reuses the home_routes implementation to avoid duplicating the ping logic."""
from ..api.home_routes import status as _status
return _status()
try:
return _ok(**_status())
except Exception as e:
return _err(str(e))
# ----- tool: wrist read/move ------------------------------------------------
@@ -118,11 +121,12 @@ def _h_move_wrist_absolute(args: dict[str, Any]) -> dict[str, Any]:
return _err("position deve avere 3 float (m), quaternion 4 float (xyzw)")
dry_run = bool(args.get("dry_run", True))
timeout_s = float(args.get("timeout_s", 5.0))
precise = bool(args.get("precise", False))
result = _deps.g1(dry_run=dry_run).wrist_absolute(
side=side, position=position, quaternion=quaternion,
wait=True, timeout_s=timeout_s,
wait=True, timeout_s=timeout_s, precise=precise,
)
return _ok(dry_run=dry_run, side=side, response=result)
return _ok(dry_run=dry_run, side=side, precise=precise, response=result)
except (G1Unavailable, G1StatusError) as e:
return _err(f"motion failed: {e}")
except Exception as e:
@@ -140,19 +144,34 @@ def _h_move_wrist_relative(args: dict[str, Any]) -> dict[str, Any]:
return _err("delta_quaternion deve avere 4 float (xyzw) o essere null")
dry_run = bool(args.get("dry_run", True))
timeout_s = float(args.get("timeout_s", 5.0))
precise = bool(args.get("precise", False))
result = _deps.g1(dry_run=dry_run).wrist_relative(
side=side,
delta_position=list(dp) if dp is not None else None,
delta_quaternion=list(dq) if dq is not None else None,
wait=True, timeout_s=timeout_s,
wait=True, timeout_s=timeout_s, precise=precise,
)
return _ok(dry_run=dry_run, side=side, response=result)
return _ok(dry_run=dry_run, side=side, precise=precise, response=result)
except (G1Unavailable, G1StatusError) as e:
return _err(f"motion failed: {e}")
except Exception as e:
return _err(str(e))
def _h_arms_go_neutral(args: dict[str, Any]) -> dict[str, Any]:
"""Riporta entrambe le braccia alla posa di home / riposo built-in del
bridge (POST /arms/go_neutral). È l'unico modo standard di "tornare a
casa" — non serve registrare pose custom."""
try:
dry_run = bool(args.get("dry_run", True))
result = _deps.g1(dry_run=dry_run).arms_go_neutral()
return _ok(dry_run=dry_run, response=result)
except (G1Unavailable, G1StatusError) as e:
return _err(f"go_neutral failed: {e}")
except Exception as e:
return _err(str(e))
def _h_hand_action(args: dict[str, Any]) -> dict[str, Any]:
try:
action = str(args.get("action", "")).upper()
@@ -422,6 +441,7 @@ _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
"move_wrist_absolute": _h_move_wrist_absolute,
"move_wrist_relative": _h_move_wrist_relative,
"hand_action": _h_hand_action,
"arms_go_neutral": _h_arms_go_neutral,
"get_marker": _h_get_marker,
"get_marker_3d": _h_get_marker_3d,
"detect_object": _h_detect_object,
@@ -479,7 +499,13 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"type": "function",
"function": {
"name": "move_wrist_absolute",
"description": "Sposta il polso a una posa assoluta nel frame base. Default dry_run=true: il robot NON si muove davvero finché dry_run=false.",
"description": (
"Sposta il polso a una posa assoluta nel frame base. Default dry_run=true: "
"il robot NON si muove davvero finché dry_run=false. Usa precise=true per "
"movimenti che richiedono accuratezza fine (es. acquisizione sample di "
"calibrazione, avvicinamento finale al grasp); precise=false è il default, "
"più veloce ma con tolleranza maggiore."
),
"parameters": {
"type": "object",
"required": ["side", "position", "quaternion"],
@@ -490,6 +516,8 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"quaternion": {"type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4,
"description": "x,y,z,w"},
"dry_run": {"type": "boolean", "default": True},
"precise": {"type": "boolean", "default": False,
"description": "true = controllo accurato (più lento)"},
"timeout_s": {"type": "number", "default": 5.0},
},
},
@@ -499,7 +527,11 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"type": "function",
"function": {
"name": "move_wrist_relative",
"description": "Sposta il polso di un delta rispetto alla posa attuale. delta_position in metri (frame base), delta_quaternion opzionale. Default dry_run=true.",
"description": (
"Sposta il polso di un delta rispetto alla posa attuale. delta_position "
"in metri (frame base), delta_quaternion opzionale. Default dry_run=true. "
"Usa precise=true se serve accuratezza fine."
),
"parameters": {
"type": "object",
"required": ["side"],
@@ -508,11 +540,27 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"delta_position": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3},
"delta_quaternion": {"type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4},
"dry_run": {"type": "boolean", "default": True},
"precise": {"type": "boolean", "default": False},
"timeout_s": {"type": "number", "default": 5.0},
},
},
},
},
{
"type": "function",
"function": {
"name": "arms_go_neutral",
"description": (
"Riporta ENTRAMBE le braccia alla posa di home/riposo del robot "
"(POST /arms/go_neutral sul bridge). Usa quando l'utente dice "
"'torna a casa', 'home', 'posizione di riposo'. Default dry_run=true."
),
"parameters": {
"type": "object",
"properties": {"dry_run": {"type": "boolean", "default": True}},
},
},
},
{
"type": "function",
"function": {
+5 -2
View File
@@ -70,8 +70,11 @@ class G1Client:
def wrist_relative(self, side: str, delta_position: list[float] | None = None,
delta_quaternion: list[float] | None = None, wait: bool = True,
timeout_s: float = 5.0) -> dict[str, Any]:
body: dict[str, Any] = {"side": side, "wait": wait, "timeout_s": timeout_s}
timeout_s: float = 5.0, precise: bool = False) -> dict[str, Any]:
# `precise` is forwarded but the bridge silently ignores it on
# /wrist/relative if not supported — safer to always send it.
body: dict[str, Any] = {"side": side, "wait": wait, "timeout_s": timeout_s,
"precise": precise}
if delta_position is not None:
body["delta_position"] = delta_position
if delta_quaternion is not None:
+1 -260
View File
@@ -109,259 +109,6 @@ async function api(method, path, body = null) {
return data;
}
// ==================== CALIBRAZIONE ====================
const calibCanvas = $('calibCanvas');
const calibCtx = calibCanvas.getContext('2d');
let displayedRgb = null; // last successfully loaded Image (stays on screen)
let rgbInFlight = false;
let previewInFlight = false;
let lastMarker = null;
let lastMarkerTs = 0;
const MARKER_STALE_MS = 1500; // keep overlay this long across hiccups
function drawCalibFrame() {
if (displayedRgb) {
if (calibCanvas.width !== displayedRgb.naturalWidth) {
calibCanvas.width = displayedRgb.naturalWidth;
calibCanvas.height = displayedRgb.naturalHeight;
}
calibCtx.drawImage(displayedRgb, 0, 0);
} else {
calibCtx.fillStyle = '#000';
calibCtx.fillRect(0, 0, calibCanvas.width, calibCanvas.height);
calibCtx.fillStyle = '#666';
calibCtx.font = '14px sans-serif';
calibCtx.fillText('no RGB', 20, 30);
}
const fresh = lastMarker && lastMarker.found && (performance.now() - lastMarkerTs < MARKER_STALE_MS);
if (fresh) {
const c = lastMarker.corners;
calibCtx.strokeStyle = '#3ecf8e';
calibCtx.lineWidth = 3;
calibCtx.beginPath();
calibCtx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i++) calibCtx.lineTo(c[i][0], c[i][1]);
calibCtx.closePath();
calibCtx.stroke();
calibCtx.fillStyle = '#3ecf8e';
c.forEach(p => { calibCtx.beginPath(); calibCtx.arc(p[0], p[1], 4, 0, 2 * Math.PI); calibCtx.fill(); });
const cx = lastMarker.center_px[0], cy = lastMarker.center_px[1];
calibCtx.font = 'bold 14px sans-serif';
const d = lastMarker.pose_camera && lastMarker.pose_camera.distance_m;
calibCtx.fillText(`ID ${lastMarker.marker_id}` + (d ? ` · ${(d*100).toFixed(1)} cm` : ''), cx + 10, cy - 10);
}
}
function refreshRgb() {
if (rgbInFlight) return;
rgbInFlight = true;
const img = new Image();
img.onload = () => {
displayedRgb = img;
rgbInFlight = false;
drawCalibFrame();
};
img.onerror = () => { rgbInFlight = false; /* keep previous frame */ };
img.src = `/api/calibration/rgb.jpg?t=${Date.now()}`;
}
async function previewMarker() {
if (previewInFlight) return;
if (!$('livePreview').checked) {
if (lastMarker) { lastMarker = null; drawCalibFrame(); }
return;
}
previewInFlight = true;
try {
const body = { marker_id: +$('arucoId').value, size_mm: +$('arucoSize').value };
const data = await api('POST', '/api/calibration/preview', body);
lastMarker = data;
lastMarkerTs = performance.now();
const info = $('markerInfo');
const t = fmtTimings(data.timings_ms);
if (data.found) {
const d = data.pose_camera && data.pose_camera.distance_m;
info.textContent = `marker ${data.marker_id} @ ${(d*100).toFixed(1)} cm · ${t}`;
info.style.color = 'var(--ok)';
} else {
info.textContent = `marker ${data.marker_id} non trovato · ${t}`;
info.style.color = 'var(--warn)';
}
} catch (e) {
// transient failure — don't wipe the overlay
$('markerInfo').textContent = `preview: ${e.message}`;
$('markerInfo').style.color = 'var(--danger)';
} finally {
previewInFlight = false;
drawCalibFrame();
}
}
// Two independent loops so a slow preview doesn't starve the RGB redraw,
// and a slow RGB doesn't block the marker polling.
setInterval(() => {
if ($('tab-calibration').classList.contains('active')) refreshRgb();
}, 150);
setInterval(() => {
if ($('tab-calibration').classList.contains('active')) previewMarker();
}, 500);
$('sampleBtn').addEventListener('click', async () => {
try {
setStatus('Acquisisco sample…');
const body = {
marker_id: +$('arucoId').value,
size_mm: +$('arucoSize').value,
side: $('side').value,
};
const r = await api('POST', '/api/calibration/sample', body);
setStatus(`Sample #${r.total} acquisito · ${fmtTimings(r.timings_ms)}`, 'ok');
await loadSamples();
} catch (e) { setStatus(e.message, 'err'); }
});
$('solveBtn').addEventListener('click', async () => {
try {
setStatus('Calcolo matrice…');
const side = $('side').value;
const r = await api('POST', '/api/calibration/solve', { method: $('method').value, side });
renderCalibResult(r);
setStatus(`Matrice calcolata (${side}) · ${fmtTimings(r.timings_ms)}`, 'ok');
} catch (e) { setStatus(e.message, 'err'); }
});
$('clearSamplesBtn').addEventListener('click', async () => {
const side = $('side').value;
if (!confirm(`Rimuovere tutti i sample del braccio ${side}?`)) return;
await api('DELETE', `/api/calibration/samples?side=${encodeURIComponent(side)}`);
await loadSamples();
});
$('saveConfigBtn').addEventListener('click', async () => {
const patch = {
aruco: { wrist_marker: { id: +$('arucoId').value, size_mm: +$('arucoSize').value } },
robot: { wrist_side: $('side').value },
calibration: { method: $('method').value },
};
try {
await api('PUT', '/api/config', patch);
setStatus('Configurazione salvata', 'ok');
} catch (e) { setStatus(e.message, 'err'); }
});
// When the user changes the side dropdown, reload per-side samples/result.
$('side').addEventListener('change', async () => {
await loadSamples().catch(() => {});
await loadCalibResult().catch(() => {});
});
// index -> { reason, detail } for samples the last solve dropped
const lastDrops = new Map();
async function loadSamples() {
const side = $('side') ? $('side').value : 'right';
const r = await api('GET', `/api/calibration/samples?side=${encodeURIComponent(side)}`);
$('sampleCount').textContent = r.total;
const ul = $('sampleList');
ul.innerHTML = '';
// Display newest first while keeping the server-side index (used in drop reports).
for (let i = r.samples.length - 1; i >= 0; i--) {
const s = r.samples[i];
const t = s.T_base_wrist;
const p = [t[0][3], t[1][3], t[2][3]].map(v => v.toFixed(3)).join(', ');
const li = document.createElement('li');
const drop = lastDrops.get(i);
let line = `#${i+1} side=${s.side} pos=[${p}] ${s.note || ''}`;
if (drop) {
line += ` · scartato (${drop.reason}: ${drop.detail})`;
li.classList.add('sample-dropped');
}
li.textContent = line;
ul.appendChild(li);
}
}
function renderCalibResult(r) {
const m = r.T_base_camera;
const fmt = (v) => (v >= 0 ? ' ' : '') + v.toFixed(4);
const rows = m.map(row => row.map(fmt).join(' '));
$('calibMatrix').textContent = rows.join('\n');
const meta = [
`method=${r.method}`,
`n=${r.n_samples}`,
`residual: ${r.residual_translation_mm.toFixed(2)} mm / ${r.residual_rotation_deg.toFixed(2)}°`,
];
const sc = r.sanity_check;
lastDrops.clear();
if (sc && sc.applied) {
const drops = [];
const pa = sc.dropped_by_pair_angle || [];
const wm = sc.dropped_by_wm_cluster || [];
const pq = sc.dropped_by_pose_quality || [];
for (const d of pq) {
const key = d.reprojection_px != null ? 'reproj' : 'ambiguity';
const val = d.reprojection_px != null ? `${d.reprojection_px.toFixed(2)} px` : `amb ${(d.ambiguity_ratio*100).toFixed(0)}%`;
lastDrops.set(d.sample_index, { reason: key, detail: val });
}
for (const d of pa) {
lastDrops.set(d.sample_index,
{ reason: 'pair-angle', detail: `${d.pair_angle_err_deg.toFixed(1)}°` });
}
for (const d of wm) {
// A sample can fail both checks; prefer the wm message (happens after solve).
lastDrops.set(d.sample_index,
{ reason: 'T_wrist_marker', detail: `${d.wm_translation_dev_mm.toFixed(1)} mm` });
}
if (pq.length) drops.push(`pose-quality: ${pq.map(d => `#${d.sample_index+1}(${d.reprojection_px != null ? d.reprojection_px.toFixed(2)+'px' : (d.ambiguity_ratio*100).toFixed(0)+'%'})`).join(', ')}`);
if (pa.length) drops.push(`pair-angle: ${pa.map(d => `#${d.sample_index+1}(${d.pair_angle_err_deg.toFixed(1)}°)`).join(', ')}`);
if (wm.length) drops.push(`T_wrist_marker: ${wm.map(d => `#${d.sample_index+1}(${d.wm_translation_dev_mm.toFixed(1)}mm)`).join(', ')}`);
const totalDropped = (sc.dropped_by_pose_quality || []).length + pa.length + wm.length;
if (drops.length) {
meta.push(`\n⚠ scartati ${totalDropped}/${sc.n_input}: ${drops.join(' · ')}`);
} else {
meta.push(`\n✓ nessun outlier (${sc.n_input} sample usati)`);
}
}
$('calibMeta').textContent = meta.join(' ');
$('calibMeta').style.whiteSpace = 'pre-wrap';
// Refresh the list so dropped rows turn red (if already visible).
loadSamples().catch(() => {});
}
async function loadCalibResult() {
const side = $('side') ? $('side').value : 'right';
try { renderCalibResult(await api('GET', `/api/calibration/result?side=${encodeURIComponent(side)}`)); } catch {}
}
// auto-grid
$('runGridBtn').addEventListener('click', async () => {
const quat = $('gridQuat').value.split(',').map(Number);
if (quat.length !== 4) { setStatus('quat deve avere 4 valori', 'err'); return; }
const body = {
start_position: [+$('gridX0').value, +$('gridY0').value, +$('gridZ0').value],
orientation_quaternion_xyzw: quat,
step_x_m: +$('gridDX').value,
step_y_m: +$('gridDY').value,
step_z_m: +$('gridDZ').value,
n_x: 3, n_y: 3, n_z: 3,
min_z_m: +$('gridMinZ').value,
settle_s: +$('gridSettle').value,
dry_run: $('gridDryRun').checked,
auto_solve: true,
};
$('gridLog').textContent = 'run in progress…';
try {
const r = await api('POST', '/api/calibration/auto_grid', body);
$('gridLog').textContent = JSON.stringify(r, null, 2);
if (r.calibration) renderCalibResult(r.calibration);
await loadSamples();
setStatus(body.dry_run ? 'auto-grid dry-run completato' : 'auto-grid completato', 'ok');
} catch (e) {
$('gridLog').textContent = e.message;
setStatus(e.message, 'err');
}
});
// ==================== TROVA UTENSILE ====================
const graspCanvas = $('graspCanvas');
const graspCtx = graspCanvas.getContext('2d');
@@ -1374,15 +1121,9 @@ startHomePoll();
(async () => {
try {
const cfg = await api('GET', '/api/config');
$('arucoId').value = cfg.aruco.wrist_marker.id;
$('arucoSize').value = cfg.aruco.wrist_marker.size_mm;
$('side').value = cfg.robot.wrist_side;
$('eeSide').value = cfg.robot.wrist_side;
if ($('eeSide')) $('eeSide').value = cfg.robot.wrist_side;
} catch {}
await loadSamples();
await loadCalibResult();
refreshWrist();
refreshRgb();
})();
// ==================== ASSISTENTE AI ====================
-71
View File
@@ -13,7 +13,6 @@
<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>
@@ -176,76 +175,6 @@
</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>