/** * ImuPanel.jsx — IMU attitude visualization. * * Topics: * /saltybot/imu (sensor_msgs/Imu) — quaternion orientation * /saltybot/balance_state (std_msgs/String JSON) — pitch/roll/yaw deg, * motor_cmd, state, mode * * Displays: * - Artificial horizon canvas (pitch / roll) * - Compass tape (yaw) * - Three.js 3D robot orientation cube * - Numeric readouts + angular velocity */ import { useEffect, useRef, useState, useCallback } from 'react'; import * as THREE from 'three'; // ── 2D canvas helpers ────────────────────────────────────────────────────────── function drawHorizon(ctx, W, H, pitch, roll) { const cx = W / 2, cy = H / 2; const rollRad = roll * Math.PI / 180; const pitchPx = pitch * (H / 60); ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#051a30'; ctx.fillRect(0, 0, W, H); ctx.save(); ctx.translate(cx, cy); ctx.rotate(-rollRad); // Ground ctx.fillStyle = '#1a0f00'; ctx.fillRect(-W, pitchPx, W * 2, H * 2); // Horizon ctx.strokeStyle = '#00ffff'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(-W, pitchPx); ctx.lineTo(W, pitchPx); ctx.stroke(); // Pitch ladder for (let d = -30; d <= 30; d += 10) { if (d === 0) continue; const y = pitchPx + d * (H / 60); const lw = Math.abs(d) % 20 === 0 ? 22 : 14; ctx.strokeStyle = 'rgba(0,210,210,0.4)'; ctx.lineWidth = 0.7; ctx.beginPath(); ctx.moveTo(-lw, y); ctx.lineTo(lw, y); ctx.stroke(); ctx.fillStyle = 'rgba(0,210,210,0.5)'; ctx.font = '7px monospace'; ctx.textAlign = 'left'; ctx.fillText((-d).toString(), lw + 2, y + 3); } ctx.restore(); // Reticle ctx.strokeStyle = '#f97316'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(cx - 28, cy); ctx.lineTo(cx - 8, cy); ctx.moveTo(cx + 8, cy); ctx.lineTo(cx + 28, cy); ctx.moveTo(cx, cy - 4); ctx.lineTo(cx, cy + 4); ctx.stroke(); } function drawCompass(ctx, W, H, yaw) { const cx = W / 2; ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#050510'; ctx.fillRect(0, 0, W, H); const degPerPx = W / 70; const cardinals = { 0:'N', 45:'NE', 90:'E', 135:'SE', 180:'S', 225:'SW', 270:'W', 315:'NW' }; for (let i = -35; i <= 35; i++) { const deg = ((Math.round(yaw) + i) % 360 + 360) % 360; const x = cx + i * degPerPx; const isMaj = deg % 45 === 0; const isMed = deg % 15 === 0; if (!isMed && !isMaj) continue; ctx.strokeStyle = isMaj ? '#00cccc' : 'rgba(0,200,200,0.3)'; ctx.lineWidth = isMaj ? 1.5 : 0.5; const tH = isMaj ? 16 : 7; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, tH); ctx.stroke(); if (isMaj && cardinals[deg] !== undefined) { ctx.fillStyle = deg === 0 ? '#ff4444' : '#00cccc'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'center'; ctx.fillText(cardinals[deg], x, 28); } } const hdg = ((Math.round(yaw) % 360) + 360) % 360; ctx.fillStyle = '#00ffff'; ctx.font = 'bold 11px monospace'; ctx.textAlign = 'center'; ctx.fillText(hdg + '°', cx, H - 4); // Pointer ctx.strokeStyle = '#f97316'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(cx, 0); ctx.lineTo(cx, 10); ctx.stroke(); } // ── Three.js hook ────────────────────────────────────────────────────────────── function useThreeOrientation(containerRef) { const sceneRef = useRef(null); useEffect(() => { const el = containerRef.current; if (!el) return; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, el.clientWidth / el.clientHeight, 0.1, 100); camera.position.set(2.5, 2, 3); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setClearColor(0x070712, 1); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(el.clientWidth, el.clientHeight); el.appendChild(renderer.domElement); // Lighting scene.add(new THREE.AmbientLight(0x404060, 3)); const dir = new THREE.DirectionalLight(0xffffff, 4); dir.position.set(5, 8, 6); scene.add(dir); // Robot body group const group = new THREE.Group(); // Body const body = new THREE.Mesh( new THREE.BoxGeometry(0.8, 1.4, 0.4), new THREE.MeshPhongMaterial({ color: 0x1a2a4a, specular: 0x334466 }) ); body.position.y = 0.5; group.add(body); // Wheels const wheelGeo = new THREE.CylinderGeometry(0.35, 0.35, 0.12, 18); const wheelMat = new THREE.MeshPhongMaterial({ color: 0x1a1a1a, specular: 0x333333 }); [-0.5, 0.5].forEach(x => { const pivot = new THREE.Group(); pivot.position.set(x, -0.2, 0); pivot.rotation.z = Math.PI / 2; pivot.add(new THREE.Mesh(wheelGeo, wheelMat)); group.add(pivot); const rim = new THREE.Mesh( new THREE.TorusGeometry(0.3, 0.02, 8, 16), new THREE.MeshPhongMaterial({ color: 0x0055cc }) ); rim.rotation.z = Math.PI / 2; rim.position.set(x * 1.07, -0.2, 0); group.add(rim); }); // Forward indicator arrow (red tip at +Z front) const arrow = new THREE.Mesh( new THREE.ConeGeometry(0.08, 0.25, 8), new THREE.MeshBasicMaterial({ color: 0xff3030 }) ); arrow.rotation.x = -Math.PI / 2; arrow.position.set(0, 0.5, 0.35); group.add(arrow); // Sensor head const head = new THREE.Mesh( new THREE.BoxGeometry(0.32, 0.18, 0.32), new THREE.MeshPhongMaterial({ color: 0x111122 }) ); head.position.set(0, 1.35, 0); group.add(head); // Axis helper scene.add(new THREE.AxesHelper(1.4)); scene.add(group); const q = new THREE.Quaternion(); let curQ = new THREE.Quaternion(); sceneRef.current = { group, q, curQ }; let animId; const animate = () => { animId = requestAnimationFrame(animate); curQ.slerp(q, 0.12); group.quaternion.copy(curQ); renderer.render(scene, camera); }; animate(); const ro = new ResizeObserver(() => { camera.aspect = el.clientWidth / el.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(el.clientWidth, el.clientHeight); }); ro.observe(el); return () => { cancelAnimationFrame(animId); ro.disconnect(); renderer.dispose(); el.removeChild(renderer.domElement); sceneRef.current = null; }; }, [containerRef]); const updateOrientation = useCallback((qx, qy, qz, qw) => { if (!sceneRef.current) return; sceneRef.current.q.set(qx, qy, qz, qw); }, []); return updateOrientation; } // ── Component ────────────────────────────────────────────────────────────────── function Readout({ label, value, unit, warn }) { return (