Files
appRobotHoming/public/calibration.js
2026-06-15 22:51:00 +02:00

1175 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* calibration.js Kalibrierungs-Frontend */
// ── Panel-Loading (lazy) ───────────────────────────────────────────────────────
const _panelLoaded = new Set();
async function loadPanel(tab, src) {
if (_panelLoaded.has(tab)) return;
try {
const r = await fetch(src);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
document.getElementById('tab-' + tab).innerHTML = await r.text();
_panelLoaded.add(tab);
// h2-Klick → Section ein-/ausklappen
document.getElementById('tab-' + tab).querySelectorAll('.section h2').forEach(h2 => {
h2.addEventListener('click', () => h2.closest('.section').classList.toggle('collapsed'));
});
// Tab-spezifische Initialisierung
if (tab === 'camera-npz') initCameraNpz();
else if (tab === 'board') initBoard();
else if (tab === 'robot-x-axis') initXAxis();
else if (tab === 'arm1') initArm('arm1');
else if (tab === 'marker') initMarker();
} catch (err) {
document.getElementById('tab-' + tab).innerHTML =
`<p style="color:#f87171;margin:16px">Panel konnte nicht geladen werden: ${err}</p>`;
}
}
// ── Tab-Switching ─────────────────────────────────────────────────────────────
document.getElementById('tabSidebar').addEventListener('click', e => {
const btn = e.target.closest('.tab-btn');
if (!btn) return;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
loadPanel(btn.dataset.tab, btn.dataset.src);
});
// Erstes Tab sofort laden
(function () {
const first = document.querySelector('.tab-btn.active');
if (first) loadPanel(first.dataset.tab, first.dataset.src);
})();
// ── Shared: SSE-Stream lesen ──────────────────────────────────────────────────
async function readSseStream(response, logFn, onDone) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop(); // unvollständiges letztes Fragment behalten
for (const part of parts) {
for (const line of part.split('\n')) {
if (!line.startsWith('data: ')) continue;
try {
const evt = JSON.parse(line.slice(6));
if (evt.type === 'log') { if (evt.text !== '') logFn(evt.text); }
else if (evt.type === 'done') { onDone(evt); }
} catch { /* ungültiges JSON überspringen */ }
}
}
}
}
// ── Camera NPZ ────────────────────────────────────────────────────────────────
function initCameraNpz() {
const logCamera = document.getElementById('log-camera');
function logC(msg) {
const ts = new Date().toLocaleTimeString('de-CH');
logCamera.value += `[${ts}] ${msg}\n`;
logCamera.scrollTop = logCamera.scrollHeight;
}
function formatDate(isoString) {
if (!isoString) return '';
return new Date(isoString).toLocaleString('de-CH');
}
function updateCalibInfo(meta) {
const sel = document.getElementById('cam-select-calib');
if (!meta) {
document.getElementById('info-timestamp').textContent = '(keine Session vorhanden)';
document.getElementById('info-created').textContent = '';
document.getElementById('info-images').textContent = '';
sel.innerHTML = '<option value=""> Kamera </option>';
return;
}
document.getElementById('info-timestamp').textContent = meta.timestamp ?? '';
document.getElementById('info-created').textContent = formatDate(meta.createdAt);
const cameras = meta.cameras ?? [];
const imgTxt = meta.imageCount != null
? `${meta.imageCount} Bilder total. ${cameras.length} Kamera(s) verwendet.`
: '';
document.getElementById('info-images').textContent = imgTxt;
const prev = sel.value;
sel.innerHTML = '<option value=""> Kamera </option>';
for (const cam of cameras) {
const opt = document.createElement('option');
opt.value = cam; opt.textContent = cam;
if (cam === prev) opt.selected = true;
sel.appendChild(opt);
}
if (cameras.length === 1) sel.value = cameras[0];
}
async function loadCalibCurrent() {
try {
const r = await fetch('/api/calibration/current');
const d = await r.json();
updateCalibInfo(d.meta);
if (d.session) logC(`Session geladen: ${d.session}`);
else logC('Noch keine Kalibrierungs-Session vorhanden.');
} catch (err) {
logC(`Fehler beim Laden: ${err}`);
}
}
async function safeJson(r) {
const raw = await r.text().catch(() => '');
try { return { ok: r.ok, status: r.status, data: JSON.parse(raw) }; }
catch { return { ok: r.ok, status: r.status, data: null, raw: raw.slice(0, 300) }; }
}
// Aktuelle Session laden
loadCalibCurrent();
// "Neue Kalibrierung anlegen"
document.getElementById('btn-new-calib').addEventListener('click', async () => {
logC('Neue Kalibrierung wird angelegt …');
try {
const r = await fetch('/api/calibration/new', { method: 'POST' });
const d = await r.json();
if (d.error) { logC(`Fehler: ${d.error}`); return; }
updateCalibInfo(d.meta);
if (d.warning) logC(`Warnung: ${d.warning}`);
else logC(`Session angelegt: ${d.session} | Fotos: ${(d.savedFiles ?? []).join(', ')}`);
} catch (err) {
logC(`Fehler: ${err}`);
}
});
// "Foto aufnehmen"
document.getElementById('btn-foto-calib').addEventListener('click', async () => {
logC('Fotos werden aufgenommen …');
try {
const r = await fetch('/api/calibration/foto', { method: 'POST' });
const d = await r.json();
if (d.error) { logC(`Fehler: ${d.error}`); return; }
updateCalibInfo(d.meta);
logC(`Gespeichert: ${(d.savedFiles ?? []).join(', ')}`);
} catch (err) {
logC(`Fehler: ${err}`);
}
});
// "Kalibrierung berechnen" SSE
document.getElementById('btn-compute-calib').addEventListener('click', async () => {
const camera = document.getElementById('cam-select-calib').value;
if (!camera) { logC('⚠ Bitte zuerst eine Kamera auswählen.'); return; }
logC(`Starte Kalibrierung für ${camera}`);
const btn = document.getElementById('btn-compute-calib');
btn.disabled = true;
try {
const response = await fetch('/api/calibration/compute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ camera }),
});
if (!response.ok) {
const raw = await response.text().catch(() => '');
let msg;
try { msg = JSON.parse(raw).error || raw; }
catch { msg = raw.slice(0, 300) || response.statusText; }
logC(`❌ HTTP ${response.status}: ${msg || '(kein Fehlertext evtl. Server neu starten?)'}`);
return;
}
await readSseStream(response, logC, (evt) => {
if (evt.exitCode === 0) logC('✅ Kalibrierung abgeschlossen.');
else logC(`❌ Script beendet mit Exit-Code ${evt.exitCode}`);
});
} catch (err) {
logC(`Fehler: ${err}`);
} finally {
btn.disabled = false;
}
});
// "NPZ speichern" → an Webcam-Service übertragen
document.getElementById('btn-upload-npz').addEventListener('click', async () => {
const camera = document.getElementById('cam-select-calib').value;
if (!camera) { logC('⚠ Bitte zuerst eine Kamera auswählen.'); return; }
logC(`NPZ wird an Webcam-Service übertragen (${camera}) …`);
try {
const { ok, status, data, raw } = await safeJson(await fetch('/api/calibration/upload-npz', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ camera }),
}));
if (!ok || data?.error) {
logC(`❌ HTTP ${status}: ${data?.error ?? raw ?? '(kein Fehlertext)'}`);
return;
}
logC(`✅ Gespeichert: ${data.webcam?.saved} (${data.size} Bytes)`);
logC(` calibrationUrl: ${data.webcam?.calibrationUrl}`);
} catch (err) {
logC(`❌ Fehler: ${err}`);
}
});
}
// ── Board: Marker-Tabelle ─────────────────────────────────────────────────────
async function loadBoardTable() {
const wrap = document.getElementById('board-marker-table-wrap');
if (!wrap) return;
const th = (a) => `style="text-align:${a};padding:3px 8px;border-bottom:1px solid #2a2d35;white-space:nowrap;background:#1e293b;color:#555b6e;font-weight:normal"`;
const td = (a, x = '') => `style="padding:2px 8px;border-bottom:1px solid #111418;text-align:${a};white-space:nowrap;${x}"`;
try {
const r = await fetch('/api/board/latest');
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
if (!data.runDir) {
wrap.innerHTML = '<p style="font-size:11px;color:#555b6e;padding:4px 0">Noch kein Board-Run vorhanden.</p>';
return;
}
const meas = data.measuredMarkers;
if (!meas?.markers?.length) {
wrap.innerHTML = `<p style="font-size:11px;color:#555b6e;padding:4px 0">Run: ${data.runDir} kein 3b-Output (≥2 Kameras erforderlich).</p>`;
return;
}
// Modell-Info aus robot.json
const boardMarkers = data.robot?.links?.Board?.markers ?? [];
const modelMap = {};
for (const m of boardMarkers) modelMap[m.id] = m;
const f1 = v => (v == null ? '' : Number(v).toFixed(1));
const f2 = v => (v == null ? '' : Number(v).toFixed(2));
const f4 = v => (v == null ? '' : Number(v).toFixed(4));
const markers = [...meas.markers].sort((a, b) => {
if (a.link !== b.link) return a.link === 'Board' ? -1 : 1;
return a.marker_id - b.marker_id;
});
let html = `<p style="font-size:10px;color:#555b6e;margin-bottom:4px">
Run: ${data.runDir} · ${markers.length} Marker trianguliert
</p>
<table style="border-collapse:collapse;font-size:11px;font-family:inherit;width:100%">
<thead><tr>
<th ${th('left')}>ID</th>
<th ${th('left')}>Set</th>
<th ${th('left')}>Link</th>
<th ${th('right')}>Kam.</th>
<th ${th('right')}>x mm</th>
<th ${th('right')}>y mm</th>
<th ${th('right')}>z mm</th>
<th ${th('right')}>nx</th>
<th ${th('right')}>ny</th>
<th ${th('right')}>nz</th>
<th ${th('right')}>dist mm</th>
<th ${th('right')}>Δz mm</th>
<th ${th('right')}>Kante mm</th>
</tr></thead>
<tbody>`;
for (const m of markers) {
const model = modelMap[m.marker_id];
const set = m.set ?? model?.set ?? '';
const [px, py, pz] = m.position_mm;
const [mnx, mny, mnz] = m.normal;
let dist = '', dz = '';
if (model && m.link === 'Board') {
const [mx, my, mz] = model.position;
const ddx = px - mx, ddy = py - my, ddz = pz - mz;
dist = Math.sqrt(ddx * ddx + ddy * ddy + ddz * ddz).toFixed(2);
dz = ddz.toFixed(2);
}
const col = m.link === 'Board' ? '' : 'color:#3b82f6;';
html += `<tr>
<td ${td('left', col)}>${m.marker_id}</td>
<td ${td('left', col)}>${set}</td>
<td ${td('left', col)}>${m.link}</td>
<td ${td('right', col)}>${m.num_cameras}</td>
<td ${td('right', col)}>${f1(px)}</td>
<td ${td('right', col)}>${f1(py)}</td>
<td ${td('right', col)}>${f1(pz)}</td>
<td ${td('right', col)}>${f4(mnx)}</td>
<td ${td('right', col)}>${f4(mny)}</td>
<td ${td('right', col)}>${f4(mnz)}</td>
<td ${td('right', col)}>${dist}</td>
<td ${td('right', col)}>${dz}</td>
<td ${td('right', col)}>${f1(m.edge_length_mm)}</td>
</tr>`;
}
html += `</tbody></table>`;
// Kamera-Tabelle
const cams = meas.cameras ?? [];
if (cams.length > 0) {
html += `<p style="font-size:10px;color:#555b6e;margin-top:12px;margin-bottom:4px">KAMERAS</p>
<table style="border-collapse:collapse;font-size:11px;font-family:inherit;width:auto">
<thead><tr>
<th ${th('left')}>ID</th>
<th ${th('right')}>x mm</th>
<th ${th('right')}>y mm</th>
<th ${th('right')}>z mm</th>
<th ${th('right')}>dir_x</th>
<th ${th('right')}>dir_y</th>
<th ${th('right')}>dir_z</th>
</tr></thead>
<tbody>`;
for (const c of cams) {
const [cx, cy, cz] = c.position_mm ?? [null, null, null];
const [dx, dy, dz] = c.direction ?? [null, null, null];
html += `<tr>
<td ${td('left', 'color:#4a9eff;')}>${c.camera_id}</td>
<td ${td('right')}>${f1(cx)}</td>
<td ${td('right')}>${f1(cy)}</td>
<td ${td('right')}>${f1(cz)}</td>
<td ${td('right')}>${f4(dx)}</td>
<td ${td('right')}>${f4(dy)}</td>
<td ${td('right')}>${f4(dz)}</td>
</tr>`;
}
html += `</tbody></table>`;
}
wrap.innerHTML = html;
} catch (err) {
if (wrap) wrap.innerHTML = `<p style="color:#f87171;font-size:11px">Fehler: ${err}</p>`;
}
}
// ── Board ─────────────────────────────────────────────────────────────────────
// ── Tab: Robot X Axis ─────────────────────────────────────────────────────────
async function populateXAxisSetDropdowns() {
let sets = [];
try {
const r = await fetch('/api/robot/board-sets');
if (r.ok) sets = (await r.json()).sets ?? [];
} catch {}
const sel = document.getElementById('xaxis-ref-set');
if (sel) {
sel.innerHTML = '<option value="">alle</option>' +
sets.map(s => `<option value="${s}">${s}</option>`).join('');
}
}
function initXAxis() {
const logEl = document.getElementById('log-xaxis');
function logX(msg) {
const ts = new Date().toLocaleTimeString('de-CH');
logEl.value += `[${ts}] ${msg}\n`;
logEl.scrollTop = logEl.scrollHeight;
}
populateXAxisSetDropdowns();
document.getElementById('btn-xaxis-run').addEventListener('click', async () => {
const refSet = document.getElementById('xaxis-ref-set')?.value ?? '';
logX(`Board-Erkennung … Referenz: ${refSet || 'alle'}`);
const btn = document.getElementById('btn-xaxis-run');
btn.disabled = true;
try {
const response = await fetch('/api/board/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refSet: refSet || undefined }),
});
if (!response.ok) {
const raw = await response.text().catch(() => '');
let msg;
try { msg = JSON.parse(raw).error || raw; }
catch { msg = raw.slice(0, 300) || `HTTP ${response.status}`; }
logX(`❌ HTTP ${response.status}: ${msg}`);
return;
}
await readSseStream(response, logX, (evt) => {
if (evt.exitCode === 0) {
logX('✅ Board-Run abgeschlossen.');
if (evt.runDir) {
document.getElementById('xaxis-last-run').textContent = evt.runDir;
const frame = document.getElementById('xaxis-viewer-frame');
if (frame?.contentWindow) {
frame.contentWindow.postMessage({ type: 'reload' }, '*');
}
}
} else {
logX(`❌ Beendet mit Exit-Code ${evt.exitCode}`);
}
});
} catch (err) {
logX(`❌ Fehler: ${err}`);
} finally {
btn.disabled = false;
}
});
// ── X-Achse übernehmen ────────────────────────────────────────────────────
// Empfängt postMessage aus dem eingebetteten boardViewer-iframe.
let _xaxisDirection = null; // zuletzt gemessene Richtung [vx,vy,vz]
const adoptBtn = document.getElementById('btn-xaxis-adopt');
function onXaxisMessage(e) {
// Nachricht muss vom boardViewer-iframe stammen
const frame = document.getElementById('xaxis-viewer-frame');
if (!frame || e.source !== frame.contentWindow) return;
const msg = e.data;
if (!msg || msg.type !== 'xaxis-measurement') return;
if (Array.isArray(msg.direction)) {
_xaxisDirection = msg.direction;
const fmt = v => (v >= 0 ? '+' : '') + v.toFixed(3) + '°';
logX(`📐 Messung empfangen: dir=[${msg.direction.map(v => v.toFixed(4)).join(', ')}]` +
` XY=${fmt(msg.angleXY)} XZ=${fmt(msg.angleXZ)}` +
` (${msg.numMarkers} Marker, Ø${msg.distMm.toFixed(1)} mm)`);
if (adoptBtn) {
adoptBtn.disabled = false;
adoptBtn.style.opacity = '1';
adoptBtn.style.cursor = 'pointer';
adoptBtn.title = `X-Achse übernehmen (dir=[${_xaxisDirection.map(v => v.toFixed(4)).join(', ')}])`;
}
} else {
// Ungültige / zu kleine Bewegung → Button sperren
_xaxisDirection = null;
if (adoptBtn) {
adoptBtn.disabled = true;
adoptBtn.style.opacity = '.45';
adoptBtn.style.cursor = 'not-allowed';
adoptBtn.title = 'Noch keine gültige Messung verfügbar';
}
}
}
window.addEventListener('message', onXaxisMessage);
if (adoptBtn) {
adoptBtn.addEventListener('click', async () => {
if (!_xaxisDirection) return;
const fmt = v => (v >= 0 ? '+' : '') + v.toFixed(4);
logX(`🔄 Übernehme X-Achse: dir=[${_xaxisDirection.map(fmt).join(', ')}] …`);
adoptBtn.disabled = true;
adoptBtn.style.opacity = '.45';
try {
const r = await fetch('/api/robot/adopt-x-axis', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction: _xaxisDirection }),
});
const data = await r.json();
if (!r.ok) {
logX(`❌ Fehler: ${data.error ?? r.status}`);
return;
}
logX(`✅ X-Achse gespeichert — ${data.numChanged} Marker rotiert`);
logX(` Ursprung (A0-Schwerpunkt): [${data.origin.join(', ')}] mm`);
logX(` Neue X-Achse: [${data.newXAxis.join(', ')}]` +
` Korr. XY=${(data.angleXYdeg >= 0 ? '+' : '') + data.angleXYdeg}°` +
` XZ=${(data.angleXZdeg >= 0 ? '+' : '') + data.angleXZdeg}°`);
// Viewer neu laden damit die aktualisierten Positionen sichtbar werden
const frame = document.getElementById('xaxis-viewer-frame');
if (frame?.contentWindow) frame.contentWindow.postMessage({ type: 'reload' }, '*');
} catch (err) {
logX(`❌ Netzwerkfehler: ${err}`);
} finally {
adoptBtn.disabled = false;
adoptBtn.style.opacity = '1';
}
});
}
}
// ── Tabs: Arm1 / Arm2 / Elbow / Hand (generisch) ────────────────────────────
// Alle Gelenk-Tabs teilen dieselbe Init-Logik der Tab-Name (arm1, arm2, …)
// wird als Prefix für Element-IDs und Viewer-Frame-ID genutzt.
function initArm(tab) {
const logEl = document.getElementById(`log-${tab}`);
const frameEl = document.getElementById(`${tab}-viewer-frame`);
const runBtn = document.getElementById(`btn-${tab}-run`);
const lastRunEl = document.getElementById(`${tab}-last-run`);
if (!logEl) return; // Panel noch nicht geladen
function log(msg) {
const ts = new Date().toLocaleTimeString('de-CH');
logEl.value += `[${ts}] ${msg}\n`;
logEl.scrollTop = logEl.scrollHeight;
}
// "Board erkennen"-Button
if (runBtn) {
runBtn.addEventListener('click', async () => {
log('Board-Erkennung …');
runBtn.disabled = true;
try {
const response = await fetch('/api/board/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (!response.ok) {
const raw = await response.text().catch(() => '');
let msg;
try { msg = JSON.parse(raw).error || raw; }
catch { msg = raw.slice(0, 300) || `HTTP ${response.status}`; }
log(`❌ HTTP ${response.status}: ${msg}`);
return;
}
await readSseStream(response, log, (evt) => {
if (evt.exitCode === 0) {
log('✅ Board-Run abgeschlossen.');
if (evt.runDir) {
if (lastRunEl) lastRunEl.textContent = evt.runDir;
if (frameEl?.contentWindow) frameEl.contentWindow.postMessage({ type: 'reload' }, '*');
}
} else {
log(`❌ Beendet mit Exit-Code ${evt.exitCode}`);
}
});
} catch (err) {
log(`❌ Fehler: ${err}`);
} finally {
runBtn.disabled = false;
}
});
}
// ── Kalibrierungs-Aktionen (werden nach Rotation-Messung aktiv) ──────────
// Tab-Name → Link-Name in robot.json
const TAB_TO_LINK = { arm1: 'Arm1', arm2: 'Arm2', elbow: 'Ellbow', hand: 'Hand' };
const robotLink = TAB_TO_LINK[tab] ?? tab;
const calibActionsEl = document.getElementById(`${tab}-calib-actions`);
const assignFixedBtn = document.getElementById(`btn-${tab}-assign-fixed`);
const assignFixedInfo = document.getElementById(`${tab}-assign-fixed-info`);
const setOriginBtn = document.getElementById(`btn-${tab}-set-origin`);
const setOriginInfo = document.getElementById(`${tab}-set-origin-info`);
let _lastYAxisMsg = null; // letztes gültiges yaxis-measurement
function enableCalibActions(msg) {
if (!calibActionsEl) return;
calibActionsEl.style.display = 'block';
// ── Button 1: Fixe Marker → Base ────────────────────────────────────────
if (assignFixedBtn) {
const skipped = msg.skipped ?? [];
if (skipped.length > 0) {
const ids = skipped.map(s => s.id).join(', ');
assignFixedBtn.disabled = false;
assignFixedBtn.style.opacity = '1';
assignFixedBtn.style.cursor = 'pointer';
assignFixedBtn.title =
`Marker ${ids} in robot.json dem Link 'Base' zuordnen`;
if (assignFixedInfo) {
assignFixedInfo.textContent =
`Kaum-bewegende Marker: ${ids} ` +
`(Bewegung < ${msg.skipped.map(s => s.maxMoveMm + ' mm').join(', ')}) ` +
`→ Link 'Base' in robot.json eintragen.`;
}
} else {
assignFixedBtn.disabled = true;
if (assignFixedInfo) assignFixedInfo.textContent = 'Alle erkannten Marker rotieren kein fixer Marker gefunden.';
}
}
// ── Button 2: Joint-Origin Y/Z ───────────────────────────────────────────
if (setOriginBtn) {
const [, ay, az] = msg.axisPoint;
setOriginBtn.disabled = false;
setOriginBtn.style.opacity = '1';
setOriginBtn.style.cursor = 'pointer';
setOriginBtn.title = `Joint '${robotLink}': origin[Y]=${ay.toFixed(1)} mm, origin[Z]=${az.toFixed(1)} mm`;
if (setOriginInfo) {
setOriginInfo.textContent =
`Berechnete Achse: Y = ${ay.toFixed(1)} mm · Z = ${az.toFixed(1)} mm ` +
`→ in robot.json links.${robotLink}.jointToParent.origin setzen.`;
}
}
}
function disableCalibActions() {
if (assignFixedBtn) {
assignFixedBtn.disabled = true;
assignFixedBtn.style.opacity = '.45';
assignFixedBtn.style.cursor = 'not-allowed';
}
if (setOriginBtn) {
setOriginBtn.disabled = true;
setOriginBtn.style.opacity = '.45';
setOriginBtn.style.cursor = 'not-allowed';
}
}
if (assignFixedBtn) {
assignFixedBtn.addEventListener('click', async () => {
if (!_lastYAxisMsg) return;
const skipped = _lastYAxisMsg.skipped ?? [];
const markerIds = skipped.map(s => s.id);
const measuredPositions = skipped
.filter(s => Array.isArray(s.posA))
.map(s => ({ id: s.id, position_mm: s.posA }));
log(`🔄 Ordne Marker [${markerIds.join(', ')}] dem Link 'Base' zu …`);
assignFixedBtn.disabled = true;
try {
const r = await fetch('/api/robot/assign-fixed-markers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ markerIds, targetLink: 'Base', measuredPositions }),
});
const data = await r.json();
if (!r.ok) { log(`❌ Fehler: ${data.error ?? r.status}`); return; }
log(`✅ Zugeordnet: ${data.numAdded} neu, ${data.numAlreadyPresent} bereits vorhanden`);
data.changes.forEach(c => {
if (c.action === 'added') log(` + Marker ${c.markerId}${c.targetLink}`);
else if (c.action === 'already-present') log(` ○ Marker ${c.markerId} bereits in '${c.existingLink}'`);
else if (c.action === 'skipped-no-position') log(` ⚠ Marker ${c.markerId}: keine Positions-Daten`);
});
} catch (err) {
log(`❌ Netzwerkfehler: ${err}`);
} finally {
assignFixedBtn.disabled = false;
}
});
}
if (setOriginBtn) {
setOriginBtn.addEventListener('click', async () => {
if (!_lastYAxisMsg) return;
const [, ay, az] = _lastYAxisMsg.axisPoint;
log(`🔄 Setze ${robotLink}.jointToParent.origin: Y=${ay.toFixed(1)} Z=${az.toFixed(1)}`);
setOriginBtn.disabled = true;
try {
const r = await fetch('/api/robot/set-joint-origin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ linkName: robotLink, y: ay, z: az }),
});
const data = await r.json();
if (!r.ok) { log(`❌ Fehler: ${data.error ?? r.status}`); return; }
log(`✅ Joint-Origin gesetzt: [${data.oldOrigin.join(', ')}] → [${data.newOrigin.join(', ')}]`);
} catch (err) {
log(`❌ Netzwerkfehler: ${err}`);
} finally {
setOriginBtn.disabled = false;
}
});
}
// ── Achsen-Messung vom Viewer empfangen ───────────────────────────────────
window.addEventListener('message', (e) => {
if (!frameEl || e.source !== frameEl.contentWindow) return;
const msg = e.data;
if (msg?.type !== 'yaxis-measurement') return;
if (Array.isArray(msg.axisDir)) {
_lastYAxisMsg = msg;
const fmt = v => (v >= 0 ? '+' : '') + v.toFixed(3) + '°';
log(`📐 Achse (${msg.numMarkers}/${msg.numMarkersCommon} Marker): dir=[${msg.axisDir.map(v => v.toFixed(4)).join(', ')}]` +
` XY=${fmt(msg.tiltXY)} YZ=${fmt(msg.tiltYZ)}`);
log(` Referenzpunkt: [${msg.axisPoint.map(v => v.toFixed(1)).join(', ')}] mm`);
if ((msg.skipped ?? []).length) {
log(` Gefiltert (zu geringe Bewegung): ${msg.skipped.map(s => `${s.id} (${s.maxMoveMm} mm)`).join(', ')}`);
}
enableCalibActions(msg);
// In rotation_detection.json speichern (anhängen)
fetch('/api/xaxis/save-rotation-detection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
axis: { dir: msg.axisDir, referencePoint: msg.axisPoint, tiltXY_deg: msg.tiltXY, tiltYZ_deg: msg.tiltYZ },
runs: { A: msg.runA ?? null, B: msg.runB ?? null, C: msg.runC ?? null },
numMarkers: msg.numMarkers,
markers: msg.markerData ?? [],
}),
}).then(r => r.json())
.then(d => log(`💾 Gespeichert: ${d.file} (${d.total} Messungen)`))
.catch(e => log(`⚠ Speichern fehlgeschlagen: ${e.message}`));
} else {
// Kein gültiges Ergebnis → Buttons deaktivieren
_lastYAxisMsg = null;
disableCalibActions();
}
});
}
// ── Tab: Board (shared helpers) ───────────────────────────────────────────────
/** Befüllt alle Set-Dropdowns aus /api/robot/board-sets */
async function populateBoardSetDropdowns() {
let sets = [];
try {
const r = await fetch('/api/robot/board-sets');
if (r.ok) sets = (await r.json()).sets ?? [];
} catch { /* kein Server / noch keine Sets → leere Dropdowns */ }
// Hilfsfunktion: <select> mit Optionen füllen
function fill(selId, placeholder, selected = '') {
const sel = document.getElementById(selId);
if (!sel) return;
sel.innerHTML = `<option value="">${placeholder}</option>` +
sets.map(s => `<option value="${s}"${s === selected ? ' selected' : ''}>${s}</option>`).join('');
}
fill('board-ref-set', 'alle');
fill('act-align-fixed', ' bleibt ');
fill('act-align-move', ' verschoben ');
}
function initBoard() {
const logBoard = document.getElementById('log-board');
function logB(msg) {
const ts = new Date().toLocaleTimeString('de-CH');
logBoard.value += `[${ts}] ${msg}\n`;
logBoard.scrollTop = logBoard.scrollHeight;
}
// Dropdowns mit Sets aus robot.json befüllen
populateBoardSetDropdowns();
// Tabelle beim ersten Öffnen des Tabs befüllen
loadBoardTable();
document.getElementById('btn-board-run').addEventListener('click', async () => {
const refSet = document.getElementById('board-ref-set')?.value ?? '';
logB(`Board-Erkennung wird gestartet … Referenz: ${refSet || 'alle'}`);
const btn = document.getElementById('btn-board-run');
btn.disabled = true;
try {
const response = await fetch('/api/board/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refSet: refSet || undefined }),
});
if (!response.ok) {
const raw = await response.text().catch(() => '');
let msg;
try { msg = JSON.parse(raw).error || raw; }
catch { msg = raw.slice(0, 300) || `HTTP ${response.status}`; }
logB(`❌ HTTP ${response.status}: ${msg}`);
return;
}
await readSseStream(response, logB, (evt) => {
if (evt.exitCode === 0) {
logB('✅ Board-Run abgeschlossen.');
if (evt.runDir) {
document.getElementById('board-last-run').textContent = evt.runDir;
// Board-Viewer im iframe nach dem Run neu laden
const frame = document.getElementById('board-viewer-frame');
if (frame?.contentWindow) {
frame.contentWindow.postMessage({ type: 'reload' }, '*');
}
// Marker-Tabelle + Set-Dropdowns aktualisieren
loadBoardTable();
populateBoardSetDropdowns();
}
} else {
logB(`❌ Beendet mit Exit-Code ${evt.exitCode}`);
}
});
} catch (err) {
logB(`❌ Fehler: ${err}`);
} finally {
btn.disabled = false;
}
});
// ── Aktion 1: Z-Bereich Zuordnung ───────────────────────────────────────────
document.getElementById('btn-act-assign').addEventListener('click', async () => {
const zMin = document.getElementById('act-z-min').value;
const zMax = document.getElementById('act-z-max').value;
const set = document.getElementById('act-set').value.trim();
const link = document.getElementById('act-link').value.trim();
const result = document.getElementById('act-result');
if (zMin === '' || zMax === '') {
result.innerHTML = '<span style="color:#f87171">⚠ Bitte z-Bereich eingeben.</span>'; return;
}
if (!set && !link) {
result.innerHTML = '<span style="color:#f87171">⚠ Bitte mindestens Set oder Link angeben.</span>'; return;
}
result.innerHTML = '<span style="color:#555b6e">Zuordnung läuft …</span>';
try {
const r = await fetch('/api/robot/assign-by-z', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ zMin: parseFloat(zMin), zMax: parseFloat(zMax), set, link }),
});
const data = await r.json();
if (!r.ok || data.error) {
result.innerHTML = `<span style="color:#f87171">❌ ${data.error ?? `HTTP ${r.status}`}</span>`; return;
}
if (data.numChanged === 0) {
result.innerHTML = '<span style="color:#555b6e">Keine Marker im Z-Bereich gefunden (weder in robot.json noch im letzten 3b-Run).</span>'; return;
}
const added = data.changes.filter(c => c.action === 'added').length;
const moved = data.changes.filter(c => c.action === 'updated' && c.oldLink !== c.newLink).length;
const updated = data.changes.filter(c => c.action === 'updated').length - moved;
const parts = [];
if (added) parts.push(`${added} neu hinzugefügt`);
if (moved) parts.push(`${moved} verschoben`);
if (updated) parts.push(`${updated} aktualisiert`);
result.innerHTML = `<span style="color:#22c55e">✅ ${data.numChanged} Marker: ${parts.join(', ')}.</span>` +
` IDs: ${data.changes.map(c => c.markerId).join(', ')}`;
loadBoardTable();
} catch (err) {
result.innerHTML = `<span style="color:#f87171">❌ ${err}</span>`;
}
});
// ── Aktion 2: Zuordnung entfernen ───────────────────────────────────────────
document.getElementById('btn-act-remove').addEventListener('click', async () => {
const markerId = document.getElementById('act-rm-id').value.trim();
const removeFrom = document.querySelector('input[name="act-rm-from"]:checked')?.value;
const result = document.getElementById('act-result');
if (!markerId) {
result.innerHTML = '<span style="color:#f87171">⚠ Bitte Marker-ID eingeben.</span>'; return;
}
result.innerHTML = '<span style="color:#555b6e">Verarbeite …</span>';
try {
const r = await fetch('/api/robot/remove-marker', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ markerId: parseInt(markerId, 10), removeFrom }),
});
const data = await r.json();
if (!r.ok) {
result.innerHTML = `<span style="color:#f87171">❌ ${data.error ?? `HTTP ${r.status}`}</span>`; return;
}
if (!data.changed) {
result.innerHTML = `<span style="color:#f87171">❌ ${data.error}</span>`; return;
}
const info = data.action === 'set-removed'
? `Set "${data.oldSet}" entfernt (bleibt in Link "${data.link}")`
: `Aus Link "${data.link}" entfernt`;
result.innerHTML = `<span style="color:#22c55e">✅ Marker ${markerId}: ${info}</span>`;
loadBoardTable();
} catch (err) {
result.innerHTML = `<span style="color:#f87171">❌ ${err}</span>`;
}
});
// ── Aktion 3: Sets justieren (Kabsch 2D+Z) ─────────────────────────────────
document.getElementById('btn-act-align').addEventListener('click', async () => {
const setFixed = document.getElementById('act-align-fixed').value;
const setToMove = document.getElementById('act-align-move').value;
const result = document.getElementById('act-result');
if (!setToMove) {
result.innerHTML = '<span style="color:#f87171">⚠ Bitte Set auswählen, das verschoben werden soll.</span>'; return;
}
if (setFixed && setFixed === setToMove) {
result.innerHTML = '<span style="color:#f87171">⚠ "Bleibt" und "verschoben" dürfen nicht dasselbe Set sein.</span>'; return;
}
result.innerHTML = '<span style="color:#555b6e">Justierung läuft …</span>';
try {
const r = await fetch('/api/robot/align-sets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ setFixed: setFixed || undefined, setToMove }),
});
const data = await r.json();
if (!r.ok || data.error) {
result.innerHTML = `<span style="color:#f87171">❌ ${data.error ?? `HTTP ${r.status}`}</span>`; return;
}
const t = data.transform;
result.innerHTML =
`<span style="color:#22c55e">✅ Set "${setToMove}": ${data.numChanged} Marker verschoben` +
` (${data.numMatchingPts} Messpunkte)${setFixed ? ` "${setFixed}" bleibt` : ''}</span>` +
` &ensp;Δx=${t.tx}&thinsp;mm&ensp;Δy=${t.ty}&thinsp;mm&ensp;Δz=${t.tz}&thinsp;mm` +
`&ensp;θ=${t.thetaDeg}°`;
loadBoardTable();
} catch (err) {
result.innerHTML = `<span style="color:#f87171">❌ ${err}</span>`;
}
});
// ── Aktion 4: Zuordnung hinzufügen ─────────────────────────────────────────
document.getElementById('btn-act-add').addEventListener('click', async () => {
const markerId = document.getElementById('act-add-id').value.trim();
const set = document.getElementById('act-add-set').value.trim();
const link = document.getElementById('act-add-link').value.trim();
const result = document.getElementById('act-result');
if (!markerId) {
result.innerHTML = '<span style="color:#f87171">⚠ Bitte Marker-ID eingeben.</span>'; return;
}
if (!set && !link) {
result.innerHTML = '<span style="color:#f87171">⚠ Bitte mindestens Set oder Link angeben.</span>'; return;
}
result.innerHTML = '<span style="color:#555b6e">Hinzufügen …</span>';
try {
const r = await fetch('/api/robot/assign-id', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ markerId: parseInt(markerId, 10), set, link }),
});
const data = await r.json();
if (!r.ok) {
result.innerHTML = `<span style="color:#f87171">❌ ${data.error ?? `HTTP ${r.status}`}</span>`; return;
}
if (!data.changed) {
result.innerHTML = `<span style="color:#f87171">❌ ${data.error}</span>`; return;
}
const c = data.change;
const info = c.action === 'added'
? `neu in Link "${c.newLink}"${c.newSet ? ` Set "${c.newSet}"` : ''}`
: `aktualisiert → Link "${c.newLink}"${c.newSet ? ` Set "${c.newSet}"` : ''}`;
result.innerHTML = `<span style="color:#22c55e">✅ Marker ${markerId}: ${info}</span>`;
loadBoardTable();
} catch (err) {
result.innerHTML = `<span style="color:#f87171">❌ ${err}</span>`;
}
});
}
// ── Tab: Marker ───────────────────────────────────────────────────────────────
function initMarker() {
const logEl = document.getElementById('log-marker');
const tableWrap = document.getElementById('marker-table-wrap');
const linkSel = document.getElementById('marker-action-link');
const idSel = document.getElementById('marker-action-id');
const spinLabel = document.getElementById('marker-spin-current');
const resultEl = document.getElementById('marker-action-result');
const frameEl = document.getElementById('marker-viewer-frame');
const ARM_LINKS = ['Arm1', 'Ellbow', 'Arm2', 'Hand', 'Palm', 'FingerA', 'FingerB'];
let _robot = null;
function logM(msg) {
const ts = new Date().toLocaleTimeString('de-CH');
logEl.value += `[${ts}] ${msg}\n`;
logEl.scrollTop = logEl.scrollHeight;
}
// ── Marker-Tabelle rendern ────────────────────────────────────────────────
function renderTable(robot) {
if (!tableWrap) return;
const links = robot?.links ?? {};
const th = (a) => `style="text-align:${a};padding:3px 8px;border-bottom:1px solid #2a2d35;white-space:nowrap;background:#1e293b;color:#555b6e;font-weight:normal"`;
const td = (a, x = '') => `style="padding:2px 8px;border-bottom:1px solid #111418;text-align:${a};white-space:nowrap;${x}"`;
let rows = '';
let total = 0;
for (const linkName of ARM_LINKS) {
const markers = links[linkName]?.markers ?? [];
for (const m of markers) {
total++;
const pos = m.position ? m.position.map(v => Number(v).toFixed(1)).join(', ') : '';
const norm = m.normal ? m.normal.map(v => Number(v).toFixed(2)).join(', ') : '';
rows += `<tr>
<td ${td('left', 'color:#4a9eff')}>${linkName}</td>
<td ${td('right')}>${m.id}</td>
<td ${td('left', 'color:#888')}>${m.name ?? ''}</td>
<td ${td('right')}>${pos}</td>
<td ${td('right', 'color:#aaa')}>${norm}</td>
<td ${td('right')}>${m.size ?? ''}</td>
<td ${td('right', 'color:#f0a500;font-weight:bold')}>${m.spin ?? 0}°</td>
</tr>`;
}
}
if (total === 0) {
tableWrap.innerHTML = '<p style="font-size:12px;color:var(--muted)">Keine Arm-Marker in robot.json eingetragen.</p>';
return;
}
tableWrap.innerHTML = `
<p style="font-size:10px;color:#555b6e;margin-bottom:4px">${total} Marker in Arm-Links</p>
<table style="border-collapse:collapse;font-size:11px;font-family:inherit;width:100%">
<thead><tr>
<th ${th('left')}>Link</th>
<th ${th('right')}>ID</th>
<th ${th('left')}>Name</th>
<th ${th('right')}>Position [x,y,z] mm</th>
<th ${th('right')}>Normal [nx,ny,nz]</th>
<th ${th('right')}>Size mm</th>
<th ${th('right')}>Spin</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
// ── Marker-Dropdown für gewählten Link befüllen ───────────────────────────
function updateMarkerDropdown() {
if (!idSel || !_robot) return;
const linkName = linkSel?.value;
const markers = _robot.links?.[linkName]?.markers ?? [];
const prev = idSel.value;
idSel.innerHTML = '<option value=""> wählen </option>' +
markers.map(m => `<option value="${m.id}">${m.id}${m.name ? ' ' + m.name : ''}</option>`).join('');
if (markers.some(m => String(m.id) === prev)) idSel.value = prev;
updateSpinLabel();
}
function updateSpinLabel() {
if (!spinLabel || !_robot) { if (spinLabel) spinLabel.textContent = ''; return; }
const linkName = linkSel?.value;
const markerId = idSel?.value;
if (!markerId) { spinLabel.textContent = ''; return; }
const markers = _robot.links?.[linkName]?.markers ?? [];
const m = markers.find(mm => String(mm.id) === String(markerId));
spinLabel.textContent = m ? `Aktuell: spin = ${m.spin ?? 0}°` : '';
}
// ── Robot laden ───────────────────────────────────────────────────────────
async function loadRobot() {
try {
const r = await fetch('/api/robot');
if (!r.ok) throw new Error(`HTTP ${r.status}`);
_robot = await r.json();
renderTable(_robot);
updateMarkerDropdown();
} catch (err) {
if (tableWrap) tableWrap.innerHTML = `<p style="color:#f87171;font-size:12px">Fehler: ${err}</p>`;
logM(`❌ robot.json konnte nicht geladen werden: ${err}`);
}
}
// ── Spin-Aktion ausführen ─────────────────────────────────────────────────
async function applySpin(delta) {
if (!resultEl) return;
const linkName = linkSel?.value;
const markerId = idSel?.value;
if (!markerId) {
resultEl.innerHTML = '<span style="color:#f87171">⚠ Bitte zuerst einen Marker wählen.</span>';
return;
}
const markers = _robot?.links?.[linkName]?.markers ?? [];
const current = markers.find(m => String(m.id) === String(markerId));
const oldSpin = current?.spin ?? 0;
const newSpin = ((oldSpin + delta) % 360 + 360) % 360;
resultEl.innerHTML = '<span style="color:#555b6e">Speichern …</span>';
try {
const r = await fetch('/api/robot/set-arm-marker-spin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ linkName, markerId: Number(markerId), spin: newSpin }),
});
const data = await r.json();
if (!r.ok || !data.changed) {
resultEl.innerHTML = `<span style="color:#f87171">❌ ${data.error ?? `HTTP ${r.status}`}</span>`;
return;
}
resultEl.innerHTML =
`<span style="color:#22c55e">✅ ${linkName} #${markerId}: spin ${data.oldSpin}° → ${data.newSpin}°</span>`;
logM(`Spin ${linkName}#${markerId}: ${data.oldSpin}° → ${data.newSpin}°`);
// Lokales Modell aktualisieren
if (current) current.spin = data.newSpin;
updateSpinLabel();
renderTable(_robot);
// Viewer neu laden
if (frameEl?.contentWindow) frameEl.contentWindow.postMessage({ type: 'reload' }, '*');
} catch (err) {
resultEl.innerHTML = `<span style="color:#f87171">❌ ${err}</span>`;
}
}
// ── Homing-Run starten ───────────────────────────────────────────────────
async function runMarkerHoming() {
const btn = document.getElementById('btn-marker-homing');
const statusEl = document.getElementById('marker-homing-status');
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = '● Läuft …';
logM('▶ Homing gestartet …');
try {
const response = await fetch('/api/homing/run', { method: 'POST' });
if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
let evt;
try { evt = JSON.parse(line.slice(6)); } catch { continue; }
if (evt.type === 'log') { logM(evt.text ?? ''); }
if (evt.type === 'step') { logM(`[${evt.step}/${evt.total}] ${evt.text ?? ''}`); }
if (evt.type === 'analysis' && evt.key?.startsWith('state_') && evt.value) {
frameEl?.contentWindow?.postMessage({ type: 'homing-state', state: evt.value }, '*');
}
if (evt.type === 'error') {
logM(`${evt.text ?? ''}`);
if (statusEl) statusEl.textContent = '✗ Fehler';
}
if (evt.type === 'done') {
if (evt.state) {
if (statusEl) statusEl.textContent = '✓ Fertig';
logM('✅ Homing abgeschlossen');
} else {
if (statusEl) statusEl.textContent = '✗ Fehler';
}
if (evt.runDir && frameEl?.contentWindow) {
frameEl.contentWindow.postMessage({ type: 'load-homing-run', runDir: evt.runDir }, '*');
if (evt.state) {
frameEl.contentWindow.postMessage({ type: 'homing-state', state: evt.state }, '*');
}
}
}
}
}
} catch (err) {
logM(`${err}`);
if (statusEl) statusEl.textContent = '✗ Fehler';
} finally {
if (btn) btn.disabled = false;
}
}
// ── Event-Listener ────────────────────────────────────────────────────────
document.getElementById('btn-marker-reload')?.addEventListener('click', () => loadRobot());
document.getElementById('btn-marker-homing')?.addEventListener('click', runMarkerHoming);
linkSel?.addEventListener('change', () => updateMarkerDropdown());
idSel?.addEventListener('change', () => updateSpinLabel());
document.getElementById('btn-spin-minus90')?.addEventListener('click', () => applySpin(-90));
document.getElementById('btn-spin-plus90')?.addEventListener('click', () => applySpin(+90));
document.getElementById('btn-spin-180')?.addEventListener('click', () => applySpin(+180));
// Init
loadRobot();
}