diff --git a/jetson/ros2_ws/src/saltybot_wheel_slip_detector/config/wheel_slip_config.yaml b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/config/wheel_slip_config.yaml new file mode 100644 index 0000000..7bae9fe --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/config/wheel_slip_config.yaml @@ -0,0 +1,5 @@ +wheel_slip_detector: + ros__parameters: + frequency: 10 + slip_threshold: 0.1 + slip_timeout: 0.5 diff --git a/jetson/ros2_ws/src/saltybot_wheel_slip_detector/launch/wheel_slip_detector.launch.py b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/launch/wheel_slip_detector.launch.py new file mode 100644 index 0000000..fc8de49 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/launch/wheel_slip_detector.launch.py @@ -0,0 +1,24 @@ +from launch import LaunchDescription +from launch_ros.actions import Node +from launch.substitutions import LaunchConfiguration +from launch.actions import DeclareLaunchArgument +import os +from ament_index_python.packages import get_package_share_directory + +def generate_launch_description(): + pkg_dir = get_package_share_directory("saltybot_wheel_slip_detector") + config_file = os.path.join(pkg_dir, "config", "wheel_slip_config.yaml") + return LaunchDescription([ + DeclareLaunchArgument( + "config_file", + default_value=config_file, + description="Path to configuration YAML file", + ), + Node( + package="saltybot_wheel_slip_detector", + executable="wheel_slip_detector_node", + name="wheel_slip_detector", + output="screen", + parameters=[LaunchConfiguration("config_file")], + ), + ]) diff --git a/jetson/ros2_ws/src/saltybot_wheel_slip_detector/package.xml b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/package.xml new file mode 100644 index 0000000..8dc2903 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/package.xml @@ -0,0 +1,18 @@ + + + + saltybot_wheel_slip_detector + 0.1.0 + Wheel slip detection by comparing commanded vs actual velocity. + Seb + Apache-2.0 + ament_python + rclpy + geometry_msgs + std_msgs + nav_msgs + pytest + + ament_python + + diff --git a/jetson/ros2_ws/src/saltybot_wheel_slip_detector/resource/saltybot_wheel_slip_detector b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/resource/saltybot_wheel_slip_detector new file mode 100644 index 0000000..e69de29 diff --git a/jetson/ros2_ws/src/saltybot_wheel_slip_detector/saltybot_wheel_slip_detector/__init__.py b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/saltybot_wheel_slip_detector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jetson/ros2_ws/src/saltybot_wheel_slip_detector/saltybot_wheel_slip_detector/wheel_slip_detector_node.py b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/saltybot_wheel_slip_detector/wheel_slip_detector_node.py new file mode 100644 index 0000000..6882287 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/saltybot_wheel_slip_detector/wheel_slip_detector_node.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Wheel slip detector for SaltyBot.""" + +from typing import Optional +import math + +import rclpy +from rclpy.node import Node +from rclpy.timer import Timer +from geometry_msgs.msg import Twist +from nav_msgs.msg import Odometry +from std_msgs.msg import Bool + + +class WheelSlipDetectorNode(Node): + """ROS2 node for wheel slip detection.""" + + def __init__(self): + super().__init__("wheel_slip_detector") + + self.declare_parameter("frequency", 10) + frequency = self.get_parameter("frequency").value + self.declare_parameter("slip_threshold", 0.1) + self.declare_parameter("slip_timeout", 0.5) + + self.slip_threshold = self.get_parameter("slip_threshold").value + self.slip_timeout = self.get_parameter("slip_timeout").value + self.period = 1.0 / frequency + + self.cmd_vel: Optional[Twist] = None + self.actual_vel: Optional[Twist] = None + self.slip_duration = 0.0 + self.slip_detected = False + + self.create_subscription(Twist, "/cmd_vel", self._on_cmd_vel, 10) + self.create_subscription(Odometry, "/odom", self._on_odom, 10) + self.pub_slip = self.create_publisher(Bool, "/saltybot/wheel_slip_detected", 10) + self.timer: Timer = self.create_timer(self.period, self._timer_callback) + + self.get_logger().info( + f"Wheel slip detector initialized at {frequency}Hz. " + f"Threshold: {self.slip_threshold} m/s, Timeout: {self.slip_timeout}s" + ) + + def _on_cmd_vel(self, msg: Twist) -> None: + """Update commanded velocity from subscription.""" + self.cmd_vel = msg + + def _on_odom(self, msg: Odometry) -> None: + """Update actual velocity from odometry subscription.""" + self.actual_vel = msg.twist.twist + + def _timer_callback(self) -> None: + """Detect wheel slip and publish detection flag.""" + if self.cmd_vel is None or self.actual_vel is None: + slip_detected = False + else: + slip_detected = self._check_slip() + + if slip_detected: + self.slip_duration += self.period + else: + self.slip_duration = 0.0 + + is_slip = self.slip_duration > self.slip_timeout + + if is_slip != self.slip_detected: + self.slip_detected = is_slip + if self.slip_detected: + self.get_logger().warn(f"WHEEL SLIP DETECTED: {self.slip_duration:.2f}s") + else: + self.get_logger().info("Wheel slip cleared") + + slip_msg = Bool() + slip_msg.data = is_slip + self.pub_slip.publish(slip_msg) + + def _check_slip(self) -> bool: + """Check if velocity difference indicates slip.""" + cmd_speed = math.sqrt(self.cmd_vel.linear.x**2 + self.cmd_vel.linear.y**2) + actual_speed = math.sqrt(self.actual_vel.linear.x**2 + self.actual_vel.linear.y**2) + vel_diff = abs(cmd_speed - actual_speed) + + if cmd_speed < 0.05 and actual_speed < 0.05: + return False + + return vel_diff > self.slip_threshold + + +def main(args=None): + rclpy.init(args=args) + node = WheelSlipDetectorNode() + try: + rclpy.spin(node) + except KeyboardInterrupt: + pass + finally: + node.destroy_node() + rclpy.shutdown() + + +if __name__ == "__main__": + main() diff --git a/jetson/ros2_ws/src/saltybot_wheel_slip_detector/setup.cfg b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/setup.cfg new file mode 100644 index 0000000..d479f34 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/setup.cfg @@ -0,0 +1,4 @@ +[develop] +script-dir=$base/lib/saltybot_wheel_slip_detector +[install] +install-scripts=$base/lib/saltybot_wheel_slip_detector diff --git a/jetson/ros2_ws/src/saltybot_wheel_slip_detector/setup.py b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/setup.py new file mode 100644 index 0000000..b068ded --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/setup.py @@ -0,0 +1,25 @@ +from setuptools import find_packages, setup +package_name = "saltybot_wheel_slip_detector" +setup( + name=package_name, + version="0.1.0", + packages=find_packages(exclude=["test"]), + data_files=[ + ("share/ament_index/resource_index/packages", ["resource/" + package_name]), + ("share/" + package_name, ["package.xml"]), + ("share/" + package_name + "/launch", ["launch/wheel_slip_detector.launch.py"]), + ("share/" + package_name + "/config", ["config/wheel_slip_config.yaml"]), + ], + install_requires=["setuptools"], + zip_safe=True, + maintainer="Seb", + maintainer_email="seb@vayrette.com", + description="Wheel slip detection from velocity command/actual mismatch", + license="Apache-2.0", + tests_require=["pytest"], + entry_points={ + "console_scripts": [ + "wheel_slip_detector_node = saltybot_wheel_slip_detector.wheel_slip_detector_node:main", + ], + }, +) diff --git a/jetson/ros2_ws/src/saltybot_wheel_slip_detector/test/__init__.py b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jetson/ros2_ws/src/saltybot_wheel_slip_detector/test/test_wheel_slip_detector.py b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/test/test_wheel_slip_detector.py new file mode 100644 index 0000000..b2fc889 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_wheel_slip_detector/test/test_wheel_slip_detector.py @@ -0,0 +1,343 @@ +"""Unit tests for wheel_slip_detector_node.""" + +import pytest +import math +from geometry_msgs.msg import Twist +from nav_msgs.msg import Odometry +from std_msgs.msg import Bool + +import rclpy + +from saltybot_wheel_slip_detector.wheel_slip_detector_node import WheelSlipDetectorNode + + +@pytest.fixture +def rclpy_fixture(): + """Initialize and cleanup rclpy.""" + rclpy.init() + yield + rclpy.shutdown() + + +@pytest.fixture +def node(rclpy_fixture): + """Create a wheel slip detector node instance.""" + node = WheelSlipDetectorNode() + yield node + node.destroy_node() + + +class TestNodeInitialization: + """Test suite for node initialization.""" + + def test_node_initialization(self, node): + """Test that node initializes with correct defaults.""" + assert node.cmd_vel is None + assert node.actual_vel is None + assert node.slip_threshold == 0.1 + assert node.slip_timeout == 0.5 + assert node.slip_duration == 0.0 + assert node.slip_detected is False + + def test_frequency_parameter(self, node): + """Test frequency parameter is set correctly.""" + frequency = node.get_parameter("frequency").value + assert frequency == 10 + + def test_slip_threshold_parameter(self, node): + """Test slip threshold parameter is set correctly.""" + threshold = node.get_parameter("slip_threshold").value + assert threshold == 0.1 + + def test_slip_timeout_parameter(self, node): + """Test slip timeout parameter is set correctly.""" + timeout = node.get_parameter("slip_timeout").value + assert timeout == 0.5 + + def test_period_calculation(self, node): + """Test that time period is correctly calculated from frequency.""" + assert node.period == pytest.approx(0.1) + + +class TestSubscriptions: + """Test suite for subscription handling.""" + + def test_cmd_vel_subscription(self, node): + """Test that cmd_vel subscription updates node state.""" + cmd = Twist() + cmd.linear.x = 1.0 + cmd.linear.y = 0.5 + node._on_cmd_vel(cmd) + assert node.cmd_vel is cmd + assert node.cmd_vel.linear.x == 1.0 + + def test_odom_subscription(self, node): + """Test that odometry subscription updates actual velocity.""" + odom = Odometry() + odom.twist.twist.linear.x = 0.95 + odom.twist.twist.linear.y = 0.48 + node._on_odom(odom) + assert node.actual_vel is odom.twist.twist + assert node.actual_vel.linear.x == 0.95 + + +class TestSlipDetection: + """Test suite for slip detection logic.""" + + def test_no_slip_perfect_match(self, node): + """Test no slip when commanded equals actual.""" + cmd = Twist() + cmd.linear.x = 1.0 + odom = Odometry() + odom.twist.twist.linear.x = 1.0 + node._on_cmd_vel(cmd) + node._on_odom(odom) + assert node._check_slip() is False + + def test_no_slip_small_difference(self, node): + """Test no slip when difference is below threshold.""" + cmd = Twist() + cmd.linear.x = 1.0 + odom = Odometry() + odom.twist.twist.linear.x = 0.95 + node._on_cmd_vel(cmd) + node._on_odom(odom) + assert node._check_slip() is False + + def test_slip_exceeds_threshold(self, node): + """Test slip detection when difference exceeds threshold.""" + cmd = Twist() + cmd.linear.x = 1.0 + odom = Odometry() + odom.twist.twist.linear.x = 0.85 + node._on_cmd_vel(cmd) + node._on_odom(odom) + assert node._check_slip() is True + + def test_slip_large_difference(self, node): + """Test slip detection with large velocity difference.""" + cmd = Twist() + cmd.linear.x = 1.0 + odom = Odometry() + odom.twist.twist.linear.x = 0.5 + node._on_cmd_vel(cmd) + node._on_odom(odom) + assert node._check_slip() is True + + def test_no_slip_both_zero(self, node): + """Test no slip when both commanded and actual are zero.""" + cmd = Twist() + cmd.linear.x = 0.0 + odom = Odometry() + odom.twist.twist.linear.x = 0.0 + node._on_cmd_vel(cmd) + node._on_odom(odom) + assert node._check_slip() is False + + def test_no_slip_both_near_zero(self, node): + """Test no slip when both are near zero (tolerance).""" + cmd = Twist() + cmd.linear.x = 0.01 + odom = Odometry() + odom.twist.twist.linear.x = 0.02 + node._on_cmd_vel(cmd) + node._on_odom(odom) + assert node._check_slip() is False + + def test_slip_2d_velocity(self, node): + """Test slip detection with 2D velocity (x and y).""" + cmd = Twist() + cmd.linear.x = 0.7 + cmd.linear.y = 0.7 + odom = Odometry() + odom.twist.twist.linear.x = 0.5 + odom.twist.twist.linear.y = 0.5 + node._on_cmd_vel(cmd) + node._on_odom(odom) + assert node._check_slip() is True + + +class TestSlipPersistence: + """Test suite for slip persistence timing.""" + + def test_slip_not_triggered_immediately(self, node): + """Test that slip is not triggered immediately but requires timeout.""" + cmd = Twist() + cmd.linear.x = 1.0 + odom = Odometry() + odom.twist.twist.linear.x = 0.5 + node._on_cmd_vel(cmd) + node._on_odom(odom) + node._timer_callback() + assert node.slip_duration > 0.0 + assert node.slip_detected is False + + def test_slip_declared_after_timeout(self, node): + """Test that slip is declared after timeout period.""" + cmd = Twist() + cmd.linear.x = 1.0 + odom = Odometry() + odom.twist.twist.linear.x = 0.5 + node._on_cmd_vel(cmd) + node._on_odom(odom) + for _ in range(6): + node._timer_callback() + assert node.slip_detected is True + + def test_slip_recovery_resets_duration(self, node): + """Test that slip duration resets when condition clears.""" + cmd = Twist() + cmd.linear.x = 1.0 + odom1 = Odometry() + odom1.twist.twist.linear.x = 0.5 + node._on_cmd_vel(cmd) + node._on_odom(odom1) + for _ in range(3): + node._timer_callback() + odom2 = Odometry() + odom2.twist.twist.linear.x = 1.0 + node._on_odom(odom2) + node._timer_callback() + assert node.slip_duration == pytest.approx(0.0) + assert node.slip_detected is False + + def test_slip_cumulative_time(self, node): + """Test that slip duration accumulates across callbacks.""" + cmd = Twist() + cmd.linear.x = 1.0 + odom = Odometry() + odom.twist.twist.linear.x = 0.5 + node._on_cmd_vel(cmd) + node._on_odom(odom) + for _ in range(3): + node._timer_callback() + assert node.slip_duration == pytest.approx(0.3) + assert node.slip_detected is False + for _ in range(3): + node._timer_callback() + assert node.slip_duration == pytest.approx(0.6) + assert node.slip_detected is True + + +class TestNoDataConditions: + """Test suite for behavior when sensor data is unavailable.""" + + def test_no_slip_without_cmd_vel(self, node): + """Test no slip declared when cmd_vel not received.""" + node.cmd_vel = None + odom = Odometry() + odom.twist.twist.linear.x = 0.5 + node._on_odom(odom) + node._timer_callback() + assert node.slip_detected is False + + def test_no_slip_without_odometry(self, node): + """Test no slip declared when odometry not received.""" + cmd = Twist() + cmd.linear.x = 1.0 + node._on_cmd_vel(cmd) + node.actual_vel = None + node._timer_callback() + assert node.slip_detected is False + + +class TestScenarios: + """Integration-style tests for realistic scenarios.""" + + def test_scenario_normal_motion_no_slip(self, node): + """Scenario: Normal motion with good wheel traction.""" + cmd = Twist() + cmd.linear.x = 0.5 + for i in range(10): + odom = Odometry() + odom.twist.twist.linear.x = 0.5 + (i * 0.001) + node._on_cmd_vel(cmd) + node._on_odom(odom) + node._timer_callback() + assert node.slip_detected is False + + def test_scenario_ice_slip_persistent(self, node): + """Scenario: Ice causes persistent wheel slip.""" + cmd = Twist() + cmd.linear.x = 1.0 + for _ in range(10): + odom = Odometry() + odom.twist.twist.linear.x = 0.7 + node._on_cmd_vel(cmd) + node._on_odom(odom) + node._timer_callback() + assert node.slip_detected is True + + def test_scenario_sandy_surface_intermittent_slip(self, node): + """Scenario: Sandy surface causes intermittent slip.""" + cmd = Twist() + cmd.linear.x = 0.8 + speeds = [0.7, 0.8, 0.6, 0.8, 0.7, 0.8] + for speed in speeds: + odom = Odometry() + odom.twist.twist.linear.x = speed + node._on_cmd_vel(cmd) + node._on_odom(odom) + node._timer_callback() + assert node.slip_detected is False + + def test_scenario_sudden_obstacle_slip(self, node): + """Scenario: Robot hits obstacle and wheels slip.""" + cmd = Twist() + cmd.linear.x = 1.0 + for _ in range(3): + odom = Odometry() + odom.twist.twist.linear.x = 1.0 + node._on_cmd_vel(cmd) + node._on_odom(odom) + node._timer_callback() + for _ in range(8): + odom = Odometry() + odom.twist.twist.linear.x = 0.2 + node._on_odom(odom) + node._timer_callback() + assert node.slip_detected is True + + def test_scenario_wet_surface_recovery(self, node): + """Scenario: Wet surface slip, then wheel regains traction.""" + cmd = Twist() + cmd.linear.x = 1.0 + for _ in range(6): + odom = Odometry() + odom.twist.twist.linear.x = 0.8 + node._on_cmd_vel(cmd) + node._on_odom(odom) + node._timer_callback() + assert node.slip_detected is True + for _ in range(3): + odom = Odometry() + odom.twist.twist.linear.x = 1.0 + node._on_odom(odom) + node._timer_callback() + assert node.slip_detected is False + + def test_scenario_backward_motion(self, node): + """Scenario: Backward motion with slip.""" + cmd = Twist() + cmd.linear.x = -0.8 + for _ in range(6): + odom = Odometry() + odom.twist.twist.linear.x = -0.4 + node._on_cmd_vel(cmd) + node._on_odom(odom) + node._timer_callback() + assert node.slip_detected is True + + def test_scenario_diagonal_motion_slip(self, node): + """Scenario: Diagonal motion with slip.""" + cmd = Twist() + cmd.linear.x = 0.7 + cmd.linear.y = 0.7 + for _ in range(6): + odom = Odometry() + odom.twist.twist.linear.x = 0.5 + odom.twist.twist.linear.y = 0.5 + node._on_cmd_vel(cmd) + node._on_odom(odom) + node._timer_callback() + assert node.slip_detected is True diff --git a/ui/social-bot/src/App.jsx b/ui/social-bot/src/App.jsx index 8e4be5f..73e1876 100644 --- a/ui/social-bot/src/App.jsx +++ b/ui/social-bot/src/App.jsx @@ -58,6 +58,9 @@ import JoystickTeleop from './components/JoystickTeleop.jsx'; // Network diagnostics (issue #222) import { NetworkPanel } from './components/NetworkPanel.jsx'; +// Waypoint editor (issue #261) +import { WaypointEditor } from './components/WaypointEditor.jsx'; + const TAB_GROUPS = [ { label: 'SOCIAL', @@ -85,6 +88,13 @@ const TAB_GROUPS = [ { id: 'cameras', label: 'Cameras', }, ], }, + { + label: 'NAVIGATION', + color: 'text-teal-600', + tabs: [ + { id: 'waypoints', label: 'Waypoints' }, + ], + }, { label: 'FLEET', color: 'text-green-600', @@ -244,8 +254,10 @@ export default function App() { )} - {activeTab === 'health' && } - {activeTab === 'cameras' && } + {activeTab === 'health' && } + {activeTab === 'cameras' && } + + {activeTab === 'waypoints' && } {activeTab === 'fleet' && } {activeTab === 'missions' && } diff --git a/ui/social-bot/src/components/WaypointEditor.jsx b/ui/social-bot/src/components/WaypointEditor.jsx new file mode 100644 index 0000000..461034b --- /dev/null +++ b/ui/social-bot/src/components/WaypointEditor.jsx @@ -0,0 +1,403 @@ +/** + * WaypointEditor.jsx — Interactive waypoint navigation editor with click-to-place and drag-to-reorder + * + * Features: + * - Click on map canvas to place waypoints + * - Drag waypoints to reorder navigation sequence + * - Right-click to delete waypoints + * - Real-time waypoint list with labels and coordinates + * - Send Nav2 goal to /navigate_to_pose action + * - Execute waypoint sequence with automatic progression + * - Clear all waypoints button + * - Visual feedback for active waypoint (executing) + */ + +import { useEffect, useRef, useState } from 'react'; + +function WaypointEditor({ subscribe, publish, callService }) { + const [waypoints, setWaypoints] = useState([]); + const [selectedWaypoint, setSelectedWaypoint] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [dragIndex, setDragIndex] = useState(null); + const [activeWaypoint, setActiveWaypoint] = useState(null); + const [executing, setExecuting] = useState(false); + + const [mapData, setMapData] = useState(null); + const [robotPose, setRobotPose] = useState({ x: 0, y: 0, theta: 0 }); + + const canvasRef = useRef(null); + const containerRef = useRef(null); + + const mapDataRef = useRef(null); + const robotPoseRef = useRef({ x: 0, y: 0, theta: 0 }); + const waypointsRef = useRef([]); + + // Subscribe to map data + useEffect(() => { + const unsubMap = subscribe( + '/map', + 'nav_msgs/OccupancyGrid', + (msg) => { + try { + const mapInfo = { + width: msg.info.width, + height: msg.info.height, + resolution: msg.info.resolution, + origin: msg.info.origin, + }; + setMapData(mapInfo); + mapDataRef.current = mapInfo; + } catch (e) { + console.error('Error parsing map data:', e); + } + } + ); + return unsubMap; + }, [subscribe]); + + // Subscribe to robot odometry + useEffect(() => { + const unsubOdom = subscribe( + '/odom', + 'nav_msgs/Odometry', + (msg) => { + try { + const pos = msg.pose.pose.position; + const ori = msg.pose.pose.orientation; + + const siny_cosp = 2 * (ori.w * ori.z + ori.x * ori.y); + const cosy_cosp = 1 - 2 * (ori.y * ori.y + ori.z * ori.z); + const theta = Math.atan2(siny_cosp, cosy_cosp); + + const newPose = { x: pos.x, y: pos.y, theta }; + setRobotPose(newPose); + robotPoseRef.current = newPose; + } catch (e) { + console.error('Error parsing odometry data:', e); + } + } + ); + return unsubOdom; + }, [subscribe]); + + const handleCanvasClick = (e) => { + if (!mapDataRef.current || !containerRef.current) return; + + const rect = containerRef.current.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const clickY = e.clientY - rect.top; + + const map = mapDataRef.current; + const robot = robotPoseRef.current; + const zoom = 1; + + const centerX = containerRef.current.clientWidth / 2; + const centerY = containerRef.current.clientHeight / 2; + + const worldX = robot.x + (clickX - centerX) / zoom; + const worldY = robot.y - (clickY - centerY) / zoom; + + const newWaypoint = { + id: Date.now(), + x: parseFloat(worldX.toFixed(2)), + y: parseFloat(worldY.toFixed(2)), + label: `WP-${waypoints.length + 1}`, + }; + + setWaypoints((prev) => [...prev, newWaypoint]); + waypointsRef.current = [...waypointsRef.current, newWaypoint]; + }; + + const handleDeleteWaypoint = (id) => { + setWaypoints((prev) => prev.filter((wp) => wp.id !== id)); + waypointsRef.current = waypointsRef.current.filter((wp) => wp.id !== id); + if (selectedWaypoint === id) setSelectedWaypoint(null); + }; + + const handleWaypointSelect = (id) => { + setSelectedWaypoint(selectedWaypoint === id ? null : id); + }; + + const handleWaypointDragStart = (e, index) => { + setIsDragging(true); + setDragIndex(index); + }; + + const handleWaypointDragOver = (e, targetIndex) => { + if (!isDragging || dragIndex === null || dragIndex === targetIndex) return; + + const newWaypoints = [...waypoints]; + const draggedWaypoint = newWaypoints[dragIndex]; + newWaypoints.splice(dragIndex, 1); + newWaypoints.splice(targetIndex, 0, draggedWaypoint); + + setWaypoints(newWaypoints); + waypointsRef.current = newWaypoints; + setDragIndex(targetIndex); + }; + + const handleWaypointDragEnd = () => { + setIsDragging(false); + setDragIndex(null); + }; + + const sendNavGoal = async (waypoint) => { + if (!callService) return; + + try { + const heading = waypoint.theta || 0; + const halfHeading = heading / 2; + + const goal = { + pose: { + position: { + x: waypoint.x, + y: waypoint.y, + z: 0, + }, + orientation: { + x: 0, + y: 0, + z: Math.sin(halfHeading), + w: Math.cos(halfHeading), + }, + }, + }; + + await callService( + '/navigate_to_pose', + 'nav2_msgs/NavigateToPose', + { pose: goal.pose } + ); + + setActiveWaypoint(waypoint.id); + return true; + } catch (e) { + console.error('Error sending nav goal:', e); + return false; + } + }; + + const executeWaypoints = async () => { + if (waypoints.length === 0) return; + + setExecuting(true); + for (const waypoint of waypoints) { + const success = await sendNavGoal(waypoint); + if (!success) break; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + setExecuting(false); + setActiveWaypoint(null); + }; + + const clearWaypoints = () => { + setWaypoints([]); + waypointsRef.current = []; + setSelectedWaypoint(null); + setActiveWaypoint(null); + }; + + const sendSingleGoal = async () => { + if (selectedWaypoint === null) return; + + const wp = waypoints.find((w) => w.id === selectedWaypoint); + if (wp) { + await sendNavGoal(wp); + } + }; + + return ( +
+ {/* Map area */} +
+
+
e.preventDefault()} + > + + {waypoints.map((wp, idx) => { + if (!mapDataRef.current) return null; + + const robot = robotPoseRef.current; + const zoom = 1; + const centerX = containerRef.current?.clientWidth / 2 || 400; + const centerY = containerRef.current?.clientHeight / 2 || 300; + + const canvasX = centerX + (wp.x - robot.x) * zoom; + const canvasY = centerY - (wp.y - robot.y) * zoom; + + const isActive = wp.id === activeWaypoint; + const isSelected = wp.id === selectedWaypoint; + + return ( + + + + {idx + 1} + + {idx < waypoints.length - 1 && ( + + )} + + ); + })} + + + +
+ {waypoints.length === 0 && ( +
+
Click to place waypoints
+
Right-click to delete
+
+ )} +
+
+
+ + {/* Info panel */} +
+
+ Waypoints: + {waypoints.length} +
+
+ Robot Position: + + ({robotPose.x.toFixed(2)}, {robotPose.y.toFixed(2)}) + +
+
+
+ + {/* Waypoint list sidebar */} +
+
+
WAYPOINTS
+
{waypoints.length}
+
+ + {/* Waypoint list */} +
+ {waypoints.length === 0 ? ( +
Click map to add waypoints
+ ) : ( + waypoints.map((wp, idx) => ( +
handleWaypointDragStart(e, idx)} + onDragOver={(e) => { + e.preventDefault(); + handleWaypointDragOver(e, idx); + }} + onDragEnd={handleWaypointDragEnd} + onClick={() => handleWaypointSelect(wp.id)} + onContextMenu={(e) => { + e.preventDefault(); + handleDeleteWaypoint(wp.id); + }} + className={`p-2 rounded border text-xs cursor-move transition-colors ${ + wp.id === activeWaypoint + ? 'bg-red-950 border-red-700 text-red-300' + : wp.id === selectedWaypoint + ? 'bg-amber-950 border-amber-700 text-amber-300' + : 'bg-gray-900 border-gray-700 text-gray-400 hover:border-gray-600' + }`} + > +
+
#{idx + 1}
+
+
{wp.label}
+
+ {wp.x.toFixed(2)}, {wp.y.toFixed(2)} +
+
+
+
+ )) + )} +
+ + {/* Control buttons */} +
+ + + + + +
+ + {/* Instructions */} +
+
CONTROLS:
+
• Click: Place waypoint
+
• Right-click: Delete waypoint
+
• Drag: Reorder waypoints
+
• Click list: Select waypoint
+
+ + {/* Topic info */} +
+
+ Service: + /navigate_to_pose +
+
+
+
+ ); +} + +export { WaypointEditor };