HTML split

This commit is contained in:
chk
2026-06-10 14:22:52 +02:00
parent 814e1cd90c
commit 86bee8810b
8 changed files with 1568 additions and 595 deletions

173
public/calibration.css Normal file
View File

@@ -0,0 +1,173 @@
/* ===== LAYOUT ===== */
body {
display: flex;
flex-direction: column;
height: 100vh;
margin: 0;
padding: 0;
overflow: hidden;
}
/* ===== HEADER (gleicher Stil wie .section auf index.html) ===== */
.calib-topbar {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 20px;
background: var(--panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.calib-topbar a {
color: var(--muted);
text-decoration: none;
font-size: 13px;
border: 1px solid #334155;
border-radius: 6px;
padding: 6px 14px;
transition: border-color 0.2s, color 0.2s;
}
.calib-topbar a:hover {
border-color: var(--accent);
color: var(--accent);
}
.calib-topbar h1 {
margin: 0;
font-size: 15px;
font-weight: 500;
color: var(--accent);
}
/* ===== BODY: SIDEBAR + CONTENT ===== */
.calib-body {
display: flex;
flex: 1;
overflow: hidden;
padding: 16px;
gap: 0;
}
/* ===== LESEZEICHEN-TABS LINKS ===== */
.tab-sidebar {
display: flex;
flex-direction: column;
gap: 3px;
padding-top: 4px;
flex-shrink: 0;
width: 148px;
}
.tab-btn {
background: #0e1a2b;
border: 1px solid #1f2937;
border-right: none;
border-radius: 6px 0 0 6px;
padding: 11px 16px;
color: var(--muted);
font-size: 13px;
text-align: left;
cursor: pointer;
transition: color 0.15s, background 0.15s, border-color 0.15s;
white-space: nowrap;
}
.tab-btn:hover {
color: var(--text);
background: #132c44;
border-color: #334155;
}
.tab-btn.active {
color: var(--accent);
background: var(--panel); /* gleiche Farbe wie Content-Area */
border-color: #334155;
border-right: none;
border-left: 3px solid var(--accent);
padding-left: 13px; /* kompensiert den dickeren linken Rand */
z-index: 1;
}
/* ===== CONTENT AREA ===== */
.tab-content {
flex: 1;
background: var(--panel);
border: 1px solid #334155;
border-radius: 0 8px 8px 8px;
overflow-y: auto;
padding: 20px 24px;
}
/* ===== TAB PANELS ===== */
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* ===== SECTION OVERRIDE: kein äusserer Rand in der Content-Area ===== */
.tab-content .section {
background: #0e1a2b;
}
/* ===== PLACEHOLDER NOTE ===== */
.placeholder-note {
margin-top: 14px;
padding: 14px 18px;
background: #0b1220;
border: 1px dashed #334155;
border-radius: 8px;
color: var(--muted);
font-size: 13px;
line-height: 1.7;
}
/* ===== STATUS BADGE ===== */
.status-badge {
display: inline-block;
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
background: #1e293b;
color: var(--muted);
margin-left: 8px;
vertical-align: middle;
font-weight: normal;
}
.status-badge.open { color: #f59e0b; }
.status-badge.done { color: #34d399; background: #064e3b; }
.status-badge.wip { color: #60a5fa; }
/* ===== INFO GRID ===== */
.info-grid {
display: grid;
grid-template-columns: 160px 1fr;
gap: 6px 12px;
margin-top: 14px;
font-size: 13px;
}
.info-label {
color: var(--muted);
}
.info-value {
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* Buttons: aktiv vs. deaktiviert visuell unterscheiden */
.controls button:disabled {
opacity: 0.35;
cursor: not-allowed;
}

View File

@@ -5,182 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kalibrierung appRobotHoming</title> <title>Kalibrierung appRobotHoming</title>
<link rel="stylesheet" href="/styles.css"> <link rel="stylesheet" href="/styles.css">
<style> <link rel="stylesheet" href="/calibration.css">
/* ===== LAYOUT ===== */
body {
display: flex;
flex-direction: column;
height: 100vh;
margin: 0;
padding: 0;
overflow: hidden;
}
/* ===== HEADER (gleicher Stil wie .section auf index.html) ===== */
.calib-topbar {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 20px;
background: var(--panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.calib-topbar a {
color: var(--muted);
text-decoration: none;
font-size: 13px;
border: 1px solid #334155;
border-radius: 6px;
padding: 6px 14px;
transition: border-color 0.2s, color 0.2s;
}
.calib-topbar a:hover {
border-color: var(--accent);
color: var(--accent);
}
.calib-topbar h1 {
margin: 0;
font-size: 15px;
font-weight: 500;
color: var(--accent);
}
/* ===== BODY: SIDEBAR + CONTENT ===== */
.calib-body {
display: flex;
flex: 1;
overflow: hidden;
padding: 16px;
gap: 0;
}
/* ===== LESEZEICHEN-TABS LINKS ===== */
.tab-sidebar {
display: flex;
flex-direction: column;
gap: 3px;
padding-top: 4px;
flex-shrink: 0;
width: 148px;
}
.tab-btn {
background: #0e1a2b;
border: 1px solid #1f2937;
border-right: none;
border-radius: 6px 0 0 6px;
padding: 11px 16px;
color: var(--muted);
font-size: 13px;
text-align: left;
cursor: pointer;
transition: color 0.15s, background 0.15s, border-color 0.15s;
white-space: nowrap;
}
.tab-btn:hover {
color: var(--text);
background: #132c44;
border-color: #334155;
}
.tab-btn.active {
color: var(--accent);
background: var(--panel); /* gleiche Farbe wie Content-Area */
border-color: #334155;
border-right: none;
border-left: 3px solid var(--accent);
padding-left: 13px; /* kompensiert den dickeren linken Rand */
z-index: 1;
}
/* ===== CONTENT AREA ===== */
.tab-content {
flex: 1;
background: var(--panel);
border: 1px solid #334155;
border-radius: 0 8px 8px 8px;
overflow-y: auto;
padding: 20px 24px;
}
/* ===== TAB PANELS ===== */
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* ===== SECTION OVERRIDE: kein äusserer Rand in der Content-Area ===== */
.tab-content .section {
background: #0e1a2b;
}
/* ===== PLACEHOLDER NOTE ===== */
.placeholder-note {
margin-top: 14px;
padding: 14px 18px;
background: #0b1220;
border: 1px dashed #334155;
border-radius: 8px;
color: var(--muted);
font-size: 13px;
line-height: 1.7;
}
/* ===== STATUS BADGE ===== */
.status-badge {
display: inline-block;
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
background: #1e293b;
color: var(--muted);
margin-left: 8px;
vertical-align: middle;
font-weight: normal;
}
.status-badge.open { color: #f59e0b; }
.status-badge.done { color: #34d399; background: #064e3b; }
.status-badge.wip { color: #60a5fa; }
/* ===== INFO GRID ===== */
.info-grid {
display: grid;
grid-template-columns: 160px 1fr;
gap: 6px 12px;
margin-top: 14px;
font-size: 13px;
}
.info-label {
color: var(--muted);
}
.info-value {
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* Buttons: aktiv vs. deaktiviert visuell unterscheiden */
.controls button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
</style>
</head> </head>
<body> <body>
@@ -195,430 +20,22 @@
<!-- LESEZEICHEN-TABS --> <!-- LESEZEICHEN-TABS -->
<nav class="tab-sidebar" id="tabSidebar"> <nav class="tab-sidebar" id="tabSidebar">
<button class="tab-btn active" data-tab="camera-npz">Camera NPZ</button> <button class="tab-btn active" data-tab="camera-npz" data-src="/calibration_camera.html">Camera NPZ</button>
<button class="tab-btn" data-tab="board">Board</button> <button class="tab-btn" data-tab="board" data-src="/calibration_board.html">Board</button>
<button class="tab-btn" data-tab="robot-x-axis">Robot X Axis</button> <button class="tab-btn" data-tab="robot-x-axis" data-src="/calibration_xaxis.html">Robot X Axis</button>
<button class="tab-btn" data-tab="arm">Arm1 / Arm2</button> <button class="tab-btn" data-tab="arm" data-src="/calibration_arm.html">Arm1 / Arm2</button>
</nav> </nav>
<!-- CONTENT --> <!-- CONTENT (Panels werden lazy per fetch befüllt) -->
<div class="tab-content"> <div class="tab-content">
<div class="tab-panel active" id="tab-camera-npz"></div>
<div class="tab-panel" id="tab-board"></div>
<div class="tab-panel" id="tab-robot-x-axis"></div>
<div class="tab-panel" id="tab-arm"></div>
</div>
<!-- PANEL: Camera NPZ -->
<div class="tab-panel active" id="tab-camera-npz">
<div class="sections">
<!-- Info-Box: Aktuelle Kalibrierung -->
<div class="section full">
<h2>Aktuelle Kalibrierung</h2>
<div id="calib-info" class="info-grid">
<span class="info-label">Timestamp</span>
<span class="info-value" id="info-timestamp"></span>
<span class="info-label">Erstellt am</span>
<span class="info-value" id="info-created"></span>
<span class="info-label">Bilder / Kameras</span>
<span class="info-value" id="info-images"></span>
</div>
</div>
<!-- Aktionen -->
<div class="section full">
<h2>Aktionen</h2>
<div class="controls" style="margin-top: 14px;">
<button id="btn-new-calib">Neue Kalibrierung anlegen</button>
<button id="btn-foto-calib">Foto aufnehmen</button>
<select id="cam-select-calib" title="Kamera für Kalibrierung wählen">
<option value=""> Kamera </option>
</select>
<button id="btn-compute-calib">Kalibrierung berechnen</button>
<button id="btn-upload-npz">NPZ speichern</button>
</div>
</div>
<!-- Ausgabe -->
<div class="section full">
<h2>Ausgabe / Log</h2>
<textarea id="log-camera" readonly placeholder="(Ausgabe erscheint hier)"></textarea>
</div>
</div>
</div>
<!-- PANEL: Board -->
<div class="tab-panel" id="tab-board">
<div class="sections">
<div class="section full">
<h2>Board ArUco &amp; Kamera-Pose</h2>
<div class="info-grid" style="margin-top: 14px;">
<span class="info-label">Ablauf</span>
<span class="info-value" style="font-family: inherit; font-size: 13px; color: var(--muted);">
Foto aufnehmen → ArUco erkennen → Kamera-Pose schätzen
</span>
<span class="info-label">Schritte</span>
<span class="info-value" style="font-family: inherit; font-size: 13px; color: var(--muted);">
1_detect_aruco_observations &nbsp;&nbsp; 2_estimate_camera_from_observations
</span>
<span class="info-label">Letzter Run</span>
<span class="info-value" id="board-last-run"></span>
</div>
<div class="controls" style="margin-top: 16px;">
<button id="btn-board-run">Board erkennen</button>
<button disabled title="Folgt später">Ergebnis anzeigen</button>
</div>
</div>
<div class="section full">
<h2>Ausgabe / Log</h2>
<textarea id="log-board" readonly placeholder="(Ausgabe erscheint hier)"></textarea>
</div>
</div>
</div>
<!-- PANEL: Robot X Axis -->
<div class="tab-panel" id="tab-robot-x-axis">
<div class="sections">
<div class="section full">
<h2>Robot X Axis <span class="status-badge open">offen</span></h2>
<div class="placeholder-note">
Ziel: X-Achse des Roboters im Weltkoordinatensystem verorten (Richtungsvektor und
Nullpunkt). Roboter fährt zwei bekannte Positionen an, Kamera beobachtet den
Endeffektor-Marker.<br><br>
Geplante Aktionen: Referenzposition 1 anfahren · Foto · Marker merken ·
Referenzposition 2 anfahren · Foto · Achsvektor berechnen · Speichern.<br><br>
<em>Aktionen werden ergänzt sobald das Konzept feststeht.</em>
</div>
<div class="controls" style="margin-top: 14px;">
<button disabled>Pos 1 anfahren</button>
<button disabled>Foto Pos 1</button>
<button disabled>Pos 2 anfahren</button>
<button disabled>Foto Pos 2</button>
<button disabled>Achse berechnen</button>
<button disabled>Speichern</button>
</div>
</div>
<div class="section full">
<h2>Ausgabe / Log</h2>
<textarea id="log-xaxis" readonly placeholder="(Ausgabe erscheint hier)"></textarea>
</div>
</div>
</div>
<!-- PANEL: Arm1 / Arm2 -->
<div class="tab-panel" id="tab-arm">
<div class="sections">
<div class="section full">
<h2>Arm1 / Arm2 <span class="status-badge open">offen</span></h2>
<div class="placeholder-note">
Ziel: Nullposition und Kinematikparameter von Arm1 und Arm2 einmessen.
Arm fährt in mechanische Nullposition, Kamera prüft die tatsächliche Pose,
Offset wird berechnet und gespeichert.<br><br>
Geplante Aktionen: Arm1 → Nullpos → Foto → Winkel · Arm2 → Nullpos → Foto →
Winkel · Offset-Korrektur speichern.<br><br>
<em>Aktionen werden ergänzt sobald das Konzept feststeht.</em>
</div>
<div class="controls" style="margin-top: 14px;">
<button disabled>Arm1 → Nullpos</button>
<button disabled>Foto Arm1</button>
<button disabled>Arm2 → Nullpos</button>
<button disabled>Foto Arm2</button>
<button disabled>Offsets berechnen</button>
<button disabled>Speichern</button>
</div>
</div>
<div class="section full">
<h2>Ausgabe / Log</h2>
<textarea id="log-arm" readonly placeholder="(Ausgabe erscheint hier)"></textarea>
</div>
</div>
</div>
</div><!-- /.tab-content -->
</div><!-- /.calib-body --> </div><!-- /.calib-body -->
<script> <script src="/calibration.js"></script>
// ── 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');
});
// ── Section collapse ───────────────────────────────────────────────────────
document.querySelectorAll('.section h2').forEach(h2 => {
h2.addEventListener('click', () => h2.closest('.section').classList.toggle('collapsed'));
});
// ── Camera NPZ ─────────────────────────────────────────────────────────────
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 = '';
// Selector leeren
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;
// Kamera-Selector aktualisieren
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);
}
// Falls nur eine Kamera vorhanden automatisch vorwählen
if (cameras.length === 1) sel.value = cameras[0];
}
// Beim Laden aktuelle Session holen
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}`);
}
}
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}`);
}
});
// Hilfsfunktion: fetch-Response sicher als Text lesen und in lesbaren Fehler umwandeln
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) }; }
}
// "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}`);
}
});
// "Kalibrierung berechnen" SSE-Stream lesen
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;
}
// SSE-Stream zeilenweise verarbeiten
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 });
// Vollständige SSE-Ereignisse (getrennt durch \n\n) extrahieren
const parts = buffer.split('\n\n');
buffer = parts.pop(); // letztes unvollständiges 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 !== '') logC(evt.text);
} else if (evt.type === 'done') {
logC(evt.exitCode === 0
? '✅ Kalibrierung abgeschlossen.'
: `❌ Script beendet mit Exit-Code ${evt.exitCode}`);
}
} catch { /* ungültiges JSON überspringen */ }
}
}
}
} catch (err) {
logC(`Fehler: ${err}`);
} finally {
btn.disabled = false;
}
});
// ── Board ──────────────────────────────────────────────────────────────────
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;
}
// SSE-Stream lesen (gleiche Logik wie compute)
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();
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 { /* ignore */ }
}
}
}
}
document.getElementById('btn-board-run').addEventListener('click', async () => {
logB('Board-Erkennung wird gestartet …');
const btn = document.getElementById('btn-board-run');
btn.disabled = true;
try {
const response = await fetch('/api/board/run', { method: 'POST' });
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;
}
} else {
logB(`❌ Beendet mit Exit-Code ${evt.exitCode}`);
}
});
} catch (err) {
logB(`❌ Fehler: ${err}`);
} finally {
btn.disabled = false;
}
});
</script>
</body> </body>
</html> </html>

