1187 lines
46 KiB
HTML
1187 lines
46 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<title>Board Viewer</title>
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
:root {
|
||
--bg: #0d0f13;
|
||
--panel: #161920;
|
||
--border: #2a2d35;
|
||
--text: #c8cdd8;
|
||
--muted: #555b6e;
|
||
--accent: #4a9eff;
|
||
}
|
||
body {
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font: 11px/1.5 'IBM Plex Mono', 'Cascadia Code', 'Courier New', monospace;
|
||
display: flex;
|
||
flex-direction: column;
|
||
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, als orientiertes Quadrat)
|
||
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 normalWorld = new THREE.Vector3(nx, nz, -ny).transformDirection(childFrame);
|
||
gArmMarkers.add(makeMarkerSquareOriented(posWorld, normalWorld, markerSizeM, col));
|
||
gArmMarkers.add(makeSphere(posWorld, 0.003, col));
|
||
}
|
||
}
|
||
}
|
||
|
||
// 5. Fehlerlinien: Modell-Marker → gemessene Position (aus 3b)
|
||
if (_measuredMarkers?.markers?.length > 0) {
|
||
for (const obs of _measuredMarkers.markers) {
|
||
if (obs.link === 'Board' || !obs.link) continue;
|
||
const ldata = links[obs.link];
|
||
if (!ldata || !frames[obs.link]) 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[obs.link]);
|
||
const obsPosW = r2vArr(obs.position_mm);
|
||
gArmMarkers.add(makeLine(modelPosW, obsPosW, 0xff8800, 0.85));
|
||
gArmMarkers.add(makeSphere(obsPosW, 0.007, LINK_COLORS[obs.link] ?? 0x3b82f6));
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 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) {
|
||
_homingAngles = 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>
|