beb4182f04
Orphan branch with no history — includes the Tauri shell (src-tauri/) and vendored Three.js (vendor/) that were never pushed before, so a fresh clone can actually build the native app. Drops the large .pcd sample captures and editor cruft (.obsidian, .DS_Store); they aren't needed to build or run the app (point clouds load via drag-and-drop, not bundling).
176 lines
7.7 KiB
JavaScript
176 lines
7.7 KiB
JavaScript
// Minimal rosbridge v2 stand-in: hand-rolled RFC6455 (no deps) + synthetic
|
|
// FAST-LIVO2-shaped topics, so the viewer's live path can be exercised for real.
|
|
import http from 'node:http';
|
|
import crypto from 'node:crypto';
|
|
import zlib from 'node:zlib';
|
|
|
|
const PORT = 9090;
|
|
|
|
// ── websocket framing ────────────────────────────────────────────────────
|
|
function accept(key) {
|
|
return crypto.createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
|
|
}
|
|
function encodeFrame(str) {
|
|
const payload = Buffer.from(str, 'utf8');
|
|
const len = payload.length;
|
|
let header;
|
|
if (len < 126) { header = Buffer.alloc(2); header[1] = len; }
|
|
else if (len < 65536) { header = Buffer.alloc(4); header[1] = 126; header.writeUInt16BE(len, 2); }
|
|
else { header = Buffer.alloc(10); header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2); }
|
|
header[0] = 0x81; // FIN + text
|
|
return Buffer.concat([header, payload]);
|
|
}
|
|
function* decodeFrames(buf) { // client → server frames are masked
|
|
let off = 0;
|
|
while (off + 2 <= buf.length) {
|
|
const b1 = buf[off + 1];
|
|
let len = b1 & 127, p = off + 2;
|
|
if (len === 126) { len = buf.readUInt16BE(p); p += 2; }
|
|
else if (len === 127) { len = Number(buf.readBigUInt64BE(p)); p += 8; }
|
|
const masked = b1 & 128;
|
|
const mask = masked ? buf.subarray(p, p + 4) : null;
|
|
if (masked) p += 4;
|
|
if (p + len > buf.length) return; // partial frame: wait for more
|
|
const data = Buffer.from(buf.subarray(p, p + len));
|
|
if (mask) for (let i = 0; i < data.length; i++) data[i] ^= mask[i % 4];
|
|
yield { opcode: buf[off] & 15, text: data.toString('utf8') };
|
|
off = p + len;
|
|
}
|
|
}
|
|
|
|
// ── a tiny PNG encoder, to stand in for the camera's compressed frames ───
|
|
function crc32(buf) {
|
|
let c, crc = 0xffffffff;
|
|
for (let n = 0; n < buf.length; n++) {
|
|
c = (crc ^ buf[n]) & 0xff;
|
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
crc = (crc >>> 8) ^ c;
|
|
}
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|
|
function chunk(type, data) {
|
|
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
|
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
|
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
|
|
return Buffer.concat([len, td, crc]);
|
|
}
|
|
function png(w, h, paint) {
|
|
const raw = Buffer.alloc(h * (w * 3 + 1));
|
|
for (let y = 0; y < h; y++) {
|
|
const row = y * (w * 3 + 1);
|
|
raw[row] = 0;
|
|
for (let x = 0; x < w; x++) {
|
|
const [r, g, b] = paint(x, y);
|
|
raw[row + 1 + x * 3] = r; raw[row + 2 + x * 3] = g; raw[row + 3 + x * 3] = b;
|
|
}
|
|
}
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4);
|
|
ihdr[8] = 8; ihdr[9] = 2; // 8-bit RGB
|
|
return Buffer.concat([
|
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))
|
|
]);
|
|
}
|
|
|
|
// ── synthetic FAST-LIVO2 session ─────────────────────────────────────────
|
|
const TOPICS = [
|
|
['/cloud_registered', 'sensor_msgs/msg/PointCloud2'],
|
|
['/cloud_effected', 'sensor_msgs/msg/PointCloud2'],
|
|
['/aft_mapped_to_init', 'nav_msgs/msg/Odometry'],
|
|
['/path', 'nav_msgs/msg/Path'],
|
|
['/origin_img', 'sensor_msgs/msg/CompressedImage'],
|
|
['/rosout', 'rcl_interfaces/msg/Log']
|
|
];
|
|
|
|
let t0 = Date.now();
|
|
const trail = [];
|
|
const pose = () => {
|
|
const t = (Date.now() - t0) / 1000;
|
|
return { x: 6 * Math.cos(t * 0.25), y: 6 * Math.sin(t * 0.25), z: 0.4 + 0.1 * Math.sin(t) };
|
|
};
|
|
const stamp = () => ({ sec: Math.floor(Date.now() / 1000), nanosec: (Date.now() % 1000) * 1e6 });
|
|
|
|
function cloudMsg(n = 2500) {
|
|
const step = 16; // x,y,z float32 + rgb packed
|
|
const buf = Buffer.alloc(n * step);
|
|
const p = pose();
|
|
for (let i = 0; i < n; i++) {
|
|
const a = Math.random() * Math.PI * 2, r = 2 + Math.random() * 3;
|
|
const x = p.x + r * Math.cos(a), y = p.y + r * Math.sin(a), z = Math.random() * 2.4;
|
|
const o = i * step;
|
|
buf.writeFloatLE(x, o); buf.writeFloatLE(y, o + 4); buf.writeFloatLE(z, o + 8);
|
|
const t = Math.min(1, z / 2.4);
|
|
const rgb = ((60 + 180 * t | 0) << 16) | ((90 + 100 * (1 - t) | 0) << 8) | (200 - 140 * t | 0);
|
|
buf.writeUInt32LE(rgb >>> 0, o + 12);
|
|
}
|
|
return {
|
|
header: { stamp: stamp(), frame_id: 'camera_init' },
|
|
height: 1, width: n,
|
|
fields: [
|
|
{ name: 'x', offset: 0, datatype: 7, count: 1 },
|
|
{ name: 'y', offset: 4, datatype: 7, count: 1 },
|
|
{ name: 'z', offset: 8, datatype: 7, count: 1 },
|
|
{ name: 'rgb', offset: 12, datatype: 7, count: 1 }
|
|
],
|
|
is_bigendian: false, point_step: step, row_step: step * n,
|
|
data: buf.toString('base64'), is_dense: true
|
|
};
|
|
}
|
|
function odomMsg() {
|
|
const p = pose();
|
|
trail.push(p);
|
|
if (trail.length > 600) trail.shift();
|
|
return { header: { stamp: stamp(), frame_id: 'camera_init' }, child_frame_id: 'body',
|
|
pose: { pose: { position: p, orientation: { x: 0, y: 0, z: 0, w: 1 } } } };
|
|
}
|
|
function pathMsg() {
|
|
return { header: { stamp: stamp(), frame_id: 'camera_init' },
|
|
poses: trail.map(p => ({ header: { stamp: stamp(), frame_id: 'camera_init' },
|
|
pose: { position: p, orientation: { x: 0, y: 0, z: 0, w: 1 } } })) };
|
|
}
|
|
function imgMsg() {
|
|
const t = (Date.now() - t0) / 400;
|
|
const buf = png(320, 180, (x, y) => {
|
|
const v = 40 + 90 * Math.sin(x / 22 + t) * Math.cos(y / 18 - t * 0.4);
|
|
return [Math.max(0, v * 1.4) | 0, Math.max(0, v) | 0, Math.max(0, v * 0.7) | 0];
|
|
});
|
|
return { header: { stamp: stamp(), frame_id: 'camera' }, format: 'rgb8; png compressed bgr8',
|
|
data: buf.toString('base64') };
|
|
}
|
|
|
|
// ── server ───────────────────────────────────────────────────────────────
|
|
const server = http.createServer((_, res) => res.end('mock rosbridge'));
|
|
server.on('upgrade', (req, socket) => {
|
|
socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' +
|
|
`Sec-WebSocket-Accept: ${accept(req.headers['sec-websocket-key'])}\r\n\r\n`);
|
|
const send = o => socket.write(encodeFrame(JSON.stringify(o)));
|
|
const timers = [];
|
|
const pub = (topic, make, hz) => timers.push(setInterval(() => send({ op: 'publish', topic, msg: make() }), 1000 / hz));
|
|
|
|
let acc = Buffer.alloc(0);
|
|
socket.on('data', d => {
|
|
acc = Buffer.concat([acc, d]);
|
|
let consumed = 0;
|
|
for (const fr of decodeFrames(acc)) {
|
|
consumed = acc.length; // our client only sends small whole frames
|
|
if (fr.opcode === 8) { socket.end(); return; }
|
|
let m; try { m = JSON.parse(fr.text); } catch { continue; }
|
|
console.log('→', m.op, m.service || m.topic || '');
|
|
if (m.op === 'call_service' && m.service === '/rosapi/topics') {
|
|
send({ op: 'service_response', service: m.service, id: m.id, result: true,
|
|
values: { topics: TOPICS.map(t => t[0]), types: TOPICS.map(t => t[1]) } });
|
|
} else if (m.op === 'subscribe') {
|
|
if (m.topic === '/cloud_registered' || m.topic === '/cloud_effected') pub(m.topic, () => cloudMsg(), 10);
|
|
else if (m.topic === '/aft_mapped_to_init') pub(m.topic, odomMsg, 20);
|
|
else if (m.topic === '/path') pub(m.topic, pathMsg, 5);
|
|
else if (m.topic === '/origin_img') pub(m.topic, imgMsg, 5);
|
|
}
|
|
}
|
|
acc = acc.subarray(consumed);
|
|
});
|
|
socket.on('close', () => timers.forEach(clearInterval));
|
|
socket.on('error', () => timers.forEach(clearInterval));
|
|
});
|
|
server.listen(PORT, () => console.log('mock rosbridge on ws://localhost:' + PORT));
|