264
public/calibration.js Normal file
View File

@@ -0,0 +1,264 @@
/* 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();
} 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 ─────────────────────────────────────────────────────────────────────
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;
}
document.getElementById('btn-board-run').addEventListener('click', async () => {
logB('Board-Erkennung wird gestartet …');
const btn = document.getElementById('btn-board-run');
btn.disabled = true;
try {
const response = await fetch('/api/board/run', { method: 'POST' });
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;
} else {
logB(`❌ Beendet mit Exit-Code ${evt.exitCode}`);
}
});
} catch (err) {
logB(`❌ Fehler: ${err}`);
} finally {
btn.disabled = false;
}
});
}

View File

@@ -0,0 +1,28 @@
<div class="sections">
<div class="section full">
<h2>Arm1 / Arm2 <span class="status-badge open">offen</span></h2>
<div class="placeholder-note">
Ziel: Nullposition und Kinematikparameter von Arm1 und Arm2 einmessen.
Arm fährt in mechanische Nullposition, Kamera prüft die tatsächliche Pose,
Offset wird berechnet und gespeichert.<br><br>
Geplante Aktionen: Arm1 → Nullpos → Foto → Winkel · Arm2 → Nullpos → Foto →
Winkel · Offset-Korrektur speichern.<br><br>
<em>Aktionen werden ergänzt sobald das Konzept feststeht.</em>
</div>
<div class="controls" style="margin-top: 14px;">
<button disabled>Arm1 → Nullpos</button>
<button disabled>Foto Arm1</button>
<button disabled>Arm2 → Nullpos</button>
<button disabled>Foto Arm2</button>
<button disabled>Offsets berechnen</button>
<button disabled>Speichern</button>
</div>
</div>
<div class="section full">
<h2>Ausgabe / Log</h2>
<textarea id="log-arm" readonly placeholder="(Ausgabe erscheint hier)"></textarea>
</div>
</div>

View File

@@ -0,0 +1,28 @@
<div class="sections">
<div class="section full">
<h2>Board ArUco &amp; Kamera-Pose</h2>
<div class="info-grid" style="margin-top: 14px;">
<span class="info-label">Ablauf</span>
<span class="info-value" style="font-family: inherit; font-size: 13px; color: var(--muted);">
Foto aufnehmen → ArUco erkennen → Kamera-Pose schätzen
</span>
<span class="info-label">Schritte</span>
<span class="info-value" style="font-family: inherit; font-size: 13px; color: var(--muted);">
1_detect_aruco_observations &nbsp;&nbsp; 2_estimate_camera_from_observations
</span>
<span class="info-label">Letzter Run</span>
<span class="info-value" id="board-last-run"></span>
</div>
<div class="controls" style="margin-top: 16px;">
<button id="btn-board-run">Board erkennen</button>
<button disabled title="Folgt später">Ergebnis anzeigen</button>
</div>
</div>
<div class="section full">
<h2>Ausgabe / Log</h2>
<textarea id="log-board" readonly placeholder="(Ausgabe erscheint hier)"></textarea>
</div>
</div>

View File

@@ -0,0 +1,40 @@
<div class="sections">
<!-- Info-Box: Aktuelle Kalibrierung -->
<div class="section full">
<h2>Aktuelle Kalibrierung</h2>
<div id="calib-info" class="info-grid">
<span class="info-label">Timestamp</span>
<span class="info-value" id="info-timestamp"></span>
<span class="info-label">Erstellt am</span>
<span class="info-value" id="info-created"></span>
<span class="info-label">Bilder / Kameras</span>
<span class="info-value" id="info-images"></span>
</div>
</div>
<!-- Aktionen -->
<div class="section full">
<h2>Aktionen</h2>
<div class="controls" style="margin-top: 14px;">
<button id="btn-new-calib">Neue Kalibrierung anlegen</button>
<button id="btn-foto-calib">Foto aufnehmen</button>
<select id="cam-select-calib" title="Kamera für Kalibrierung wählen">
<option value=""> Kamera </option>
</select>
<button id="btn-compute-calib">Kalibrierung berechnen</button>
<button id="btn-upload-npz">NPZ speichern</button>
</div>
</div>
<!-- Ausgabe -->
<div class="section full">
<h2>Ausgabe / Log</h2>
<textarea id="log-camera" readonly placeholder="(Ausgabe erscheint hier)"></textarea>
</div>
</div>

View File

@@ -0,0 +1,28 @@
<div class="sections">
<div class="section full">
<h2>Robot X Axis <span class="status-badge open">offen</span></h2>
<div class="placeholder-note">
Ziel: X-Achse des Roboters im Weltkoordinatensystem verorten (Richtungsvektor und
Nullpunkt). Roboter fährt zwei bekannte Positionen an, Kamera beobachtet den
Endeffektor-Marker.<br><br>
Geplante Aktionen: Referenzposition 1 anfahren · Foto · Marker merken ·
Referenzposition 2 anfahren · Foto · Achsvektor berechnen · Speichern.<br><br>
<em>Aktionen werden ergänzt sobald das Konzept feststeht.</em>
</div>
<div class="controls" style="margin-top: 14px;">
<button disabled>Pos 1 anfahren</button>
<button disabled>Foto Pos 1</button>
<button disabled>Pos 2 anfahren</button>
<button disabled>Foto Pos 2</button>
<button disabled>Achse berechnen</button>
<button disabled>Speichern</button>
</div>
</div>
<div class="section full">
<h2>Ausgabe / Log</h2>
<textarea id="log-xaxis" readonly placeholder="(Ausgabe erscheint hier)"></textarea>
</div>
</div>

995
public/sceneViewer.html Normal file
View File

@@ -0,0 +1,995 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Robot FK Viewer</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0f13;
--panel: #161920;
--border: #2a2d35;
--text: #c8cdd8;
--accent: #4a9eff;
--muted: #555b6e;
--ok: #3ecf6b;
--warn: #f59e0b;
--err: #ff4f4f;
--panel-w: 300px;
}
body {
background: var(--bg);
color: var(--text);
font: 13px/1.5 'IBM Plex Mono', 'Cascadia Code', 'Courier New', monospace;
display: flex;
height: 100vh;
overflow: hidden;
}
/* ── sidebar ── */
#sidebar {
width: var(--panel-w);
flex-shrink: 0;
background: var(--panel);
border-right: 1px solid var(--border);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0;
}
.section {
border-bottom: 1px solid var(--border);
padding: 12px 14px;
}
.section h3 {
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 8px;
}
/* file inputs */
.file-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.file-row label {
font-size: 11px;
color: var(--muted);
width: 58px;
flex-shrink: 0;
}
.file-row input[type=file] {
flex: 1;
font-size: 10px;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
padding: 3px 6px;
cursor: pointer;
}
.file-row input[type=file]::file-selector-button {
background: var(--border);
color: var(--text);
border: none;
padding: 2px 8px;
cursor: pointer;
font-family: inherit;
font-size: 10px;
border-radius: 2px;
margin-right: 6px;
}
/* joint sliders */
.slider-row {
display: grid;
grid-template-columns: 22px 1fr 48px;
align-items: center;
gap: 6px;
margin-bottom: 5px;
}
.slider-row span { color: var(--accent); font-size: 12px; }
input[type=range] {
-webkit-appearance: none;
height: 3px;
background: var(--border);
border-radius: 2px;
outline: none;
cursor: pointer;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px; height: 12px;
background: var(--accent);
border-radius: 50%;
}
.val-box {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
padding: 2px 4px;
font-size: 11px;
text-align: right;
color: var(--text);
width: 48px;
}
/* toggles */
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 5px;
}
.toggle-label { font-size: 11px; color: var(--text); }
.toggle {
position: relative; width: 36px; height: 18px;
}
.toggle input { display: none; }
.slider-track {
position: absolute; inset: 0;
background: var(--border);
border-radius: 9px;
cursor: pointer;
transition: background .2s;
}
.slider-track::after {
content: '';
position: absolute;
left: 2px; top: 2px;
width: 14px; height: 14px;
background: var(--muted);
border-radius: 50%;
transition: transform .2s, background .2s;
}
.toggle input:checked + .slider-track { background: var(--accent); }
.toggle input:checked + .slider-track::after {
transform: translateX(18px);
background: #fff;
}
/* stats */
#stats-content {
font-size: 11px;
line-height: 1.8;
}
.stat-line { display: flex; justify-content: space-between; }
.stat-val { color: var(--accent); }
.stat-ok { color: var(--ok); }
.stat-warn { color: var(--warn); }
.stat-err { color: var(--err); }
#status-bar {
font-size: 10px;
color: var(--muted);
padding: 8px 14px;
border-top: 1px solid var(--border);
margin-top: auto;
}
/* canvas */
#canvas-wrap {
flex: 1;
position: relative;
overflow: hidden;
}
canvas { display: block; width: 100%; height: 100%; }
</style>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.162.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.162.0/examples/jsm/"
}
}
</script>
</head>
<body>
<div id="sidebar">
<!-- files -->
<div class="section">
<h3>Data Files</h3>
<div class="file-row">
<label>robot.json</label>
<input type="file" id="fRobot" accept=".json">
</div>
<div class="file-row">
<label>aruco</label>
<input type="file" id="fAruco" accept=".json">
</div>
<div class="file-row">
<label>solution</label>
<input type="file" id="fSolution" accept=".json">
</div>
<button id="btnJump" disabled style="width:100%;margin-top:8px;background:var(--accent);
color:#06101a;border:none;border-radius:3px;padding:6px;font:inherit;font-size:11px;
font-weight:bold;cursor:pointer;opacity:0.45;">Auf Lösung springen</button>
<div id="solInfo" style="font-size:10px;color:var(--muted);min-height:12px;margin-top:5px;"></div>
</div>
<!-- test poses -->
<div class="section" id="poseSection" style="display:none">
<h3>Test Poses</h3>
<select id="poseSelect" style="
width:100%; background:var(--bg); border:1px solid var(--border);
color:var(--text); font:inherit; font-size:11px; padding:4px 6px;
border-radius:3px; cursor:pointer; margin-bottom:4px;">
<option value="">— select pose —</option>
</select>
<div id="poseInfo" style="font-size:10px;color:var(--muted);min-height:14px;"></div>
</div>
<!-- joint sliders -->
<div class="section">
<h3>Joint Values</h3>
<div id="sliders"></div>
</div>
<!-- display toggles -->
<div class="section">
<h3>Display</h3>
<div class="toggle-row">
<span class="toggle-label">Skeleton</span>
<label class="toggle"><input type="checkbox" id="tSkeleton" checked><span class="slider-track"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">Model markers</span>
<label class="toggle"><input type="checkbox" id="tModel" checked><span class="slider-track"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">Model normals</span>
<label class="toggle"><input type="checkbox" id="tNormals"><span class="slider-track"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">Observed markers</span>
<label class="toggle"><input type="checkbox" id="tObserved" checked><span class="slider-track"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">Observed normals</span>
<label class="toggle"><input type="checkbox" id="tObsNormals"><span class="slider-track"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">Error lines</span>
<label class="toggle"><input type="checkbox" id="tErrors" checked><span class="slider-track"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">Board plane</span>
<label class="toggle"><input type="checkbox" id="tBoard" checked><span class="slider-track"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">Cameras</span>
<label class="toggle"><input type="checkbox" id="tCameras" checked><span class="slider-track"></span></label>
</div>
</div>
<!-- stats -->
<div class="section">
<h3>Error Statistics</h3>
<div id="stats-content"><span style="color:var(--muted)">Load both files…</span></div>
</div>
<div id="status-bar">Drag to orbit · Scroll to zoom · Right-drag to pan</div>
</div>
<div id="canvas-wrap">
<canvas id="cv"></canvas>
</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// ═══════════════════════════════════════════════════════════════
// FK in plain JavaScript — mirrors robot_fk.py exactly
// ═══════════════════════════════════════════════════════════════
function norm(v) {
const n = Math.sqrt(v.reduce((s, x) => s + x * x, 0));
return n < 1e-12 ? v.map(() => 0) : v.map(x => x / n);
}
function mm3(A, B) { // 3×3 row-major mat-mat
const C = new Array(9).fill(0);
for (let i = 0; i < 3; i++)
for (let j = 0; j < 3; j++)
for (let k = 0; k < 3; k++)
C[i*3+j] += A[i*3+k] * B[k*3+j];
return C;
}
function rotAA(axis, deg) { // Rodrigues
const [x, y, z] = norm(axis);
const r = deg * Math.PI / 180, c = Math.cos(r), s = Math.sin(r), t = 1 - c;
return [t*x*x+c, t*x*y-s*z, t*x*z+s*y,
t*x*y+s*z, t*y*y+c, t*y*z-s*x,
t*x*z-s*y, t*y*z+s*x, t*z*z+c];
}
function rotXYZ(rx, ry, rz) {
return mm3(mm3(rotAA([0,0,1],rz), rotAA([0,1,0],ry)), rotAA([1,0,0],rx));
}
// 4×4 row-major
function makeT(R3, tx, ty, tz) {
return [R3[0],R3[1],R3[2],tx, R3[3],R3[4],R3[5],ty,
R3[6],R3[7],R3[8],tz, 0,0,0,1];
}
function I4() { return [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]; }
function mm4(A, B) {
const C = new Array(16).fill(0);
for (let i = 0; i < 4; i++)
for (let j = 0; j < 4; j++)
for (let k = 0; k < 4; k++)
C[i*4+j] += A[i*4+k] * B[k*4+j];
return C;
}
function applyT(T, p) {
return [T[0]*p[0]+T[1]*p[1]+T[2]*p[2]+T[3],
T[4]*p[0]+T[5]*p[1]+T[6]*p[2]+T[7],
T[8]*p[0]+T[9]*p[1]+T[10]*p[2]+T[11]];
}
function buildTopoOrder(links) {
const parent = Object.fromEntries(Object.entries(links).map(([n,d]) => [n, d.parent||null]));
const visited = new Set(), order = [];
const visit = n => {
if (visited.has(n)) return;
visited.add(n);
if (parent[n]) visit(parent[n]);
order.push(n);
};
Object.keys(links).forEach(visit);
return order;
}
function computeFK(robotData, joints) {
const links = robotData.links || {};
const state = {x:0,y:0,z:0,a:0,b:0,c:0,e:0, ...joints};
const T = {};
for (const lname of buildTopoOrder(links)) {
const ld = links[lname];
const par = ld.parent;
const Tp = T[par] || I4();
const mp = ld.mountPosition || [0,0,0];
const mr = ld.mountRotation || [0,0,0];
const Tm = makeT(rotXYZ(...mr), ...mp);
const jt = ld.jointToParent || {};
const jo = jt.origin || [0,0,0];
const jr = jt.rotation || [0,0,0];
const Tj = makeT(rotXYZ(...jr), ...jo);
const type = (jt.type||'').toLowerCase();
const axis = jt.axis || [1,0,0];
const v = state[jt.variable] || 0;
let Tmot = I4();
if (type === 'revolute') {
Tmot = makeT(rotAA(axis, v), 0, 0, 0);
} else if (type === 'linear') {
const [ax, ay, az] = norm(axis);
Tmot = makeT([1,0,0,0,1,0,0,0,1], ax*v, ay*v, az*v);
}
T[lname] = mm4(mm4(mm4(Tp, Tm), Tj), Tmot);
}
return T;
}
function markerWorld(T, link, local) {
return applyT(T[link] || I4(), local);
}
// ═══════════════════════════════════════════════════════════════
// Coordinate conversion robot-mm → Three.js scene (metres)
// robot: x=right, y=backward, z=up
// Three.js: x=right, y=up, z=toward viewer
// ═══════════════════════════════════════════════════════════════
const S = 1 / 1000;
function r2v(rx, ry, rz) { return new THREE.Vector3(rx*S, rz*S, -ry*S); }
function r2vArr(p) { return r2v(p[0], p[1], p[2]); }
// ═══════════════════════════════════════════════════════════════
// Three.js scene
// ═══════════════════════════════════════════════════════════════
const canvas = document.getElementById('cv');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.9;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0d0f13);
scene.fog = new THREE.FogExp2(0x0d0f13, 0.35);
const camera = new THREE.PerspectiveCamera(45, 1, 0.001, 20);
camera.position.set(0.35, 0.55, 1.1);
camera.lookAt(0.2, 0.05, 0);
const controls = new OrbitControls(camera, canvas);
controls.target.set(0.2, 0.05, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
// Lighting
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
const sun = new THREE.DirectionalLight(0xfff4e0, 1.4);
sun.position.set(-0.8, 1.2, 0.9);
sun.castShadow = true;
scene.add(sun);
const fill = new THREE.DirectionalLight(0xc0d8ff, 0.4);
fill.position.set(0.6, 0.3, -0.5);
scene.add(fill);
// Grid
const grid = new THREE.GridHelper(3, 30, 0x1e2230, 0x1a1e28);
grid.position.y = -0.028;
scene.add(grid);
// Axes helper (world origin)
const axes = new THREE.AxesHelper(0.1);
axes.position.set(0, 0, 0);
scene.add(axes);
// ─── geometry helpers ─────────────────────────────────────────
function makeCylinder(p1, p2, radius, color, opacity=1) {
const dir = p2.clone().sub(p1);
const len = dir.length();
if (len < 0.0001) return null;
const geo = new THREE.CylinderGeometry(radius, radius, len, 8, 1);
const mat = new THREE.MeshPhongMaterial({
color, transparent: opacity < 1, opacity,
shininess: 60
});
const m = new THREE.Mesh(geo, mat);
m.castShadow = true;
m.position.copy(p1.clone().add(p2).multiplyScalar(0.5));
m.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), dir.normalize());
return m;
}
function makeSphere(pos, radius, color) {
const geo = new THREE.SphereGeometry(radius, 12, 8);
const mat = new THREE.MeshPhongMaterial({ color, shininess: 80 });
const m = new THREE.Mesh(geo, mat);
m.position.copy(pos);
m.castShadow = true;
return m;
}
function transformDirByT(T, dir) {
return [
T[0]*dir[0] + T[1]*dir[1] + T[2]*dir[2],
T[4]*dir[0] + T[5]*dir[1] + T[6]*dir[2],
T[8]*dir[0] + T[9]*dir[1] + T[10]*dir[2],
];
}
function makeMarkerSquare(pos, normal, size, color) {
const geo = new THREE.BoxGeometry(size, size, size * 0.1);
const mat = new THREE.MeshPhongMaterial({
color,
shininess: 40
});
const m = new THREE.Mesh(geo, mat);
m.position.copy(pos);
// Fallback falls keine gültige Normale vorhanden
let nx = 0, ny = 0, nz = 1;
if (Array.isArray(normal) && normal.length >= 3) {
nx = Number(normal[0]) || 0;
ny = Number(normal[1]) || 0;
nz = Number(normal[2]) || 1;
} else if (normal instanceof THREE.Vector3) {
nx = normal.x;
ny = normal.y;
nz = normal.z;
}
const n = new THREE.Vector3(nx, ny, nz);
if (n.lengthSq() > 1e-12) {
n.normalize();
m.quaternion.setFromUnitVectors(
new THREE.Vector3(0, 0, 1),
n
);
}
return m;
}
function makeLine(p1, p2, color) {
const geo = new THREE.BufferGeometry().setFromPoints([p1, p2]);
const mat = new THREE.LineBasicMaterial({ color });
return new THREE.Line(geo, mat);
}
// ─── link colours ─────────────────────────────────────────────
const LINK_COLORS = {
Board: 0x8b6528,
Base: 0xc8c8c8,
Arm1: 0x3355cc,
Ellbow: 0xcccccc,
Arm2: 0xbbbbbb,
Hand: 0xaaaaaa,
Palm: 0x999999,
FingerA: 0xdddddd,
FingerB: 0xdddddd,
};
function linkColor(name) { return LINK_COLORS[name] ?? 0x888888; }
// ─── scene groups ─────────────────────────────────────────────
const gSkeleton = new THREE.Group();
const gModel = new THREE.Group();
const gNormals = new THREE.Group();
const gObserved = new THREE.Group();
const gObsNormals= new THREE.Group();
const gErrors = new THREE.Group();
const gBoard = new THREE.Group();
const gCameras = new THREE.Group();
scene.add(gSkeleton, gModel, gNormals, gObserved, gObsNormals, gErrors, gBoard, gCameras);
// ─── toggle wiring ────────────────────────────────────────────
const toggles = {
tSkeleton: gSkeleton,
tModel: gModel,
tNormals: gNormals,
tObserved: gObserved,
tObsNormals:gObsNormals,
tErrors: gErrors,
tBoard: gBoard,
tCameras: gCameras,
};
for (const [id, grp] of Object.entries(toggles)) {
document.getElementById(id).addEventListener('change', e => {
grp.visible = e.target.checked;
});
}
// ═══════════════════════════════════════════════════════════════
// State
// ═══════════════════════════════════════════════════════════════
let robotData = null;
let arucoData = null;
let solutionData = null;
let cameraData = [];
const joints = { x:0, y:0, z:0, a:0, b:0, c:0, e:0 };
// ─── slider setup ─────────────────────────────────────────────
const JOINT_DEFS = [
{ key:'x', label:'x', min:-50, max:600, step:1, unit:'mm' },
{ key:'y', label:'y', min:-180, max:180, step:0.5,unit:'°' },
{ key:'z', label:'z', min:-180, max:180, step:0.5,unit:'°' },
{ key:'a', label:'a', min:-180, max:180, step:0.5,unit:'°' },
{ key:'b', label:'b', min:-180, max:180, step:0.5,unit:'°' },
{ key:'c', label:'c', min:-180, max:180, step:0.5,unit:'°' },
{ key:'e', label:'e', min:0, max:60, step:0.5,unit:'mm' },
];
const sliderEls = {}, valEls = {};
const slidersDiv = document.getElementById('sliders');
for (const d of JOINT_DEFS) {
const row = document.createElement('div');
row.className = 'slider-row';
const lbl = document.createElement('span');
lbl.textContent = d.key;
const sl = document.createElement('input');
sl.type = 'range';
sl.min = d.min; sl.max = d.max; sl.step = d.step; sl.value = 0;
const vb = document.createElement('input');
vb.type = 'text'; vb.className = 'val-box'; vb.value = '0';
vb.style.width = '58px';
sl.addEventListener('input', () => {
joints[d.key] = parseFloat(sl.value);
vb.value = parseFloat(sl.value).toFixed(d.step < 1 ? 1 : 0);
rebuild();
});
vb.addEventListener('change', () => {
const v = parseFloat(vb.value);
if (!isNaN(v)) {
joints[d.key] = v;
sl.value = v;
rebuild();
}
});
row.append(lbl, sl, vb);
slidersDiv.appendChild(row);
sliderEls[d.key] = sl;
valEls[d.key] = vb;
}
function setSlider(key, val) {
joints[key] = val;
sliderEls[key].value = val;
valEls[key].value = val.toFixed(JOINT_DEFS.find(d=>d.key===key)?.step < 1 ? 1 : 0);
}
// ─── file loading ──────────────────────────────────────────────
function readJSON(file) {
return new Promise((res, rej) => {
const r = new FileReader();
r.onload = e => { try { res(JSON.parse(e.target.result)); } catch(err) { rej(err); } };
r.onerror = rej;
r.readAsText(file);
});
}
document.getElementById('fRobot').addEventListener('change', async e => {
if (!e.target.files[0]) return;
robotData = await readJSON(e.target.files[0]);
// init sliders from defaultPosition if present
const dp = robotData.defaultPosition || {};
for (const k of Object.keys(joints)) {
if (dp[k] != null) setSlider(k, dp[k]);
}
populatePoses(robotData);
setStatus('robot.json loaded');
rebuild();
});
document.getElementById('fAruco').addEventListener('change', async e => {
if (!e.target.files[0]) return;
arucoData = await readJSON(e.target.files[0]);
// Kamera-Posen aus derselben Datei übernehmen (falls vorhanden) → Frusta
cameraData = (arucoData.cameras || []).map(c => ({
pos_mm: c.position_mm || (c.position_m ? c.position_m.map(v => v * 1000) : null),
dir: c.direction,
id: c.camera_id
})).filter(c => c.pos_mm && c.dir);
setStatus('aruco geladen' + (cameraData.length ? ` (+ ${cameraData.length} Kameras)` : ''));
rebuild();
});
// ─── solution (robot_state.json) → set joint sliders ───────────
function applySolution() {
if (!solutionData) { setStatus('keine Lösung geladen'); return; }
const mv = solutionData.movements || {};
const done = [];
for (const k of Object.keys(joints)) {
const m = mv[k];
if (m == null) continue;
let v = (typeof m === 'object') ? (m.value ?? m.value_mm ?? m.value_deg) : m;
if (v == null || isNaN(v)) continue;
setSlider(k, Number(v));
const conf = (typeof m === 'object') ? (m.confidence || '') : '';
done.push(k + ((conf === 'low' || conf === 'none') ? '⚠' : ''));
}
rebuild();
const info = document.getElementById('solInfo');
if (info) info.textContent = done.length
? ('gesetzt: ' + done.join(' ') + ' (⚠ = geringe Konfidenz)')
: 'keine movements in der Datei gefunden';
setStatus('Auf Lösung gesprungen');
}
document.getElementById('fSolution').addEventListener('change', async e => {
if (!e.target.files[0]) return;
solutionData = await readJSON(e.target.files[0]);
const btn = document.getElementById('btnJump');
btn.disabled = false;
btn.style.opacity = '1';
setStatus('solution (robot_state.json) loaded');
applySolution(); // direkt anwenden
});
document.getElementById('btnJump').addEventListener('click', applySolution);
// ═══════════════════════════════════════════════════════════════
// Test-pose dropdown
// ═══════════════════════════════════════════════════════════════
function populatePoses(data) {
const poses = data.robot_test_poses || {};
const keys = Object.keys(poses);
const section = document.getElementById('poseSection');
const sel = document.getElementById('poseSelect');
const info = document.getElementById('poseInfo');
if (keys.length === 0) { section.style.display = 'none'; return; }
section.style.display = '';
// Rebuild options
sel.innerHTML = '<option value="">— select pose —</option>';
keys.forEach(k => {
const o = document.createElement('option');
o.value = k; o.textContent = k;
sel.appendChild(o);
});
// Remove old listener by replacing element
const newSel = sel.cloneNode(true);
sel.parentNode.replaceChild(newSel, sel);
newSel.addEventListener('change', () => {
const k = newSel.value;
if (!k) { info.textContent = ''; return; }
const p = poses[k];
info.textContent = Object.entries(p).map(([a,b])=>`${a}:${b}`).join(' ');
for (const [key, val] of Object.entries(p)) {
if (key in joints) setSlider(key, Number(val));
}
rebuild();
});
}
// ─── clear groups ──────────────────────────────────────────────
function clearGroup(g) {
while (g.children.length) {
const c = g.children[0];
c.geometry?.dispose?.();
if (Array.isArray(c.material)) c.material.forEach(m => m.dispose?.());
else c.material?.dispose?.();
g.remove(c);
}
}
// ═══════════════════════════════════════════════════════════════
// Normal-arrow helper
// ═══════════════════════════════════════════════════════════════
function makeNormalArrow(posThreeJS, normalRobot, length, hexColor) {
// normalRobot is in robot coords → convert to Three.js direction
const dir = new THREE.Vector3(normalRobot[0], normalRobot[2], -normalRobot[1]).normalize();
const arrow = new THREE.ArrowHelper(
dir,
posThreeJS,
length,
hexColor,
length * 0.40, // cone head length
length * 0.20 // cone head width
);
return arrow;
}
// ─── rebuild scene ─────────────────────────────────────────────
function r2vDir(rx, ry, rz) {
return new THREE.Vector3(rx, rz, -ry).normalize();
}
// ═══════════════════════════════════════════════════════════════
// Camera frustum (halbtransparente Pyramide, Spitze = Linse)
// ═══════════════════════════════════════════════════════════════
function makeCameraFrustum(posThree, dirThree, size, hexColor) {
const geo = new THREE.ConeGeometry(size * 0.6, size, 4);
geo.translate(0, -size / 2, 0); // Spitze (Linse) an den lokalen Ursprung
geo.rotateY(Math.PI / 4); // Pyramidenkanten ausrichten
const mat = new THREE.MeshPhongMaterial({
color: hexColor, transparent: true, opacity: 0.28,
side: THREE.DoubleSide, depthWrite: false
});
const m = new THREE.Mesh(geo, mat);
m.position.copy(posThree);
const d = dirThree.clone().normalize();
if (d.lengthSq() > 1e-9) m.quaternion.setFromUnitVectors(new THREE.Vector3(0, -1, 0), d);
return m;
}
function rebuild() {
clearGroup(gSkeleton);
clearGroup(gModel);
clearGroup(gNormals);
clearGroup(gObserved);
clearGroup(gObsNormals);
clearGroup(gErrors);
clearGroup(gBoard);
clearGroup(gCameras);
// Kamera-Frusta hängen nicht vom Roboterzustand ab
for (const cam of cameraData) {
gCameras.add(makeCameraFrustum(r2vArr(cam.pos_mm), r2vDir(...cam.dir), 0.05, 0x9b7bff));
}
if (!robotData) return;
const T = computeFK(robotData, joints);
// ── board plane ──
const boardSize = robotData.links?.Board?.size || [1000, 200, 25];
{
const w = boardSize[0]*S, d = boardSize[1]*S, h = boardSize[2]*S;
const geo = new THREE.BoxGeometry(w, h, d);
const mat = new THREE.MeshPhongMaterial({ color:0x7a5520, transparent:true, opacity:0.5 });
const m = new THREE.Mesh(geo, mat);
m.position.set(w/2, -h/2, -d/2);
m.receiveShadow = true;
gBoard.add(m);
}
// ── skeleton ──
const links = robotData.links || {};
for (const [lname, ld] of Object.entries(links)) {
const sk = ld.skeleton;
if (!sk) continue;
const from = markerWorld(T, lname, sk.from || [0,0,0]);
const to = markerWorld(T, lname, sk.to || [0,0,0]);
const r = (sk.radius || 4) * S * 0.8;
const col = sk.color ? new THREE.Color(...sk.color) : new THREE.Color(linkColor(lname));
const cyl = makeCylinder(r2vArr(from), r2vArr(to), r, col);
if (cyl) gSkeleton.add(cyl);
}
// ── model markers + normals ──
const modelPositions = {};
const modelNormals = {};
for (const [lname, ld] of Object.entries(links)) {
const col = linkColor(lname);
for (const m of (ld.markers||[])) {
if (!m.position) continue;
const mid = m.id;
const wp = markerWorld(T, lname, m.position);
const nLocal = m.normal || [0,0,1];
const nWorld = transformDirByT(T[lname] || I4(), nLocal);
modelPositions[mid] = wp;
modelNormals[mid] = nWorld;
const sq = makeMarkerSquare(r2vArr(wp), r2vDir(...nWorld), 0.022, col);
gModel.add(sq);
// normal arrow (length = half a marker size = ~12.5mm → 0.0125m)
const arr = makeNormalArrow(r2vArr(wp), nWorld, 0.018, col);
gNormals.add(arr);
}
}
// ── observed markers + normals + error lines ──
const obs = {};
if (arucoData) {
for (const m of (arucoData.markers||[])) {
const mid = m.marker_id ?? m.id;
if (mid == null) continue;
let pos, nor = null;
if (m.position_mm) pos = m.position_mm;
else if (m.position_m) pos = m.position_m.map(v=>v*1000);
else continue;
// optional orientation from step-3 output or render markers.json
if (m.normal_world) nor = m.normal_world;
else if (m.normal) nor = m.normal;
obs[mid] = { pos, nor };
}
}
const errors = [];
const normalErrors = [];
for (const [midStr, {pos: opos, nor: oNor}] of Object.entries(obs)) {
const mid = parseInt(midStr);
const mpos = modelPositions[mid];
const op = r2vArr(opos);
const errMm = mpos
? Math.sqrt(opos.reduce((s,v,i) => s+(v-mpos[i])**2, 0))
: null;
const col = errMm == null ? 0x888888
: errMm < 2 ? 0x3ecf6b
: errMm < 5 ? 0xf59e0b
: 0xff4f4f;
gObserved.add(makeSphere(op, 0.006, col));
// observed normal arrow — coloured by angular deviation from the model normal
if (oNor) {
try {
let normCol = 0xffaa00; // no model to compare -> orange
const mn = modelNormals[mid];
if (mn) {
const dot = (oNor[0]*mn[0] + oNor[1]*mn[1] + oNor[2]*mn[2]) /
((Math.hypot(oNor[0],oNor[1],oNor[2]) * Math.hypot(mn[0],mn[1],mn[2])) || 1);
let a = Math.acos(Math.max(-1, Math.min(1, dot))) * 180 / Math.PI;
a = Math.min(a, 180 - a); // flip-invariant
normalErrors.push(a);
normCol = a < 2 ? 0x3ecf6b : a < 5 ? 0xf59e0b : 0xff4f4f;
}
gObsNormals.add(makeNormalArrow(op, oNor, 0.018, normCol));
} catch (e) {
console.warn('Invalid normal for marker', mid, oNor, e);
}
} else if (modelNormals[mid]) {
// fallback: show model normal at observed position (grey = no obs normal data)
const obsArrow = makeNormalArrow(op, modelNormals[mid], 0.014, 0x666666);
gObsNormals.add(obsArrow);
}
if (mpos) {
errors.push(errMm);
const mp = r2vArr(mpos);
gErrors.add(makeLine(mp, op, col));
}
}
// ── stats ──
updateStats(errors, normalErrors, Object.keys(obs).length, Object.keys(modelPositions).length);
}
// ─── statistics panel ──────────────────────────────────────────
function updateStats(errArr, normArr, nObs, nModel) {
const el = document.getElementById('stats-content');
normArr = normArr || [];
if (errArr.length === 0) {
el.innerHTML = `<div class="stat-line"><span>Observed:</span><span class="stat-val">${nObs}</span></div>
<div class="stat-line"><span>Model:</span><span class="stat-val">${nModel}</span></div>
<div class="stat-line"><span style="color:var(--muted)">No matches found</span></div>`;
return;
}
const sorted = [...errArr].sort((a,b)=>a-b);
const mean = errArr.reduce((s,v)=>s+v,0) / errArr.length;
const rms = Math.sqrt(errArr.reduce((s,v)=>s+v*v,0) / errArr.length);
const p50 = sorted[Math.floor(sorted.length*0.5)];
const p90 = sorted[Math.floor(sorted.length*0.9)];
const max = sorted[sorted.length-1];
const cls = v => v < 2 ? 'stat-ok' : v < 5 ? 'stat-warn' : 'stat-err';
let normHtml = '';
if (normArr.length) {
const nmean = normArr.reduce((s,v)=>s+v,0) / normArr.length;
const nmax = Math.max(...normArr);
const ncls = v => v < 2 ? 'stat-ok' : v < 5 ? 'stat-warn' : 'stat-err';
normHtml = `<div class="stat-line" style="margin-top:4px"><span>Normal mean:</span><span class="${ncls(nmean)}">${nmean.toFixed(1)}&deg;</span></div>
<div class="stat-line"><span>Normal max:</span><span class="${ncls(nmax)}">${nmax.toFixed(1)}&deg;</span></div>`;
}
el.innerHTML = `
<div class="stat-line"><span>Observed:</span><span class="stat-val">${nObs}</span></div>
<div class="stat-line"><span>Matched:</span><span class="stat-val">${errArr.length}</span></div>
<div class="stat-line"><span>Mean error:</span><span class="${cls(mean)}">${mean.toFixed(1)} mm</span></div>
<div class="stat-line"><span>RMS error:</span><span class="${cls(rms)}">${rms.toFixed(1)} mm</span></div>
<div class="stat-line"><span>Median:</span><span class="${cls(p50)}">${p50.toFixed(1)} mm</span></div>
<div class="stat-line"><span>p90:</span><span class="${cls(p90)}">${p90.toFixed(1)} mm</span></div>
<div class="stat-line"><span>Max:</span><span class="${cls(max)}">${max.toFixed(1)} mm</span></div>
${normHtml}
<div style="margin-top:6px;font-size:10px;color:var(--muted)">
🟢 &lt;2mm/° &nbsp; 🟡 25 &nbsp; 🔴 &gt;5</div>`;
}
function setStatus(msg) {
document.getElementById('status-bar').textContent = msg + ' — Drag to orbit · Scroll to zoom';
}
// ═══════════════════════════════════════════════════════════════
// Render loop
// ═══════════════════════════════════════════════════════════════
function onResize() {
const w = canvas.parentElement.clientWidth;
const h = canvas.parentElement.clientHeight;
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
window.addEventListener('resize', onResize);
onResize();
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>