From 5e05622628e51775370e6e85d5fb7b4dc91d3b28 Mon Sep 17 00:00:00 2001 From: sl-webui Date: Mon, 2 Mar 2026 11:59:42 -0500 Subject: [PATCH] feat(webui): 3D robot pose viewer with IMU visualization (Issue #229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ui/social-bot/src/App.jsx | 6 +- ui/social-bot/src/components/PoseViewer.jsx | 284 ++++++++++++++++++++ 2 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 ui/social-bot/src/components/PoseViewer.jsx diff --git a/ui/social-bot/src/App.jsx b/ui/social-bot/src/App.jsx index 961155d..645ecb0 100644 --- a/ui/social-bot/src/App.jsx +++ b/ui/social-bot/src/App.jsx @@ -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() { {/* ── Content ── */} -
+
{activeTab === 'status' && } {activeTab === 'faces' && } {activeTab === 'conversation' && } {activeTab === 'personality' && } {activeTab === 'navigation' && } - {activeTab === 'imu' && } + {activeTab === 'imu' && } {activeTab === 'battery' && } {activeTab === 'motors' && } {activeTab === 'map' && } diff --git a/ui/social-bot/src/components/PoseViewer.jsx b/ui/social-bot/src/components/PoseViewer.jsx new file mode 100644 index 0000000..050215d --- /dev/null +++ b/ui/social-bot/src/components/PoseViewer.jsx @@ -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 ( +
+ {/* Controls */} +
+
+
+ 3D POSE VIEWER +
+
+ Trail: {trailLength} points (30s max) +
+
+ +
+ + {/* 3D Viewer */} +
+ + {/* Info footer */} +
+
+ Input Topics: + /saltybot/imu, /odom +
+
+ Trail Duration: + 30 seconds +
+
+ Update Rate: + 100ms intervals +
+
+
+ ); +} + +export default PoseViewer; -- 2.47.2