Files
appRobotHoming/public/boardViewer.html
2026-06-10 21:48:44 +02:00

1051 lines
40 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>Pos B</span>
<span><span class="dot circle" style="background:#22d3ee"></span>Pos C</span>
<span><span class="dot circle" style="background:#fb7185"></span>Kreismittelpkt.</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">Pos B
<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>
<span class="run-lbl" style="margin-left:10px">Pos C
<span style="text-transform:none;opacity:.7;font-size:9px">(Y-Achse)</span>
</span>
<select id="sel-run-c" 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(); // Pos B Marker (nur fremd, orange)
const gCompareLines = new THREE.Group(); // Verbindungslinien Pos A↔Pos B
const gPositionC = new THREE.Group(); // Pos C Marker (nur fremd, cyan)
const gYAxis = new THREE.Group(); // Y-Achse Visualisierung (Kreismittelpunkte, Achse)
scene.add(gPaper, gMarkers, gMeasured, gCameras, gCompare, gCompareLines, gPositionC, gYAxis);
// ── Zustand für Positionen ────────────────────────────────────────────────────
let _primaryFremdMarkers = []; // Pos A [{marker_id, position_mm, num_cameras}]
let _compareFremdMarkers = []; // Pos B [{marker_id, position_mm, num_cameras}]
let _positionCFremdMarkers = []; // Pos C [{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 Pos B: ${onlyCompare.join(' ')}`);
if (noMatch.length) parts.push(`nur Pos A: ${noMatch.join(' ')}`);
vlog(parts.join(' | '), matchedIds.length ? 'ok' : 'warn');
// ── Bewegungsanalyse ──────────────────────────────────────────────────────────
// Pos C aktiv → Bogen-Modus (Y-Achse), keine lineare X-Achsen-Analyse
const posC_active = !!document.getElementById('sel-run-c')?.value;
if (posC_active) {
vlog('Pos C aktiv → Bogen-Modus, X-Achsen-Analyse übersprungen');
window.parent.postMessage({ type: 'xaxis-measurement', direction: null }, '*');
} else if (matchedIds.length > 0) {
// ── Lineare X-Achsen-Analyse (mittlerer Verschiebungsvektor) ──────────────
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');
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');
window.parent.postMessage({ type: 'xaxis-measurement', direction: null }, '*');
}
}
// Y-Achse neu berechnen (nutzt _positionCFremdMarkers kein-op wenn leer)
computeAndShowYAxis();
}
// ── Y-Achsen-Berechnung aus drei Positionen ───────────────────────────────────
function computeAndShowYAxis() {
clearGroup(gYAxis);
// Nur aktiv wenn alle drei Positionen vorliegen
if (_positionCFremdMarkers.length === 0) return;
if (_primaryFremdMarkers.length === 0) return;
if (_compareFremdMarkers.length === 0) {
vlog('Y-Achse: Pos B nicht gewählt kein Vergleich aktiv', 'warn');
return;
}
const mapA = new Map(_primaryFremdMarkers .map(m => [m.marker_id, m]));
const mapB = new Map(_compareFremdMarkers .map(m => [m.marker_id, m]));
const mapC = new Map(_positionCFremdMarkers.map(m => [m.marker_id, m]));
function dist2(P, Q) { return (P[0]-Q[0])**2 + (P[1]-Q[1])**2 + (P[2]-Q[2])**2; }
const circumcenters = []; // [{id, C:[x,y,z]}] in mm
const normals = []; // [[nx,ny,nz]] Achsenrichtung je Marker
for (const [id, ma] of mapA) {
const mb = mapB.get(id);
const mc = mapC.get(id);
if (!mb || !mc) continue;
const P1 = ma.position_mm.map(Number);
const P2 = mb.position_mm.map(Number);
const P3 = mc.position_mm.map(Number);
// Normalenvektor der Kreisebene = Achsenrichtung
const v1 = [P2[0]-P1[0], P2[1]-P1[1], P2[2]-P1[2]];
const v2 = [P3[0]-P1[0], P3[1]-P1[1], P3[2]-P1[2]];
const cross = [
v1[1]*v2[2] - v1[2]*v2[1],
v1[2]*v2[0] - v1[0]*v2[2],
v1[0]*v2[1] - v1[1]*v2[0],
];
const crossLen = Math.sqrt(cross[0]**2 + cross[1]**2 + cross[2]**2);
if (crossLen < 1e-3) {
vlog(`Y-Achse: Marker ${id} degenerat (Punkte zu nahe / kollinear)`, 'warn');
continue;
}
const n = cross.map(c => c / crossLen);
normals.push(n);
// Umkreismittelpunkt (bary­zentrischer Ansatz)
const a2 = dist2(P2, P3), b2 = dist2(P1, P3), c2 = dist2(P1, P2);
const w1 = a2*(b2+c2-a2), w2 = b2*(a2+c2-b2), w3 = c2*(a2+b2-c2);
const wSum = w1 + w2 + w3;
if (Math.abs(wSum) < 1e-6) {
vlog(`Y-Achse: Marker ${id} Umkreis undefiniert`, 'warn');
continue;
}
const C = [
(w1*P1[0] + w2*P2[0] + w3*P3[0]) / wSum,
(w1*P1[1] + w2*P2[1] + w3*P3[1]) / wSum,
(w1*P1[2] + w2*P2[2] + w3*P3[2]) / wSum,
];
circumcenters.push({ id, C });
// Kreismittelpunkt (rose)
gYAxis.add(makeSphere(r2vArr(C), 0.007, 0xfb7185));
// Bogen-Linie B→C (cyan)
gYAxis.add(makeLine(r2vArr(P2), r2vArr(P3), 0x22d3ee, 0.6));
}
if (circumcenters.length === 0) {
vlog('Y-Achse: Keine gemeinsamen fremd-Marker in Pos A+B+C gefunden', 'warn');
window.parent.postMessage({ type: 'yaxis-measurement', axisDir: null }, '*');
return;
}
// Achsenrichtung: Mittlere Normale (Vorzeichen angleichen)
const ref = normals[0];
const aligned = normals.map(n => {
const dot = n[0]*ref[0] + n[1]*ref[1] + n[2]*ref[2];
return dot >= 0 ? n : n.map(c => -c);
});
const meanN = [0, 1, 2].map(i => aligned.reduce((s, n) => s + n[i], 0) / aligned.length);
const meanNLen = Math.sqrt(meanN[0]**2 + meanN[1]**2 + meanN[2]**2);
const axisDir = meanN.map(c => c / meanNLen); // Roboter-Koordinaten
// Referenzpunkt: Schwerpunkt der Umkreismittelpunkte
const axisPoint = [0, 1, 2].map(i =>
circumcenters.reduce((s, c) => s + c.C[i], 0) / circumcenters.length
);
// Achse als Linie ±500 mm visualisieren (magenta)
const L = 500;
const p1mm = axisPoint.map((v, i) => v - L * axisDir[i]);
const p2mm = axisPoint.map((v, i) => v + L * axisDir[i]);
gYAxis.add(makeLine(r2vArr(p1mm), r2vArr(p2mm), 0xe879f9, 0.9));
gYAxis.add(makeSphere(r2vArr(axisPoint), 0.011, 0xe879f9));
// Abweichung von der idealen Y-Achse [0,1,0] in Roboter-Koordinaten
const [ax, ay, az] = axisDir;
const tiltXY = Math.atan2(ax, ay) * 180 / Math.PI; // Kippung in XY-Ebene
const tiltYZ = Math.atan2(az, ay) * 180 / Math.PI; // Kippung in YZ-Ebene
const fmt = v => (v >= 0 ? '+' : '') + v.toFixed(3) + '°';
const good = Math.abs(tiltXY) < 0.5 && Math.abs(tiltYZ) < 0.5;
vlog(`Y-Achse (${circumcenters.length} Marker): dir=[${axisDir.map(v => v.toFixed(4)).join(', ')}]`);
vlog(` Referenzpunkt: [${axisPoint.map(v => v.toFixed(1)).join(', ')}] mm`);
vlog(` Abw. von Y-Achse: XY ${fmt(tiltXY)} YZ ${fmt(tiltYZ)}`, good ? 'ok' : 'warn');
window.parent.postMessage({
type: 'yaxis-measurement',
axisDir,
axisPoint,
tiltXY,
tiltYZ,
numMarkers: circumcenters.length,
}, '*');
}
// ── 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(`Pos B Fehler: ${err}`, 'err'); }
buildCompareLines(); // Linien + Transparenz + Y-Achse aktualisieren
}
/**
* Pos C laden.
* Zeigt nur fremd-triangulierte Marker als cyan Kugeln.
* Danach wird Y-Achse neu berechnet (computeAndShowYAxis).
*/
async function loadPositionC() {
clearGroup(gPositionC);
_positionCFremdMarkers = [];
const selRun = document.getElementById('sel-run-c')?.value ?? '';
if (!selRun) {
vlog('Pos C: nicht gewählt');
computeAndShowYAxis(); // räumt gYAxis auf
return;
}
vlog(`Pos C: lade ${selRun}`);
try {
const r = await fetch(`/api/board/latest?run=${encodeURIComponent(selRun)}`);
if (!r.ok) { vlog(`Pos C: HTTP ${r.status}`, 'err'); computeAndShowYAxis(); return; }
const data = await r.json();
const markers = data.measuredMarkers?.markers ?? [];
const boardIds = new Set((data.robot?.links?.Board?.markers ?? []).map(m => m.id));
for (const m of markers) {
if (!boardIds.has(m.marker_id)) {
_positionCFremdMarkers.push(m);
gPositionC.add(makeSphere(r2vArr(m.position_mm), 0.006, 0x22d3ee)); // cyan
}
}
vlog(`Pos C: ${markers.length} gesamt fremd=${_positionCFremdMarkers.length}` +
(_positionCFremdMarkers.length ? ` (${_positionCFremdMarkers.map(m => m.marker_id).join(' ')})` : ''));
} catch (err) { vlog(`Pos C Fehler: ${err}`, 'err'); }
computeAndShowYAxis();
}
/** Run-Listen laden und alle 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('');
}
// ── Default-Verhalten per URL-Param steuern ──────────────────────────────
// ?defaults=a → nur Pos A bekommt Default (Board-Tab, Y-Achse-Tab)
// ?defaults=ab → Pos A + Pos B (X-Achsen-Tab)
// ?defaults=abc → alle drei (Arm-Tab)
// kein Param → wie "ab" (Rückwärtskompatibilität)
// ?compare=none → wie "a" (Rückwärtskompatibilität)
const params = new URLSearchParams(window.location.search);
const rawDef = params.get('defaults')
?? (params.get('compare') === 'none' ? 'a' : 'ab');
const defB = rawDef.includes('b');
const defC = rawDef.includes('c');
if (selC) {
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 (defB && runs10.length >= 2) {
selC.value = runs10[1]; // zweiter neuester als Default
}
}
// ── Pos C ──
const selRunC = document.getElementById('sel-run-c');
if (selRunC) {
const prevC = selRunC.value;
selRunC.innerHTML = '<option value=""> keiner </option>' +
runs10.map(r => `<option value="${r}">${r}</option>`).join('');
if (prevC) {
selRunC.value = prevC; // bisher gewählten behalten
} else if (defC && runs10.length >= 3) {
selRunC.value = runs10[2]; // dritter neuester als Default
}
}
} catch { /* offline oder noch keine Runs */ }
}
/** Vollständige Initialisierung: Selektoren → Pos A → Pos B → Pos C (sequenziell!) */
async function initAll() {
await initRunSelectors();
await loadData(); // setzt _primaryFremdMarkers
await loadCompareData(); // setzt _compareFremdMarkers + baut Linien + Y-Achse (no-op)
await loadPositionC(); // setzt _positionCFremdMarkers + berechnet Y-Achse
}
initAll();
document.getElementById('btnReload').addEventListener('click', initAll);
document.getElementById('sel-run-primary')?.addEventListener('change', async () => {
await loadData();
await loadCompareData();
await loadPositionC();
});
document.getElementById('sel-run-compare')?.addEventListener('change', async () => {
await loadCompareData(); // baut Linien + ruft computeAndShowYAxis auf
});
document.getElementById('sel-run-c')?.addEventListener('change', loadPositionC);
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>