Anlegen vom Raum-Scanner
This commit is contained in:
338
public/app.js
Normal file
338
public/app.js
Normal file
@@ -0,0 +1,338 @@
|
||||
// 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';
|
||||
});
|
||||
})();
|
||||
63
public/index.html
Normal file
63
public/index.html
Normal file
@@ -0,0 +1,63 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>AppRobot_Room – 3D Viewer</title>
|
||||
data:,
|
||||
|
||||
<style>
|
||||
html, body { height: 100%; margin: 0; background: #ffffff; color: #111; font-family: system-ui, Segoe UI, Arial, sans-serif; }
|
||||
#app { display: grid; grid-template-columns: 1fr 320px; grid-template-rows: auto 1fr; height: 100%; }
|
||||
header { grid-column: 1 / span 2; padding: 8px 12px; background: #f4f6f8; border-bottom: 1px solid #d9dee3; }
|
||||
#canvas-wrap { position: relative; }
|
||||
#hud { position: absolute; left: 8px; top: 8px; background: rgba(0,0,0,0.5); color:#fff; padding: 6px 8px; border-radius: 6px; font-size: 12px; }
|
||||
aside { border-left: 1px solid #d9dee3; padding: 10px; background: #f9fbfc; }
|
||||
.row { margin-bottom: 8px; }
|
||||
label { font-size: 12px; color: #4b5563; }
|
||||
input[type="number"], input[type="text"] { width: 100%; padding: 6px; background: #fff; border: 1px solid #cbd5e1; color: #111; border-radius: 4px; }
|
||||
button { padding: 6px 10px; background: #2563eb; border: 0; color: #fff; border-radius: 4px; cursor: pointer; }
|
||||
a { color: #1d4ed8; }
|
||||
canvas { display:block; }
|
||||
</style>
|
||||
|
||||
<!-- Import-Map: three & addons von /vendor/three (server.js mappt node_modules) -->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "/vendor/three/build/three.module.js",
|
||||
"three/addons/": "/vendor/three/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header>
|
||||
<strong>AppRobot_Room</strong> · <span id="status">Verbinde …</span>
|
||||
</header>
|
||||
|
||||
<div id="canvas-wrap">
|
||||
<div id="hud">
|
||||
<div>FPS: <span id="fps">–</span></div>
|
||||
<div>Frame: <span id="frameInfo">–</span></div>
|
||||
<div>Frames (10s): <span id="frames10s">0</span></div>
|
||||
</div>
|
||||
<canvas id="three"></canvas>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<aside>
|
||||
<div class="row"><label>Server-Config:</label>
|
||||
<pre id="cfg" style="white-space: pre-wrap; word-break: break-word; font-size: 12px; background: #fff; padding: 8px; border-radius: 6px; max-height: 40vh; overflow:auto;"></pre>
|
||||
</div>
|
||||
<div class="row"><button id="btnReset">Ansicht zurücksetzen</button></div>
|
||||
<p style="font-size:12px; color:#4b5563;">WASD/Mouse: Kamera · Spheres: ~5 cm Ø · Farben: Distanz</p>
|
||||
</aside>
|
||||
-->
|
||||
</div>
|
||||
|
||||
<!-- App als ES-Modul -->
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user