394 lines
14 KiB
HTML
394 lines
14 KiB
HTML
<!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>
|