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

394 lines
14 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;
height: 100vh;
overflow: hidden;
}
#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; 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;
}
</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:#fde68a"></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>
<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, 0xfde68a));
}
}
// 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 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);
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>