338 lines
12 KiB
JavaScript
338 lines
12 KiB
JavaScript
// Importe gemäß Import-Map
|
||
import * as THREE from 'three';
|
||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||
|
||
(function safeMain(){
|
||
// Robust gegen Fehlstarts
|
||
window.addEventListener('error', (e) => console.error('[APP] Window error:', e.message, e.error));
|
||
window.addEventListener('unhandledrejection', (e) => console.error('[APP] Unhandled promise rejection:', e.reason));
|
||
|
||
(async function(){
|
||
const statusEl = document.getElementById('status');
|
||
const fpsEl = document.getElementById('fps');
|
||
const frameInfoEl = document.getElementById('frameInfo');
|
||
const frames10sEl = document.getElementById('frames10s');
|
||
//const cfgEl = document.getElementById('cfg');
|
||
const canvas = document.getElementById('three');
|
||
|
||
function setStatus(txt) { statusEl.textContent = txt; console.log('[APP]', txt); }
|
||
|
||
// --- Config laden ---
|
||
setStatus('Lade /config …');
|
||
let sensor, pose;
|
||
try {
|
||
const res = await fetch('/config', { cache: 'no-store' });
|
||
if (!res.ok) throw new Error(`/config HTTP ${res.status}`);
|
||
const json = await res.json();
|
||
sensor = json.sensor; pose = json.pose;
|
||
//cfgEl.textContent = JSON.stringify({ sensor, pose }, null, 2);
|
||
setStatus('Config geladen');
|
||
} catch (err) {
|
||
console.error('[APP] /config Fehler:', err);
|
||
setStatus('Fehler: /config nicht erreichbar');
|
||
return;
|
||
}
|
||
|
||
// --- Three.js Setup ---
|
||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0xffffff); // weißer Hintergrund
|
||
|
||
const camera = new THREE.PerspectiveCamera(60, 1, 0.01, 200);
|
||
camera.position.set(0.5, 0.5, 1.2);
|
||
|
||
const controls = new OrbitControls(camera, renderer.domElement);
|
||
controls.target.set(0, 0, 0.4);
|
||
|
||
function resize() {
|
||
const w = window.innerWidth ; //- 320; // Seitenpanel
|
||
const h = window.innerHeight - 48; // Header
|
||
renderer.setSize(w, h, false);
|
||
camera.aspect = Math.max(0.1, w / h);
|
||
camera.updateProjectionMatrix();
|
||
}
|
||
|
||
// Licht & Helfer
|
||
scene.add(new THREE.AmbientLight(0xffffff, 0.8));
|
||
const dir = new THREE.DirectionalLight(0xffffff, 0.5); dir.position.set(1,2,3); scene.add(dir);
|
||
const grid = new THREE.GridHelper(8, 80, 0xbbc4ce, 0xe0e6eb); grid.position.y = -0.001; scene.add(grid);
|
||
const axesWorld = new THREE.AxesHelper(1.0); scene.add(axesWorld); // R/G/B
|
||
const axesSensor = new THREE.AxesHelper(0.5); scene.add(axesSensor); // Sensorpose
|
||
|
||
// Instanced spheres
|
||
const W = sensor.gridW, H = sensor.gridH;
|
||
const instances = W * H;
|
||
const radius = (sensor.sphereDiameterM || 0.05) / 2;
|
||
|
||
const geom = new THREE.SphereGeometry(radius, 16, 12);
|
||
|
||
// 1) Basis-Vertexfarben = Weiß (1,1,1) an die Geometrie hängen
|
||
const vcount = geom.attributes.position.count;
|
||
const white = new Float32Array(vcount * 3);
|
||
white.fill(1.0);
|
||
geom.setAttribute('color', new THREE.BufferAttribute(white, 3));
|
||
|
||
// 2) Material: Vertexfarben aktivieren, neutraler Materialfarbton
|
||
const mat = new THREE.MeshBasicMaterial({ color: 0xffffff, vertexColors: true });
|
||
|
||
// 3) InstancedMesh erzeugen
|
||
const spheres = new THREE.InstancedMesh(geom, mat, instances);
|
||
spheres.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||
|
||
|
||
if (!spheres.instanceColor) {
|
||
const colors = new Float32Array(instances * 3);
|
||
for (let i = 0; i < instances; i++) {
|
||
colors[i * 3 + 0] = 1.0; // R
|
||
colors[i * 3 + 1] = 1.0; // G
|
||
colors[i * 3 + 2] = 1.0; // B
|
||
}
|
||
spheres.instanceColor = new THREE.InstancedBufferAttribute(colors, 3);
|
||
}
|
||
|
||
|
||
spheres.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||
scene.add(spheres);
|
||
const dummy = new THREE.Object3D();
|
||
|
||
// Pose
|
||
let sensorPose = {
|
||
position: new THREE.Vector3(...pose.position),
|
||
rotationEulerDeg: pose.rotationEulerDeg,
|
||
eulerOrder: pose.eulerOrder || 'ZYX'
|
||
};
|
||
function applyPose(vec) {
|
||
const euler = new THREE.Euler(
|
||
THREE.MathUtils.degToRad(sensorPose.rotationEulerDeg?.[0] || 0),
|
||
THREE.MathUtils.degToRad(sensorPose.rotationEulerDeg?.[1] || 0),
|
||
THREE.MathUtils.degToRad(sensorPose.rotationEulerDeg?.[2] || 0),
|
||
sensorPose.eulerOrder || 'ZYX'
|
||
);
|
||
const q = new THREE.Quaternion().setFromEuler(euler);
|
||
vec.applyQuaternion(q).add(sensorPose.position);
|
||
return vec;
|
||
}
|
||
function updateSensorAxes() {
|
||
axesSensor.position.copy(sensorPose.position);
|
||
const euler = new THREE.Euler(
|
||
THREE.MathUtils.degToRad(sensorPose.rotationEulerDeg?.[0] || 0),
|
||
THREE.MathUtils.degToRad(sensorPose.rotationEulerDeg?.[1] || 0),
|
||
THREE.MathUtils.degToRad(sensorPose.rotationEulerDeg?.[2] || 0),
|
||
sensorPose.eulerOrder || 'ZYX'
|
||
);
|
||
axesSensor.setRotationFromEuler(euler);
|
||
}
|
||
updateSensorAxes();
|
||
|
||
|
||
// Distanz-Farbskala: 0 m (nah) = rot, 4 m (weit) = blau
|
||
function colorForDist(m) {
|
||
// Normierung auf 0..4 m
|
||
let t = THREE.MathUtils.clamp(m/4 , 0, 1); // << const -> let
|
||
t = Math.min(Math.max(t, 0.01), 0.99); // sanftes Clamping, vermeidet reinste Endfarben
|
||
|
||
const col = new THREE.Color();
|
||
col.setHSL( (1 - t) * 0.0 + t * (240/360), 1.0, 0.5 );
|
||
return col;
|
||
}
|
||
|
||
|
||
|
||
// Richtungsvektor je Zelle (FoV Konfiguration aus sensor.*)
|
||
const hTan = Math.tan(THREE.MathUtils.degToRad(sensor.hFovDeg) / 2);
|
||
const vTan = Math.tan(THREE.MathUtils.degToRad(sensor.vFovDeg) / 2);
|
||
function cellDir(xIdx, yIdx) {
|
||
const u = ((xIdx + 0.5) / W) * 2 - 1; // -1..+1
|
||
const v = ((yIdx + 0.5) / H) * 2 - 1; // -1..+1
|
||
const dx = u * hTan;
|
||
const dy = -v * vTan; // screen-y -> world up
|
||
return new THREE.Vector3(dx, dy, 1).normalize();
|
||
}
|
||
|
||
// --- WebSockets ---
|
||
function explainCloseCode(code) {
|
||
switch (code) {
|
||
case 1000: return 'Normal Closure';
|
||
case 1001: return 'Going Away';
|
||
case 1002: return 'Protocol Error';
|
||
case 1003: return 'Unsupported Data';
|
||
case 1005: return 'No Status (internal)';
|
||
case 1006: return 'Abnormal Closure – häufig: Handshake blockiert (Pfad/Port), Reverse-Proxy, CORS/Mixed-Content, Firewall';
|
||
case 1007: return 'Invalid Payload';
|
||
case 1008: return 'Policy Violation';
|
||
case 1009: return 'Message Too Big';
|
||
case 1011: return 'Server Error';
|
||
default: return 'Unbekannt';
|
||
}
|
||
}
|
||
|
||
function makeWS(path, label) {
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
const url = `${proto}://${location.host}${path}`;
|
||
const ws = new WebSocket(url);
|
||
ws.binaryType = 'arraybuffer';
|
||
|
||
ws.addEventListener('open', () => {
|
||
setStatus(`${label}: Verbunden (${url})`);
|
||
});
|
||
|
||
ws.addEventListener('close', (ev) => {
|
||
console.warn(`[WS ${label}] close`, { code: ev.code, reason: ev.reason, wasClean: ev.wasClean });
|
||
const note = explainCloseCode(ev.code);
|
||
setStatus(`${label}: Getrennt (code ${ev.code}${note ? ` · ${note}` : ''})`);
|
||
console.info(`[WS ${label}] Diagnose: Prüfe Pfad ${path}, Server-Port, Mixed-Content (http+ws vs https+wss), evtl. Proxy/Firewall.`);
|
||
});
|
||
|
||
ws.addEventListener('error', (err) => {
|
||
console.error(`[WS ${label}] error`, err);
|
||
setStatus(`${label}: Fehler (siehe Konsole)`);
|
||
});
|
||
|
||
ws.addEventListener('message', (ev) => {
|
||
// nur Sizing loggen, um Konsole nicht zu fluten
|
||
// console.debug(`[WS ${label}] message`, typeof ev.data, ev.data?.byteLength || ev.data?.length);
|
||
});
|
||
|
||
return ws;
|
||
}
|
||
|
||
const wsFrames = makeWS('/ws/frames', 'Frames'); // Datenstrom
|
||
const wsState = makeWS('/ws/state', 'State'); // Pose/Status
|
||
|
||
// Client-Statistik: empfangene Frames pro 10s
|
||
let framesReceived10s = 0;
|
||
setInterval(() => {
|
||
frames10sEl.textContent = String(framesReceived10s);
|
||
console.info(`[STAT 10s Client] framesReceived:${framesReceived10s}`);
|
||
framesReceived10s = 0;
|
||
}, 10_000);
|
||
|
||
wsState.onmessage = (ev) => {
|
||
try {
|
||
const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data));
|
||
if (msg.type === 'pose' && msg.pose) {
|
||
sensorPose.position = new THREE.Vector3(...msg.pose.position);
|
||
sensorPose.rotationEulerDeg = msg.pose.rotationEulerDeg;
|
||
sensorPose.eulerOrder = msg.pose.eulerOrder || 'ZYX';
|
||
updateSensorAxes();
|
||
}
|
||
} catch (e) {
|
||
console.error('[WS State] parse error', e);
|
||
}
|
||
};
|
||
|
||
// CSV: ts,w,h,d0..dN
|
||
function handleCSV(text) {
|
||
try {
|
||
const s = text.trim();
|
||
const p = s.split(',');
|
||
if (p.length < 3) return;
|
||
|
||
|
||
const expected = 3 + W * H;
|
||
if (p.length < expected) {
|
||
console.warn('[CSV] zu kurze Zeile', { got: p.length, expected });
|
||
return; // oder fehlende Werte als NaN behandeln
|
||
}
|
||
|
||
|
||
const w = parseInt(p[1], 10), h = parseInt(p[2], 10);
|
||
if (w !== W || h !== H) {
|
||
console.warn('[CSV] Unerwartete Dimension:', w, 'x', h, 'erwartet:', W, 'x', H);
|
||
return;
|
||
}
|
||
|
||
const vals = new Array(W * H);
|
||
for (let i = 0; i < W * H; i++) {
|
||
const raw = p[3 + i];
|
||
// Leere Felder oder fehlende Indizes abfangen
|
||
if (raw === undefined || raw === null || raw === '') {
|
||
vals[i] = NaN;
|
||
continue;
|
||
}
|
||
const v = Number(raw); // robust gegen floats und "nan"
|
||
vals[i] = Number.isFinite(v) ? v : NaN;
|
||
}
|
||
|
||
|
||
const col = new THREE.Color();
|
||
for (let y=0; y<H; y++) {
|
||
for (let x=0; x<W; x++) {
|
||
const idx = y*W + x;
|
||
const mm = vals[idx];
|
||
const m = mm * 0.001; // mm -> m
|
||
const dir = cellDir(x,y);
|
||
const posLocal = dir.clone().multiplyScalar(m);
|
||
const posWorld = applyPose(posLocal);
|
||
dummy.position.copy(posWorld);
|
||
dummy.rotation.set(0,0,0);
|
||
dummy.updateMatrix();
|
||
spheres.setMatrixAt(idx, dummy.matrix);
|
||
col.copy(colorForDist(m));
|
||
spheres.setColorAt(idx, col);
|
||
//const testColor = new THREE.Color(0x00ff00);
|
||
//spheres.setColorAt(idx, testColor);
|
||
|
||
|
||
}
|
||
}
|
||
spheres.instanceMatrix.needsUpdate = true;
|
||
if (spheres.instanceColor) spheres.instanceColor.needsUpdate = true;
|
||
|
||
frameInfoEl.textContent = `${new Date().toLocaleTimeString()} · ${W}x${H}`;
|
||
framesReceived10s++;
|
||
} catch (e) {
|
||
console.error('[CSV] handle error:', e);
|
||
}
|
||
|
||
|
||
// nachdem alle setMatrixAt(...) und setColorAt(...) fertig sind:
|
||
spheres.instanceMatrix.needsUpdate = true;
|
||
if (spheres.instanceColor) spheres.instanceColor.needsUpdate = true;
|
||
|
||
if (!window.__primed__) {
|
||
spheres.material.needsUpdate = true; // <<< WICHTIG
|
||
window.__primed__ = true;
|
||
}
|
||
|
||
|
||
}
|
||
|
||
wsFrames.onmessage = (ev) => {
|
||
if (typeof ev.data === 'string') handleCSV(ev.data);
|
||
else if (ev.data instanceof Blob) ev.data.text().then(handleCSV, (e)=>console.error('[CSV] blob->text error', e));
|
||
else if (ev.data instanceof ArrayBuffer) {
|
||
const txt = new TextDecoder().decode(ev.data);
|
||
handleCSV(txt);
|
||
} else {
|
||
console.warn('[WS Frames] unbekannter Datentyp', ev.data);
|
||
}
|
||
};
|
||
|
||
// Renderloop + FPS
|
||
let last = performance.now(), accum = 0, frames = 0;
|
||
function loop(t){
|
||
const dt = t - last; last = t; accum += dt; frames++;
|
||
if (accum >= 1000) { fpsEl.textContent = Math.round(frames * 1000 / accum); frames = 0; accum = 0; }
|
||
controls.update();
|
||
renderer.render(scene, camera);
|
||
requestAnimationFrame(loop);
|
||
}
|
||
requestAnimationFrame(loop);
|
||
|
||
window.addEventListener('resize', resize); resize();
|
||
/*
|
||
document.getElementById('btnReset').addEventListener('click', () => {
|
||
camera.position.set(0.5, 0.5, 1.2);
|
||
controls.target.set(0, 0, 0.4);
|
||
controls.update();
|
||
});
|
||
*/
|
||
|
||
setStatus('Viewer bereit – warte auf Frames …');
|
||
})().catch(err => {
|
||
console.error('[APP] fatal:', err);
|
||
const statusEl = document.getElementById('status');
|
||
if (statusEl) statusEl.textContent = 'Fehler: siehe Konsole';
|
||
});
|
||
})(); |