231 lines
9.3 KiB
JavaScript
231 lines
9.3 KiB
JavaScript
import * as THREE from 'three';
|
||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||
|
||
// ── Renderer ──────────────────────────────────────────────────────────────────
|
||
const viewerEl = document.getElementById('viewer');
|
||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||
renderer.setSize(viewerEl.clientWidth, viewerEl.clientHeight);
|
||
viewerEl.appendChild(renderer.domElement);
|
||
|
||
// ── Scene ─────────────────────────────────────────────────────────────────────
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0x06060f);
|
||
scene.fog = new THREE.Fog(0x06060f, 500, 2000);
|
||
scene.add(new THREE.GridHelper(1000, 100, 0x111133, 0x0a0a22));
|
||
scene.add(new THREE.AxesHelper(5));
|
||
|
||
// ── Camera + Controls ─────────────────────────────────────────────────────────
|
||
const camera = new THREE.PerspectiveCamera(60, viewerEl.clientWidth / viewerEl.clientHeight, 0.1, 5000);
|
||
camera.position.set(0, 150, 250);
|
||
const controls = new OrbitControls(camera, renderer.domElement);
|
||
controls.enableDamping = true;
|
||
controls.dampingFactor = 0.08;
|
||
controls.minDistance = 1;
|
||
controls.maxDistance = 3000;
|
||
|
||
// ── Layer registry ────────────────────────────────────────────────────────────
|
||
const layers = {};
|
||
|
||
function addLayer(name, obj) {
|
||
layers[name] = layers[name] || [];
|
||
layers[name].push(obj);
|
||
scene.add(obj);
|
||
}
|
||
|
||
// ── Geometry helpers ──────────────────────────────────────────────────────────
|
||
function makeLine(xArr, yArr, zArr, color) {
|
||
const pts = xArr.map((x, i) => new THREE.Vector3(x, zArr[i], -yArr[i]));
|
||
const geo = new THREE.BufferGeometry().setFromPoints(pts);
|
||
return new THREE.Line(geo, new THREE.LineBasicMaterial({ color }));
|
||
}
|
||
|
||
function makePoints(xArr, yArr, zArr, colorFn) {
|
||
const pos = new Float32Array(xArr.length * 3);
|
||
const col = new Float32Array(xArr.length * 3);
|
||
for (let i = 0; i < xArr.length; i++) {
|
||
pos[i*3] = xArr[i];
|
||
pos[i*3+1] = zArr[i];
|
||
pos[i*3+2] = -yArr[i];
|
||
const [r,g,b] = colorFn(i);
|
||
col[i*3]=r; col[i*3+1]=g; col[i*3+2]=b;
|
||
}
|
||
const geo = new THREE.BufferGeometry();
|
||
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
||
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
|
||
return new THREE.Points(geo, new THREE.PointsMaterial({ size: 4, vertexColors: true, sizeAttenuation: true }));
|
||
}
|
||
|
||
function makeFrustum(T_flat) {
|
||
// Wireframe camera pyramid pointing downward (-Y): apex at origin, base 1m×0.75m at y=-1.5m
|
||
const verts = new Float32Array([
|
||
// 4 edges from apex to base corners
|
||
0,0,0, 0.5,-1.5, 0.375,
|
||
0,0,0, -0.5,-1.5, 0.375,
|
||
0,0,0, 0.5,-1.5,-0.375,
|
||
0,0,0, -0.5,-1.5,-0.375,
|
||
// 4 edges of base rectangle
|
||
0.5,-1.5, 0.375, -0.5,-1.5, 0.375,
|
||
-0.5,-1.5, 0.375, -0.5,-1.5,-0.375,
|
||
-0.5,-1.5,-0.375, 0.5,-1.5,-0.375,
|
||
0.5,-1.5,-0.375, 0.5,-1.5, 0.375,
|
||
]);
|
||
const geo = new THREE.BufferGeometry();
|
||
geo.setAttribute('position', new THREE.BufferAttribute(verts, 3));
|
||
const mesh = new THREE.LineSegments(geo,
|
||
new THREE.LineBasicMaterial({ color: 0xffd700, transparent: true, opacity: 0.5 }));
|
||
const m = new THREE.Matrix4();
|
||
// T_flat is row-major 4x4 from Python (need to transpose for Three.js column-major)
|
||
m.set(
|
||
T_flat[0][0],T_flat[0][1],T_flat[0][2],T_flat[0][3],
|
||
T_flat[1][0],T_flat[1][1],T_flat[1][2],T_flat[1][3],
|
||
T_flat[2][0],T_flat[2][1],T_flat[2][2],T_flat[2][3],
|
||
T_flat[3][0],T_flat[3][1],T_flat[3][2],T_flat[3][3],
|
||
);
|
||
mesh.applyMatrix4(m);
|
||
return mesh;
|
||
}
|
||
|
||
// ── RTK color mapping ─────────────────────────────────────────────────────────
|
||
const RTK_COLORS = [[0.8,0.3,0.1],[0.9,0.7,0.1],[0.1,0.9,0.2]];
|
||
|
||
// ── Load data ─────────────────────────────────────────────────────────────────
|
||
fetch('/api/trajectory')
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
document.getElementById('status').textContent =
|
||
`traj: ${d.traj_status || 'n/a'}`;
|
||
|
||
if (d.usv_gps) {
|
||
const { x, y, z, rtk } = d.usv_gps;
|
||
addLayer('usv_gps', makeLine(x, y, z, 0x3a9fff));
|
||
addLayer('usv_gps', makePoints(x, y, z, i => RTK_COLORS[Math.min(rtk[i], 2)]));
|
||
}
|
||
|
||
if (d.auv_usbl) {
|
||
const { x, y, z } = d.auv_usbl;
|
||
addLayer('auv_usbl', makePoints(x, y, z, () => [0.27, 1.0, 0.67]));
|
||
}
|
||
|
||
const trajData = d.fused || d.lingbot_local;
|
||
const trajLayer = d.fused ? 'fused' : 'lingbot_local';
|
||
if (trajData) {
|
||
const { x, y, z, T_4x4 } = trajData;
|
||
addLayer(trajLayer, makeLine(x, y, z, d.fused ? 0xffffff : 0xffd700));
|
||
|
||
if (T_4x4 && T_4x4.length > 0) {
|
||
const step = Math.max(1, Math.floor(T_4x4.length / 100)); // max 100 frustums
|
||
for (let i = 0; i < T_4x4.length; i += step) {
|
||
addLayer('frustums', makeFrustum(T_4x4[i]));
|
||
}
|
||
}
|
||
|
||
// Center camera
|
||
if (x.length > 0) {
|
||
controls.target.set(x[0], z[0], -y[0]);
|
||
camera.position.set(x[0], z[0] + 50, -y[0] + 100);
|
||
controls.update();
|
||
}
|
||
}
|
||
|
||
if (d.ply) {
|
||
const { x, y, z } = d.ply;
|
||
addLayer('ply', makePoints(x, y, z, (i) => {
|
||
const t = Math.min(1, Math.max(0, (-z[i]) / 20));
|
||
return [0.2+0.3*t, 0.35+0.2*t, 0.5-0.2*t];
|
||
}));
|
||
}
|
||
|
||
// If only USV GPS, center on that
|
||
if (!trajData && d.usv_gps) {
|
||
const { x, y, z } = d.usv_gps;
|
||
const cx = x.reduce((a,b)=>a+b,0)/x.length;
|
||
const cy = y.reduce((a,b)=>a+b,0)/y.length;
|
||
controls.target.set(cx, 0, -cy);
|
||
camera.position.set(cx, 50, -cy + 80);
|
||
controls.update();
|
||
}
|
||
|
||
// 2D depth/altitude chart
|
||
if (d.auv_profile) {
|
||
const { t_s, depth_m, altitude_m } = d.auv_profile;
|
||
const hasAlt = altitude_m && altitude_m.some(v => !isNaN(v) && v !== null);
|
||
const datasets = [
|
||
{
|
||
label: 'Depth (m)',
|
||
data: t_s.map((t, i) => ({ x: t, y: -depth_m[i] })),
|
||
borderColor: '#3a9fff',
|
||
backgroundColor: 'rgba(58,159,255,0.08)',
|
||
borderWidth: 1.5,
|
||
pointRadius: 0,
|
||
fill: true,
|
||
},
|
||
];
|
||
if (hasAlt) {
|
||
datasets.push({
|
||
label: 'Altitude above seafloor (m)',
|
||
data: t_s.map((t, i) => ({ x: t, y: altitude_m[i] })),
|
||
borderColor: '#44ffaa',
|
||
backgroundColor: 'transparent',
|
||
borderWidth: 1.5,
|
||
pointRadius: 0,
|
||
});
|
||
}
|
||
Chart.defaults.color = '#556';
|
||
Chart.defaults.backgroundColor = 'transparent';
|
||
new Chart(document.getElementById('depth-chart'), {
|
||
type: 'line',
|
||
data: { datasets },
|
||
options: {
|
||
animation: false,
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
parsing: false,
|
||
plugins: {
|
||
legend: { labels: { color: '#889', font: { size: 10, family: 'Courier New' } } },
|
||
tooltip: { enabled: false },
|
||
},
|
||
scales: {
|
||
x: {
|
||
type: 'linear',
|
||
title: { display: true, text: 'Time (s)', color: '#445' },
|
||
ticks: { color: '#445', font: { size: 9 } },
|
||
grid: { color: '#111' },
|
||
},
|
||
y: {
|
||
suggestedMin: 0,
|
||
title: { display: true, text: 'm', color: '#445' },
|
||
ticks: { color: '#445', font: { size: 9 } },
|
||
grid: { color: '#111' },
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
})
|
||
.catch(err => {
|
||
document.getElementById('status').textContent = `Error: ${err}`;
|
||
});
|
||
|
||
// ── Layer toggles ─────────────────────────────────────────────────────────────
|
||
document.querySelectorAll('[data-layer]').forEach(cb => {
|
||
cb.addEventListener('change', e => {
|
||
const name = e.target.dataset.layer;
|
||
(layers[name] || []).forEach(obj => { obj.visible = e.target.checked; });
|
||
});
|
||
});
|
||
|
||
// ── Resize ────────────────────────────────────────────────────────────────────
|
||
window.addEventListener('resize', () => {
|
||
camera.aspect = viewerEl.clientWidth / viewerEl.clientHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(viewerEl.clientWidth, viewerEl.clientHeight);
|
||
});
|
||
|
||
// ── Render loop ───────────────────────────────────────────────────────────────
|
||
(function animate() {
|
||
requestAnimationFrame(animate);
|
||
controls.update();
|
||
renderer.render(scene, camera);
|
||
})();
|