Compare commits
No commits in common. "e477284c2f3f97914ba77496d3d2509be88381a0" and "3b352ad2c5e5548529678d375869b0e441d1298c" have entirely different histories.
e477284c2f
...
3b352ad2c5
@ -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 PoseViewer from './components/PoseViewer.jsx';
|
import { ImuPanel } from './components/ImuPanel.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', 'imu'].includes(activeTab) ? 'flex flex-col' : 'overflow-y-auto'} p-4`}>
|
<main className={`flex-1 ${['eventlog', 'control'].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' && <PoseViewer subscribe={subscribe} />}
|
{activeTab === 'imu' && <ImuPanel 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} />}
|
||||||
|
|||||||
@ -1,284 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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