Skip to main content

cu_rrt_star/
rrt.rs

1//! Seeded RRT* over an [`RrtSpace`]: anything that can sample a candidate
2//! point and answer clearance queries. The bundled space is [`World`], a
3//! rectangle of round obstacles; the algorithm itself never depends on the
4//! obstacle shape.
5//!
6//! The planner knows nothing about Copper: it only exposes [`RrtStar::grow`],
7//! one bounded block of iterations. [`crate::RrtStarPlanner`] calls it once
8//! from `base()` and once per anytime refinement quantum.
9//!
10//! The steps follow Karaman and Frazzoli: sample with goal bias, nearest,
11//! steer, choose the cheapest parent, rewire the neighborhood, and prune by
12//! branch and bound. Three points are stricter here than in a textbook write-up:
13//! the final leg to the goal is collision checked and counted in the path cost,
14//! rewiring refuses an ancestor so rounding cannot close a cycle, and the cost
15//! shift after a rewire is iterative instead of recursive.
16
17use bincode::{Decode, Encode};
18use core::fmt::Debug;
19use cu_rng::prelude::*;
20use cu_spatial_payloads::{BBox2f, Point2f, Point2fSoa, Point3f, Point3fSoa};
21use cu29::prelude::*;
22use cu29::units::si::area::square_meter;
23use cu29::units::si::f32::{Area, Length, Ratio};
24use cu29::units::si::length::meter;
25use serde::{Deserialize, Serialize};
26
27/// Waypoints carried by a published path. Kept at 32 because serde derives
28/// array impls up to that size.
29pub const MAX_WAYPOINTS: usize = 32;
30
31/// Tree nodes one job can hold. This is the capacity of the SoA position set,
32/// so it is fixed at compile time; [`RrtParams::max_nodes`] is clamped to it.
33pub const MAX_NODES: usize = 4096;
34
35/// Obstacles carried by a [`World`]. Bounded so the map can travel inside a
36/// [`crate::PlanRequest`] without allocating; must stay at or under 32 for
37/// the same serde reason as [`MAX_WAYPOINTS`].
38pub const MAX_OBSTACLES: usize = 16;
39
40/// A round obstacle: anything within `radius` of `center` is occupied.
41#[derive(
42    Default, Debug, Clone, Copy, PartialEq, Encode, Decode, Serialize, Deserialize, Reflect,
43)]
44pub struct Obstacle {
45    pub center: Point2f,
46    pub radius: Length,
47}
48
49impl Obstacle {
50    pub const fn new(center: Point2f, radius: Length) -> Self {
51        Self { center, radius }
52    }
53}
54
55/// Fixed-capacity SoA storage for the tree positions.
56///
57/// The planner keeps every node position in one of these so the two scans it
58/// runs each iteration are a single vectorized pass over packed coordinates.
59pub trait PointSet<P>: Default {
60    fn len(&self) -> usize;
61
62    fn is_empty(&self) -> bool {
63        self.len() == 0
64    }
65
66    fn clear(&mut self);
67
68    /// # Panics
69    /// If the set is already at capacity.
70    fn push(&mut self, point: P);
71
72    /// # Panics
73    /// If `index` is at or past the length.
74    fn get(&self, index: usize) -> P;
75
76    /// Squared distance from every stored point to `target`, into `out[..len]`.
77    ///
78    /// # Panics
79    /// If `out` is shorter than the length.
80    fn distances_squared(&self, target: P, out: &mut [Area]);
81
82    /// Moves the point at `source` down to `destination`, for compaction.
83    /// `destination` must not exceed `source`.
84    fn compact(&mut self, destination: usize, source: usize);
85
86    /// Drops everything past `len`, which must not exceed the current length.
87    fn truncate(&mut self, len: usize);
88}
89
90/// A point the planner can search over, and the SoA set that stores a batch of
91/// them. `Point2f` and `Point3f` implement it, so the algorithm is the same
92/// code in 2D and 3D; the space picks the dimension.
93pub trait PlanPoint: Copy + Debug + PartialEq + 'static {
94    /// Storage for up to [`MAX_NODES`] of these points.
95    type Set: PointSet<Self>;
96
97    /// Euclidean distance to `other`.
98    fn distance(self, other: Self) -> Length;
99
100    /// The point at `ratio` of the way toward `other`. Ratio 0 returns
101    /// `self`, ratio 1 returns `other`.
102    fn lerp(self, other: Self, ratio: Ratio) -> Self;
103
104    /// `((p - a) . (b - a), |b - a|^2)`, the two products the point-to-segment
105    /// distance needs. Both are areas; their quotient is the projection
106    /// parameter along the segment.
107    fn project(a: Self, b: Self, p: Self) -> (Area, Area);
108}
109
110macro_rules! impl_plan_point {
111    ($point:ty, $set:ty, $($axis:ident),+) => {
112        impl PointSet<$point> for $set {
113            fn len(&self) -> usize {
114                <$set>::len(self)
115            }
116
117            fn clear(&mut self) {
118                self.len = 0;
119            }
120
121            fn push(&mut self, point: $point) {
122                <$set>::push(self, point)
123            }
124
125            fn get(&self, index: usize) -> $point {
126                <$set>::get(self, index)
127            }
128
129            fn distances_squared(&self, target: $point, out: &mut [Area]) {
130                <$set>::distances_squared(self, target, out)
131            }
132
133            fn compact(&mut self, destination: usize, source: usize) {
134                debug_assert!(destination <= source);
135                $(self.$axis[destination] = self.$axis[source];)+
136            }
137
138            fn truncate(&mut self, len: usize) {
139                debug_assert!(len <= self.len);
140                self.len = len;
141            }
142        }
143
144        impl PlanPoint for $point {
145            type Set = $set;
146
147            fn distance(self, other: Self) -> Length {
148                <$point>::distance(self, other)
149            }
150
151            fn lerp(self, other: Self, ratio: Ratio) -> Self {
152                <$point>::lerp(self, other, ratio.raw())
153            }
154
155            fn project(a: Self, b: Self, p: Self) -> (Area, Area) {
156                let (mut dot, mut len_sq) = (0.0f32, 0.0f32);
157                $(
158                    let along = (b.$axis - a.$axis).raw();
159                    let to_point = (p.$axis - a.$axis).raw();
160                    dot += to_point * along;
161                    len_sq += along * along;
162                )+
163                (
164                    Area::new::<square_meter>(dot),
165                    Area::new::<square_meter>(len_sq),
166                )
167            }
168        }
169    };
170}
171
172impl_plan_point!(Point2f, Point2fSoa<MAX_NODES>, x, y);
173impl_plan_point!(Point3f, Point3fSoa<MAX_NODES>, x, y, z);
174
175/// A space that can answer how far a point is from the nearest obstacle.
176///
177/// The clearance is a signed distance: positive in free space, zero on a
178/// surface, negative inside an obstacle or out of bounds. What counts as free
179/// is the caller's predicate - `clearance(p) > robot_radius` - so one space
180/// serves robots of any size.
181pub trait Clearance {
182    /// The point type of the space, which fixes its dimension.
183    type Point: PlanPoint;
184
185    /// Signed distance from `p` to the nearest occupied geometry.
186    fn clearance(&self, p: Self::Point) -> Length;
187
188    /// Smallest clearance anywhere along the segment `a`-`b`.
189    fn clearance_segment(&self, a: Self::Point, b: Self::Point) -> Length;
190}
191
192/// A rectangular world and its obstacles.
193///
194/// The obstacle storage is a fixed array so the map can travel inside a
195/// [`crate::PlanRequest`]: the planner has no map of its own, every job
196/// carries the one it must solve.
197#[derive(Default, Debug, Clone, Encode, Decode, Serialize, Deserialize, Reflect)]
198pub struct World {
199    pub bounds: BBox2f,
200    pub obstacles: [Obstacle; MAX_OBSTACLES],
201    /// Obstacles actually used in `obstacles`.
202    pub obstacle_count: u32,
203}
204
205impl World {
206    /// A world from its bounds and a list of obstacles. Errors when the list
207    /// exceeds [`MAX_OBSTACLES`].
208    pub fn new(bounds: BBox2f, obstacles: &[Obstacle]) -> CuResult<Self> {
209        if obstacles.len() > MAX_OBSTACLES {
210            return Err(format!(
211                "rrt*: {} obstacles, the world holds at most {MAX_OBSTACLES}",
212                obstacles.len()
213            )
214            .into());
215        }
216        let mut world = Self {
217            bounds,
218            obstacles: [Obstacle::default(); MAX_OBSTACLES],
219            obstacle_count: obstacles.len() as u32,
220        };
221        world.obstacles[..obstacles.len()].copy_from_slice(obstacles);
222        Ok(world)
223    }
224
225    /// The map the demo and the tests run on: a 10x10 m depot with five
226    /// pillars, placed so the straight line between opposite corners is
227    /// blocked. A first path is therefore always a detour, and refinement has
228    /// real work to do.
229    pub fn depot() -> Self {
230        let meters = Length::new::<meter>;
231        let point = Point2f::from_meters;
232        Self::new(
233            BBox2f::new(point(0.0, 0.0), point(10.0, 10.0)),
234            &[
235                Obstacle::new(point(3.0, 3.0), meters(1.2)),
236                Obstacle::new(point(6.0, 6.0), meters(1.5)),
237                Obstacle::new(point(7.0, 2.5), meters(1.0)),
238                Obstacle::new(point(2.5, 7.0), meters(1.0)),
239                Obstacle::new(point(5.0, 1.5), meters(0.8)),
240            ],
241        )
242        .expect("the depot obstacles fit MAX_OBSTACLES")
243    }
244
245    /// The obstacles in use, with a count out of range clamped rather than
246    /// trusted: a decoded `World` must not be able to cause a panic here.
247    pub fn obstacles(&self) -> &[Obstacle] {
248        &self.obstacles[..(self.obstacle_count as usize).min(MAX_OBSTACLES)]
249    }
250
251    /// Area left free by the obstacles. Assumes every obstacle lies inside the
252    /// bounds and none overlap, which holds for [`World::depot`].
253    pub fn free_area(&self) -> Area {
254        let blocked: f32 = self
255            .obstacles()
256            .iter()
257            .map(|o| core::f32::consts::PI * o.radius.raw() * o.radius.raw())
258            .sum();
259        let width = (self.bounds.max.x - self.bounds.min.x).raw();
260        let height = (self.bounds.max.y - self.bounds.min.y).raw();
261        Area::new::<square_meter>((width * height - blocked).max(f32::EPSILON))
262    }
263}
264
265impl Clearance for World {
266    type Point = Point2f;
267
268    fn clearance(&self, p: Point2f) -> Length {
269        let b = &self.bounds;
270        let mut clearance = (p.x - b.min.x)
271            .raw()
272            .min((b.max.x - p.x).raw())
273            .min((p.y - b.min.y).raw())
274            .min((b.max.y - p.y).raw());
275        for o in self.obstacles() {
276            clearance = clearance.min(p.distance(o.center).raw() - o.radius.raw());
277        }
278        Length::new::<meter>(clearance)
279    }
280
281    fn clearance_segment(&self, a: Point2f, b: Point2f) -> Length {
282        // The wall terms are linear along the segment, so their minimum sits
283        // at an endpoint; the obstacle terms need the true segment distance.
284        let mut clearance = self.clearance(a).raw().min(self.clearance(b).raw());
285        for o in self.obstacles() {
286            clearance = clearance.min(distance_to_segment(a, b, o.center).raw() - o.radius.raw());
287        }
288        Length::new::<meter>(clearance)
289    }
290}
291
292/// What RRT* needs from the space it searches: the clearance queries of
293/// [`Clearance`], a sampler, and the rewiring constant. The algorithm never
294/// depends on the obstacle shape or the dimension; [`World`] is the bundled
295/// 2D implementation.
296pub trait RrtSpace: Clearance {
297    /// A uniform random point inside the bounds of the space.
298    fn sample(&self, rng: &mut CuRng) -> Self::Point;
299
300    /// The RRT* radius constant of Karaman and Frazzoli:
301    /// `gamma* = 2 * (1 + 1/d)^(1/d) * (free_measure / zeta_d)^(1/d)`.
302    ///
303    /// A smaller constant shrinks the rewiring neighborhood below what
304    /// asymptotic optimality needs, and the planner degrades toward plain RRT
305    /// as the tree grows.
306    fn rrt_star_gamma(&self) -> Length;
307}
308
309impl RrtSpace for World {
310    fn sample(&self, rng: &mut CuRng) -> Point2f {
311        let b = &self.bounds;
312        Point2f::new(
313            b.min.x + (b.max.x - b.min.x) * rng.random::<f32>(),
314            b.min.y + (b.max.y - b.min.y) * rng.random::<f32>(),
315        )
316    }
317
318    /// The 2D instance of the constant: `d = 2` and `zeta_2 = pi`.
319    fn rrt_star_gamma(&self) -> Length {
320        Length::new::<meter>(
321            2.0 * 1.5f32.sqrt() * (self.free_area().raw() / core::f32::consts::PI).sqrt(),
322        )
323    }
324}
325
326/// Distance from `point` to the segment `a`-`b`.
327fn distance_to_segment<P: PlanPoint>(a: P, b: P, point: P) -> Length {
328    let (dot, len_sq) = P::project(a, b, point);
329    if len_sq.raw() <= f32::EPSILON {
330        return a.distance(point);
331    }
332    let along = ratio_of((dot.raw() / len_sq.raw()).clamp(0.0, 1.0));
333    a.lerp(b, along).distance(point)
334}
335
336/// A length in meters.
337pub(crate) fn meters(value: f32) -> Length {
338    Length::new::<meter>(value)
339}
340
341/// A dimensionless ratio from a normalized scalar.
342pub(crate) fn ratio_of(value: f32) -> Ratio {
343    Ratio::new::<cu29::units::si::ratio::ratio>(value)
344}
345
346/// The smaller of two lengths. cu29-units quantities carry `PartialOrd` but
347/// no `min`, since they are not `Ord`.
348fn shorter(a: Length, b: Length) -> Length {
349    if b < a { b } else { a }
350}
351
352/// Tuning knobs of the planner, all read from the node's RON `config:`.
353#[derive(Debug, Clone, Copy, Reflect)]
354pub struct RrtParams {
355    /// Longest edge the planner adds in one extension.
356    pub step_size: Length,
357    /// Probability of sampling the goal instead of a random point.
358    pub goal_bias: Ratio,
359    /// A node this close to the goal closes a path.
360    pub goal_threshold: Length,
361    /// Gamma of the RRT* rewiring radius `gamma * sqrt(ln n / n)`. Zero
362    /// derives it from the space through [`RrtSpace::rrt_star_gamma`], which is
363    /// the value RRT* needs to converge to the optimum.
364    pub gamma: Length,
365    /// Branch-and-bound prune every N iterations; 0 disables pruning.
366    pub prune_interval: u32,
367    /// Hard cap on the tree size, so one job cannot grow without bound.
368    /// Clamped to [`MAX_NODES`] by [`RrtStar::new`].
369    pub max_nodes: u32,
370}
371
372impl Default for RrtParams {
373    fn default() -> Self {
374        Self {
375            step_size: meters(0.8),
376            goal_bias: ratio_of(0.05),
377            goal_threshold: meters(0.5),
378            gamma: meters(0.0),
379            prune_interval: 512,
380            max_nodes: 4000,
381        }
382    }
383}
384
385/// The topology of one vertex. Its position lives in [`RrtStar::positions`] at
386/// the same index.
387#[derive(Debug, Clone)]
388struct TreeNode {
389    /// `None` for the root only.
390    parent: Option<u32>,
391    /// Path cost from the start to this node.
392    cost: Length,
393    children: Vec<u32>,
394}
395
396/// An RRT* search for one start/goal pair over a space `S`.
397///
398/// The tree only ever improves: `best_cost` is monotone non-increasing over
399/// iterations, which is what makes the algorithm a good anytime task.
400pub struct RrtStar<S: RrtSpace = World> {
401    space: S,
402    params: RrtParams,
403    /// The rewiring gamma in effect for the current job: the configured one,
404    /// or the one derived from the job's space when the config left it at 0.
405    gamma: Length,
406    start: S::Point,
407    goal: S::Point,
408    /// Node positions, kept apart from the topology so the two scans every
409    /// iteration runs - nearest and the rewiring neighborhood - are one
410    /// vectorized pass over packed coordinates.
411    positions: <S::Point as PlanPoint>::Set,
412    /// Parent, cost and children, indexed like `positions`. The two always
413    /// hold the same number of entries.
414    tree: Vec<TreeNode>,
415    /// Node closing the best path found so far.
416    best_goal: Option<u32>,
417    /// Cost of the best path found so far, infinite until one is found.
418    best_cost: Length,
419    iterations: u32,
420    rng: CuRng,
421    /// Reused between iterations so the scans stay off the allocator. The tree
422    /// topology still allocates: `TreeNode::children`, and the buffers `prune`
423    /// and `write_path` build.
424    scratch_d2: Vec<Area>,
425    scratch_near: Vec<u32>,
426    scratch_stack: Vec<u32>,
427}
428
429impl<S: RrtSpace> RrtStar<S> {
430    /// Starts a search rooted at `start`. An unreachable or blocked `start`
431    /// simply never grows a tree; the caller sees "no path" and the anytime
432    /// quality floor drops the result.
433    pub fn new(space: S, params: RrtParams, start: S::Point, goal: S::Point, seed: u64) -> Self {
434        let mut planner = Self {
435            space,
436            params: RrtParams {
437                // The position set has a compile-time capacity, so a config
438                // asking for more nodes than it holds is capped, not honored.
439                max_nodes: params.max_nodes.min(MAX_NODES as u32),
440                ..params
441            },
442            gamma: meters(0.0),
443            start,
444            goal,
445            positions: <S::Point as PlanPoint>::Set::default(),
446            tree: Vec::new(),
447            best_goal: None,
448            best_cost: meters(f32::INFINITY),
449            iterations: 0,
450            rng: CuRng::from_seed(seed),
451            // Sized once so the per-iteration scans never touch the allocator.
452            scratch_d2: vec![Area::default(); MAX_NODES],
453            scratch_near: Vec::new(),
454            scratch_stack: Vec::new(),
455        };
456        planner.restart(start, goal, seed);
457        planner
458    }
459
460    /// Restarts the search on a new problem, keeping the capacity the previous
461    /// job grew: after the first job the planner asks the allocator for much
462    /// less.
463    pub fn reset(&mut self, space: S, start: S::Point, goal: S::Point, seed: u64) {
464        self.space = space;
465        self.restart(start, goal, seed);
466    }
467
468    fn restart(&mut self, start: S::Point, goal: S::Point, seed: u64) {
469        // The map can change between jobs, so a derived gamma must follow it.
470        self.gamma = if self.params.gamma > meters(0.0) {
471            self.params.gamma
472        } else {
473            self.space.rrt_star_gamma()
474        };
475        self.start = start;
476        self.goal = goal;
477        self.tree.clear();
478        self.positions.clear();
479        self.positions.push(start);
480        self.tree.push(TreeNode {
481            parent: None,
482            cost: meters(0.0),
483            children: Vec::new(),
484        });
485        self.best_goal = None;
486        self.best_cost = meters(f32::INFINITY);
487        self.iterations = 0;
488        self.rng = CuRng::from_seed(seed);
489    }
490
491    /// Runs one bounded block of `iterations` RRT* iterations.
492    pub fn grow(&mut self, iterations: u32) {
493        for _ in 0..iterations {
494            self.iterations += 1;
495            if self.tree.len() < self.params.max_nodes as usize {
496                self.step();
497            }
498            if self.params.prune_interval > 0
499                && self.iterations.is_multiple_of(self.params.prune_interval)
500                && self.best_goal.is_some()
501            {
502                self.prune();
503            }
504        }
505    }
506
507    /// Cost of the best path so far, infinite while no path is known.
508    pub fn best_cost(&self) -> Length {
509        self.best_cost
510    }
511
512    /// True once a path to the goal exists.
513    pub fn has_solution(&self) -> bool {
514        self.best_goal.is_some()
515    }
516
517    pub fn tree_size(&self) -> u32 {
518        self.tree.len() as u32
519    }
520
521    pub fn iterations(&self) -> u32 {
522        self.iterations
523    }
524
525    /// True when the tree is full and pruning can never free room again, so no
526    /// further iteration can change anything.
527    pub fn is_exhausted(&self) -> bool {
528        self.tree.len() >= self.params.max_nodes as usize
529            && (self.params.prune_interval == 0 || self.best_goal.is_none())
530    }
531
532    /// Shortest conceivable path: the straight line, obstacles ignored.
533    pub fn lower_bound(&self) -> Length {
534        self.start.distance(self.goal)
535    }
536
537    /// Normalized quality in `0.0..=1.0`: how close the best path is to the
538    /// straight-line lower bound. 0.0 means no path yet, 1.0 means the path is
539    /// as short as the world allows.
540    pub fn quality(&self) -> Ratio {
541        if !self.has_solution() {
542            return ratio_of(0.0);
543        }
544        let lower_bound = self.lower_bound();
545        if self.best_cost <= lower_bound {
546            // Covers the degenerate job with the start on the goal, where the
547            // ratio would divide zero by zero.
548            return ratio_of(1.0);
549        }
550        ratio_of((lower_bound.raw() / self.best_cost.raw()).clamp(0.0, 1.0))
551    }
552
553    /// Nodes on the best path before shortcutting, the goal included. Zero
554    /// while no path is known.
555    pub fn tree_path_len(&self) -> usize {
556        let Some(goal_node) = self.best_goal else {
557            return 0;
558        };
559        let mut len = 1; // the goal itself, which is not a tree node
560        let mut cursor = Some(goal_node);
561        while let Some(index) = cursor {
562            len += 1;
563            cursor = self.tree[index as usize].parent;
564        }
565        len
566    }
567
568    /// Writes the best path into `out` and returns how many waypoints it used.
569    ///
570    /// The tree path routinely holds more nodes than [`MAX_WAYPOINTS`], so it
571    /// is shortcut first: from each waypoint the path jumps to the furthest
572    /// later one still reachable in a straight free line. Shortcutting is what
573    /// a planner publishes anyway, and it keeps every published segment
574    /// collision free - dropping the tail instead would publish a straight
575    /// jump across the map.
576    ///
577    /// The shortcut path is never longer than the tree path, so the reported
578    /// cost stays an upper bound on what the robot drives. `None` means even
579    /// the shortcut path does not fit; the caller then publishes nothing
580    /// rather than a path that cuts through an obstacle.
581    pub fn write_path(&self, out: &mut [S::Point; MAX_WAYPOINTS]) -> Option<u32> {
582        let goal_node = self.best_goal?;
583        let mut chain = Vec::new();
584        let mut cursor = Some(goal_node);
585        while let Some(index) = cursor {
586            chain.push(self.positions.get(index as usize));
587            cursor = self.tree[index as usize].parent;
588        }
589        chain.reverse();
590        chain.push(self.goal);
591
592        let mut len = 0usize;
593        let mut at = 0usize;
594        loop {
595            if len == MAX_WAYPOINTS {
596                return None;
597            }
598            out[len] = chain[at];
599            len += 1;
600            if at == chain.len() - 1 {
601                return Some(len as u32);
602            }
603            // The next tree node is always reachable - it is a tree edge - so
604            // the scan only looks for something further.
605            let mut next = at + 1;
606            for candidate in (at + 2)..chain.len() {
607                if self.segment_free(chain[at], chain[candidate]) {
608                    next = candidate;
609                }
610            }
611            at = next;
612        }
613    }
614
615    /// True when the whole segment keeps positive clearance.
616    fn segment_free(&self, a: S::Point, b: S::Point) -> bool {
617        self.space.clearance_segment(a, b) > meters(0.0)
618    }
619
620    /// One RRT* iteration: sample, steer, choose the cheapest parent, rewire
621    /// the neighborhood, then check whether the new node closes a better path.
622    fn step(&mut self) {
623        let sample = self.sample();
624        let nearest = self.nearest(sample);
625        let from = self.positions.get(nearest as usize);
626        let new_pos = steer(from, sample, self.params.step_size);
627        if !self.segment_free(from, new_pos) {
628            return;
629        }
630
631        // Squared radius against squared distances: the whole neighborhood
632        // scan then stays sqrt-free, which is what lets it vectorize.
633        let radius = self.near_radius();
634        let radius_sq = Area::new::<square_meter>(radius.raw() * radius.raw());
635        let n = self.positions.len();
636        self.positions
637            .distances_squared(new_pos, &mut self.scratch_d2);
638        let mut near = core::mem::take(&mut self.scratch_near);
639        near.clear();
640        for index in 0..n {
641            if self.scratch_d2[index] <= radius_sq {
642                near.push(index as u32);
643            }
644        }
645
646        // Choose the parent that gives the cheapest path to the new node.
647        let mut parent = nearest;
648        let mut cost = self.tree[nearest as usize].cost + from.distance(new_pos);
649        for &index in near.iter() {
650            let candidate_pos = self.positions.get(index as usize);
651            let candidate_cost = self.tree[index as usize].cost + candidate_pos.distance(new_pos);
652            if candidate_cost < cost && self.segment_free(candidate_pos, new_pos) {
653                parent = index;
654                cost = candidate_cost;
655            }
656        }
657
658        let new_index = self.tree.len() as u32;
659        self.positions.push(new_pos);
660        self.tree.push(TreeNode {
661            parent: Some(parent),
662            cost,
663            children: Vec::new(),
664        });
665        self.tree[parent as usize].children.push(new_index);
666
667        // Rewire: neighbors that are cheaper to reach through the new node.
668        for &index in near.iter() {
669            if index == parent {
670                continue;
671            }
672            let neighbor_pos = self.positions.get(index as usize);
673            let neighbor_cost = self.tree[index as usize].cost;
674            let rewired_cost = cost + neighbor_pos.distance(new_pos);
675            if rewired_cost < neighbor_cost
676                && !self.is_ancestor(index, new_index)
677                && self.segment_free(new_pos, neighbor_pos)
678            {
679                self.reparent(index, new_index, rewired_cost);
680            }
681        }
682        self.scratch_near = near;
683
684        // Does the new node close a better path?
685        let to_goal = new_pos.distance(self.goal);
686        if to_goal <= self.params.goal_threshold
687            && self.segment_free(new_pos, self.goal)
688            && cost + to_goal < self.best_cost
689        {
690            self.best_cost = cost + to_goal;
691            self.best_goal = Some(new_index);
692        }
693        // Rewiring may have shortened the current best path too.
694        if let Some(goal_node) = self.best_goal {
695            let cost = self.tree[goal_node as usize].cost;
696            let pos = self.positions.get(goal_node as usize);
697            self.best_cost = shorter(self.best_cost, cost + pos.distance(self.goal));
698        }
699    }
700
701    /// A random point of the space, biased toward the goal.
702    fn sample(&mut self) -> S::Point {
703        if self.rng.random::<f32>() < self.params.goal_bias.raw() {
704            return self.goal;
705        }
706        self.space.sample(&mut self.rng)
707    }
708
709    /// Index of the tree node closest to `point`. Linear on purpose: over the
710    /// packed SoA coordinates the scan is one vectorized sqrt-free pass, which
711    /// is fast enough at `max_nodes` scale; a spatial index would only pay off
712    /// on much larger trees. Squared distance has the same argmin as distance.
713    fn nearest(&mut self, point: S::Point) -> u32 {
714        let n = self.positions.len();
715        self.positions
716            .distances_squared(point, &mut self.scratch_d2);
717        let mut best = 0u32;
718        let mut best_distance = Area::new::<square_meter>(f32::INFINITY);
719        for index in 0..n {
720            let distance = self.scratch_d2[index];
721            if distance < best_distance {
722                best_distance = distance;
723                best = index as u32;
724            }
725        }
726        best
727    }
728
729    /// RRT* rewiring radius `gamma * sqrt(ln n / n)`, capped at one step.
730    fn near_radius(&self) -> Length {
731        let n = (self.tree.len() as f32).max(2.0);
732        shorter(self.gamma * (n.ln() / n).sqrt(), self.params.step_size)
733    }
734
735    /// True when `candidate` sits on the path from `node` up to the root.
736    ///
737    /// Rewiring an ancestor would turn the tree into a graph with a cycle, and
738    /// every walk over it would then loop forever. Exact arithmetic already
739    /// rules it out - reaching an ancestor through its own descendant is never
740    /// cheaper - but rounding on two nearly coincident samples must not be able
741    /// to break that.
742    fn is_ancestor(&self, candidate: u32, node: u32) -> bool {
743        let mut cursor = self.tree[node as usize].parent;
744        while let Some(index) = cursor {
745            if index == candidate {
746                return true;
747            }
748            cursor = self.tree[index as usize].parent;
749        }
750        false
751    }
752
753    /// Moves `node` under `new_parent` and shifts the cost of its whole
754    /// subtree by the same delta.
755    fn reparent(&mut self, node: u32, new_parent: u32, new_cost: Length) {
756        if let Some(old_parent) = self.tree[node as usize].parent {
757            self.tree[old_parent as usize]
758                .children
759                .retain(|&child| child != node);
760        }
761        self.tree[node as usize].parent = Some(new_parent);
762        self.tree[new_parent as usize].children.push(node);
763
764        let delta = new_cost - self.tree[node as usize].cost;
765        let mut stack = core::mem::take(&mut self.scratch_stack);
766        stack.clear();
767        stack.push(node);
768        while let Some(index) = stack.pop() {
769            self.tree[index as usize].cost += delta;
770            for i in 0..self.tree[index as usize].children.len() {
771                stack.push(self.tree[index as usize].children[i]);
772            }
773        }
774        self.scratch_stack = stack;
775    }
776
777    /// Branch and bound: drop every node that cannot belong to a path better
778    /// than the best one known.
779    ///
780    /// Walking down from the root keeps the tree consistent: a node is kept
781    /// only if its parent is kept, so no orphan survives the compaction. The
782    /// triangle inequality makes that almost free anyway - a kept node's parent
783    /// always satisfies the bound as well.
784    fn prune(&mut self) {
785        // The best path itself is protected: rounding must never let branch and
786        // bound drop the path it is bounding against.
787        let mut protected = vec![false; self.tree.len()];
788        let mut cursor = self.best_goal;
789        while let Some(index) = cursor {
790            protected[index as usize] = true;
791            cursor = self.tree[index as usize].parent;
792        }
793
794        let mut keep = vec![false; self.tree.len()];
795        let mut stack = core::mem::take(&mut self.scratch_stack);
796        stack.clear();
797        stack.push(0);
798        keep[0] = true;
799        while let Some(index) = stack.pop() {
800            for i in 0..self.tree[index as usize].children.len() {
801                let child = self.tree[index as usize].children[i];
802                let cost = self.tree[child as usize].cost;
803                let pos = self.positions.get(child as usize);
804                if protected[child as usize] || cost + pos.distance(self.goal) <= self.best_cost {
805                    keep[child as usize] = true;
806                    stack.push(child);
807                }
808            }
809        }
810        self.scratch_stack = stack;
811
812        let mut remap = vec![u32::MAX; self.tree.len()];
813        let mut kept = Vec::with_capacity(self.tree.len());
814        for index in 0..self.tree.len() {
815            if keep[index] {
816                // The positions compact in place: a kept node never moves to a
817                // higher slot, so the copy never overwrites a slot still to read.
818                let destination = kept.len();
819                remap[index] = destination as u32;
820                self.positions.compact(destination, index);
821                kept.push(TreeNode {
822                    parent: self.tree[index].parent,
823                    cost: self.tree[index].cost,
824                    children: Vec::new(),
825                });
826            }
827        }
828        self.positions.truncate(kept.len());
829        for node in kept.iter_mut() {
830            node.parent = node.parent.map(|parent| remap[parent as usize]);
831        }
832        for index in 0..kept.len() {
833            if let Some(parent) = kept[index].parent {
834                kept[parent as usize].children.push(index as u32);
835            }
836        }
837        self.best_goal = self.best_goal.map(|goal| remap[goal as usize]);
838        self.tree = kept;
839    }
840}
841
842/// Point at most `step_size` away from `from` in the direction of `to`.
843fn steer<P: PlanPoint>(from: P, to: P, step_size: Length) -> P {
844    let distance = from.distance(to);
845    if distance <= step_size {
846        return to;
847    }
848    from.lerp(to, ratio_of(step_size.raw() / distance.raw()))
849}
850
851#[cfg(test)]
852mod tests {
853    use super::*;
854
855    fn start() -> Point2f {
856        Point2f::from_meters(0.5, 0.5)
857    }
858
859    fn goal() -> Point2f {
860        Point2f::from_meters(9.5, 9.5)
861    }
862
863    fn planner(seed: u64) -> RrtStar {
864        RrtStar::new(World::depot(), RrtParams::default(), start(), goal(), seed)
865    }
866
867    /// A box with one spherical obstacle in the middle. It exists to hold the
868    /// planner to its claim of being dimension-generic: the same [`RrtStar`]
869    /// code has to solve a 3D job with no 2D assumption left in it.
870    #[derive(Clone)]
871    struct Room {
872        bounds: cu_spatial_payloads::BBox3f,
873        center: Point3f,
874        radius: Length,
875    }
876
877    impl Room {
878        fn new() -> Self {
879            Self {
880                bounds: cu_spatial_payloads::BBox3f::new(
881                    Point3f::from_meters(0.0, 0.0, 0.0),
882                    Point3f::from_meters(10.0, 10.0, 10.0),
883                ),
884                // On the diagonal, so the straight line start-to-goal is blocked.
885                center: Point3f::from_meters(5.0, 5.0, 5.0),
886                radius: Length::new::<meter>(1.5),
887            }
888        }
889    }
890
891    impl Clearance for Room {
892        type Point = Point3f;
893
894        fn clearance(&self, p: Point3f) -> Length {
895            let b = &self.bounds;
896            let walls = (p.x - b.min.x)
897                .raw()
898                .min((b.max.x - p.x).raw())
899                .min((p.y - b.min.y).raw())
900                .min((b.max.y - p.y).raw())
901                .min((p.z - b.min.z).raw())
902                .min((b.max.z - p.z).raw());
903            let sphere = p.distance(self.center).raw() - self.radius.raw();
904            Length::new::<meter>(walls.min(sphere))
905        }
906
907        fn clearance_segment(&self, a: Point3f, b: Point3f) -> Length {
908            let ends = self.clearance(a).raw().min(self.clearance(b).raw());
909            let sphere = distance_to_segment(a, b, self.center).raw() - self.radius.raw();
910            meters(ends.min(sphere))
911        }
912    }
913
914    impl RrtSpace for Room {
915        fn sample(&self, rng: &mut CuRng) -> Point3f {
916            let b = &self.bounds;
917            Point3f::new(
918                b.min.x + (b.max.x - b.min.x) * rng.random::<f32>(),
919                b.min.y + (b.max.y - b.min.y) * rng.random::<f32>(),
920                b.min.z + (b.max.z - b.min.z) * rng.random::<f32>(),
921            )
922        }
923
924        /// The 3D instance of the constant: `d = 3` and `zeta_3 = 4/3 pi`.
925        fn rrt_star_gamma(&self) -> Length {
926            let b = &self.bounds;
927            let side = |min: Length, max: Length| (max - min).raw();
928            let volume = side(b.min.x, b.max.x) * side(b.min.y, b.max.y) * side(b.min.z, b.max.z)
929                - 4.0 / 3.0 * core::f32::consts::PI * self.radius.raw().powi(3);
930            let zeta_3 = 4.0 / 3.0 * core::f32::consts::PI;
931            Length::new::<meter>(2.0 * (4.0f32 / 3.0).cbrt() * (volume / zeta_3).cbrt())
932        }
933    }
934
935    /// The planner is generic over the dimension, not just written as if it
936    /// were: this drives the whole algorithm - sample, steer, rewire, prune,
937    /// shortcut - over `Point3f` and checks the published path is drivable.
938    #[test]
939    fn the_same_planner_solves_a_3d_job() {
940        let room = Room::new();
941        let start = Point3f::from_meters(0.5, 0.5, 0.5);
942        let goal = Point3f::from_meters(9.5, 9.5, 9.5);
943        assert!(
944            room.clearance_segment(start, goal) <= meters(0.0),
945            "the straight line should be blocked, or the job is trivial"
946        );
947
948        let params = RrtParams {
949            step_size: meters(1.2),
950            ..Default::default()
951        };
952        let mut planner = RrtStar::new(room.clone(), params, start, goal, 5);
953        planner.grow(4000);
954        assert!(planner.has_solution(), "no 3D path found");
955        assert_eq!(planner.positions.len(), planner.tree.len());
956
957        let mut waypoints = [Point3f::default(); MAX_WAYPOINTS];
958        let len = planner.write_path(&mut waypoints).expect("the path fits");
959        assert!(len >= 2);
960        assert_eq!(waypoints[0], start);
961        assert_eq!(waypoints[(len - 1) as usize], goal);
962        for pair in waypoints[..len as usize].windows(2) {
963            assert!(
964                room.clearance_segment(pair[0], pair[1]) > meters(0.0),
965                "the published 3D path crosses the sphere"
966            );
967        }
968        assert!(planner.best_cost() >= planner.lower_bound());
969    }
970
971    #[test]
972    fn world_rejects_too_many_obstacles() {
973        let radius = Length::new::<meter>(0.1);
974        let bounds = BBox2f::new(
975            Point2f::from_meters(0.0, 0.0),
976            Point2f::from_meters(10.0, 10.0),
977        );
978        let too_many = [Obstacle::new(Point2f::from_meters(1.0, 1.0), radius); MAX_OBSTACLES + 1];
979        assert!(World::new(bounds, &too_many).is_err());
980        assert!(World::new(bounds, &too_many[..MAX_OBSTACLES]).is_ok());
981    }
982
983    #[test]
984    fn clearance_signs_match_the_geometry() {
985        let world = World::depot();
986        let point = Point2f::from_meters;
987        // Straight through the pillar at (3, 3).
988        assert!(world.clearance_segment(point(1.0, 1.0), point(5.0, 5.0)) <= meters(0.0));
989        // Along the free bottom edge.
990        assert!(world.clearance_segment(point(0.2, 0.2), point(0.2, 9.8)) > meters(0.0));
991        // An endpoint out of bounds.
992        assert!(world.clearance_segment(start(), point(11.0, 0.5)) <= meters(0.0));
993        // Inside a pillar the clearance goes negative.
994        assert!(world.clearance(point(3.0, 3.0)) < meters(0.0));
995    }
996
997    #[test]
998    fn refinement_only_improves_the_path() {
999        let mut planner = planner(42);
1000        planner.grow(400);
1001        assert!(planner.has_solution(), "no first path after the base block");
1002
1003        let mut previous = planner.best_cost();
1004        for _ in 0..16 {
1005            planner.grow(256);
1006            assert!(
1007                planner.best_cost() <= previous + meters(1e-4),
1008                "cost went up: {:?} then {:?}",
1009                previous,
1010                planner.best_cost()
1011            );
1012            previous = planner.best_cost();
1013        }
1014        assert!(planner.quality() > ratio_of(0.0) && planner.quality() <= ratio_of(1.0));
1015        assert!(planner.best_cost() >= planner.lower_bound());
1016    }
1017
1018    /// Every published path must be drivable, at every stop point the anytime
1019    /// policy could pick.
1020    ///
1021    /// A small `gamma` is included on purpose: it barely rewires, so its tree
1022    /// paths grow past [`MAX_WAYPOINTS`] and the shortcut is exercised rather
1023    /// than skipped. The same sweep doubles as the gamma comparison: a gamma
1024    /// below the value the free area implies shrinks the rewiring
1025    /// neighborhood, and refinement then converges to a worse path.
1026    #[test]
1027    fn published_path_is_valid_at_every_stop_point() {
1028        let world = World::depot();
1029        let derived = world.rrt_star_gamma().raw();
1030        assert!(
1031            (12.0..13.0).contains(&derived),
1032            "gamma for the depot map should be near 12.4 m, got {derived}"
1033        );
1034
1035        let mut longest_tree_path = 0;
1036        // Total refined cost over all seeds, per gamma: the derived one first.
1037        let mut total_cost = [meters(0.0); 2];
1038        for (seed, index) in (1..40u64).flat_map(|seed| [(seed, 0usize), (seed, 1)]) {
1039            let params = RrtParams {
1040                gamma: [meters(0.0), meters(3.0)][index],
1041                ..Default::default()
1042            };
1043            let mut planner = RrtStar::new(World::depot(), params, start(), goal(), seed);
1044            planner.grow(400);
1045            for _ in 0..24 {
1046                planner.grow(256);
1047                let mut waypoints = [Point2f::default(); MAX_WAYPOINTS];
1048                let Some(len) = planner.write_path(&mut waypoints) else {
1049                    panic!("seed {seed}: the shortcut path did not fit");
1050                };
1051                longest_tree_path = longest_tree_path.max(planner.tree_path_len());
1052                assert!(len >= 2, "a path has at least a start and a goal");
1053                assert_eq!(waypoints[0], start());
1054                assert_eq!(waypoints[(len - 1) as usize], goal());
1055                for pair in waypoints[..len as usize].windows(2) {
1056                    assert!(
1057                        world.clearance_segment(pair[0], pair[1]) > meters(0.0),
1058                        "seed {seed}: published path crosses an obstacle"
1059                    );
1060                }
1061                // The shortcut only removes waypoints it can bypass in a
1062                // straight free line, so it never lengthens the path.
1063                let published = waypoints[..len as usize]
1064                    .windows(2)
1065                    .fold(meters(0.0), |sum, pair| sum + pair[0].distance(pair[1]));
1066                assert!(
1067                    published <= planner.best_cost() + meters(1e-3),
1068                    "seed {seed}: shortcut path {:?} longer than the cost {:?}",
1069                    published,
1070                    planner.best_cost()
1071                );
1072            }
1073            total_cost[index] += planner.best_cost();
1074        }
1075        assert!(
1076            longest_tree_path > MAX_WAYPOINTS,
1077            "the tree path never outgrew MAX_WAYPOINTS, so the shortcut was never exercised"
1078        );
1079        assert!(
1080            total_cost[0] < total_cost[1],
1081            "the derived gamma should refine to a shorter path than a small one"
1082        );
1083    }
1084
1085    /// A job with the start on the goal is solved by definition: quality 1.0,
1086    /// never NaN from the zero-by-zero ratio.
1087    #[test]
1088    fn degenerate_job_reports_full_quality() {
1089        let mut planner = RrtStar::new(World::depot(), RrtParams::default(), start(), start(), 3);
1090        planner.grow(400);
1091        assert!(planner.has_solution());
1092        assert_eq!(planner.quality(), ratio_of(1.0));
1093    }
1094
1095    #[test]
1096    fn same_seed_replays_the_same_tree() {
1097        let (mut a, mut b) = (planner(11), planner(11));
1098        a.grow(600);
1099        b.grow(300);
1100        b.grow(300);
1101        assert_eq!(a.tree_size(), b.tree_size());
1102        assert_eq!(a.best_cost(), b.best_cost());
1103    }
1104
1105    #[test]
1106    fn pruning_keeps_the_best_path_reachable() {
1107        let mut planner = planner(3);
1108        planner.grow(1500);
1109        let cost_before = planner.best_cost();
1110        // Positions and topology are indexed together, so they must stay the
1111        // same length across a compaction.
1112        assert_eq!(planner.positions.len(), planner.tree.len());
1113        planner.prune();
1114        assert_eq!(planner.positions.len(), planner.tree.len());
1115        assert!(planner.has_solution(), "pruning dropped the goal node");
1116        // The tree stays consistent: every node still reaches the root.
1117        for index in 0..planner.tree.len() {
1118            let mut cursor = Some(index as u32);
1119            let mut hops = 0;
1120            while let Some(current) = cursor {
1121                cursor = planner.tree[current as usize].parent;
1122                hops += 1;
1123                assert!(hops <= planner.tree.len(), "cycle in the tree");
1124            }
1125        }
1126        assert_eq!(planner.best_cost(), cost_before);
1127    }
1128
1129    /// A config asking for more nodes than the position set holds must cap,
1130    /// not overrun the fixed capacity.
1131    #[test]
1132    fn max_nodes_is_clamped_to_the_soa_capacity() {
1133        let params = RrtParams {
1134            max_nodes: MAX_NODES as u32 * 4,
1135            // No pruning, so the tree can only grow into the cap.
1136            prune_interval: 0,
1137            ..Default::default()
1138        };
1139        let mut planner = RrtStar::new(World::depot(), params, start(), goal(), 7);
1140        assert_eq!(planner.params.max_nodes, MAX_NODES as u32);
1141        planner.grow(MAX_NODES as u32 * 2);
1142        assert!(planner.tree.len() <= MAX_NODES);
1143        assert_eq!(planner.positions.len(), planner.tree.len());
1144        assert!(
1145            planner.is_exhausted(),
1146            "the tree should have filled the cap"
1147        );
1148    }
1149}