Refactor CBVG for ray-based node creation and angular frontier detection

Major improvements to the Clearance-Based Visibility Graph (CBVG) system:

CBVG Graph:
- Replace grid-based node generation with ray-based placement along lidar scans
- Add angular gap analysis for frontier detection (nodes with uncovered sectors)
- Add clique optimization for merging redundant nodes
- Add path planning with node exclusion for alternative approach angles
- Increase max_nodes to 5000 for large multi-room layouts
- Add configurable max_lidar_range for node creation

Exploration Controller:
- Track CBVG goal nodes that led to collision for each frontier
- Add oscillation detection and frontier suppression
- Implement multi-attempt collision recovery before marking unreachable
- Plan alternative paths by excluding previously failed goal nodes
- Handle collisions during recovery state

Vector Map:
- Add get_path_excluding_nodes() for alternative approach planning
- Integrate CBVG-based frontier detection with configurable parameters

Other:
- Scan module: Include max-range readings for open-space node creation
- Example: Add command-line map file selection
- Temporarily disable release optimizations for debugging
This commit is contained in:
codetiger
2025-12-27 10:24:08 +05:30
parent f08c4538fa
commit 0980b31e77
15 changed files with 2138 additions and 576 deletions
+3 -3
View File
@@ -30,6 +30,6 @@ name = "exploration"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1 # Better optimization, slower compile
panic = "abort" # Smaller binary, no unwinding overhead
strip = true # Strip debug symbols for smaller binary
# codegen-units = 1 # Better optimization, slower compile
# panic = "abort" # Smaller binary, no unwinding overhead
# strip = true # Strip debug symbols for smaller binary
+18 -1
View File
@@ -158,6 +158,12 @@ frontier:
# Maximum gap width to consider as a single gap (m)
# Allow gaps across rooms (up to 10m)
max_gap_width: 10.0
# CBVG-based frontier detection parameters
# Minimum uncovered angle for a node to be a frontier (radians)
# 1.75 rad = 100 degrees
min_frontier_angle: 1.75
# Maximum distance to check for walls when detecting frontiers (m)
max_wall_distance: 0.5
# ============================================================================
# Occupancy Queries
@@ -180,7 +186,8 @@ cbvg:
# Sample resolution along Voronoi edges (m)
voronoi_sample_step: 0.1
# Maximum edge length between nodes (m)
max_edge_length: 0.8
# Increased from 0.8 to 1.5 for better graph connectivity during exploration
max_edge_length: 1.5
# Interval for placing coverage nodes in open areas (m)
# Ensures nodes exist even in areas without medial axis
coverage_node_interval: 0.35
@@ -192,6 +199,16 @@ cbvg:
edge_clearance_step: 0.05
# Wall sampling step for Voronoi approximation (m)
wall_sample_step: 0.1
# Minimum clique size for merging during optimization
# Cliques (mutually visible node groups) smaller than this are kept separate
min_clique_size_for_merge: 3
# Number of lidar scans between clique optimization runs
# Set to 1 to optimize after every scan for best graph quality
optimize_interval: 1
# Maximum range for CBVG node creation (m)
# Nodes are only placed up to this distance from robot
# (wall detection still uses full lidar range)
max_lidar_range: 1.0
# ============================================================================
# Loop Closure Detection
+58 -6
View File
@@ -10,6 +10,10 @@
//! # Running
//!
//! ```bash
//! # With a specific map file:
//! cargo run --example exploration --release -- path/to/map.yaml
//!
//! # With random map selection:
//! cargo run --example exploration --release
//! ```
//!
@@ -31,8 +35,8 @@ use vastu_map::exploration::{
use vastu_map::integration::CoplanarMergeConfig;
use vastu_map::query::cbvg::ClearanceVisibilityGraph;
/// Maximum simulation time in seconds.
const MAX_TIME: f32 = 200.0; // 3.3 minutes
/// Maximum simulation time in seconds (no limit).
const MAX_TIME: f32 = f32::MAX;
/// Directory containing available maps.
const MAPS_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/data/maps");
@@ -173,8 +177,21 @@ fn main() {
// Ensure results directory exists
std::fs::create_dir_all("results").ok();
// Select a random map from available maps
let map_file = select_random_map();
// Check for command-line argument for map file
let args: Vec<String> = std::env::args().collect();
let map_file = if args.len() > 1 {
let provided_map = &args[1];
// Check if file exists
if !std::path::Path::new(provided_map).exists() {
eprintln!("Error: Map file not found: {}", provided_map);
std::process::exit(1);
}
println!("Using provided map: {}", provided_map);
provided_map.clone()
} else {
// Select a random map from available maps
select_random_map()
};
// Get default robot radius for clearance checking
let default_config = HarnessConfig::default();
@@ -261,7 +278,7 @@ fn run_exploration_loop(harness: &mut TestHarness, config: ExplorationConfig) ->
let mut last_target: Option<Point2D> = None;
let mut last_cycle_print = 0;
let mut pending_collision: Option<CollisionEvent> = None;
const MAX_STEPS: usize = 20000;
const MAX_STEPS: usize = usize::MAX;
const LIDAR_INTERVAL: usize = 5; // Process lidar every N steps
while step_count < MAX_STEPS && harness.sim_time() < MAX_TIME {
@@ -387,7 +404,42 @@ fn run_exploration_loop(harness: &mut TestHarness, config: ExplorationConfig) ->
ExplorationState::RecoveringFromCollision { .. } => {
// Execute backoff velocity (or stop if none)
let vel = step.velocity.unwrap_or(VelocityCommand::new(0.0, 0.0));
harness.step(vel.linear, vel.angular);
let step_result = harness.step(vel.linear, vel.angular);
// Check for collision during recovery (defensive - should be rare)
if step_result.any_bumper() {
collisions += 1;
let collision_type = match (step_result.left_bumper, step_result.right_bumper) {
(true, true) => CollisionType::BumperBoth,
(true, false) => CollisionType::BumperLeft,
(false, true) => CollisionType::BumperRight,
_ => CollisionType::BumperBoth,
};
let theta = step_result.physics_pose.theta;
let radius = harness.config().robot.robot_radius;
let point = Point2D::new(
step_result.physics_pose.x + (radius + 0.02) * theta.cos(),
step_result.physics_pose.y + (radius + 0.02) * theta.sin(),
);
// Create collision event for controller
pending_collision = Some(CollisionEvent::new(collision_type, point, theta));
// Also add virtual wall directly to map
let wall = pending_collision
.as_ref()
.unwrap()
.to_virtual_wall(config.virtual_wall_length);
harness.slam_mut().add_line(wall.line);
virtual_walls += 1;
println!(
" Recovery collision at ({:.2}, {:.2}) - added virtual wall",
point.x, point.y
);
}
}
}
}
+6 -2
View File
@@ -69,7 +69,9 @@ impl PolarScan {
let mut cloud = PointCloud2D::with_capacity(self.points.len());
for &(angle, dist, quality) in &self.points {
if quality >= min_quality && dist >= min_range && dist <= max_range {
// Use 1.5% tolerance to include max-range readings (no wall hit)
// These are needed for CBVG to create nodes in open-space directions
if quality >= min_quality && dist >= min_range && dist <= max_range * 1.015 {
let (sin, cos) = angle.sin_cos();
cloud.xs.push(dist * cos);
cloud.ys.push(dist * sin);
@@ -112,7 +114,9 @@ impl PolarScan {
target.ys.reserve(self.points.len());
for &(angle, dist, quality) in &self.points {
if quality >= min_quality && dist >= min_range && dist <= max_range {
// Use 1.5% tolerance to include max-range readings (no wall hit)
// These are needed for CBVG to create nodes in open-space directions
if quality >= min_quality && dist >= min_range && dist <= max_range * 1.015 {
let (sin, cos) = angle.sin_cos();
target.xs.push(dist * cos);
target.ys.push(dist * sin);
+457 -25
View File
@@ -13,6 +13,8 @@
//!
//! This approach eliminates ping-pong behavior and improves coverage efficiency.
use std::collections::{HashMap, VecDeque};
use crate::core::{Point2D, Pose2D};
use crate::{Frontier, Map, Path};
@@ -39,6 +41,8 @@ pub enum ExplorationState {
waypoint_idx: usize,
/// Target frontier point.
target_frontier: Point2D,
/// CBVG goal node index used for this path (for collision tracking).
goal_node_idx: Option<usize>,
},
/// Recovering from collision (backing off).
@@ -49,6 +53,12 @@ pub enum ExplorationState {
backoff_heading: f32,
/// Position when backoff started.
start_position: Point2D,
/// The frontier we were trying to reach (for alternative path planning).
target_frontier: Point2D,
/// The CBVG goal node that led to collision (to exclude in retry).
failed_goal_node: Option<usize>,
/// Number of recovery collision attempts.
recovery_attempts: u32,
},
/// Exploration complete - no frontiers remain.
@@ -179,12 +189,15 @@ pub struct ExplorationController {
/// Configuration.
config: ExplorationConfig,
/// Number of consecutive failed path planning attempts.
/// Number of consecutive failed path planning attempts for current frontier.
failed_planning_attempts: usize,
/// Maximum failed attempts before giving up on a frontier.
max_failed_attempts: usize,
/// The frontier viewpoint we're currently attempting (to track per-frontier failures).
current_attempt_frontier: Option<Point2D>,
/// Frontiers that have been marked as unreachable.
unreachable_frontiers: Vec<Point2D>,
@@ -203,6 +216,19 @@ pub struct ExplorationController {
/// Robot position when unreachable frontiers were last cleared.
/// Used to re-evaluate unreachable frontiers after robot moves significantly.
position_at_last_unreachable_clear: Option<Point2D>,
/// CBVG nodes that led to collision for each frontier viewpoint.
/// Key is grid-quantized frontier position, value is list of failed node indices.
/// Used to find alternative approach angles after collision.
collision_nodes: HashMap<(i32, i32), Vec<usize>>,
/// Recent frontier targets for oscillation detection.
/// Stores (grid_x, grid_y) of last N selected frontiers.
recent_targets: VecDeque<(i32, i32)>,
/// Frontiers temporarily suppressed due to oscillation detection.
/// Key is grid position, value is suppression count (decrements each cycle).
suppressed_frontiers: HashMap<(i32, i32), u32>,
}
impl ExplorationController {
@@ -218,12 +244,16 @@ impl ExplorationController {
config,
failed_planning_attempts: 0,
max_failed_attempts: 3,
current_attempt_frontier: None,
unreachable_frontiers: Vec::new(),
frontier_match_distance: 0.5,
planner,
history,
last_update_time: None,
position_at_last_unreachable_clear: None,
collision_nodes: HashMap::new(),
recent_targets: VecDeque::with_capacity(8),
suppressed_frontiers: HashMap::new(),
}
}
@@ -232,10 +262,13 @@ impl ExplorationController {
self.state = ExplorationState::SelectingFrontier;
self.failed_planning_attempts = 0;
self.unreachable_frontiers.clear();
self.collision_nodes.clear();
self.planner.reset();
self.history.clear();
self.last_update_time = Some(std::time::Instant::now());
self.position_at_last_unreachable_clear = None;
self.recent_targets.clear();
self.suppressed_frontiers.clear();
}
/// Reset to idle state.
@@ -243,10 +276,13 @@ impl ExplorationController {
self.state = ExplorationState::Idle;
self.failed_planning_attempts = 0;
self.unreachable_frontiers.clear();
self.collision_nodes.clear();
self.planner.reset();
self.history.clear();
self.last_update_time = None;
self.position_at_last_unreachable_clear = None;
self.recent_targets.clear();
self.suppressed_frontiers.clear();
}
/// Get current state.
@@ -302,17 +338,31 @@ impl ExplorationController {
path,
waypoint_idx,
target_frontier,
} => self.follow_path(map, current_pose, path, waypoint_idx, target_frontier),
goal_node_idx,
} => self.follow_path(
map,
current_pose,
path,
waypoint_idx,
target_frontier,
goal_node_idx,
),
ExplorationState::RecoveringFromCollision {
backoff_remaining,
backoff_heading,
start_position,
target_frontier,
failed_goal_node,
recovery_attempts,
} => self.execute_backoff(
current_pose,
backoff_remaining,
backoff_heading,
start_position,
target_frontier,
failed_goal_node,
recovery_attempts,
),
ExplorationState::Complete => ExplorationStep::idle(self.state.clone()),
@@ -322,6 +372,15 @@ impl ExplorationController {
}
/// Handle a collision event.
///
/// When collision occurs during path following:
/// 1. Record the failed CBVG goal node (if any)
/// 2. Enter recovery state with the target frontier preserved
/// 3. After backoff completes, path planning will try alternative nodes
///
/// When collision occurs during recovery:
/// 1. Increment recovery attempts counter
/// 2. If too many attempts, mark frontier as unreachable
fn handle_collision(&mut self, event: CollisionEvent, current_pose: Pose2D) -> ExplorationStep {
log::info!(
"Collision detected: {:?} at ({:.2}, {:.2})",
@@ -333,11 +392,107 @@ impl ExplorationController {
// Create virtual wall at collision point
let virtual_wall = event.to_virtual_wall(self.config.virtual_wall_length);
// Transition to recovery state
// Handle collision during recovery - increment attempts and possibly give up
if let ExplorationState::RecoveringFromCollision {
target_frontier,
failed_goal_node,
recovery_attempts,
backoff_heading,
..
} = &self.state
{
let attempts = *recovery_attempts + 1;
let target = *target_frontier;
let failed_node = *failed_goal_node;
log::warn!(
"Recovery collision at ({:.2}, {:.2}) - attempt {} of 3",
event.point.x,
event.point.y,
attempts
);
if attempts >= 3 {
// Too many recovery collisions - mark frontier as unreachable
log::warn!(
"Marking frontier at ({:.2}, {:.2}) as unreachable after {} recovery collisions",
target.x,
target.y,
attempts
);
// Track position for unreachable frontier reset logic
if self.unreachable_frontiers.is_empty() {
self.position_at_last_unreachable_clear = Some(current_pose.position());
}
self.unreachable_frontiers.push(target);
// Go back to selecting a different frontier
self.state = ExplorationState::SelectingFrontier;
let mut step = ExplorationStep::idle(self.state.clone());
step.virtual_wall = Some(virtual_wall);
return step;
}
// Continue recovery with incremented counter
// Keep original start_position so backoff distance calculation is correct
let original_start =
if let ExplorationState::RecoveringFromCollision { start_position, .. } =
&self.state
{
*start_position
} else {
current_pose.position()
};
self.state = ExplorationState::RecoveringFromCollision {
backoff_remaining: self.config.backoff_distance,
backoff_heading: *backoff_heading,
start_position: original_start, // Keep original start for correct distance calculation
target_frontier: target,
failed_goal_node: failed_node,
recovery_attempts: attempts,
};
let mut step =
ExplorationStep::with_velocity(VelocityCommand::stop(), self.state.clone());
step.virtual_wall = Some(virtual_wall);
return step;
}
// Collision during FollowingPath - extract values then record the failed node
let (target_frontier, failed_goal_node) = if let ExplorationState::FollowingPath {
target_frontier,
goal_node_idx,
..
} = &self.state
{
(*target_frontier, *goal_node_idx)
} else {
// Collision in some other state (shouldn't happen, but handle gracefully)
(current_pose.position(), None)
};
// Record this node as failed for this frontier (after extracting values to avoid borrow conflict)
if let Some(node_idx) = failed_goal_node {
self.add_collision_node_for_frontier(target_frontier, node_idx);
}
log::info!(
"Collision while heading to frontier ({:.2}, {:.2}), failed node: {:?}",
target_frontier.x,
target_frontier.y,
failed_goal_node
);
// Transition to recovery state - preserve target frontier for alternative path planning
self.state = ExplorationState::RecoveringFromCollision {
backoff_remaining: self.config.backoff_distance,
backoff_heading: event.backoff_heading(),
start_position: current_pose.position(),
target_frontier,
failed_goal_node,
recovery_attempts: 0,
};
// Return stop command with virtual wall
@@ -364,6 +519,9 @@ impl ExplorationController {
.unwrap_or(0.1);
self.last_update_time = Some(std::time::Instant::now());
// Decay suppression counters from oscillation detection
self.decay_suppressed_frontiers();
// Update history with current position
self.history.mark_visited(robot_pos);
@@ -383,19 +541,22 @@ impl ExplorationController {
if should_clear {
log::debug!(
"Clearing {} unreachable frontiers after robot moved {:.1}m",
"Clearing {} unreachable frontiers and {} collision node entries after robot moved {:.1}m",
self.unreachable_frontiers.len(),
self.collision_nodes.len(),
UNREACHABLE_RESET_DISTANCE
);
self.unreachable_frontiers.clear();
self.collision_nodes.clear(); // Also reset collision history
self.position_at_last_unreachable_clear = Some(robot_pos);
}
}
// Filter out unreachable frontiers (using viewpoint for distance checks)
// Filter out unreachable and suppressed frontiers (using viewpoint for distance checks)
let reachable_frontiers: Vec<Frontier> = all_frontiers
.iter()
.filter(|f| !self.is_frontier_unreachable(f.viewpoint))
.filter(|f| !self.is_frontier_suppressed(f.viewpoint))
.cloned()
.collect();
@@ -423,12 +584,50 @@ impl ExplorationController {
.update(&reachable_frontiers, robot_pos, &self.history, dt);
// Try to find a frontier with a valid path
// Safety limit to prevent infinite loops - max iterations = 2 * number of frontiers + regions
const MAX_LOOP_ITERATIONS: usize = 100;
let mut loop_iterations = 0;
loop {
loop_iterations += 1;
if loop_iterations > MAX_LOOP_ITERATIONS {
log::error!(
"Exploration stuck: exceeded {} iterations in select_frontier loop",
MAX_LOOP_ITERATIONS
);
self.state = ExplorationState::Failed {
reason: format!(
"frontier selection loop exceeded {} iterations",
MAX_LOOP_ITERATIONS
),
};
return ExplorationStep::idle(self.state.clone());
}
// Check if we've exhausted all regions
if self.planner.plan().is_complete() {
// Force replan to pick up any new frontiers
// Re-filter frontiers to exclude those marked unreachable during this loop
let still_reachable: Vec<Frontier> = reachable_frontiers
.iter()
.filter(|f| !self.is_frontier_unreachable(f.viewpoint))
.cloned()
.collect();
if still_reachable.is_empty() {
// All frontiers are now unreachable
log::warn!(
"Exploration stuck: all {} frontiers are unreachable",
reachable_frontiers.len()
);
self.state = ExplorationState::Failed {
reason: format!("{} frontiers unreachable", reachable_frontiers.len()),
};
return ExplorationStep::idle(self.state.clone());
}
// Force replan with updated reachable frontiers
self.planner
.force_replan(&reachable_frontiers, robot_pos, &self.history);
.force_replan(&still_reachable, robot_pos, &self.history);
if self.planner.plan().is_complete() || self.planner.plan().is_empty() {
// Still no regions - exploration complete
@@ -454,13 +653,14 @@ impl ExplorationController {
// Find nearest CBVG node to the frontier - guaranteed reachable
// CBVG nodes are in visible areas with proper wall clearance
let target = match selected_frontier {
let (target, frontier_viewpoint) = match selected_frontier {
Some(f) => {
// Try to find nearest CBVG node (within 2m of frontier viewpoint)
match map.nearest_cbvg_node(f.viewpoint, 2.0) {
let target_pos = match map.nearest_cbvg_node(f.viewpoint, 2.0) {
Some(node_pos) => node_pos,
None => f.viewpoint, // Fallback to viewpoint if no node nearby
}
};
(target_pos, f.viewpoint)
}
None => {
// Current region exhausted, advance to next
@@ -483,28 +683,79 @@ impl ExplorationController {
);
// Mark as visited so we don't keep selecting it
self.history.mark_visited(target);
self.planner.plan_mut().advance_region();
// Remove this frontier from the current region so it won't be selected again
if let Some(region) = self.planner.plan_mut().current_region_mut() {
region
.frontiers
.retain(|f| f.viewpoint.distance(frontier_viewpoint) > 0.1);
}
// If region is now empty, advance to next region
if self
.planner
.plan()
.current_region()
.is_none_or(|r| r.is_empty())
{
self.planner.plan_mut().advance_region();
}
continue;
}
// Get excluded nodes for this frontier (nodes that led to collision before)
let excluded_nodes = self.get_collision_nodes_for_frontier(frontier_viewpoint);
log::debug!(
"Selected frontier viewpoint at ({:.2}, {:.2}) from region {}, distance: {:.2}m",
"Selected frontier viewpoint at ({:.2}, {:.2}) from region {}, distance: {:.2}m, excluded nodes: {:?}",
target.x,
target.y,
region.id,
distance_to_target
distance_to_target,
excluded_nodes
);
// Plan path directly to viewpoint (no offset needed - viewpoint is already safe)
match map.get_path(robot_pos, target) {
Some(path) => {
// Plan path directly to viewpoint, excluding nodes that led to collision
match map.get_path_excluding_nodes(robot_pos, target, &excluded_nodes) {
Some((path, goal_node_idx)) => {
log::debug!(
"Path planned: {} waypoints, {:.2}m total",
"Path planned: {} waypoints, {:.2}m total, via node {}",
path.points.len(),
path.length
path.length,
goal_node_idx
);
// Check for oscillation before committing to this frontier
// If oscillation is detected, suppress the "other" frontier and continue with this one
if let Some(suppress_key) =
self.record_frontier_and_check_oscillation(frontier_viewpoint)
{
log::info!(
"Oscillation detected - suppressing other frontier at grid {:?}, continuing with ({:.2}, {:.2})",
suppress_key,
frontier_viewpoint.x,
frontier_viewpoint.y
);
// Suppress the other frontier for 10 cycles
self.suppressed_frontiers.insert(suppress_key, 10);
// Also remove the suppressed frontier from planner's current region
// so it won't be selected again in subsequent loop iterations
if let Some(region) = self.planner.plan_mut().current_region_mut() {
let grid_cell_size = 0.5; // Same as frontier_grid_key
region.frontiers.retain(|f| {
let key = (
(f.viewpoint.x / grid_cell_size) as i32,
(f.viewpoint.y / grid_cell_size) as i32,
);
key != suppress_key
});
}
// Continue with the current frontier (don't skip it)
}
self.failed_planning_attempts = 0;
self.current_attempt_frontier = None;
// Immediately start following the path
let result = self.path_follower.follow(
@@ -517,7 +768,9 @@ impl ExplorationController {
self.state = ExplorationState::FollowingPath {
path: path.clone(),
waypoint_idx: result.waypoint_idx,
target_frontier: target, // Viewpoint is already a safe target
// Use frontier_viewpoint (not target/CBVG node) so collision tracking matches
target_frontier: frontier_viewpoint,
goal_node_idx: Some(goal_node_idx), // Track which node we're using
};
let mut step =
@@ -534,7 +787,19 @@ impl ExplorationController {
target.y
);
self.failed_planning_attempts += 1;
// Track per-frontier failures - reset counter if we switched frontiers
let is_same_frontier = self
.current_attempt_frontier
.map(|prev| prev.distance(frontier_viewpoint) < 0.1)
.unwrap_or(false);
if is_same_frontier {
self.failed_planning_attempts += 1;
} else {
// New frontier - reset counter
self.current_attempt_frontier = Some(frontier_viewpoint);
self.failed_planning_attempts = 1;
}
if self.failed_planning_attempts >= self.max_failed_attempts {
// Track position when we first mark any frontier as unreachable
@@ -543,19 +808,20 @@ impl ExplorationController {
self.position_at_last_unreachable_clear = Some(robot_pos);
}
// Mark this frontier as unreachable
self.unreachable_frontiers.push(target);
// Mark this frontier as unreachable (use viewpoint, not CBVG node)
self.unreachable_frontiers.push(frontier_viewpoint);
self.failed_planning_attempts = 0;
self.current_attempt_frontier = None;
log::warn!(
"Frontier marked as unreachable after {} failed attempts",
self.max_failed_attempts
);
// Remove from current region and try again
// Remove from current region and try again (use viewpoint for matching)
if let Some(region) = self.planner.plan_mut().current_region_mut() {
region
.frontiers
.retain(|f| f.viewpoint.distance(target) > 0.1);
.retain(|f| f.viewpoint.distance(frontier_viewpoint) > 0.1);
}
continue;
}
@@ -575,6 +841,7 @@ impl ExplorationController {
path: Path,
waypoint_idx: usize,
target_frontier: Point2D,
goal_node_idx: Option<usize>,
) -> ExplorationStep {
let result = self.path_follower.follow(
current_pose,
@@ -590,6 +857,8 @@ impl ExplorationController {
target_frontier.x,
target_frontier.y
);
// Clear collision nodes for this frontier since we reached it successfully
self.clear_collision_nodes_for_frontier(target_frontier);
self.state = ExplorationState::SelectingFrontier;
return ExplorationStep::idle(self.state.clone());
@@ -604,6 +873,7 @@ impl ExplorationController {
path,
waypoint_idx: result.waypoint_idx,
target_frontier,
goal_node_idx,
};
let mut step = ExplorationStep::with_velocity(result.velocity, self.state.clone());
@@ -619,6 +889,9 @@ impl ExplorationController {
backoff_remaining: f32,
backoff_heading: f32,
start_position: Point2D,
target_frontier: Point2D,
failed_goal_node: Option<usize>,
recovery_attempts: u32,
) -> ExplorationStep {
// Calculate how far we've backed up
let backed_up = current_pose.position().distance(start_position);
@@ -626,7 +899,12 @@ impl ExplorationController {
if remaining <= 0.0 {
// Backoff complete - go back to selecting frontier
log::debug!("Backoff complete, selecting new frontier");
// The collision node has already been recorded in handle_collision
log::debug!(
"Backoff complete, selecting new frontier (failed node: {:?}, attempts: {})",
failed_goal_node,
recovery_attempts
);
self.state = ExplorationState::SelectingFrontier;
return ExplorationStep::idle(self.state.clone());
}
@@ -636,6 +914,9 @@ impl ExplorationController {
backoff_remaining: remaining,
backoff_heading,
start_position,
target_frontier,
failed_goal_node,
recovery_attempts,
};
// Command slow reverse motion
@@ -665,6 +946,141 @@ impl ExplorationController {
pub fn config(&self) -> &ExplorationConfig {
&self.config
}
// ─────────────────────────────────────────────────────────────────────────
// Oscillation Detection (prevents ping-pong between nearby frontiers)
// ─────────────────────────────────────────────────────────────────────────
/// Record a frontier selection and check for oscillation or repeated visits.
/// Returns the frontier to suppress if a problematic pattern is detected.
fn record_frontier_and_check_oscillation(&mut self, frontier: Point2D) -> Option<(i32, i32)> {
let key = self.frontier_grid_key(frontier);
// Add to recent targets (keep last 8)
self.recent_targets.push_back(key);
if self.recent_targets.len() > 8 {
self.recent_targets.pop_front();
}
let targets: Vec<_> = self.recent_targets.iter().collect();
let len = targets.len();
// Check 1: A-B-A-B pattern (oscillation between two frontiers)
if len >= 4 {
let a = targets[len - 4];
let b = targets[len - 3];
let c = targets[len - 2];
let d = targets[len - 1]; // This is the current selection (key)
// Pattern: A, B, A, B (same A and B repeating)
if a == c && b == d && a != b {
log::warn!(
"Oscillation detected between frontiers at grid {:?} and {:?}",
a,
b
);
// Copy the key to suppress before clearing
let suppress_key = *c;
self.recent_targets.clear();
return Some(suppress_key);
}
}
// Check 2: Same frontier visited 3+ times in last 6 selections (stuck on one frontier)
if len >= 6 {
let current = targets[len - 1];
let count = targets[len - 6..].iter().filter(|&&t| t == current).count();
if count >= 3 {
log::warn!(
"Repeated visits detected: frontier at grid {:?} visited {} times in last 6",
current,
count
);
// Suppress this frontier since we keep returning to it
let suppress_key = *current;
self.recent_targets.clear();
return Some(suppress_key);
}
}
// Check 3: Three frontiers in rotation (A-B-C-A-B-C pattern)
if len >= 6 {
let a = targets[len - 6];
let b = targets[len - 5];
let c = targets[len - 4];
let d = targets[len - 3];
let e = targets[len - 2];
let f = targets[len - 1];
// Pattern: A, B, C, A, B, C
if a == d && b == e && c == f && a != b && b != c && a != c {
log::warn!("Three-way rotation detected: {:?}, {:?}, {:?}", a, b, c);
// Suppress the oldest frontier in the pattern
let suppress_key = *a;
self.recent_targets.clear();
return Some(suppress_key);
}
}
None
}
/// Check if a frontier is currently suppressed due to oscillation.
fn is_frontier_suppressed(&self, frontier: Point2D) -> bool {
let key = self.frontier_grid_key(frontier);
self.suppressed_frontiers.get(&key).copied().unwrap_or(0) > 0
}
/// Decrement suppression counters (call once per frontier selection cycle).
fn decay_suppressed_frontiers(&mut self) {
self.suppressed_frontiers.retain(|_, count| {
*count = count.saturating_sub(1);
*count > 0
});
}
// ─────────────────────────────────────────────────────────────────────────
// Collision Node Tracking (for alternative approach angles)
// ─────────────────────────────────────────────────────────────────────────
/// Convert a frontier point to a grid key for collision tracking.
/// Uses 0.5m grid cells to group nearby frontiers.
fn frontier_grid_key(&self, p: Point2D) -> (i32, i32) {
((p.x / 0.5) as i32, (p.y / 0.5) as i32)
}
/// Record a failed CBVG goal node for a frontier.
/// This node led to a collision when trying to reach the frontier.
fn add_collision_node_for_frontier(&mut self, frontier: Point2D, node_idx: usize) {
let key = self.frontier_grid_key(frontier);
let nodes = self.collision_nodes.entry(key).or_default();
if !nodes.contains(&node_idx) {
nodes.push(node_idx);
log::debug!(
"Recorded failed node {} for frontier at ({:.2}, {:.2})",
node_idx,
frontier.x,
frontier.y
);
}
}
/// Get failed CBVG goal nodes for a frontier.
/// Returns nodes that should be excluded when planning paths to this frontier.
fn get_collision_nodes_for_frontier(&self, frontier: Point2D) -> Vec<usize> {
let key = self.frontier_grid_key(frontier);
self.collision_nodes.get(&key).cloned().unwrap_or_default()
}
/// Clear collision nodes for a frontier (called when frontier is successfully reached).
fn clear_collision_nodes_for_frontier(&mut self, frontier: Point2D) {
let key = self.frontier_grid_key(frontier);
self.collision_nodes.remove(&key);
}
}
#[cfg(test)]
@@ -731,6 +1147,22 @@ mod tests {
})
}
fn get_path_excluding_nodes(
&self,
from: Point2D,
to: Point2D,
_excluded_goal_nodes: &[usize],
) -> Option<(Path, usize)> {
// Simple direct path, return node index 0
Some((
Path {
points: vec![from, to],
length: from.distance(to),
},
0,
))
}
fn nearest_cbvg_node(&self, target: Point2D, _max_distance: f32) -> Option<Point2D> {
// For testing, just return the target point
Some(target)
+7 -4
View File
@@ -469,12 +469,15 @@ impl ExplorationPlanner {
}
/// Normalize an angle to [-PI, PI].
/// Uses modular arithmetic to avoid potential infinite loops with extreme values.
fn normalize_angle(angle: f32) -> f32 {
let mut result = angle;
while result > PI {
result -= 2.0 * PI;
if !angle.is_finite() {
return 0.0;
}
while result < -PI {
let mut result = angle % (2.0 * PI);
if result > PI {
result -= 2.0 * PI;
} else if result < -PI {
result += 2.0 * PI;
}
result
+21
View File
@@ -388,6 +388,27 @@ pub trait Map: Send + Sync {
/// Path if one exists, None otherwise.
fn get_path(&self, from: Point2D, to: Point2D) -> Option<Path>;
/// Plan path between two points, excluding specific CBVG goal nodes.
///
/// This is used for finding alternative approach angles after a collision.
/// When navigation to a frontier causes a collision, the goal node that
/// led to the collision can be excluded, forcing the planner to find a
/// different approach.
///
/// # Arguments
/// * `from` - Start position
/// * `to` - Goal position
/// * `excluded_goal_nodes` - CBVG node indices to exclude when selecting the goal node
///
/// # Returns
/// Tuple of (Path, goal_node_index) if path exists, None otherwise.
fn get_path_excluding_nodes(
&self,
from: Point2D,
to: Point2D,
excluded_goal_nodes: &[usize],
) -> Option<(Path, usize)>;
/// Find the nearest CBVG node to a target point.
///
/// This is useful for exploration to find reachable navigation targets
+52 -10
View File
@@ -52,20 +52,41 @@ pub struct CBVGConfig {
/// Points are sampled along walls for Voronoi computation.
/// Default: 0.1
pub wall_sample_step: f32,
/// Minimum clique size for merging during optimization.
/// Cliques (mutually visible node groups) smaller than this are kept separate.
/// Set to 3 to merge triangles and larger clusters.
/// Default: 3
pub min_clique_size_for_merge: usize,
/// Number of lidar scans between clique optimization runs.
/// Higher values reduce CPU but may leave redundant nodes longer.
/// Set to 1 to optimize after every scan.
/// Default: 1
pub optimize_interval: usize,
/// Maximum lidar range (meters).
/// When a scan point is at or near this distance, it indicates no wall was hit
/// (open space). Nodes are still added along the ray up to this distance.
/// Default: 8.0
pub max_lidar_range: f32,
}
impl Default for CBVGConfig {
fn default() -> Self {
Self {
min_clearance: 0.3,
min_clearance: 0.25, // Reduced since robot radius is accounted separately
min_clearance_narrow: 0.18,
voronoi_sample_step: 0.1,
max_edge_length: 0.8,
coverage_node_interval: 0.35,
node_merge_distance: 0.15,
max_nodes: 500,
max_edge_length: 0.8, // Reduced for tighter connectivity
coverage_node_interval: 0.4, // Reasonable node spacing
node_merge_distance: 0.4, // Minimum distance between nodes
max_nodes: 5000, // Support large multi-room layouts
edge_clearance_step: 0.05,
wall_sample_step: 0.1,
min_clique_size_for_merge: 3,
optimize_interval: 1,
max_lidar_range: 8.0,
}
}
}
@@ -130,6 +151,24 @@ impl CBVGConfig {
self
}
/// Builder-style setter for minimum clique size for merging.
pub fn with_min_clique_size_for_merge(mut self, size: usize) -> Self {
self.min_clique_size_for_merge = size;
self
}
/// Builder-style setter for optimization interval.
pub fn with_optimize_interval(mut self, interval: usize) -> Self {
self.optimize_interval = interval;
self
}
/// Builder-style setter for maximum lidar range.
pub fn with_max_lidar_range(mut self, range: f32) -> Self {
self.max_lidar_range = range;
self
}
/// Get the effective clearance for a given measured clearance.
///
/// Returns `min_clearance` for open areas, or `min_clearance_narrow`
@@ -151,15 +190,18 @@ mod tests {
#[test]
fn test_default_config() {
let config = CBVGConfig::default();
assert_eq!(config.min_clearance, 0.3);
assert_eq!(config.min_clearance, 0.25);
assert_eq!(config.min_clearance_narrow, 0.18);
assert_eq!(config.voronoi_sample_step, 0.1);
assert_eq!(config.max_edge_length, 0.8);
assert_eq!(config.coverage_node_interval, 0.35);
assert_eq!(config.node_merge_distance, 0.15);
assert_eq!(config.max_nodes, 500);
assert_eq!(config.coverage_node_interval, 0.4);
assert_eq!(config.node_merge_distance, 0.4);
assert_eq!(config.max_nodes, 5000);
assert_eq!(config.edge_clearance_step, 0.05);
assert_eq!(config.wall_sample_step, 0.1);
assert_eq!(config.min_clique_size_for_merge, 3);
assert_eq!(config.optimize_interval, 1);
assert_eq!(config.max_lidar_range, 8.0);
}
#[test]
@@ -177,7 +219,7 @@ mod tests {
let config = CBVGConfig::default();
// Open area - use full clearance
assert_eq!(config.effective_clearance(0.5), 0.3);
assert_eq!(config.effective_clearance(0.5), 0.25);
// Narrow passage - use reduced clearance
assert_eq!(config.effective_clearance(0.2), 0.18);
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -78,6 +78,6 @@ pub use graph::ClearanceVisibilityGraph;
pub use node::{CBVGNode, NodeType};
pub use visibility::VisibilityRegion;
pub use voronoi::{
VoronoiEdge, compute_voronoi_edges, generate_medial_axis_nodes, generate_nodes_from_visibility,
generate_nodes_hybrid, sample_medial_axis_nodes, sample_wall_points,
VoronoiEdge, compute_voronoi_edges, generate_grid_nodes, sample_medial_axis_nodes,
sample_wall_points,
};
+138 -10
View File
@@ -25,6 +25,10 @@ pub struct VisibilityRegion {
/// Indexed as grid[y_cell][x_cell].
grid: Vec<Vec<bool>>,
/// 2D grid of processed cells: true = already processed for node generation.
/// Used to track which visible cells have been returned by get_newly_visible_cells().
processed: Vec<Vec<bool>>,
/// Resolution of the visibility grid (meters per cell).
resolution: f32,
@@ -47,6 +51,7 @@ impl VisibilityRegion {
pub fn new(resolution: f32) -> Self {
Self {
grid: Vec::new(),
processed: Vec::new(),
resolution,
origin: Point2D::zero(),
width: 0,
@@ -97,7 +102,9 @@ impl VisibilityRegion {
/// Update visibility from a lidar scan.
///
/// Marks all cells along rays from robot to scan points as visible.
/// Marks all cells in the scanned area as visible by filling triangular
/// sectors between adjacent lidar rays. This ensures the entire interior
/// of the scanned region is marked visible, not just the rays themselves.
///
/// # Arguments
/// * `robot_pose` - Current robot pose (position and heading)
@@ -112,9 +119,38 @@ impl VisibilityRegion {
// First, ensure grid covers all scan points plus robot position
self.ensure_coverage(robot_pos, scan_points);
// Mark rays from robot to each scan point
for &scan_point in scan_points {
self.mark_ray_visible(robot_pos, scan_point);
// Sort scan points by angle from robot for proper sector filling
let mut sorted_points: Vec<Point2D> = scan_points.to_vec();
sorted_points.sort_by(|a, b| {
let angle_a = (a.y - robot_pos.y).atan2(a.x - robot_pos.x);
let angle_b = (b.y - robot_pos.y).atan2(b.x - robot_pos.x);
angle_a
.partial_cmp(&angle_b)
.unwrap_or(std::cmp::Ordering::Equal)
});
// Fill triangular sectors between adjacent scan points
// This marks the entire interior as visible, not just the rays
for i in 0..sorted_points.len() {
let p1 = sorted_points[i];
let p2 = sorted_points[(i + 1) % sorted_points.len()];
// Check if the angle between rays is small enough to fill
// (avoid filling across large gaps like behind the robot)
let angle1 = (p1.y - robot_pos.y).atan2(p1.x - robot_pos.x);
let angle2 = (p2.y - robot_pos.y).atan2(p2.x - robot_pos.x);
let mut angle_diff = (angle2 - angle1).abs();
if angle_diff > std::f32::consts::PI {
angle_diff = 2.0 * std::f32::consts::PI - angle_diff;
}
// Only fill sectors with small angular gaps (< 15 degrees)
if angle_diff < 0.26 {
self.fill_triangle(robot_pos, p1, p2);
} else {
// For larger gaps, just mark the rays
self.mark_ray_visible(robot_pos, p1);
}
}
}
@@ -180,8 +216,9 @@ impl VisibilityRegion {
self.width = ((bounds.width() / self.resolution).ceil() as usize).max(1);
self.height = ((bounds.height() / self.resolution).ceil() as usize).max(1);
// Create grid filled with false (not visible)
// Create grids filled with false (not visible, not processed)
self.grid = vec![vec![false; self.width]; self.height];
self.processed = vec![vec![false; self.width]; self.height];
}
/// Expand the grid to cover new bounds while preserving existing data.
@@ -198,20 +235,28 @@ impl VisibilityRegion {
let new_width = ((expanded.width() / self.resolution).ceil() as usize).max(1);
let new_height = ((expanded.height() / self.resolution).ceil() as usize).max(1);
// Create new grid
// Create new grids
let mut new_grid = vec![vec![false; new_width]; new_height];
let mut new_processed = vec![vec![false; new_width]; new_height];
// Copy existing data
let offset_x = ((self.origin.x - new_origin.x) / self.resolution).round() as isize;
let offset_y = ((self.origin.y - new_origin.y) / self.resolution).round() as isize;
for (old_y, row) in self.grid.iter().enumerate() {
for old_y in 0..self.height {
let new_y = old_y as isize + offset_y;
if new_y >= 0 && (new_y as usize) < new_height {
for (old_x, &visible) in row.iter().enumerate() {
for old_x in 0..self.width {
let new_x = old_x as isize + offset_x;
if new_x >= 0 && (new_x as usize) < new_width && visible {
new_grid[new_y as usize][new_x as usize] = true;
if new_x >= 0 && (new_x as usize) < new_width {
let nx = new_x as usize;
let ny = new_y as usize;
if self.grid[old_y][old_x] {
new_grid[ny][nx] = true;
}
if self.processed[old_y][old_x] {
new_processed[ny][nx] = true;
}
}
}
}
@@ -222,6 +267,7 @@ impl VisibilityRegion {
self.width = new_width;
self.height = new_height;
self.grid = new_grid;
self.processed = new_processed;
}
/// Mark all cells along a ray from start to end as visible.
@@ -266,6 +312,54 @@ impl VisibilityRegion {
}
}
/// Fill a triangular region as visible.
///
/// Uses a scanline algorithm to fill the triangle formed by three points.
fn fill_triangle(&mut self, p0: Point2D, p1: Point2D, p2: Point2D) {
// Convert to grid coordinates
let (x0, y0) = self.world_to_grid(p0);
let (x1, y1) = self.world_to_grid(p1);
let (x2, y2) = self.world_to_grid(p2);
// Find bounding box
let min_y = y0.min(y1).min(y2).max(0);
let max_y = y0.max(y1).max(y2).min(self.height as i32 - 1);
let min_x = x0.min(x1).min(x2).max(0);
let max_x = x0.max(x1).max(x2).min(self.width as i32 - 1);
// Convert to f32 for edge calculations
let (x0, y0) = (x0 as f32, y0 as f32);
let (x1, y1) = (x1 as f32, y1 as f32);
let (x2, y2) = (x2 as f32, y2 as f32);
// Use barycentric coordinates to check if points are inside triangle
let area = 0.5 * (-y1 * x2 + y0 * (-x1 + x2) + x0 * (y1 - y2) + x1 * y2);
if area.abs() < 0.001 {
// Degenerate triangle, just mark the edges
self.mark_ray_visible(p0, p1);
self.mark_ray_visible(p0, p2);
return;
}
let sign = if area < 0.0 { -1.0 } else { 1.0 };
// Scanline fill
for py in min_y..=max_y {
for px in min_x..=max_x {
let px_f = px as f32;
let py_f = py as f32;
// Calculate barycentric coordinates
let s = sign * (y0 * x2 - x0 * y2 + (y2 - y0) * px_f + (x0 - x2) * py_f);
let t = sign * (x0 * y1 - y0 * x1 + (y0 - y1) * px_f + (x1 - x0) * py_f);
if s >= 0.0 && t >= 0.0 && (s + t) <= 2.0 * area.abs() * sign {
self.mark_cell_visible(px, py);
}
}
}
}
/// Convert world coordinates to grid cell indices.
fn world_to_grid(&self, point: Point2D) -> (i32, i32) {
let x = ((point.x - self.origin.x) / self.resolution).floor() as i32;
@@ -304,6 +398,40 @@ impl VisibilityRegion {
self.visible_cell_count() as f32 / self.total_cell_count() as f32
}
/// Get newly visible cells that haven't been processed yet.
///
/// Returns the center points of cells that are visible but not yet processed,
/// and marks them as processed. This is used for incremental node generation
/// in the CBVG graph.
///
/// # Returns
/// Vector of cell center points in world coordinates.
pub fn get_newly_visible_cells(&mut self) -> Vec<Point2D> {
let mut new_cells = Vec::new();
for y in 0..self.height {
for x in 0..self.width {
if self.grid[y][x] && !self.processed[y][x] {
new_cells.push(self.grid_to_world_center(x, y));
self.processed[y][x] = true;
}
}
}
new_cells
}
/// Reset the processed state for all cells.
///
/// This allows regenerating nodes in all visible areas.
pub fn reset_processed(&mut self) {
for row in &mut self.processed {
for cell in row {
*cell = false;
}
}
}
/// Iterator over all visible cell center points.
pub fn visible_points(&self) -> impl Iterator<Item = Point2D> + '_ {
self.grid.iter().enumerate().flat_map(move |(y, row)| {
+41 -355
View File
@@ -8,8 +8,7 @@ use crate::features::Line2D;
use super::clearance::{min_distance_to_walls, sample_line};
use super::config::CBVGConfig;
use super::node::{CBVGNode, NodeType};
use super::visibility::VisibilityRegion;
use super::node::CBVGNode;
/// A Voronoi edge connecting two points in free space.
#[derive(Clone, Debug)]
@@ -124,15 +123,11 @@ pub fn compute_voronoi_edges(
}
/// Check if a point is near the medial axis (equidistant from multiple walls).
///
/// A point is on the medial axis if it's approximately equidistant from
/// its two nearest walls.
fn is_near_medial_axis(point: Point2D, lines: &[Line2D], config: &CBVGConfig) -> bool {
if lines.len() < 2 {
return false;
}
// Find distances to all walls
let mut distances: Vec<(f32, usize)> = lines
.iter()
.enumerate()
@@ -148,16 +143,12 @@ fn is_near_medial_axis(point: Point2D, lines: &[Line2D], config: &CBVGConfig) ->
let d1 = distances[0].0;
let d2 = distances[1].0;
// Point must have minimum clearance
if d1 < config.min_clearance_narrow {
return false;
}
// Check if approximately equidistant (within tolerance)
let tolerance = config.voronoi_sample_step * 1.5;
let diff = (d1 - d2).abs();
// On medial axis if distances are similar
diff < tolerance
}
@@ -247,9 +238,15 @@ pub fn sample_medial_axis_nodes(
nodes
}
/// Generate nodes directly from distance field without explicit Voronoi computation.
/// Generate nodes on a uniform grid with clearance checks.
///
/// This is a simpler alternative that samples the medial axis directly.
/// This replaces medial axis generation with simple grid sampling:
/// 1. Sample points on a uniform grid (coverage_node_interval spacing)
/// 2. Keep only points with sufficient clearance from walls
/// 3. Apply deterministic jitter for better distribution
///
/// The graph's optimize_nodes() should be called after to reduce density
/// by merging visibility cliques into centroids.
///
/// # Arguments
/// * `lines` - Wall segments
@@ -257,8 +254,8 @@ pub fn sample_medial_axis_nodes(
/// * `config` - CBVG configuration
///
/// # Returns
/// Vector of CBVG nodes on the medial axis.
pub fn generate_medial_axis_nodes(
/// Vector of CBVG nodes uniformly distributed in free space.
pub fn generate_grid_nodes(
lines: &[Line2D],
bounds: Option<&Bounds>,
config: &CBVGConfig,
@@ -272,14 +269,15 @@ pub fn generate_medial_axis_nodes(
None => compute_bounds_from_lines(lines),
};
// Expand bounds
// Expand bounds slightly to ensure edge coverage
let margin = config.min_clearance;
let expanded_bounds = Bounds {
min: Point2D::new(bounds.min.x - margin, bounds.min.y - margin),
max: Point2D::new(bounds.max.x + margin, bounds.max.y + margin),
};
let step = config.voronoi_sample_step;
let step = config.coverage_node_interval;
let jitter_range = step * 0.3; // ±30% jitter for better distribution
let mut nodes: Vec<CBVGNode> = Vec::new();
let x_steps = ((expanded_bounds.max.x - expanded_bounds.min.x) / step).ceil() as usize;
@@ -287,86 +285,39 @@ pub fn generate_medial_axis_nodes(
for ix in 0..=x_steps {
for iy in 0..=y_steps {
let x = expanded_bounds.min.x + ix as f32 * step;
let y = expanded_bounds.min.y + iy as f32 * step;
let point = Point2D::new(x, y);
// Base grid position with deterministic jitter
let base_x = expanded_bounds.min.x + ix as f32 * step;
let base_y = expanded_bounds.min.y + iy as f32 * step;
let (jitter_x, jitter_y) = deterministic_jitter(ix, iy, jitter_range);
let point = Point2D::new(base_x + jitter_x, base_y + jitter_y);
// Check if on medial axis with sufficient clearance
if is_near_medial_axis(point, lines, config) {
let clearance = min_distance_to_walls(point, lines);
// Check clearance from walls
let clearance = min_distance_to_walls(point, lines);
if clearance < config.min_clearance_narrow {
continue;
}
if clearance >= config.min_clearance_narrow {
let is_narrow = clearance < config.min_clearance;
// Skip duplicates
let is_duplicate = nodes
.iter()
.any(|n| n.position.distance(point) < config.node_merge_distance);
// Check for duplicates
let is_duplicate = nodes
.iter()
.any(|n| n.position.distance(point) < config.node_merge_distance);
if is_duplicate {
continue;
}
if !is_duplicate {
nodes.push(CBVGNode::medial_axis(point, clearance, is_narrow));
let is_narrow = clearance < config.min_clearance;
nodes.push(CBVGNode::medial_axis(point, clearance, is_narrow));
if nodes.len() >= config.max_nodes {
return nodes;
}
}
}
if nodes.len() >= config.max_nodes {
return nodes;
}
}
}
// Add junction nodes at corridor intersections
add_junction_nodes(&mut nodes, lines, config);
nodes
}
/// Add junction nodes where multiple corridors meet.
fn add_junction_nodes(nodes: &mut [CBVGNode], lines: &[Line2D], config: &CBVGConfig) {
// Find points where 3+ walls are approximately equidistant
// These are corridor junctions
if lines.len() < 3 {
return;
}
// For now, detect junctions as points where medial axis changes direction significantly
// This is a simplified approach; a full implementation would use Voronoi vertices
let mut junction_candidates: Vec<Point2D> = Vec::new();
for node in nodes.iter() {
// Count nearby walls at similar distances
let clearance = node.clearance;
let tolerance = config.voronoi_sample_step;
let nearby_walls: usize = lines
.iter()
.filter(|line| {
let dist = line.distance_to_point_segment(node.position);
(dist - clearance).abs() < tolerance
})
.count();
// Junction if equidistant from 3+ walls
if nearby_walls >= 3 {
junction_candidates.push(node.position);
}
}
// Mark existing nodes as junctions or add new junction nodes
for candidate in junction_candidates {
// Find if there's an existing node nearby
let existing = nodes
.iter_mut()
.find(|n| n.position.distance(candidate) < config.node_merge_distance);
if let Some(node) = existing {
node.node_type = NodeType::Junction;
}
}
}
/// Compute bounds from line segments.
fn compute_bounds_from_lines(lines: &[Line2D]) -> Bounds {
if lines.is_empty() {
@@ -384,98 +335,6 @@ fn compute_bounds_from_lines(lines: &[Line2D]) -> Bounds {
Bounds { min, max }
}
/// Generate nodes from lidar visibility region.
///
/// Only creates nodes in areas the lidar has actually observed, with proper
/// wall clearance. Uses a grid-based approach with the coverage_node_interval
/// to ensure uniform coverage in open areas.
///
/// # Arguments
/// * `visibility` - Visibility region tracking scanned areas
/// * `lines` - Wall segments for clearance computation
/// * `config` - CBVG configuration
///
/// # Returns
/// Vector of CBVG nodes in visible areas with safe clearance.
pub fn generate_nodes_from_visibility(
visibility: &VisibilityRegion,
lines: &[Line2D],
config: &CBVGConfig,
) -> Vec<CBVGNode> {
if visibility.is_empty() || lines.is_empty() {
return Vec::new();
}
let bounds = visibility.visible_bounds();
if bounds.is_empty() {
return Vec::new();
}
let step = config.coverage_node_interval;
let mut nodes: Vec<CBVGNode> = Vec::new();
// Jitter parameters: nodes are offset by ±30% of step size
// This prevents systematic gaps near walls due to grid alignment
let jitter_range = step * 0.3;
// Grid sample within visible bounds
let x_start = bounds.min.x;
let x_end = bounds.max.x;
let y_start = bounds.min.y;
let y_end = bounds.max.y;
let x_steps = ((x_end - x_start) / step).ceil() as usize;
let y_steps = ((y_end - y_start) / step).ceil() as usize;
for ix in 0..=x_steps {
for iy in 0..=y_steps {
// Base grid position
let base_x = x_start + ix as f32 * step;
let base_y = y_start + iy as f32 * step;
// Apply deterministic jitter based on grid cell
// Uses a simple hash to create pseudo-random but reproducible offsets
let (jitter_x, jitter_y) = deterministic_jitter(ix, iy, jitter_range);
let x = base_x + jitter_x;
let y = base_y + jitter_y;
let point = Point2D::new(x, y);
// Must be in visible area (scanned by lidar)
if !visibility.is_visible(point) {
continue;
}
// Must have clearance from walls
let clearance = min_distance_to_walls(point, lines);
if clearance < config.min_clearance_narrow {
continue;
}
// Check for duplicates
let is_duplicate = nodes
.iter()
.any(|n| n.position.distance(point) < config.node_merge_distance);
if is_duplicate {
continue;
}
// Determine node type based on clearance
let is_narrow = clearance < config.min_clearance;
nodes.push(CBVGNode::medial_axis(point, clearance, is_narrow));
if nodes.len() >= config.max_nodes {
return nodes;
}
}
}
// Add junction nodes at corridor intersections
add_junction_nodes(&mut nodes, lines, config);
nodes
}
/// Generate deterministic jitter for a grid cell.
///
/// Uses a simple hash function to create pseudo-random but reproducible
@@ -489,7 +348,7 @@ pub fn generate_nodes_from_visibility(
///
/// # Returns
/// Tuple of (x_offset, y_offset) in range [-range, +range]
fn deterministic_jitter(ix: usize, iy: usize, range: f32) -> (f32, f32) {
pub fn deterministic_jitter(ix: usize, iy: usize, range: f32) -> (f32, f32) {
// Simple hash combining grid indices
// Using prime multipliers for better distribution
let hash1 = ((ix.wrapping_mul(73856093)) ^ (iy.wrapping_mul(19349663))) as u32;
@@ -502,82 +361,6 @@ fn deterministic_jitter(ix: usize, iy: usize, range: f32) -> (f32, f32) {
(norm1 * range, norm2 * range)
}
/// Generate nodes combining visibility-aware and medial axis approaches.
///
/// This hybrid approach:
/// 1. Places coverage nodes uniformly in visible areas
/// 2. Adds medial axis nodes where they don't already exist
/// 3. Ensures good connectivity in narrow passages
///
/// # Arguments
/// * `visibility` - Visibility region tracking scanned areas
/// * `lines` - Wall segments
/// * `config` - CBVG configuration
///
/// # Returns
/// Vector of CBVG nodes optimized for path planning.
pub fn generate_nodes_hybrid(
visibility: &VisibilityRegion,
lines: &[Line2D],
config: &CBVGConfig,
) -> Vec<CBVGNode> {
// Start with visibility-based coverage nodes
let mut nodes = generate_nodes_from_visibility(visibility, lines, config);
if nodes.len() >= config.max_nodes {
return nodes;
}
// Add medial axis nodes for better coverage in corridors
// Use smaller step for more precision in narrow areas
let corridor_config = CBVGConfig {
voronoi_sample_step: config.voronoi_sample_step,
..config.clone()
};
let bounds = visibility.visible_bounds();
for ix in 0..((bounds.width() / corridor_config.voronoi_sample_step).ceil() as usize) {
for iy in 0..((bounds.height() / corridor_config.voronoi_sample_step).ceil() as usize) {
let x = bounds.min.x + ix as f32 * corridor_config.voronoi_sample_step;
let y = bounds.min.y + iy as f32 * corridor_config.voronoi_sample_step;
let point = Point2D::new(x, y);
// Only add in visible areas
if !visibility.is_visible(point) {
continue;
}
// Only add if on medial axis
if !is_near_medial_axis(point, lines, &corridor_config) {
continue;
}
let clearance = min_distance_to_walls(point, lines);
if clearance < config.min_clearance_narrow {
continue;
}
// Skip if already covered
let is_duplicate = nodes
.iter()
.any(|n| n.position.distance(point) < config.node_merge_distance);
if is_duplicate {
continue;
}
let is_narrow = clearance < config.min_clearance;
nodes.push(CBVGNode::medial_axis(point, clearance, is_narrow));
if nodes.len() >= config.max_nodes {
return nodes;
}
}
}
nodes
}
#[cfg(test)]
mod tests {
use super::*;
@@ -638,28 +421,14 @@ mod tests {
}
#[test]
fn test_is_near_medial_axis() {
let walls = make_corridor();
let config = CBVGConfig::default().with_voronoi_sample_step(0.1);
// Point in the middle of corridor should be on medial axis
let center = Point2D::new(2.5, 0.5);
assert!(is_near_medial_axis(center, &walls, &config));
// Point close to a wall should not be on medial axis
let near_wall = Point2D::new(2.5, 0.1);
assert!(!is_near_medial_axis(near_wall, &walls, &config));
}
#[test]
fn test_generate_medial_axis_nodes() {
fn test_generate_grid_nodes() {
let walls = make_room();
let config = CBVGConfig::default()
.with_voronoi_sample_step(0.2)
.with_coverage_node_interval(0.3)
.with_min_clearance(0.3)
.with_min_clearance_narrow(0.18);
let nodes = generate_medial_axis_nodes(&walls, None, &config);
let nodes = generate_grid_nodes(&walls, None, &config);
// Should have nodes in the room
assert!(!nodes.is_empty());
@@ -669,7 +438,7 @@ mod tests {
assert!(node.clearance >= config.min_clearance_narrow);
}
// Most nodes should be near the center of the room
// Should have nodes near the center of the room
let center = Point2D::new(2.0, 1.5);
let near_center: usize = nodes
.iter()
@@ -692,90 +461,7 @@ mod tests {
#[test]
fn test_empty_walls() {
let config = CBVGConfig::default();
let nodes = generate_medial_axis_nodes(&[], None, &config);
let nodes = generate_grid_nodes(&[], None, &config);
assert!(nodes.is_empty());
}
#[test]
fn test_generate_nodes_from_visibility() {
use crate::core::Pose2D;
let walls = make_room();
let config = CBVGConfig::default()
.with_coverage_node_interval(0.5)
.with_min_clearance(0.3)
.with_min_clearance_narrow(0.18);
// Create visibility region with scan from center of room
let mut visibility = VisibilityRegion::new(0.1);
let robot_pose = Pose2D::new(2.0, 1.5, 0.0);
// Simulate a circular scan around the robot
let scan_points: Vec<Point2D> = (0..36)
.map(|i| {
let angle = i as f32 * std::f32::consts::PI / 18.0;
let dist = 1.5; // 1.5m range
robot_pose.transform_point(Point2D::from_polar(angle, dist))
})
.collect();
visibility.update_from_scan(robot_pose, &scan_points);
let nodes = generate_nodes_from_visibility(&visibility, &walls, &config);
// Should have nodes
assert!(!nodes.is_empty());
// All nodes should be in visible area
for node in &nodes {
assert!(visibility.is_visible(node.position));
}
// All nodes should have sufficient clearance
for node in &nodes {
assert!(node.clearance >= config.min_clearance_narrow);
}
}
#[test]
fn test_visibility_nodes_only_in_scanned_area() {
use crate::core::Pose2D;
// Create a large room
let walls = vec![
Line2D::new(Point2D::new(0.0, 0.0), Point2D::new(10.0, 0.0)),
Line2D::new(Point2D::new(10.0, 0.0), Point2D::new(10.0, 10.0)),
Line2D::new(Point2D::new(10.0, 10.0), Point2D::new(0.0, 10.0)),
Line2D::new(Point2D::new(0.0, 10.0), Point2D::new(0.0, 0.0)),
];
let config = CBVGConfig::default()
.with_coverage_node_interval(0.5)
.with_min_clearance(0.3);
// Scan only the bottom-left corner
let mut visibility = VisibilityRegion::new(0.1);
let robot_pose = Pose2D::new(2.0, 2.0, 0.0);
let scan_points: Vec<Point2D> = (0..36)
.map(|i| {
let angle = i as f32 * std::f32::consts::PI / 18.0;
let dist = 1.5;
robot_pose.transform_point(Point2D::from_polar(angle, dist))
})
.collect();
visibility.update_from_scan(robot_pose, &scan_points);
let nodes = generate_nodes_from_visibility(&visibility, &walls, &config);
// All nodes should be near the robot (not in unexplored far corner)
for node in &nodes {
assert!(
node.position.distance(robot_pose.position()) < 3.0,
"Node at {:?} is too far from scanned area",
node.position
);
}
}
}
+16
View File
@@ -467,6 +467,19 @@ pub struct FrontierConfig {
/// Larger distances are treated as separate areas, not a gap.
/// Default: 3.0m
pub max_gap_width: f32,
// ─────────────────────────────────────────────────────────────────────────
// CBVG-based frontier detection parameters
// ─────────────────────────────────────────────────────────────────────────
/// Minimum uncovered angle for a CBVG node to be a frontier (radians).
/// Nodes with a gap of at least this size (no neighbors or walls) are frontiers.
/// Default: 1.75 rad (100 degrees)
pub min_frontier_angle: f32,
/// Maximum distance to check for walls when detecting frontiers (meters).
/// Walls closer than this block the direction from being a frontier.
/// Default: 0.5m
pub max_wall_distance: f32,
}
impl Default for FrontierConfig {
@@ -481,6 +494,9 @@ impl Default for FrontierConfig {
viewpoint_step: 0.1,
min_gap_width: 0.4, // robot diameter (0.3m) + margin
max_gap_width: 10.0, // allow gaps across rooms
// CBVG-based parameters
min_frontier_angle: 1.75, // 100 degrees in radians
max_wall_distance: 0.5,
}
}
}
+88 -37
View File
@@ -14,7 +14,7 @@ use crate::integration::{
use crate::loop_closure::{LoopClosure, LoopClosureDetector};
use crate::matching::PointToLineIcp;
use crate::query::cbvg::{ClearanceVisibilityGraph, VisibilityRegion};
use crate::query::{detect_frontiers, is_straight_path_clear, query_occupancy, raycast};
use crate::query::{is_straight_path_clear, query_occupancy, raycast};
use crate::{Frontier, Map, ObserveResult, Occupancy, Path, TimingBreakdown};
use super::config::VectorMapConfig;
@@ -269,17 +269,10 @@ impl VectorMap {
/// Build and return a CBVG for visualization.
///
/// Uses visibility-based node generation if visibility data is available.
/// Returns a clone of the internal CBVG which is incrementally updated
/// with each lidar scan via `add_nodes_from_scan`.
pub fn build_cbvg(&self) -> ClearanceVisibilityGraph {
let mut cbvg = ClearanceVisibilityGraph::new(self.config.cbvg.clone());
if !self.visibility.is_empty() {
cbvg.build_from_visibility(&self.visibility, self.line_store.lines());
} else {
cbvg.build(self.line_store.lines(), self.line_store.bounds());
}
cbvg
self.cbvg.clone()
}
/// Find the nearest CBVG node to a target point.
@@ -485,7 +478,15 @@ impl VectorMap {
.collect();
self.visibility
.update_from_scan(final_pose, &world_scan_points);
self.cbvg_dirty = true;
// Incrementally add nodes from this scan
self.cbvg
.add_nodes_from_scan(final_pose, &world_scan_points, self.line_store.lines());
// Clean up any existing nodes that are now too close to scan points
self.cbvg
.cleanup_nodes_near_scan_points(&world_scan_points, self.line_store.lines());
// Clean up any edges that pass too close to scan points
self.cbvg.cleanup_edges_near_scan_points(&world_scan_points);
self.cbvg_dirty = false;
// ─── Scan Storage (Exploration Mode) ───
if let Some(ref mut store) = self.scan_store {
@@ -579,7 +580,15 @@ impl VectorMap {
.collect();
self.visibility
.update_from_scan(odometry, &world_scan_points);
self.cbvg_dirty = true;
// Incrementally add nodes from this scan
self.cbvg
.add_nodes_from_scan(odometry, &world_scan_points, self.line_store.lines());
// Clean up any existing nodes that are now too close to scan points
self.cbvg
.cleanup_nodes_near_scan_points(&world_scan_points, self.line_store.lines());
// Clean up any edges that pass too close to scan points
self.cbvg.cleanup_edges_near_scan_points(&world_scan_points);
self.cbvg_dirty = false;
// ─── Scan Storage (Exploration Mode) ───
if let Some(ref mut store) = self.scan_store {
@@ -896,32 +905,35 @@ impl Map for VectorMap {
}
fn frontiers(&self) -> Vec<Frontier> {
// Use current robot pose for distance-based filtering
detect_frontiers(
// Use CBVG-based frontier detection
// A node is a frontier if it has a large uncovered angular gap
// (no neighbors or walls in that direction)
let (frontiers, _indices) = self.cbvg.detect_frontiers(
self.line_store.lines(),
self.current_pose.position(),
&self.config.frontier,
)
self.config.frontier.min_frontier_angle,
self.config.frontier.max_wall_distance,
);
frontiers
}
fn get_path(&self, from: Point2D, to: Point2D) -> Option<Path> {
// Use CBVG for path planning with visibility-aware node generation
// This ensures nodes are only placed in lidar-scanned areas
let mut cbvg = ClearanceVisibilityGraph::new(self.config.cbvg.clone());
// Use the internal CBVG which is incrementally updated with each scan
self.cbvg.find_path(from, to, self.line_store.lines())
}
// Use visibility-based build if we have visibility data
if !self.visibility.is_empty() {
cbvg.build_from_visibility(&self.visibility, self.line_store.lines());
} else {
// Fallback to wall-based build for legacy/testing
cbvg.build(self.line_store.lines(), self.line_store.bounds());
}
cbvg.find_path(from, to, self.line_store.lines())
fn get_path_excluding_nodes(
&self,
from: Point2D,
to: Point2D,
excluded_goal_nodes: &[usize],
) -> Option<(Path, usize)> {
// Use CBVG with node exclusions for alternative approach angles
self.cbvg
.find_path_excluding_nodes(from, to, self.line_store.lines(), excluded_goal_nodes)
}
fn nearest_cbvg_node(&self, target: Point2D, max_distance: f32) -> Option<Point2D> {
self.nearest_cbvg_node(target, max_distance)
self.cbvg.nearest_node(target, max_distance)
}
fn is_path_clear(&self, from: Point2D, to: Point2D) -> bool {
@@ -1086,18 +1098,57 @@ mod tests {
assert!(frontiers.is_empty());
}
/// Create a point cloud for a partial room (3 walls, top missing).
/// Simulates a lidar scan from origin seeing walls at x=-2, x=2, y=-2.
fn make_partial_room_scan() -> PointCloud2D {
let mut xs = Vec::new();
let mut ys = Vec::new();
// Left wall (x = -2)
for i in -10..=10 {
let y = i as f32 * 0.2;
xs.push(-2.0);
ys.push(y);
}
// Right wall (x = 2)
for i in -10..=10 {
let y = i as f32 * 0.2;
xs.push(2.0);
ys.push(y);
}
// Bottom wall (y = -2)
for i in -10..=10 {
let x = i as f32 * 0.2;
xs.push(x);
ys.push(-2.0);
}
// Top wall is MISSING - this is where frontier should be detected
PointCloud2D { xs, ys }
}
#[test]
fn test_frontiers_partial_room() {
let mut map = VectorMap::default();
map.add_lines(&[
Line2D::new(Point2D::new(-2.0, -2.0), Point2D::new(2.0, -2.0)),
Line2D::new(Point2D::new(2.0, -2.0), Point2D::new(2.0, 2.0)),
Line2D::new(Point2D::new(-2.0, 2.0), Point2D::new(-2.0, -2.0)),
]);
// Use observe() to properly build lines AND CBVG nodes
let scan = make_partial_room_scan();
map.observe(&scan, Pose2D::identity());
// With 3 walls visible and top open, we should have frontiers
let frontiers = map.frontiers();
assert!(!frontiers.is_empty());
// CBVG nodes should exist after observation
assert!(
!map.cbvg.nodes().is_empty(),
"CBVG should have nodes after observation"
);
// Frontiers should be detected in the open direction (toward +y)
assert!(
!frontiers.is_empty(),
"Partial room with open top should have frontiers"
);
}
#[test]
+4
View File
@@ -257,6 +257,8 @@ frontier:
viewpoint_step: 0.1
min_gap_width: 0.4
max_gap_width: 10.0
min_frontier_angle: 1.75
max_wall_distance: 0.5
occupancy:
obstacle_distance: 0.1
known_distance: 2.0
@@ -376,6 +378,8 @@ frontier:
viewpoint_step: 0.1
min_gap_width: 0.4
max_gap_width: 10.0
min_frontier_angle: 1.75
max_wall_distance: 0.5
occupancy:
obstacle_distance: 0.1
known_distance: 2.0