Skip to main content

rust_robotics_control/
arm_obstacle_navigation.rs

1#![allow(dead_code, clippy::new_without_default)]
2
3//! Arm path planning with obstacle avoidance in joint space.
4//!
5//! Builds an occupancy grid in joint space by checking arm-obstacle collisions,
6//! then uses A\* search on a toroidal grid to find a collision-free path.
7//!
8//! Reference: PythonRobotics ArmNavigation/arm\_obstacle\_navigation
9
10use std::f64::consts::PI;
11
12/// Default grid resolution (discretization of each joint angle axis).
13const DEFAULT_GRID_SIZE: usize = 100;
14
15/// Minimal 2-joint arm for collision checking in the occupancy grid.
16///
17/// This is a lightweight version focused on forward kinematics for the
18/// obstacle navigation use case. For the full N-link arm with inverse
19/// kinematics, see `n_joint_arm_control::NLinkArm`.
20#[derive(Debug, Clone)]
21struct Arm2Link {
22    link_lengths: [f64; 2],
23    points: [[f64; 2]; 3],
24}
25
26impl Arm2Link {
27    fn new(link_lengths: [f64; 2]) -> Self {
28        let mut arm = Arm2Link {
29            link_lengths,
30            points: [[0.0; 2]; 3],
31        };
32        arm.update_joints(0.0, 0.0);
33        arm
34    }
35
36    fn update_joints(&mut self, theta1: f64, theta2: f64) {
37        self.points[0] = [0.0, 0.0];
38        self.points[1] = [
39            self.link_lengths[0] * theta1.cos(),
40            self.link_lengths[0] * theta1.sin(),
41        ];
42        let cumulative = theta1 + theta2;
43        self.points[2] = [
44            self.points[1][0] + self.link_lengths[1] * cumulative.cos(),
45            self.points[1][1] + self.link_lengths[1] * cumulative.sin(),
46        ];
47    }
48}
49
50/// A circular obstacle defined by center (x, y) and radius.
51#[derive(Debug, Clone, Copy)]
52pub struct CircleObstacle {
53    pub x: f64,
54    pub y: f64,
55    pub radius: f64,
56}
57
58impl CircleObstacle {
59    pub fn new(x: f64, y: f64, radius: f64) -> Self {
60        CircleObstacle { x, y, radius }
61    }
62}
63
64/// A grid cell coordinate in joint space.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66pub struct GridCell {
67    pub i: usize,
68    pub j: usize,
69}
70
71/// Result of an A\* path search in joint space.
72#[derive(Debug, Clone)]
73pub struct ArmPath {
74    /// Sequence of grid cells from start to goal.
75    pub route_cells: Vec<GridCell>,
76    /// Corresponding joint angle pairs \[theta1, theta2\] for each cell.
77    pub joint_angles: Vec<[f64; 2]>,
78}
79
80/// Occupancy grid for a 2-joint arm in joint space.
81///
82/// Each cell `(i, j)` represents a pair of joint angles and is marked
83/// as occupied (true) if the arm configuration collides with any obstacle.
84#[derive(Debug, Clone)]
85pub struct JointSpaceGrid {
86    /// Grid size along each axis.
87    pub size: usize,
88    /// Flattened occupancy data (row-major): `grid[i * size + j]`.
89    pub data: Vec<bool>,
90}
91
92impl JointSpaceGrid {
93    /// Creates a new empty grid.
94    pub fn new(size: usize) -> Self {
95        JointSpaceGrid {
96            size,
97            data: vec![false; size * size],
98        }
99    }
100
101    /// Returns the occupancy value at cell (i, j).
102    pub fn get(&self, i: usize, j: usize) -> bool {
103        self.data[i * self.size + j]
104    }
105
106    /// Sets the occupancy value at cell (i, j).
107    pub fn set(&mut self, i: usize, j: usize, val: bool) {
108        self.data[i * self.size + j] = val;
109    }
110
111    /// Converts a grid index to a joint angle in \[-pi, pi\).
112    pub fn index_to_angle(&self, idx: usize) -> f64 {
113        2.0 * PI * idx as f64 / self.size as f64 - PI
114    }
115}
116
117/// Determines whether a line segment intersects a circle.
118///
119/// The line segment is defined by endpoints `a` and `b`.
120/// The circle is defined by center `c` and `radius`.
121///
122/// Uses projection of circle center onto line segment to find closest point.
123pub fn detect_collision(a: &[f64; 2], b: &[f64; 2], obstacle: &CircleObstacle) -> bool {
124    let line_vec = [b[0] - a[0], b[1] - a[1]];
125    let line_mag = (line_vec[0] * line_vec[0] + line_vec[1] * line_vec[1]).sqrt();
126    if line_mag < 1e-12 {
127        // Degenerate segment: just check point distance
128        let dx = a[0] - obstacle.x;
129        let dy = a[1] - obstacle.y;
130        return (dx * dx + dy * dy).sqrt() <= obstacle.radius;
131    }
132
133    let line_unit = [line_vec[0] / line_mag, line_vec[1] / line_mag];
134    let circle_vec = [obstacle.x - a[0], obstacle.y - a[1]];
135    let proj = circle_vec[0] * line_unit[0] + circle_vec[1] * line_unit[1];
136
137    let closest = if proj <= 0.0 {
138        *a
139    } else if proj >= line_mag {
140        *b
141    } else {
142        [a[0] + line_unit[0] * proj, a[1] + line_unit[1] * proj]
143    };
144
145    let dx = closest[0] - obstacle.x;
146    let dy = closest[1] - obstacle.y;
147    (dx * dx + dy * dy).sqrt() <= obstacle.radius
148}
149
150/// Builds the joint-space occupancy grid for a 2-joint arm.
151///
152/// Iterates over all discretized joint angle combinations, computes the arm
153/// configuration, and checks for collision with each obstacle.
154pub fn build_occupancy_grid(
155    link_lengths: &[f64; 2],
156    obstacles: &[CircleObstacle],
157    grid_size: usize,
158) -> JointSpaceGrid {
159    let mut grid = JointSpaceGrid::new(grid_size);
160    let mut arm = Arm2Link::new(*link_lengths);
161
162    for i in 0..grid_size {
163        let theta1 = grid.index_to_angle(i);
164        for j in 0..grid_size {
165            let theta2 = grid.index_to_angle(j);
166            arm.update_joints(theta1, theta2);
167
168            let mut collision = false;
169            // Check each link segment against each obstacle
170            for k in 0..2 {
171                if collision {
172                    break;
173                }
174                for obs in obstacles {
175                    if detect_collision(&arm.points[k], &arm.points[k + 1], obs) {
176                        collision = true;
177                        break;
178                    }
179                }
180            }
181            grid.set(i, j, collision);
182        }
183    }
184    grid
185}
186
187/// Builds occupancy grid with default grid size.
188pub fn build_occupancy_grid_default(
189    link_lengths: &[f64; 2],
190    obstacles: &[CircleObstacle],
191) -> JointSpaceGrid {
192    build_occupancy_grid(link_lengths, obstacles, DEFAULT_GRID_SIZE)
193}
194
195/// Finds the 4-connected neighbors on a toroidal grid.
196fn find_neighbors(i: usize, j: usize, m: usize) -> [(usize, usize); 4] {
197    let up = if i == 0 { m - 1 } else { i - 1 };
198    let down = if i + 1 >= m { 0 } else { i + 1 };
199    let left = if j == 0 { m - 1 } else { j - 1 };
200    let right = if j + 1 >= m { 0 } else { j + 1 };
201    [(up, j), (down, j), (i, left), (i, right)]
202}
203
204/// Computes the toroidal Manhattan heuristic for A\* on a toroidal grid.
205fn calc_heuristic_map(m: usize, goal: GridCell) -> Vec<Vec<f64>> {
206    let mut hmap = vec![vec![0.0f64; m]; m];
207    for (i, hmap_row) in hmap.iter_mut().enumerate() {
208        for (j, hmap_val) in hmap_row.iter_mut().enumerate() {
209            let di = {
210                let d = (i as isize - goal.i as isize).unsigned_abs();
211                d.min(m - d)
212            };
213            let dj = {
214                let d = (j as isize - goal.j as isize).unsigned_abs();
215                d.min(m - d)
216            };
217            *hmap_val = (di + dj) as f64;
218        }
219    }
220    hmap
221}
222
223/// Performs A\* search on a toroidal joint-space grid.
224///
225/// The grid wraps around in both dimensions (since joint angles are periodic).
226///
227/// Returns `Some(ArmPath)` if a route is found, `None` otherwise.
228pub fn astar_torus(grid: &JointSpaceGrid, start: GridCell, goal: GridCell) -> Option<ArmPath> {
229    let m = grid.size;
230
231    // Cell states: 0 = free, 1 = obstacle, 2 = explored, 3 = frontier, 4 = start, 5 = goal
232    let mut cell_state = vec![vec![0u8; m]; m];
233    for (i, state_row) in cell_state.iter_mut().enumerate() {
234        for (j, state_val) in state_row.iter_mut().enumerate() {
235            if grid.get(i, j) {
236                *state_val = 1; // obstacle
237            }
238        }
239    }
240
241    // Check start and goal are not in obstacles
242    if cell_state[start.i][start.j] == 1 || cell_state[goal.i][goal.j] == 1 {
243        return None;
244    }
245
246    cell_state[start.i][start.j] = 4;
247    cell_state[goal.i][goal.j] = 5;
248
249    let heuristic_map = calc_heuristic_map(m, goal);
250
251    // f-score map (heuristic + distance) for explored nodes
252    let mut f_score = vec![vec![f64::INFINITY; m]; m];
253    let mut g_score = vec![vec![f64::INFINITY; m]; m];
254    let mut parent: Vec<Vec<Option<(usize, usize)>>> = vec![vec![None; m]; m];
255
256    f_score[start.i][start.j] = heuristic_map[start.i][start.j];
257    g_score[start.i][start.j] = 0.0;
258
259    loop {
260        // Find cell with minimum f-score
261        let mut min_f = f64::INFINITY;
262        let mut current = (0, 0);
263        for (i, f_row) in f_score.iter().enumerate() {
264            for (j, &f_val) in f_row.iter().enumerate() {
265                if f_val < min_f {
266                    min_f = f_val;
267                    current = (i, j);
268                }
269            }
270        }
271
272        if min_f.is_infinite() {
273            return None; // No route found
274        }
275
276        if current == (goal.i, goal.j) {
277            break; // Found the goal
278        }
279
280        // Mark as explored
281        cell_state[current.0][current.1] = 2;
282        f_score[current.0][current.1] = f64::INFINITY; // Remove from open set
283
284        let neighbors = find_neighbors(current.0, current.1, m);
285        for &(ni, nj) in &neighbors {
286            let state = cell_state[ni][nj];
287            if state == 0 || state == 5 {
288                let tentative_g = g_score[current.0][current.1] + 1.0;
289                if tentative_g < g_score[ni][nj] {
290                    g_score[ni][nj] = tentative_g;
291                    f_score[ni][nj] = tentative_g + heuristic_map[ni][nj];
292                    parent[ni][nj] = Some(current);
293                    if state != 5 {
294                        cell_state[ni][nj] = 3; // frontier
295                    }
296                }
297            }
298        }
299    }
300
301    // Reconstruct route
302    let mut route = vec![GridCell {
303        i: goal.i,
304        j: goal.j,
305    }];
306    let mut cur = (goal.i, goal.j);
307    while let Some(p) = parent[cur.0][cur.1] {
308        route.push(GridCell { i: p.0, j: p.1 });
309        cur = p;
310    }
311    route.reverse();
312
313    // Convert grid cells to joint angles
314    let joint_angles: Vec<[f64; 2]> = route
315        .iter()
316        .map(|cell| [grid.index_to_angle(cell.i), grid.index_to_angle(cell.j)])
317        .collect();
318
319    Some(ArmPath {
320        route_cells: route,
321        joint_angles,
322    })
323}
324
325/// High-level function: plan a collision-free path for a 2-joint arm.
326///
327/// Given link lengths, start/goal joint configurations (as grid cell indices),
328/// and obstacles, builds the occupancy grid and runs A\* to find a path.
329pub fn plan_arm_path(
330    link_lengths: &[f64; 2],
331    obstacles: &[CircleObstacle],
332    start: GridCell,
333    goal: GridCell,
334    grid_size: usize,
335) -> Option<ArmPath> {
336    let grid = build_occupancy_grid(link_lengths, obstacles, grid_size);
337    astar_torus(&grid, start, goal)
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn test_detect_collision_hit() {
346        let a = [0.0, 0.0];
347        let b = [2.0, 0.0];
348        let obs = CircleObstacle::new(1.0, 0.0, 0.5);
349        assert!(detect_collision(&a, &b, &obs));
350    }
351
352    #[test]
353    fn test_detect_collision_miss() {
354        let a = [0.0, 0.0];
355        let b = [2.0, 0.0];
356        let obs = CircleObstacle::new(1.0, 2.0, 0.5);
357        assert!(!detect_collision(&a, &b, &obs));
358    }
359
360    #[test]
361    fn test_detect_collision_endpoint() {
362        let a = [0.0, 0.0];
363        let b = [1.0, 0.0];
364        // Obstacle at endpoint b
365        let obs = CircleObstacle::new(1.0, 0.0, 0.1);
366        assert!(detect_collision(&a, &b, &obs));
367    }
368
369    #[test]
370    fn test_detect_collision_before_segment() {
371        let a = [1.0, 0.0];
372        let b = [2.0, 0.0];
373        let obs = CircleObstacle::new(0.0, 0.0, 0.5);
374        assert!(!detect_collision(&a, &b, &obs));
375    }
376
377    #[test]
378    fn test_detect_collision_degenerate_segment() {
379        let a = [0.0, 0.0];
380        let b = [0.0, 0.0];
381        let obs = CircleObstacle::new(0.0, 0.0, 0.5);
382        assert!(detect_collision(&a, &b, &obs));
383    }
384
385    #[test]
386    fn test_joint_space_grid_basic() {
387        let grid = JointSpaceGrid::new(10);
388        assert!(!grid.get(0, 0));
389        assert_eq!(grid.size, 10);
390    }
391
392    #[test]
393    fn test_grid_set_get() {
394        let mut grid = JointSpaceGrid::new(10);
395        grid.set(3, 5, true);
396        assert!(grid.get(3, 5));
397        assert!(!grid.get(5, 3));
398    }
399
400    #[test]
401    fn test_index_to_angle() {
402        let grid = JointSpaceGrid::new(100);
403        // Index 0 => -pi
404        assert!((grid.index_to_angle(0) - (-PI)).abs() < 1e-10);
405        // Index 50 => 0
406        assert!(grid.index_to_angle(50).abs() < 1e-10);
407    }
408
409    #[test]
410    fn test_find_neighbors_middle() {
411        let neighbors = find_neighbors(5, 5, 10);
412        assert_eq!(neighbors, [(4, 5), (6, 5), (5, 4), (5, 6)]);
413    }
414
415    #[test]
416    fn test_find_neighbors_wrap() {
417        let neighbors = find_neighbors(0, 0, 10);
418        assert_eq!(neighbors, [(9, 0), (1, 0), (0, 9), (0, 1)]);
419    }
420
421    #[test]
422    fn test_heuristic_map_at_goal() {
423        let goal = GridCell { i: 5, j: 5 };
424        let hmap = calc_heuristic_map(10, goal);
425        assert!((hmap[5][5] - 0.0).abs() < 1e-10);
426    }
427
428    #[test]
429    fn test_heuristic_map_toroidal() {
430        let goal = GridCell { i: 0, j: 0 };
431        let hmap = calc_heuristic_map(10, goal);
432        // Cell (9, 9) should have distance 2 (wrap around in both dims)
433        assert!((hmap[9][9] - 2.0).abs() < 1e-10);
434    }
435
436    #[test]
437    fn test_astar_torus_empty_grid() {
438        let grid = JointSpaceGrid::new(20);
439        let start = GridCell { i: 2, j: 2 };
440        let goal = GridCell { i: 18, j: 18 };
441        let result = astar_torus(&grid, start, goal);
442        assert!(result.is_some());
443        let path = result.unwrap();
444        assert_eq!(path.route_cells.first().unwrap(), &start);
445        assert_eq!(path.route_cells.last().unwrap(), &goal);
446        assert_eq!(path.route_cells.len(), path.joint_angles.len());
447    }
448
449    #[test]
450    fn test_astar_torus_wrapping() {
451        // On a 10x10 grid, going from (0,0) to (9,9) should wrap
452        let grid = JointSpaceGrid::new(10);
453        let start = GridCell { i: 0, j: 0 };
454        let goal = GridCell { i: 9, j: 9 };
455        let result = astar_torus(&grid, start, goal);
456        assert!(result.is_some());
457        let path = result.unwrap();
458        // Wrapping path should be short: 2 steps
459        assert_eq!(path.route_cells.len(), 3); // start + 2 moves
460    }
461
462    #[test]
463    fn test_astar_torus_blocked() {
464        // On a torus, completely isolating a cell requires blocking ALL its neighbors.
465        // Block all 8 neighbors of (3,3) on a 5x5 torus.
466        let mut grid = JointSpaceGrid::new(5);
467        for di in [-1i32, 0, 1] {
468            for dj in [-1i32, 0, 1] {
469                if di == 0 && dj == 0 {
470                    continue;
471                }
472                let ni = ((3 + di).rem_euclid(5)) as usize;
473                let nj = ((3 + dj).rem_euclid(5)) as usize;
474                grid.set(ni, nj, true);
475            }
476        }
477        let start = GridCell { i: 0, j: 0 };
478        let goal = GridCell { i: 3, j: 3 };
479        let result = astar_torus(&grid, start, goal);
480        assert!(result.is_none());
481    }
482
483    #[test]
484    fn test_build_occupancy_grid_no_obstacles() {
485        let link_lengths = [1.0, 1.0];
486        let grid = build_occupancy_grid(&link_lengths, &[], 20);
487        // With no obstacles, no cell should be occupied
488        for i in 0..20 {
489            for j in 0..20 {
490                assert!(!grid.get(i, j));
491            }
492        }
493    }
494
495    #[test]
496    fn test_build_occupancy_grid_with_obstacle() {
497        let link_lengths = [1.0, 1.0];
498        // Obstacle right on the x-axis at distance 1.5
499        let obstacles = vec![CircleObstacle::new(1.5, 0.0, 0.3)];
500        let grid = build_occupancy_grid(&link_lengths, &obstacles, 20);
501        // The configuration (0, 0) should collide (arm extends to x=2 along x-axis)
502        // Index for angle 0 is at index 10 (midpoint of grid)
503        assert!(grid.get(10, 10));
504    }
505
506    #[test]
507    fn test_plan_arm_path_simple() {
508        // No obstacles: path should always be found
509        let link_lengths = [1.0, 1.0];
510        let start = GridCell { i: 5, j: 5 };
511        let goal = GridCell { i: 15, j: 15 };
512        let result = plan_arm_path(&link_lengths, &[], start, goal, 20);
513        assert!(result.is_some());
514        let path = result.unwrap();
515        assert!(!path.route_cells.is_empty());
516        assert_eq!(path.route_cells[0], start);
517        assert_eq!(*path.route_cells.last().unwrap(), goal);
518    }
519
520    #[test]
521    fn test_plan_arm_path_with_obstacles() {
522        let link_lengths = [1.0, 1.0];
523        let obstacles = vec![
524            CircleObstacle::new(1.75, 0.75, 0.6),
525            CircleObstacle::new(0.55, 1.5, 0.5),
526        ];
527        let grid = build_occupancy_grid(&link_lengths, &obstacles, 50);
528
529        // Find a start and goal that are free
530        let start = GridCell { i: 5, j: 25 };
531        let goal = GridCell { i: 30, j: 30 };
532
533        // Only try if start and goal are free
534        if !grid.get(start.i, start.j) && !grid.get(goal.i, goal.j) {
535            let result = astar_torus(&grid, start, goal);
536            if let Some(path) = result {
537                // Verify no cell in route is an obstacle
538                for cell in &path.route_cells {
539                    assert!(!grid.get(cell.i, cell.j));
540                }
541            }
542        }
543    }
544
545    #[test]
546    fn test_arm_path_joint_angles_match_cells() {
547        let grid = JointSpaceGrid::new(20);
548        let start = GridCell { i: 3, j: 7 };
549        let goal = GridCell { i: 10, j: 15 };
550        let result = astar_torus(&grid, start, goal).unwrap();
551
552        for (cell, angles) in result.route_cells.iter().zip(result.joint_angles.iter()) {
553            let expected_theta1 = grid.index_to_angle(cell.i);
554            let expected_theta2 = grid.index_to_angle(cell.j);
555            assert!((angles[0] - expected_theta1).abs() < 1e-10);
556            assert!((angles[1] - expected_theta2).abs() < 1e-10);
557        }
558    }
559
560    #[test]
561    fn test_start_equals_goal() {
562        let grid = JointSpaceGrid::new(10);
563        let cell = GridCell { i: 5, j: 5 };
564        let result = astar_torus(&grid, cell, cell);
565        assert!(result.is_some());
566        let path = result.unwrap();
567        assert_eq!(path.route_cells.len(), 1);
568    }
569}