/** * 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 (