Files
appRobotHoming/public/boardViewer.html
2026-06-10 20:41:42 +02:00

854 lines
32 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); }
#run-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
background: var(--panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
flex-wrap: wrap;
}
.run-lbl {
font-size: 10px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .04em;
white-space: nowrap;
}
#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: ''; }
/* ── Viewer-Log ── */
#viewer-log {
flex-shrink: 0;
max-height: 64px;
overflow-y: auto;
border-top: 1px solid var(--border);
background: #090b0e;
padding: 2px 10px 3px;
font-size: 10px;
color: #4a5568;
line-height: 1.6;
}
#viewer-log .vl-ok { color: #4ade80; }
#viewer-log .vl-warn{ color: #fbbf24; }
#viewer-log .vl-err { color: #f87171; }
.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 circle" style="background:#f97316"></span>Vergleich</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="run-bar">
<span class="run-lbl">Basis</span>
<select id="sel-run-primary" class="btn" style="min-width:158px">
<option value="">⟳ aktuellster</option>
</select>
<span class="run-lbl" style="margin-left:10px">Vergleich
<span style="text-transform:none;opacity:.7;font-size:9px">(nur fremd)</span>
</span>
<select id="sel-run-compare" class="btn" style="min-width:158px">
<option value=""> keiner </option>
</select>
</div>
<div id="canvas-wrap">
<canvas id="cv"></canvas>
<div id="hint">Orbit · Scroll · Rechte Taste = Pan</div>
</div>
<div id="table-wrap"></div>
<div id="viewer-log"></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
const gCompare = new THREE.Group(); // Vergleichs-Punkte (anderer Timestamp, nur fremd)
const gCompareLines = new THREE.Group(); // Verbindungslinien Basis↔Vergleich
scene.add(gPaper, gMarkers, gMeasured, gCameras, gCompare, gCompareLines);
// ── Zustand für Vergleichs-Linien ─────────────────────────────────────────────
let _primaryFremdMarkers = []; // [{marker_id, position_mm, num_cameras}]
let _compareFremdMarkers = []; // [{marker_id, position_mm, num_cameras}]
// ── Viewer-interner Logger ────────────────────────────────────────────────────
function vlog(msg, kind = '') {
const el = document.getElementById('viewer-log');
if (!el) return;
const d = document.createElement('div');
d.className = kind ? `vl-${kind}` : '';
d.textContent = `${new Date().toLocaleTimeString('de-CH')} ${msg}`;
el.appendChild(d);
while (el.children.length > 40) el.removeChild(el.firstChild);
el.scrollTop = el.scrollHeight;
}
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 = Infinity; // Minimum aller z tiefster Marker (z=up → kleinstes = am tiefsten)
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;
if (rz < markerRz) markerRz = rz;
}
if (!isFinite(markerRz)) markerRz = -27.3;
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 isA0 = m.set === 'A0';
// A0: grün (erkannt) / rot (nicht erkannt) andere Sets: blau / dunkelblau
const color = isA0
? (detected ? 0x22c55e : 0xef4444)
: (detected ? 0x3b82f6 : 0x1e40af);
const sq = makeMarkerSquare(pos, visSize, color);
sq.position.y += 0.0005;
gMarkers.add(sq);
const borderCol = isA0
? (detected ? 0x4ade80 : 0xfca5a5)
: (detected ? 0x60a5fa : 0x3b82f6);
const border = makeEdgeBorder(pos, visSize, borderCol);
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;
}
// ── Vergleichs-Overlay: Transparenz + Linien ─────────────────────────────────
/**
* Setzt Board-Marker und Papier-Ebene auf geringere Deckkraft, wenn der
* Vergleichs-Modus aktiv ist (compareActive=true → 10 % Deckkraft).
* Ursprüngliche Deckkraft wird pro Material einmalig gespeichert.
*/
function setSceneOpacity(compareActive) {
for (const obj of [...gPaper.children, ...gMarkers.children]) {
const mats = obj.material
? (Array.isArray(obj.material) ? obj.material : [obj.material])
: [];
for (const mat of mats) {
if (!mat) continue;
if (mat._origOpacity === undefined) mat._origOpacity = mat.opacity ?? 1.0;
if (mat._origTransparent === undefined) mat._origTransparent = mat.transparent ?? false;
mat.transparent = compareActive || mat._origTransparent;
mat.opacity = compareActive ? mat._origOpacity * 0.10 : mat._origOpacity;
mat.needsUpdate = true;
}
}
}
/**
* Zeichnet Verbindungslinien von Basis-fremd-Markern zu gleich-ID-Vergleichs-Markern.
* Aktualisiert gleichzeitig die Board-Transparenz.
*/
function buildCompareLines() {
clearGroup(gCompareLines);
const compareActive = _compareFremdMarkers.length > 0;
setSceneOpacity(compareActive);
if (!compareActive) {
vlog(`Vergleich inaktiv Board-Marker voll sichtbar`);
return;
}
if (!_primaryFremdMarkers.length) {
vlog(`⚠ Basis hat 0 fremd-Marker keine Linien möglich`, 'warn');
return;
}
const primaryMap = new Map(_primaryFremdMarkers.map(m => [m.marker_id, m]));
const matchedIds = [];
const onlyCompare = []; // in compare aber nicht in primary
const onlyPrimary = [...primaryMap.keys()]; // werden unten gefiltert
for (const cm of _compareFremdMarkers) {
const pm = primaryMap.get(cm.marker_id);
if (!pm) { onlyCompare.push(cm.marker_id); continue; }
matchedIds.push(cm.marker_id);
// Linie: Basis-Position (blau) → Vergleichs-Position (orange)
gCompareLines.add(makeLine(r2vArr(pm.position_mm), r2vArr(cm.position_mm), 0xfb923c, 0.85));
}
const noMatch = onlyPrimary.filter(id => !matchedIds.includes(id));
const parts = [`${matchedIds.length} Linien`];
if (matchedIds.length) parts.push(`IDs: ${matchedIds.join(' ')}`);
if (onlyCompare.length) parts.push(`nur Vergleich: ${onlyCompare.join(' ')}`);
if (noMatch.length) parts.push(`nur Basis: ${noMatch.join(' ')}`);
vlog(parts.join(' | '), matchedIds.length ? 'ok' : 'warn');
// ── Bewegungsanalyse: mittlerer Verschiebungsvektor → Abweichung von X-Achse ──
if (matchedIds.length > 0) {
// Summe aller Verschiebungsvektoren (in Roboter-Koordinaten mm)
let sx = 0, sy = 0, sz = 0;
for (const cm of _compareFremdMarkers) {
const pm = primaryMap.get(cm.marker_id);
if (!pm) continue;
const [pmx, pmy, pmz] = pm.position_mm.map(Number);
const [cmx, cmy, cmz] = cm.position_mm.map(Number);
sx += cmx - pmx; sy += cmy - pmy; sz += cmz - pmz;
}
const n = matchedIds.length;
const dx = sx / n, dy = sy / n, dz = sz / n;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz);
if (dist > 0.01) { // mindestens 0.01 mm Bewegung
// Einheitsvektor immer in Richtung positives X zeigen (konsistente Vorzeichen)
let vx = dx/dist, vy = dy/dist, vz = dz/dist;
if (vx < 0) { vx = -vx; vy = -vy; vz = -vz; }
// Abweichungswinkel zur X-Achse [1,0,0] in Roboter-Koordinaten
// horizontal (XY-Ebene, Rotation um Z): positiv = nach Y (rückwärts) verschoben
// vertikal (XZ-Ebene, Rotation um Y): positiv = nach oben (+Z) verschoben
const degXY = Math.atan2(vy, vx) * 180 / Math.PI;
const degXZ = Math.atan2(vz, vx) * 180 / Math.PI;
const fmt = v => (v >= 0 ? '+' : '') + v.toFixed(3) + '°';
const good = Math.abs(degXY) < 0.5 && Math.abs(degXZ) < 0.5;
vlog(`Bewegung: ⌀${dist.toFixed(2)} mm dir=[${vx.toFixed(4)}, ${vy.toFixed(4)}, ${vz.toFixed(4)}] (${n} Marker)`);
vlog(`Abw. von X-Achse: horizontal(XY) ${fmt(degXY)} vertikal(XZ) ${fmt(degXZ)}`, good ? 'ok' : 'warn');
// Parent informieren → aktiviert "X-Achse übernehmen"-Button
window.parent.postMessage({
type: 'xaxis-measurement',
direction: [vx, vy, vz],
angleXY: degXY,
angleXZ: degXZ,
numMarkers: n,
distMm: dist,
}, '*');
} else {
vlog(`Bewegung zu klein (${dist.toFixed(3)} mm) Winkelberechnung übersprungen`, 'warn');
// Messung ungültig → Parent mitteilen
window.parent.postMessage({ type: 'xaxis-measurement', direction: null }, '*');
}
}
}
// ── Daten laden ───────────────────────────────────────────────────────────────
/** Haupt-Run laden (Basis-Dropdown). Ohne Selektion → neuester Run. */
async function loadData() {
const statusEl = document.getElementById('status');
statusEl.textContent = 'Laden …';
const selRun = document.getElementById('sel-run-primary')?.value ?? '';
const url = selRun
? `/api/board/latest?run=${encodeURIComponent(selRun)}`
: '/api/board/latest';
try {
const r = await fetch(url);
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);
// Fremd-Marker für Verbindungslinien merken (Marker, die nicht in Board-Link stehen)
const bIds = new Set((data.robot?.links?.Board?.markers ?? []).map(m => m.id));
_primaryFremdMarkers = (data.measuredMarkers?.markers ?? []).filter(m => !bIds.has(m.marker_id));
const measTotal = data.measuredMarkers?.markers?.length ?? 0;
vlog(`Basis: run=${data.runDir} gesamt=${measTotal} fremd=${_primaryFremdMarkers.length} boardIDs=${bIds.size}` +
(_primaryFremdMarkers.length ? ` (${_primaryFremdMarkers.map(m => m.marker_id).join(' ')})` : ''));
buildCompareLines();
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}`;
}
}
/**
* Vergleichs-Run laden (Compare-Dropdown).
* Zeigt nur fremd-triangulierte Marker (nicht im Board-Link) als orange Kugeln.
* Zieht außerdem Verbindungslinien zu gleich-ID-Markern im Basis-Run.
*/
async function loadCompareData() {
clearGroup(gCompare);
_compareFremdMarkers = [];
const selRun = document.getElementById('sel-run-compare')?.value ?? '';
if (!selRun) { vlog('Vergleich: keiner gewählt'); buildCompareLines(); return; }
vlog(`Vergleich: lade ${selRun}`);
try {
const r = await fetch(`/api/board/latest?run=${encodeURIComponent(selRun)}`);
if (!r.ok) { vlog(`Vergleich: HTTP ${r.status}`, 'err'); buildCompareLines(); return; }
const data = await r.json();
const markers = data.measuredMarkers?.markers ?? [];
// Board-Marker-IDs aus Robot.json dieses Runs
const boardIds = new Set((data.robot?.links?.Board?.markers ?? []).map(m => m.id));
for (const m of markers) {
if (!boardIds.has(m.marker_id)) {
_compareFremdMarkers.push(m); // für Linien
gCompare.add(makeSphere(r2vArr(m.position_mm), 0.006, 0xf97316)); // orange Kugel
}
}
vlog(`Vergleich: ${markers.length} gesamt fremd=${_compareFremdMarkers.length} boardIDs=${boardIds.size}` +
(_compareFremdMarkers.length ? ` (${_compareFremdMarkers.map(m => m.marker_id).join(' ')})` : ''));
} catch (err) { vlog(`Vergleich Fehler: ${err}`, 'err'); }
buildCompareLines(); // Linien + Transparenz aktualisieren
}
/** Run-Listen laden und beide Dropdowns befüllen. */
async function initRunSelectors() {
try {
const [r5, r10] = await Promise.all([
fetch('/api/board/runs?limit=5'),
fetch('/api/board/runs?limit=10'),
]);
const { runs: runs5 } = r5.ok ? await r5.json() : { runs: [] };
const { runs: runs10 } = r10.ok ? await r10.json() : { runs: [] };
const selP = document.getElementById('sel-run-primary');
const selC = document.getElementById('sel-run-compare');
const cur = selP?.value ?? ''; // aktuell gewählten Run behalten
if (selP) {
selP.innerHTML = '<option value="">⟳ aktuellster</option>' +
runs5.map(r => `<option value="${r}"${r === cur ? ' selected' : ''}>${r}</option>`).join('');
}
if (selC) {
// Default: zweiten neuesten Run vorwählen (falls vorhanden und noch kein Wert gesetzt)
const prevCompare = selC.value;
selC.innerHTML = '<option value=""> keiner </option>' +
runs10.map(r => `<option value="${r}">${r}</option>`).join('');
if (prevCompare) {
selC.value = prevCompare; // bisher gewählten behalten
} else if (runs10.length >= 2) {
selC.value = runs10[1]; // zweiter neuester als Default
}
}
} catch { /* offline oder noch keine Runs */ }
}
/** Vollständige Initialisierung: Selektoren → Basis → Vergleich (sequenziell!) */
async function initAll() {
await initRunSelectors();
await loadData(); // setzt _primaryFremdMarkers
await loadCompareData(); // setzt _compareFremdMarkers + baut Linien
}
initAll();
document.getElementById('btnReload').addEventListener('click', initAll);
document.getElementById('sel-run-primary')?.addEventListener('change', async () => {
await loadData();
await loadCompareData();
});
document.getElementById('sel-run-compare')?.addEventListener('change', loadCompareData);
window.addEventListener('message', async (e) => {
if (e.data?.type === 'reload') await initAll();
});
// ── 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>