Files
appRobotHoming/public/boardViewer.html
2026-06-10 16:54:36 +02:00

584 lines
20 KiB
HTML
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.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Board Viewer</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0f13;
--panel: #161920;
--border: #2a2d35;
--text: #c8cdd8;
--muted: #555b6e;
--accent: #4a9eff;
}
body {
background: var(--bg);
color: var(--text);
font: 11px/1.5 'IBM Plex Mono', 'Cascadia Code', 'Courier New', monospace;
display: flex;
flex-direction: column;
min-height: 100vh;
overflow-y: auto;
}
#topbar {
display: flex;
align-items: center;
gap: 14px;
padding: 5px 12px;
background: var(--panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
flex-wrap: wrap;
}
.legend { display: flex; gap: 10px; align-items: center; }
.dot {
display: inline-block; width: 10px; height: 10px;
border-radius: 2px; margin-right: 3px; vertical-align: middle;
}
.dot.circle { border-radius: 50%; }
#status { color: var(--muted); flex: 1; min-width: 100px; }
#stats { color: var(--accent); }
.btn {
background: #1e293b;
color: var(--text);
border: 1px solid #334155;
border-radius: 3px;
padding: 3px 9px;
cursor: pointer;
font: inherit;
font-size: 11px;
}
.btn:hover { border-color: var(--accent); color: var(--accent); }
#canvas-wrap { flex: 1; min-height: 360px; position: relative; overflow: hidden; }
canvas { display: block; width: 100%; height: 100%; }
#hint {
position: absolute; bottom: 6px; right: 10px;
font-size: 9px; color: var(--muted); pointer-events: none;
}
/* ── Daten-Tabelle ── */
#table-wrap {
flex-shrink: 0;
max-height: 260px;
overflow-y: auto;
border-top: 1px solid var(--border);
background: var(--bg);
}
.tbl-head {
padding: 4px 10px 2px;
font-size: 9px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .07em;
background: var(--panel);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
}
table.dtbl {
width: 100%;
border-collapse: collapse;
font-size: 10px;
}
table.dtbl th {
position: sticky;
top: 0;
background: var(--panel);
color: var(--muted);
font-weight: normal;
text-align: right;
padding: 2px 7px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
table.dtbl th:first-child,
table.dtbl th:nth-child(2) { text-align: left; }
table.dtbl td {
text-align: right;
padding: 1px 7px;
border-bottom: 1px solid #111418;
white-space: nowrap;
}
table.dtbl td:first-child,
table.dtbl td:nth-child(2) { text-align: left; }
table.dtbl tr:hover td { background: #1a1f2b; }
.row-1cam td:first-child::before { content: ''; }
.cell-hi { color: #fbbf24; } /* amber: trianguliert */
.cell-lo { color: #dde3ec; } /* hell: nur 2D */
.cell-unk { color: #3b82f6; } /* blau: fremd */
.cell-mut { color: var(--muted); }
</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="topbar">
<div class="legend">
<span><span class="dot" style="background:#22c55e"></span>Erkannt</span>
<span><span class="dot" style="background:#ef4444"></span>Nicht erkannt</span>
<span><span class="dot circle" style="background:#dde3ec"></span>Erkannt (nur 2D)</span>
<span><span class="dot circle" style="background:#fbbf24"></span>Gemessen (3b)</span>
<span><span class="dot circle" style="background:#3b82f6"></span>Fremd (3b)</span>
<span><span class="dot" style="background:#9b7bff"></span>Kamera</span>
</div>
<span id="stats"></span>
<span id="status">Laden …</span>
<button class="btn" id="btnReload"></button>
</div>
<div id="canvas-wrap">
<canvas id="cv"></canvas>
<div id="hint">Orbit · Scroll · Rechte Taste = Pan</div>
</div>
<div id="table-wrap"></div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const S = 1 / 1000; // mm → m
// robot (x=right, y=backward, z=up) → Three.js (x=right, y=up, z=toward viewer)
function r2v(rx, ry, rz) { return new THREE.Vector3(rx * S, rz * S, -ry * S); }
function r2vArr([rx, ry, rz]) { return r2v(rx, ry, rz); }
function r2dir([dx, dy, dz]) { return new THREE.Vector3(dx, dz, -dy).normalize(); }
// ── Renderer ──────────────────────────────────────────────────────────────────
const canvas = document.getElementById('cv');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(devicePixelRatio);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0d0f13);
const CAM_TARGET = new THREE.Vector3(0.49, -0.03, 0.015);
const cam = new THREE.PerspectiveCamera(45, 1, 0.001, 20);
cam.position.set(0.49, 1.5, 0.85);
cam.lookAt(CAM_TARGET);
const controls = new OrbitControls(cam, canvas);
controls.target.copy(CAM_TARGET);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
scene.add(new THREE.AmbientLight(0xffffff, 0.8));
const sun = new THREE.DirectionalLight(0xfff4e0, 0.6);
sun.position.set(-0.5, 1.2, 0.5);
scene.add(sun);
scene.add(new THREE.AxesHelper(0.08));
// ── Groups ────────────────────────────────────────────────────────────────────
const gPaper = new THREE.Group(); // weißes A0-Papier
const gMarkers = new THREE.Group(); // Modell-Rechtecke
const gMeasured = new THREE.Group(); // gemessene Positionen (3b)
const gCameras = new THREE.Group(); // Kamera-Frusta
scene.add(gPaper, gMarkers, gMeasured, gCameras);
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);
}
}
// ── Geometry helpers ──────────────────────────────────────────────────────────
function makeMarkerSquare(pos, size, color) {
const geo = new THREE.PlaneGeometry(size, size);
geo.rotateX(-Math.PI / 2);
const mat = new THREE.MeshPhongMaterial({ color, side: THREE.DoubleSide });
const m = new THREE.Mesh(geo, mat);
m.position.copy(pos);
return m;
}
function makeEdgeBorder(pos, size, color) {
const geo = new THREE.EdgesGeometry(new THREE.PlaneGeometry(size, size));
const mat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.75 });
const m = new THREE.LineSegments(geo, mat);
m.rotation.x = -Math.PI / 2;
m.position.copy(pos);
return m;
}
function makeSphere(pos, radius, color) {
const geo = new THREE.SphereGeometry(radius, 10, 8);
const mat = new THREE.MeshPhongMaterial({ color, shininess: 80 });
const m = new THREE.Mesh(geo, mat);
m.position.copy(pos);
return m;
}
function makeLine(p1, p2, color, opacity = 1) {
const geo = new THREE.BufferGeometry().setFromPoints([p1, p2]);
const mat = new THREE.LineBasicMaterial({ color, transparent: opacity < 1, opacity });
return new THREE.Line(geo, mat);
}
function makeCameraFrustum(posThree, dirThree, size) {
const geo = new THREE.ConeGeometry(size * 0.55, size, 4);
geo.translate(0, -size / 2, 0);
geo.rotateY(Math.PI / 4);
const mat = new THREE.MeshPhongMaterial({
color: 0x9b7bff, transparent: true, opacity: 0.55, side: THREE.DoubleSide,
});
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;
}
// ── Szene aus API-Daten aufbauen ──────────────────────────────────────────────
function buildScene(data) {
clearGroup(gPaper); clearGroup(gMarkers); clearGroup(gMeasured); clearGroup(gCameras);
const { robot, detections, cameraPoses, measuredMarkers } = data;
// Alle erkannten Marker-IDs (über alle Kameras)
const detectedIds = new Set();
for (const det of (detections ?? [])) {
for (const id of (det.detectedMarkerIds ?? [])) detectedIds.add(id);
}
const boardMarkers = robot?.links?.Board?.markers ?? [];
const markerSize = robot?.vision_config?.MarkerSize ?? 0.025; // m
// ── A0-Papier (weißes Rechteck) ──
if (boardMarkers.length > 0) {
let minRx = Infinity, maxRx = -Infinity;
let minRy = Infinity, maxRy = -Infinity;
let markerRz = -27.3;
for (const m of boardMarkers) {
const [rx, ry, rz] = m.position;
if (rx < minRx) minRx = rx; if (rx > maxRx) maxRx = rx;
if (ry < minRy) minRy = ry; if (ry > maxRy) maxRy = ry;
markerRz = rz;
}
const pad = 40; // mm Rand
const planeW = (maxRx - minRx + 2 * pad) * S;
const planeH = (maxRy - minRy + 2 * pad) * S;
const cx = ((minRx + maxRx) / 2) * S;
const cz = -((minRy + maxRy) / 2) * S;
const cy = markerRz * S - 0.001;
const geo = new THREE.PlaneGeometry(planeW, planeH);
geo.rotateX(-Math.PI / 2);
const mat = new THREE.MeshPhongMaterial({ color: 0xf0ebe0, side: THREE.DoubleSide });
const plane = new THREE.Mesh(geo, mat);
plane.position.set(cx, cy, cz);
gPaper.add(plane);
}
// ── Modell-Marker (Rechtecke) ──
const visSize = markerSize * 0.9;
let nDetected = 0;
for (const m of boardMarkers) {
const pos = r2vArr(m.position);
const detected = detectedIds.has(m.id);
if (detected) nDetected++;
const color = detected ? 0x22c55e : 0xef4444;
const sq = makeMarkerSquare(pos, visSize, color);
sq.position.y += 0.0005;
gMarkers.add(sq);
const border = makeEdgeBorder(pos, visSize, detected ? 0x4ade80 : 0xfca5a5);
border.position.y += 0.001;
gMarkers.add(border);
}
// ── Gemessene Positionen von 3b (gelbe Punkte) ──
let nTriangulated = 0;
let nUnknown = 0; // triangulierte Marker ohne Board-Eintrag
const measuredById = {};
if (measuredMarkers?.markers?.length > 0) {
// Nur A0-Marker (Board-Link)
const a0markers = measuredMarkers.markers.filter(m =>
m.link === 'Board' || boardMarkers.some(bm => bm.id === m.marker_id)
);
for (const m of a0markers) {
nTriangulated++;
// Kein künstlicher Offset Kugelmittelpunkt exakt an triangulierter Position
const mpos = r2vArr(m.position_mm);
measuredById[m.marker_id] = mpos.clone();
// Amber-Kugel an exakter gemessener Position
const dot = makeSphere(mpos, 0.0055, 0xfbbf24);
gMeasured.add(dot);
// Verbindungslinie: exakte Modellposition → exakte Messposition
const modelMarker = boardMarkers.find(bm => bm.id === m.marker_id);
if (modelMarker) {
const modelPos = r2vArr(modelMarker.position);
gMeasured.add(makeLine(modelPos, mpos.clone(), 0x78716c, 0.5));
}
}
// ── Blaue Kugeln: triangulierte Marker, die NICHT in boardMarkers stehen ──
// (unbekannte IDs oder andere Links keine Modellposition vorhanden)
const unknownTriangulated = measuredMarkers.markers.filter(m =>
!boardMarkers.some(bm => bm.id === m.marker_id)
);
for (const m of unknownTriangulated) {
nUnknown++;
const mpos = r2vArr(m.position_mm);
gMeasured.add(makeSphere(mpos, 0.0055, 0x3b82f6));
}
// Kamera-Frusta aus 3b
if (measuredMarkers.cameras?.length > 0) {
for (const c of measuredMarkers.cameras) {
const cpos = r2vArr(c.position_mm);
const cdir = r2dir(c.direction);
gCameras.add(makeCameraFrustum(cpos, cdir, 0.07));
const boardCenter = r2v(490, 0, -27.3);
gCameras.add(makeLine(cpos, boardCenter, 0x9b7bff, 0.35));
gCameras.add(makeSphere(cpos, 0.012, 0x9b7bff));
}
}
}
// ── Helle Kugeln: Board-Marker erkannt, aber nicht trianguliert ──
// (nur 1 Kamera sah den Marker, oder 3b lief nicht)
let nDetectedNotTriangulated = 0;
for (const m of boardMarkers) {
if (detectedIds.has(m.id) && !Object.hasOwn(measuredById, m.id)) {
nDetectedNotTriangulated++;
const pos = r2vArr(m.position);
gMeasured.add(makeSphere(pos, 0.0055, 0xdde3ec));
}
}
// Falls keine 3b-Daten: Kamera-Frusta aus camera_pose-Dateien
if (!measuredMarkers?.cameras?.length) {
const boardCenter = r2v(490, 0, -27.3);
for (const cp of (cameraPoses ?? [])) {
if (!cp.position_mm) continue;
const cpos = r2vArr(cp.position_mm);
let dirWorld = [0, 0, 1];
if (cp.rotation_matrix?.length >= 3) dirWorld = cp.rotation_matrix[2];
const cdir = r2dir(dirWorld);
gCameras.add(makeCameraFrustum(cpos, cdir, 0.07));
gCameras.add(makeLine(cpos, boardCenter, 0x9b7bff, 0.35));
gCameras.add(makeSphere(cpos, 0.012, 0x9b7bff));
}
}
// ── Stats-Zeile ──
const camInfo = (cameraPoses ?? []).map(cp => {
const rms = cp.rms_px != null ? `${cp.rms_px.toFixed(1)} px` : '?';
return `${cp.cameraId}: ${rms}`;
}).join(' │ ');
const triInfo = measuredMarkers === null
? ' │ 3b: '
: ` │ 3b: ${nTriangulated}`;
const only2dInfo = nDetectedNotTriangulated > 0 ? ` │ nur 2D: ${nDetectedNotTriangulated}` : '';
const unknownInfo = nUnknown > 0 ? ` │ fremd: ${nUnknown}` : '';
document.getElementById('stats').textContent =
`Erkannt ${nDetected}/${boardMarkers.length}${triInfo}${only2dInfo}${unknownInfo}` +
(camInfo ? ` │ RMS: ${camInfo}` : '');
}
// ── Daten-Tabelle ─────────────────────────────────────────────────────────────
function buildTable(data) {
const wrap = document.getElementById('table-wrap');
if (!wrap) return;
const { robot, detections, cameraPoses, measuredMarkers } = data;
const boardMarkers = robot?.links?.Board?.markers ?? [];
// Marker → Liste der Kameras, die ihn gesehen haben
const camerasByMkr = {};
for (const det of (detections ?? [])) {
for (const id of (det.detectedMarkerIds ?? [])) {
(camerasByMkr[id] ??= []).push(det.cameraId);
}
}
// measuredMarkers indiziert nach marker_id
const measuredMap = {};
for (const m of (measuredMarkers?.markers ?? [])) measuredMap[m.marker_id] = m;
// Modell-Positionen indiziert
const modelMap = {};
for (const m of boardMarkers) modelMap[m.id] = m.position; // [x,y,z] mm
// Alle relevanten IDs (erkannt oder trianguliert)
const allIds = new Set([
...(detections ?? []).flatMap(d => d.detectedMarkerIds ?? []),
...Object.keys(measuredMap).map(Number),
]);
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));
// Zeilen zusammenstellen
const rows = [...allIds].map(id => {
const meas = measuredMap[id];
const model = modelMap[id]; // null wenn nicht in Board
const link = meas?.link ?? (model ? 'Board' : 'unknown');
const cameras = camerasByMkr[id] ?? [];
const numCam = meas?.num_cameras ?? cameras.length;
let x = null, y = null, z = null;
let nx = null, ny = null, nz = null;
let dist = null, dz = null, edge = null;
let state = 'none'; // 'tri', '1cam', 'unk'
if (meas) {
[x, y, z] = meas.position_mm;
[nx, ny, nz] = meas.normal;
edge = meas.edge_length_mm;
state = model ? 'tri' : 'unk';
if (model) {
const [mx, my, mz] = model;
const dx = x - mx, dy = y - my, ddz = z - mz;
dist = Math.sqrt(dx*dx + dy*dy + ddz*ddz);
dz = ddz;
}
} else if (cameras.length > 0) {
state = '1cam';
}
return { id, link, numCam, x, y, z, nx, ny, nz, dist, dz, edge, state };
}).sort((a, b) => {
// Reihenfolge: Board tri → Board 1cam → unknown → Rest; innerhalb nach ID
const rank = r => r.state === 'tri' ? 0 : r.state === '1cam' ? 1 : r.state === 'unk' ? 2 : 3;
return rank(a) - rank(b) || a.id - b.id;
});
// ── Marker-Tabelle ──
const stateStyle = { tri: 'cell-hi', '1cam': 'cell-lo', unk: 'cell-unk', none: 'cell-mut' };
const stateLabel = { tri: '▲', '1cam': '●', unk: '◆', none: '' };
let html = `
<div class="tbl-head">Erkannte Marker (${rows.length})</div>
<table class="dtbl">
<thead><tr>
<th>ID</th><th>Link</th><th>Kam.</th>
<th>x mm</th><th>y mm</th><th>z mm</th>
<th>nx</th><th>ny</th><th>nz</th>
<th>dist mm</th><th>Δz mm</th><th>Kante mm</th>
</tr></thead>
<tbody>`;
for (const r of rows) {
const cs = stateStyle[r.state] ?? '';
html += `<tr>
<td class="${cs}">${stateLabel[r.state]} ${r.id}</td>
<td>${r.link}</td>
<td>${r.numCam}</td>
<td>${f1(r.x)}</td><td>${f1(r.y)}</td><td>${f1(r.z)}</td>
<td>${f4(r.nx)}</td><td>${f4(r.ny)}</td><td>${f4(r.nz)}</td>
<td>${f2(r.dist)}</td><td>${f2(r.dz)}</td><td>${f1(r.edge)}</td>
</tr>`;
}
html += `</tbody></table>`;
// ── Kamera-Tabelle ──
const camRows = measuredMarkers?.cameras?.length
? measuredMarkers.cameras.map(c => ({
id: c.camera_id,
pos: c.position_mm,
dir: c.direction,
}))
: (cameraPoses ?? []).map(cp => ({
id: cp.cameraId,
pos: cp.position_mm,
dir: cp.rotation_matrix?.[2] ?? null,
}));
if (camRows.length > 0) {
html += `
<div class="tbl-head" style="margin-top:0">Kameras (${camRows.length})</div>
<table class="dtbl">
<thead><tr>
<th>ID</th>
<th>x mm</th><th>y mm</th><th>z mm</th>
<th>dir_x</th><th>dir_y</th><th>dir_z</th>
</tr></thead>
<tbody>`;
for (const c of camRows) {
const [cx, cy, cz] = c.pos ?? [null, null, null];
const [dx, dy, dz] = c.dir ?? [null, null, null];
html += `<tr>
<td style="color:var(--accent)">${c.id}</td>
<td>${f1(cx)}</td><td>${f1(cy)}</td><td>${f1(cz)}</td>
<td>${f4(dx)}</td><td>${f4(dy)}</td><td>${f4(dz)}</td>
</tr>`;
}
html += `</tbody></table>`;
}
wrap.innerHTML = html;
}
// ── Daten laden ───────────────────────────────────────────────────────────────
async function loadData() {
const statusEl = document.getElementById('status');
statusEl.textContent = 'Laden …';
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) {
statusEl.textContent = 'Kein Board-Run vorhanden.';
document.getElementById('stats').textContent = '';
clearGroup(gPaper); clearGroup(gMarkers); clearGroup(gMeasured); clearGroup(gCameras);
return;
}
buildScene(data);
buildTable(data);
const robotLabel = data.robotFile ? ` • Robot: ${data.robotFile}` : '';
statusEl.textContent = `Run: ${data.runDir}${robotLabel}${new Date().toLocaleTimeString('de-CH')}`;
} catch (err) {
statusEl.textContent = `Fehler: ${err.message ?? err}`;
}
}
loadData();
document.getElementById('btnReload').addEventListener('click', loadData);
window.addEventListener('message', (e) => {
if (e.data?.type === 'reload') loadData();
});
// ── Resize & Render-Loop ──────────────────────────────────────────────────────
function onResize() {
const wrap = document.getElementById('canvas-wrap');
renderer.setSize(wrap.clientWidth, wrap.clientHeight);
cam.aspect = wrap.clientWidth / wrap.clientHeight;
cam.updateProjectionMatrix();
}
window.addEventListener('resize', onResize);
onResize();
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, cam);
}
animate();
</script>
</body>
</html>