python-motion-planning 2.0.dev1__py3-none-any.whl → 2.0.dev2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. python_motion_planning/common/env/map/base_map.py +2 -8
  2. python_motion_planning/common/env/map/grid.py +74 -81
  3. python_motion_planning/common/utils/__init__.py +2 -1
  4. python_motion_planning/common/utils/child_tree.py +22 -0
  5. python_motion_planning/common/visualizer/__init__.py +3 -1
  6. python_motion_planning/common/visualizer/base_visualizer.py +165 -0
  7. python_motion_planning/common/visualizer/{visualizer.py → visualizer_2d.py} +97 -220
  8. python_motion_planning/common/visualizer/visualizer_3d.py +242 -0
  9. python_motion_planning/controller/base_controller.py +37 -4
  10. python_motion_planning/controller/path_tracker/__init__.py +2 -1
  11. python_motion_planning/controller/path_tracker/apf.py +22 -23
  12. python_motion_planning/controller/path_tracker/dwa.py +14 -17
  13. python_motion_planning/controller/path_tracker/path_tracker.py +4 -1
  14. python_motion_planning/controller/path_tracker/pid.py +7 -1
  15. python_motion_planning/controller/path_tracker/pure_pursuit.py +7 -1
  16. python_motion_planning/controller/path_tracker/rpp.py +111 -0
  17. python_motion_planning/path_planner/__init__.py +2 -1
  18. python_motion_planning/path_planner/base_path_planner.py +45 -11
  19. python_motion_planning/path_planner/graph_search/__init__.py +4 -1
  20. python_motion_planning/path_planner/graph_search/a_star.py +12 -14
  21. python_motion_planning/path_planner/graph_search/dijkstra.py +15 -15
  22. python_motion_planning/path_planner/graph_search/gbfs.py +100 -0
  23. python_motion_planning/path_planner/graph_search/jps.py +199 -0
  24. python_motion_planning/path_planner/graph_search/lazy_theta_star.py +113 -0
  25. python_motion_planning/path_planner/graph_search/theta_star.py +17 -19
  26. python_motion_planning/path_planner/hybrid_search/__init__.py +1 -0
  27. python_motion_planning/path_planner/hybrid_search/voronoi_planner.py +204 -0
  28. python_motion_planning/path_planner/sample_search/__init__.py +2 -1
  29. python_motion_planning/path_planner/sample_search/rrt.py +70 -28
  30. python_motion_planning/path_planner/sample_search/rrt_connect.py +237 -0
  31. python_motion_planning/path_planner/sample_search/rrt_star.py +201 -151
  32. {python_motion_planning-2.0.dev1.dist-info → python_motion_planning-2.0.dev2.dist-info}/METADATA +54 -19
  33. {python_motion_planning-2.0.dev1.dist-info → python_motion_planning-2.0.dev2.dist-info}/RECORD +36 -27
  34. python_motion_planning/common/env/robot/tmp.py +0 -404
  35. {python_motion_planning-2.0.dev1.dist-info → python_motion_planning-2.0.dev2.dist-info}/WHEEL +0 -0
  36. {python_motion_planning-2.0.dev1.dist-info → python_motion_planning-2.0.dev2.dist-info}/licenses/LICENSE +0 -0
  37. {python_motion_planning-2.0.dev1.dist-info → python_motion_planning-2.0.dev2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,204 @@
1
+ """
2
+ @file: voronoi.py
3
+ @author: Wu Maojia
4
+ @update: 2025.12.20
5
+ """
6
+ import copy
7
+ from typing import Union, List, Tuple, Dict, Any
8
+ import heapq
9
+
10
+ import numpy as np
11
+ from scipy.spatial import Voronoi
12
+
13
+ from python_motion_planning.common import Node, TYPES, Grid
14
+ from python_motion_planning.path_planner.base_path_planner import BasePathPlanner
15
+ from python_motion_planning.path_planner.graph_search.a_star import AStar
16
+
17
+
18
+ class VoronoiPlanner(BasePathPlanner):
19
+ """
20
+ Path planner based on Voronoi diagram.
21
+ Core idea: find the nearest points on the Voronoi diagram to the start and goal,
22
+ plan the path on the Voronoi graph using a base planner, and then concatenate the full path.
23
+ Note that the boundary of grid map passed in Voronoi planner should be filled with obstacles using Grid.fill_boundary_with_obstacles() method. If not, the Voronoi planner will automatically fill.
24
+
25
+ Args:
26
+ *args: see the parent class.
27
+ base_planner: base planner class for path planning.
28
+ base_planner_kwargs: keyword arguments for the base planner.
29
+ cover_inflation: determine whether the voronoi candidates cover the inflation region.
30
+ *kwargs: see the parent class.
31
+
32
+ Examples:
33
+ >>> map_ = Grid(bounds=[[0, 15], [0, 15]])
34
+ >>> planner = VoronoiPlanner(map_=map_, start=(5, 5), goal=(10, 10))
35
+ >>> path, path_info = planner.plan()
36
+ >>> print(path_info['success'])
37
+ True
38
+
39
+ >>> planner.map_.type_map[3:10, 6] = TYPES.OBSTACLE
40
+ >>> path, path_info = planner.plan()
41
+ >>> print(path_info['success'])
42
+ True
43
+ """
44
+ def __init__(self,
45
+ *args,
46
+ base_planner: BasePathPlanner = AStar,
47
+ base_planner_kwargs: dict = {},
48
+ cover_inflation: bool = False,
49
+ **kwargs
50
+ ) -> None:
51
+ super().__init__(*args, **kwargs)
52
+
53
+ self.base_planner = base_planner
54
+ self.base_planner_kwargs = base_planner_kwargs
55
+ self.base_planner_kwargs["map_"] = self.map_
56
+ self.base_planner_kwargs["start"] = self.start
57
+ self.base_planner_kwargs["goal"] = self.goal
58
+
59
+ self.cover_inflation = cover_inflation
60
+
61
+ def __str__(self) -> str:
62
+ return "Voronoi Planner"
63
+
64
+ def find_voronoi_candidates(self, map_: Grid) -> np.ndarray:
65
+ """
66
+ Find Voronoi candidate points for the grid map.
67
+
68
+ Args:
69
+ map_: grid map.
70
+
71
+ Returns:
72
+ candidates: Voronoi candidate points matrix.
73
+ """
74
+ generators = np.argwhere(map_.data == 1)
75
+ candidates = np.zeros_like(map_.data, dtype=np.bool_)
76
+ if len(generators) < 2:
77
+ return candidates
78
+
79
+ vor = Voronoi(generators)
80
+
81
+ for ridge in vor.ridge_vertices:
82
+ v1_idx, v2_idx = ridge[:2]
83
+ if v1_idx == -1 or v2_idx == -1:
84
+ continue # skip infinite ridges
85
+
86
+ # continuous coordinates
87
+ v1 = vor.vertices[v1_idx]
88
+ v2 = vor.vertices[v2_idx]
89
+
90
+ v1 = map_.point_float_to_int(v1)
91
+ v2 = map_.point_float_to_int(v2)
92
+
93
+ line = map_.line_of_sight(v1, v2)
94
+
95
+ for point in line:
96
+ candidates[point] = True
97
+
98
+ candidates[map_.data == TYPES.OBSTACLE] = False
99
+ if not self.cover_inflation:
100
+ candidates[map_.data == TYPES.INFLATION] = False
101
+
102
+ return candidates
103
+
104
+ def find_nearest_voronoi_point(self,
105
+ point: Tuple[float, ...],
106
+ voronoi_candidates: np.ndarray,
107
+ ) -> Union[Tuple[float, ...], None]:
108
+ """
109
+ Find the nearest Voronoi candidate point to the target point using brute force search.
110
+
111
+ Args:
112
+ point: target point.
113
+ voronoi_candidates: Voronoi candidate points matrix.
114
+
115
+ Returns:
116
+ nearest_point: nearest Voronoi point.
117
+ """
118
+ min_dist = float('inf')
119
+ nearest_point = None
120
+
121
+ # Iterate through all Voronoi candidate points to find the nearest one
122
+ for indices in np.ndindex(voronoi_candidates.shape):
123
+ if voronoi_candidates[indices]:
124
+ candidate_point = indices
125
+ dist = self.map_.get_distance(point, candidate_point)
126
+ if dist < min_dist:
127
+ min_dist = dist
128
+ nearest_point = candidate_point
129
+
130
+ return nearest_point
131
+
132
+ def plan(self) -> Union[List[Tuple[float, ...]], Dict[str, Any]]:
133
+ """
134
+ Execute the path planning:
135
+ 1. Compute Voronoi candidate points
136
+ 2. Find the nearest Voronoi points for start and goal
137
+ 3. Plan the path on the Voronoi graph
138
+ 4. Concatenate the full path (start -> Voronoi start -> ... -> Voronoi goal -> goal)
139
+
140
+ Returns:
141
+ path: A list containing the path waypoints
142
+ path_info: A dictionary containing the path information
143
+ """
144
+ voronoi_map = copy.deepcopy(self.map_)
145
+ voronoi_map.fill_boundary_with_obstacles()
146
+
147
+ # Compute Voronoi candidate points
148
+ voronoi_candidates = self.find_voronoi_candidates(voronoi_map)
149
+
150
+ # If no Voronoi candidates are found, fall back to normal base planner
151
+ if not np.any(voronoi_candidates):
152
+ return self.base_planner(**self.base_planner_kwargs).plan()
153
+
154
+ # Find the nearest Voronoi points for start and goal
155
+ start_voronoi = self.find_nearest_voronoi_point(self.start, voronoi_candidates)
156
+ goal_voronoi = self.find_nearest_voronoi_point(self.goal, voronoi_candidates)
157
+
158
+ # If no valid Voronoi points found, fall back to normal base planner
159
+ if start_voronoi is None or goal_voronoi is None:
160
+ return self.base_planner(**self.base_planner_kwargs).plan()
161
+
162
+ voronoi_map.type_map[voronoi_candidates] = TYPES.FREE
163
+ voronoi_map.type_map[~voronoi_candidates] = TYPES.OBSTACLE
164
+
165
+ self.base_planner_kwargs["map_"] = voronoi_map
166
+ self.base_planner_kwargs["start"] = start_voronoi
167
+ self.base_planner_kwargs["goal"] = goal_voronoi
168
+
169
+ voronoi_path, voronoi_path_info = self.base_planner(**self.base_planner_kwargs).plan()
170
+
171
+ # If Voronoi path planning fails, fall back to normal base planner
172
+ if not voronoi_path_info["success"]:
173
+ self.base_planner_kwargs["map_"] = self.map_
174
+ self.base_planner_kwargs["start"] = self.start
175
+ self.base_planner_kwargs["goal"] = self.goal
176
+ return self.base_planner(**self.base_planner_kwargs).plan()
177
+
178
+ # Compute total path length and cost
179
+ start_segment_len = self.map_.get_distance(self.start, start_voronoi)
180
+ end_segment_len = self.map_.get_distance(goal_voronoi, self.goal)
181
+ total_length = voronoi_path_info["length"] + start_segment_len + end_segment_len
182
+
183
+ start_segment_cost = self.get_cost(self.start, start_voronoi)
184
+ end_segment_cost = self.get_cost(goal_voronoi, self.goal)
185
+ total_cost = voronoi_path_info["cost"] + start_segment_cost + end_segment_cost
186
+
187
+ # Concatenate the final path
188
+ final_path = [self.start] + voronoi_path + [self.goal]
189
+
190
+ # Collect path information
191
+ path_info = {
192
+ "success": True,
193
+ "start": self.start,
194
+ "goal": self.goal,
195
+ "length": total_length,
196
+ "cost": total_cost,
197
+ "expand": voronoi_path_info["expand"],
198
+ "voronoi_candidates": voronoi_candidates,
199
+ "voronoi_start": start_voronoi,
200
+ "voronoi_goal": goal_voronoi,
201
+ "voronoi_path": voronoi_path
202
+ }
203
+
204
+ return final_path, path_info
@@ -1,2 +1,3 @@
1
1
  from .rrt import *
2
- from .rrt_star import *
2
+ from .rrt_star import *
3
+ from .rrt_connect import *
@@ -1,12 +1,14 @@
1
1
  """
2
2
  @file: rrt.py
3
3
  @author: Wu Maojia, Yang Haodong
4
- @update: 2025.10.3
4
+ @update: 2025.12.19
5
5
  """
6
6
  import math
7
7
  import random
8
+ from typing import Union, Dict, List, Tuple, Any
9
+
8
10
  import numpy as np
9
- from typing import Union, Dict, List, Tuple
11
+ import faiss
10
12
 
11
13
  from python_motion_planning.common import BaseMap, Node, TYPES, Grid
12
14
  from python_motion_planning.path_planner import BasePathPlanner
@@ -17,9 +19,11 @@ class RRT(BasePathPlanner):
17
19
 
18
20
  Args:
19
21
  *args: see the parent class.
20
- max_dist: Maximum expansion distance for each step (default: 1.0).
21
- sample_num: Maximum number of samples to generate (default: 10000).
22
- goal_sample_rate: Probability of sampling the goal directly (default: 0.05).
22
+ max_dist: Maximum expansion distance for each step.
23
+ sample_num: Maximum number of samples to generate.
24
+ goal_sample_rate: Probability of sampling the goal directly.
25
+ discrete: Whether to use discrete or continuous space.
26
+ faiss: Whether to use Faiss to accelerate the search.
23
27
  *kwargs: see the parent class.
24
28
 
25
29
  References:
@@ -40,21 +44,20 @@ class RRT(BasePathPlanner):
40
44
  def __init__(self, *args,
41
45
  max_dist: float = 5.0, sample_num: int = 100000,
42
46
  goal_sample_rate: float = 0.05,
47
+ discrete: bool = False,
48
+ use_faiss: bool = True,
43
49
  **kwargs) -> None:
44
50
  super().__init__(*args, **kwargs)
45
- # Maximum expansion distance per step
46
51
  self.max_dist = max_dist
47
- # Maximum number of samples
48
52
  self.sample_num = sample_num
49
- # Goal sampling probability
50
53
  self.goal_sample_rate = goal_sample_rate
51
- # Environment bounds from the map
52
- self.bounds = self.map_.bounds
54
+ self.discrete = discrete
55
+ self.use_faiss = use_faiss
53
56
 
54
57
  def __str__(self) -> str:
55
- return "Rapidly-exploring Random Tree (RRT)"
58
+ return "RRT"
56
59
 
57
- def plan(self) -> Union[list, dict]:
60
+ def plan(self) -> Union[List[Tuple[float, ...]], Dict[str, Any]]:
58
61
  """
59
62
  RRT path planning algorithm implementation.
60
63
 
@@ -67,6 +70,12 @@ class RRT(BasePathPlanner):
67
70
  start_node = Node(self.start, None, 0, 0)
68
71
  tree[self.start] = start_node
69
72
 
73
+ # Initialize FAISS index
74
+ if self.use_faiss:
75
+ faiss_index = faiss.IndexFlatL2(self.dim)
76
+ faiss_nodes = []
77
+ self._faiss_add_node(start_node, faiss_index, faiss_nodes)
78
+
70
79
  # Main sampling loop
71
80
  for _ in range(self.sample_num):
72
81
  # Generate random sample node
@@ -77,7 +86,7 @@ class RRT(BasePathPlanner):
77
86
  continue
78
87
 
79
88
  # Find nearest node in tree
80
- node_near = self._get_nearest_node(tree, node_rand)
89
+ node_near = self._get_nearest_node(tree, node_rand, faiss_index, faiss_nodes)
81
90
 
82
91
  # Create new node towards random sample
83
92
  node_new = self._steer(node_near, node_rand)
@@ -85,19 +94,27 @@ class RRT(BasePathPlanner):
85
94
  continue
86
95
 
87
96
  # Check if edge is collision-free
88
- if self.map_.in_collision(node_new.current, node_near.current):
97
+ if self.map_.in_collision(
98
+ self.map_.point_float_to_int(node_new.current),
99
+ self.map_.point_float_to_int(node_near.current)
100
+ ):
89
101
  continue
90
102
 
91
103
  # Add new node to tree
92
104
  node_new.parent = node_near.current
93
105
  node_new.g = node_near.g + self.get_cost(node_near.current, node_new.current)
94
106
  tree[node_new.current] = node_new
107
+ if self.use_faiss:
108
+ self._faiss_add_node(node_new, faiss_index, faiss_nodes)
95
109
 
96
110
  # Check if goal is reachable
97
111
  dist_to_goal = self.get_cost(node_new.current, self.goal)
98
112
  if dist_to_goal <= self.max_dist:
99
113
  # Check final edge to goal
100
- if not self.map_.in_collision(node_new.current, self.goal):
114
+ if not self.map_.in_collision(
115
+ self.map_.point_float_to_int(node_new.current),
116
+ self.map_.point_float_to_int(self.goal)
117
+ ):
101
118
  if node_new.current == self.goal:
102
119
  goal_node = node_new
103
120
  else:
@@ -131,25 +148,37 @@ class RRT(BasePathPlanner):
131
148
 
132
149
  point = []
133
150
  # Generate random integer point within grid bounds
134
- for d in range(self.map_.dim):
135
- d_min, d_max = self.bounds[d]
136
- point.append(random.randint(int(d_min), int(d_max)))
151
+ for d in range(self.dim):
152
+ d_min, d_max = -0.5, self.map_.shape[d] - 0.5
153
+ point.append(random.uniform(d_min, d_max))
137
154
  point = tuple(point)
138
155
 
156
+ if self.discrete:
157
+ point = self.map_.point_float_to_int(point)
158
+
139
159
  return Node(point, None, 0, 0)
140
160
 
141
161
  def _get_nearest_node(self, tree: Dict[Tuple[int, ...], Node],
142
- node_rand: Node) -> Node:
162
+ node_rand: Node, index=None, nodes=None) -> Node:
143
163
  """
144
164
  Find the nearest node in the tree to a random sample.
145
165
 
146
166
  Args:
147
167
  tree: Current tree of nodes
148
168
  node_rand: Random sample node
169
+ index: FAISS index (required when `use_faiss`=True)
170
+ nodes: List of nodes in FAISS index (required when `use_faiss`=True)
149
171
 
150
172
  Returns:
151
173
  node: Nearest node in the tree
152
174
  """
175
+ # knn search using faiss
176
+ if self.use_faiss:
177
+ query = np.array(node_rand.current, dtype=np.float32).reshape(1, -1)
178
+ _, indices = index.search(query, 1)
179
+ return nodes[indices[0][0]]
180
+
181
+ # brute force search
153
182
  min_dist = float('inf')
154
183
  nearest_node = None
155
184
 
@@ -174,7 +203,7 @@ class RRT(BasePathPlanner):
174
203
  node: New node in direction of random sample
175
204
  """
176
205
  # Calculate differences for each dimension
177
- diffs = [node_rand.current[i] - node_near.current[i] for i in range(self.map_.dim)]
206
+ diffs = [node_rand.current[i] - node_near.current[i] for i in range(self.dim)]
178
207
 
179
208
  # Calculate Euclidean distance in n-dimensional space
180
209
  dist = math.sqrt(sum(diff**2 for diff in diffs))
@@ -189,13 +218,26 @@ class RRT(BasePathPlanner):
189
218
 
190
219
  # Otherwise scale to maximum distance
191
220
  scale = self.max_dist / dist
192
- new_coords = [
193
- node_near.current[i] + scale * diffs[i]
194
- for i in range(self.map_.dim)
221
+ new_point = [
222
+ node_near.current[i] + scale * diffs[i]
223
+ for i in range(self.dim)
195
224
  ]
196
-
197
- # Round coordinates if original points were integers
198
- if all(isinstance(coord, int) for coord in node_near.current):
199
- new_coords = [round(coord) for coord in new_coords]
225
+ new_point = tuple(new_point)
226
+
227
+ if self.discrete:
228
+ new_point = self.map_.point_float_to_int(new_point)
200
229
 
201
- return Node(tuple(new_coords), None, 0, 0)
230
+ return Node(new_point, None, 0, 0)
231
+
232
+ def _faiss_add_node(self, node: Node, index, nodes):
233
+ """
234
+ Add a node to the FAISS index.
235
+
236
+ Args:
237
+ node: Node to add
238
+ index: FAISS index
239
+ nodes: List of nodes in FAISS index
240
+ """
241
+ vec = np.array(node.current, dtype=np.float32).reshape(1, -1)
242
+ index.add(vec)
243
+ nodes.append(node)
@@ -0,0 +1,237 @@
1
+ """
2
+ @file: rrt_connect.py
3
+ @author: Wu Maojia
4
+ @update: 2025.12.19
5
+ """
6
+ from typing import Union, Dict, List, Tuple, Any
7
+
8
+ import faiss
9
+
10
+ from python_motion_planning.common import Node, Grid, TYPES
11
+ from python_motion_planning.path_planner.sample_search.rrt import RRT
12
+
13
+ class RRTConnect(RRT):
14
+ """
15
+ Class for RRT-Connect path planner, an improved version of RRT.
16
+
17
+ RRT-Connect uses two trees (one from start, one from goal) that grow towards
18
+ each other, which typically results in faster convergence than standard RRT.
19
+
20
+ Args:
21
+ *args: see the parent class.
22
+ *kwargs: see the parent class.
23
+
24
+ References:
25
+ [1] RRT-connect: An efficient approach to single-query path planning.
26
+
27
+ Examples:
28
+ >>> map_ = Grid(bounds=[[0, 15], [0, 15]])
29
+ >>> planner = RRTConnect(map_=map_, start=(5, 5), goal=(10, 10))
30
+ >>> path, path_info = planner.plan()
31
+ >>> print(path_info['success'])
32
+ True
33
+
34
+ >>> planner.map_.type_map[3:10, 6] = TYPES.OBSTACLE
35
+ >>> path, path_info = planner.plan()
36
+ >>> print(path_info['success'])
37
+ True
38
+ """
39
+ def __init__(self, *args, **kwargs) -> None:
40
+ super().__init__(*args, **kwargs)
41
+ # Two trees for bidirectional search: one from start, one from goal
42
+ self.tree_a = None # Tree originating from start point
43
+ self.tree_b = None # Tree originating from goal point
44
+
45
+ if self.use_faiss:
46
+ self.index_a = None
47
+ self.index_b = None
48
+ self.nodes_a = []
49
+ self.nodes_b = []
50
+
51
+ def __str__(self) -> str:
52
+ return "RRT-Connect"
53
+
54
+ def plan(self) -> Union[List[Tuple[float, ...]], Dict[str, Any]]:
55
+ """
56
+ RRT-Connect path planning algorithm implementation.
57
+
58
+ Returns:
59
+ path: A list containing the path waypoints
60
+ path_info: A dictionary containing path information
61
+ """
62
+ # Initialize both trees with start and goal nodes respectively
63
+ self.tree_a = {self.start: Node(self.start, None, 0, 0)}
64
+ self.tree_b = {self.goal: Node(self.goal, None, 0, 0)}
65
+
66
+ # Initialize FAISS index
67
+ if self.use_faiss:
68
+ self.index_a = faiss.IndexFlatL2(self.dim)
69
+ self.index_b = faiss.IndexFlatL2(self.dim)
70
+ self.nodes_a = []
71
+ self.nodes_b = []
72
+ self._faiss_add_node(self.tree_a[self.start], self.index_a, self.nodes_a)
73
+ self._faiss_add_node(self.tree_b[self.goal], self.index_b, self.nodes_b)
74
+
75
+ # Main planning loop
76
+ for _ in range(self.sample_num):
77
+ # Generate random sample node
78
+ node_rand = self._generate_random_node()
79
+
80
+ # Extend tree A towards random sample
81
+ node_new_a = self._extend(self.tree_a, node_rand, self.index_a, self.nodes_a)
82
+ if node_new_a:
83
+ # Try to connect tree B to the new node from tree A
84
+ if self._connect(self.tree_b, node_new_a.current, self.index_b, self.nodes_b):
85
+ tree_a_goal = self.tree_a.get(self.goal)
86
+ if tree_a_goal is not None and tree_a_goal.parent is None:
87
+ self.tree_a, self.tree_b = self.tree_b, self.tree_a
88
+ # Path found - combine paths from both trees
89
+ path_a, length_a, cost_a = self._extract_subpath(self.tree_a, node_new_a.current, self.start)
90
+ path_b, length_b, cost_b = self._extract_subpath(self.tree_b, node_new_a.current, self.goal)
91
+ path_a = path_a[::-1]
92
+ # Combine paths (remove duplicate meeting point)
93
+ full_path = path_a + path_b[1:]
94
+ total_length = length_a + length_b
95
+ total_cost = cost_a + cost_b
96
+
97
+ return full_path, {
98
+ "success": True,
99
+ "start": self.start,
100
+ "goal": self.goal,
101
+ "length": total_length,
102
+ "cost": total_cost,
103
+ "expand": [self.tree_a, self.tree_b],
104
+ }
105
+
106
+ # Swap trees to maintain bidirectional growth
107
+ self.tree_a, self.tree_b = self.tree_b, self.tree_a
108
+
109
+ if self.use_faiss:
110
+ self.index_a, self.index_b = self.index_b, self.index_a
111
+ self.nodes_a, self.nodes_b = self.nodes_b, self.nodes_a
112
+
113
+ # If loop exits without return, planning failed
114
+ self.failed_info[1]["expand"] = [self.tree_a, self.tree_b]
115
+ return self.failed_info
116
+
117
+ def _extend(self, tree: Dict[Tuple[int, ...], Node], node_rand: Node, index=None, nodes=None) -> Union[Node, None]:
118
+ """
119
+ Extend the tree towards a random node, adding at most one new node.
120
+
121
+ Args:
122
+ tree: The tree to extend
123
+ node_rand: The target node to extend towards
124
+ index: FAISS index (required when `use_faiss`=True)
125
+ nodes: List of nodes in FAISS index (required when `use_faiss`=True)
126
+
127
+ Returns:
128
+ The new node added to the tree, or None if no node was added
129
+ """
130
+ # Find nearest node in the tree
131
+ node_near = self._get_nearest_node(tree, node_rand, index, nodes)
132
+
133
+ # Steer towards the random node
134
+ node_new = self._steer(node_near, node_rand)
135
+ if node_new is None:
136
+ return None
137
+
138
+ # Check if path is collision-free
139
+ if not self.map_.in_collision(
140
+ self.map_.point_float_to_int(node_near.current),
141
+ self.map_.point_float_to_int(node_new.current)
142
+ ):
143
+ # Add new node to the tree
144
+ node_new.parent = node_near.current
145
+ node_new.g = node_near.g + self.get_cost(node_near.current, node_new.current)
146
+ tree[node_new.current] = node_new
147
+ if self.use_faiss:
148
+ self._faiss_add_node(node_new, index, nodes)
149
+ return node_new
150
+
151
+ return None
152
+
153
+ def _connect(self, tree: Dict[Tuple[int, ...], Node], target: Tuple, index=None, nodes=None) -> bool:
154
+ """
155
+ Connect the tree to a target point by repeatedly extending towards it.
156
+
157
+ Args:
158
+ tree: The tree to connect
159
+ target: The target point to connect to
160
+ index: FAISS index (required when `use_faiss`=True)
161
+ nodes: List of nodes in FAISS index (required when `use_faiss`=True)
162
+
163
+ Returns:
164
+ True if connection successful, False otherwise
165
+ """
166
+ while True:
167
+ # Create node for the target
168
+ node_target = Node(target, None, 0, 0)
169
+
170
+ # Find nearest node in the tree
171
+ node_near = self._get_nearest_node(tree, node_target, index, nodes)
172
+
173
+ # Check distance to target
174
+ dist = self.get_cost(node_near.current, target)
175
+
176
+ # If close enough, check final connection
177
+ if dist <= self.max_dist:
178
+ if not self.map_.in_collision(
179
+ self.map_.point_float_to_int(node_near.current),
180
+ self.map_.point_float_to_int(target)
181
+ ):
182
+ # Add target to tree
183
+ node_new = Node(target, node_near.current,
184
+ node_near.g + dist, 0)
185
+ tree[target] = node_new
186
+ if self.use_faiss:
187
+ self._faiss_add_node(node_new, index, nodes)
188
+ return True
189
+ return False
190
+
191
+ # Otherwise, extend towards target
192
+ node_new = self._steer(node_near, node_target)
193
+ if node_new is None:
194
+ return False
195
+
196
+ # Check collision
197
+ if self.map_.in_collision(
198
+ self.map_.point_float_to_int(node_near.current),
199
+ self.map_.point_float_to_int(node_new.current)
200
+ ):
201
+ return False
202
+
203
+ # Add new node to tree
204
+ node_new.parent = node_near.current
205
+ node_new.g = node_near.g + self.get_cost(node_near.current, node_new.current)
206
+ tree[node_new.current] = node_new
207
+ if self.use_faiss:
208
+ self._faiss_add_node(node_new, index, nodes)
209
+
210
+ def _extract_subpath(self, tree: dict, end_point: tuple, root: tuple) -> Tuple[List[Tuple[float, ...]], float, float]:
211
+ """
212
+ Extract a subpath from the root of the tree to the end_point.
213
+
214
+ Args:
215
+ tree: Tree to extract path from
216
+ end_point: End point of the subpath
217
+ root: Root point of the tree
218
+
219
+ Returns:
220
+ path: the subpath
221
+ length: length of the subpath
222
+ cost: cost of the subpath
223
+ """
224
+ length = 0
225
+ cost = 0
226
+ node = tree.get(end_point)
227
+ path = [node.current]
228
+
229
+ while node.current != root:
230
+ node_parent = tree.get(node.parent)
231
+ length += self.map_.get_distance(node.current, node_parent.current)
232
+ cost += self.get_cost(node.current, node_parent.current)
233
+ node = node_parent
234
+ path.append(node.current)
235
+
236
+ return path, length, cost
237
+