1use std::cmp::Ordering;
24use std::collections::{BinaryHeap, VecDeque};
25
26use rust_robotics_core::{RoboticsError, RoboticsResult};
27
28const SQRT2: f64 = std::f64::consts::SQRT_2;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Knowledge {
33 Unknown,
34 Free,
35 Occupied,
36}
37
38#[derive(Debug, Clone, PartialEq)]
40pub struct FrontierNavWorld {
41 pub width: i32,
42 pub height: i32,
43 pub occupied: Vec<Vec<bool>>,
45 pub start: (i32, i32),
46 pub goal: (i32, i32),
47}
48
49impl FrontierNavWorld {
50 pub fn new(
52 width: i32,
53 height: i32,
54 start: (i32, i32),
55 goal: (i32, i32),
56 ) -> RoboticsResult<Self> {
57 if width <= 0 || height <= 0 {
58 return Err(RoboticsError::InvalidParameter(
59 "frontier-nav world dimensions must be positive".to_string(),
60 ));
61 }
62 Ok(Self {
63 width,
64 height,
65 occupied: vec![vec![false; height as usize]; width as usize],
66 start,
67 goal,
68 })
69 }
70
71 fn in_bounds(&self, x: i32, y: i32) -> bool {
72 x >= 0 && y >= 0 && x < self.width && y < self.height
73 }
74
75 fn is_occupied(&self, x: i32, y: i32) -> bool {
76 !self.in_bounds(x, y) || self.occupied[x as usize][y as usize]
77 }
78
79 pub fn fill_rect(&mut self, min_x: i32, max_x: i32, min_y: i32, max_y: i32) {
81 for x in min_x.max(0)..=max_x.min(self.width - 1) {
82 for y in min_y.max(0)..=max_y.min(self.height - 1) {
83 self.occupied[x as usize][y as usize] = true;
84 }
85 }
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq)]
91pub struct FrontierChoice {
92 pub robot: (i32, i32),
93 pub frontier: (i32, i32),
94 pub score: f64,
95 pub line_of_sight: bool,
96}
97
98#[derive(Debug, Clone, PartialEq)]
100pub struct FrontierNavConfig {
101 pub sensor_range: f64,
103 pub step_budget: usize,
105 pub max_iterations: usize,
107 pub goal_weight: f64,
109 pub cost_weight: f64,
111 pub line_of_sight_weight: f64,
113 pub openness_weight: f64,
115}
116
117impl Default for FrontierNavConfig {
118 fn default() -> Self {
119 Self {
120 sensor_range: 5.0,
121 step_budget: 3,
122 max_iterations: 200,
123 goal_weight: 1.0,
124 cost_weight: 0.35,
125 line_of_sight_weight: 1.5,
126 openness_weight: 0.15,
127 }
128 }
129}
130
131#[derive(Debug, Clone, PartialEq)]
133pub struct FrontierNavReport {
134 pub path: Vec<(i32, i32)>,
135 pub reached_goal: bool,
136 pub path_length: f64,
137 pub frontier_selections: usize,
138 pub iterations: usize,
139 pub known_free_cells: usize,
140 pub selected_frontiers: Vec<FrontierChoice>,
141 pub known: Vec<Vec<Knowledge>>,
143}
144
145pub fn simulate_frontier_navigation(
147 world: &FrontierNavWorld,
148 config: &FrontierNavConfig,
149) -> RoboticsResult<FrontierNavReport> {
150 validate(world, config)?;
151
152 let (w, h) = (world.width, world.height);
153 let mut known = vec![vec![Knowledge::Unknown; h as usize]; w as usize];
154 let mut robot = world.start;
155 let mut path = vec![robot];
156 let mut path_length = 0.0;
157 let mut selected_frontiers: Vec<FrontierChoice> = Vec::new();
158 let mut reached_goal = false;
159 let mut iterations = 0;
160
161 sense(world, &mut known, robot, config.sensor_range);
162
163 for _ in 0..config.max_iterations {
164 iterations += 1;
165
166 if known[world.goal.0 as usize][world.goal.1 as usize] == Knowledge::Free {
168 if let Some(dist) = dijkstra_from(&known, world.goal) {
169 if dist[robot.0 as usize][robot.1 as usize].is_finite() {
170 follow_gradient(
171 &known,
172 &dist,
173 &mut robot,
174 world.goal,
175 usize::MAX,
176 &mut path,
177 &mut path_length,
178 );
179 if robot == world.goal {
180 reached_goal = true;
181 break;
182 }
183 }
184 }
185 }
186
187 let frontiers = compute_frontiers(&known);
188 if frontiers.is_empty() {
189 break;
190 }
191
192 let reach = match dijkstra_from(&known, robot) {
194 Some(field) => field,
195 None => break,
196 };
197
198 let goal_distance = euclid(robot, world.goal);
199 let max_openness = frontiers
200 .iter()
201 .map(|f| f.openness)
202 .max()
203 .unwrap_or(1)
204 .max(1) as f64;
205
206 let mut best: Option<(f64, &FrontierCluster)> = None;
207 for frontier in &frontiers {
208 let reach_cost = reach[frontier.rep.0 as usize][frontier.rep.1 as usize];
209 if !reach_cost.is_finite() {
210 continue;
211 }
212 let los = line_of_sight(&known, robot, frontier.rep);
213 let goal_gain = goal_distance - euclid(frontier.rep, world.goal);
214 let openness = frontier.openness as f64 / max_openness;
215 let score = config.goal_weight * goal_gain - config.cost_weight * reach_cost
216 + config.line_of_sight_weight * if los { 1.0 } else { 0.0 }
217 + config.openness_weight * openness;
218 let better = match best {
219 None => true,
220 Some((best_score, best_cluster)) => {
221 score > best_score || (score == best_score && frontier.rep < best_cluster.rep)
222 }
223 };
224 if better {
225 best = Some((score, frontier));
226 }
227 }
228
229 let Some((score, frontier)) = best else { break };
230 let target = frontier.rep;
231 selected_frontiers.push(FrontierChoice {
232 robot,
233 frontier: target,
234 score,
235 line_of_sight: line_of_sight(&known, robot, target),
236 });
237
238 let field = match dijkstra_from(&known, target) {
240 Some(field) => field,
241 None => break,
242 };
243 let before = robot;
244 follow_gradient(
245 &known,
246 &field,
247 &mut robot,
248 target,
249 config.step_budget,
250 &mut path,
251 &mut path_length,
252 );
253 if robot == before {
254 break; }
256 sense(world, &mut known, robot, config.sensor_range);
257 }
258
259 let known_free_cells = known
260 .iter()
261 .flatten()
262 .filter(|c| **c == Knowledge::Free)
263 .count();
264
265 Ok(FrontierNavReport {
266 path,
267 reached_goal,
268 path_length,
269 frontier_selections: selected_frontiers.len(),
270 iterations,
271 known_free_cells,
272 selected_frontiers,
273 known,
274 })
275}
276
277fn validate(world: &FrontierNavWorld, config: &FrontierNavConfig) -> RoboticsResult<()> {
278 if !world.in_bounds(world.start.0, world.start.1)
279 || world.is_occupied(world.start.0, world.start.1)
280 {
281 return Err(RoboticsError::InvalidParameter(
282 "frontier-nav start must be a free in-bounds cell".to_string(),
283 ));
284 }
285 if !world.in_bounds(world.goal.0, world.goal.1) || world.is_occupied(world.goal.0, world.goal.1)
286 {
287 return Err(RoboticsError::InvalidParameter(
288 "frontier-nav goal must be a free in-bounds cell".to_string(),
289 ));
290 }
291 if !config.sensor_range.is_finite() || config.sensor_range <= 0.0 {
292 return Err(RoboticsError::InvalidParameter(
293 "frontier-nav sensor range must be finite and positive".to_string(),
294 ));
295 }
296 if config.step_budget == 0 || config.max_iterations == 0 {
297 return Err(RoboticsError::InvalidParameter(
298 "frontier-nav step budget and iterations must be positive".to_string(),
299 ));
300 }
301 Ok(())
302}
303
304#[derive(Debug, Clone, PartialEq)]
306struct FrontierCluster {
307 rep: (i32, i32),
308 openness: usize,
309}
310
311fn sense(world: &FrontierNavWorld, known: &mut [Vec<Knowledge>], from: (i32, i32), range: f64) {
313 let r = range.ceil() as i32;
314 for dx in -r..=r {
315 for dy in -r..=r {
316 let x = from.0 + dx;
317 let y = from.1 + dy;
318 if !world.in_bounds(x, y) {
319 continue;
320 }
321 if ((dx * dx + dy * dy) as f64).sqrt() > range {
322 continue;
323 }
324 if visible(world, from, (x, y)) {
325 known[x as usize][y as usize] = if world.is_occupied(x, y) {
326 Knowledge::Occupied
327 } else {
328 Knowledge::Free
329 };
330 }
331 }
332 }
333}
334
335fn visible(world: &FrontierNavWorld, from: (i32, i32), target: (i32, i32)) -> bool {
337 let steps = (((target.0 - from.0).abs()).max((target.1 - from.1).abs()) * 3).max(1);
338 for i in 1..=steps {
339 let t = i as f64 / steps as f64;
340 let x = (from.0 as f64 + t * (target.0 - from.0) as f64).round() as i32;
341 let y = (from.1 as f64 + t * (target.1 - from.1) as f64).round() as i32;
342 if (x, y) == target {
343 return true;
344 }
345 if world.is_occupied(x, y) {
346 return false; }
348 }
349 true
350}
351
352fn line_of_sight(known: &[Vec<Knowledge>], from: (i32, i32), target: (i32, i32)) -> bool {
354 let steps = (((target.0 - from.0).abs()).max((target.1 - from.1).abs()) * 3).max(1);
355 for i in 1..=steps {
356 let t = i as f64 / steps as f64;
357 let x = (from.0 as f64 + t * (target.0 - from.0) as f64).round() as i32;
358 let y = (from.1 as f64 + t * (target.1 - from.1) as f64).round() as i32;
359 if (x, y) == target {
360 return true;
361 }
362 if known[x as usize][y as usize] == Knowledge::Occupied {
363 return false;
364 }
365 }
366 true
367}
368
369fn compute_frontiers(known: &[Vec<Knowledge>]) -> Vec<FrontierCluster> {
371 let w = known.len() as i32;
372 let h = known[0].len() as i32;
373 let is_free = |x: i32, y: i32| known[x as usize][y as usize] == Knowledge::Free;
374 let unknown_neighbors = |x: i32, y: i32| {
375 let mut count = 0;
376 for (dx, dy) in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
377 let nx = x + dx;
378 let ny = y + dy;
379 if nx >= 0
380 && ny >= 0
381 && nx < w
382 && ny < h
383 && known[nx as usize][ny as usize] == Knowledge::Unknown
384 {
385 count += 1;
386 }
387 }
388 count
389 };
390
391 let mut is_frontier = vec![vec![false; h as usize]; w as usize];
392 for x in 0..w {
393 for y in 0..h {
394 if is_free(x, y) && unknown_neighbors(x, y) > 0 {
395 is_frontier[x as usize][y as usize] = true;
396 }
397 }
398 }
399
400 let mut visited = vec![vec![false; h as usize]; w as usize];
402 let mut clusters = Vec::new();
403 for x in 0..w {
404 for y in 0..h {
405 if !is_frontier[x as usize][y as usize] || visited[x as usize][y as usize] {
406 continue;
407 }
408 let mut queue = VecDeque::new();
409 queue.push_back((x, y));
410 visited[x as usize][y as usize] = true;
411 let mut members = Vec::new();
412 let mut openness = 0;
413 while let Some((cx, cy)) = queue.pop_front() {
414 members.push((cx, cy));
415 openness += unknown_neighbors(cx, cy);
416 for dx in -1..=1 {
417 for dy in -1..=1 {
418 if dx == 0 && dy == 0 {
419 continue;
420 }
421 let nx = cx + dx;
422 let ny = cy + dy;
423 if nx >= 0
424 && ny >= 0
425 && nx < w
426 && ny < h
427 && is_frontier[nx as usize][ny as usize]
428 && !visited[nx as usize][ny as usize]
429 {
430 visited[nx as usize][ny as usize] = true;
431 queue.push_back((nx, ny));
432 }
433 }
434 }
435 }
436 let cx = members.iter().map(|m| m.0).sum::<i32>() as f64 / members.len() as f64;
438 let cy = members.iter().map(|m| m.1).sum::<i32>() as f64 / members.len() as f64;
439 let rep = *members
440 .iter()
441 .min_by(|a, b| {
442 let da = (a.0 as f64 - cx).powi(2) + (a.1 as f64 - cy).powi(2);
443 let db = (b.0 as f64 - cx).powi(2) + (b.1 as f64 - cy).powi(2);
444 da.partial_cmp(&db)
445 .unwrap_or(Ordering::Equal)
446 .then_with(|| a.cmp(b))
447 })
448 .unwrap();
449 clusters.push(FrontierCluster { rep, openness });
450 }
451 }
452 clusters
453}
454
455fn dijkstra_from(known: &[Vec<Knowledge>], source: (i32, i32)) -> Option<Vec<Vec<f64>>> {
457 let w = known.len() as i32;
458 let h = known[0].len() as i32;
459 if known[source.0 as usize][source.1 as usize] != Knowledge::Free {
460 return None;
461 }
462 let mut dist = vec![vec![f64::INFINITY; h as usize]; w as usize];
463 dist[source.0 as usize][source.1 as usize] = 0.0;
464 let mut heap = BinaryHeap::new();
465 heap.push(DijkstraNode {
466 cost: 0.0,
467 cell: source,
468 });
469 while let Some(DijkstraNode { cost, cell }) = heap.pop() {
470 if cost > dist[cell.0 as usize][cell.1 as usize] {
471 continue;
472 }
473 for dx in -1..=1 {
474 for dy in -1..=1 {
475 if dx == 0 && dy == 0 {
476 continue;
477 }
478 let nx = cell.0 + dx;
479 let ny = cell.1 + dy;
480 if nx < 0 || ny < 0 || nx >= w || ny >= h {
481 continue;
482 }
483 if known[nx as usize][ny as usize] != Knowledge::Free {
484 continue;
485 }
486 let step = if dx != 0 && dy != 0 { SQRT2 } else { 1.0 };
487 let next = cost + step;
488 if next < dist[nx as usize][ny as usize] {
489 dist[nx as usize][ny as usize] = next;
490 heap.push(DijkstraNode {
491 cost: next,
492 cell: (nx, ny),
493 });
494 }
495 }
496 }
497 }
498 Some(dist)
499}
500
501fn follow_gradient(
503 known: &[Vec<Knowledge>],
504 field: &[Vec<f64>],
505 robot: &mut (i32, i32),
506 target: (i32, i32),
507 budget: usize,
508 path: &mut Vec<(i32, i32)>,
509 path_length: &mut f64,
510) {
511 let w = known.len() as i32;
512 let h = known[0].len() as i32;
513 for _ in 0..budget {
514 if *robot == target {
515 break;
516 }
517 let here = field[robot.0 as usize][robot.1 as usize];
518 let mut best: Option<(f64, (i32, i32), f64)> = None;
519 for dx in -1..=1 {
520 for dy in -1..=1 {
521 if dx == 0 && dy == 0 {
522 continue;
523 }
524 let nx = robot.0 + dx;
525 let ny = robot.1 + dy;
526 if nx < 0 || ny < 0 || nx >= w || ny >= h {
527 continue;
528 }
529 if known[nx as usize][ny as usize] != Knowledge::Free {
530 continue;
531 }
532 let d = field[nx as usize][ny as usize];
533 if !d.is_finite() || d >= here {
534 continue;
535 }
536 let step = if dx != 0 && dy != 0 { SQRT2 } else { 1.0 };
537 let better = match best {
538 None => true,
539 Some((best_d, best_cell, _)) => {
540 d < best_d || (d == best_d && (nx, ny) < best_cell)
541 }
542 };
543 if better {
544 best = Some((d, (nx, ny), step));
545 }
546 }
547 }
548 let Some((_, next, step)) = best else { break };
549 *robot = next;
550 path.push(next);
551 *path_length += step;
552 }
553}
554
555#[derive(Debug, Clone, Copy)]
556struct DijkstraNode {
557 cost: f64,
558 cell: (i32, i32),
559}
560
561impl PartialEq for DijkstraNode {
562 fn eq(&self, other: &Self) -> bool {
563 self.cost == other.cost
564 }
565}
566impl Eq for DijkstraNode {}
567impl PartialOrd for DijkstraNode {
568 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
569 Some(self.cmp(other))
570 }
571}
572impl Ord for DijkstraNode {
573 fn cmp(&self, other: &Self) -> Ordering {
574 other
576 .cost
577 .partial_cmp(&self.cost)
578 .unwrap_or(Ordering::Equal)
579 .then_with(|| other.cell.cmp(&self.cell))
580 }
581}
582
583fn euclid(a: (i32, i32), b: (i32, i32)) -> f64 {
584 let dx = (a.0 - b.0) as f64;
585 let dy = (a.1 - b.1) as f64;
586 (dx * dx + dy * dy).sqrt()
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592
593 fn walled_world() -> FrontierNavWorld {
595 let mut world = FrontierNavWorld::new(24, 16, (1, 8), (22, 8)).unwrap();
596 world.fill_rect(12, 13, 0, 12);
598 world
599 }
600
601 #[test]
602 fn reaches_goal_around_occlusion() {
603 let report =
604 simulate_frontier_navigation(&walled_world(), &FrontierNavConfig::default()).unwrap();
605 assert!(report.reached_goal, "did not reach goal");
606 assert!(*report.path.last().unwrap() == (22, 8));
607 assert!(report.frontier_selections >= 1);
609 }
610
611 #[test]
612 fn open_world_reaches_goal() {
613 let world = FrontierNavWorld::new(20, 12, (1, 6), (18, 6)).unwrap();
614 let report = simulate_frontier_navigation(&world, &FrontierNavConfig::default()).unwrap();
615 assert!(report.reached_goal);
616 }
617
618 #[test]
619 fn is_deterministic() {
620 let world = walled_world();
621 let config = FrontierNavConfig::default();
622 let first = simulate_frontier_navigation(&world, &config).unwrap();
623 let second = simulate_frontier_navigation(&world, &config).unwrap();
624 assert_eq!(first.path, second.path);
625 assert_eq!(first.frontier_selections, second.frontier_selections);
626 }
627
628 #[test]
629 fn sensing_is_occlusion_aware() {
630 let mut world = FrontierNavWorld::new(24, 16, (1, 8), (22, 8)).unwrap();
632 world.fill_rect(4, 4, 5, 11);
633 let mut known = vec![vec![Knowledge::Unknown; 16]; 24];
634 sense(&world, &mut known, (1, 8), 6.0);
635 assert_eq!(known[3][8], Knowledge::Free);
637 assert_eq!(known[4][8], Knowledge::Occupied);
639 assert_eq!(known[6][8], Knowledge::Unknown);
641 }
642
643 #[test]
644 fn rejects_blocked_start() {
645 let mut world = FrontierNavWorld::new(10, 10, (1, 1), (8, 8)).unwrap();
646 world.fill_rect(1, 1, 1, 1);
647 assert!(simulate_frontier_navigation(&world, &FrontierNavConfig::default()).is_err());
648 }
649}