Files
appRobotHoming/public/boardViewer.html
2026-06-15 23:06:04 +02:00

1241 lines
49 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>
<!-- reine Berechnungslogik (kein DOM/Three.js) auch von Jest-Tests genutzt -->
<script src="/yAxisCompute.js"></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
// ── Modus-Erkennung ──────────────────────────────────────────────────────────
const _urlParams = new URLSearchParams(window.location.search);
const IS_HOMING = _urlParams.get('mode') === 'homing';
if (IS_HOMING) {
document.getElementById('run-bar').style.display = 'none';
}
// 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)
const gSkeleton = new THREE.Group(); // Roboter-Skeleton (FK, nur im Homing-Mode)
const gArmMarkers = new THREE.Group(); // Arm-Marker Modellpositionen + Fehlerlinien (FK)
scene.add(gPaper, gMarkers, gMeasured, gCameras, gCompare, gCompareLines, gPositionC, gYAxis, gSkeleton, gArmMarkers);
const LINK_COLORS = {
Board: 0x8b6528, Base: 0x888888,
Arm1: 0x3355cc, Ellbow: 0xaaaaaa, Arm2: 0xddcc88,
Hand: 0xcc8833, Palm: 0xcc3333,
FingerA: 0x33aa33, FingerB: 0x33aa33,
};
// ── 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}]
// ── Homing-Mode Zustand ───────────────────────────────────────────────────────
let _currentRobot = null; // robot.json nach loadData()
let _homingAngles = null; // { x, y, z, a, b, c, e } nach Homing-Run
let _measuredMarkers = null; // measuredMarkers aus letztem buildScene-Aufruf
// ── Roboter-Skeleton (Forward Kinematics) ─────────────────────────────────────
/**
* Zeichnet das Roboter-Skeleton mit vorwärts-kinematischer Berechnung.
* Nutzt skeleton.from/to aus robot.json (in lokalem Link-Frame).
* angles: { x_mm→x, y_deg→y, z_deg→z, a_deg→a, b_deg→b, c_deg→c, e_mm→e }
*/
function buildSkeletonFK(robot, angles) {
clearGroup(gSkeleton);
clearGroup(gArmMarkers);
if (!robot?.links) return;
const links = robot.links;
const order = ['Base', 'Arm1', 'Ellbow', 'Arm2', 'Hand', 'Palm', 'FingerA', 'FingerB'];
// frames: Link-Name → Matrix4 (link-lokal → Welt)
const frames = { Board: new THREE.Matrix4() }; // Board = Welt-Ursprung
for (const linkName of order) {
const link = links[linkName];
if (!link?.jointToParent) continue;
const parentName = link.parent ?? 'Board';
const parentFrame = frames[parentName] ?? new THREE.Matrix4();
const jtp = link.jointToParent;
// 1. Translation zum Gelenk-Ursprung (im Parent-Frame)
const [ox, oy, oz] = jtp.origin ?? [0, 0, 0];
const T_origin = new THREE.Matrix4().makeTranslation(ox * S, oz * S, -oy * S);
// 2. Gelenk-Transformation (Rotation/Translation je nach Typ)
const varName = jtp.variable;
const q = angles?.[varName] ?? 0;
let T_joint = new THREE.Matrix4(); // Einheitsmatrix bei q=0
if (jtp.type === 'revolute') {
const [ax, ay, az] = jtp.axis ?? [0, 1, 0];
// robot (x,y,z) → Three.js (x, z, -y)
const axisV = new THREE.Vector3(ax, az, -ay).normalize();
T_joint.makeRotationAxis(axisV, q * Math.PI / 180);
} else if (jtp.type === 'linear') {
const [ax, ay, az] = jtp.axis ?? [1, 0, 0];
T_joint.makeTranslation(ax * q * S, az * q * S, -ay * q * S);
}
// Child-Frame = Parent-Frame × T_origin × T_joint
const childFrame = parentFrame.clone().multiply(T_origin).multiply(T_joint);
frames[linkName] = childFrame;
// 3. Skeleton-Segment zeichnen
const skel = link.skeleton;
if (skel?.from && skel?.to) {
const [fx, fy, fz] = skel.from;
const [tx, ty, tz] = skel.to;
const fromW = new THREE.Vector3(fx * S, fz * S, -fy * S).applyMatrix4(childFrame);
const toW = new THREE.Vector3(tx * S, tz * S, -ty * S).applyMatrix4(childFrame);
const [cr, cg, cb] = skel.color ?? [0.8, 0.2, 0.2];
const color = new THREE.Color(cr, cg, cb);
const rad = Math.max((skel.radius ?? 4) * S, 0.004);
gSkeleton.add(makeLine(fromW, toW, color, 0.9));
gSkeleton.add(makeSphere(fromW, rad, color));
gSkeleton.add(makeSphere(toW, rad, color));
// Gelenk-Mittelpunkt (Welt-Ursprung des Link-Frames)
const jointW = new THREE.Vector3().applyMatrix4(childFrame);
gSkeleton.add(makeSphere(jointW, 0.004, 0xc8cdd8));
}
// 4. Arm-Marker zeichnen (Modellposition via FK, orientiertes Quadrat + spin)
if (link.markers?.length > 0) {
const col = LINK_COLORS[linkName] ?? 0xffffff;
for (const m of link.markers) {
if (!m.position) continue;
const [lx, ly, lz] = m.position;
const posWorld = new THREE.Vector3(lx * S, lz * S, -ly * S).applyMatrix4(childFrame);
const markerSizeM = (m.size ?? 25) * S;
const [nx, ny, nz] = m.normal ?? [0, 0, 1];
const normalW = new THREE.Vector3(nx, nz, -ny).transformDirection(childFrame).normalize();
// P1: Quadrat mit spin-Rotation (um die Marker-Normale in Welt-Koordinaten)
const markerMesh = makeMarkerSquareOriented(posWorld, normalW, markerSizeM, col);
const spinRad = ((m.spin ?? 0) * Math.PI) / 180;
if (Math.abs(spinRad) > 1e-6) {
markerMesh.quaternion.premultiply(
new THREE.Quaternion().setFromAxisAngle(normalW, spinRad)
);
}
gArmMarkers.add(markerMesh);
gArmMarkers.add(makeSphere(posWorld, 0.0006, col));
// P3b (Modell-Seite): Orientierungszeiger zur Ecke 0 (top-left bei spin=0)
// markerMesh.quaternion kodiert bereits Q_normal ∘ Q_spin
const ptrDir = new THREE.Vector3(1, 1, 0).normalize().applyQuaternion(markerMesh.quaternion);
const corner0W = posWorld.clone().add(ptrDir.multiplyScalar(markerSizeM * Math.SQRT1_2));
gArmMarkers.add(makeLine(posWorld, corner0W, col, 0.9));
gArmMarkers.add(makeSphere(corner0W, 0.0008, col));
// Modell-Normale (Pfeil, gleiche Link-Farbe, halbtransparent)
const modelNormalEnd = posWorld.clone().add(normalW.clone().multiplyScalar(markerSizeM * 1.5));
gArmMarkers.add(makeLine(posWorld, modelNormalEnd, col, 0.5));
gArmMarkers.add(makeSphere(modelNormalEnd, 0.0005, col));
}
}
}
// 5. Fehlerlinien: Modell-Marker → gemessene Position (aus 3b)
if (_measuredMarkers?.markers?.length > 0) {
// Fallback-Lookup: marker_id → Link-Name, falls obs.link fehlt oder 'Board' ist
const markerIdToLink = {};
for (const [lname, ldata] of Object.entries(links)) {
if (lname === 'Board') continue;
for (const m of (ldata.markers ?? [])) markerIdToLink[m.id] = lname;
}
for (const obs of _measuredMarkers.markers) {
const obsLink = (obs.link && obs.link !== 'Board')
? obs.link
: markerIdToLink[obs.marker_id];
if (!obsLink) continue;
const ldata = links[obsLink];
if (!ldata || !frames[obsLink]) continue;
const modelM = ldata.markers?.find(mm => mm.id === obs.marker_id);
if (!modelM?.position) continue;
const [lx, ly, lz] = modelM.position;
const modelPosW = new THREE.Vector3(lx * S, lz * S, -ly * S).applyMatrix4(frames[obsLink]);
const obsPosW = r2vArr(obs.position_mm);
const obsCol = LINK_COLORS[obsLink] ?? 0x3b82f6;
gArmMarkers.add(makeLine(modelPosW, obsPosW, 0xff8800, 0.85));
gArmMarkers.add(makeSphere(obsPosW, 0.007, obsCol));
// Beobachtungs-Ecke 0: Orientierungszeiger für Spin-Vergleich
// corners_m sind Weltkoordinaten in Metern → direkt in Three.js (Y↑=Z, Z↑=-Y)
const c0 = obs.corners_m?.[0];
if (c0) {
const obsCorner0W = new THREE.Vector3(c0[0], c0[2], -c0[1]);
gArmMarkers.add(makeLine(obsPosW, obsCorner0W, obsCol, 0.85));
gArmMarkers.add(makeSphere(obsCorner0W, 0.0012, obsCol));
}
// Beobachtungs-Normale (weißer Pfeil)
if (obs.normal) {
const [on0, on1, on2] = obs.normal;
const obsNormalW = new THREE.Vector3(on0, on2, -on1).normalize();
const arrowLen = (modelM.size ?? 25) * S * 1.5;
const obsNormalEnd = obsPosW.clone().add(obsNormalW.multiplyScalar(arrowLen));
gArmMarkers.add(makeLine(obsPosW, obsNormalEnd, 0xffffff, 0.55));
gArmMarkers.add(makeSphere(obsNormalEnd, 0.001, 0xffffff));
}
}
}
}
// ── 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 makeMarkerSquareOriented(pos, normalVec, size, color) {
const geo = new THREE.PlaneGeometry(size, size);
const mat = new THREE.MeshPhongMaterial({ color, side: THREE.DoubleSide, transparent: true, opacity: 0.85 });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(pos);
const n = normalVec.clone().normalize();
if (n.lengthSq() > 1e-9) {
mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), n);
}
return mesh;
}
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;
_measuredMarkers = measuredMarkers ?? null;
// 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;
}
// ── Berechnung via yAxisCompute.js (kein DOM/Three.js) ───────────────────
const result = YAxisCompute.computeYAxis(
_primaryFremdMarkers,
_compareFremdMarkers,
_positionCFremdMarkers,
);
if (!result.ok) {
vlog(`Y-Achse: ${result.reason}`, 'warn');
window.parent.postMessage({ type: 'yaxis-measurement', axisDir: null, skipped: result.skipped }, '*');
return;
}
const { axisDir, axisPoint, tiltXY, tiltYZ, skipped, markerData } = result;
// ── Visualisierung (Three.js) ─────────────────────────────────────────────
for (const { posB: P2, posC: P3, circumcenter: C } of markerData) {
gYAxis.add(makeSphere(r2vArr(C), 0.007, 0xfb7185)); // Umkreismittelpunkt (rose)
gYAxis.add(makeLine(r2vArr(P2), r2vArr(P3), 0x22d3ee, 0.6)); // Bogen B→C (cyan)
}
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));
// ── Logging ───────────────────────────────────────────────────────────────
const fmt = v => (v >= 0 ? '+' : '') + v.toFixed(3) + '°';
const good = Math.abs(tiltXY) < 0.5 && Math.abs(tiltYZ) < 0.5;
const usedIds = markerData.map(m => m.markerId);
const skippedIds = skipped.map(s => s.id);
vlog(`Y-Achse: ${usedIds.length} Marker genutzt (${usedIds.join(', ')})` +
(skippedIds.length ? ` · ${skippedIds.length} gefiltert (${skippedIds.join(', ')})` : ''));
vlog(` 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');
if (skippedIds.length) {
skipped.forEach(s => vlog(` ↳ Marker ${s.id} übersprungen: ${s.reason} (${s.maxMoveMm} mm)`, 'warn'));
}
window.parent.postMessage({
type: 'yaxis-measurement',
axisDir,
axisPoint,
tiltXY,
tiltYZ,
numMarkers: result.numMarkers,
numMarkersCommon: result.numMarkersCommon,
skipped,
// Für rotation_detection.json: Run-Referenzen und Marker-Rohdaten
runA: document.getElementById('sel-run-primary')?.value ?? null,
runB: document.getElementById('sel-run-compare')?.value ?? null,
runC: document.getElementById('sel-run-c')?.value ?? null,
markerData,
}, '*');
}
// ── Daten laden ───────────────────────────────────────────────────────────────
/**
* Haupt-Run laden.
* Im Homing-Mode: lädt spezifischen Run per runDir-Parameter (kein Dropdown).
* @param {string|null} specificRunDir Timestamp des Runs (nur im Homing-Mode genutzt)
*/
async function loadData(specificRunDir = null) {
const statusEl = document.getElementById('status');
statusEl.textContent = 'Laden …';
let url;
if (IS_HOMING) {
url = specificRunDir
? `/api/board/latest?from=homing&run=${encodeURIComponent(specificRunDir)}`
: '/api/board/latest?from=homing';
} else {
const selRun = document.getElementById('sel-run-primary')?.value ?? '';
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);
if (IS_HOMING) clearGroup(gSkeleton);
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')}`;
// Skeleton im Homing-Mode
if (IS_HOMING && data.robot) {
_currentRobot = data.robot;
const angles = _homingAngles ?? data.robot.defaultPosition ?? {};
buildSkeletonFK(data.robot, angles);
}
} 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() {
if (IS_HOMING) {
// Homing-Mode: nur neuester Run laden, kein Dropdown, kein Vergleich
await loadData();
return;
}
await initRunSelectors();
await loadData(); // setzt _primaryFremdMarkers
await loadCompareData(); // setzt _compareFremdMarkers + baut Linien + Y-Achse (no-op)
await loadPositionC(); // setzt _positionCFremdMarkers + berechnet Y-Achse
}
if (IS_HOMING) {
// Robot-Modell mit Defaultposition sofort laden (kein Board-Run nötig)
fetch('/api/robot').then(r => r.ok ? r.json() : null).then(robot => {
if (!robot) return;
_currentRobot = robot;
buildSkeletonFK(robot, robot.defaultPosition ?? {});
document.getElementById('status').textContent = '→ Homing-Run starten …';
}).catch(() => {
document.getElementById('status').textContent = '→ Homing-Run starten …';
});
} else {
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();
if (e.data?.type === 'load-homing-run' && IS_HOMING) {
await loadData(e.data.runDir);
}
if (e.data?.type === 'homing-state' && IS_HOMING) {
// Gefundene Winkel über Default-Position mergen, damit noch nicht erkannte
// Gelenke nicht auf 0 zusammenfallen, sondern sinnvoll stehen bleiben.
const base = _currentRobot?.defaultPosition ?? {};
_homingAngles = { ...base, ...e.data.state };
if (_currentRobot) buildSkeletonFK(_currentRobot, _homingAngles);
}
});
// ── 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>