Skip to main content

rust_robotics_planning/
rigid_body_mip.rs

1//! Rigid-body MIP-style path planning.
2//!
3//! This module implements a deterministic first reproduction slice for
4//! mixed-integer rigid-body planning. It avoids an external MILP solver by
5//! searching a discretized SE(2) lattice, while every accepted pose carries the
6//! same kind of binary disjunctive obstacle-avoidance certificate used in
7//! convex-obstacle MILP formulations: for each obstacle, one active half-space
8//! separates the whole robot rectangle from that obstacle.
9
10use 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/// 2-D point.
19#[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/// Rigid-body center pose.
32#[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/// Half-space `a*x + b*y <= c` containing the obstacle interior.
46#[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/// Convex obstacle represented by vertices and fixed half-spaces.
69#[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/// Active binary-style obstacle-avoidance choice for one obstacle at one pose.
150#[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/// Planner configuration.
158#[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/// Planning result.
196#[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/// Backend-agnostic planning outcome.
210///
211/// Both the deterministic lattice backend and the sampling RRT backend report
212/// the same comparable metrics so a benchmark can place them side by side: the
213/// realized SE(2) path, its Euclidean position length, accumulated absolute
214/// heading change, the search effort (lattice expansions or RRT samples), and
215/// the tightest obstacle-separation margin along the path.
216#[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
226/// Common interface for rigid-body planning backends.
227///
228/// The lattice planner is the deterministic fallback backend. The trait exists
229/// so an exact mixed-integer (MILP) backend can later be dropped in behind the
230/// same call site, and so sampling planners can be benchmarked against the
231/// lattice search on identical scenes.
232pub trait RigidBodyPlanningBackend {
233    /// Human-readable backend name used in benchmark reports.
234    fn name(&self) -> &'static str;
235
236    /// Plan a feasible rigid-body path from `start` to `goal`.
237    fn plan_path(
238        &self,
239        start: RigidBodyPose2D,
240        goal: RigidBodyPose2D,
241        require_goal_heading: bool,
242    ) -> RoboticsResult<RigidBodyPlanOutcome2D>;
243}
244
245/// Discretized rigid-body planner with MIP-style separation certificates.
246#[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(&current).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/// Configuration for the sampling RRT rigid-body backend.
651#[derive(Debug, Clone, PartialEq)]
652pub struct RigidBodyRrtConfig2D {
653    /// Deterministic PRNG seed (no `thread_rng`: runs must be reproducible).
654    pub seed: u64,
655    /// Maximum number of sampling iterations before giving up.
656    pub max_samples: usize,
657    /// Maximum position extension distance per tree growth step.
658    pub step_size: f64,
659    /// Maximum absolute heading change per extension step.
660    pub heading_step: f64,
661    /// Probability in `[0, 1]` of sampling the goal pose directly.
662    pub goal_bias: f64,
663    /// Heading weight in the nearest-neighbor SE(2) distance metric.
664    pub heading_weight: f64,
665    /// Position tolerance for reaching the goal.
666    pub goal_position_tol: f64,
667    /// Heading tolerance for reaching the goal when a goal heading is required.
668    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/// Sampling rigid-body backend (RRT over SE(2)).
687///
688/// Reuses the lattice planner's geometric feasibility machinery (pose and
689/// swept-segment separation certificates) so both backends share an identical
690/// notion of collision, while exploring the workspace by random extension
691/// instead of an exhaustive lattice search.
692#[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/// Deterministic SplitMix64 PRNG (reproducible RRT sampling, no `thread_rng`).
875#[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        // 53-bit mantissa mapped to [0, 1).
895        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
896    }
897}
898
899/// Exact branch-and-bound rigid-body backend.
900///
901/// Where the lattice backend minimizes an integer move/turn cost over a coarse
902/// 8-heading SE(2) lattice, this backend minimizes the *true Euclidean path
903/// length* over a 16-connected motion grid, with the body oriented along its
904/// direction of travel. Feasibility of every motion uses the same disjunctive
905/// separating-half-space certificates as the MIP formulation: for each obstacle,
906/// at least one of its half-spaces must separate the swept footprint (the
907/// binary/disjunctive choice). Best-first search with an admissible
908/// straight-line lower bound is a branch-and-bound that returns the
909/// length-optimal path on this motion graph, so it dominates the coarse lattice
910/// and the sampling RRT in realized path length and heading change.
911///
912/// The body is oriented along motion, so heading constraints (`require_goal_
913/// heading`) are not enforced; the backend is meant for optimal *position*
914/// routing of the rigid footprint.
915#[derive(Debug, Clone, PartialEq)]
916pub struct RigidBodyExactBackend2D {
917    planner: RigidBodyMipPlanner2D,
918}
919
920/// 16-connected motion set: 8 unit moves plus 8 knight moves, so headings take
921/// ~22.5/26.5-degree increments and paths approach the true Euclidean optimum.
922const 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        // Min-heap on f, then g, then cell (deterministic).
957        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    /// Heading of the move `(dx, dy)` in world space (cells are square).
1004    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        // Reconstruct the cell path and the per-segment heading.
1091        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(&current) {
1095            headings.push(heading);
1096            cells.push(prev);
1097            current = prev;
1098        }
1099        cells.reverse();
1100        headings.reverse();
1101
1102        // Each pose carries its outgoing-segment heading (last repeats the final
1103        // segment heading), so the body is oriented along motion.
1104        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        // Minimum margin under the constant-heading-per-segment model actually
1118        // used for feasibility (not the lattice's heading-interpolated check).
1119        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        // The exhaustive lattice search is the deterministic fallback baseline:
1472        // identical inputs always yield an identical path and search effort.
1473        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        // The unsmoothed RRT path may detour, but should stay within a small
1485        // factor of the lattice path length on this open scene.
1486        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        // The exact backend minimizes true Euclidean length over a richer motion
1533        // set, so it never produces a longer path than the coarse lattice.
1534        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}