Add wheel odometry, scan preprocessing, and visibility graph path planning
- Add odometry module with WheelOdometry for differential drive robots using arc-based motion integration (ROS REP-103 convention) - Add preprocessing module with outlier removal and voxel downsampling for point cloud filtering - Add path_planning module with visibility graph and Dijkstra's algorithm for optimal shortest-path planning around obstacles - Implement get_path() and is_path_clear() on VectorMap using the new path planner - Update test harness to simulate encoder ticks and use WheelOdometry instead of ground-truth-derived relative odometry
This commit is contained in:
+15
-1
@@ -118,6 +118,8 @@ pub mod integration;
|
||||
pub mod loop_closure;
|
||||
pub mod matching;
|
||||
pub mod motion_model;
|
||||
pub mod odometry;
|
||||
pub mod preprocessing;
|
||||
pub mod query;
|
||||
pub mod vector_map;
|
||||
|
||||
@@ -339,11 +341,23 @@ pub trait Map: Send + Sync {
|
||||
|
||||
/// Plan path between two points.
|
||||
///
|
||||
/// Uses visibility graph path planning to find the shortest path
|
||||
/// around obstacles.
|
||||
///
|
||||
/// # Returns
|
||||
/// Path if one exists, None otherwise.
|
||||
/// Note: Path planning is deferred in initial implementation.
|
||||
fn get_path(&self, from: Point2D, to: Point2D) -> Option<Path>;
|
||||
|
||||
/// Check if a straight-line path between two points is clear.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `from` - Start position
|
||||
/// * `to` - End position
|
||||
///
|
||||
/// # Returns
|
||||
/// True if no obstacles block the straight-line path.
|
||||
fn is_path_clear(&self, from: Point2D, to: Point2D) -> bool;
|
||||
|
||||
/// Get current map bounds.
|
||||
///
|
||||
/// Returns the axis-aligned bounding box containing all map features.
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Wheel odometry for differential drive robots.
|
||||
//!
|
||||
//! Computes robot pose changes from wheel encoder ticks using
|
||||
//! standard differential drive kinematics with arc-based integration.
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust
|
||||
//! use vastu_map::odometry::WheelOdometry;
|
||||
//!
|
||||
//! // Create odometry tracker (0.3m wheel base, 1000 ticks/meter)
|
||||
//! let mut odom = WheelOdometry::new(0.3, 1000.0);
|
||||
//!
|
||||
//! // Process encoder readings
|
||||
//! let delta = odom.update(100, 100); // Straight forward
|
||||
//! println!("Moved: ({:.3}, {:.3}, {:.3})", delta.x, delta.y, delta.theta);
|
||||
//!
|
||||
//! // Get cumulative pose
|
||||
//! let total = odom.cumulative();
|
||||
//! println!("Total: ({:.3}, {:.3}, {:.3})", total.x, total.y, total.theta);
|
||||
//! ```
|
||||
|
||||
use crate::core::Pose2D;
|
||||
|
||||
/// Differential drive wheel odometry.
|
||||
///
|
||||
/// Tracks robot pose from wheel encoder ticks. Uses arc-based motion model
|
||||
/// for accurate integration, especially during turns.
|
||||
///
|
||||
/// # Coordinate Frame
|
||||
///
|
||||
/// Follows ROS REP-103 convention:
|
||||
/// - X-forward (positive X is in front of robot)
|
||||
/// - Y-left (positive Y is to the left)
|
||||
/// - Theta counter-clockwise positive
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WheelOdometry {
|
||||
/// Distance between wheel centers (meters).
|
||||
wheel_base: f32,
|
||||
|
||||
/// Encoder ticks per meter of wheel travel.
|
||||
ticks_per_meter: f32,
|
||||
|
||||
/// Last left encoder reading (ticks).
|
||||
last_left: i32,
|
||||
|
||||
/// Last right encoder reading (ticks).
|
||||
last_right: i32,
|
||||
|
||||
/// Cumulative pose from origin.
|
||||
cumulative_pose: Pose2D,
|
||||
|
||||
/// Whether the odometry has been initialized with first reading.
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
impl WheelOdometry {
|
||||
/// Create a new wheel odometry tracker.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `wheel_base` - Distance between wheel centers in meters
|
||||
/// * `ticks_per_meter` - Encoder ticks per meter of wheel travel
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `wheel_base` or `ticks_per_meter` is not positive.
|
||||
pub fn new(wheel_base: f32, ticks_per_meter: f32) -> Self {
|
||||
assert!(wheel_base > 0.0, "wheel_base must be positive");
|
||||
assert!(ticks_per_meter > 0.0, "ticks_per_meter must be positive");
|
||||
|
||||
Self {
|
||||
wheel_base,
|
||||
ticks_per_meter,
|
||||
last_left: 0,
|
||||
last_right: 0,
|
||||
cumulative_pose: Pose2D::identity(),
|
||||
initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process encoder readings and return pose delta since last update.
|
||||
///
|
||||
/// On the first call, initializes the encoder baselines and returns identity pose.
|
||||
/// Subsequent calls compute the pose change using arc-based motion model.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `left_ticks` - Current left wheel encoder reading (ticks)
|
||||
/// * `right_ticks` - Current right wheel encoder reading (ticks)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Pose delta in robot frame (how the robot moved since last update).
|
||||
pub fn update(&mut self, left_ticks: i32, right_ticks: i32) -> Pose2D {
|
||||
if !self.initialized {
|
||||
// First reading - just store baselines
|
||||
self.last_left = left_ticks;
|
||||
self.last_right = right_ticks;
|
||||
self.initialized = true;
|
||||
return Pose2D::identity();
|
||||
}
|
||||
|
||||
// Compute tick deltas
|
||||
let delta_left_ticks = left_ticks - self.last_left;
|
||||
let delta_right_ticks = right_ticks - self.last_right;
|
||||
|
||||
// Update stored values
|
||||
self.last_left = left_ticks;
|
||||
self.last_right = right_ticks;
|
||||
|
||||
// Convert to meters
|
||||
let delta_left = delta_left_ticks as f32 / self.ticks_per_meter;
|
||||
let delta_right = delta_right_ticks as f32 / self.ticks_per_meter;
|
||||
|
||||
// Differential drive kinematics
|
||||
let delta_theta = (delta_right - delta_left) / self.wheel_base;
|
||||
let delta_linear = (delta_left + delta_right) / 2.0;
|
||||
|
||||
// Arc-based motion model for accurate integration
|
||||
let (dx, dy) = if delta_theta.abs() < 1e-6 {
|
||||
// Nearly straight motion - avoid division by zero
|
||||
let theta = self.cumulative_pose.theta;
|
||||
(delta_linear * theta.cos(), delta_linear * theta.sin())
|
||||
} else {
|
||||
// Arc motion
|
||||
let radius = delta_linear / delta_theta;
|
||||
let theta = self.cumulative_pose.theta;
|
||||
let new_theta = theta + delta_theta;
|
||||
(
|
||||
radius * (new_theta.sin() - theta.sin()),
|
||||
radius * (theta.cos() - new_theta.cos()),
|
||||
)
|
||||
};
|
||||
|
||||
// Create delta pose (in world frame)
|
||||
let delta = Pose2D::new(dx, dy, delta_theta);
|
||||
|
||||
// Update cumulative pose
|
||||
self.cumulative_pose = Pose2D::new(
|
||||
self.cumulative_pose.x + dx,
|
||||
self.cumulative_pose.y + dy,
|
||||
crate::core::math::normalize_angle(self.cumulative_pose.theta + delta_theta),
|
||||
);
|
||||
|
||||
delta
|
||||
}
|
||||
|
||||
/// Get the cumulative pose from origin.
|
||||
///
|
||||
/// This is the total pose accumulated since creation or last reset.
|
||||
#[inline]
|
||||
pub fn cumulative(&self) -> Pose2D {
|
||||
self.cumulative_pose
|
||||
}
|
||||
|
||||
/// Reset odometry to origin.
|
||||
///
|
||||
/// Clears the cumulative pose but keeps the current encoder readings
|
||||
/// as the new baseline.
|
||||
pub fn reset(&mut self) {
|
||||
self.cumulative_pose = Pose2D::identity();
|
||||
}
|
||||
|
||||
/// Reset odometry completely.
|
||||
///
|
||||
/// Clears both cumulative pose and encoder baselines.
|
||||
/// The next `update()` call will re-initialize.
|
||||
pub fn reset_all(&mut self) {
|
||||
self.cumulative_pose = Pose2D::identity();
|
||||
self.last_left = 0;
|
||||
self.last_right = 0;
|
||||
self.initialized = false;
|
||||
}
|
||||
|
||||
/// Get the wheel base (distance between wheels).
|
||||
#[inline]
|
||||
pub fn wheel_base(&self) -> f32 {
|
||||
self.wheel_base
|
||||
}
|
||||
|
||||
/// Get the ticks per meter conversion factor.
|
||||
#[inline]
|
||||
pub fn ticks_per_meter(&self) -> f32 {
|
||||
self.ticks_per_meter
|
||||
}
|
||||
|
||||
/// Check if odometry has been initialized.
|
||||
#[inline]
|
||||
pub fn is_initialized(&self) -> bool {
|
||||
self.initialized
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use approx::assert_relative_eq;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
#[test]
|
||||
fn test_new() {
|
||||
let odom = WheelOdometry::new(0.3, 1000.0);
|
||||
assert_eq!(odom.wheel_base(), 0.3);
|
||||
assert_eq!(odom.ticks_per_meter(), 1000.0);
|
||||
assert!(!odom.is_initialized());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "wheel_base must be positive")]
|
||||
fn test_new_invalid_wheel_base() {
|
||||
WheelOdometry::new(0.0, 1000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "ticks_per_meter must be positive")]
|
||||
fn test_new_invalid_ticks_per_meter() {
|
||||
WheelOdometry::new(0.3, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_first_update_initializes() {
|
||||
let mut odom = WheelOdometry::new(0.3, 1000.0);
|
||||
|
||||
let delta = odom.update(100, 100);
|
||||
|
||||
assert!(odom.is_initialized());
|
||||
assert_eq!(delta, Pose2D::identity());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_straight_forward() {
|
||||
let mut odom = WheelOdometry::new(0.3, 1000.0);
|
||||
|
||||
// Initialize
|
||||
odom.update(0, 0);
|
||||
|
||||
// Move 1 meter forward (1000 ticks on each wheel)
|
||||
let delta = odom.update(1000, 1000);
|
||||
|
||||
assert_relative_eq!(delta.x, 1.0, epsilon = 0.001);
|
||||
assert_relative_eq!(delta.y, 0.0, epsilon = 0.001);
|
||||
assert_relative_eq!(delta.theta, 0.0, epsilon = 0.001);
|
||||
|
||||
let cum = odom.cumulative();
|
||||
assert_relative_eq!(cum.x, 1.0, epsilon = 0.001);
|
||||
assert_relative_eq!(cum.y, 0.0, epsilon = 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_straight_backward() {
|
||||
let mut odom = WheelOdometry::new(0.3, 1000.0);
|
||||
|
||||
odom.update(0, 0);
|
||||
|
||||
// Move 0.5 meter backward
|
||||
let delta = odom.update(-500, -500);
|
||||
|
||||
assert_relative_eq!(delta.x, -0.5, epsilon = 0.001);
|
||||
assert_relative_eq!(delta.y, 0.0, epsilon = 0.001);
|
||||
assert_relative_eq!(delta.theta, 0.0, epsilon = 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rotate_in_place_ccw() {
|
||||
let mut odom = WheelOdometry::new(0.3, 1000.0);
|
||||
|
||||
odom.update(0, 0);
|
||||
|
||||
// Rotate counter-clockwise: left wheel back, right wheel forward
|
||||
// For 90° rotation: arc = wheel_base * theta / 2 for each wheel
|
||||
// theta = PI/2, arc = 0.3 * PI/2 / 2 = 0.2356m per wheel
|
||||
// ticks = 0.2356 * 1000 = 235.6
|
||||
let ticks = (0.3 * PI / 4.0 * 1000.0) as i32; // ~236
|
||||
let delta = odom.update(-ticks, ticks);
|
||||
|
||||
// Should rotate ~90° with minimal translation
|
||||
assert_relative_eq!(delta.theta, PI / 2.0, epsilon = 0.01);
|
||||
// Translation should be minimal for pure rotation
|
||||
assert!(delta.x.abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arc_motion() {
|
||||
let mut odom = WheelOdometry::new(0.3, 1000.0);
|
||||
|
||||
odom.update(0, 0);
|
||||
|
||||
// Arc motion: right wheel travels further than left
|
||||
// This should result in a left turn
|
||||
let delta = odom.update(800, 1200);
|
||||
|
||||
// Should have forward motion and counter-clockwise rotation
|
||||
assert!(delta.x > 0.0);
|
||||
assert!(delta.theta > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cumulative_multiple_updates() {
|
||||
let mut odom = WheelOdometry::new(0.3, 1000.0);
|
||||
|
||||
odom.update(0, 0);
|
||||
|
||||
// Move forward 1m
|
||||
odom.update(1000, 1000);
|
||||
// Move forward another 1m
|
||||
odom.update(2000, 2000);
|
||||
|
||||
let cum = odom.cumulative();
|
||||
assert_relative_eq!(cum.x, 2.0, epsilon = 0.001);
|
||||
assert_relative_eq!(cum.y, 0.0, epsilon = 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset() {
|
||||
let mut odom = WheelOdometry::new(0.3, 1000.0);
|
||||
|
||||
odom.update(0, 0);
|
||||
odom.update(1000, 1000);
|
||||
|
||||
assert!(odom.cumulative().x > 0.0);
|
||||
|
||||
odom.reset();
|
||||
|
||||
let cum = odom.cumulative();
|
||||
assert_eq!(cum.x, 0.0);
|
||||
assert_eq!(cum.y, 0.0);
|
||||
assert_eq!(cum.theta, 0.0);
|
||||
|
||||
// Should still be initialized
|
||||
assert!(odom.is_initialized());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset_all() {
|
||||
let mut odom = WheelOdometry::new(0.3, 1000.0);
|
||||
|
||||
odom.update(0, 0);
|
||||
odom.update(1000, 1000);
|
||||
|
||||
odom.reset_all();
|
||||
|
||||
assert!(!odom.is_initialized());
|
||||
|
||||
// Next update should re-initialize
|
||||
let delta = odom.update(500, 500);
|
||||
assert_eq!(delta, Pose2D::identity());
|
||||
assert!(odom.is_initialized());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encoder_overflow_handling() {
|
||||
let mut odom = WheelOdometry::new(0.3, 1000.0);
|
||||
|
||||
// Start near max i32
|
||||
odom.update(i32::MAX - 500, i32::MAX - 500);
|
||||
|
||||
// Overflow (in real hardware, this would wrap)
|
||||
// For now, just ensure it doesn't panic
|
||||
let delta = odom.update(i32::MAX - 400, i32::MAX - 400);
|
||||
|
||||
// Should show forward motion of 100 ticks = 0.1m
|
||||
assert_relative_eq!(delta.x, 0.1, epsilon = 0.001);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
//! Scan preprocessing for point cloud filtering.
|
||||
//!
|
||||
//! Provides outlier removal and downsampling operations for lidar scans.
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust
|
||||
//! use vastu_map::preprocessing::{PreprocessingConfig, preprocess, remove_outliers, downsample};
|
||||
//! use vastu_map::core::PointCloud2D;
|
||||
//!
|
||||
//! // Create a point cloud with some outliers
|
||||
//! let mut cloud = PointCloud2D::new();
|
||||
//! cloud.push(0.0, 0.0);
|
||||
//! cloud.push(0.1, 0.0);
|
||||
//! cloud.push(0.2, 0.0);
|
||||
//! cloud.push(10.0, 10.0); // Outlier
|
||||
//!
|
||||
//! // Remove outliers
|
||||
//! let config = PreprocessingConfig::default();
|
||||
//! let clean = remove_outliers(&cloud, &config);
|
||||
//! assert!(clean.len() < cloud.len()); // Outlier removed
|
||||
//! ```
|
||||
|
||||
use crate::core::PointCloud2D;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Configuration for scan preprocessing.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PreprocessingConfig {
|
||||
/// Radius for neighbor search in outlier removal (meters).
|
||||
/// Default: 0.1m
|
||||
pub outlier_radius: f32,
|
||||
|
||||
/// Minimum number of neighbors required to keep a point.
|
||||
/// Points with fewer neighbors are removed as outliers.
|
||||
/// Default: 2
|
||||
pub min_neighbors: usize,
|
||||
|
||||
/// Voxel size for downsampling (meters).
|
||||
/// Set to 0.0 to disable downsampling.
|
||||
/// Default: 0.0 (disabled)
|
||||
pub downsample_resolution: f32,
|
||||
}
|
||||
|
||||
impl Default for PreprocessingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
outlier_radius: 0.1,
|
||||
min_neighbors: 2,
|
||||
downsample_resolution: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PreprocessingConfig {
|
||||
/// Create a new configuration with default values.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Builder-style setter for outlier radius.
|
||||
pub fn with_outlier_radius(mut self, radius: f32) -> Self {
|
||||
self.outlier_radius = radius;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder-style setter for minimum neighbors.
|
||||
pub fn with_min_neighbors(mut self, min: usize) -> Self {
|
||||
self.min_neighbors = min;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder-style setter for downsample resolution.
|
||||
pub fn with_downsample_resolution(mut self, resolution: f32) -> Self {
|
||||
self.downsample_resolution = resolution;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove isolated outlier points using radius-based filtering.
|
||||
///
|
||||
/// Keeps points that have at least `config.min_neighbors` other points
|
||||
/// within `config.outlier_radius` distance.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cloud` - Input point cloud
|
||||
/// * `config` - Preprocessing configuration
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// New point cloud with outliers removed.
|
||||
///
|
||||
/// # Complexity
|
||||
///
|
||||
/// O(n²) where n is the number of points. Sufficient for typical lidar scans (~360 points).
|
||||
pub fn remove_outliers(cloud: &PointCloud2D, config: &PreprocessingConfig) -> PointCloud2D {
|
||||
let mut result = PointCloud2D::with_capacity(cloud.len());
|
||||
remove_outliers_into(cloud, config, &mut result);
|
||||
result
|
||||
}
|
||||
|
||||
/// Zero-allocation variant of outlier removal.
|
||||
///
|
||||
/// Writes results to a pre-allocated output buffer.
|
||||
pub fn remove_outliers_into(
|
||||
cloud: &PointCloud2D,
|
||||
config: &PreprocessingConfig,
|
||||
out: &mut PointCloud2D,
|
||||
) {
|
||||
out.clear();
|
||||
|
||||
if cloud.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let radius_sq = config.outlier_radius * config.outlier_radius;
|
||||
let n = cloud.len();
|
||||
|
||||
// For each point, count neighbors within radius
|
||||
for i in 0..n {
|
||||
let xi = cloud.xs[i];
|
||||
let yi = cloud.ys[i];
|
||||
|
||||
let mut neighbor_count = 0;
|
||||
|
||||
for j in 0..n {
|
||||
if i == j {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dx = cloud.xs[j] - xi;
|
||||
let dy = cloud.ys[j] - yi;
|
||||
let dist_sq = dx * dx + dy * dy;
|
||||
|
||||
if dist_sq <= radius_sq {
|
||||
neighbor_count += 1;
|
||||
if neighbor_count >= config.min_neighbors {
|
||||
break; // Early exit - we have enough neighbors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if neighbor_count >= config.min_neighbors {
|
||||
out.push(xi, yi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reduce point density using voxel grid downsampling.
|
||||
///
|
||||
/// Divides space into grid cells of the given resolution and keeps
|
||||
/// the first point encountered in each cell.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cloud` - Input point cloud
|
||||
/// * `resolution` - Voxel grid cell size in meters
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Downsampled point cloud.
|
||||
///
|
||||
/// # Complexity
|
||||
///
|
||||
/// O(n) where n is the number of points.
|
||||
pub fn downsample(cloud: &PointCloud2D, resolution: f32) -> PointCloud2D {
|
||||
let mut result = PointCloud2D::with_capacity(cloud.len());
|
||||
downsample_into(cloud, resolution, &mut result);
|
||||
result
|
||||
}
|
||||
|
||||
/// Zero-allocation variant of downsampling.
|
||||
///
|
||||
/// Writes results to a pre-allocated output buffer.
|
||||
pub fn downsample_into(cloud: &PointCloud2D, resolution: f32, out: &mut PointCloud2D) {
|
||||
out.clear();
|
||||
|
||||
if cloud.is_empty() || resolution <= 0.0 {
|
||||
// If resolution is invalid, copy all points
|
||||
if resolution <= 0.0 {
|
||||
out.xs.extend_from_slice(&cloud.xs);
|
||||
out.ys.extend_from_slice(&cloud.ys);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let inv_resolution = 1.0 / resolution;
|
||||
|
||||
// Hash map to track which cells have been occupied
|
||||
// Key: (grid_x, grid_y) as i32 tuple converted to single i64
|
||||
let mut occupied: HashMap<(i32, i32), bool> = HashMap::with_capacity(cloud.len());
|
||||
|
||||
for i in 0..cloud.len() {
|
||||
let x = cloud.xs[i];
|
||||
let y = cloud.ys[i];
|
||||
|
||||
// Compute grid cell indices
|
||||
let gx = (x * inv_resolution).floor() as i32;
|
||||
let gy = (y * inv_resolution).floor() as i32;
|
||||
|
||||
// Keep first point in each cell
|
||||
if occupied.insert((gx, gy), true).is_none() {
|
||||
out.push(x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply full preprocessing pipeline.
|
||||
///
|
||||
/// Applies outlier removal followed by downsampling (if resolution > 0).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cloud` - Input point cloud
|
||||
/// * `config` - Preprocessing configuration
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Preprocessed point cloud.
|
||||
pub fn preprocess(cloud: &PointCloud2D, config: &PreprocessingConfig) -> PointCloud2D {
|
||||
let mut result = PointCloud2D::with_capacity(cloud.len());
|
||||
preprocess_into(cloud, config, &mut result);
|
||||
result
|
||||
}
|
||||
|
||||
/// Zero-allocation variant of full preprocessing.
|
||||
///
|
||||
/// Uses an internal temporary buffer for intermediate results.
|
||||
pub fn preprocess_into(cloud: &PointCloud2D, config: &PreprocessingConfig, out: &mut PointCloud2D) {
|
||||
// First, remove outliers
|
||||
let mut temp = PointCloud2D::with_capacity(cloud.len());
|
||||
remove_outliers_into(cloud, config, &mut temp);
|
||||
|
||||
// Then, downsample if enabled
|
||||
if config.downsample_resolution > 0.0 {
|
||||
downsample_into(&temp, config.downsample_resolution, out);
|
||||
} else {
|
||||
// Just copy the outlier-filtered result
|
||||
out.clear();
|
||||
out.xs.extend_from_slice(&temp.xs);
|
||||
out.ys.extend_from_slice(&temp.ys);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_line_cloud() -> PointCloud2D {
|
||||
let mut cloud = PointCloud2D::new();
|
||||
// Line of points at y=0, x from 0 to 1 in 0.05 increments
|
||||
for i in 0..21 {
|
||||
cloud.push(i as f32 * 0.05, 0.0);
|
||||
}
|
||||
cloud
|
||||
}
|
||||
|
||||
fn make_cloud_with_outlier() -> PointCloud2D {
|
||||
let mut cloud = make_line_cloud();
|
||||
// Add an outlier far from the line
|
||||
cloud.push(10.0, 10.0);
|
||||
cloud
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_default() {
|
||||
let config = PreprocessingConfig::default();
|
||||
assert_eq!(config.outlier_radius, 0.1);
|
||||
assert_eq!(config.min_neighbors, 2);
|
||||
assert_eq!(config.downsample_resolution, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_builder() {
|
||||
let config = PreprocessingConfig::new()
|
||||
.with_outlier_radius(0.2)
|
||||
.with_min_neighbors(3)
|
||||
.with_downsample_resolution(0.05);
|
||||
|
||||
assert_eq!(config.outlier_radius, 0.2);
|
||||
assert_eq!(config.min_neighbors, 3);
|
||||
assert_eq!(config.downsample_resolution, 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_outliers_empty() {
|
||||
let cloud = PointCloud2D::new();
|
||||
let config = PreprocessingConfig::default();
|
||||
|
||||
let result = remove_outliers(&cloud, &config);
|
||||
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_outliers_keeps_dense_points() {
|
||||
let cloud = make_line_cloud();
|
||||
let config = PreprocessingConfig::default();
|
||||
|
||||
let result = remove_outliers(&cloud, &config);
|
||||
|
||||
// Most points should be kept (they have neighbors)
|
||||
// Edge points might be removed
|
||||
assert!(result.len() >= cloud.len() - 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_outliers_removes_isolated() {
|
||||
let cloud = make_cloud_with_outlier();
|
||||
let config = PreprocessingConfig::default();
|
||||
|
||||
let result = remove_outliers(&cloud, &config);
|
||||
|
||||
// The outlier at (10, 10) should be removed
|
||||
assert!(result.len() < cloud.len());
|
||||
|
||||
// Check that the outlier is not in the result
|
||||
for i in 0..result.len() {
|
||||
assert!(result.xs[i] < 5.0, "Outlier should be removed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_outliers_into() {
|
||||
let cloud = make_cloud_with_outlier();
|
||||
let config = PreprocessingConfig::default();
|
||||
|
||||
let mut result = PointCloud2D::new();
|
||||
remove_outliers_into(&cloud, &config, &mut result);
|
||||
|
||||
assert!(result.len() < cloud.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_downsample_empty() {
|
||||
let cloud = PointCloud2D::new();
|
||||
|
||||
let result = downsample(&cloud, 0.1);
|
||||
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_downsample_zero_resolution() {
|
||||
let cloud = make_line_cloud();
|
||||
|
||||
let result = downsample(&cloud, 0.0);
|
||||
|
||||
// Should copy all points when resolution is 0
|
||||
assert_eq!(result.len(), cloud.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_downsample_reduces_density() {
|
||||
let cloud = make_line_cloud(); // 21 points from 0 to 1 at 0.05 spacing
|
||||
|
||||
// Downsample with 0.1 resolution - should reduce by ~half
|
||||
let result = downsample(&cloud, 0.1);
|
||||
|
||||
assert!(result.len() < cloud.len());
|
||||
assert!(result.len() >= 10); // At least 10 cells from x=0 to x=1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_downsample_into() {
|
||||
let cloud = make_line_cloud();
|
||||
|
||||
let mut result = PointCloud2D::new();
|
||||
downsample_into(&cloud, 0.1, &mut result);
|
||||
|
||||
assert!(result.len() < cloud.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_downsample_preserves_first_point() {
|
||||
let mut cloud = PointCloud2D::new();
|
||||
cloud.push(0.0, 0.0);
|
||||
cloud.push(0.01, 0.01); // Same cell at 0.1 resolution
|
||||
cloud.push(0.02, 0.02); // Same cell
|
||||
|
||||
let result = downsample(&cloud, 0.1);
|
||||
|
||||
// Should keep only the first point
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result.xs[0], 0.0);
|
||||
assert_eq!(result.ys[0], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preprocess_outlier_only() {
|
||||
let cloud = make_cloud_with_outlier();
|
||||
let config = PreprocessingConfig::default(); // downsample disabled
|
||||
|
||||
let result = preprocess(&cloud, &config);
|
||||
|
||||
// Should remove outlier
|
||||
assert!(result.len() < cloud.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preprocess_full_pipeline() {
|
||||
let cloud = make_cloud_with_outlier();
|
||||
let config = PreprocessingConfig::new()
|
||||
.with_outlier_radius(0.1)
|
||||
.with_min_neighbors(2)
|
||||
.with_downsample_resolution(0.1);
|
||||
|
||||
let result = preprocess(&cloud, &config);
|
||||
|
||||
// Should remove outlier AND downsample
|
||||
assert!(result.len() < cloud.len() - 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preprocess_into() {
|
||||
let cloud = make_cloud_with_outlier();
|
||||
let config = PreprocessingConfig::new().with_downsample_resolution(0.1);
|
||||
|
||||
let mut result = PointCloud2D::new();
|
||||
preprocess_into(&cloud, &config, &mut result);
|
||||
|
||||
assert!(result.len() < cloud.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_negative_coordinates() {
|
||||
let mut cloud = PointCloud2D::new();
|
||||
cloud.push(-1.0, -1.0);
|
||||
cloud.push(-0.95, -0.95);
|
||||
cloud.push(-0.9, -0.9);
|
||||
cloud.push(1.0, 1.0);
|
||||
cloud.push(1.05, 1.05);
|
||||
cloud.push(1.1, 1.1);
|
||||
|
||||
let config = PreprocessingConfig::new()
|
||||
.with_outlier_radius(0.2)
|
||||
.with_min_neighbors(2);
|
||||
|
||||
let result = remove_outliers(&cloud, &config);
|
||||
|
||||
// Should keep points in both clusters
|
||||
assert_eq!(result.len(), 6);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,12 @@
|
||||
//! - **Raycast**: Find distance to obstacles in a direction
|
||||
//! - **Occupancy**: Query whether a point is free, occupied, or unknown
|
||||
//! - **Frontier**: Detect unexplored boundaries for exploration
|
||||
//! - **Path Planning**: Find paths using visibility graph
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use vastu_map::query::{raycast, query_occupancy, detect_frontiers};
|
||||
//! use vastu_map::query::{raycast, query_occupancy, detect_frontiers, PathPlanner};
|
||||
//! use vastu_map::core::Point2D;
|
||||
//!
|
||||
//! // Raycast to find wall distance
|
||||
@@ -21,10 +22,15 @@
|
||||
//!
|
||||
//! // Find exploration frontiers
|
||||
//! let frontiers = detect_frontiers(&map_lines, &frontier_config);
|
||||
//!
|
||||
//! // Plan a path
|
||||
//! let planner = PathPlanner::new(Default::default());
|
||||
//! let path = planner.plan(start, goal, &lines, None, &occupancy_config);
|
||||
//! ```
|
||||
|
||||
pub mod frontier;
|
||||
pub mod occupancy;
|
||||
pub mod path_planning;
|
||||
pub mod raycast;
|
||||
|
||||
// Re-export main types and functions
|
||||
@@ -36,6 +42,7 @@ pub use occupancy::{
|
||||
OccupancyConfig, is_path_clear, is_region_clear, query_occupancy, query_occupancy_batch,
|
||||
query_occupancy_indexed,
|
||||
};
|
||||
pub use path_planning::{PathPlanner, PathPlanningConfig, VisibilityGraph, is_straight_path_clear};
|
||||
pub use raycast::{
|
||||
RaycastResult, raycast, raycast_360, raycast_batch, raycast_detailed, raycast_indexed,
|
||||
raycast_sweep,
|
||||
|
||||
@@ -0,0 +1,737 @@
|
||||
//! Vector-based path planning using Visibility Graph.
|
||||
//!
|
||||
//! Implements optimal shortest-path planning using a visibility graph
|
||||
//! constructed from map line endpoints. This approach is natural for
|
||||
//! VastuMap's line-based representation and provides optimal paths.
|
||||
//!
|
||||
//! # Algorithm
|
||||
//!
|
||||
//! 1. Collect nodes: all line endpoints + start + goal
|
||||
//! 2. Build edges: connect mutually visible node pairs
|
||||
//! 3. Search: Dijkstra from start to goal
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use vastu_map::query::path_planning::{PathPlanner, PathPlanningConfig};
|
||||
//! use vastu_map::core::Point2D;
|
||||
//!
|
||||
//! let config = PathPlanningConfig::default();
|
||||
//! let planner = PathPlanner::new(config);
|
||||
//!
|
||||
//! let path = planner.plan(
|
||||
//! Point2D::new(0.0, 0.0),
|
||||
//! Point2D::new(5.0, 5.0),
|
||||
//! &map_lines,
|
||||
//! Some(&bounds),
|
||||
//! &occupancy_config,
|
||||
//! );
|
||||
//! ```
|
||||
|
||||
use crate::core::{Bounds, Point2D};
|
||||
use crate::features::Line2D;
|
||||
use crate::query::occupancy::OccupancyConfig;
|
||||
use crate::{Occupancy, Path};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::BinaryHeap;
|
||||
|
||||
/// Configuration for path planning.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PathPlanningConfig {
|
||||
/// Robot radius for obstacle inflation (meters).
|
||||
/// Paths will avoid coming within this distance of obstacles.
|
||||
/// Default: 0.15m
|
||||
pub robot_radius: f32,
|
||||
|
||||
/// Maximum number of nodes in the visibility graph.
|
||||
/// Limits computation for very complex maps.
|
||||
/// Default: 1000
|
||||
pub max_nodes: usize,
|
||||
|
||||
/// Only plan through known free space.
|
||||
/// When true, edges that pass through unknown areas are rejected.
|
||||
/// Default: true
|
||||
pub conservative: bool,
|
||||
}
|
||||
|
||||
impl Default for PathPlanningConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
robot_radius: 0.15,
|
||||
max_nodes: 1000,
|
||||
conservative: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PathPlanningConfig {
|
||||
/// Create a new configuration with default values.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Builder-style setter for robot radius.
|
||||
pub fn with_robot_radius(mut self, radius: f32) -> Self {
|
||||
self.robot_radius = radius;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder-style setter for max nodes.
|
||||
pub fn with_max_nodes(mut self, max: usize) -> Self {
|
||||
self.max_nodes = max;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder-style setter for conservative mode.
|
||||
pub fn with_conservative(mut self, conservative: bool) -> Self {
|
||||
self.conservative = conservative;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Visibility graph for path planning.
|
||||
///
|
||||
/// Nodes are points (line endpoints + start/goal), edges connect
|
||||
/// mutually visible points with distance as cost.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VisibilityGraph {
|
||||
/// Node positions.
|
||||
nodes: Vec<Point2D>,
|
||||
|
||||
/// Adjacency list: for each node, list of (neighbor_idx, distance).
|
||||
edges: Vec<Vec<(usize, f32)>>,
|
||||
|
||||
/// Index of start node.
|
||||
start_idx: usize,
|
||||
|
||||
/// Index of goal node.
|
||||
goal_idx: usize,
|
||||
}
|
||||
|
||||
impl VisibilityGraph {
|
||||
/// Build a visibility graph from line endpoints plus start and goal.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `lines` - Map lines (obstacles)
|
||||
/// * `start` - Start position
|
||||
/// * `goal` - Goal position
|
||||
/// * `robot_radius` - Robot radius for collision checking
|
||||
/// * `max_nodes` - Maximum number of nodes
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A visibility graph ready for path search.
|
||||
pub fn build(
|
||||
lines: &[Line2D],
|
||||
start: Point2D,
|
||||
goal: Point2D,
|
||||
robot_radius: f32,
|
||||
max_nodes: usize,
|
||||
) -> Self {
|
||||
// Collect nodes: line endpoints + start + goal
|
||||
let mut nodes = Vec::with_capacity(lines.len() * 2 + 2);
|
||||
|
||||
// Add line endpoints (offset by robot radius perpendicular to line)
|
||||
for line in lines {
|
||||
// Add both endpoints
|
||||
nodes.push(line.start);
|
||||
nodes.push(line.end);
|
||||
|
||||
if nodes.len() >= max_nodes.saturating_sub(2) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add start and goal
|
||||
let start_idx = nodes.len();
|
||||
nodes.push(start);
|
||||
let goal_idx = nodes.len();
|
||||
nodes.push(goal);
|
||||
|
||||
// Build edges using visibility checks
|
||||
let n = nodes.len();
|
||||
let mut edges: Vec<Vec<(usize, f32)>> = vec![Vec::new(); n];
|
||||
|
||||
// For each pair of nodes, check visibility
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
let p1 = nodes[i];
|
||||
let p2 = nodes[j];
|
||||
|
||||
// Create a test segment
|
||||
let test_line = Line2D::new(p1, p2);
|
||||
let test_length = test_line.length();
|
||||
|
||||
// Skip very short edges
|
||||
if test_length < 0.001 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this edge intersects any obstacle line
|
||||
let mut blocked = false;
|
||||
for line in lines {
|
||||
// Skip lines that share an endpoint with the test edge
|
||||
// (these are valid connections to wall corners)
|
||||
let shares_endpoint = p1.distance(line.start) < 0.01
|
||||
|| p1.distance(line.end) < 0.01
|
||||
|| p2.distance(line.start) < 0.01
|
||||
|| p2.distance(line.end) < 0.01;
|
||||
|
||||
// Check for intersection with the obstacle line
|
||||
if let Some(intersection) = test_line.intersection(line) {
|
||||
// Make sure the intersection isn't at the endpoints
|
||||
// (we allow touching at corners)
|
||||
let dist_to_p1 = intersection.distance(p1);
|
||||
let dist_to_p2 = intersection.distance(p2);
|
||||
|
||||
if dist_to_p1 > 0.01 && dist_to_p2 > 0.01 {
|
||||
blocked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check minimum distance to line segment (for robot radius)
|
||||
// Skip this check for lines sharing an endpoint
|
||||
if robot_radius > 0.0 && !shares_endpoint {
|
||||
// Sample points along the test edge
|
||||
let num_samples = (test_length / robot_radius).ceil() as usize;
|
||||
let num_samples = num_samples.max(3);
|
||||
|
||||
for k in 1..num_samples {
|
||||
let t = k as f32 / num_samples as f32;
|
||||
let sample = test_line.point_at(t);
|
||||
// Use distance_to_point_segment for accurate segment distance
|
||||
let dist = line.distance_to_point_segment(sample);
|
||||
|
||||
if dist < robot_radius {
|
||||
blocked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if blocked {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !blocked {
|
||||
let dist = test_length;
|
||||
edges[i].push((j, dist));
|
||||
edges[j].push((i, dist));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
nodes,
|
||||
edges,
|
||||
start_idx,
|
||||
goal_idx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find shortest path using Dijkstra's algorithm.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Sequence of node indices from start to goal, or None if no path exists.
|
||||
pub fn find_path(&self) -> Option<Vec<usize>> {
|
||||
let n = self.nodes.len();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Handle same start and goal
|
||||
if self.start_idx == self.goal_idx {
|
||||
return Some(vec![self.start_idx]);
|
||||
}
|
||||
|
||||
// Distance to each node
|
||||
let mut dist: Vec<f32> = vec![f32::INFINITY; n];
|
||||
let mut prev: Vec<Option<usize>> = vec![None; n];
|
||||
dist[self.start_idx] = 0.0;
|
||||
|
||||
// Priority queue: (negative distance for max-heap to min-heap, node_idx)
|
||||
let mut heap = BinaryHeap::new();
|
||||
heap.push(DijkstraState {
|
||||
cost: 0.0,
|
||||
node: self.start_idx,
|
||||
});
|
||||
|
||||
while let Some(DijkstraState { cost, node }) = heap.pop() {
|
||||
// Skip if we've found a better path
|
||||
if cost > dist[node] {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found goal
|
||||
if node == self.goal_idx {
|
||||
break;
|
||||
}
|
||||
|
||||
// Explore neighbors
|
||||
for &(neighbor, edge_dist) in &self.edges[node] {
|
||||
let new_dist = dist[node] + edge_dist;
|
||||
if new_dist < dist[neighbor] {
|
||||
dist[neighbor] = new_dist;
|
||||
prev[neighbor] = Some(node);
|
||||
heap.push(DijkstraState {
|
||||
cost: new_dist,
|
||||
node: neighbor,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct path
|
||||
prev[self.goal_idx]?;
|
||||
|
||||
let mut path = Vec::new();
|
||||
let mut current = self.goal_idx;
|
||||
while current != self.start_idx {
|
||||
path.push(current);
|
||||
current = prev[current]?;
|
||||
}
|
||||
path.push(self.start_idx);
|
||||
path.reverse();
|
||||
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// Get node position by index.
|
||||
pub fn node(&self, idx: usize) -> Option<&Point2D> {
|
||||
self.nodes.get(idx)
|
||||
}
|
||||
|
||||
/// Get all nodes.
|
||||
pub fn nodes(&self) -> &[Point2D] {
|
||||
&self.nodes
|
||||
}
|
||||
|
||||
/// Get start node index.
|
||||
pub fn start_idx(&self) -> usize {
|
||||
self.start_idx
|
||||
}
|
||||
|
||||
/// Get goal node index.
|
||||
pub fn goal_idx(&self) -> usize {
|
||||
self.goal_idx
|
||||
}
|
||||
|
||||
/// Get number of nodes.
|
||||
pub fn node_count(&self) -> usize {
|
||||
self.nodes.len()
|
||||
}
|
||||
|
||||
/// Get number of edges.
|
||||
pub fn edge_count(&self) -> usize {
|
||||
self.edges.iter().map(|e| e.len()).sum::<usize>() / 2
|
||||
}
|
||||
}
|
||||
|
||||
/// State for Dijkstra's algorithm priority queue.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct DijkstraState {
|
||||
cost: f32,
|
||||
node: usize,
|
||||
}
|
||||
|
||||
impl PartialEq for DijkstraState {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.cost == other.cost
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for DijkstraState {}
|
||||
|
||||
impl Ord for DijkstraState {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
// Reverse ordering for min-heap
|
||||
other
|
||||
.cost
|
||||
.partial_cmp(&self.cost)
|
||||
.unwrap_or(Ordering::Equal)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for DijkstraState {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
/// High-level path planner using visibility graph.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PathPlanner {
|
||||
config: PathPlanningConfig,
|
||||
}
|
||||
|
||||
impl PathPlanner {
|
||||
/// Create a new path planner with the given configuration.
|
||||
pub fn new(config: PathPlanningConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Plan a path from start to goal.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `from` - Start position
|
||||
/// * `to` - Goal position
|
||||
/// * `lines` - Map lines (obstacles)
|
||||
/// * `bounds` - Map bounds (for conservative mode)
|
||||
/// * `occupancy_config` - Occupancy configuration (for conservative mode)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Path if one exists, None otherwise.
|
||||
pub fn plan(
|
||||
&self,
|
||||
from: Point2D,
|
||||
to: Point2D,
|
||||
lines: &[Line2D],
|
||||
bounds: Option<&Bounds>,
|
||||
occupancy_config: &OccupancyConfig,
|
||||
) -> Option<Path> {
|
||||
// Handle same start and goal - trivial path
|
||||
if from.distance(to) < 0.001 {
|
||||
return Some(Path {
|
||||
points: vec![from],
|
||||
length: 0.0,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if start or goal is occupied
|
||||
if self.config.conservative {
|
||||
let start_occ =
|
||||
crate::query::occupancy::query_occupancy(from, lines, bounds, occupancy_config);
|
||||
let goal_occ =
|
||||
crate::query::occupancy::query_occupancy(to, lines, bounds, occupancy_config);
|
||||
|
||||
// In conservative mode, reject if either is not Free
|
||||
if start_occ != Occupancy::Free || goal_occ != Occupancy::Free {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// Build visibility graph
|
||||
let graph = VisibilityGraph::build(
|
||||
lines,
|
||||
from,
|
||||
to,
|
||||
self.config.robot_radius,
|
||||
self.config.max_nodes,
|
||||
);
|
||||
|
||||
// Find path
|
||||
let indices = graph.find_path()?;
|
||||
|
||||
// Convert to Path
|
||||
let mut points = Vec::with_capacity(indices.len());
|
||||
let mut length = 0.0;
|
||||
|
||||
for (i, &idx) in indices.iter().enumerate() {
|
||||
let point = *graph.node(idx)?;
|
||||
points.push(point);
|
||||
|
||||
if i > 0 {
|
||||
let prev = points[i - 1];
|
||||
length += prev.distance(point);
|
||||
}
|
||||
}
|
||||
|
||||
Some(Path { points, length })
|
||||
}
|
||||
|
||||
/// Get the configuration.
|
||||
pub fn config(&self) -> &PathPlanningConfig {
|
||||
&self.config
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a straight-line path is clear of obstacles.
|
||||
///
|
||||
/// This is a simpler check than full path planning - just tests if
|
||||
/// the direct line between two points intersects any obstacles.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `from` - Start position
|
||||
/// * `to` - End position
|
||||
/// * `lines` - Map lines (obstacles)
|
||||
/// * `robot_radius` - Robot radius for clearance
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// True if the straight path is clear.
|
||||
pub fn is_straight_path_clear(
|
||||
from: Point2D,
|
||||
to: Point2D,
|
||||
lines: &[Line2D],
|
||||
robot_radius: f32,
|
||||
) -> bool {
|
||||
let test_line = Line2D::new(from, to);
|
||||
let test_length = test_line.length();
|
||||
|
||||
if test_length < 0.001 {
|
||||
return true; // Zero-length path is always clear
|
||||
}
|
||||
|
||||
for line in lines {
|
||||
// Check for direct intersection
|
||||
if test_line.intersection(line).is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check clearance along the path (use segment distance, not infinite line)
|
||||
if robot_radius > 0.0 {
|
||||
let num_samples = (test_length / robot_radius).ceil() as usize;
|
||||
let num_samples = num_samples.max(3);
|
||||
|
||||
for k in 0..=num_samples {
|
||||
let t = k as f32 / num_samples as f32;
|
||||
let sample = test_line.point_at(t);
|
||||
// Use distance_to_point_segment for accurate segment distance
|
||||
let dist = line.distance_to_point_segment(sample);
|
||||
|
||||
if dist < robot_radius {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_room() -> Vec<Line2D> {
|
||||
// Simple 10x10 room centered at origin
|
||||
vec![
|
||||
Line2D::new(Point2D::new(-5.0, -5.0), Point2D::new(5.0, -5.0)), // Bottom
|
||||
Line2D::new(Point2D::new(5.0, -5.0), Point2D::new(5.0, 5.0)), // Right
|
||||
Line2D::new(Point2D::new(5.0, 5.0), Point2D::new(-5.0, 5.0)), // Top
|
||||
Line2D::new(Point2D::new(-5.0, 5.0), Point2D::new(-5.0, -5.0)), // Left
|
||||
]
|
||||
}
|
||||
|
||||
fn make_room_with_obstacle() -> Vec<Line2D> {
|
||||
let mut lines = make_room();
|
||||
// Add a vertical wall in the middle
|
||||
lines.push(Line2D::new(Point2D::new(0.0, -3.0), Point2D::new(0.0, 3.0)));
|
||||
lines
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_default() {
|
||||
let config = PathPlanningConfig::default();
|
||||
assert_eq!(config.robot_radius, 0.15);
|
||||
assert_eq!(config.max_nodes, 1000);
|
||||
assert!(config.conservative);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_builder() {
|
||||
let config = PathPlanningConfig::new()
|
||||
.with_robot_radius(0.2)
|
||||
.with_max_nodes(500)
|
||||
.with_conservative(false);
|
||||
|
||||
assert_eq!(config.robot_radius, 0.2);
|
||||
assert_eq!(config.max_nodes, 500);
|
||||
assert!(!config.conservative);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_visibility_graph_empty() {
|
||||
let lines: Vec<Line2D> = vec![];
|
||||
let graph = VisibilityGraph::build(
|
||||
&lines,
|
||||
Point2D::new(0.0, 0.0),
|
||||
Point2D::new(1.0, 1.0),
|
||||
0.0,
|
||||
100,
|
||||
);
|
||||
|
||||
// Should have just start and goal
|
||||
assert_eq!(graph.node_count(), 2);
|
||||
assert_eq!(graph.start_idx(), 0);
|
||||
assert_eq!(graph.goal_idx(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_visibility_graph_simple() {
|
||||
let lines = vec![Line2D::new(Point2D::new(0.0, 0.0), Point2D::new(1.0, 0.0))];
|
||||
|
||||
let graph = VisibilityGraph::build(
|
||||
&lines,
|
||||
Point2D::new(-1.0, 0.0),
|
||||
Point2D::new(2.0, 0.0),
|
||||
0.0,
|
||||
100,
|
||||
);
|
||||
|
||||
// 2 line endpoints + start + goal = 4 nodes
|
||||
assert_eq!(graph.node_count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_path_direct() {
|
||||
let lines: Vec<Line2D> = vec![];
|
||||
let graph = VisibilityGraph::build(
|
||||
&lines,
|
||||
Point2D::new(0.0, 0.0),
|
||||
Point2D::new(1.0, 1.0),
|
||||
0.0,
|
||||
100,
|
||||
);
|
||||
|
||||
let path = graph.find_path();
|
||||
assert!(path.is_some());
|
||||
let path = path.unwrap();
|
||||
assert_eq!(path.len(), 2); // Direct path: start -> goal
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_path_around_obstacle() {
|
||||
let lines = make_room_with_obstacle();
|
||||
let graph = VisibilityGraph::build(
|
||||
&lines,
|
||||
Point2D::new(-3.0, 0.0), // Left of center wall
|
||||
Point2D::new(3.0, 0.0), // Right of center wall
|
||||
0.1,
|
||||
100,
|
||||
);
|
||||
|
||||
let path = graph.find_path();
|
||||
assert!(path.is_some());
|
||||
|
||||
let indices = path.unwrap();
|
||||
// Should go around the obstacle (more than 2 nodes)
|
||||
assert!(indices.len() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_planner_direct_path() {
|
||||
let lines = make_room();
|
||||
let config = PathPlanningConfig::new()
|
||||
.with_robot_radius(0.1)
|
||||
.with_conservative(false);
|
||||
let planner = PathPlanner::new(config);
|
||||
let occupancy_config = OccupancyConfig::default();
|
||||
|
||||
let path = planner.plan(
|
||||
Point2D::new(-2.0, 0.0),
|
||||
Point2D::new(2.0, 0.0),
|
||||
&lines,
|
||||
None,
|
||||
&occupancy_config,
|
||||
);
|
||||
|
||||
assert!(path.is_some());
|
||||
let path = path.unwrap();
|
||||
assert!(!path.is_empty());
|
||||
assert!(path.length > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_planner_around_obstacle() {
|
||||
let lines = make_room_with_obstacle();
|
||||
let config = PathPlanningConfig::new()
|
||||
.with_robot_radius(0.1)
|
||||
.with_conservative(false);
|
||||
let planner = PathPlanner::new(config);
|
||||
let occupancy_config = OccupancyConfig::default();
|
||||
|
||||
let path = planner.plan(
|
||||
Point2D::new(-2.0, 0.0),
|
||||
Point2D::new(2.0, 0.0),
|
||||
&lines,
|
||||
None,
|
||||
&occupancy_config,
|
||||
);
|
||||
|
||||
assert!(path.is_some());
|
||||
let path = path.unwrap();
|
||||
// Path should go around the obstacle
|
||||
assert!(path.length > 4.0); // Longer than direct 4m path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_straight_path_clear_empty() {
|
||||
let lines: Vec<Line2D> = vec![];
|
||||
|
||||
assert!(is_straight_path_clear(
|
||||
Point2D::new(0.0, 0.0),
|
||||
Point2D::new(1.0, 1.0),
|
||||
&lines,
|
||||
0.0,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_straight_path_clear_blocked() {
|
||||
let lines = vec![Line2D::new(Point2D::new(0.5, -1.0), Point2D::new(0.5, 1.0))];
|
||||
|
||||
assert!(!is_straight_path_clear(
|
||||
Point2D::new(0.0, 0.0),
|
||||
Point2D::new(1.0, 0.0),
|
||||
&lines,
|
||||
0.0,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_straight_path_clear_with_radius() {
|
||||
let lines = vec![Line2D::new(Point2D::new(0.5, -1.0), Point2D::new(0.5, 1.0))];
|
||||
|
||||
// Path at y=0.5, parallel to obstacle at x=0.5
|
||||
// Should be blocked due to robot radius
|
||||
assert!(!is_straight_path_clear(
|
||||
Point2D::new(0.0, 0.5),
|
||||
Point2D::new(1.0, 0.5),
|
||||
&lines,
|
||||
0.2, // Robot would clip the wall
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_straight_path_clear_with_clearance() {
|
||||
let lines = vec![Line2D::new(Point2D::new(0.5, -1.0), Point2D::new(0.5, 1.0))];
|
||||
|
||||
// Path at y=2.0, far from obstacle
|
||||
assert!(is_straight_path_clear(
|
||||
Point2D::new(0.0, 2.0),
|
||||
Point2D::new(1.0, 2.0),
|
||||
&lines,
|
||||
0.2,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_start_goal() {
|
||||
let lines = make_room();
|
||||
let config = PathPlanningConfig::new().with_conservative(false);
|
||||
let planner = PathPlanner::new(config);
|
||||
let occupancy_config = OccupancyConfig::default();
|
||||
|
||||
let path = planner.plan(
|
||||
Point2D::new(0.0, 0.0),
|
||||
Point2D::new(0.0, 0.0),
|
||||
&lines,
|
||||
None,
|
||||
&occupancy_config,
|
||||
);
|
||||
|
||||
// Should return a path with just one point
|
||||
assert!(path.is_some());
|
||||
let path = path.unwrap();
|
||||
assert_eq!(path.points.len(), 1);
|
||||
assert_eq!(path.length, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use crate::extraction::{CornerConfig, RansacLineConfig, SplitMergeConfig};
|
||||
use crate::integration::{AssociationConfig, MergerConfig};
|
||||
use crate::loop_closure::LoopClosureConfig;
|
||||
use crate::matching::IcpConfig;
|
||||
use crate::query::{FrontierConfig, OccupancyConfig};
|
||||
use crate::query::{FrontierConfig, OccupancyConfig, PathPlanningConfig};
|
||||
|
||||
// ============================================================================
|
||||
// Configuration Validation
|
||||
@@ -116,6 +116,9 @@ pub struct VectorMapConfig {
|
||||
/// Configuration for occupancy queries.
|
||||
pub occupancy: OccupancyConfig,
|
||||
|
||||
/// Configuration for path planning.
|
||||
pub path_planning: PathPlanningConfig,
|
||||
|
||||
/// Configuration for loop closure detection.
|
||||
pub loop_closure: LoopClosureConfig,
|
||||
|
||||
@@ -146,6 +149,7 @@ impl Default for VectorMapConfig {
|
||||
merger: MergerConfig::default(),
|
||||
frontier: FrontierConfig::default(),
|
||||
occupancy: OccupancyConfig::default(),
|
||||
path_planning: PathPlanningConfig::default(),
|
||||
loop_closure: LoopClosureConfig::default(),
|
||||
min_match_confidence: 0.3,
|
||||
mapping_enabled: true,
|
||||
@@ -300,6 +304,20 @@ impl VectorMapConfig {
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder-style setter for robot radius (path planning).
|
||||
///
|
||||
/// Sets the robot radius used for collision checking in path planning.
|
||||
pub fn with_robot_radius(mut self, radius: f32) -> Self {
|
||||
self.path_planning.robot_radius = radius;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder-style setter for path planning configuration.
|
||||
pub fn with_path_planning(mut self, config: PathPlanningConfig) -> Self {
|
||||
self.path_planning = config;
|
||||
self
|
||||
}
|
||||
|
||||
// ===== Configuration Validation =====
|
||||
|
||||
/// Validate the configuration parameters.
|
||||
|
||||
@@ -10,7 +10,9 @@ use crate::integration::{
|
||||
};
|
||||
use crate::loop_closure::{LoopClosure, LoopClosureDetector};
|
||||
use crate::matching::PointToLineIcp;
|
||||
use crate::query::{detect_frontiers, query_occupancy, raycast};
|
||||
use crate::query::{
|
||||
PathPlanner, detect_frontiers, is_straight_path_clear, query_occupancy, raycast,
|
||||
};
|
||||
use crate::{Frontier, Map, ObserveResult, Occupancy, Path, TimingBreakdown};
|
||||
|
||||
use super::config::VectorMapConfig;
|
||||
@@ -558,9 +560,24 @@ impl Map for VectorMap {
|
||||
detect_frontiers(self.line_store.lines(), &self.config.frontier)
|
||||
}
|
||||
|
||||
fn get_path(&self, _from: Point2D, _to: Point2D) -> Option<Path> {
|
||||
// Path planning not yet implemented
|
||||
None
|
||||
fn get_path(&self, from: Point2D, to: Point2D) -> Option<Path> {
|
||||
let planner = PathPlanner::new(self.config.path_planning.clone());
|
||||
planner.plan(
|
||||
from,
|
||||
to,
|
||||
self.line_store.lines(),
|
||||
self.line_store.bounds(),
|
||||
&self.config.occupancy,
|
||||
)
|
||||
}
|
||||
|
||||
fn is_path_clear(&self, from: Point2D, to: Point2D) -> bool {
|
||||
is_straight_path_clear(
|
||||
from,
|
||||
to,
|
||||
self.line_store.lines(),
|
||||
self.config.path_planning.robot_radius,
|
||||
)
|
||||
}
|
||||
|
||||
fn bounds(&self) -> Bounds {
|
||||
|
||||
@@ -10,6 +10,7 @@ use sangam_io::devices::mock::noise::NoiseGenerator;
|
||||
use sangam_io::devices::mock::physics::PhysicsState;
|
||||
|
||||
use vastu_map::core::Pose2D;
|
||||
use vastu_map::odometry::WheelOdometry;
|
||||
use vastu_map::{Map, VectorMap, VectorMapConfig};
|
||||
|
||||
use crate::adapters::lidar_to_point_cloud;
|
||||
@@ -202,6 +203,12 @@ pub struct TestHarness {
|
||||
trajectory: TrajectoryHistory,
|
||||
/// Algorithm timing statistics across all observations
|
||||
timing_stats: TimingStats,
|
||||
/// Wheel odometry tracker using vastu-map's odometry module
|
||||
wheel_odometry: WheelOdometry,
|
||||
/// Simulated left encoder ticks (accumulated)
|
||||
left_encoder_ticks: i32,
|
||||
/// Simulated right encoder ticks (accumulated)
|
||||
right_encoder_ticks: i32,
|
||||
}
|
||||
|
||||
impl TestHarness {
|
||||
@@ -229,6 +236,10 @@ impl TestHarness {
|
||||
|
||||
let start_pose = Pose2D::new(config.start_x, config.start_y, config.start_theta);
|
||||
|
||||
// Create wheel odometry tracker with robot configuration
|
||||
let wheel_odometry =
|
||||
WheelOdometry::new(config.robot.wheel_base, config.robot.ticks_per_meter);
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
map,
|
||||
@@ -245,6 +256,9 @@ impl TestHarness {
|
||||
convergence_stats: ConvergenceStats::new(),
|
||||
trajectory: TrajectoryHistory::new(),
|
||||
timing_stats: TimingStats::new(),
|
||||
wheel_odometry,
|
||||
left_encoder_ticks: 0,
|
||||
right_encoder_ticks: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -272,6 +286,9 @@ impl TestHarness {
|
||||
let lidar_steps = (segment.duration / lidar_dt).max(1.0) as usize;
|
||||
|
||||
for _ in 0..lidar_steps {
|
||||
// Simulate encoder ticks from wheel velocities
|
||||
self.simulate_encoder_ticks(lidar_dt, segment.linear_vel, segment.angular_vel);
|
||||
|
||||
// Single physics update covering the full lidar interval
|
||||
self.physics.update(
|
||||
lidar_dt,
|
||||
@@ -289,6 +306,13 @@ impl TestHarness {
|
||||
let steps = (segment.duration / self.config.dt) as usize;
|
||||
|
||||
for _ in 0..steps {
|
||||
// Simulate encoder ticks from wheel velocities
|
||||
self.simulate_encoder_ticks(
|
||||
self.config.dt,
|
||||
segment.linear_vel,
|
||||
segment.angular_vel,
|
||||
);
|
||||
|
||||
// Update physics
|
||||
self.physics.update(
|
||||
self.config.dt,
|
||||
@@ -336,22 +360,28 @@ impl TestHarness {
|
||||
// Store the robot-frame scan for visualization
|
||||
self.last_scan_robot_frame = Some(cloud.clone());
|
||||
|
||||
// Compute relative odometry from ground truth (simplified)
|
||||
// In real tests, this would come from encoder simulation
|
||||
// Get ground truth pose for metrics/visualization
|
||||
let current_truth = Pose2D::new(self.physics.x(), self.physics.y(), self.physics.theta());
|
||||
|
||||
// Get odometry from wheel encoders using vastu-map's WheelOdometry module
|
||||
let odometry = if self.observations == 0 {
|
||||
// First observation - use absolute pose as initial guess
|
||||
// First observation - initialize odometry and use absolute pose as initial guess
|
||||
// This triggers WheelOdometry initialization with the encoder baseline
|
||||
let _ = self
|
||||
.wheel_odometry
|
||||
.update(self.left_encoder_ticks, self.right_encoder_ticks);
|
||||
current_truth
|
||||
} else {
|
||||
// Subsequent observations - compute relative pose
|
||||
self.compute_relative_odometry(current_truth)
|
||||
// Subsequent observations - get delta from wheel odometry
|
||||
// WheelOdometry::update returns the delta pose in robot frame
|
||||
self.wheel_odometry
|
||||
.update(self.left_encoder_ticks, self.right_encoder_ticks)
|
||||
};
|
||||
|
||||
// Feed to SLAM
|
||||
let result = self.slam.observe(&cloud, odometry);
|
||||
self.last_slam_pose = result.pose;
|
||||
self.last_physics_pose = current_truth; // Update for next odometry computation
|
||||
self.last_physics_pose = current_truth; // Update for metrics computation
|
||||
self.observations += 1;
|
||||
|
||||
// Record ICP convergence stats
|
||||
@@ -386,23 +416,31 @@ impl TestHarness {
|
||||
);
|
||||
}
|
||||
|
||||
/// Compute relative odometry between consecutive ground truth poses.
|
||||
/// Simulate encoder ticks from commanded wheel velocities.
|
||||
///
|
||||
/// Uses physics poses (not SLAM poses) to mimic real-world encoders that
|
||||
/// measure actual wheel motion independent of SLAM corrections.
|
||||
fn compute_relative_odometry(&self, current_truth: Pose2D) -> Pose2D {
|
||||
// Compute delta from previous physics pose (like real encoders)
|
||||
let dx = current_truth.x - self.last_physics_pose.x;
|
||||
let dy = current_truth.y - self.last_physics_pose.y;
|
||||
let dtheta = normalize_angle(current_truth.theta - self.last_physics_pose.theta);
|
||||
/// Computes individual wheel velocities from linear/angular velocity commands
|
||||
/// and integrates them over the timestep to produce encoder tick counts.
|
||||
/// This mimics how real wheel encoders would accumulate ticks.
|
||||
fn simulate_encoder_ticks(&mut self, dt: f32, linear_vel: f32, angular_vel: f32) {
|
||||
// Compute individual wheel velocities from (v, ω) commands
|
||||
// Using differential drive kinematics:
|
||||
// v_left = v - ω * wheel_base / 2
|
||||
// v_right = v + ω * wheel_base / 2
|
||||
let (left_vel, right_vel) =
|
||||
self.physics
|
||||
.wheel_velocities(linear_vel, angular_vel, self.config.robot.wheel_base);
|
||||
|
||||
// Transform to robot frame using previous physics pose orientation
|
||||
let cos_th = self.last_physics_pose.theta.cos();
|
||||
let sin_th = self.last_physics_pose.theta.sin();
|
||||
let dx_robot = dx * cos_th + dy * sin_th;
|
||||
let dy_robot = -dx * sin_th + dy * cos_th;
|
||||
// Integrate wheel velocities to get distance traveled per wheel
|
||||
let left_distance = left_vel * dt;
|
||||
let right_distance = right_vel * dt;
|
||||
|
||||
Pose2D::new(dx_robot, dy_robot, dtheta)
|
||||
// Convert distance to encoder ticks
|
||||
let left_delta_ticks = (left_distance * self.config.robot.ticks_per_meter) as i32;
|
||||
let right_delta_ticks = (right_distance * self.config.robot.ticks_per_meter) as i32;
|
||||
|
||||
// Accumulate encoder ticks
|
||||
self.left_encoder_ticks += left_delta_ticks;
|
||||
self.right_encoder_ticks += right_delta_ticks;
|
||||
}
|
||||
|
||||
/// Finalize the test and compute metrics.
|
||||
@@ -455,15 +493,3 @@ impl TestHarness {
|
||||
&self.physics
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize angle to [-PI, PI].
|
||||
fn normalize_angle(angle: f32) -> f32 {
|
||||
let mut a = angle;
|
||||
while a > std::f32::consts::PI {
|
||||
a -= 2.0 * std::f32::consts::PI;
|
||||
}
|
||||
while a < -std::f32::consts::PI {
|
||||
a += 2.0 * std::f32::consts::PI;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user