Files
appRobotRender/run/robot_viewer.html
2026-06-02 07:09:26 +02:00

996 lines
34 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="en">
<head>
<meta charset="UTF-8">
<title>Robot FK Viewer</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0f13;
--panel: #161920;
--border: #2a2d35;
--text: #c8cdd8;
--accent: #4a9eff;
--muted: #555b6e;
--ok: #3ecf6b;
--warn: #f59e0b;
--err: #ff4f4f;
--panel-w: 300px;
}
body {
background: var(--bg);
color: var(--text);
font: 13px/1.5 'IBM Plex Mono', 'Cascadia Code', 'Courier New', monospace;
display: flex;
height: 100vh;
overflow: hidden;
}
/* ── sidebar ── */
#sidebar {
width: var(--panel-w);
flex-shrink: 0;
background: var(--panel);
border-right: 1px solid var(--border);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0;
}
.section {
border-bottom: 1px solid var(--border);
padding: 12px 14px;
}
.section h3 {
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 8px;
}
/* file inputs */
.file-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.file-row label {
font-size: 11px;
color: var(--muted);
width: 58px;
flex-shrink: 0;
}
.file-row input[type=file] {
flex: 1;
font-size: 10px;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
padding: 3px 6px;
cursor: pointer;
}
.file-row input[type=file]::file-selector-button {
background: var(--border);
color: var(--text);
border: none;
padding: 2px 8px;
cursor: pointer;
font-family: inherit;
font-size: 10px;
border-radius: 2px;
margin-right: 6px;
}
/* joint sliders */
.slider-row {
display: grid;
grid-template-columns: 22px 1fr 48px;
align-items: center;
gap: 6px;
margin-bottom: 5px;
}
.slider-row span { color: var(--accent); font-size: 12px; }
input[type=range] {
-webkit-appearance: none;
height: 3px;
background: var(--border);
border-radius: 2px;
outline: none;
cursor: pointer;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px; height: 12px;
background: var(--accent);
border-radius: 50%;
}
.val-box {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
padding: 2px 4px;
font-size: 11px;
text-align: right;
color: var(--text);
width: 48px;
}
/* toggles */
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 5px;
}
.toggle-label { font-size: 11px; color: var(--text); }
.toggle {
position: relative; width: 36px; height: 18px;
}
.toggle input { display: none; }
.slider-track {
position: absolute; inset: 0;
background: var(--border);
border-radius: 9px;
cursor: pointer;
transition: background .2s;
}
.slider-track::after {
content: '';
position: absolute;
left: 2px; top: 2px;
width: 14px; height: 14px;
background: var(--muted);
border-radius: 50%;
transition: transform .2s, background .2s;
}
.toggle input:checked + .slider-track { background: var(--accent); }
.toggle input:checked + .slider-track::after {
transform: translateX(18px);
background: #fff;
}
/* stats */
#stats-content {
font-size: 11px;
line-height: 1.8;
}
.stat-line { display: flex; justify-content: space-between; }
.stat-val { color: var(--accent); }
.stat-ok { color: var(--ok); }
.stat-warn { color: var(--warn); }
.stat-err { color: var(--err); }
#status-bar {
font-size: 10px;
color: var(--muted);
padding: 8px 14px;
border-top: 1px solid var(--border);
margin-top: auto;
}
/* canvas */
#canvas-wrap {
flex: 1;
position: relative;
overflow: hidden;
}
canvas { display: block; width: 100%; height: 100%; }
</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="sidebar">
<!-- files -->
<div class="section">
<h3>Data Files</h3>
<div class="file-row">
<label>robot.json</label>
<input type="file" id="fRobot" accept=".json">
</div>
<div class="file-row">
<label>aruco</label>
<input type="file" id="fAruco" accept=".json">
</div>
<div class="file-row">
<label>solution</label>
<input type="file" id="fSolution" accept=".json">
</div>
<button id="btnJump" disabled style="width:100%;margin-top:8px;background:var(--accent);
color:#06101a;border:none;border-radius:3px;padding:6px;font:inherit;font-size:11px;
font-weight:bold;cursor:pointer;opacity:0.45;">Auf Lösung springen</button>
<div id="solInfo" style="font-size:10px;color:var(--muted);min-height:12px;margin-top:5px;"></div>
</div>
<!-- test poses -->
<div class="section" id="poseSection" style="display:none">
<h3>Test Poses</h3>
<select id="poseSelect" style="
width:100%; background:var(--bg); border:1px solid var(--border);
color:var(--text); font:inherit; font-size:11px; padding:4px 6px;
border-radius:3px; cursor:pointer; margin-bottom:4px;">
<option value="">— select pose —</option>
</select>
<div id="poseInfo" style="font-size:10px;color:var(--muted);min-height:14px;"></div>
</div>
<!-- joint sliders -->
<div class="section">
<h3>Joint Values</h3>
<div id="sliders"></div>
</div>
<!-- display toggles -->
<div class="section">
<h3>Display</h3>
<div class="toggle-row">
<span class="toggle-label">Skeleton</span>
<label class="toggle"><input type="checkbox" id="tSkeleton" checked><span class="slider-track"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">Model markers</span>
<label class="toggle"><input type="checkbox" id="tModel" checked><span class="slider-track"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">Model normals</span>
<label class="toggle"><input type="checkbox" id="tNormals"><span class="slider-track"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">Observed markers</span>
<label class="toggle"><input type="checkbox" id="tObserved" checked><span class="slider-track"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">Observed normals</span>
<label class="toggle"><input type="checkbox" id="tObsNormals"><span class="slider-track"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">Error lines</span>
<label class="toggle"><input type="checkbox" id="tErrors" checked><span class="slider-track"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">Board plane</span>
<label class="toggle"><input type="checkbox" id="tBoard" checked><span class="slider-track"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">Cameras</span>
<label class="toggle"><input type="checkbox" id="tCameras" checked><span class="slider-track"></span></label>
</div>
</div>
<!-- stats -->
<div class="section">
<h3>Error Statistics</h3>
<div id="stats-content"><span style="color:var(--muted)">Load both files…</span></div>
</div>
<div id="status-bar">Drag to orbit · Scroll to zoom · Right-drag to pan</div>
</div>
<div id="canvas-wrap">
<canvas id="cv"></canvas>
</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// ═══════════════════════════════════════════════════════════════
// FK in plain JavaScript — mirrors robot_fk.py exactly
// ═══════════════════════════════════════════════════════════════
function norm(v) {
const n = Math.sqrt(v.reduce((s, x) => s + x * x, 0));
return n < 1e-12 ? v.map(() => 0) : v.map(x => x / n);
}
function mm3(A, B) { // 3×3 row-major mat-mat
const C = new Array(9).fill(0);
for (let i = 0; i < 3; i++)
for (let j = 0; j < 3; j++)
for (let k = 0; k < 3; k++)
C[i*3+j] += A[i*3+k] * B[k*3+j];
return C;
}
function rotAA(axis, deg) { // Rodrigues
const [x, y, z] = norm(axis);
const r = deg * Math.PI / 180, c = Math.cos(r), s = Math.sin(r), t = 1 - c;
return [t*x*x+c, t*x*y-s*z, t*x*z+s*y,
t*x*y+s*z, t*y*y+c, t*y*z-s*x,
t*x*z-s*y, t*y*z+s*x, t*z*z+c];
}
function rotXYZ(rx, ry, rz) {
return mm3(mm3(rotAA([0,0,1],rz), rotAA([0,1,0],ry)), rotAA([1,0,0],rx));
}
// 4×4 row-major
function makeT(R3, tx, ty, tz) {
return [R3[0],R3[1],R3[2],tx, R3[3],R3[4],R3[5],ty,
R3[6],R3[7],R3[8],tz, 0,0,0,1];
}
function I4() { return [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]; }
function mm4(A, B) {
const C = new Array(16).fill(0);
for (let i = 0; i < 4; i++)
for (let j = 0; j < 4; j++)
for (let k = 0; k < 4; k++)
C[i*4+j] += A[i*4+k] * B[k*4+j];
return C;
}
function applyT(T, p) {
return [T[0]*p[0]+T[1]*p[1]+T[2]*p[2]+T[3],
T[4]*p[0]+T[5]*p[1]+T[6]*p[2]+T[7],
T[8]*p[0]+T[9]*p[1]+T[10]*p[2]+T[11]];
}
function buildTopoOrder(links) {
const parent = Object.fromEntries(Object.entries(links).map(([n,d]) => [n, d.parent||null]));
const visited = new Set(), order = [];
const visit = n => {
if (visited.has(n)) return;
visited.add(n);
if (parent[n]) visit(parent[n]);
order.push(n);
};
Object.keys(links).forEach(visit);
return order;
}
function computeFK(robotData, joints) {
const links = robotData.links || {};
const state = {x:0,y:0,z:0,a:0,b:0,c:0,e:0, ...joints};
const T = {};
for (const lname of buildTopoOrder(links)) {
const ld = links[lname];
const par = ld.parent;
const Tp = T[par] || I4();
const mp = ld.mountPosition || [0,0,0];
const mr = ld.mountRotation || [0,0,0];
const Tm = makeT(rotXYZ(...mr), ...mp);
const jt = ld.jointToParent || {};
const jo = jt.origin || [0,0,0];
const jr = jt.rotation || [0,0,0];
const Tj = makeT(rotXYZ(...jr), ...jo);
const type = (jt.type||'').toLowerCase();
const axis = jt.axis || [1,0,0];
const v = state[jt.variable] || 0;
let Tmot = I4();
if (type === 'revolute') {
Tmot = makeT(rotAA(axis, v), 0, 0, 0);
} else if (type === 'linear') {
const [ax, ay, az] = norm(axis);
Tmot = makeT([1,0,0,0,1,0,0,0,1], ax*v, ay*v, az*v);
}
T[lname] = mm4(mm4(mm4(Tp, Tm), Tj), Tmot);
}
return T;
}
function markerWorld(T, link, local) {
return applyT(T[link] || I4(), local);
}
// ═══════════════════════════════════════════════════════════════
// Coordinate conversion robot-mm → Three.js scene (metres)
// robot: x=right, y=backward, z=up
// Three.js: x=right, y=up, z=toward viewer
// ═══════════════════════════════════════════════════════════════
const S = 1 / 1000;
function r2v(rx, ry, rz) { return new THREE.Vector3(rx*S, rz*S, -ry*S); }
function r2vArr(p) { return r2v(p[0], p[1], p[2]); }
// ═══════════════════════════════════════════════════════════════
// Three.js scene
// ═══════════════════════════════════════════════════════════════
const canvas = document.getElementById('cv');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.9;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0d0f13);
scene.fog = new THREE.FogExp2(0x0d0f13, 0.35);
const camera = new THREE.PerspectiveCamera(45, 1, 0.001, 20);
camera.position.set(0.35, 0.55, 1.1);
camera.lookAt(0.2, 0.05, 0);
const controls = new OrbitControls(camera, canvas);
controls.target.set(0.2, 0.05, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
// Lighting
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
const sun = new THREE.DirectionalLight(0xfff4e0, 1.4);
sun.position.set(-0.8, 1.2, 0.9);
sun.castShadow = true;
scene.add(sun);
const fill = new THREE.DirectionalLight(0xc0d8ff, 0.4);
fill.position.set(0.6, 0.3, -0.5);
scene.add(fill);
// Grid
const grid = new THREE.GridHelper(3, 30, 0x1e2230, 0x1a1e28);
grid.position.y = -0.028;
scene.add(grid);
// Axes helper (world origin)
const axes = new THREE.AxesHelper(0.1);
axes.position.set(0, 0, 0);
scene.add(axes);
// ─── geometry helpers ─────────────────────────────────────────
function makeCylinder(p1, p2, radius, color, opacity=1) {
const dir = p2.clone().sub(p1);
const len = dir.length();
if (len < 0.0001) return null;
const geo = new THREE.CylinderGeometry(radius, radius, len, 8, 1);
const mat = new THREE.MeshPhongMaterial({
color, transparent: opacity < 1, opacity,
shininess: 60
});
const m = new THREE.Mesh(geo, mat);
m.castShadow = true;
m.position.copy(p1.clone().add(p2).multiplyScalar(0.5));
m.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), dir.normalize());
return m;
}
function makeSphere(pos, radius, color) {
const geo = new THREE.SphereGeometry(radius, 12, 8);
const mat = new THREE.MeshPhongMaterial({ color, shininess: 80 });
const m = new THREE.Mesh(geo, mat);
m.position.copy(pos);
m.castShadow = true;
return m;
}
function transformDirByT(T, dir) {
return [
T[0]*dir[0] + T[1]*dir[1] + T[2]*dir[2],
T[4]*dir[0] + T[5]*dir[1] + T[6]*dir[2],
T[8]*dir[0] + T[9]*dir[1] + T[10]*dir[2],
];
}
function makeMarkerSquare(pos, normal, size, color) {
const geo = new THREE.BoxGeometry(size, size, size * 0.1);
const mat = new THREE.MeshPhongMaterial({
color,
shininess: 40
});
const m = new THREE.Mesh(geo, mat);
m.position.copy(pos);
// Fallback falls keine gültige Normale vorhanden
let nx = 0, ny = 0, nz = 1;
if (Array.isArray(normal) && normal.length >= 3) {
nx = Number(normal[0]) || 0;
ny = Number(normal[1]) || 0;
nz = Number(normal[2]) || 1;
} else if (normal instanceof THREE.Vector3) {
nx = normal.x;
ny = normal.y;
nz = normal.z;
}
const n = new THREE.Vector3(nx, ny, nz);
if (n.lengthSq() > 1e-12) {
n.normalize();
m.quaternion.setFromUnitVectors(
new THREE.Vector3(0, 0, 1),
n
);
}
return m;
}
function makeLine(p1, p2, color) {
const geo = new THREE.BufferGeometry().setFromPoints([p1, p2]);
const mat = new THREE.LineBasicMaterial({ color });
return new THREE.Line(geo, mat);
}
// ─── link colours ─────────────────────────────────────────────
const LINK_COLORS = {
Board: 0x8b6528,
Base: 0xc8c8c8,
Arm1: 0x3355cc,
Ellbow: 0xcccccc,
Arm2: 0xbbbbbb,
Hand: 0xaaaaaa,
Palm: 0x999999,
FingerA: 0xdddddd,
FingerB: 0xdddddd,
};
function linkColor(name) { return LINK_COLORS[name] ?? 0x888888; }
// ─── scene groups ─────────────────────────────────────────────
const gSkeleton = new THREE.Group();
const gModel = new THREE.Group();
const gNormals = new THREE.Group();
const gObserved = new THREE.Group();
const gObsNormals= new THREE.Group();
const gErrors = new THREE.Group();
const gBoard = new THREE.Group();
const gCameras = new THREE.Group();
scene.add(gSkeleton, gModel, gNormals, gObserved, gObsNormals, gErrors, gBoard, gCameras);
// ─── toggle wiring ────────────────────────────────────────────
const toggles = {
tSkeleton: gSkeleton,
tModel: gModel,
tNormals: gNormals,
tObserved: gObserved,
tObsNormals:gObsNormals,
tErrors: gErrors,
tBoard: gBoard,
tCameras: gCameras,
};
for (const [id, grp] of Object.entries(toggles)) {
document.getElementById(id).addEventListener('change', e => {
grp.visible = e.target.checked;
});
}
// ═══════════════════════════════════════════════════════════════
// State
// ═══════════════════════════════════════════════════════════════
let robotData = null;
let arucoData = null;
let solutionData = null;
let cameraData = [];
const joints = { x:0, y:0, z:0, a:0, b:0, c:0, e:0 };
// ─── slider setup ─────────────────────────────────────────────
const JOINT_DEFS = [
{ key:'x', label:'x', min:-50, max:600, step:1, unit:'mm' },
{ key:'y', label:'y', min:-180, max:180, step:0.5,unit:'°' },
{ key:'z', label:'z', min:-180, max:180, step:0.5,unit:'°' },
{ key:'a', label:'a', min:-180, max:180, step:0.5,unit:'°' },
{ key:'b', label:'b', min:-180, max:180, step:0.5,unit:'°' },
{ key:'c', label:'c', min:-180, max:180, step:0.5,unit:'°' },
{ key:'e', label:'e', min:0, max:60, step:0.5,unit:'mm' },
];
const sliderEls = {}, valEls = {};
const slidersDiv = document.getElementById('sliders');
for (const d of JOINT_DEFS) {
const row = document.createElement('div');
row.className = 'slider-row';
const lbl = document.createElement('span');
lbl.textContent = d.key;
const sl = document.createElement('input');
sl.type = 'range';
sl.min = d.min; sl.max = d.max; sl.step = d.step; sl.value = 0;
const vb = document.createElement('input');
vb.type = 'text'; vb.className = 'val-box'; vb.value = '0';
vb.style.width = '58px';
sl.addEventListener('input', () => {
joints[d.key] = parseFloat(sl.value);
vb.value = parseFloat(sl.value).toFixed(d.step < 1 ? 1 : 0);
rebuild();
});
vb.addEventListener('change', () => {
const v = parseFloat(vb.value);
if (!isNaN(v)) {
joints[d.key] = v;
sl.value = v;
rebuild();
}
});
row.append(lbl, sl, vb);
slidersDiv.appendChild(row);
sliderEls[d.key] = sl;
valEls[d.key] = vb;
}
function setSlider(key, val) {
joints[key] = val;
sliderEls[key].value = val;
valEls[key].value = val.toFixed(JOINT_DEFS.find(d=>d.key===key)?.step < 1 ? 1 : 0);
}
// ─── file loading ──────────────────────────────────────────────
function readJSON(file) {
return new Promise((res, rej) => {
const r = new FileReader();
r.onload = e => { try { res(JSON.parse(e.target.result)); } catch(err) { rej(err); } };
r.onerror = rej;
r.readAsText(file);
});
}
document.getElementById('fRobot').addEventListener('change', async e => {
if (!e.target.files[0]) return;
robotData = await readJSON(e.target.files[0]);
// init sliders from defaultPosition if present
const dp = robotData.defaultPosition || {};
for (const k of Object.keys(joints)) {
if (dp[k] != null) setSlider(k, dp[k]);
}
populatePoses(robotData);
setStatus('robot.json loaded');
rebuild();
});
document.getElementById('fAruco').addEventListener('change', async e => {
if (!e.target.files[0]) return;
arucoData = await readJSON(e.target.files[0]);
// Kamera-Posen aus derselben Datei übernehmen (falls vorhanden) → Frusta
cameraData = (arucoData.cameras || []).map(c => ({
pos_mm: c.position_mm || (c.position_m ? c.position_m.map(v => v * 1000) : null),
dir: c.direction,
id: c.camera_id
})).filter(c => c.pos_mm && c.dir);
setStatus('aruco geladen' + (cameraData.length ? ` (+ ${cameraData.length} Kameras)` : ''));
rebuild();
});
// ─── solution (robot_state.json) → set joint sliders ───────────
function applySolution() {
if (!solutionData) { setStatus('keine Lösung geladen'); return; }
const mv = solutionData.movements || {};
const done = [];
for (const k of Object.keys(joints)) {
const m = mv[k];
if (m == null) continue;
let v = (typeof m === 'object') ? (m.value ?? m.value_mm ?? m.value_deg) : m;
if (v == null || isNaN(v)) continue;
setSlider(k, Number(v));
const conf = (typeof m === 'object') ? (m.confidence || '') : '';
done.push(k + ((conf === 'low' || conf === 'none') ? '⚠' : ''));
}
rebuild();
const info = document.getElementById('solInfo');
if (info) info.textContent = done.length
? ('gesetzt: ' + done.join(' ') + ' (⚠ = geringe Konfidenz)')
: 'keine movements in der Datei gefunden';
setStatus('Auf Lösung gesprungen');
}
document.getElementById('fSolution').addEventListener('change', async e => {
if (!e.target.files[0]) return;
solutionData = await readJSON(e.target.files[0]);
const btn = document.getElementById('btnJump');
btn.disabled = false;
btn.style.opacity = '1';
setStatus('solution (robot_state.json) loaded');
applySolution(); // direkt anwenden
});
document.getElementById('btnJump').addEventListener('click', applySolution);
// ═══════════════════════════════════════════════════════════════
// Test-pose dropdown
// ═══════════════════════════════════════════════════════════════
function populatePoses(data) {
const poses = data.robot_test_poses || {};
const keys = Object.keys(poses);
const section = document.getElementById('poseSection');
const sel = document.getElementById('poseSelect');
const info = document.getElementById('poseInfo');
if (keys.length === 0) { section.style.display = 'none'; return; }
section.style.display = '';
// Rebuild options
sel.innerHTML = '<option value="">— select pose —</option>';
keys.forEach(k => {
const o = document.createElement('option');
o.value = k; o.textContent = k;
sel.appendChild(o);
});
// Remove old listener by replacing element
const newSel = sel.cloneNode(true);
sel.parentNode.replaceChild(newSel, sel);
newSel.addEventListener('change', () => {
const k = newSel.value;
if (!k) { info.textContent = ''; return; }
const p = poses[k];
info.textContent = Object.entries(p).map(([a,b])=>`${a}:${b}`).join(' ');
for (const [key, val] of Object.entries(p)) {
if (key in joints) setSlider(key, Number(val));
}
rebuild();
});
}
// ─── clear groups ──────────────────────────────────────────────
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);
}
}
// ═══════════════════════════════════════════════════════════════
// Normal-arrow helper
// ═══════════════════════════════════════════════════════════════
function makeNormalArrow(posThreeJS, normalRobot, length, hexColor) {
// normalRobot is in robot coords → convert to Three.js direction
const dir = new THREE.Vector3(normalRobot[0], normalRobot[2], -normalRobot[1]).normalize();
const arrow = new THREE.ArrowHelper(
dir,
posThreeJS,
length,
hexColor,
length * 0.40, // cone head length
length * 0.20 // cone head width
);
return arrow;
}
// ─── rebuild scene ─────────────────────────────────────────────
function r2vDir(rx, ry, rz) {
return new THREE.Vector3(rx, rz, -ry).normalize();
}
// ═══════════════════════════════════════════════════════════════
// Camera frustum (halbtransparente Pyramide, Spitze = Linse)
// ═══════════════════════════════════════════════════════════════
function makeCameraFrustum(posThree, dirThree, size, hexColor) {
const geo = new THREE.ConeGeometry(size * 0.6, size, 4);
geo.translate(0, -size / 2, 0); // Spitze (Linse) an den lokalen Ursprung
geo.rotateY(Math.PI / 4); // Pyramidenkanten ausrichten
const mat = new THREE.MeshPhongMaterial({
color: hexColor, transparent: true, opacity: 0.28,
side: THREE.DoubleSide, depthWrite: false
});
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;
}
function rebuild() {
clearGroup(gSkeleton);
clearGroup(gModel);
clearGroup(gNormals);
clearGroup(gObserved);
clearGroup(gObsNormals);
clearGroup(gErrors);
clearGroup(gBoard);
clearGroup(gCameras);
// Kamera-Frusta hängen nicht vom Roboterzustand ab
for (const cam of cameraData) {
gCameras.add(makeCameraFrustum(r2vArr(cam.pos_mm), r2vDir(...cam.dir), 0.05, 0x9b7bff));
}
if (!robotData) return;
const T = computeFK(robotData, joints);
// ── board plane ──
const boardSize = robotData.links?.Board?.size || [1000, 200, 25];
{
const w = boardSize[0]*S, d = boardSize[1]*S, h = boardSize[2]*S;
const geo = new THREE.BoxGeometry(w, h, d);
const mat = new THREE.MeshPhongMaterial({ color:0x7a5520, transparent:true, opacity:0.5 });
const m = new THREE.Mesh(geo, mat);
m.position.set(w/2, -h/2, -d/2);
m.receiveShadow = true;
gBoard.add(m);
}
// ── skeleton ──
const links = robotData.links || {};
for (const [lname, ld] of Object.entries(links)) {
const sk = ld.skeleton;
if (!sk) continue;
const from = markerWorld(T, lname, sk.from || [0,0,0]);
const to = markerWorld(T, lname, sk.to || [0,0,0]);
const r = (sk.radius || 4) * S * 0.8;
const col = sk.color ? new THREE.Color(...sk.color) : new THREE.Color(linkColor(lname));
const cyl = makeCylinder(r2vArr(from), r2vArr(to), r, col);
if (cyl) gSkeleton.add(cyl);
}
// ── model markers + normals ──
const modelPositions = {};
const modelNormals = {};
for (const [lname, ld] of Object.entries(links)) {
const col = linkColor(lname);
for (const m of (ld.markers||[])) {
if (!m.position) continue;
const mid = m.id;
const wp = markerWorld(T, lname, m.position);
const nLocal = m.normal || [0,0,1];
const nWorld = transformDirByT(T[lname] || I4(), nLocal);
modelPositions[mid] = wp;
modelNormals[mid] = nWorld;
const sq = makeMarkerSquare(r2vArr(wp), r2vDir(...nWorld), 0.022, col);
gModel.add(sq);
// normal arrow (length = half a marker size = ~12.5mm → 0.0125m)
const arr = makeNormalArrow(r2vArr(wp), nWorld, 0.018, col);
gNormals.add(arr);
}
}
// ── observed markers + normals + error lines ──
const obs = {};
if (arucoData) {
for (const m of (arucoData.markers||[])) {
const mid = m.marker_id ?? m.id;
if (mid == null) continue;
let pos, nor = null;
if (m.position_mm) pos = m.position_mm;
else if (m.position_m) pos = m.position_m.map(v=>v*1000);
else continue;
// optional orientation from step-3 output or render markers.json
if (m.normal_world) nor = m.normal_world;
else if (m.normal) nor = m.normal;
obs[mid] = { pos, nor };
}
}
const errors = [];
const normalErrors = [];
for (const [midStr, {pos: opos, nor: oNor}] of Object.entries(obs)) {
const mid = parseInt(midStr);
const mpos = modelPositions[mid];
const op = r2vArr(opos);
const errMm = mpos
? Math.sqrt(opos.reduce((s,v,i) => s+(v-mpos[i])**2, 0))
: null;
const col = errMm == null ? 0x888888
: errMm < 2 ? 0x3ecf6b
: errMm < 5 ? 0xf59e0b
: 0xff4f4f;
gObserved.add(makeSphere(op, 0.006, col));
// observed normal arrow — coloured by angular deviation from the model normal
if (oNor) {
try {
let normCol = 0xffaa00; // no model to compare -> orange
const mn = modelNormals[mid];
if (mn) {
const dot = (oNor[0]*mn[0] + oNor[1]*mn[1] + oNor[2]*mn[2]) /
((Math.hypot(oNor[0],oNor[1],oNor[2]) * Math.hypot(mn[0],mn[1],mn[2])) || 1);
let a = Math.acos(Math.max(-1, Math.min(1, dot))) * 180 / Math.PI;
a = Math.min(a, 180 - a); // flip-invariant
normalErrors.push(a);
normCol = a < 2 ? 0x3ecf6b : a < 5 ? 0xf59e0b : 0xff4f4f;
}
gObsNormals.add(makeNormalArrow(op, oNor, 0.018, normCol));
} catch (e) {
console.warn('Invalid normal for marker', mid, oNor, e);
}
} else if (modelNormals[mid]) {
// fallback: show model normal at observed position (grey = no obs normal data)
const obsArrow = makeNormalArrow(op, modelNormals[mid], 0.014, 0x666666);
gObsNormals.add(obsArrow);
}
if (mpos) {
errors.push(errMm);
const mp = r2vArr(mpos);
gErrors.add(makeLine(mp, op, col));
}
}
// ── stats ──
updateStats(errors, normalErrors, Object.keys(obs).length, Object.keys(modelPositions).length);
}
// ─── statistics panel ──────────────────────────────────────────
function updateStats(errArr, normArr, nObs, nModel) {
const el = document.getElementById('stats-content');
normArr = normArr || [];
if (errArr.length === 0) {
el.innerHTML = `<div class="stat-line"><span>Observed:</span><span class="stat-val">${nObs}</span></div>
<div class="stat-line"><span>Model:</span><span class="stat-val">${nModel}</span></div>
<div class="stat-line"><span style="color:var(--muted)">No matches found</span></div>`;
return;
}
const sorted = [...errArr].sort((a,b)=>a-b);
const mean = errArr.reduce((s,v)=>s+v,0) / errArr.length;
const rms = Math.sqrt(errArr.reduce((s,v)=>s+v*v,0) / errArr.length);
const p50 = sorted[Math.floor(sorted.length*0.5)];
const p90 = sorted[Math.floor(sorted.length*0.9)];
const max = sorted[sorted.length-1];
const cls = v => v < 2 ? 'stat-ok' : v < 5 ? 'stat-warn' : 'stat-err';
let normHtml = '';
if (normArr.length) {
const nmean = normArr.reduce((s,v)=>s+v,0) / normArr.length;
const nmax = Math.max(...normArr);
const ncls = v => v < 2 ? 'stat-ok' : v < 5 ? 'stat-warn' : 'stat-err';
normHtml = `<div class="stat-line" style="margin-top:4px"><span>Normal mean:</span><span class="${ncls(nmean)}">${nmean.toFixed(1)}&deg;</span></div>
<div class="stat-line"><span>Normal max:</span><span class="${ncls(nmax)}">${nmax.toFixed(1)}&deg;</span></div>`;
}
el.innerHTML = `
<div class="stat-line"><span>Observed:</span><span class="stat-val">${nObs}</span></div>
<div class="stat-line"><span>Matched:</span><span class="stat-val">${errArr.length}</span></div>
<div class="stat-line"><span>Mean error:</span><span class="${cls(mean)}">${mean.toFixed(1)} mm</span></div>
<div class="stat-line"><span>RMS error:</span><span class="${cls(rms)}">${rms.toFixed(1)} mm</span></div>
<div class="stat-line"><span>Median:</span><span class="${cls(p50)}">${p50.toFixed(1)} mm</span></div>
<div class="stat-line"><span>p90:</span><span class="${cls(p90)}">${p90.toFixed(1)} mm</span></div>
<div class="stat-line"><span>Max:</span><span class="${cls(max)}">${max.toFixed(1)} mm</span></div>
${normHtml}
<div style="margin-top:6px;font-size:10px;color:var(--muted)">
🟢 &lt;2mm/° &nbsp; 🟡 25 &nbsp; 🔴 &gt;5</div>`;
}
function setStatus(msg) {
document.getElementById('status-bar').textContent = msg + ' — Drag to orbit · Scroll to zoom';
}
// ═══════════════════════════════════════════════════════════════
// Render loop
// ═══════════════════════════════════════════════════════════════
function onResize() {
const w = canvas.parentElement.clientWidth;
const h = canvas.parentElement.clientHeight;
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
window.addEventListener('resize', onResize);
onResize();
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>