1use std::cmp::Ordering;
11use std::collections::{BinaryHeap, HashMap};
12use std::f64::consts::TAU;
13
14use rust_robotics_core::{RoboticsError, RoboticsResult};
15
16const EPS: f64 = 1.0e-9;
17
18#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct RigidBodyPoint2D {
21 pub x: f64,
22 pub y: f64,
23}
24
25impl RigidBodyPoint2D {
26 pub fn new(x: f64, y: f64) -> Self {
27 Self { x, y }
28 }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct RigidBodyPose2D {
34 pub x: f64,
35 pub y: f64,
36 pub theta: f64,
37}
38
39impl RigidBodyPose2D {
40 pub fn new(x: f64, y: f64, theta: f64) -> Self {
41 Self { x, y, theta }
42 }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq)]
47pub struct RigidBodyHalfspace2D {
48 pub a: f64,
49 pub b: f64,
50 pub c: f64,
51}
52
53impl RigidBodyHalfspace2D {
54 pub fn new(a: f64, b: f64, c: f64) -> RoboticsResult<Self> {
55 if !a.is_finite() || !b.is_finite() || !c.is_finite() || (a.abs() + b.abs()) <= EPS {
56 return Err(RoboticsError::InvalidParameter(
57 "rigid-body MIP half-space coefficients must be finite and non-zero".to_string(),
58 ));
59 }
60 Ok(Self { a, b, c })
61 }
62
63 pub fn signed_violation(self, point: RigidBodyPoint2D) -> f64 {
64 self.a * point.x + self.b * point.y - self.c
65 }
66}
67
68#[derive(Debug, Clone, PartialEq)]
70pub struct RigidBodyConvexObstacle2D {
71 pub vertices: Vec<RigidBodyPoint2D>,
72 pub halfspaces: Vec<RigidBodyHalfspace2D>,
73}
74
75impl RigidBodyConvexObstacle2D {
76 pub fn convex_polygon(vertices: Vec<RigidBodyPoint2D>) -> RoboticsResult<Self> {
77 if vertices.len() < 3 {
78 return Err(RoboticsError::InvalidParameter(
79 "rigid-body MIP convex polygon must have at least 3 vertices".to_string(),
80 ));
81 }
82 if !vertices
83 .iter()
84 .all(|vertex| vertex.x.is_finite() && vertex.y.is_finite())
85 {
86 return Err(RoboticsError::InvalidParameter(
87 "rigid-body MIP convex polygon vertices must be finite".to_string(),
88 ));
89 }
90 let area = signed_area(&vertices);
91 if area.abs() <= EPS {
92 return Err(RoboticsError::InvalidParameter(
93 "rigid-body MIP convex polygon area must be non-zero".to_string(),
94 ));
95 }
96 let mut vertices = vertices;
97 if area < 0.0 {
98 vertices.reverse();
99 }
100 validate_convex_ccw_polygon(&vertices)?;
101
102 let mut halfspaces = Vec::with_capacity(vertices.len());
103 for index in 0..vertices.len() {
104 let from = vertices[index];
105 let to = vertices[(index + 1) % vertices.len()];
106 let dx = to.x - from.x;
107 let dy = to.y - from.y;
108 halfspaces.push(RigidBodyHalfspace2D::new(
109 dy,
110 -dx,
111 dy * from.x - dx * from.y,
112 )?);
113 }
114 Ok(Self {
115 vertices,
116 halfspaces,
117 })
118 }
119
120 pub fn aabb(min_x: f64, max_x: f64, min_y: f64, max_y: f64) -> RoboticsResult<Self> {
121 if !min_x.is_finite()
122 || !max_x.is_finite()
123 || !min_y.is_finite()
124 || !max_y.is_finite()
125 || min_x >= max_x
126 || min_y >= max_y
127 {
128 return Err(RoboticsError::InvalidParameter(
129 "rigid-body MIP AABB bounds must be finite and ordered".to_string(),
130 ));
131 }
132 Ok(Self {
133 vertices: vec![
134 RigidBodyPoint2D::new(min_x, min_y),
135 RigidBodyPoint2D::new(max_x, min_y),
136 RigidBodyPoint2D::new(max_x, max_y),
137 RigidBodyPoint2D::new(min_x, max_y),
138 ],
139 halfspaces: vec![
140 RigidBodyHalfspace2D::new(-1.0, 0.0, -min_x)?,
141 RigidBodyHalfspace2D::new(1.0, 0.0, max_x)?,
142 RigidBodyHalfspace2D::new(0.0, -1.0, -min_y)?,
143 RigidBodyHalfspace2D::new(0.0, 1.0, max_y)?,
144 ],
145 })
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq)]
151pub struct RigidBodyMipSeparationCertificate2D {
152 pub obstacle_index: usize,
153 pub halfspace_index: usize,
154 pub margin: f64,
155}
156
157#[derive(Debug, Clone, PartialEq)]
159pub struct RigidBodyMipConfig2D {
160 pub min_x: f64,
161 pub max_x: f64,
162 pub min_y: f64,
163 pub max_y: f64,
164 pub position_step: f64,
165 pub heading_count: usize,
166 pub robot_half_length: f64,
167 pub robot_half_width: f64,
168 pub clearance: f64,
169 pub move_cost: u64,
170 pub turn_cost: u64,
171 pub max_expansions: usize,
172 pub obstacles: Vec<RigidBodyConvexObstacle2D>,
173}
174
175impl RigidBodyMipConfig2D {
176 pub fn new(min_x: f64, max_x: f64, min_y: f64, max_y: f64) -> Self {
177 Self {
178 min_x,
179 max_x,
180 min_y,
181 max_y,
182 position_step: 0.5,
183 heading_count: 8,
184 robot_half_length: 0.55,
185 robot_half_width: 0.25,
186 clearance: 0.02,
187 move_cost: 10,
188 turn_cost: 3,
189 max_expansions: 20_000,
190 obstacles: Vec::new(),
191 }
192 }
193}
194
195#[derive(Debug, Clone, PartialEq)]
197pub struct RigidBodyMipPlan2D {
198 pub poses: Vec<RigidBodyPose2D>,
199 pub certificates: Vec<Vec<RigidBodyMipSeparationCertificate2D>>,
200 pub segment_certificates: Vec<Vec<RigidBodyMipSeparationCertificate2D>>,
201 pub total_cost: u64,
202 pub expanded_states: usize,
203 pub binary_separation_choices: usize,
204 pub segment_binary_separation_choices: usize,
205 pub min_separation_margin: f64,
206 pub min_segment_separation_margin: f64,
207}
208
209#[derive(Debug, Clone, PartialEq)]
217pub struct RigidBodyPlanOutcome2D {
218 pub backend: &'static str,
219 pub poses: Vec<RigidBodyPose2D>,
220 pub path_length: f64,
221 pub heading_change: f64,
222 pub iterations: usize,
223 pub min_separation_margin: f64,
224}
225
226pub trait RigidBodyPlanningBackend {
233 fn name(&self) -> &'static str;
235
236 fn plan_path(
238 &self,
239 start: RigidBodyPose2D,
240 goal: RigidBodyPose2D,
241 require_goal_heading: bool,
242 ) -> RoboticsResult<RigidBodyPlanOutcome2D>;
243}
244
245#[derive(Debug, Clone, PartialEq)]
247pub struct RigidBodyMipPlanner2D {
248 config: RigidBodyMipConfig2D,
249 nx: i32,
250 ny: i32,
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
254struct SearchState {
255 ix: i32,
256 iy: i32,
257 heading: usize,
258}
259
260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261struct OpenEntry {
262 state: SearchState,
263 f: u64,
264 g: u64,
265}
266
267impl Ord for OpenEntry {
268 fn cmp(&self, other: &Self) -> Ordering {
269 other
270 .f
271 .cmp(&self.f)
272 .then_with(|| other.g.cmp(&self.g))
273 .then_with(|| other.state.heading.cmp(&self.state.heading))
274 }
275}
276
277impl PartialOrd for OpenEntry {
278 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
279 Some(self.cmp(other))
280 }
281}
282
283impl RigidBodyMipPlanner2D {
284 pub fn new(config: RigidBodyMipConfig2D) -> RoboticsResult<Self> {
285 validate_config(&config)?;
286 let nx = ((config.max_x - config.min_x) / config.position_step).round() as i32;
287 let ny = ((config.max_y - config.min_y) / config.position_step).round() as i32;
288 Ok(Self { config, nx, ny })
289 }
290
291 pub fn config(&self) -> &RigidBodyMipConfig2D {
292 &self.config
293 }
294
295 pub fn plan(
296 &self,
297 start: RigidBodyPose2D,
298 goal: RigidBodyPose2D,
299 require_goal_heading: bool,
300 ) -> RoboticsResult<RigidBodyMipPlan2D> {
301 let start_state = self.pose_to_state(start)?;
302 let goal_state = self.pose_to_state(goal)?;
303 if !self.is_state_feasible(start_state) || !self.is_state_feasible(goal_state) {
304 return Err(RoboticsError::InvalidParameter(
305 "rigid-body MIP start and goal poses must be feasible".to_string(),
306 ));
307 }
308
309 let mut open = BinaryHeap::new();
310 let mut best_g = HashMap::new();
311 let mut parent: HashMap<SearchState, Option<SearchState>> = HashMap::new();
312 best_g.insert(start_state, 0);
313 parent.insert(start_state, None);
314 open.push(OpenEntry {
315 state: start_state,
316 g: 0,
317 f: self.heuristic(start_state, goal_state, require_goal_heading),
318 });
319
320 let mut expanded_states = 0;
321 while let Some(entry) = open.pop() {
322 if entry.g > *best_g.get(&entry.state).unwrap_or(&u64::MAX) {
323 continue;
324 }
325 expanded_states += 1;
326 if expanded_states > self.config.max_expansions {
327 return Err(RoboticsError::PlanningError(
328 "rigid-body MIP planner exceeded expansion limit".to_string(),
329 ));
330 }
331 if self.is_goal(entry.state, goal_state, require_goal_heading) {
332 return self.build_plan(parent, entry.state, entry.g, expanded_states);
333 }
334 for (next, step_cost) in self.successors(entry.state) {
335 if !self.is_state_feasible(next) || !self.is_transition_feasible(entry.state, next)
336 {
337 continue;
338 }
339 let next_g = entry.g + step_cost;
340 if next_g < *best_g.get(&next).unwrap_or(&u64::MAX) {
341 best_g.insert(next, next_g);
342 parent.insert(next, Some(entry.state));
343 open.push(OpenEntry {
344 state: next,
345 g: next_g,
346 f: next_g + self.heuristic(next, goal_state, require_goal_heading),
347 });
348 }
349 }
350 }
351
352 Err(RoboticsError::PlanningError(
353 "rigid-body MIP planner could not find a feasible path".to_string(),
354 ))
355 }
356
357 pub fn robot_vertices(&self, pose: RigidBodyPose2D) -> Vec<RigidBodyPoint2D> {
358 let c = pose.theta.cos();
359 let s = pose.theta.sin();
360 [
361 (self.config.robot_half_length, self.config.robot_half_width),
362 (self.config.robot_half_length, -self.config.robot_half_width),
363 (
364 -self.config.robot_half_length,
365 -self.config.robot_half_width,
366 ),
367 (-self.config.robot_half_length, self.config.robot_half_width),
368 ]
369 .into_iter()
370 .map(|(lx, ly)| RigidBodyPoint2D::new(pose.x + c * lx - s * ly, pose.y + s * lx + c * ly))
371 .collect()
372 }
373
374 pub fn separation_certificates(
375 &self,
376 pose: RigidBodyPose2D,
377 ) -> Option<Vec<RigidBodyMipSeparationCertificate2D>> {
378 let vertices = self.robot_vertices(pose);
379 self.separation_certificates_for_vertices(&vertices)
380 }
381
382 pub fn segment_separation_certificates(
383 &self,
384 from: RigidBodyPose2D,
385 to: RigidBodyPose2D,
386 ) -> Option<Vec<RigidBodyMipSeparationCertificate2D>> {
387 let mut vertices = Vec::new();
388 for alpha in [0.0, 0.5, 1.0] {
389 vertices.extend(self.robot_vertices(interpolate_pose(from, to, alpha)));
390 }
391 self.separation_certificates_for_vertices(&vertices)
392 }
393
394 fn separation_certificates_for_vertices(
395 &self,
396 vertices: &[RigidBodyPoint2D],
397 ) -> Option<Vec<RigidBodyMipSeparationCertificate2D>> {
398 if !vertices
399 .iter()
400 .all(|vertex| self.point_inside_bounds(*vertex))
401 {
402 return None;
403 }
404
405 let mut certificates = Vec::with_capacity(self.config.obstacles.len());
406 for (obstacle_index, obstacle) in self.config.obstacles.iter().enumerate() {
407 let mut best: Option<RigidBodyMipSeparationCertificate2D> = None;
408 for (halfspace_index, halfspace) in obstacle.halfspaces.iter().copied().enumerate() {
409 let margin = vertices
410 .iter()
411 .map(|vertex| halfspace.signed_violation(*vertex))
412 .fold(f64::INFINITY, f64::min);
413 if margin > self.config.clearance {
414 let certificate = RigidBodyMipSeparationCertificate2D {
415 obstacle_index,
416 halfspace_index,
417 margin,
418 };
419 best = match best {
420 Some(previous) if previous.margin >= certificate.margin => Some(previous),
421 _ => Some(certificate),
422 };
423 }
424 }
425 certificates.push(best?);
426 }
427 Some(certificates)
428 }
429
430 pub fn is_pose_feasible(&self, pose: RigidBodyPose2D) -> bool {
431 self.separation_certificates(pose).is_some()
432 }
433
434 pub fn is_segment_feasible(&self, from: RigidBodyPose2D, to: RigidBodyPose2D) -> bool {
435 self.segment_separation_certificates(from, to).is_some()
436 }
437
438 fn build_plan(
439 &self,
440 parent: HashMap<SearchState, Option<SearchState>>,
441 mut current: SearchState,
442 total_cost: u64,
443 expanded_states: usize,
444 ) -> RoboticsResult<RigidBodyMipPlan2D> {
445 let mut states = Vec::new();
446 loop {
447 states.push(current);
448 match parent.get(¤t).copied().flatten() {
449 Some(previous) => current = previous,
450 None => break,
451 }
452 }
453 states.reverse();
454
455 let mut poses = Vec::with_capacity(states.len());
456 let mut certificates = Vec::with_capacity(states.len());
457 let mut min_separation_margin = f64::INFINITY;
458 for &state in &states {
459 let pose = self.state_to_pose(state);
460 let pose_certificates = self.separation_certificates(pose).ok_or_else(|| {
461 RoboticsError::PlanningError(
462 "rigid-body MIP reconstructed an infeasible pose".to_string(),
463 )
464 })?;
465 for certificate in &pose_certificates {
466 min_separation_margin = min_separation_margin.min(certificate.margin);
467 }
468 poses.push(pose);
469 certificates.push(pose_certificates);
470 }
471 let mut segment_certificates = Vec::with_capacity(poses.len().saturating_sub(1));
472 let mut min_segment_separation_margin = f64::INFINITY;
473 for window in poses.windows(2) {
474 let certificates = self
475 .segment_separation_certificates(window[0], window[1])
476 .ok_or_else(|| {
477 RoboticsError::PlanningError(
478 "rigid-body MIP reconstructed an infeasible segment".to_string(),
479 )
480 })?;
481 for certificate in &certificates {
482 min_segment_separation_margin =
483 min_segment_separation_margin.min(certificate.margin);
484 }
485 segment_certificates.push(certificates);
486 }
487 if segment_certificates.is_empty() {
488 min_segment_separation_margin = min_separation_margin;
489 }
490 Ok(RigidBodyMipPlan2D {
491 binary_separation_choices: certificates.iter().map(Vec::len).sum(),
492 segment_binary_separation_choices: segment_certificates.iter().map(Vec::len).sum(),
493 poses,
494 certificates,
495 segment_certificates,
496 total_cost,
497 expanded_states,
498 min_separation_margin,
499 min_segment_separation_margin,
500 })
501 }
502
503 fn successors(&self, state: SearchState) -> Vec<(SearchState, u64)> {
504 let mut successors = vec![
505 (
506 SearchState {
507 ix: state.ix + 1,
508 iy: state.iy,
509 heading: state.heading,
510 },
511 self.config.move_cost,
512 ),
513 (
514 SearchState {
515 ix: state.ix - 1,
516 iy: state.iy,
517 heading: state.heading,
518 },
519 self.config.move_cost,
520 ),
521 (
522 SearchState {
523 ix: state.ix,
524 iy: state.iy + 1,
525 heading: state.heading,
526 },
527 self.config.move_cost,
528 ),
529 (
530 SearchState {
531 ix: state.ix,
532 iy: state.iy - 1,
533 heading: state.heading,
534 },
535 self.config.move_cost,
536 ),
537 (
538 SearchState {
539 ix: state.ix,
540 iy: state.iy,
541 heading: (state.heading + 1) % self.config.heading_count,
542 },
543 self.config.turn_cost,
544 ),
545 (
546 SearchState {
547 ix: state.ix,
548 iy: state.iy,
549 heading: (state.heading + self.config.heading_count - 1)
550 % self.config.heading_count,
551 },
552 self.config.turn_cost,
553 ),
554 ];
555 successors.retain(|(candidate, _)| self.state_in_bounds(*candidate));
556 successors
557 }
558
559 fn is_state_feasible(&self, state: SearchState) -> bool {
560 self.state_in_bounds(state) && self.is_pose_feasible(self.state_to_pose(state))
561 }
562
563 fn is_transition_feasible(&self, from: SearchState, to: SearchState) -> bool {
564 self.is_segment_feasible(self.state_to_pose(from), self.state_to_pose(to))
565 }
566
567 fn state_in_bounds(&self, state: SearchState) -> bool {
568 state.ix >= 0
569 && state.iy >= 0
570 && state.ix <= self.nx
571 && state.iy <= self.ny
572 && state.heading < self.config.heading_count
573 }
574
575 fn is_goal(&self, state: SearchState, goal: SearchState, require_goal_heading: bool) -> bool {
576 state.ix == goal.ix
577 && state.iy == goal.iy
578 && (!require_goal_heading || state.heading == goal.heading)
579 }
580
581 fn heuristic(&self, state: SearchState, goal: SearchState, require_goal_heading: bool) -> u64 {
582 let moves =
583 (state.ix - goal.ix).unsigned_abs() as u64 + (state.iy - goal.iy).unsigned_abs() as u64;
584 let turns = if require_goal_heading {
585 circular_heading_distance(state.heading, goal.heading, self.config.heading_count) as u64
586 } else {
587 0
588 };
589 moves * self.config.move_cost + turns * self.config.turn_cost
590 }
591
592 fn pose_to_state(&self, pose: RigidBodyPose2D) -> RoboticsResult<SearchState> {
593 if !pose.x.is_finite() || !pose.y.is_finite() || !pose.theta.is_finite() {
594 return Err(RoboticsError::InvalidParameter(
595 "rigid-body MIP pose must be finite".to_string(),
596 ));
597 }
598 let ix = ((pose.x - self.config.min_x) / self.config.position_step).round() as i32;
599 let iy = ((pose.y - self.config.min_y) / self.config.position_step).round() as i32;
600 let heading = theta_to_index(pose.theta, self.config.heading_count);
601 let state = SearchState { ix, iy, heading };
602 if !self.state_in_bounds(state) {
603 return Err(RoboticsError::InvalidParameter(
604 "rigid-body MIP pose is outside planner bounds".to_string(),
605 ));
606 }
607 Ok(state)
608 }
609
610 fn state_to_pose(&self, state: SearchState) -> RigidBodyPose2D {
611 RigidBodyPose2D::new(
612 self.config.min_x + state.ix as f64 * self.config.position_step,
613 self.config.min_y + state.iy as f64 * self.config.position_step,
614 state.heading as f64 * TAU / self.config.heading_count as f64,
615 )
616 }
617
618 fn point_inside_bounds(&self, point: RigidBodyPoint2D) -> bool {
619 point.x >= self.config.min_x + self.config.clearance
620 && point.x <= self.config.max_x - self.config.clearance
621 && point.y >= self.config.min_y + self.config.clearance
622 && point.y <= self.config.max_y - self.config.clearance
623 }
624}
625
626impl RigidBodyPlanningBackend for RigidBodyMipPlanner2D {
627 fn name(&self) -> &'static str {
628 "lattice-astar"
629 }
630
631 fn plan_path(
632 &self,
633 start: RigidBodyPose2D,
634 goal: RigidBodyPose2D,
635 require_goal_heading: bool,
636 ) -> RoboticsResult<RigidBodyPlanOutcome2D> {
637 let plan = self.plan(start, goal, require_goal_heading)?;
638 let (path_length, heading_change) = path_extent(&plan.poses);
639 Ok(RigidBodyPlanOutcome2D {
640 backend: self.name(),
641 path_length,
642 heading_change,
643 iterations: plan.expanded_states,
644 min_separation_margin: plan.min_separation_margin,
645 poses: plan.poses,
646 })
647 }
648}
649
650#[derive(Debug, Clone, PartialEq)]
652pub struct RigidBodyRrtConfig2D {
653 pub seed: u64,
655 pub max_samples: usize,
657 pub step_size: f64,
659 pub heading_step: f64,
661 pub goal_bias: f64,
663 pub heading_weight: f64,
665 pub goal_position_tol: f64,
667 pub goal_heading_tol: f64,
669}
670
671impl RigidBodyRrtConfig2D {
672 pub fn new(seed: u64) -> Self {
673 Self {
674 seed,
675 max_samples: 20_000,
676 step_size: 0.5,
677 heading_step: TAU / 8.0,
678 goal_bias: 0.1,
679 heading_weight: 0.5,
680 goal_position_tol: 0.5,
681 goal_heading_tol: TAU / 8.0,
682 }
683 }
684}
685
686#[derive(Debug, Clone, PartialEq)]
693pub struct RigidBodyRrtBackend2D {
694 geometry: RigidBodyMipPlanner2D,
695 rrt: RigidBodyRrtConfig2D,
696}
697
698#[derive(Debug, Clone, Copy)]
699struct RrtNode {
700 pose: RigidBodyPose2D,
701 parent: Option<usize>,
702}
703
704impl RigidBodyRrtBackend2D {
705 pub fn new(config: RigidBodyMipConfig2D, rrt: RigidBodyRrtConfig2D) -> RoboticsResult<Self> {
706 validate_rrt_config(&rrt)?;
707 Ok(Self {
708 geometry: RigidBodyMipPlanner2D::new(config)?,
709 rrt,
710 })
711 }
712
713 pub fn config(&self) -> &RigidBodyMipConfig2D {
714 self.geometry.config()
715 }
716
717 pub fn rrt_config(&self) -> &RigidBodyRrtConfig2D {
718 &self.rrt
719 }
720
721 fn se2_distance(&self, a: RigidBodyPose2D, b: RigidBodyPose2D) -> f64 {
722 let position = ((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt();
723 let heading = shortest_angle_delta(a.theta, b.theta).abs();
724 position + self.rrt.heading_weight * heading
725 }
726
727 fn sample(&self, rng: &mut SplitMix64, goal: RigidBodyPose2D) -> RigidBodyPose2D {
728 if rng.next_f64() < self.rrt.goal_bias {
729 return goal;
730 }
731 let config = self.config();
732 let x = config.min_x + rng.next_f64() * (config.max_x - config.min_x);
733 let y = config.min_y + rng.next_f64() * (config.max_y - config.min_y);
734 let theta = rng.next_f64() * TAU;
735 RigidBodyPose2D::new(x, y, theta)
736 }
737
738 fn steer(&self, from: RigidBodyPose2D, toward: RigidBodyPose2D) -> RigidBodyPose2D {
739 let dx = toward.x - from.x;
740 let dy = toward.y - from.y;
741 let dist = (dx * dx + dy * dy).sqrt();
742 let (nx, ny) = if dist <= self.rrt.step_size || dist <= EPS {
743 (toward.x, toward.y)
744 } else {
745 let scale = self.rrt.step_size / dist;
746 (from.x + dx * scale, from.y + dy * scale)
747 };
748 let dtheta = shortest_angle_delta(from.theta, toward.theta)
749 .clamp(-self.rrt.heading_step, self.rrt.heading_step);
750 RigidBodyPose2D::new(nx, ny, (from.theta + dtheta).rem_euclid(TAU))
751 }
752
753 fn reaches_goal(
754 &self,
755 pose: RigidBodyPose2D,
756 goal: RigidBodyPose2D,
757 require_goal_heading: bool,
758 ) -> bool {
759 let position = ((pose.x - goal.x).powi(2) + (pose.y - goal.y).powi(2)).sqrt();
760 if position > self.rrt.goal_position_tol {
761 return false;
762 }
763 if require_goal_heading
764 && shortest_angle_delta(pose.theta, goal.theta).abs() > self.rrt.goal_heading_tol
765 {
766 return false;
767 }
768 true
769 }
770
771 fn nearest(&self, nodes: &[RrtNode], target: RigidBodyPose2D) -> usize {
772 let mut best_index = 0;
773 let mut best_distance = f64::INFINITY;
774 for (index, node) in nodes.iter().enumerate() {
775 let distance = self.se2_distance(node.pose, target);
776 if distance < best_distance {
777 best_distance = distance;
778 best_index = index;
779 }
780 }
781 best_index
782 }
783
784 fn reconstruct(&self, nodes: &[RrtNode], mut index: usize) -> Vec<RigidBodyPose2D> {
785 let mut poses = Vec::new();
786 loop {
787 poses.push(nodes[index].pose);
788 match nodes[index].parent {
789 Some(parent) => index = parent,
790 None => break,
791 }
792 }
793 poses.reverse();
794 poses
795 }
796}
797
798impl RigidBodyPlanningBackend for RigidBodyRrtBackend2D {
799 fn name(&self) -> &'static str {
800 "rrt-se2"
801 }
802
803 fn plan_path(
804 &self,
805 start: RigidBodyPose2D,
806 goal: RigidBodyPose2D,
807 require_goal_heading: bool,
808 ) -> RoboticsResult<RigidBodyPlanOutcome2D> {
809 if !self.geometry.is_pose_feasible(start) || !self.geometry.is_pose_feasible(goal) {
810 return Err(RoboticsError::InvalidParameter(
811 "rigid-body RRT start and goal poses must be feasible".to_string(),
812 ));
813 }
814
815 let mut rng = SplitMix64::new(self.rrt.seed);
816 let mut nodes = vec![RrtNode {
817 pose: start,
818 parent: None,
819 }];
820
821 for iteration in 1..=self.rrt.max_samples {
822 let target = self.sample(&mut rng, goal);
823 let nearest_index = self.nearest(&nodes, target);
824 let nearest_pose = nodes[nearest_index].pose;
825 let candidate = self.steer(nearest_pose, target);
826 if !self.geometry.is_pose_feasible(candidate)
827 || !self.geometry.is_segment_feasible(nearest_pose, candidate)
828 {
829 continue;
830 }
831 nodes.push(RrtNode {
832 pose: candidate,
833 parent: Some(nearest_index),
834 });
835 let new_index = nodes.len() - 1;
836
837 if self.reaches_goal(candidate, goal, require_goal_heading) {
838 let connect_goal = require_goal_heading
839 && (candidate.x != goal.x
840 || candidate.y != goal.y
841 || candidate.theta != goal.theta);
842 let goal_index = if connect_goal {
843 if !self.geometry.is_segment_feasible(candidate, goal) {
844 continue;
845 }
846 nodes.push(RrtNode {
847 pose: goal,
848 parent: Some(new_index),
849 });
850 nodes.len() - 1
851 } else {
852 new_index
853 };
854 let poses = self.reconstruct(&nodes, goal_index);
855 let (path_length, heading_change) = path_extent(&poses);
856 let min_separation_margin = path_min_margin(&self.geometry, &poses);
857 return Ok(RigidBodyPlanOutcome2D {
858 backend: self.name(),
859 path_length,
860 heading_change,
861 iterations: iteration,
862 min_separation_margin,
863 poses,
864 });
865 }
866 }
867
868 Err(RoboticsError::PlanningError(
869 "rigid-body RRT could not find a feasible path within the sample budget".to_string(),
870 ))
871 }
872}
873
874#[derive(Debug, Clone, PartialEq)]
876struct SplitMix64 {
877 state: u64,
878}
879
880impl SplitMix64 {
881 fn new(seed: u64) -> Self {
882 Self { state: seed }
883 }
884
885 fn next_u64(&mut self) -> u64 {
886 self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
887 let mut z = self.state;
888 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
889 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
890 z ^ (z >> 31)
891 }
892
893 fn next_f64(&mut self) -> f64 {
894 (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
896 }
897}
898
899#[derive(Debug, Clone, PartialEq)]
916pub struct RigidBodyExactBackend2D {
917 planner: RigidBodyMipPlanner2D,
918}
919
920const EXACT_MOVES: [(i32, i32); 16] = [
923 (1, 0),
924 (-1, 0),
925 (0, 1),
926 (0, -1),
927 (1, 1),
928 (1, -1),
929 (-1, 1),
930 (-1, -1),
931 (2, 1),
932 (2, -1),
933 (-2, 1),
934 (-2, -1),
935 (1, 2),
936 (1, -2),
937 (-1, 2),
938 (-1, -2),
939];
940
941#[derive(Debug, Clone, Copy)]
942struct ExactOpen {
943 f: f64,
944 g: f64,
945 cell: (i32, i32),
946}
947
948impl PartialEq for ExactOpen {
949 fn eq(&self, other: &Self) -> bool {
950 self.f == other.f && self.g == other.g && self.cell == other.cell
951 }
952}
953impl Eq for ExactOpen {}
954impl Ord for ExactOpen {
955 fn cmp(&self, other: &Self) -> Ordering {
956 other
958 .f
959 .total_cmp(&self.f)
960 .then_with(|| other.g.total_cmp(&self.g))
961 .then_with(|| other.cell.cmp(&self.cell))
962 }
963}
964impl PartialOrd for ExactOpen {
965 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
966 Some(self.cmp(other))
967 }
968}
969
970impl RigidBodyExactBackend2D {
971 pub fn new(config: RigidBodyMipConfig2D) -> RoboticsResult<Self> {
972 Ok(Self {
973 planner: RigidBodyMipPlanner2D::new(config)?,
974 })
975 }
976
977 pub fn config(&self) -> &RigidBodyMipConfig2D {
978 self.planner.config()
979 }
980
981 fn grid_extent(&self) -> (i32, i32) {
982 let c = self.config();
983 let nx = ((c.max_x - c.min_x) / c.position_step).floor() as i32;
984 let ny = ((c.max_y - c.min_y) / c.position_step).floor() as i32;
985 (nx.max(0), ny.max(0))
986 }
987
988 fn snap(&self, x: f64, y: f64, nx: i32, ny: i32) -> (i32, i32) {
989 let c = self.config();
990 let ix = ((x - c.min_x) / c.position_step).round() as i32;
991 let iy = ((y - c.min_y) / c.position_step).round() as i32;
992 (ix.clamp(0, nx), iy.clamp(0, ny))
993 }
994
995 fn world(&self, cell: (i32, i32)) -> (f64, f64) {
996 let c = self.config();
997 (
998 c.min_x + cell.0 as f64 * c.position_step,
999 c.min_y + cell.1 as f64 * c.position_step,
1000 )
1001 }
1002
1003 fn move_heading(&self, dx: i32, dy: i32) -> f64 {
1005 (dy as f64).atan2(dx as f64).rem_euclid(TAU)
1006 }
1007}
1008
1009impl RigidBodyPlanningBackend for RigidBodyExactBackend2D {
1010 fn name(&self) -> &'static str {
1011 "exact-bnb"
1012 }
1013
1014 fn plan_path(
1015 &self,
1016 start: RigidBodyPose2D,
1017 goal: RigidBodyPose2D,
1018 _require_goal_heading: bool,
1019 ) -> RoboticsResult<RigidBodyPlanOutcome2D> {
1020 let (nx, ny) = self.grid_extent();
1021 let start_cell = self.snap(start.x, start.y, nx, ny);
1022 let goal_cell = self.snap(goal.x, goal.y, nx, ny);
1023
1024 let heuristic = |cell: (i32, i32)| {
1025 let (wx, wy) = self.world(cell);
1026 let (gx, gy) = self.world(goal_cell);
1027 ((wx - gx).powi(2) + (wy - gy).powi(2)).sqrt()
1028 };
1029
1030 let mut best_g: HashMap<(i32, i32), f64> = HashMap::new();
1031 let mut parent: HashMap<(i32, i32), ((i32, i32), f64)> = HashMap::new();
1032 let mut open = BinaryHeap::new();
1033 best_g.insert(start_cell, 0.0);
1034 open.push(ExactOpen {
1035 f: heuristic(start_cell),
1036 g: 0.0,
1037 cell: start_cell,
1038 });
1039
1040 let mut expanded = 0usize;
1041 let max_expansions = self.config().max_expansions;
1042 let mut reached = false;
1043
1044 while let Some(ExactOpen { g, cell, .. }) = open.pop() {
1045 if g > *best_g.get(&cell).unwrap_or(&f64::INFINITY) {
1046 continue;
1047 }
1048 if cell == goal_cell {
1049 reached = true;
1050 break;
1051 }
1052 expanded += 1;
1053 if expanded > max_expansions {
1054 break;
1055 }
1056
1057 let (wx, wy) = self.world(cell);
1058 for (dx, dy) in EXACT_MOVES {
1059 let next = (cell.0 + dx, cell.1 + dy);
1060 if next.0 < 0 || next.1 < 0 || next.0 > nx || next.1 > ny {
1061 continue;
1062 }
1063 let heading = self.move_heading(dx, dy);
1064 let (wnx, wny) = self.world(next);
1065 let from = RigidBodyPose2D::new(wx, wy, heading);
1066 let to = RigidBodyPose2D::new(wnx, wny, heading);
1067 if !self.planner.is_segment_feasible(from, to) {
1068 continue;
1069 }
1070 let edge = ((wnx - wx).powi(2) + (wny - wy).powi(2)).sqrt();
1071 let tentative = g + edge;
1072 if tentative + 1e-9 < *best_g.get(&next).unwrap_or(&f64::INFINITY) {
1073 best_g.insert(next, tentative);
1074 parent.insert(next, (cell, heading));
1075 open.push(ExactOpen {
1076 f: tentative + heuristic(next),
1077 g: tentative,
1078 cell: next,
1079 });
1080 }
1081 }
1082 }
1083
1084 if !reached {
1085 return Err(RoboticsError::PlanningError(
1086 "exact rigid-body backend found no feasible path".to_string(),
1087 ));
1088 }
1089
1090 let mut cells = vec![goal_cell];
1092 let mut headings = Vec::new();
1093 let mut current = goal_cell;
1094 while let Some(&(prev, heading)) = parent.get(¤t) {
1095 headings.push(heading);
1096 cells.push(prev);
1097 current = prev;
1098 }
1099 cells.reverse();
1100 headings.reverse();
1101
1102 let mut poses = Vec::with_capacity(cells.len());
1105 for (i, &cell) in cells.iter().enumerate() {
1106 let (wx, wy) = self.world(cell);
1107 let heading = if i < headings.len() {
1108 headings[i]
1109 } else {
1110 *headings.last().unwrap_or(&start.theta)
1111 };
1112 poses.push(RigidBodyPose2D::new(wx, wy, heading));
1113 }
1114
1115 let (path_length, heading_change) = path_extent(&poses);
1116
1117 let mut min_separation_margin = f64::INFINITY;
1120 for (i, window) in poses.windows(2).enumerate() {
1121 let heading = headings.get(i).copied().unwrap_or(window[0].theta);
1122 let from = RigidBodyPose2D::new(window[0].x, window[0].y, heading);
1123 let to = RigidBodyPose2D::new(window[1].x, window[1].y, heading);
1124 if let Some(certificates) = self.planner.segment_separation_certificates(from, to) {
1125 for certificate in certificates {
1126 min_separation_margin = min_separation_margin.min(certificate.margin);
1127 }
1128 }
1129 }
1130 if !min_separation_margin.is_finite() {
1131 min_separation_margin = 0.0;
1132 }
1133
1134 Ok(RigidBodyPlanOutcome2D {
1135 backend: self.name(),
1136 path_length,
1137 heading_change,
1138 iterations: expanded,
1139 min_separation_margin,
1140 poses,
1141 })
1142 }
1143}
1144
1145fn path_extent(poses: &[RigidBodyPose2D]) -> (f64, f64) {
1146 let mut length = 0.0;
1147 let mut heading = 0.0;
1148 for window in poses.windows(2) {
1149 length +=
1150 ((window[1].x - window[0].x).powi(2) + (window[1].y - window[0].y).powi(2)).sqrt();
1151 heading += shortest_angle_delta(window[0].theta, window[1].theta).abs();
1152 }
1153 (length, heading)
1154}
1155
1156fn path_min_margin(planner: &RigidBodyMipPlanner2D, poses: &[RigidBodyPose2D]) -> f64 {
1157 let mut min_margin = f64::INFINITY;
1158 for &pose in poses {
1159 if let Some(certificates) = planner.separation_certificates(pose) {
1160 for certificate in certificates {
1161 min_margin = min_margin.min(certificate.margin);
1162 }
1163 }
1164 }
1165 for window in poses.windows(2) {
1166 if let Some(certificates) = planner.segment_separation_certificates(window[0], window[1]) {
1167 for certificate in certificates {
1168 min_margin = min_margin.min(certificate.margin);
1169 }
1170 }
1171 }
1172 min_margin
1173}
1174
1175fn validate_rrt_config(config: &RigidBodyRrtConfig2D) -> RoboticsResult<()> {
1176 if config.max_samples == 0 {
1177 return Err(RoboticsError::InvalidParameter(
1178 "rigid-body RRT max_samples must be positive".to_string(),
1179 ));
1180 }
1181 if !config.step_size.is_finite()
1182 || !config.heading_step.is_finite()
1183 || !config.heading_weight.is_finite()
1184 || !config.goal_position_tol.is_finite()
1185 || !config.goal_heading_tol.is_finite()
1186 || config.step_size <= 0.0
1187 || config.heading_step <= 0.0
1188 || config.heading_weight < 0.0
1189 || config.goal_position_tol <= 0.0
1190 || config.goal_heading_tol <= 0.0
1191 {
1192 return Err(RoboticsError::InvalidParameter(
1193 "rigid-body RRT step, heading, and tolerance parameters must be valid".to_string(),
1194 ));
1195 }
1196 if !config.goal_bias.is_finite() || !(0.0..=1.0).contains(&config.goal_bias) {
1197 return Err(RoboticsError::InvalidParameter(
1198 "rigid-body RRT goal_bias must be within [0, 1]".to_string(),
1199 ));
1200 }
1201 Ok(())
1202}
1203
1204fn circular_heading_distance(a: usize, b: usize, count: usize) -> usize {
1205 let raw = a.abs_diff(b);
1206 raw.min(count - raw)
1207}
1208
1209fn theta_to_index(theta: f64, heading_count: usize) -> usize {
1210 let wrapped = theta.rem_euclid(TAU);
1211 ((wrapped / TAU * heading_count as f64).round() as usize) % heading_count
1212}
1213
1214fn interpolate_pose(from: RigidBodyPose2D, to: RigidBodyPose2D, alpha: f64) -> RigidBodyPose2D {
1215 let dtheta = shortest_angle_delta(from.theta, to.theta);
1216 RigidBodyPose2D::new(
1217 from.x + (to.x - from.x) * alpha,
1218 from.y + (to.y - from.y) * alpha,
1219 (from.theta + dtheta * alpha).rem_euclid(TAU),
1220 )
1221}
1222
1223fn shortest_angle_delta(from: f64, to: f64) -> f64 {
1224 (to - from + std::f64::consts::PI).rem_euclid(TAU) - std::f64::consts::PI
1225}
1226
1227fn signed_area(vertices: &[RigidBodyPoint2D]) -> f64 {
1228 let mut area = 0.0;
1229 for index in 0..vertices.len() {
1230 let current = vertices[index];
1231 let next = vertices[(index + 1) % vertices.len()];
1232 area += current.x * next.y - next.x * current.y;
1233 }
1234 area * 0.5
1235}
1236
1237fn validate_convex_ccw_polygon(vertices: &[RigidBodyPoint2D]) -> RoboticsResult<()> {
1238 for index in 0..vertices.len() {
1239 let a = vertices[index];
1240 let b = vertices[(index + 1) % vertices.len()];
1241 let c = vertices[(index + 2) % vertices.len()];
1242 let cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x);
1243 if cross < -EPS {
1244 return Err(RoboticsError::InvalidParameter(
1245 "rigid-body MIP polygon vertices must form a convex polygon".to_string(),
1246 ));
1247 }
1248 if ((b.x - a.x).powi(2) + (b.y - a.y).powi(2)).sqrt() <= EPS {
1249 return Err(RoboticsError::InvalidParameter(
1250 "rigid-body MIP polygon edges must have non-zero length".to_string(),
1251 ));
1252 }
1253 }
1254 Ok(())
1255}
1256
1257fn validate_config(config: &RigidBodyMipConfig2D) -> RoboticsResult<()> {
1258 if !config.min_x.is_finite()
1259 || !config.max_x.is_finite()
1260 || !config.min_y.is_finite()
1261 || !config.max_y.is_finite()
1262 || config.min_x >= config.max_x
1263 || config.min_y >= config.max_y
1264 {
1265 return Err(RoboticsError::InvalidParameter(
1266 "rigid-body MIP workspace bounds must be finite and ordered".to_string(),
1267 ));
1268 }
1269 if !config.position_step.is_finite()
1270 || !config.robot_half_length.is_finite()
1271 || !config.robot_half_width.is_finite()
1272 || !config.clearance.is_finite()
1273 || config.position_step <= 0.0
1274 || config.robot_half_length <= 0.0
1275 || config.robot_half_width <= 0.0
1276 || config.clearance < 0.0
1277 {
1278 return Err(RoboticsError::InvalidParameter(
1279 "rigid-body MIP step, robot dimensions, and clearance must be valid".to_string(),
1280 ));
1281 }
1282 if config.heading_count < 4 || config.move_cost == 0 || config.turn_cost == 0 {
1283 return Err(RoboticsError::InvalidParameter(
1284 "rigid-body MIP heading count and action costs must be positive".to_string(),
1285 ));
1286 }
1287 if config.max_expansions == 0 {
1288 return Err(RoboticsError::InvalidParameter(
1289 "rigid-body MIP max_expansions must be positive".to_string(),
1290 ));
1291 }
1292 let steps_x = (config.max_x - config.min_x) / config.position_step;
1293 let steps_y = (config.max_y - config.min_y) / config.position_step;
1294 if (steps_x.round() - steps_x).abs() > 1.0e-6 || (steps_y.round() - steps_y).abs() > 1.0e-6 {
1295 return Err(RoboticsError::InvalidParameter(
1296 "rigid-body MIP bounds must align with position_step".to_string(),
1297 ));
1298 }
1299 Ok(())
1300}
1301
1302#[cfg(test)]
1303mod tests {
1304 use super::*;
1305
1306 fn corridor_planner() -> RigidBodyMipPlanner2D {
1307 let obstacles = vec![
1308 RigidBodyConvexObstacle2D::aabb(3.0, 7.0, 0.0, 2.6).unwrap(),
1309 RigidBodyConvexObstacle2D::aabb(3.0, 7.0, 3.4, 6.0).unwrap(),
1310 ];
1311 RigidBodyMipPlanner2D::new(RigidBodyMipConfig2D {
1312 obstacles,
1313 max_expansions: 50_000,
1314 ..RigidBodyMipConfig2D::new(0.0, 10.0, 0.0, 6.0)
1315 })
1316 .unwrap()
1317 }
1318
1319 #[test]
1320 fn halfspace_certificate_separates_pose_from_obstacle() {
1321 let planner = corridor_planner();
1322 let pose = RigidBodyPose2D::new(4.0, 3.0, 0.0);
1323 let certificates = planner.separation_certificates(pose).unwrap();
1324
1325 assert_eq!(certificates.len(), 2);
1326 assert!(certificates[0].margin > planner.config.clearance);
1327 assert!(certificates[1].margin > planner.config.clearance);
1328 }
1329
1330 #[test]
1331 fn colliding_pose_has_no_certificate() {
1332 let planner = corridor_planner();
1333 let pose = RigidBodyPose2D::new(4.0, 2.4, 0.0);
1334
1335 assert!(planner.separation_certificates(pose).is_none());
1336 }
1337
1338 #[test]
1339 fn convex_polygon_constructor_accepts_clockwise_vertices() {
1340 let obstacle = RigidBodyConvexObstacle2D::convex_polygon(vec![
1341 RigidBodyPoint2D::new(2.0, 2.0),
1342 RigidBodyPoint2D::new(2.0, 1.0),
1343 RigidBodyPoint2D::new(3.0, 1.5),
1344 ])
1345 .unwrap();
1346 let planner = RigidBodyMipPlanner2D::new(RigidBodyMipConfig2D {
1347 obstacles: vec![obstacle],
1348 robot_half_length: 0.15,
1349 robot_half_width: 0.15,
1350 ..RigidBodyMipConfig2D::new(0.0, 5.0, 0.0, 5.0)
1351 })
1352 .unwrap();
1353
1354 assert!(planner
1355 .separation_certificates(RigidBodyPose2D::new(0.8, 1.5, 0.0))
1356 .is_some());
1357 assert!(planner
1358 .separation_certificates(RigidBodyPose2D::new(2.35, 1.5, 0.0))
1359 .is_none());
1360 }
1361
1362 #[test]
1363 fn segment_certificate_catches_midpoint_collision() {
1364 let planner = RigidBodyMipPlanner2D::new(RigidBodyMipConfig2D {
1365 obstacles: vec![RigidBodyConvexObstacle2D::aabb(1.45, 1.55, 0.8, 1.2).unwrap()],
1366 robot_half_length: 0.10,
1367 robot_half_width: 0.10,
1368 clearance: 0.0,
1369 ..RigidBodyMipConfig2D::new(0.0, 3.0, 0.0, 2.0)
1370 })
1371 .unwrap();
1372 let from = RigidBodyPose2D::new(1.0, 1.0, 0.0);
1373 let to = RigidBodyPose2D::new(2.0, 1.0, 0.0);
1374
1375 assert!(planner.separation_certificates(from).is_some());
1376 assert!(planner.separation_certificates(to).is_some());
1377 assert!(planner.segment_separation_certificates(from, to).is_none());
1378 }
1379
1380 #[test]
1381 fn planner_rotates_through_narrow_gap() {
1382 let planner = corridor_planner();
1383 let start = RigidBodyPose2D::new(1.0, 3.0, TAU / 4.0);
1384 let goal = RigidBodyPose2D::new(9.0, 3.0, 0.0);
1385 let plan = planner.plan(start, goal, true).unwrap();
1386
1387 assert!(plan.poses.len() > 2);
1388 assert!(plan.expanded_states > 0);
1389 assert!(plan.binary_separation_choices >= plan.poses.len() * 2);
1390 assert_eq!(plan.segment_certificates.len(), plan.poses.len() - 1);
1391 assert!(plan.segment_binary_separation_choices >= plan.segment_certificates.len() * 2);
1392 assert!(plan.min_separation_margin > planner.config.clearance);
1393 assert!(plan.min_segment_separation_margin > planner.config.clearance);
1394 assert!((plan.poses.last().unwrap().theta - 0.0).abs() < 1.0e-9);
1395 assert!(plan
1396 .poses
1397 .iter()
1398 .filter(|pose| pose.x >= 3.0 && pose.x <= 7.0)
1399 .all(|pose| pose.theta.abs() < 1.0e-9 || (pose.theta - TAU / 2.0).abs() < 1.0e-9));
1400 }
1401
1402 fn open_field_config() -> RigidBodyMipConfig2D {
1403 RigidBodyMipConfig2D {
1404 obstacles: vec![RigidBodyConvexObstacle2D::aabb(3.0, 5.0, 0.0, 3.0).unwrap()],
1405 robot_half_length: 0.4,
1406 robot_half_width: 0.2,
1407 max_expansions: 50_000,
1408 ..RigidBodyMipConfig2D::new(0.0, 8.0, 0.0, 6.0)
1409 }
1410 }
1411
1412 #[test]
1413 fn lattice_backend_reports_comparable_outcome() {
1414 let planner = RigidBodyMipPlanner2D::new(open_field_config()).unwrap();
1415 let start = RigidBodyPose2D::new(0.5, 4.5, 0.0);
1416 let goal = RigidBodyPose2D::new(7.5, 1.5, 0.0);
1417 let outcome = planner.plan_path(start, goal, false).unwrap();
1418
1419 assert_eq!(outcome.backend, "lattice-astar");
1420 assert!(outcome.poses.len() > 2);
1421 assert!(outcome.path_length > 0.0);
1422 assert!(outcome.iterations > 0);
1423 assert!(outcome.min_separation_margin > planner.config().clearance);
1424 }
1425
1426 #[test]
1427 fn rrt_backend_is_deterministic_for_fixed_seed() {
1428 let start = RigidBodyPose2D::new(0.5, 4.5, 0.0);
1429 let goal = RigidBodyPose2D::new(7.5, 1.5, 0.0);
1430 let backend = || {
1431 RigidBodyRrtBackend2D::new(open_field_config(), RigidBodyRrtConfig2D::new(7)).unwrap()
1432 };
1433 let first = backend().plan_path(start, goal, false).unwrap();
1434 let second = backend().plan_path(start, goal, false).unwrap();
1435
1436 assert_eq!(first.poses, second.poses);
1437 assert_eq!(first.iterations, second.iterations);
1438 }
1439
1440 #[test]
1441 fn both_backends_avoid_obstacle_and_reach_goal() {
1442 let config = open_field_config();
1443 let start = RigidBodyPose2D::new(0.5, 4.5, 0.0);
1444 let goal = RigidBodyPose2D::new(7.5, 1.5, 0.0);
1445
1446 let lattice = RigidBodyMipPlanner2D::new(config.clone()).unwrap();
1447 let rrt =
1448 RigidBodyRrtBackend2D::new(config.clone(), RigidBodyRrtConfig2D::new(11)).unwrap();
1449
1450 for outcome in [
1451 lattice.plan_path(start, goal, false).unwrap(),
1452 rrt.plan_path(start, goal, false).unwrap(),
1453 ] {
1454 assert!(outcome.min_separation_margin > config.clearance);
1455 let last = outcome.poses.last().unwrap();
1456 let position_error = ((last.x - goal.x).powi(2) + (last.y - goal.y).powi(2)).sqrt();
1457 assert!(
1458 position_error <= 0.5,
1459 "{} ended {position_error} from the goal",
1460 outcome.backend
1461 );
1462 }
1463 }
1464
1465 #[test]
1466 fn lattice_backend_is_deterministic_and_bounds_rrt() {
1467 let config = open_field_config();
1468 let start = RigidBodyPose2D::new(0.5, 4.5, 0.0);
1469 let goal = RigidBodyPose2D::new(7.5, 1.5, 0.0);
1470
1471 let first = RigidBodyMipPlanner2D::new(config.clone())
1474 .unwrap()
1475 .plan_path(start, goal, false)
1476 .unwrap();
1477 let second = RigidBodyMipPlanner2D::new(config.clone())
1478 .unwrap()
1479 .plan_path(start, goal, false)
1480 .unwrap();
1481 assert_eq!(first.poses, second.poses);
1482 assert_eq!(first.iterations, second.iterations);
1483
1484 let rrt = RigidBodyRrtBackend2D::new(config, RigidBodyRrtConfig2D::new(3))
1487 .unwrap()
1488 .plan_path(start, goal, false)
1489 .unwrap();
1490 assert!(rrt.path_length <= first.path_length * 3.0);
1491 }
1492
1493 #[test]
1494 fn rrt_rejects_invalid_config() {
1495 let mut rrt = RigidBodyRrtConfig2D::new(1);
1496 rrt.goal_bias = 1.5;
1497 assert!(RigidBodyRrtBackend2D::new(open_field_config(), rrt).is_err());
1498 }
1499
1500 #[test]
1501 fn exact_backend_reaches_goal_and_is_deterministic() {
1502 let config = open_field_config();
1503 let start = RigidBodyPose2D::new(0.5, 4.5, 0.0);
1504 let goal = RigidBodyPose2D::new(7.5, 1.5, 0.0);
1505 let backend = RigidBodyExactBackend2D::new(config.clone()).unwrap();
1506 let first = backend.plan_path(start, goal, false).unwrap();
1507 let second = backend.plan_path(start, goal, false).unwrap();
1508
1509 assert_eq!(first.backend, "exact-bnb");
1510 assert_eq!(first.poses, second.poses);
1511 assert!(first.min_separation_margin > config.clearance);
1512 let last = first.poses.last().unwrap();
1513 let position_error = ((last.x - goal.x).powi(2) + (last.y - goal.y).powi(2)).sqrt();
1514 assert!(position_error <= 0.5, "ended {position_error} from goal");
1515 }
1516
1517 #[test]
1518 fn exact_backend_is_no_longer_than_lattice() {
1519 let config = open_field_config();
1520 let start = RigidBodyPose2D::new(0.5, 4.5, 0.0);
1521 let goal = RigidBodyPose2D::new(7.5, 1.5, 0.0);
1522
1523 let lattice = RigidBodyMipPlanner2D::new(config.clone())
1524 .unwrap()
1525 .plan_path(start, goal, false)
1526 .unwrap();
1527 let exact = RigidBodyExactBackend2D::new(config)
1528 .unwrap()
1529 .plan_path(start, goal, false)
1530 .unwrap();
1531
1532 assert!(
1535 exact.path_length <= lattice.path_length + 1e-6,
1536 "exact {} should not exceed lattice {}",
1537 exact.path_length,
1538 lattice.path_length
1539 );
1540 }
1541}