Skip to main content

condor_grid/algorithms/
rectangular_symmetry_reduction.rs

1//! Static-grid [`Pathfinder`]: Rectangular Symmetry Reduction (RSR).
2//!
3//! Each search builds a transient room decomposition, searches perimeter and macro
4//! edges, and materializes a concrete path with the standard invalid/found/no-path
5//! outcome. Its final cost recomputes per-cell [`traversal_cost`](crate::Grid::traversal_cost)
6//! over path steps. Prefer [`super::astar::AStar`] as the general default; use RSR
7//! only when the grid’s rectangular free space is the relevant structure.
8
9use std::{cmp::Ordering, collections::BinaryHeap};
10
11use super::dijkstra::Dijkstra;
12use crate::{
13    grid::Grid,
14    path::Path,
15    point::Point,
16    search::{Pathfinder, SearchRequest, SearchResult},
17};
18
19/// Online [`Pathfinder`]: Rectangular Symmetry Reduction (RSR).
20///
21/// Per-search room decomposition (no durable preprocess); searches perimeter/macro
22/// edges then materializes cardinal segments. Final path cost recomputes `traversal_cost`
23/// over steps. Prefer on large rectangular free spaces; falls back to Dijkstra on
24/// mismatched room-path costs.
25#[derive(Debug, Default, Clone, Copy)]
26pub struct RectangularSymmetryReduction;
27
28impl Pathfinder for RectangularSymmetryReduction {
29    fn name(&self) -> &'static str {
30        "rectangular-symmetry-reduction"
31    }
32
33    fn search(&self, grid: &Grid, request: SearchRequest) -> SearchResult {
34        crate::search::validate_request(grid, request)?;
35        let Some(start_index) = grid.index_of(request.start) else {
36            return crate::search::not_found(0);
37        };
38        let Some(goal_index) = grid.index_of(request.goal) else {
39            return crate::search::not_found(0);
40        };
41
42        if !grid.is_walkable(request.start) || !grid.is_walkable(request.goal) {
43            return crate::search::not_found(0);
44        }
45
46        if !grid.is_reachable(request.start, request.goal) {
47            return crate::search::not_found(0);
48        }
49
50        if request.start == request.goal {
51            return crate::search::found(
52                Path::from_steps(vec![request.start]).expect("path contains at least one point"),
53                1,
54            );
55        }
56
57        let decomposition = RoomDecomposition::new(grid);
58        let Some(start_room_id) = decomposition.room_id(start_index) else {
59            return crate::search::not_found(0);
60        };
61        let Some(goal_room_id) = decomposition.room_id(goal_index) else {
62            return crate::search::not_found(0);
63        };
64        let start_room = decomposition.room(start_room_id);
65        let goal_room = decomposition.room(goal_room_id);
66
67        if start_room_id == goal_room_id {
68            let steps = direct_room_path(request.start, request.goal);
69            let cost = weighted_path_cost(grid, &steps);
70            if cost > manhattan_distance(request.start, request.goal) {
71                return Dijkstra.search(grid, request);
72            }
73            return crate::search::found(
74                Path::from_steps_with_cost(steps, cost).expect("path contains at least one point"),
75                1,
76            );
77        }
78
79        let start_is_inserted = !start_room.is_kept(request.start);
80        let goal_is_inserted = !goal_room.is_kept(request.goal);
81        let endpoints = SearchEndpoints {
82            start: request.start,
83            start_index,
84            start_is_inserted,
85            goal: request.goal,
86            goal_index,
87            goal_is_inserted,
88        };
89        let start_adapter = SpecialAdapter::new(
90            grid,
91            &decomposition,
92            endpoints.start,
93            endpoints.start_index,
94            endpoints.start_is_inserted,
95        );
96        let goal_adapter = SpecialAdapter::new(
97            grid,
98            &decomposition,
99            endpoints.goal,
100            endpoints.goal_index,
101            endpoints.goal_is_inserted,
102        );
103        let adapters = EndpointAdapters {
104            start: start_adapter.as_ref(),
105            goal: goal_adapter.as_ref(),
106        };
107        let mut frontier = BinaryHeap::from([FrontierEntry {
108            estimated_total_cost: manhattan_distance(request.start, request.goal),
109            cost_so_far: 0,
110            index: start_index,
111        }]);
112        let mut best_costs = vec![usize::MAX; grid.cell_count()];
113        let mut parents = std::iter::repeat_with(|| None)
114            .take(grid.cell_count())
115            .collect::<Vec<Option<usize>>>();
116        let mut visited_nodes = 0;
117        let watch = crate::search::BudgetWatch::start(request.budget);
118
119        best_costs[start_index] = 0;
120
121        while let Some(entry) = frontier.pop() {
122            if entry.cost_so_far != best_costs[entry.index] {
123                continue;
124            }
125
126            visited_nodes += 1;
127            if entry.index == goal_index {
128                break;
129            }
130
131            if let Err(reason) = watch.check(visited_nodes) {
132                return Err(crate::search::budget_error(reason));
133            }
134
135            let current = grid.point_from_index(entry.index);
136            for_each_successor(
137                grid,
138                &decomposition,
139                current,
140                entry.index,
141                endpoints,
142                adapters,
143                |target_index, edge_cost| {
144                    let next_cost = entry.cost_so_far + edge_cost;
145                    if next_cost >= best_costs[target_index] {
146                        return;
147                    }
148
149                    best_costs[target_index] = next_cost;
150                    parents[target_index] = Some(entry.index);
151
152                    let target = grid.point_from_index(target_index);
153                    frontier.push(FrontierEntry {
154                        estimated_total_cost: next_cost + manhattan_distance(target, request.goal),
155                        cost_so_far: next_cost,
156                        index: target_index,
157                    });
158                },
159            );
160        }
161
162        if best_costs[goal_index] == usize::MAX {
163            return crate::search::not_found(visited_nodes);
164        }
165
166        crate::search::found(
167            reconstruct_path(grid, request.start, goal_index, &parents),
168            visited_nodes,
169        )
170    }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174struct FrontierEntry {
175    estimated_total_cost: usize,
176    cost_so_far: usize,
177    index: usize,
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181struct SearchEndpoints {
182    start: Point,
183    start_index: usize,
184    start_is_inserted: bool,
185    goal: Point,
186    goal_index: usize,
187    goal_is_inserted: bool,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191struct EndpointAdapters<'a> {
192    start: Option<&'a SpecialAdapter>,
193    goal: Option<&'a SpecialAdapter>,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
197struct SpecialAdapter {
198    room_id: usize,
199    special: Point,
200    special_index: usize,
201    forward_targets: [(usize, usize); 4],
202    forward_len: usize,
203    back_connectors: [usize; 4],
204    back_len: usize,
205}
206
207impl SpecialAdapter {
208    fn new(
209        grid: &Grid,
210        decomposition: &RoomDecomposition,
211        special: Point,
212        special_index: usize,
213        is_inserted: bool,
214    ) -> Option<Self> {
215        if !is_inserted {
216            return None;
217        }
218
219        let room_id = decomposition
220            .room_id(special_index)
221            .expect("inserted special nodes must belong to a room");
222        let room = decomposition.room(room_id);
223        let mut forward_targets = [(0usize, 0usize); 4];
224        let mut back_connectors = [0usize; 4];
225        let mut forward_len = 0usize;
226        let mut back_len = 0usize;
227
228        room.for_each_connection_point(special, |target| {
229            let target_index = grid
230                .index_of(target)
231                .expect("room connection points must exist inside the grid");
232            forward_targets[forward_len] = (target_index, manhattan_distance(special, target));
233            back_connectors[back_len] = target_index;
234            forward_len += 1;
235            back_len += 1;
236        });
237
238        Some(Self {
239            room_id,
240            special,
241            special_index,
242            forward_targets,
243            forward_len,
244            back_connectors,
245            back_len,
246        })
247    }
248
249    fn for_each_forward_edge(&self, mut f: impl FnMut(usize, usize)) {
250        for (target_index, edge_cost) in self.forward_targets[..self.forward_len].iter().copied() {
251            f(target_index, edge_cost);
252        }
253    }
254
255    fn maybe_emit_back_edge(
256        &self,
257        current_room_id: usize,
258        current_index: usize,
259        current: Point,
260        mut f: impl FnMut(usize, usize),
261    ) {
262        if current_room_id != self.room_id {
263            return;
264        }
265
266        if self.back_connectors[..self.back_len]
267            .iter()
268            .all(|connector| *connector != current_index)
269        {
270            return;
271        }
272
273        f(
274            self.special_index,
275            manhattan_distance(current, self.special),
276        );
277    }
278}
279
280impl Ord for FrontierEntry {
281    fn cmp(&self, other: &Self) -> Ordering {
282        other
283            .estimated_total_cost
284            .cmp(&self.estimated_total_cost)
285            .then_with(|| other.cost_so_far.cmp(&self.cost_so_far))
286            .then_with(|| other.index.cmp(&self.index))
287    }
288}
289
290impl PartialOrd for FrontierEntry {
291    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
292        Some(self.cmp(other))
293    }
294}
295
296#[derive(Debug, Clone, PartialEq, Eq)]
297struct Room {
298    x1: usize,
299    y1: usize,
300    x2: usize,
301    y2: usize,
302    trivial: bool,
303    left_side: Vec<SideEntry>,
304    right_side: Vec<SideEntry>,
305    top_side: Vec<SideEntry>,
306    bottom_side: Vec<SideEntry>,
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310struct SideEntry {
311    index: usize,
312    coordinate: usize,
313}
314
315impl Room {
316    fn width(&self) -> usize {
317        self.x2 - self.x1 + 1
318    }
319
320    fn height(&self) -> usize {
321        self.y2 - self.y1 + 1
322    }
323
324    fn is_trivial(&self) -> bool {
325        self.trivial
326    }
327
328    fn is_perimeter(&self, point: Point) -> bool {
329        point.x == self.x1 || point.x == self.x2 || point.y == self.y1 || point.y == self.y2
330    }
331
332    fn is_kept(&self, point: Point) -> bool {
333        self.is_trivial() || self.is_perimeter(point)
334    }
335
336    fn for_each_connection_point(&self, point: Point, mut f: impl FnMut(Point)) {
337        let mut seen = [None; 4];
338        let mut seen_len = 0usize;
339
340        for candidate in [
341            Point::new(self.x1, point.y),
342            Point::new(self.x2, point.y),
343            Point::new(point.x, self.y1),
344            Point::new(point.x, self.y2),
345        ] {
346            if seen[..seen_len]
347                .iter()
348                .flatten()
349                .any(|prior| *prior == candidate)
350            {
351                continue;
352            }
353
354            seen[seen_len] = Some(candidate);
355            seen_len += 1;
356            f(candidate);
357        }
358    }
359
360    fn emit_materialized_macro_edges(
361        &self,
362        point: Point,
363        point_index: usize,
364        side_mask: u8,
365        mut f: impl FnMut(usize, usize),
366    ) {
367        if self.is_trivial() || side_mask == 0 {
368            return;
369        }
370
371        if side_mask & SIDE_LEFT != 0 {
372            let base_cost = self.width() - 1;
373            for entry in &self.right_side {
374                if entry.index != point_index {
375                    f(entry.index, base_cost + point.y.abs_diff(entry.coordinate));
376                }
377            }
378        }
379
380        if side_mask & SIDE_RIGHT != 0 {
381            let base_cost = self.width() - 1;
382            for entry in &self.left_side {
383                if entry.index != point_index {
384                    f(entry.index, base_cost + point.y.abs_diff(entry.coordinate));
385                }
386            }
387        }
388
389        if side_mask & SIDE_TOP != 0 {
390            let skipped_coordinate = if side_mask & SIDE_LEFT != 0 {
391                Some(self.x2)
392            } else if side_mask & SIDE_RIGHT != 0 {
393                Some(self.x1)
394            } else {
395                None
396            };
397            let base_cost = self.height() - 1;
398            for entry in &self.bottom_side {
399                if Some(entry.coordinate) == skipped_coordinate || entry.index == point_index {
400                    continue;
401                }
402                f(entry.index, base_cost + point.x.abs_diff(entry.coordinate));
403            }
404        }
405
406        if side_mask & SIDE_BOTTOM != 0 {
407            let skipped_coordinate = if side_mask & SIDE_LEFT != 0 {
408                Some(self.x2)
409            } else if side_mask & SIDE_RIGHT != 0 {
410                Some(self.x1)
411            } else {
412                None
413            };
414            let base_cost = self.height() - 1;
415            for entry in &self.top_side {
416                if Some(entry.coordinate) == skipped_coordinate || entry.index == point_index {
417                    continue;
418                }
419                f(entry.index, base_cost + point.x.abs_diff(entry.coordinate));
420            }
421        }
422    }
423}
424
425const SIDE_LEFT: u8 = 1;
426const SIDE_RIGHT: u8 = 1 << 1;
427const SIDE_TOP: u8 = 1 << 2;
428const SIDE_BOTTOM: u8 = 1 << 3;
429
430#[derive(Debug, Clone, PartialEq, Eq)]
431struct RoomDecomposition {
432    rooms: Vec<Room>,
433    room_by_index: Vec<Option<usize>>,
434    search_node_by_index: Vec<bool>,
435    side_mask_by_index: Vec<u8>,
436}
437
438impl RoomDecomposition {
439    fn new(grid: &Grid) -> Self {
440        let mut assigned = vec![false; grid.cell_count()];
441        let mut room_by_index = vec![None; grid.cell_count()];
442        let mut search_node_by_index = vec![false; grid.cell_count()];
443        let mut side_mask_by_index = vec![0u8; grid.cell_count()];
444        let mut rooms = Vec::new();
445
446        for y in 0..grid.height() {
447            for x in 0..grid.width() {
448                let point = Point::new(x, y);
449                let Some(index) = grid.index_of(point) else {
450                    continue;
451                };
452
453                if assigned[index] || !grid.is_walkable(point) {
454                    continue;
455                }
456
457                let (width, height) = best_room_from(grid, &assigned, point);
458                let x2 = x + width - 1;
459                let y2 = y + height - 1;
460                let trivial = width <= 2 || height <= 2;
461                let mut left_side = Vec::new();
462                let mut right_side = Vec::new();
463                let mut top_side = Vec::new();
464                let mut bottom_side = Vec::new();
465                let room = Room {
466                    x1: x,
467                    y1: y,
468                    x2,
469                    y2,
470                    trivial,
471                    left_side: Vec::new(),
472                    right_side: Vec::new(),
473                    top_side: Vec::new(),
474                    bottom_side: Vec::new(),
475                };
476                let room_id = rooms.len();
477
478                for yy in room.y1..=room.y2 {
479                    for xx in room.x1..=room.x2 {
480                        let member = Point::new(xx, yy);
481                        let member_index = grid
482                            .index_of(member)
483                            .expect("room members must exist inside the grid");
484                        assigned[member_index] = true;
485                        room_by_index[member_index] = Some(room_id);
486
487                        let mut side_mask = 0u8;
488                        if xx == room.x1 {
489                            side_mask |= SIDE_LEFT;
490                        }
491                        if xx == room.x2 {
492                            side_mask |= SIDE_RIGHT;
493                        }
494                        if yy == room.y1 {
495                            side_mask |= SIDE_TOP;
496                        }
497                        if yy == room.y2 {
498                            side_mask |= SIDE_BOTTOM;
499                        }
500
501                        if trivial || side_mask != 0 {
502                            search_node_by_index[member_index] = true;
503                        }
504                        side_mask_by_index[member_index] = side_mask;
505
506                        if !trivial {
507                            if side_mask & SIDE_LEFT != 0 {
508                                left_side.push(SideEntry {
509                                    index: member_index,
510                                    coordinate: yy,
511                                });
512                            }
513                            if side_mask & SIDE_RIGHT != 0 {
514                                right_side.push(SideEntry {
515                                    index: member_index,
516                                    coordinate: yy,
517                                });
518                            }
519                            if side_mask & SIDE_TOP != 0 {
520                                top_side.push(SideEntry {
521                                    index: member_index,
522                                    coordinate: xx,
523                                });
524                            }
525                            if side_mask & SIDE_BOTTOM != 0 {
526                                bottom_side.push(SideEntry {
527                                    index: member_index,
528                                    coordinate: xx,
529                                });
530                            }
531                        }
532                    }
533                }
534
535                rooms.push(Room {
536                    left_side,
537                    right_side,
538                    top_side,
539                    bottom_side,
540                    ..room
541                });
542            }
543        }
544
545        Self {
546            rooms,
547            room_by_index,
548            search_node_by_index,
549            side_mask_by_index,
550        }
551    }
552
553    fn room_id(&self, index: usize) -> Option<usize> {
554        self.room_by_index[index]
555    }
556
557    fn room(&self, room_id: usize) -> &Room {
558        &self.rooms[room_id]
559    }
560
561    fn is_search_node(&self, index: usize, start_index: usize, goal_index: usize) -> bool {
562        index == start_index || index == goal_index || self.search_node_by_index[index]
563    }
564
565    fn side_mask(&self, index: usize) -> u8 {
566        self.side_mask_by_index[index]
567    }
568}
569
570fn best_room_from(grid: &Grid, assigned: &[bool], origin: Point) -> (usize, usize) {
571    let mut best_width = 1;
572    let mut best_height = 1;
573    let mut best_interior = 0usize;
574    let mut best_area = 1usize;
575    let mut min_width = usize::MAX;
576
577    for y in origin.y..grid.height() {
578        let row_width = contiguous_unassigned_width(grid, assigned, origin.x, y);
579        if row_width == 0 {
580            break;
581        }
582
583        min_width = min_width.min(row_width);
584        let height = y - origin.y + 1;
585        let area = min_width * height;
586        let interior = interior_node_count(min_width, height);
587
588        if (interior, area) > (best_interior, best_area) {
589            best_interior = interior;
590            best_area = area;
591            best_width = min_width;
592            best_height = height;
593        }
594    }
595
596    (best_width, best_height)
597}
598
599fn contiguous_unassigned_width(grid: &Grid, assigned: &[bool], start_x: usize, y: usize) -> usize {
600    let mut width = 0;
601    for x in start_x..grid.width() {
602        let point = Point::new(x, y);
603        if !grid.is_walkable(point) {
604            break;
605        }
606
607        let index = grid
608            .index_of(point)
609            .expect("walkable points must exist inside the grid");
610        if assigned[index] {
611            break;
612        }
613
614        width += 1;
615    }
616    width
617}
618
619fn interior_node_count(width: usize, height: usize) -> usize {
620    width.saturating_sub(2) * height.saturating_sub(2)
621}
622
623fn for_each_successor(
624    grid: &Grid,
625    decomposition: &RoomDecomposition,
626    current: Point,
627    current_index: usize,
628    endpoints: SearchEndpoints,
629    adapters: EndpointAdapters<'_>,
630    mut f: impl FnMut(usize, usize),
631) {
632    let room_id = decomposition
633        .room_id(current_index)
634        .expect("walkable search nodes must belong to a room");
635    let room = decomposition.room(room_id);
636
637    for_each_cardinal_successor(
638        grid,
639        decomposition,
640        current,
641        current_index,
642        endpoints,
643        &mut f,
644    );
645
646    if room.is_trivial() {
647        return;
648    }
649
650    if current_index == endpoints.start_index
651        && let Some(adapter) = adapters.start
652    {
653        adapter.for_each_forward_edge(&mut f);
654    }
655
656    if current_index == endpoints.goal_index
657        && let Some(adapter) = adapters.goal
658    {
659        adapter.for_each_forward_edge(&mut f);
660    }
661
662    let side_mask = decomposition.side_mask(current_index);
663    if side_mask != 0 {
664        room.emit_materialized_macro_edges(current, current_index, side_mask, &mut f);
665
666        if let Some(adapter) = adapters.start {
667            adapter.maybe_emit_back_edge(room_id, current_index, current, &mut f);
668        }
669
670        if let Some(adapter) = adapters.goal {
671            adapter.maybe_emit_back_edge(room_id, current_index, current, &mut f);
672        }
673    }
674}
675
676fn for_each_cardinal_successor(
677    grid: &Grid,
678    decomposition: &RoomDecomposition,
679    current: Point,
680    current_index: usize,
681    endpoints: SearchEndpoints,
682    mut f: impl FnMut(usize, usize),
683) {
684    if current.x > 0 {
685        let target_index = current_index - 1;
686        if decomposition.is_search_node(target_index, endpoints.start_index, endpoints.goal_index) {
687            f(target_index, 1);
688        }
689    }
690
691    if current.x + 1 < grid.width() {
692        let target_index = current_index + 1;
693        if decomposition.is_search_node(target_index, endpoints.start_index, endpoints.goal_index) {
694            f(target_index, 1);
695        }
696    }
697
698    if current.y > 0 {
699        let target_index = current_index - grid.width();
700        if decomposition.is_search_node(target_index, endpoints.start_index, endpoints.goal_index) {
701            f(target_index, 1);
702        }
703    }
704
705    if current.y + 1 < grid.height() {
706        let target_index = current_index + grid.width();
707        if decomposition.is_search_node(target_index, endpoints.start_index, endpoints.goal_index) {
708            f(target_index, 1);
709        }
710    }
711}
712
713fn straight_segment(from: Point, to: Point) -> Vec<Point> {
714    let mut points = Vec::with_capacity(manhattan_distance(from, to));
715    let mut current = from;
716
717    while current.x != to.x {
718        current = if current.x < to.x {
719            Point::new(current.x + 1, current.y)
720        } else {
721            Point::new(current.x - 1, current.y)
722        };
723        points.push(current);
724    }
725
726    while current.y != to.y {
727        current = if current.y < to.y {
728            Point::new(current.x, current.y + 1)
729        } else {
730            Point::new(current.x, current.y - 1)
731        };
732        points.push(current);
733    }
734
735    points
736}
737
738fn direct_room_path(start: Point, goal: Point) -> Vec<Point> {
739    let mut steps = vec![start];
740    steps.extend(straight_segment(start, goal));
741    steps
742}
743
744fn reconstruct_path(
745    grid: &Grid,
746    start: Point,
747    goal_index: usize,
748    parents: &[Option<usize>],
749) -> Path {
750    let mut segments = Vec::new();
751    let mut current_index = goal_index;
752
753    while let Some(parent_index) = parents[current_index] {
754        let parent = grid.point_from_index(parent_index);
755        let current = grid.point_from_index(current_index);
756        segments.push(straight_segment(parent, current));
757        current_index = parent_index;
758    }
759
760    let mut steps = vec![start];
761    for segment in segments.iter().rev() {
762        steps.extend(segment.iter().copied());
763    }
764
765    let cost = weighted_path_cost(grid, &steps);
766    Path::from_steps_with_cost(steps, cost).expect("path contains at least one point")
767}
768
769fn weighted_path_cost(grid: &Grid, steps: &[Point]) -> usize {
770    steps.iter().skip(1).fold(0usize, |total, point| {
771        total.saturating_add(grid.traversal_cost(*point).unwrap_or(1))
772    })
773}
774
775fn manhattan_distance(from: Point, to: Point) -> usize {
776    from.x.abs_diff(to.x) + from.y.abs_diff(to.y)
777}
778
779#[cfg(test)]
780mod tests {
781    use crate::{
782        algorithms::rectangular_symmetry_reduction::RectangularSymmetryReduction,
783        grid::{Cell, Grid},
784        point::Point,
785        search::{Pathfinder, SearchRequest},
786    };
787
788    #[test]
789    fn charges_per_cell_traversal_cost_matching_dijkstra() {
790        let mut grid = Grid::new(8, 1).expect("grid dimensions are valid");
791        assert_eq!(grid.set_traversal_cost(Point::new(4, 0), 5), Ok(()));
792
793        let request = SearchRequest::new(Point::new(0, 0), Point::new(7, 0));
794        let result = RectangularSymmetryReduction.search(&grid, request);
795
796        assert!(result.as_ref().expect("valid search request").is_found());
797        assert_eq!(
798            result.as_ref().expect("valid search request").cost(),
799            Some(11)
800        );
801
802        let path = result
803            .as_ref()
804            .expect("valid search request")
805            .path()
806            .expect("path should exist");
807        let manual: usize = path
808            .steps()
809            .iter()
810            .skip(1)
811            .map(|p| grid.traversal_cost(*p).expect("walkable cell has cost"))
812            .sum();
813        assert_eq!(
814            Some(manual),
815            result.as_ref().expect("valid search request").cost()
816        );
817
818        let dijkstra_cost = crate::Dijkstra
819            .search(&grid, request)
820            .as_ref()
821            .expect("valid search request")
822            .cost();
823        assert_eq!(
824            result.as_ref().expect("valid search request").cost(),
825            dijkstra_cost
826        );
827    }
828
829    #[test]
830    fn finds_a_shortest_path_through_the_only_gap() {
831        let mut grid = Grid::new(5, 5).expect("grid dimensions are valid");
832        for y in 0..5 {
833            if y != 2 {
834                grid.set_cell(Point::new(2, y), Cell::Blocked)
835                    .expect("valid grid edit");
836            }
837        }
838
839        let rsr = RectangularSymmetryReduction;
840        let result = rsr.search(
841            &grid,
842            SearchRequest::new(Point::new(0, 0), Point::new(4, 4)),
843        );
844
845        assert!(result.as_ref().expect("valid search request").is_found());
846        assert_eq!(
847            result.as_ref().expect("valid search request").cost(),
848            Some(8)
849        );
850
851        let path = result
852            .as_ref()
853            .expect("valid search request")
854            .path()
855            .expect("path should exist");
856        assert_eq!(path.start(), Point::new(0, 0));
857        assert_eq!(path.goal(), Point::new(4, 4));
858        assert!(path.steps().contains(&Point::new(2, 2)));
859    }
860
861    #[test]
862    fn reports_when_no_path_exists() {
863        let mut grid = Grid::new(3, 3).expect("grid dimensions are valid");
864        for x in 0..3 {
865            grid.set_cell(Point::new(x, 1), Cell::Blocked)
866                .expect("valid grid edit");
867        }
868
869        let rsr = RectangularSymmetryReduction;
870        let result = rsr.search(
871            &grid,
872            SearchRequest::new(Point::new(0, 0), Point::new(2, 2)),
873        );
874
875        assert!(!result.as_ref().expect("valid search request").is_found());
876        assert_eq!(result.as_ref().expect("valid search request").cost(), None);
877        assert!(
878            result
879                .as_ref()
880                .expect("valid search request")
881                .stats()
882                .visited_nodes
883                > 0
884        );
885    }
886
887    #[test]
888    fn directly_connects_points_inside_the_same_empty_room() {
889        let grid = Grid::new(6, 5).expect("grid dimensions are valid");
890        let rsr = RectangularSymmetryReduction;
891        let result = rsr.search(
892            &grid,
893            SearchRequest::new(Point::new(1, 1), Point::new(4, 3)),
894        );
895
896        assert!(result.as_ref().expect("valid search request").is_found());
897        assert_eq!(
898            result.as_ref().expect("valid search request").cost(),
899            Some(5)
900        );
901        assert_eq!(
902            result
903                .as_ref()
904                .expect("valid search request")
905                .stats()
906                .visited_nodes,
907            1
908        );
909    }
910
911    #[test]
912    fn same_room_weighted_route_matches_dijkstra() {
913        let mut grid = Grid::new(3, 3).expect("grid dimensions are valid");
914        assert_eq!(grid.set_traversal_cost(Point::new(1, 1), 100), Ok(()));
915
916        let request = SearchRequest::new(Point::new(0, 1), Point::new(2, 1));
917        let result = RectangularSymmetryReduction.search(&grid, request);
918        let dijkstra = crate::Dijkstra.search(&grid, request);
919
920        assert!(result.as_ref().expect("valid search request").is_found());
921        assert_eq!(
922            result.as_ref().expect("valid search request").cost(),
923            Some(4)
924        );
925        assert_eq!(
926            result.as_ref().expect("valid search request").cost(),
927            dijkstra.as_ref().expect("valid search request").cost()
928        );
929        assert!(
930            !result
931                .as_ref()
932                .expect("valid search request")
933                .path()
934                .expect("path should exist")
935                .steps()
936                .contains(&Point::new(1, 1))
937        );
938    }
939
940    #[test]
941    fn connects_inserted_room_endpoints_through_a_doorway() {
942        let mut grid = Grid::new(8, 5).expect("grid dimensions are valid");
943        for y in 0..5 {
944            if y != 2 {
945                grid.set_cell(Point::new(4, y), Cell::Blocked)
946                    .expect("valid grid edit");
947            }
948        }
949
950        let rsr = RectangularSymmetryReduction;
951        let result = rsr.search(
952            &grid,
953            SearchRequest::new(Point::new(1, 1), Point::new(6, 3)),
954        );
955
956        assert!(result.as_ref().expect("valid search request").is_found());
957        assert_eq!(
958            result.as_ref().expect("valid search request").cost(),
959            Some(7)
960        );
961
962        let path = result
963            .as_ref()
964            .expect("valid search request")
965            .path()
966            .expect("path should exist");
967        assert_eq!(path.start(), Point::new(1, 1));
968        assert_eq!(path.goal(), Point::new(6, 3));
969        assert!(path.steps().contains(&Point::new(4, 2)));
970    }
971
972    #[test]
973    fn materializes_non_trivial_room_perimeter_metadata() {
974        let grid = Grid::new(4, 4).expect("grid dimensions are valid");
975        let decomposition = super::RoomDecomposition::new(&grid);
976        let room = decomposition.room(0);
977
978        assert!(!room.is_trivial());
979        assert_eq!(room.left_side.len(), 4);
980        assert_eq!(room.right_side.len(), 4);
981        assert_eq!(room.top_side.len(), 4);
982        assert_eq!(room.bottom_side.len(), 4);
983
984        let top_left = grid
985            .index_of(Point::new(0, 0))
986            .expect("top-left should exist");
987        let center = grid
988            .index_of(Point::new(1, 1))
989            .expect("center should exist");
990
991        assert_eq!(
992            decomposition.side_mask(top_left),
993            super::SIDE_LEFT | super::SIDE_TOP
994        );
995        assert!(decomposition.search_node_by_index[top_left]);
996        assert!(!decomposition.search_node_by_index[center]);
997    }
998
999    #[test]
1000    fn materializes_trivial_rooms_as_search_nodes_without_macro_sides() {
1001        let grid = Grid::new(2, 2).expect("grid dimensions are valid");
1002        let decomposition = super::RoomDecomposition::new(&grid);
1003        let room = decomposition.room(0);
1004
1005        assert!(room.is_trivial());
1006        assert!(room.left_side.is_empty());
1007        assert!(room.right_side.is_empty());
1008        assert!(room.top_side.is_empty());
1009        assert!(room.bottom_side.is_empty());
1010
1011        for y in 0..grid.height() {
1012            for x in 0..grid.width() {
1013                let index = grid
1014                    .index_of(Point::new(x, y))
1015                    .expect("grid point should exist");
1016                assert!(decomposition.search_node_by_index[index]);
1017            }
1018        }
1019    }
1020}