feat(webui): 3D robot pose viewer (Issue #229) #232
@ -28,7 +28,7 @@ import { PersonalityTuner } from './components/PersonalityTuner.jsx';
|
|||||||
import { NavModeSelector } from './components/NavModeSelector.jsx';
|
import { NavModeSelector } from './components/NavModeSelector.jsx';
|
||||||
|
|
||||||
// Telemetry panels
|
// Telemetry panels
|
||||||
import { ImuPanel } from './components/ImuPanel.jsx';
|
import PoseViewer from './components/PoseViewer.jsx';
|
||||||
import { BatteryPanel } from './components/BatteryPanel.jsx';
|
import { BatteryPanel } from './components/BatteryPanel.jsx';
|
||||||
import { MotorPanel } from './components/MotorPanel.jsx';
|
import { MotorPanel } from './components/MotorPanel.jsx';
|
||||||
import { MapViewer } from './components/MapViewer.jsx';
|
import { MapViewer } from './components/MapViewer.jsx';
|
||||||
@ -217,14 +217,14 @@ export default function App() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* ── Content ── */}
|
{/* ── Content ── */}
|
||||||
<main className={`flex-1 ${['eventlog', 'control'].includes(activeTab) ? 'flex flex-col' : 'overflow-y-auto'} p-4`}>
|
<main className={`flex-1 ${['eventlog', 'control', 'imu'].includes(activeTab) ? 'flex flex-col' : 'overflow-y-auto'} p-4`}>
|
||||||
{activeTab === 'status' && <StatusPanel subscribe={subscribe} />}
|
{activeTab === 'status' && <StatusPanel subscribe={subscribe} />}
|
||||||
{activeTab === 'faces' && <FaceGallery subscribe={subscribe} callService={callService} />}
|
{activeTab === 'faces' && <FaceGallery subscribe={subscribe} callService={callService} />}
|
||||||
{activeTab === 'conversation' && <ConversationLog subscribe={subscribe} />}
|
{activeTab === 'conversation' && <ConversationLog subscribe={subscribe} />}
|
||||||
{activeTab === 'personality' && <PersonalityTuner subscribe={subscribe} setParam={setParam} />}
|
{activeTab === 'personality' && <PersonalityTuner subscribe={subscribe} setParam={setParam} />}
|
||||||
{activeTab === 'navigation' && <NavModeSelector subscribe={subscribe} publish={publishFn} />}
|
{activeTab === 'navigation' && <NavModeSelector subscribe={subscribe} publish={publishFn} />}
|
||||||
|
|
||||||
{activeTab === 'imu' && <ImuPanel subscribe={subscribe} />}
|
{activeTab === 'imu' && <PoseViewer subscribe={subscribe} />}
|
||||||
{activeTab === 'battery' && <BatteryPanel subscribe={subscribe} />}
|
{activeTab === 'battery' && <BatteryPanel subscribe={subscribe} />}
|
||||||
{activeTab === 'motors' && <MotorPanel subscribe={subscribe} />}
|
{activeTab === 'motors' && <MotorPanel subscribe={subscribe} />}
|
||||||
{activeTab === 'map' && <MapViewer subscribe={subscribe} />}
|
{activeTab === 'map' && <MapViewer subscribe={subscribe} />}
|
||||||
|
|||||||
284
ui/social-bot/src/components/PoseViewer.jsx
Normal file
284
ui/social-bot/src/components/PoseViewer.jsx
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* PoseViewer.jsx — 3D robot pose visualization
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Three.js 3D scene with robot box model
|
||||||
|
* - Real-time rotation from /saltybot/imu quaternion
|
||||||
|
* - 30-second position history trail from /odom
|
||||||
|
* - Reset button to clear trail
|
||||||
|
* - Interactive controls and auto-rotation fallback
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
const TRAIL_DURATION = 30000; // 30 seconds in milliseconds
|
||||||
|
const TRAIL_POINT_INTERVAL = 100; // Add point every 100ms
|
||||||
|
const TRAIL_MAX_POINTS = Math.ceil(TRAIL_DURATION / TRAIL_POINT_INTERVAL);
|
||||||
|
|
||||||
|
function PoseViewer({ subscribe }) {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const sceneRef = useRef(null);
|
||||||
|
const cameraRef = useRef(null);
|
||||||
|
const rendererRef = useRef(null);
|
||||||
|
const robotRef = useRef(null);
|
||||||
|
const trailRef = useRef(null);
|
||||||
|
const trailPointsRef = useRef([]);
|
||||||
|
const quaternionRef = useRef(new THREE.Quaternion());
|
||||||
|
const positionRef = useRef(new THREE.Vector3(0, 0, 0));
|
||||||
|
const lastTrailPointRef = useRef(Date.now());
|
||||||
|
const [trailLength, setTrailLength] = useState(0);
|
||||||
|
const animationIdRef = useRef(null);
|
||||||
|
|
||||||
|
// Initialize Three.js scene
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
// Scene setup
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x0f172a);
|
||||||
|
sceneRef.current = scene;
|
||||||
|
|
||||||
|
// Camera setup
|
||||||
|
const camera = new THREE.PerspectiveCamera(
|
||||||
|
75,
|
||||||
|
containerRef.current.clientWidth / containerRef.current.clientHeight,
|
||||||
|
0.1,
|
||||||
|
1000
|
||||||
|
);
|
||||||
|
camera.position.set(2, 2, 3);
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
cameraRef.current = camera;
|
||||||
|
|
||||||
|
// Renderer setup
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setSize(
|
||||||
|
containerRef.current.clientWidth,
|
||||||
|
containerRef.current.clientHeight
|
||||||
|
);
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
containerRef.current.appendChild(renderer.domElement);
|
||||||
|
rendererRef.current = renderer;
|
||||||
|
|
||||||
|
// Lighting
|
||||||
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||||
|
scene.add(ambientLight);
|
||||||
|
|
||||||
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||||
|
directionalLight.position.set(5, 10, 7);
|
||||||
|
directionalLight.castShadow = true;
|
||||||
|
directionalLight.shadow.mapSize.width = 2048;
|
||||||
|
directionalLight.shadow.mapSize.height = 2048;
|
||||||
|
scene.add(directionalLight);
|
||||||
|
|
||||||
|
// Ground plane
|
||||||
|
const groundGeometry = new THREE.PlaneGeometry(10, 10);
|
||||||
|
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x1f2937 });
|
||||||
|
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
|
||||||
|
ground.rotation.x = -Math.PI / 2;
|
||||||
|
ground.receiveShadow = true;
|
||||||
|
scene.add(ground);
|
||||||
|
|
||||||
|
// Grid helper
|
||||||
|
const gridHelper = new THREE.GridHelper(10, 20, 0x374151, 0x1f2937);
|
||||||
|
scene.add(gridHelper);
|
||||||
|
|
||||||
|
// Robot box model
|
||||||
|
const robotGeometry = new THREE.BoxGeometry(0.3, 0.3, 0.5);
|
||||||
|
const robotMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x06b6d4,
|
||||||
|
metalness: 0.5,
|
||||||
|
roughness: 0.3,
|
||||||
|
});
|
||||||
|
const robot = new THREE.Mesh(robotGeometry, robotMaterial);
|
||||||
|
robot.castShadow = true;
|
||||||
|
robot.receiveShadow = true;
|
||||||
|
scene.add(robot);
|
||||||
|
robotRef.current = robot;
|
||||||
|
|
||||||
|
// Add directional indicator (arrow pointing forward)
|
||||||
|
const arrowGeometry = new THREE.ConeGeometry(0.05, 0.15, 8);
|
||||||
|
const arrowMaterial = new THREE.MeshStandardMaterial({ color: 0xf59e0b });
|
||||||
|
const arrow = new THREE.Mesh(arrowGeometry, arrowMaterial);
|
||||||
|
arrow.position.z = -0.3;
|
||||||
|
arrow.castShadow = true;
|
||||||
|
robot.add(arrow);
|
||||||
|
|
||||||
|
// Trail line
|
||||||
|
const trailGeometry = new THREE.BufferGeometry();
|
||||||
|
const trailMaterial = new THREE.LineBasicMaterial({
|
||||||
|
color: 0x10b981,
|
||||||
|
linewidth: 2,
|
||||||
|
});
|
||||||
|
const trailLine = new THREE.Line(trailGeometry, trailMaterial);
|
||||||
|
scene.add(trailLine);
|
||||||
|
trailRef.current = trailLine;
|
||||||
|
|
||||||
|
// Animation loop
|
||||||
|
const animate = () => {
|
||||||
|
animationIdRef.current = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
// Apply quaternion rotation
|
||||||
|
robot.quaternion.copy(quaternionRef.current);
|
||||||
|
|
||||||
|
// Update trail
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTrailPointRef.current > TRAIL_POINT_INTERVAL) {
|
||||||
|
trailPointsRef.current.push({
|
||||||
|
pos: positionRef.current.clone(),
|
||||||
|
time: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove old points outside 30-second window
|
||||||
|
trailPointsRef.current = trailPointsRef.current.filter(
|
||||||
|
(p) => now - p.time < TRAIL_DURATION
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update trail geometry
|
||||||
|
if (trailPointsRef.current.length > 0) {
|
||||||
|
const positions = new Float32Array(
|
||||||
|
trailPointsRef.current.length * 3
|
||||||
|
);
|
||||||
|
trailPointsRef.current.forEach((p, i) => {
|
||||||
|
positions[i * 3] = p.pos.x;
|
||||||
|
positions[i * 3 + 1] = p.pos.y;
|
||||||
|
positions[i * 3 + 2] = p.pos.z;
|
||||||
|
});
|
||||||
|
|
||||||
|
trailGeometry.setAttribute(
|
||||||
|
'position',
|
||||||
|
new THREE.BufferAttribute(positions, 3)
|
||||||
|
);
|
||||||
|
setTrailLength(trailPointsRef.current.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTrailPointRef.current = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
};
|
||||||
|
|
||||||
|
animate();
|
||||||
|
|
||||||
|
// Handle window resize
|
||||||
|
const handleResize = () => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const width = containerRef.current.clientWidth;
|
||||||
|
const height = containerRef.current.clientHeight;
|
||||||
|
camera.aspect = width / height;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
if (animationIdRef.current) {
|
||||||
|
cancelAnimationFrame(animationIdRef.current);
|
||||||
|
}
|
||||||
|
renderer.dispose();
|
||||||
|
renderer.domElement.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Subscribe to IMU quaternion
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubImu = subscribe(
|
||||||
|
'/saltybot/imu',
|
||||||
|
'sensor_msgs/Imu',
|
||||||
|
(msg) => {
|
||||||
|
if (msg.orientation) {
|
||||||
|
quaternionRef.current.set(
|
||||||
|
msg.orientation.x,
|
||||||
|
msg.orientation.y,
|
||||||
|
msg.orientation.z,
|
||||||
|
msg.orientation.w
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return unsubImu;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
// Subscribe to odometry for position
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubOdom = subscribe(
|
||||||
|
'/odom',
|
||||||
|
'nav_msgs/Odometry',
|
||||||
|
(msg) => {
|
||||||
|
if (msg.pose && msg.pose.pose && msg.pose.pose.position) {
|
||||||
|
positionRef.current.set(
|
||||||
|
msg.pose.pose.position.x,
|
||||||
|
msg.pose.pose.position.y,
|
||||||
|
msg.pose.pose.position.z
|
||||||
|
);
|
||||||
|
|
||||||
|
if (robotRef.current) {
|
||||||
|
robotRef.current.position.copy(positionRef.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return unsubOdom;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
const resetTrail = () => {
|
||||||
|
trailPointsRef.current = [];
|
||||||
|
setTrailLength(0);
|
||||||
|
if (trailRef.current && trailRef.current.geometry) {
|
||||||
|
trailRef.current.geometry.dispose();
|
||||||
|
const newGeometry = new THREE.BufferGeometry();
|
||||||
|
trailRef.current.geometry = newGeometry;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full space-y-3">
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-cyan-700 text-xs font-bold tracking-widest">
|
||||||
|
3D POSE VIEWER
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 text-xs">
|
||||||
|
Trail: {trailLength} points (30s max)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={resetTrail}
|
||||||
|
className="px-3 py-1.5 text-xs font-bold tracking-widest rounded border border-gray-700 text-gray-400 hover:text-cyan-400 hover:border-cyan-700 transition-colors"
|
||||||
|
>
|
||||||
|
RESET TRAIL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3D Viewer */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex-1 bg-slate-950 rounded-lg border border-cyan-950 overflow-hidden"
|
||||||
|
style={{ minHeight: '400px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Info footer */}
|
||||||
|
<div className="bg-gray-950 rounded border border-gray-800 p-2 text-xs text-gray-600 space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Input Topics:</span>
|
||||||
|
<span className="text-gray-500">/saltybot/imu, /odom</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Trail Duration:</span>
|
||||||
|
<span className="text-gray-500">30 seconds</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Update Rate:</span>
|
||||||
|
<span className="text-gray-500">100ms intervals</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PoseViewer;
|
||||||
Loading…
x
Reference in New Issue
Block a user