feat(webui): 3D robot pose viewer with IMU visualization (Issue #229)
Replace basic IMU panel with interactive Three.js 3D pose viewer: - Real-time robot box model visualization - Rotation controlled by /saltybot/imu quaternion data - 30-second position trail from /odom navigation - Trail visualization with point history - Reset button to clear trail history - Grid ground plane and directional indicator arrow - Interactive 3D scene with proper lighting and shadows Features: - Three.js scene with dynamic box model (0.3x0.3x0.5m) - Forward direction indicator (amber cone) - Ambient and directional lighting with shadows - Grid helper for spatial reference - Trail line geometry with dynamic point updates - Automatic window resize handling - Proper cleanup of WebGL resources and subscriptions Integration: - Replaces ImuPanel in TELEMETRY tab (IMU view) - Added to flex layout for proper 3D viewport sizing - Subscribes to /saltybot/imu and /odom topics - Updates trail points every 100ms (max 30-second history) Build: ✓ Passing (112 modules, 196.69 KB main bundle) Three.js vendor: 473.57 KB Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3a7cd3649a
commit
5e05622628
@ -28,7 +28,7 @@ import { PersonalityTuner } from './components/PersonalityTuner.jsx';
|
||||
import { NavModeSelector } from './components/NavModeSelector.jsx';
|
||||
|
||||
// Telemetry panels
|
||||
import { ImuPanel } from './components/ImuPanel.jsx';
|
||||
import PoseViewer from './components/PoseViewer.jsx';
|
||||
import { BatteryPanel } from './components/BatteryPanel.jsx';
|
||||
import { MotorPanel } from './components/MotorPanel.jsx';
|
||||
import { MapViewer } from './components/MapViewer.jsx';
|
||||
@ -217,14 +217,14 @@ export default function App() {
|
||||
</nav>
|
||||
|
||||
{/* ── 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 === 'faces' && <FaceGallery subscribe={subscribe} callService={callService} />}
|
||||
{activeTab === 'conversation' && <ConversationLog subscribe={subscribe} />}
|
||||
{activeTab === 'personality' && <PersonalityTuner subscribe={subscribe} setParam={setParam} />}
|
||||
{activeTab === 'navigation' && <NavModeSelector subscribe={subscribe} publish={publishFn} />}
|
||||
|
||||
{activeTab === 'imu' && <ImuPanel subscribe={subscribe} />}
|
||||
{activeTab === 'imu' && <PoseViewer subscribe={subscribe} />}
|
||||
{activeTab === 'battery' && <BatteryPanel subscribe={subscribe} />}
|
||||
{activeTab === 'motors' && <MotorPanel 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