Add exploration mode with point-cloud-based line re-fitting

Implements a new exploration mode that stores raw scan data and periodically
re-fits lines from accumulated point clouds, eliminating first-scan bias in
line extraction.

New modules:
- scan_store: Retains raw scans with pose tracking and memory management
- point_association: Associates world-frame points with existing map lines
- refitter: Re-fits lines from accumulated points with gap detection/splitting

Key features:
- ExplorationConfig for controlling re-fit intervals and thresholds
- LineFitConfig with max_line_length splitting for robustness
- Line2D point_count field for tracking scan point origins
- Gap-based line splitting during re-fitting
- Range-based weighting using noise model during fitting
This commit is contained in:
codetiger
2025-12-22 16:53:04 +05:30
parent d6acdd4a88
commit 6f9df02675
16 changed files with 3906 additions and 92 deletions
+116
View File
@@ -119,6 +119,98 @@ impl LidarNoiseModel {
}
}
/// Configuration for exploration mode with point-cloud-based line re-fitting.
///
/// When exploration mode is enabled, VectorMap stores raw scan data and
/// periodically re-fits lines from accumulated point clouds. This eliminates
/// first-scan bias in line extraction.
///
/// # Example
/// ```rust,ignore
/// use vastu_map::config::ExplorationConfig;
///
/// let config = ExplorationConfig::default()
/// .with_refit_interval(10)
/// .with_gap_threshold(0.3);
///
/// map.enable_exploration_mode(config);
/// ```
#[derive(Clone, Debug)]
pub struct ExplorationConfig {
/// Re-fit lines every N observations.
/// Default: 10
pub refit_interval: usize,
/// Minimum points per line to trigger re-fitting.
/// Lines with fewer associated points keep their current geometry.
/// Default: 10
pub min_points_per_line: usize,
/// Maximum perpendicular distance for point-to-line association (meters).
/// Points farther than this from any line are not associated.
/// Default: 0.10m (10cm)
pub max_point_distance: f32,
/// Gap threshold for splitting lines (meters).
/// If consecutive points along a line have a gap larger than this,
/// the line is split into multiple segments.
/// Default: 0.30m (30cm)
pub gap_threshold: f32,
/// Noise model for range-based weighting during re-fitting.
/// Closer points get higher weight.
pub noise_model: LidarNoiseModel,
}
impl Default for ExplorationConfig {
fn default() -> Self {
Self {
refit_interval: 10,
min_points_per_line: 10,
max_point_distance: 0.10,
gap_threshold: 0.30,
noise_model: LidarNoiseModel::default(),
}
}
}
impl ExplorationConfig {
/// Create a new configuration with default values.
pub fn new() -> Self {
Self::default()
}
/// Set the re-fit interval (every N observations).
pub fn with_refit_interval(mut self, interval: usize) -> Self {
self.refit_interval = interval;
self
}
/// Set the minimum points per line for re-fitting.
pub fn with_min_points(mut self, min_points: usize) -> Self {
self.min_points_per_line = min_points;
self
}
/// Set the maximum point-to-line distance for association.
pub fn with_max_point_distance(mut self, distance: f32) -> Self {
self.max_point_distance = distance;
self
}
/// Set the gap threshold for line splitting.
pub fn with_gap_threshold(mut self, gap: f32) -> Self {
self.gap_threshold = gap;
self
}
/// Set the noise model for range-based weighting.
pub fn with_noise_model(mut self, model: LidarNoiseModel) -> Self {
self.noise_model = model;
self
}
}
/// Configuration for VectorMap SLAM.
///
/// All parameters have sensible defaults. Create with `Default::default()`
@@ -465,4 +557,28 @@ mod tests {
assert_eq!(config.noise_model.sigma_base, 0.02);
assert_eq!(config.noise_model.sigma_range, 0.002);
}
#[test]
fn test_exploration_config_default() {
let config = ExplorationConfig::default();
assert_eq!(config.refit_interval, 10);
assert_eq!(config.min_points_per_line, 10);
assert!((config.max_point_distance - 0.10).abs() < 1e-6);
assert!((config.gap_threshold - 0.30).abs() < 1e-6);
}
#[test]
fn test_exploration_config_builder() {
let config = ExplorationConfig::new()
.with_refit_interval(20)
.with_min_points(15)
.with_max_point_distance(0.15)
.with_gap_threshold(0.5);
assert_eq!(config.refit_interval, 20);
assert_eq!(config.min_points_per_line, 15);
assert!((config.max_point_distance - 0.15).abs() < 1e-6);
assert!((config.gap_threshold - 0.5).abs() < 1e-6);
}
}
+503 -5
View File
@@ -41,6 +41,153 @@ use crate::config::LidarNoiseModel;
use crate::core::Point2D;
use crate::features::Line2D;
// ============================================================================
// Configuration
// ============================================================================
/// Configuration for line fitting operations.
///
/// Controls how lines are fitted to point sets, including maximum line length
/// constraints that can improve robustness by creating smaller line segments
/// that are later merged by the integration layer.
///
/// # Example
/// ```
/// use vastu_map::extraction::LineFitConfig;
///
/// // Create config with 1m max line length
/// let config = LineFitConfig::new()
/// .with_max_line_length(1.0);
///
/// // Lines longer than 1m will be split into smaller segments
/// ```
#[derive(Debug, Clone)]
pub struct LineFitConfig {
/// Maximum length for a single line segment.
///
/// If a fitted line exceeds this length, it will be split into multiple
/// smaller segments. This can improve robustness as shorter segments are
/// less prone to fitting errors from outliers.
///
/// Set to `None` (default) for no length limit.
pub max_line_length: Option<f32>,
}
impl Default for LineFitConfig {
fn default() -> Self {
Self {
max_line_length: None,
}
}
}
impl LineFitConfig {
/// Create a new configuration with default settings.
pub fn new() -> Self {
Self::default()
}
/// Set the maximum line length.
///
/// Lines longer than this will be split into smaller segments.
/// Pass `None` to disable length limiting (default).
///
/// # Arguments
/// * `length` - Maximum length in meters, or None for no limit
pub fn with_max_line_length(mut self, length: f32) -> Self {
self.max_line_length = Some(length);
self
}
/// Set the maximum line length (optional variant).
pub fn with_max_line_length_opt(mut self, length: Option<f32>) -> Self {
self.max_line_length = length;
self
}
}
// ============================================================================
// Line Splitting
// ============================================================================
/// Split a line into smaller segments if it exceeds the maximum length.
///
/// Returns a vector of line segments, each no longer than `max_length`.
/// The point counts are distributed proportionally across segments.
///
/// # Arguments
/// * `line` - The line to potentially split
/// * `max_length` - Maximum length for each segment
///
/// # Returns
/// Vector of line segments. If the line is already short enough,
/// returns a vector containing just the original line.
pub fn split_line_by_length(line: &Line2D, max_length: f32) -> Vec<Line2D> {
let length = line.length();
if length <= max_length || max_length <= 0.0 {
return vec![line.clone()];
}
// Calculate number of segments needed
let num_segments = (length / max_length).ceil() as usize;
// Direction vector (not normalized - used with t parameter 0..1)
let dir = line.direction();
let mut segments = Vec::with_capacity(num_segments);
// Distribute point count proportionally
let points_per_segment = if line.point_count > 0 {
(line.point_count as f32 / num_segments as f32).ceil() as u32
} else {
0
};
for i in 0..num_segments {
// Use t parameter (0 to 1) to interpolate along line
let t_start = i as f32 / num_segments as f32;
let t_end = (i + 1) as f32 / num_segments as f32;
let start = Point2D::new(
line.start.x + dir.x * t_start,
line.start.y + dir.y * t_start,
);
let end = if i == num_segments - 1 {
// Use exact endpoint for last segment to avoid floating point drift
line.end
} else {
Point2D::new(
line.start.x + dir.x * t_end,
line.start.y + dir.y * t_end,
)
};
// Each segment inherits observation_count=1, proportional point_count
let segment_point_count = if i == num_segments - 1 {
// Last segment gets remaining points
line.point_count.saturating_sub(points_per_segment * (num_segments - 1) as u32)
} else {
points_per_segment
};
segments.push(Line2D::full(start, end, 1, segment_point_count));
}
segments
}
/// Split multiple lines by maximum length.
///
/// Convenience function that applies `split_line_by_length` to all lines.
pub fn split_lines_by_length(lines: &[Line2D], max_length: f32) -> Vec<Line2D> {
let mut result = Vec::new();
for line in lines {
result.extend(split_line_by_length(line, max_length));
}
result
}
// ============================================================================
// Error Types
// ============================================================================
@@ -212,7 +359,7 @@ pub fn fit_line(points: &[Point2D]) -> Option<Line2D> {
centroid.y + t_max * direction.y,
);
Some(Line2D::new(start, end))
Some(Line2D::with_point_count(start, end, points.len() as u32))
}
/// Fit a line to a set of points using Total Least Squares (with explicit errors).
@@ -307,7 +454,7 @@ pub fn fit_line_checked(points: &[Point2D]) -> Result<Line2D, FitError> {
centroid.y + t_max * direction.y,
);
Ok(Line2D::new(start, end))
Ok(Line2D::with_point_count(start, end, points.len() as u32))
}
/// Simple line fitting fallback (checked version).
@@ -318,7 +465,11 @@ fn fit_line_simple_checked(points: &[Point2D]) -> Result<Line2D, FitError> {
required: 2,
});
}
Ok(Line2D::new(points[0], points[points.len() - 1]))
Ok(Line2D::with_point_count(
points[0],
points[points.len() - 1],
points.len() as u32,
))
}
/// Simple line fitting fallback using first and last points.
@@ -326,7 +477,11 @@ fn fit_line_simple(points: &[Point2D]) -> Option<Line2D> {
if points.len() < 2 {
return None;
}
Some(Line2D::new(points[0], points[points.len() - 1]))
Some(Line2D::with_point_count(
points[0],
points[points.len() - 1],
points.len() as u32,
))
}
/// Compute range-based weights for line fitting.
@@ -453,7 +608,7 @@ pub fn fit_line_weighted(points: &[Point2D], weights: &[f32]) -> Option<Line2D>
centroid.y + t_max * direction.y,
);
Some(Line2D::new(start, end))
Some(Line2D::with_point_count(start, end, points.len() as u32))
}
/// Fit a line to points using range-based weighting from sensor position.
@@ -494,6 +649,119 @@ pub fn fit_line_from_sensor(
fit_line_weighted(points, &weights)
}
// ============================================================================
// Config-based Line Fitting (with max length splitting)
// ============================================================================
/// Fit a line to points with configuration, potentially splitting into segments.
///
/// If `config.max_line_length` is set, lines longer than this threshold
/// will be split into smaller segments for improved robustness.
///
/// # Arguments
/// * `points` - Points to fit line to
/// * `config` - Configuration controlling fitting behavior
///
/// # Returns
/// Vector of fitted line segments. Returns empty if fitting fails.
///
/// # Example
/// ```
/// use vastu_map::extraction::{fit_line_with_config, LineFitConfig};
/// use vastu_map::core::Point2D;
///
/// let points: Vec<Point2D> = (0..50)
/// .map(|i| Point2D::new(i as f32 * 0.1, 0.0)) // 5m line
/// .collect();
///
/// let config = LineFitConfig::new().with_max_line_length(1.0);
/// let lines = fit_line_with_config(&points, &config);
///
/// // Line is split into ~5 segments of ~1m each
/// assert!(lines.len() >= 4);
/// ```
pub fn fit_line_with_config(points: &[Point2D], config: &LineFitConfig) -> Vec<Line2D> {
let Some(line) = fit_line(points) else {
return Vec::new();
};
match config.max_line_length {
Some(max_len) if max_len > 0.0 => split_line_by_length(&line, max_len),
_ => vec![line],
}
}
/// Fit a line to points with weighting and configuration.
///
/// Combines weighted fitting with max length splitting for robust line extraction.
///
/// # Arguments
/// * `points` - Points to fit line to
/// * `weights` - Weight for each point
/// * `config` - Configuration controlling fitting behavior
///
/// # Returns
/// Vector of fitted line segments. Returns empty if fitting fails.
pub fn fit_line_weighted_with_config(
points: &[Point2D],
weights: &[f32],
config: &LineFitConfig,
) -> Vec<Line2D> {
let Some(line) = fit_line_weighted(points, weights) else {
return Vec::new();
};
match config.max_line_length {
Some(max_len) if max_len > 0.0 => split_line_by_length(&line, max_len),
_ => vec![line],
}
}
/// Fit a line using sensor-based weighting with configuration.
///
/// Combines range-based weighting with max length splitting.
///
/// # Arguments
/// * `points` - Points to fit line to
/// * `sensor_pos` - Position of the sensor (typically robot position)
/// * `noise_model` - Lidar noise model parameters (uses default if None)
/// * `config` - Configuration controlling fitting behavior
///
/// # Returns
/// Vector of fitted line segments. Returns empty if fitting fails.
///
/// # Example
/// ```
/// use vastu_map::extraction::{fit_line_from_sensor_with_config, LineFitConfig};
/// use vastu_map::core::Point2D;
///
/// let sensor_pos = Point2D::new(0.0, 0.0);
/// let points: Vec<Point2D> = (1..30)
/// .map(|i| Point2D::new(i as f32 * 0.1, 1.0)) // ~3m wall
/// .collect();
///
/// let config = LineFitConfig::new().with_max_line_length(0.5);
/// let lines = fit_line_from_sensor_with_config(&points, sensor_pos, None, &config);
///
/// // Wall is split into ~6 segments of ~0.5m each
/// assert!(lines.len() >= 5);
/// ```
pub fn fit_line_from_sensor_with_config(
points: &[Point2D],
sensor_pos: Point2D,
noise_model: Option<&LidarNoiseModel>,
config: &LineFitConfig,
) -> Vec<Line2D> {
let Some(line) = fit_line_from_sensor(points, sensor_pos, noise_model) else {
return Vec::new();
};
match config.max_line_length {
Some(max_len) if max_len > 0.0 => split_line_by_length(&line, max_len),
_ => vec![line],
}
}
/// Compute weighted fitting error (RMS distance to line, weighted by measurement quality).
///
/// Returns the weighted root-mean-square of perpendicular distances.
@@ -905,4 +1173,234 @@ mod tests {
weighted_error
);
}
// ========================================
// Line Splitting Tests
// ========================================
#[test]
fn test_split_line_short_line_no_split() {
let line = Line2D::with_point_count(
Point2D::new(0.0, 0.0),
Point2D::new(0.5, 0.0),
10,
);
let segments = split_line_by_length(&line, 1.0);
assert_eq!(segments.len(), 1);
assert_relative_eq!(segments[0].start.x, 0.0, epsilon = 1e-6);
assert_relative_eq!(segments[0].end.x, 0.5, epsilon = 1e-6);
assert_eq!(segments[0].point_count, 10);
}
#[test]
fn test_split_line_exact_max_length() {
let line = Line2D::with_point_count(
Point2D::new(0.0, 0.0),
Point2D::new(1.0, 0.0),
10,
);
let segments = split_line_by_length(&line, 1.0);
assert_eq!(segments.len(), 1);
}
#[test]
fn test_split_line_into_two() {
let line = Line2D::with_point_count(
Point2D::new(0.0, 0.0),
Point2D::new(2.0, 0.0),
20,
);
let segments = split_line_by_length(&line, 1.0);
assert_eq!(segments.len(), 2);
// First segment
assert_relative_eq!(segments[0].start.x, 0.0, epsilon = 1e-6);
assert_relative_eq!(segments[0].end.x, 1.0, epsilon = 1e-6);
assert_eq!(segments[0].point_count, 10);
// Second segment
assert_relative_eq!(segments[1].start.x, 1.0, epsilon = 1e-6);
assert_relative_eq!(segments[1].end.x, 2.0, epsilon = 1e-6);
assert_eq!(segments[1].point_count, 10);
}
#[test]
fn test_split_line_into_multiple() {
let line = Line2D::with_point_count(
Point2D::new(0.0, 0.0),
Point2D::new(5.0, 0.0),
50,
);
let segments = split_line_by_length(&line, 1.0);
assert_eq!(segments.len(), 5);
// Total length should be preserved
let total_length: f32 = segments.iter().map(|s| s.length()).sum();
assert_relative_eq!(total_length, 5.0, epsilon = 1e-5);
// Total point count should be preserved
let total_points: u32 = segments.iter().map(|s| s.point_count).sum();
assert_eq!(total_points, 50);
}
#[test]
fn test_split_line_diagonal() {
// Diagonal line of length 5*sqrt(2) ≈ 7.07
let line = Line2D::with_point_count(
Point2D::new(0.0, 0.0),
Point2D::new(5.0, 5.0),
30,
);
let segments = split_line_by_length(&line, 2.0);
// 7.07 / 2.0 = 3.54, so 4 segments
assert_eq!(segments.len(), 4);
// Segments should be collinear
for seg in &segments {
assert_relative_eq!(seg.angle(), FRAC_PI_4, epsilon = 0.01);
}
// Endpoints should be preserved
assert_relative_eq!(segments[0].start.x, 0.0, epsilon = 1e-5);
assert_relative_eq!(segments[0].start.y, 0.0, epsilon = 1e-5);
assert_relative_eq!(segments.last().unwrap().end.x, 5.0, epsilon = 1e-5);
assert_relative_eq!(segments.last().unwrap().end.y, 5.0, epsilon = 1e-5);
}
#[test]
fn test_split_line_zero_max_length() {
let line = Line2D::with_point_count(
Point2D::new(0.0, 0.0),
Point2D::new(2.0, 0.0),
10,
);
// Zero or negative max_length should return original line
let segments = split_line_by_length(&line, 0.0);
assert_eq!(segments.len(), 1);
let segments_neg = split_line_by_length(&line, -1.0);
assert_eq!(segments_neg.len(), 1);
}
#[test]
fn test_split_lines_by_length() {
let lines = vec![
Line2D::with_point_count(Point2D::new(0.0, 0.0), Point2D::new(3.0, 0.0), 30),
Line2D::with_point_count(Point2D::new(0.0, 1.0), Point2D::new(0.5, 1.0), 5),
];
let segments = split_lines_by_length(&lines, 1.0);
// First line splits into 3, second line stays as 1
assert_eq!(segments.len(), 4);
}
// ========================================
// Config-based Fitting Tests
// ========================================
#[test]
fn test_line_fit_config_default() {
let config = LineFitConfig::default();
assert!(config.max_line_length.is_none());
}
#[test]
fn test_line_fit_config_builder() {
let config = LineFitConfig::new()
.with_max_line_length(1.5);
assert_eq!(config.max_line_length, Some(1.5));
}
#[test]
fn test_fit_line_with_config_no_split() {
let points: Vec<Point2D> = (0..10)
.map(|i| Point2D::new(i as f32 * 0.1, 0.0))
.collect();
// Config with no max length
let config = LineFitConfig::new();
let lines = fit_line_with_config(&points, &config);
assert_eq!(lines.len(), 1);
}
#[test]
fn test_fit_line_with_config_with_split() {
// Create a 5m horizontal line
let points: Vec<Point2D> = (0..51)
.map(|i| Point2D::new(i as f32 * 0.1, 0.0))
.collect();
// Config with 1m max length
let config = LineFitConfig::new().with_max_line_length(1.0);
let lines = fit_line_with_config(&points, &config);
// 5m line should split into 5 segments
assert_eq!(lines.len(), 5);
// Each segment should be approximately 1m
for line in &lines {
assert!(line.length() <= 1.01, "Line length {} > 1.01", line.length());
}
}
#[test]
fn test_fit_line_with_config_empty_input() {
let points: Vec<Point2D> = vec![];
let config = LineFitConfig::new().with_max_line_length(1.0);
let lines = fit_line_with_config(&points, &config);
assert!(lines.is_empty());
}
#[test]
fn test_fit_line_from_sensor_with_config() {
let sensor_pos = Point2D::new(0.0, 0.0);
let points: Vec<Point2D> = (10..40)
.map(|i| Point2D::new(i as f32 * 0.1, 1.0)) // 3m wall at y=1
.collect();
let config = LineFitConfig::new().with_max_line_length(0.5);
let lines = fit_line_from_sensor_with_config(&points, sensor_pos, None, &config);
// 3m line should split into 6 segments of ~0.5m
assert!(lines.len() >= 5, "Expected >= 5 segments, got {}", lines.len());
// All segments should be approximately horizontal
for line in &lines {
assert!(
line.angle().abs() < 0.1,
"Line angle {} is not horizontal",
line.angle()
);
}
}
#[test]
fn test_fit_line_weighted_with_config() {
let points: Vec<Point2D> = (0..31)
.map(|i| Point2D::new(i as f32 * 0.1, 0.0))
.collect();
let weights: Vec<f32> = vec![1.0; 31];
let config = LineFitConfig::new().with_max_line_length(1.0);
let lines = fit_line_weighted_with_config(&points, &weights, &config);
// 3m line should split into 3 segments
assert_eq!(lines.len(), 3);
}
}
+4 -2
View File
@@ -52,8 +52,10 @@ pub use corner_detection::{
detect_curvature_corners, detect_hybrid_corners,
};
pub use line_fitting::{
FitError, compute_range_weights, fit_line, fit_line_checked, fit_line_from_sensor,
fit_line_segment, fit_line_weighted, fitting_error, fitting_error_weighted, max_distance_point,
FitError, LineFitConfig, compute_range_weights, fit_line, fit_line_checked,
fit_line_from_sensor, fit_line_from_sensor_with_config, fit_line_segment, fit_line_weighted,
fit_line_weighted_with_config, fit_line_with_config, fitting_error, fitting_error_weighted,
max_distance_point, split_line_by_length, split_lines_by_length,
};
// Re-export from ransac_lines module
pub use ransac_lines::{
+1 -1
View File
@@ -59,7 +59,7 @@ impl FeatureSet {
/// Add a line from components.
#[inline]
pub fn add_line_from_points(&mut self, start: Point2D, end: Point2D) {
self.lines.push(start.x, start.y, end.x, end.y, 1);
self.lines.push_full(start.x, start.y, end.x, end.y, 1, 0);
}
/// Number of lines.
+38 -1
View File
@@ -29,6 +29,8 @@ pub struct Line2D {
pub end: Point2D,
/// Number of times this line has been observed (for merging).
pub observation_count: u32,
/// Number of scan points that formed this line (for dominance).
pub point_count: u32,
}
impl Line2D {
@@ -39,6 +41,7 @@ impl Line2D {
start,
end,
observation_count: 1,
point_count: 0,
}
}
@@ -49,6 +52,37 @@ impl Line2D {
start,
end,
observation_count: count,
point_count: 0,
}
}
/// Create a line segment with a specific point count.
///
/// Used when extracting lines from scan points where point_count
/// indicates how many scan points formed this line.
#[inline]
pub fn with_point_count(start: Point2D, end: Point2D, point_count: u32) -> Self {
Self {
start,
end,
observation_count: 1,
point_count,
}
}
/// Create a line segment with all fields specified.
#[inline]
pub fn full(
start: Point2D,
end: Point2D,
observation_count: u32,
point_count: u32,
) -> Self {
Self {
start,
end,
observation_count,
point_count,
}
}
@@ -160,10 +194,11 @@ impl Line2D {
(self.end.y - self.start.y) * 0.5 * factor,
);
Line2D::with_observation_count(
Line2D::full(
Point2D::new(mid.x - half_dir.x, mid.y - half_dir.y),
Point2D::new(mid.x + half_dir.x, mid.y + half_dir.y),
self.observation_count,
self.point_count,
)
}
@@ -284,6 +319,7 @@ impl Line2D {
start: pose.transform_point(self.start),
end: pose.transform_point(self.end),
observation_count: self.observation_count,
point_count: self.point_count,
}
}
@@ -392,6 +428,7 @@ impl Default for Line2D {
start: Point2D::zero(),
end: Point2D::zero(),
observation_count: 0,
point_count: 0,
}
}
}
+29 -8
View File
@@ -59,6 +59,8 @@ pub struct LineCollection {
pub end_ys: Vec<f32>,
/// Observation counts for each line.
pub observation_counts: Vec<u32>,
/// Point counts for each line (scan points that formed the line).
pub point_counts: Vec<u32>,
// === Pre-computed cached values for performance ===
/// X components of unit normal vectors.
@@ -84,6 +86,7 @@ impl LineCollection {
end_xs: Vec::with_capacity(capacity),
end_ys: Vec::with_capacity(capacity),
observation_counts: Vec::with_capacity(capacity),
point_counts: Vec::with_capacity(capacity),
normal_xs: Vec::with_capacity(capacity),
normal_ys: Vec::with_capacity(capacity),
inv_lengths: Vec::with_capacity(capacity),
@@ -126,14 +129,23 @@ impl LineCollection {
/// Add a line to the collection.
#[inline]
pub fn push(&mut self, start_x: f32, start_y: f32, end_x: f32, end_y: f32, count: u32) {
pub fn push_full(
&mut self,
start_x: f32,
start_y: f32,
end_x: f32,
end_y: f32,
obs_count: u32,
pt_count: u32,
) {
let (nx, ny, inv_len) = Self::compute_cached_values(start_x, start_y, end_x, end_y);
self.start_xs.push(start_x);
self.start_ys.push(start_y);
self.end_xs.push(end_x);
self.end_ys.push(end_y);
self.observation_counts.push(count);
self.observation_counts.push(obs_count);
self.point_counts.push(pt_count);
self.normal_xs.push(nx);
self.normal_ys.push(ny);
self.inv_lengths.push(inv_len);
@@ -142,12 +154,13 @@ impl LineCollection {
/// Add a Line2D to the collection.
#[inline]
pub fn push_line(&mut self, line: &Line2D) {
self.push(
self.push_full(
line.start.x,
line.start.y,
line.end.x,
line.end.y,
line.observation_count,
line.point_count,
);
}
@@ -195,10 +208,11 @@ impl LineCollection {
#[inline]
pub fn get(&self, index: usize) -> Option<Line2D> {
if index < self.len() {
Some(Line2D::with_observation_count(
Some(Line2D::full(
Point2D::new(self.start_xs[index], self.start_ys[index]),
Point2D::new(self.end_xs[index], self.end_ys[index]),
self.observation_counts[index],
self.point_counts[index],
))
} else {
None
@@ -209,10 +223,11 @@ impl LineCollection {
pub fn to_lines(&self) -> Vec<Line2D> {
(0..self.len())
.map(|i| {
Line2D::with_observation_count(
Line2D::full(
Point2D::new(self.start_xs[i], self.start_ys[i]),
Point2D::new(self.end_xs[i], self.end_ys[i]),
self.observation_counts[i],
self.point_counts[i],
)
})
.collect()
@@ -600,6 +615,7 @@ impl LineCollection {
result.normal_xs.resize(n, 0.0);
result.normal_ys.resize(n, 0.0);
result.observation_counts = self.observation_counts.clone();
result.point_counts = self.point_counts.clone();
// Inverse lengths are preserved by rotation+translation
result.inv_lengths = self.inv_lengths.clone();
@@ -675,6 +691,7 @@ impl LineCollection {
self.end_xs.clear();
self.end_ys.clear();
self.observation_counts.clear();
self.point_counts.clear();
self.normal_xs.clear();
self.normal_ys.clear();
self.inv_lengths.clear();
@@ -687,6 +704,7 @@ impl LineCollection {
self.end_xs.swap_remove(index);
self.end_ys.swap_remove(index);
self.observation_counts.swap_remove(index);
self.point_counts.swap_remove(index);
self.normal_xs.swap_remove(index);
self.normal_ys.swap_remove(index);
self.inv_lengths.swap_remove(index);
@@ -695,10 +713,11 @@ impl LineCollection {
/// Iterate over lines.
pub fn iter(&self) -> impl Iterator<Item = Line2D> + '_ {
(0..self.len()).map(move |i| {
Line2D::with_observation_count(
Line2D::full(
Point2D::new(self.start_xs[i], self.start_ys[i]),
Point2D::new(self.end_xs[i], self.end_ys[i]),
self.observation_counts[i],
self.point_counts[i],
)
})
}
@@ -729,8 +748,8 @@ mod tests {
#[test]
fn test_to_lines() {
let mut collection = LineCollection::new();
collection.push(0.0, 0.0, 1.0, 0.0, 1);
collection.push(0.0, 1.0, 1.0, 1.0, 2);
collection.push_full(0.0, 0.0, 1.0, 0.0, 1, 10);
collection.push_full(0.0, 1.0, 1.0, 1.0, 2, 20);
let lines = collection.to_lines();
@@ -738,7 +757,9 @@ mod tests {
assert_eq!(lines[0].start, Point2D::new(0.0, 0.0));
assert_eq!(lines[0].end, Point2D::new(1.0, 0.0));
assert_eq!(lines[0].observation_count, 1);
assert_eq!(lines[0].point_count, 10);
assert_eq!(lines[1].observation_count, 2);
assert_eq!(lines[1].point_count, 20);
}
#[test]
+628 -5
View File
@@ -66,6 +66,75 @@ impl MergerConfig {
}
}
/// Configuration for coplanar line merging optimization.
///
/// Used by [`optimize_coplanar_lines`] to merge multiple lines
/// that lie on the same infinite line (within tolerances).
#[derive(Clone, Debug)]
pub struct CoplanarMergeConfig {
/// Maximum angle difference for lines to be considered coplanar (radians).
/// Default: 0.052 rad (~3°)
pub max_angle_diff: f32,
/// Maximum perpendicular distance between lines (meters).
/// Default: 0.05m (5cm)
pub max_perpendicular_dist: f32,
/// Maximum gap between line endpoints to allow merging (meters).
/// Lines that are coplanar but separated by more than this gap
/// will not be merged.
/// Default: 0.3m (30cm)
pub max_gap: f32,
/// Minimum point count for a line to become the dominant line.
/// Lines with fewer scan points may be absorbed but won't be chosen
/// as the base direction for merging.
/// Default: 10
pub min_dominant_point_count: u32,
}
impl Default for CoplanarMergeConfig {
fn default() -> Self {
Self {
max_angle_diff: 0.052, // ~3 degrees
max_perpendicular_dist: 0.05, // 5cm
max_gap: 0.3, // 30cm
min_dominant_point_count: 10,
}
}
}
impl CoplanarMergeConfig {
/// Create a new configuration with default values.
pub fn new() -> Self {
Self::default()
}
/// Builder-style setter for maximum angle difference.
pub fn with_max_angle_diff(mut self, radians: f32) -> Self {
self.max_angle_diff = radians;
self
}
/// Builder-style setter for maximum perpendicular distance.
pub fn with_max_perpendicular_dist(mut self, meters: f32) -> Self {
self.max_perpendicular_dist = meters;
self
}
/// Builder-style setter for maximum gap.
pub fn with_max_gap(mut self, meters: f32) -> Self {
self.max_gap = meters;
self
}
/// Builder-style setter for minimum dominant point count.
pub fn with_min_dominant_point_count(mut self, count: u32) -> Self {
self.min_dominant_point_count = count;
self
}
}
/// Result of merging a line.
#[derive(Clone, Debug)]
pub struct MergeResult {
@@ -191,11 +260,12 @@ pub fn merge_lines(
let final_start = new_start + perp * merged_offset;
let final_end = new_end + perp * merged_offset;
// Create merged line with incremented observation count
let merged_line = Line2D::with_observation_count(
// Create merged line with incremented observation count and summed point count
let merged_line = Line2D::full(
final_start,
final_end,
map_line.observation_count.saturating_add(1),
map_line.point_count.saturating_add(scan_line.point_count),
);
MergeResult {
@@ -243,8 +313,9 @@ pub fn batch_merge(
/// Create a new line from a scan observation (for unmatched scan lines).
///
/// This is used when adding new features to the map.
/// Preserves the scan line's point_count for dominance tracking.
pub fn create_new_line(scan_line: &Line2D) -> Line2D {
Line2D::with_observation_count(scan_line.start, scan_line.end, 1)
Line2D::full(scan_line.start, scan_line.end, 1, scan_line.point_count)
}
/// Merge two line segments that represent the same physical feature.
@@ -286,11 +357,270 @@ pub fn merge_collinear_lines(line1: &Line2D, line2: &Line2D) -> Option<Line2D> {
let new_start = reference + dir * min_t;
let new_end = reference + dir * max_t;
let count = line1
let obs_count = line1
.observation_count
.saturating_add(line2.observation_count);
let pt_count = line1.point_count.saturating_add(line2.point_count);
Some(Line2D::with_observation_count(new_start, new_end, count))
Some(Line2D::full(new_start, new_end, obs_count, pt_count))
}
/// Merge two line segments using configurable thresholds.
///
/// This is like [`merge_collinear_lines`] but uses explicit configuration
/// instead of hardcoded values.
pub fn merge_collinear_lines_with_config(
line1: &Line2D,
line2: &Line2D,
config: &CoplanarMergeConfig,
) -> Option<Line2D> {
if !are_lines_coplanar(line1, line2, config) {
return None;
}
// Merge by finding the extended endpoints
let dir = line1.unit_direction();
let reference = line1.midpoint();
let project = |p: Point2D| -> f32 { (p - reference).dot(dir) };
let t1 = project(line1.start);
let t2 = project(line1.end);
let t3 = project(line2.start);
let t4 = project(line2.end);
let min_t = t1.min(t2).min(t3).min(t4);
let max_t = t1.max(t2).max(t3).max(t4);
let new_start = reference + dir * min_t;
let new_end = reference + dir * max_t;
let obs_count = line1
.observation_count
.saturating_add(line2.observation_count);
let pt_count = line1.point_count.saturating_add(line2.point_count);
Some(Line2D::full(new_start, new_end, obs_count, pt_count))
}
/// Check if two lines are coplanar (on the same infinite line within tolerances).
///
/// Two lines are considered coplanar if:
/// 1. Their angles differ by less than `max_angle_diff` (handling 180° ambiguity)
/// 2. The perpendicular distance between them is less than `max_perpendicular_dist`
fn are_lines_coplanar(line1: &Line2D, line2: &Line2D, config: &CoplanarMergeConfig) -> bool {
// Check angular compatibility
let angle_diff = crate::core::math::angle_diff(line1.angle(), line2.angle()).abs();
let angle_diff = angle_diff.min(std::f32::consts::PI - angle_diff);
if angle_diff > config.max_angle_diff {
return false;
}
// Check perpendicular distance using midpoint of line2
let dist = line1.distance_to_point(line2.midpoint());
dist <= config.max_perpendicular_dist
}
/// Check if two coplanar lines can be merged (overlap or small gap).
///
/// Lines can be merged if they are coplanar AND either:
/// - They overlap, OR
/// - The gap between their closest endpoints is less than `max_gap`
fn lines_can_merge(line1: &Line2D, line2: &Line2D, config: &CoplanarMergeConfig) -> bool {
if !are_lines_coplanar(line1, line2, config) {
return false;
}
// Project line2's endpoints onto line1's direction to check overlap/gap
let dir = line1.unit_direction();
let reference = line1.midpoint();
let project = |p: Point2D| -> f32 { (p - reference).dot(dir) };
let t1_min = project(line1.start).min(project(line1.end));
let t1_max = project(line1.start).max(project(line1.end));
let t2_min = project(line2.start).min(project(line2.end));
let t2_max = project(line2.start).max(project(line2.end));
// Check for overlap
if t1_max >= t2_min && t2_max >= t1_min {
return true;
}
// Check gap
let gap = if t1_max < t2_min {
t2_min - t1_max
} else {
t1_min - t2_max
};
gap <= config.max_gap
}
/// Merge a group of coplanar lines into a single extended line.
///
/// Uses the line with most scan points as the base direction (for robust dominance).
/// Falls back to observation_count if no line has point_count set.
/// Both observation_count and point_count are summed.
///
/// # Panics
/// Panics if `lines` is empty.
fn merge_coplanar_group(lines: &[&Line2D]) -> Line2D {
assert!(!lines.is_empty(), "Cannot merge empty group");
if lines.len() == 1 {
return *lines[0];
}
// Find the dominant line (most scan points, fallback to observation count)
let dominant = lines
.iter()
.max_by_key(|l| {
// Use point_count if available, otherwise fall back to observation_count
if l.point_count > 0 {
l.point_count
} else {
l.observation_count
}
})
.unwrap();
let dir = dominant.unit_direction();
let reference = dominant.midpoint();
let project = |p: Point2D| -> f32 { (p - reference).dot(dir) };
// Find extent across all lines and sum counts
let mut min_t = f32::MAX;
let mut max_t = f32::MIN;
let mut total_obs_count: u32 = 0;
let mut total_pt_count: u32 = 0;
for line in lines {
let t_start = project(line.start);
let t_end = project(line.end);
min_t = min_t.min(t_start).min(t_end);
max_t = max_t.max(t_start).max(t_end);
total_obs_count = total_obs_count.saturating_add(line.observation_count);
total_pt_count = total_pt_count.saturating_add(line.point_count);
}
let new_start = reference + dir * min_t;
let new_end = reference + dir * max_t;
Line2D::full(new_start, new_end, total_obs_count, total_pt_count)
}
/// Optimize map lines by merging coplanar segments.
///
/// Groups lines by coplanarity (similar angle + perpendicular offset) and merges
/// each group into a single extended line using the most-observed line as the base.
///
/// # Arguments
/// * `lines` - Mutable vector of lines to optimize (modified in place)
/// * `config` - Coplanar merge configuration
///
/// # Returns
/// Number of lines merged (removed from the vector)
///
/// # Example
/// ```rust,ignore
/// use vastu_map::integration::{optimize_coplanar_lines, CoplanarMergeConfig};
///
/// let mut lines = vec![...]; // Multiple lines on the same wall
/// let config = CoplanarMergeConfig::default();
/// let merged_count = optimize_coplanar_lines(&mut lines, &config);
/// println!("Merged {} lines", merged_count);
/// ```
pub fn optimize_coplanar_lines(lines: &mut Vec<Line2D>, config: &CoplanarMergeConfig) -> usize {
if lines.len() < 2 {
return 0;
}
let n = lines.len();
// Union-Find for grouping coplanar lines
let mut parent: Vec<usize> = (0..n).collect();
let mut rank: Vec<usize> = vec![0; n];
fn find(parent: &mut [usize], i: usize) -> usize {
if parent[i] != i {
parent[i] = find(parent, parent[i]);
}
parent[i]
}
fn union(parent: &mut [usize], rank: &mut [usize], i: usize, j: usize) {
let pi = find(parent, i);
let pj = find(parent, j);
if pi != pj {
if rank[pi] < rank[pj] {
parent[pi] = pj;
} else if rank[pi] > rank[pj] {
parent[pj] = pi;
} else {
parent[pj] = pi;
rank[pi] += 1;
}
}
}
// Group coplanar lines that can be merged
for i in 0..n {
for j in (i + 1)..n {
if lines_can_merge(&lines[i], &lines[j], config) {
union(&mut parent, &mut rank, i, j);
}
}
}
// Collect groups
let mut groups: std::collections::HashMap<usize, Vec<usize>> =
std::collections::HashMap::new();
for i in 0..n {
let root = find(&mut parent, i);
groups.entry(root).or_default().push(i);
}
// Process each group and build result
let mut result: Vec<Line2D> = Vec::with_capacity(n);
let mut merged_count = 0;
for (_root, indices) in groups {
if indices.len() == 1 {
// Single line, keep as-is
result.push(lines[indices[0]]);
} else {
// Check if any line in the group has enough scan points to be dominant
let has_dominant = indices.iter().any(|&i| {
let line = &lines[i];
// Use point_count if available, fall back to observation_count
let confidence = if line.point_count > 0 {
line.point_count
} else {
line.observation_count
};
confidence >= config.min_dominant_point_count
});
if !has_dominant {
// No dominant line, keep all lines as-is
for &idx in &indices {
result.push(lines[idx]);
}
} else {
// Merge the group
let group_lines: Vec<&Line2D> = indices.iter().map(|&i| &lines[i]).collect();
let merged = merge_coplanar_group(&group_lines);
result.push(merged);
merged_count += indices.len() - 1;
}
}
}
*lines = result;
merged_count
}
#[cfg(test)]
@@ -418,4 +748,297 @@ mod tests {
assert!(merged.is_none());
}
// ===== Coplanar merge config tests =====
#[test]
fn test_coplanar_merge_config_default() {
let config = CoplanarMergeConfig::default();
assert_relative_eq!(config.max_angle_diff, 0.052, epsilon = 0.001);
assert_relative_eq!(config.max_perpendicular_dist, 0.05, epsilon = 0.001);
assert_relative_eq!(config.max_gap, 0.3, epsilon = 0.001);
assert_eq!(config.min_dominant_point_count, 10);
}
#[test]
fn test_coplanar_merge_config_builders() {
let config = CoplanarMergeConfig::new()
.with_max_angle_diff(0.2)
.with_max_perpendicular_dist(0.08)
.with_max_gap(1.0)
.with_min_dominant_point_count(5);
assert_relative_eq!(config.max_angle_diff, 0.2, epsilon = 0.001);
assert_relative_eq!(config.max_perpendicular_dist, 0.08, epsilon = 0.001);
assert_relative_eq!(config.max_gap, 1.0, epsilon = 0.001);
assert_eq!(config.min_dominant_point_count, 5);
}
// ===== are_lines_coplanar tests =====
#[test]
fn test_are_lines_coplanar_same_line() {
let line1 = Line2D::new(Point2D::new(0.0, 0.0), Point2D::new(5.0, 0.0));
let line2 = Line2D::new(Point2D::new(3.0, 0.0), Point2D::new(8.0, 0.0));
let config = CoplanarMergeConfig::default();
assert!(are_lines_coplanar(&line1, &line2, &config));
}
#[test]
fn test_are_lines_coplanar_parallel_close() {
let line1 = Line2D::new(Point2D::new(0.0, 0.0), Point2D::new(5.0, 0.0));
let line2 = Line2D::new(Point2D::new(0.0, 0.05), Point2D::new(5.0, 0.05)); // 5cm offset
let config = CoplanarMergeConfig::default();
assert!(are_lines_coplanar(&line1, &line2, &config));
}
#[test]
fn test_are_lines_coplanar_rejects_angle() {
let line1 = Line2D::new(Point2D::new(0.0, 0.0), Point2D::new(5.0, 0.0));
let line2 = Line2D::new(Point2D::new(0.0, 0.0), Point2D::new(5.0, 1.0)); // ~11° angle
let config = CoplanarMergeConfig::default();
assert!(!are_lines_coplanar(&line1, &line2, &config));
}
#[test]
fn test_are_lines_coplanar_rejects_distance() {
let line1 = Line2D::new(Point2D::new(0.0, 0.0), Point2D::new(5.0, 0.0));
let line2 = Line2D::new(Point2D::new(0.0, 0.5), Point2D::new(5.0, 0.5)); // 50cm offset
let config = CoplanarMergeConfig::default();
assert!(!are_lines_coplanar(&line1, &line2, &config));
}
#[test]
fn test_are_lines_coplanar_opposite_direction() {
// Lines pointing in opposite directions should still be coplanar
let line1 = Line2D::new(Point2D::new(0.0, 0.0), Point2D::new(5.0, 0.0));
let line2 = Line2D::new(Point2D::new(10.0, 0.0), Point2D::new(6.0, 0.0)); // Opposite direction
let config = CoplanarMergeConfig::default();
assert!(are_lines_coplanar(&line1, &line2, &config));
}
// ===== lines_can_merge tests =====
#[test]
fn test_lines_can_merge_overlapping() {
let line1 = Line2D::new(Point2D::new(0.0, 0.0), Point2D::new(5.0, 0.0));
let line2 = Line2D::new(Point2D::new(3.0, 0.0), Point2D::new(8.0, 0.0));
let config = CoplanarMergeConfig::default();
assert!(lines_can_merge(&line1, &line2, &config));
}
#[test]
fn test_lines_can_merge_with_small_gap() {
let line1 = Line2D::new(Point2D::new(0.0, 0.0), Point2D::new(3.0, 0.0));
let line2 = Line2D::new(Point2D::new(3.3, 0.0), Point2D::new(6.0, 0.0)); // 0.3m gap
let config = CoplanarMergeConfig::default();
assert!(lines_can_merge(&line1, &line2, &config));
}
#[test]
fn test_lines_can_merge_rejects_large_gap() {
let line1 = Line2D::new(Point2D::new(0.0, 0.0), Point2D::new(3.0, 0.0));
let line2 = Line2D::new(Point2D::new(4.0, 0.0), Point2D::new(7.0, 0.0)); // 1.0m gap
let config = CoplanarMergeConfig::default();
assert!(!lines_can_merge(&line1, &line2, &config));
}
// ===== optimize_coplanar_lines tests =====
#[test]
fn test_optimize_coplanar_lines_merges_overlapping() {
let mut lines = vec![
Line2D::with_point_count(
Point2D::new(0.0, 0.0),
Point2D::new(3.0, 0.0),
15, // Above threshold
),
Line2D::with_point_count(
Point2D::new(2.0, 0.0),
Point2D::new(5.0, 0.0),
12,
),
];
let config = CoplanarMergeConfig::default();
let merged_count = optimize_coplanar_lines(&mut lines, &config);
assert_eq!(merged_count, 1);
assert_eq!(lines.len(), 1);
assert_relative_eq!(lines[0].length(), 5.0, epsilon = 0.01);
assert_eq!(lines[0].point_count, 27); // 15 + 12
}
#[test]
fn test_optimize_coplanar_lines_merges_with_gap() {
let mut lines = vec![
Line2D::with_point_count(
Point2D::new(0.0, 0.0),
Point2D::new(2.0, 0.0),
12,
),
Line2D::with_point_count(
Point2D::new(2.2, 0.0), // 0.2m gap, within default 0.3m
Point2D::new(5.0, 0.0),
15,
),
];
let config = CoplanarMergeConfig::default();
let merged_count = optimize_coplanar_lines(&mut lines, &config);
assert_eq!(merged_count, 1);
assert_eq!(lines.len(), 1);
assert_relative_eq!(lines[0].length(), 5.0, epsilon = 0.01);
}
#[test]
fn test_optimize_coplanar_lines_uses_dominant() {
// The line with most points should determine the direction
let mut lines = vec![
Line2D::with_point_count(
Point2D::new(0.0, 0.0),
Point2D::new(2.0, 0.0),
25, // Dominant (most points)
),
Line2D::with_point_count(
Point2D::new(2.0, 0.0),
Point2D::new(4.0, 0.01), // Slightly off
10,
),
];
let config = CoplanarMergeConfig::default();
let merged_count = optimize_coplanar_lines(&mut lines, &config);
assert_eq!(merged_count, 1);
assert_eq!(lines.len(), 1);
// The merged line should use the dominant line's direction
// So it should be close to horizontal
assert!(lines[0].angle().abs() < 0.02);
}
#[test]
fn test_optimize_coplanar_lines_respects_max_gap() {
let mut lines = vec![
Line2D::with_point_count(
Point2D::new(0.0, 0.0),
Point2D::new(2.0, 0.0),
15,
),
Line2D::with_point_count(
Point2D::new(4.0, 0.0), // 2.0m gap, above default 0.3m
Point2D::new(6.0, 0.0),
15,
),
];
let config = CoplanarMergeConfig::default(); // max_gap = 0.3m
let merged_count = optimize_coplanar_lines(&mut lines, &config);
assert_eq!(merged_count, 0);
assert_eq!(lines.len(), 2);
}
#[test]
fn test_optimize_coplanar_lines_keeps_non_dominant() {
// When no line has enough points, don't merge
let mut lines = vec![
Line2D::with_point_count(
Point2D::new(0.0, 0.0),
Point2D::new(2.0, 0.0),
5, // Below threshold of 10
),
Line2D::with_point_count(
Point2D::new(2.0, 0.0),
Point2D::new(4.0, 0.0),
5, // Below threshold of 10
),
];
let config = CoplanarMergeConfig::default(); // min_dominant_point_count = 10
let merged_count = optimize_coplanar_lines(&mut lines, &config);
assert_eq!(merged_count, 0);
assert_eq!(lines.len(), 2);
}
#[test]
fn test_optimize_coplanar_lines_multiple_groups() {
// Two separate walls (horizontal and vertical)
let mut lines = vec![
// Horizontal wall (will merge)
Line2D::with_point_count(
Point2D::new(0.0, 0.0),
Point2D::new(2.0, 0.0),
15,
),
Line2D::with_point_count(
Point2D::new(2.0, 0.0),
Point2D::new(4.0, 0.0),
12,
),
// Vertical wall (will merge)
Line2D::with_point_count(
Point2D::new(5.0, 0.0),
Point2D::new(5.0, 2.0),
15,
),
Line2D::with_point_count(
Point2D::new(5.0, 2.0),
Point2D::new(5.0, 4.0),
12,
),
];
let config = CoplanarMergeConfig::default();
let merged_count = optimize_coplanar_lines(&mut lines, &config);
assert_eq!(merged_count, 2); // Merged 2 lines (one from each group)
assert_eq!(lines.len(), 2); // Two merged lines remain
}
#[test]
fn test_optimize_coplanar_lines_empty() {
let mut lines: Vec<Line2D> = vec![];
let config = CoplanarMergeConfig::default();
let merged_count = optimize_coplanar_lines(&mut lines, &config);
assert_eq!(merged_count, 0);
assert_eq!(lines.len(), 0);
}
#[test]
fn test_optimize_coplanar_lines_single() {
let mut lines = vec![Line2D::new(Point2D::new(0.0, 0.0), Point2D::new(5.0, 0.0))];
let config = CoplanarMergeConfig::default();
let merged_count = optimize_coplanar_lines(&mut lines, &config);
assert_eq!(merged_count, 0);
assert_eq!(lines.len(), 1);
}
#[test]
fn test_merge_collinear_lines_with_config() {
let line1 = Line2D::new(Point2D::new(0.0, 0.0), Point2D::new(3.0, 0.0));
let line2 = Line2D::new(Point2D::new(2.0, 0.0), Point2D::new(5.0, 0.0));
let config = CoplanarMergeConfig::default();
let merged = merge_collinear_lines_with_config(&line1, &line2, &config);
assert!(merged.is_some());
let merged = merged.unwrap();
assert_relative_eq!(merged.length(), 5.0, epsilon = 0.01);
}
}
+11 -1
View File
@@ -41,6 +41,9 @@
pub mod association;
pub mod merger;
pub mod point_association;
pub mod refitter;
pub mod scan_store;
pub mod spatial_index;
// Re-export main types
@@ -49,6 +52,13 @@ pub use association::{
find_unmatched_scan_lines,
};
pub use merger::{
MergeResult, MergerConfig, batch_merge, create_new_line, merge_collinear_lines, merge_lines,
CoplanarMergeConfig, MergeResult, MergerConfig, batch_merge, create_new_line,
merge_collinear_lines, merge_collinear_lines_with_config, merge_lines, optimize_coplanar_lines,
};
pub use point_association::{
AssociatedPoint, PointAssociationConfig, PointAssociationResult, associate_points_to_lines,
associate_scan_points, extract_points_for_line, lines_with_enough_points,
};
pub use refitter::{RefitConfig, RefitResult, RefitStats, refit_line, refit_line_simple};
pub use scan_store::{ScanStore, ScanStoreConfig, ScanStoreStats, StoredScan};
pub use spatial_index::{IndexedLine, SpatialIndex};
@@ -0,0 +1,550 @@
//! Point-to-line association for line re-fitting.
//!
//! Associates world-frame points from ScanStore with existing map lines,
//! enabling periodic re-fitting of lines from accumulated point clouds.
use crate::core::Point2D;
use crate::features::Line2D;
use crate::integration::{ScanStore, SpatialIndex};
/// Configuration for point-to-line association.
#[derive(Clone, Debug)]
pub struct PointAssociationConfig {
/// Maximum perpendicular distance from line to associate a point (meters).
/// Points further than this from all lines are unassociated.
/// Default: 0.10m (10cm)
pub max_distance: f32,
/// Maximum projection extension beyond line endpoints.
/// Points can project up to this fraction beyond line ends.
/// 0.0 = strict segment bounds, 0.2 = 20% extension allowed.
/// Default: 0.1 (10% extension)
pub max_projection_extension: f32,
}
impl Default for PointAssociationConfig {
fn default() -> Self {
Self {
max_distance: 0.10,
max_projection_extension: 0.1,
}
}
}
impl PointAssociationConfig {
/// Create a new configuration with defaults.
pub fn new() -> Self {
Self::default()
}
/// Set maximum association distance.
pub fn with_max_distance(mut self, meters: f32) -> Self {
self.max_distance = meters;
self
}
/// Set maximum projection extension.
pub fn with_max_projection_extension(mut self, fraction: f32) -> Self {
self.max_projection_extension = fraction;
self
}
}
/// A point associated with a map line.
#[derive(Clone, Debug)]
pub struct AssociatedPoint {
/// World-frame point.
pub point: Point2D,
/// Scan ID the point came from.
pub scan_id: u32,
/// Perpendicular distance to the line.
pub distance: f32,
/// Projection parameter on the line (0=start, 1=end).
pub projection_t: f32,
}
/// Result of point-to-line association.
#[derive(Clone, Debug)]
pub struct PointAssociationResult {
/// Points associated with each map line, indexed by line index.
pub line_points: Vec<Vec<AssociatedPoint>>,
/// Number of points associated.
pub total_associated: usize,
/// Number of points not associated (too far from all lines).
pub total_unassociated: usize,
}
impl PointAssociationResult {
/// Get the total number of points processed.
pub fn total_points(&self) -> usize {
self.total_associated + self.total_unassociated
}
/// Get association rate (0.0 - 1.0).
pub fn association_rate(&self) -> f32 {
let total = self.total_points();
if total == 0 {
0.0
} else {
self.total_associated as f32 / total as f32
}
}
}
/// Associate all points from ScanStore with map lines.
///
/// Each point is associated with its nearest line if within `max_distance`
/// and within projection bounds (considering extension).
///
/// # Arguments
/// * `scan_store` - Storage containing accumulated scans
/// * `lines` - Map lines to associate points with
/// * `index` - Spatial index for efficient line lookup
/// * `config` - Association configuration
///
/// # Returns
/// Association result with points grouped by line index.
pub fn associate_points_to_lines(
scan_store: &ScanStore,
lines: &[Line2D],
index: &SpatialIndex,
config: &PointAssociationConfig,
) -> PointAssociationResult {
let num_lines = lines.len();
let mut line_points: Vec<Vec<AssociatedPoint>> = vec![Vec::new(); num_lines];
let mut total_associated = 0usize;
let mut total_unassociated = 0usize;
// Iterate through all scans and their world-frame points
for scan in scan_store.iter() {
let pose = &scan.estimated_pose;
for robot_point in scan.points() {
// Transform to world frame
let world_point = pose.transform_point(*robot_point);
// Find nearest line using spatial index
if let Some((line_idx, distance)) = index.nearest_line(world_point) {
// Check distance threshold
if distance <= config.max_distance && line_idx < num_lines {
let line = &lines[line_idx];
// Check projection bounds
let t = line.project_point(world_point);
if line.contains_projection_extended(t, config.max_projection_extension) {
line_points[line_idx].push(AssociatedPoint {
point: world_point,
scan_id: scan.scan_id,
distance,
projection_t: t,
});
total_associated += 1;
continue;
}
}
}
total_unassociated += 1;
}
}
PointAssociationResult {
line_points,
total_associated,
total_unassociated,
}
}
/// Associate points from a single scan with map lines.
///
/// Similar to `associate_points_to_lines` but for a single scan.
/// Useful for incremental association during observations.
pub fn associate_scan_points(
points: &[Point2D],
scan_id: u32,
lines: &[Line2D],
index: &SpatialIndex,
config: &PointAssociationConfig,
) -> PointAssociationResult {
let num_lines = lines.len();
let mut line_points: Vec<Vec<AssociatedPoint>> = vec![Vec::new(); num_lines];
let mut total_associated = 0usize;
let mut total_unassociated = 0usize;
for &world_point in points {
if let Some((line_idx, distance)) = index.nearest_line(world_point) {
if distance <= config.max_distance && line_idx < num_lines {
let line = &lines[line_idx];
let t = line.project_point(world_point);
if line.contains_projection_extended(t, config.max_projection_extension) {
line_points[line_idx].push(AssociatedPoint {
point: world_point,
scan_id,
distance,
projection_t: t,
});
total_associated += 1;
continue;
}
}
}
total_unassociated += 1;
}
PointAssociationResult {
line_points,
total_associated,
total_unassociated,
}
}
/// Get line indices that have enough points for re-fitting.
///
/// Returns indices of lines with at least `min_points` associated points.
pub fn lines_with_enough_points(result: &PointAssociationResult, min_points: usize) -> Vec<usize> {
result
.line_points
.iter()
.enumerate()
.filter(|(_, points)| points.len() >= min_points)
.map(|(idx, _)| idx)
.collect()
}
/// Extract just the points for a specific line (without metadata).
///
/// Useful when only the point coordinates are needed for fitting.
pub fn extract_points_for_line(
result: &PointAssociationResult,
line_idx: usize,
) -> Vec<Point2D> {
result
.line_points
.get(line_idx)
.map(|pts| pts.iter().map(|ap| ap.point).collect())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{PointCloud2D, Pose2D};
fn make_horizontal_line(y: f32, x_start: f32, x_end: f32) -> Line2D {
Line2D::new(Point2D::new(x_start, y), Point2D::new(x_end, y))
}
fn make_vertical_line(x: f32, y_start: f32, y_end: f32) -> Line2D {
Line2D::new(Point2D::new(x, y_start), Point2D::new(x, y_end))
}
fn make_scan_store_with_points(points: &[(f32, f32)]) -> ScanStore {
use crate::integration::ScanStoreConfig;
let mut store = ScanStore::new(ScanStoreConfig::default());
let xs: Vec<f32> = points.iter().map(|(x, _)| *x).collect();
let ys: Vec<f32> = points.iter().map(|(_, y)| *y).collect();
let cloud = PointCloud2D { xs, ys };
// Use identity pose so robot-frame = world-frame
store.add_scan(Pose2D::identity(), Pose2D::identity(), &cloud, 0, 1.0);
store
}
#[test]
fn test_config_defaults() {
let config = PointAssociationConfig::default();
assert!((config.max_distance - 0.10).abs() < 0.001);
assert!((config.max_projection_extension - 0.1).abs() < 0.001);
}
#[test]
fn test_config_builder() {
let config = PointAssociationConfig::new()
.with_max_distance(0.15)
.with_max_projection_extension(0.2);
assert!((config.max_distance - 0.15).abs() < 0.001);
assert!((config.max_projection_extension - 0.2).abs() < 0.001);
}
#[test]
fn test_empty_inputs() {
let store = ScanStore::default();
let lines: Vec<Line2D> = vec![];
let index = SpatialIndex::empty();
let config = PointAssociationConfig::default();
let result = associate_points_to_lines(&store, &lines, &index, &config);
assert_eq!(result.total_associated, 0);
assert_eq!(result.total_unassociated, 0);
assert!(result.line_points.is_empty());
}
#[test]
fn test_points_associate_to_nearest_line() {
// Create two horizontal lines
let lines = vec![
make_horizontal_line(0.0, 0.0, 10.0), // y=0
make_horizontal_line(5.0, 0.0, 10.0), // y=5
];
let index = SpatialIndex::new(&lines);
let config = PointAssociationConfig::default().with_max_distance(0.5);
// Points close to line 0 (y=0)
let store = make_scan_store_with_points(&[
(2.0, 0.05), // Very close to y=0
(5.0, 0.02), // Very close to y=0
(8.0, 4.98), // Very close to y=5
]);
let result = associate_points_to_lines(&store, &lines, &index, &config);
assert_eq!(result.total_associated, 3);
assert_eq!(result.line_points[0].len(), 2); // Two points near y=0
assert_eq!(result.line_points[1].len(), 1); // One point near y=5
}
#[test]
fn test_points_outside_max_distance() {
let lines = vec![make_horizontal_line(0.0, 0.0, 10.0)];
let index = SpatialIndex::new(&lines);
let config = PointAssociationConfig::default().with_max_distance(0.1);
// Points too far from line
let store = make_scan_store_with_points(&[
(5.0, 0.5), // 0.5m away, too far
(5.0, 1.0), // 1.0m away, too far
]);
let result = associate_points_to_lines(&store, &lines, &index, &config);
assert_eq!(result.total_associated, 0);
assert_eq!(result.total_unassociated, 2);
}
#[test]
fn test_points_outside_projection_bounds() {
let lines = vec![make_horizontal_line(0.0, 0.0, 10.0)];
let index = SpatialIndex::new(&lines);
// Strict projection bounds (no extension)
let config = PointAssociationConfig::default()
.with_max_distance(0.5)
.with_max_projection_extension(0.0);
// Points beyond line endpoints
let store = make_scan_store_with_points(&[
(-2.0, 0.0), // Before start
(12.0, 0.0), // After end
(5.0, 0.0), // On segment (should associate)
]);
let result = associate_points_to_lines(&store, &lines, &index, &config);
assert_eq!(result.total_associated, 1);
assert_eq!(result.total_unassociated, 2);
}
#[test]
fn test_projection_extension() {
// Line from (0,0) to (10,0), length 10
let lines = vec![make_horizontal_line(0.0, 0.0, 10.0)];
let index = SpatialIndex::new(&lines);
// Allow 10% extension = 1m beyond endpoints
let config = PointAssociationConfig::default()
.with_max_distance(0.5)
.with_max_projection_extension(0.1);
let store = make_scan_store_with_points(&[
(-0.5, 0.0), // 5% before start, within extension
(10.5, 0.0), // 5% after end, within extension
(-2.0, 0.0), // 20% before start, outside extension
]);
let result = associate_points_to_lines(&store, &lines, &index, &config);
assert_eq!(result.total_associated, 2);
assert_eq!(result.total_unassociated, 1);
}
#[test]
fn test_associated_point_metadata() {
let lines = vec![make_horizontal_line(0.0, 0.0, 10.0)];
let index = SpatialIndex::new(&lines);
let config = PointAssociationConfig::default().with_max_distance(0.5);
let store = make_scan_store_with_points(&[(5.0, 0.1)]);
let result = associate_points_to_lines(&store, &lines, &index, &config);
assert_eq!(result.line_points[0].len(), 1);
let ap = &result.line_points[0][0];
assert!((ap.point.x - 5.0).abs() < 0.01);
assert!((ap.point.y - 0.1).abs() < 0.01);
assert_eq!(ap.scan_id, 0);
assert!((ap.distance - 0.1).abs() < 0.01); // Perpendicular distance
assert!((ap.projection_t - 0.5).abs() < 0.01); // Midpoint of line
}
#[test]
fn test_multiple_scans() {
use crate::integration::ScanStoreConfig;
let lines = vec![make_horizontal_line(0.0, 0.0, 10.0)];
let index = SpatialIndex::new(&lines);
let config = PointAssociationConfig::default().with_max_distance(0.5);
let mut store = ScanStore::new(ScanStoreConfig::default());
// Add first scan
let cloud1 = PointCloud2D {
xs: vec![2.0, 3.0],
ys: vec![0.05, 0.05],
};
store.add_scan(Pose2D::identity(), Pose2D::identity(), &cloud1, 0, 1.0);
// Add second scan
let cloud2 = PointCloud2D {
xs: vec![7.0, 8.0],
ys: vec![0.05, 0.05],
};
store.add_scan(Pose2D::identity(), Pose2D::identity(), &cloud2, 0, 1.0);
let result = associate_points_to_lines(&store, &lines, &index, &config);
assert_eq!(result.total_associated, 4);
assert_eq!(result.line_points[0].len(), 4);
// Verify scan IDs are preserved
let scan_ids: Vec<u32> = result.line_points[0].iter().map(|ap| ap.scan_id).collect();
assert!(scan_ids.contains(&0));
assert!(scan_ids.contains(&1));
}
#[test]
fn test_transformed_poses() {
use crate::integration::ScanStoreConfig;
// Line at y=10 in world frame
let lines = vec![make_horizontal_line(10.0, 0.0, 10.0)];
let index = SpatialIndex::new(&lines);
let config = PointAssociationConfig::default().with_max_distance(0.5);
let mut store = ScanStore::new(ScanStoreConfig::default());
// Robot-frame points at (5, 0) - directly in front of robot
let cloud = PointCloud2D {
xs: vec![5.0],
ys: vec![0.0],
};
// Robot at (0, 10), facing +X -> world point at (5, 10)
let pose = Pose2D::new(0.0, 10.0, 0.0);
store.add_scan(Pose2D::identity(), pose, &cloud, 0, 1.0);
let result = associate_points_to_lines(&store, &lines, &index, &config);
// Point at (5, 10) should associate with line at y=10
assert_eq!(result.total_associated, 1);
let ap = &result.line_points[0][0];
assert!((ap.point.x - 5.0).abs() < 0.01);
assert!((ap.point.y - 10.0).abs() < 0.01);
}
#[test]
fn test_associate_scan_points() {
let lines = vec![
make_horizontal_line(0.0, 0.0, 10.0),
make_vertical_line(10.0, 0.0, 10.0),
];
let index = SpatialIndex::new(&lines);
let config = PointAssociationConfig::default().with_max_distance(0.5);
let points = vec![
Point2D::new(5.0, 0.05), // Near line 0
Point2D::new(9.95, 5.0), // Near line 1
Point2D::new(5.0, 5.0), // Far from both
];
let result = associate_scan_points(&points, 42, &lines, &index, &config);
assert_eq!(result.total_associated, 2);
assert_eq!(result.total_unassociated, 1);
assert_eq!(result.line_points[0].len(), 1);
assert_eq!(result.line_points[1].len(), 1);
assert_eq!(result.line_points[0][0].scan_id, 42);
}
#[test]
fn test_lines_with_enough_points() {
let lines = vec![
make_horizontal_line(0.0, 0.0, 10.0),
make_horizontal_line(5.0, 0.0, 10.0),
make_horizontal_line(10.0, 0.0, 10.0),
];
let index = SpatialIndex::new(&lines);
let config = PointAssociationConfig::default().with_max_distance(0.5);
// Create points: 5 near line 0, 3 near line 1, 10 near line 2
let mut points = vec![];
for i in 0..5 {
points.push((i as f32, 0.05)); // Near line 0
}
for i in 0..3 {
points.push((i as f32, 5.05)); // Near line 1
}
for i in 0..10 {
points.push((i as f32, 10.05)); // Near line 2
}
let store = make_scan_store_with_points(&points);
let result = associate_points_to_lines(&store, &lines, &index, &config);
// Lines with at least 5 points
let enough = lines_with_enough_points(&result, 5);
assert_eq!(enough.len(), 2);
assert!(enough.contains(&0));
assert!(enough.contains(&2));
assert!(!enough.contains(&1)); // Only 3 points
}
#[test]
fn test_extract_points_for_line() {
let lines = vec![make_horizontal_line(0.0, 0.0, 10.0)];
let index = SpatialIndex::new(&lines);
let config = PointAssociationConfig::default().with_max_distance(0.5);
let store = make_scan_store_with_points(&[
(2.0, 0.05),
(5.0, 0.05),
(8.0, 0.05),
]);
let result = associate_points_to_lines(&store, &lines, &index, &config);
let points = extract_points_for_line(&result, 0);
assert_eq!(points.len(), 3);
assert!((points[0].x - 2.0).abs() < 0.01);
assert!((points[1].x - 5.0).abs() < 0.01);
assert!((points[2].x - 8.0).abs() < 0.01);
}
#[test]
fn test_association_rate() {
let lines = vec![make_horizontal_line(0.0, 0.0, 10.0)];
let index = SpatialIndex::new(&lines);
let config = PointAssociationConfig::default().with_max_distance(0.1);
let store = make_scan_store_with_points(&[
(5.0, 0.05), // Close enough
(5.0, 0.5), // Too far
(5.0, 0.08), // Close enough
(5.0, 1.0), // Too far
]);
let result = associate_points_to_lines(&store, &lines, &index, &config);
assert_eq!(result.total_associated, 2);
assert_eq!(result.total_unassociated, 2);
assert!((result.association_rate() - 0.5).abs() < 0.001);
assert_eq!(result.total_points(), 4);
}
}
+706
View File
@@ -0,0 +1,706 @@
//! Line re-fitting from accumulated point clouds.
//!
//! Re-fits existing map lines using points accumulated in ScanStore,
//! eliminating first-scan bias in line geometry.
use std::collections::HashMap;
use crate::config::LidarNoiseModel;
use crate::core::{Point2D, Pose2D};
use crate::extraction::fit_line_weighted;
use crate::features::Line2D;
use crate::integration::point_association::AssociatedPoint;
/// Configuration for line re-fitting.
#[derive(Clone, Debug)]
pub struct RefitConfig {
/// Minimum points required to re-fit a line.
/// Lines with fewer points keep their original geometry.
/// Default: 10
pub min_points: usize,
/// Gap threshold for splitting lines (meters).
/// If consecutive points (sorted by projection) have a gap larger than this,
/// the line is split into multiple segments.
/// Default: 0.30m (30cm)
pub gap_threshold: f32,
/// Noise model for range-based weighting.
/// Closer points get higher weights during fitting.
pub noise_model: LidarNoiseModel,
}
impl Default for RefitConfig {
fn default() -> Self {
Self {
min_points: 10,
gap_threshold: 0.30,
noise_model: LidarNoiseModel::default(),
}
}
}
impl RefitConfig {
/// Create a new configuration with defaults.
pub fn new() -> Self {
Self::default()
}
/// Set minimum points for re-fitting.
pub fn with_min_points(mut self, min: usize) -> Self {
self.min_points = min;
self
}
/// Set gap threshold for line splitting.
pub fn with_gap_threshold(mut self, gap: f32) -> Self {
self.gap_threshold = gap;
self
}
/// Set the noise model for weighting.
pub fn with_noise_model(mut self, model: LidarNoiseModel) -> Self {
self.noise_model = model;
self
}
}
/// Result of re-fitting a line.
#[derive(Clone, Debug)]
pub enum RefitResult {
/// Single line fitted (no gaps detected).
Single(Line2D),
/// Line was split due to gaps in point cloud.
Split(Vec<Line2D>),
/// Not enough points to re-fit (keeps original geometry).
Insufficient,
}
impl RefitResult {
/// Get all lines from the result.
pub fn lines(&self) -> Vec<Line2D> {
match self {
RefitResult::Single(line) => vec![line.clone()],
RefitResult::Split(lines) => lines.clone(),
RefitResult::Insufficient => vec![],
}
}
/// Get number of lines produced.
pub fn line_count(&self) -> usize {
match self {
RefitResult::Single(_) => 1,
RefitResult::Split(lines) => lines.len(),
RefitResult::Insufficient => 0,
}
}
/// Check if re-fitting was successful (not insufficient).
pub fn is_success(&self) -> bool {
!matches!(self, RefitResult::Insufficient)
}
}
/// Statistics from a batch re-fitting operation.
#[derive(Clone, Debug, Default)]
pub struct RefitStats {
/// Number of lines re-fitted as single segments.
pub lines_refitted_single: usize,
/// Number of lines that were split due to gaps.
pub lines_split: usize,
/// Number of lines with insufficient points (kept original).
pub lines_insufficient: usize,
/// Total number of new line segments created (from splits).
pub total_segments_created: usize,
}
impl RefitStats {
/// Total lines processed.
pub fn total_processed(&self) -> usize {
self.lines_refitted_single + self.lines_split + self.lines_insufficient
}
/// Success rate (refitted / total).
pub fn success_rate(&self) -> f32 {
let total = self.total_processed();
if total == 0 {
0.0
} else {
(self.lines_refitted_single + self.lines_split) as f32 / total as f32
}
}
}
/// Re-fit a line from associated points with gap detection.
///
/// # Arguments
/// * `points` - Points associated with this line (from point_association)
/// * `original` - Original line geometry (for fallback and observation_count)
/// * `robot_poses` - Map of scan_id -> robot pose (for computing weights)
/// * `config` - Re-fitting configuration
///
/// # Returns
/// - `RefitResult::Single(line)` if fitted as a single line
/// - `RefitResult::Split(lines)` if gaps were detected and line was split
/// - `RefitResult::Insufficient` if not enough points
pub fn refit_line(
points: &[AssociatedPoint],
original: &Line2D,
robot_poses: &HashMap<u32, Pose2D>,
config: &RefitConfig,
) -> RefitResult {
// Check minimum points
if points.len() < config.min_points {
return RefitResult::Insufficient;
}
// Sort points by projection onto original line direction
let mut sorted_points: Vec<&AssociatedPoint> = points.iter().collect();
sorted_points.sort_by(|a, b| {
a.projection_t
.partial_cmp(&b.projection_t)
.unwrap_or(std::cmp::Ordering::Equal)
});
// Detect gaps and find split indices
let split_indices = find_gap_splits(&sorted_points, config.gap_threshold, original);
if split_indices.is_empty() {
// No gaps - fit as single line
let line = fit_segment(
&sorted_points,
robot_poses,
original.observation_count,
&config.noise_model,
);
match line {
Some(l) => RefitResult::Single(l),
None => RefitResult::Insufficient,
}
} else {
// Gaps detected - split into segments
let segments = split_and_fit(
&sorted_points,
&split_indices,
robot_poses,
original.observation_count,
config,
);
if segments.is_empty() {
RefitResult::Insufficient
} else if segments.len() == 1 {
RefitResult::Single(segments.into_iter().next().unwrap())
} else {
RefitResult::Split(segments)
}
}
}
/// Find indices where the point cloud should be split due to gaps.
///
/// Returns a list of indices where splits should occur.
/// Each index represents the start of a new segment.
fn find_gap_splits(
sorted_points: &[&AssociatedPoint],
gap_threshold: f32,
original: &Line2D,
) -> Vec<usize> {
if sorted_points.len() < 2 {
return vec![];
}
let line_length = original.length();
if line_length < f32::EPSILON {
return vec![];
}
let mut splits = Vec::new();
for i in 1..sorted_points.len() {
let prev = sorted_points[i - 1];
let curr = sorted_points[i];
// Gap in projection space (as fraction of line length)
let t_gap = (curr.projection_t - prev.projection_t).abs();
// Convert to world distance
let world_gap = t_gap * line_length;
if world_gap > gap_threshold {
splits.push(i);
}
}
splits
}
/// Split points at gap indices and fit each segment.
fn split_and_fit(
sorted_points: &[&AssociatedPoint],
split_indices: &[usize],
robot_poses: &HashMap<u32, Pose2D>,
observation_count: u32,
config: &RefitConfig,
) -> Vec<Line2D> {
let mut segments = Vec::new();
// Create segment ranges
let mut ranges: Vec<(usize, usize)> = Vec::new();
let mut start = 0;
for &split_idx in split_indices {
if split_idx > start {
ranges.push((start, split_idx));
}
start = split_idx;
}
// Add final segment
if start < sorted_points.len() {
ranges.push((start, sorted_points.len()));
}
// Fit each segment
for (range_start, range_end) in ranges {
let segment_points: Vec<&AssociatedPoint> =
sorted_points[range_start..range_end].to_vec();
if segment_points.len() < config.min_points {
continue;
}
if let Some(line) = fit_segment(
&segment_points,
robot_poses,
observation_count,
&config.noise_model,
) {
segments.push(line);
}
}
segments
}
/// Fit a line segment from points using weighted TLS.
fn fit_segment(
points: &[&AssociatedPoint],
robot_poses: &HashMap<u32, Pose2D>,
observation_count: u32,
noise_model: &LidarNoiseModel,
) -> Option<Line2D> {
if points.len() < 2 {
return None;
}
// Extract Point2D and compute weights
let mut point_vec: Vec<Point2D> = Vec::with_capacity(points.len());
let mut weights: Vec<f32> = Vec::with_capacity(points.len());
for ap in points {
point_vec.push(ap.point);
// Compute weight based on range from robot
let weight = if let Some(pose) = robot_poses.get(&ap.scan_id) {
let robot_pos = Point2D::new(pose.x, pose.y);
let range = ap.point.distance(robot_pos);
noise_model.weight(range)
} else {
// Fallback: use default weight
1.0
};
weights.push(weight);
}
// Fit using weighted TLS
let mut line = fit_line_weighted(&point_vec, &weights)?;
// Preserve observation count from original
line.observation_count = observation_count;
line.point_count = points.len() as u32;
Some(line)
}
/// Re-fit a line from points using simple (unweighted) fitting.
///
/// Use this when robot poses are not available.
pub fn refit_line_simple(
points: &[AssociatedPoint],
original: &Line2D,
config: &RefitConfig,
) -> RefitResult {
// Use empty pose map for simple fitting
let empty_poses: HashMap<u32, Pose2D> = HashMap::new();
refit_line(points, original, &empty_poses, config)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_horizontal_line(y: f32, x_start: f32, x_end: f32) -> Line2D {
Line2D::with_observation_count(
Point2D::new(x_start, y),
Point2D::new(x_end, y),
5,
)
}
fn make_associated_point(x: f32, y: f32, scan_id: u32, t: f32) -> AssociatedPoint {
AssociatedPoint {
point: Point2D::new(x, y),
scan_id,
distance: 0.0,
projection_t: t,
}
}
fn make_points_along_line(
x_start: f32,
x_end: f32,
y: f32,
count: usize,
) -> Vec<AssociatedPoint> {
let line_length = (x_end - x_start).abs();
(0..count)
.map(|i| {
let t = i as f32 / (count - 1).max(1) as f32;
let x = x_start + t * (x_end - x_start);
// projection_t is the fraction along the line
make_associated_point(x, y, 0, x / line_length)
})
.collect()
}
/// Create a dense point cloud (no gaps > threshold).
fn make_dense_points(x_start: f32, x_end: f32, y: f32, gap_threshold: f32) -> Vec<AssociatedPoint> {
let line_length = (x_end - x_start).abs();
// Create points with spacing less than gap_threshold
let spacing = gap_threshold * 0.5;
let count = ((line_length / spacing) as usize).max(2);
(0..count)
.map(|i| {
let x = x_start + i as f32 * spacing;
let t = x / line_length;
make_associated_point(x.min(x_end), y, 0, t.min(1.0))
})
.collect()
}
#[test]
fn test_config_defaults() {
let config = RefitConfig::default();
assert_eq!(config.min_points, 10);
assert!((config.gap_threshold - 0.30).abs() < 0.001);
}
#[test]
fn test_config_builder() {
let config = RefitConfig::new()
.with_min_points(5)
.with_gap_threshold(0.5);
assert_eq!(config.min_points, 5);
assert!((config.gap_threshold - 0.5).abs() < 0.001);
}
#[test]
fn test_refit_insufficient_points() {
let original = make_horizontal_line(0.0, 0.0, 10.0);
let points = make_points_along_line(0.0, 10.0, 0.0, 5); // Only 5 points
let config = RefitConfig::default().with_min_points(10);
let poses = HashMap::new();
let result = refit_line(&points, &original, &poses, &config);
assert!(matches!(result, RefitResult::Insufficient));
assert!(!result.is_success());
assert_eq!(result.line_count(), 0);
}
#[test]
fn test_refit_single_line() {
let original = make_horizontal_line(0.0, 0.0, 10.0);
// Create dense points along the line with small noise (spacing < gap_threshold)
let gap_threshold = 0.30;
let spacing = gap_threshold * 0.5; // 0.15m spacing
let count = (10.0 / spacing) as usize; // ~67 points for 10m line
let mut points: Vec<AssociatedPoint> = Vec::new();
for i in 0..count {
let x = i as f32 * spacing;
let t = x / 10.0;
let y = if i % 2 == 0 { 0.01 } else { -0.01 }; // Small noise
points.push(make_associated_point(x, y, 0, t));
}
let config = RefitConfig::default()
.with_min_points(10)
.with_gap_threshold(gap_threshold);
let poses = HashMap::new();
let result = refit_line(&points, &original, &poses, &config);
assert!(result.is_success());
assert!(matches!(result, RefitResult::Single(_)));
let lines = result.lines();
assert_eq!(lines.len(), 1);
// Verify line is approximately horizontal
let line = &lines[0];
assert!(line.angle().abs() < 0.1);
// Verify point count is set
assert_eq!(line.point_count as usize, count);
}
#[test]
fn test_refit_with_gap_split() {
let original = make_horizontal_line(0.0, 0.0, 10.0);
let gap_threshold = 0.5;
let spacing = 0.1; // Dense points within each segment
// Create points with a gap in the middle
let mut points: Vec<AssociatedPoint> = Vec::new();
// First segment: 0-3m (dense points)
let mut x = 0.0;
while x <= 3.0 {
let t = x / 10.0;
points.push(make_associated_point(x, 0.0, 0, t));
x += spacing;
}
// GAP: 3m to 7m (4m gap > 0.5m threshold)
// Second segment: 7-10m (dense points)
x = 7.0;
while x <= 10.0 {
let t = x / 10.0;
points.push(make_associated_point(x, 0.0, 1, t));
x += spacing;
}
let config = RefitConfig::default()
.with_min_points(5)
.with_gap_threshold(gap_threshold);
let poses = HashMap::new();
let result = refit_line(&points, &original, &poses, &config);
assert!(result.is_success());
assert!(
matches!(result, RefitResult::Split(_)),
"Expected Split, got {:?}",
result
);
let lines = result.lines();
assert_eq!(lines.len(), 2);
// First line should cover ~0-3m
assert!(lines[0].start.x < 1.0);
assert!(lines[0].end.x < 4.0);
// Second line should cover ~7-10m
assert!(lines[1].start.x > 6.0);
assert!(lines[1].end.x > 9.0);
}
#[test]
fn test_refit_with_poses_weighting() {
let original = make_horizontal_line(0.0, 0.0, 10.0);
let gap_threshold = 0.5;
let spacing = 0.1; // Dense points
// Create dense points with noise
let mut points: Vec<AssociatedPoint> = Vec::new();
// Points close to robot (scan 0 at origin) - on y=0
let mut x = 0.0;
while x <= 5.0 {
let t = x / 10.0;
points.push(make_associated_point(x, 0.0, 0, t));
x += spacing;
}
// Points far from robot (scan 1 at x=10) - with noise above y=0
x = 5.0 + spacing;
while x <= 10.0 {
let t = x / 10.0;
points.push(make_associated_point(x, 0.5, 1, t));
x += spacing;
}
let mut poses = HashMap::new();
poses.insert(0, Pose2D::new(0.0, 0.0, 0.0)); // Robot at origin for scan 0
poses.insert(1, Pose2D::new(10.0, 0.0, 0.0)); // Robot at x=10 for scan 1
let config = RefitConfig::default()
.with_min_points(10)
.with_gap_threshold(gap_threshold);
let result = refit_line(&points, &original, &poses, &config);
assert!(result.is_success());
let lines = result.lines();
assert_eq!(lines.len(), 1);
// Line should be closer to y=0 due to close points having higher weight
let line = &lines[0];
let midpoint = line.midpoint();
assert!(
midpoint.y < 0.3,
"Midpoint y={} should be < 0.3 (closer to close points)",
midpoint.y
);
}
#[test]
fn test_refit_preserves_observation_count() {
let mut original = make_horizontal_line(0.0, 0.0, 10.0);
original.observation_count = 42;
// Use dense points to avoid gap splitting
let points = make_dense_points(0.0, 10.0, 0.0, 0.3);
let config = RefitConfig::default()
.with_min_points(10)
.with_gap_threshold(0.3);
let poses = HashMap::new();
let result = refit_line(&points, &original, &poses, &config);
let lines = result.lines();
assert_eq!(lines.len(), 1, "Expected 1 line, got {}", lines.len());
assert_eq!(lines[0].observation_count, 42);
}
#[test]
fn test_refit_simple() {
let original = make_horizontal_line(0.0, 0.0, 10.0);
// Use dense points to avoid gap splitting
let points = make_dense_points(0.0, 10.0, 0.05, 0.3);
let config = RefitConfig::default()
.with_min_points(10)
.with_gap_threshold(0.3);
let result = refit_line_simple(&points, &original, &config);
assert!(result.is_success());
}
#[test]
fn test_refit_stats() {
let stats = RefitStats {
lines_refitted_single: 5,
lines_split: 2,
lines_insufficient: 3,
total_segments_created: 5, // 2 splits creating ~2.5 segments each
};
assert_eq!(stats.total_processed(), 10);
assert!((stats.success_rate() - 0.7).abs() < 0.001);
}
#[test]
fn test_find_gap_splits_no_gaps() {
let original = make_horizontal_line(0.0, 0.0, 10.0);
let gap_threshold = 0.5;
let spacing = 0.2; // Smaller than gap_threshold
// Create dense points with no gaps
let points: Vec<AssociatedPoint> = (0..50)
.map(|i| {
let x = i as f32 * spacing;
let t = x / 10.0;
make_associated_point(x.min(10.0), 0.0, 0, t.min(1.0))
})
.collect();
let sorted: Vec<&AssociatedPoint> = points.iter().collect();
let splits = find_gap_splits(&sorted, gap_threshold, &original);
assert!(splits.is_empty(), "Expected no splits, got {:?}", splits);
}
#[test]
fn test_find_gap_splits_with_gap() {
let original = make_horizontal_line(0.0, 0.0, 10.0);
let gap_threshold = 0.5;
// Create points with a clear gap
let points = vec![
make_associated_point(0.0, 0.0, 0, 0.0),
make_associated_point(0.2, 0.0, 0, 0.02),
make_associated_point(0.4, 0.0, 0, 0.04),
// Gap here: 0.4 to 5.0 = 4.6m > 0.5m threshold
make_associated_point(5.0, 0.0, 0, 0.5),
make_associated_point(5.2, 0.0, 0, 0.52),
];
let sorted: Vec<&AssociatedPoint> = points.iter().collect();
let splits = find_gap_splits(&sorted, gap_threshold, &original);
assert_eq!(splits.len(), 1, "Expected 1 split, got {:?}", splits);
assert_eq!(splits[0], 3); // Split before the 4th point (index 3)
}
#[test]
fn test_refitresult_lines() {
let line = make_horizontal_line(0.0, 0.0, 10.0);
let single = RefitResult::Single(line.clone());
assert_eq!(single.lines().len(), 1);
assert_eq!(single.line_count(), 1);
assert!(single.is_success());
let split = RefitResult::Split(vec![line.clone(), line.clone()]);
assert_eq!(split.lines().len(), 2);
assert_eq!(split.line_count(), 2);
assert!(split.is_success());
let insufficient = RefitResult::Insufficient;
assert!(insufficient.lines().is_empty());
assert_eq!(insufficient.line_count(), 0);
assert!(!insufficient.is_success());
}
#[test]
fn test_multiple_gaps_creates_multiple_segments() {
let original = make_horizontal_line(0.0, 0.0, 10.0);
// Create three clusters of points with gaps between
let mut points: Vec<AssociatedPoint> = Vec::new();
// Cluster 1: t = 0.0 to 0.2 (10 points)
for i in 0..10 {
let t = i as f32 / 50.0;
points.push(make_associated_point(t * 10.0, 0.0, 0, t));
}
// Cluster 2: t = 0.4 to 0.6 (10 points) - gap before
for i in 0..10 {
let t = 0.4 + i as f32 / 50.0;
points.push(make_associated_point(t * 10.0, 0.0, 0, t));
}
// Cluster 3: t = 0.8 to 1.0 (10 points) - gap before
for i in 0..10 {
let t = 0.8 + i as f32 / 50.0;
points.push(make_associated_point(t * 10.0, 0.0, 0, t));
}
let config = RefitConfig::default()
.with_min_points(5)
.with_gap_threshold(0.3); // 0.3m < 2m gaps
let poses = HashMap::new();
let result = refit_line(&points, &original, &poses, &config);
assert!(result.is_success());
assert!(matches!(result, RefitResult::Split(_)));
let lines = result.lines();
assert_eq!(lines.len(), 3);
}
}
+909
View File
@@ -0,0 +1,909 @@
//! Scan storage for exploration mode.
//!
//! Retains raw scan data during exploration for visualization,
//! debugging, and future point-cloud-based line fitting.
use crate::core::{Point2D, PointCloud2D, Pose2D};
/// A stored scan with associated pose information.
#[derive(Clone, Debug)]
pub struct StoredScan {
/// Unique scan identifier (sequential).
pub scan_id: u32,
/// Raw odometry pose (input to observe).
pub odometry: Pose2D,
/// Estimated pose after ICP matching.
/// This is the corrected pose used for map integration.
pub estimated_pose: Pose2D,
/// Scan points in robot frame.
/// Transform with `estimated_pose` to get world-frame points.
points: Vec<Point2D>,
/// Number of features extracted from this scan.
pub features_extracted: usize,
/// ICP matching confidence (0.0 - 1.0).
pub match_confidence: f32,
}
impl StoredScan {
/// Create a new stored scan.
pub fn new(
scan_id: u32,
odometry: Pose2D,
estimated_pose: Pose2D,
points: Vec<Point2D>,
features_extracted: usize,
match_confidence: f32,
) -> Self {
Self {
scan_id,
odometry,
estimated_pose,
points,
features_extracted,
match_confidence,
}
}
/// Get robot-frame points.
#[inline]
pub fn points(&self) -> &[Point2D] {
&self.points
}
/// Get the number of points in this scan.
#[inline]
pub fn point_count(&self) -> usize {
self.points.len()
}
/// Get world-frame points (transformed by estimated pose).
pub fn world_points(&self) -> Vec<Point2D> {
self.points
.iter()
.map(|p| self.estimated_pose.transform_point(*p))
.collect()
}
/// Fill a buffer with world-frame points (zero-allocation if buffer is large enough).
pub fn world_points_into(&self, buffer: &mut Vec<Point2D>) {
buffer.clear();
buffer.reserve(self.points.len());
for p in &self.points {
buffer.push(self.estimated_pose.transform_point(*p));
}
}
/// Get the pose correction applied by ICP.
/// Returns the difference between estimated and odometry-predicted pose.
pub fn pose_correction(&self) -> Pose2D {
// correction = estimated * odometry.inverse()
// This gives the transform that was applied to correct odometry
self.estimated_pose.compose(self.odometry.inverse())
}
/// Memory usage in bytes (approximate).
pub fn memory_bytes(&self) -> usize {
std::mem::size_of::<Self>() + self.points.len() * std::mem::size_of::<Point2D>()
}
}
/// Configuration for scan storage.
#[derive(Clone, Debug)]
pub struct ScanStoreConfig {
/// Maximum number of scans to store.
/// Oldest scans are removed when limit is reached.
/// None = unlimited. Default: None
pub max_scans: Option<usize>,
/// Whether to store point data.
/// If false, only poses are stored (much less memory).
/// Default: true
pub store_points: bool,
/// Minimum distance traveled to store a new scan (meters).
/// Helps reduce redundant scans when robot is stationary.
/// Default: 0.0 (store all scans)
pub min_distance: f32,
/// Minimum rotation to store a new scan (radians).
/// Default: 0.0 (store all scans)
pub min_rotation: f32,
}
impl Default for ScanStoreConfig {
fn default() -> Self {
Self {
max_scans: None,
store_points: true,
min_distance: 0.0,
min_rotation: 0.0,
}
}
}
impl ScanStoreConfig {
/// Create a new configuration with defaults.
pub fn new() -> Self {
Self::default()
}
/// Set maximum number of scans to store.
pub fn with_max_scans(mut self, max: usize) -> Self {
self.max_scans = Some(max);
self
}
/// Set unlimited scan storage.
pub fn with_unlimited_scans(mut self) -> Self {
self.max_scans = None;
self
}
/// Enable or disable point storage.
pub fn with_store_points(mut self, store: bool) -> Self {
self.store_points = store;
self
}
/// Set minimum distance between stored scans.
pub fn with_min_distance(mut self, meters: f32) -> Self {
self.min_distance = meters;
self
}
/// Set minimum rotation between stored scans.
pub fn with_min_rotation(mut self, radians: f32) -> Self {
self.min_rotation = radians;
self
}
}
/// Storage for scans during exploration.
///
/// Retains raw scan data for visualization, debugging, and
/// point-cloud-based algorithms.
///
/// # Example
///
/// ```rust,ignore
/// use vastu_map::integration::{ScanStore, ScanStoreConfig};
///
/// let config = ScanStoreConfig::default().with_max_scans(1000);
/// let mut store = ScanStore::new(config);
///
/// // After each observation...
/// store.add_scan(odometry, estimated_pose, &scan_points, features, confidence);
///
/// // For visualization
/// for scan in store.iter() {
/// let world_points = scan.world_points();
/// draw_points(&world_points);
/// }
/// ```
#[derive(Clone, Debug)]
pub struct ScanStore {
/// Stored scans (oldest first).
scans: Vec<StoredScan>,
/// Next scan ID to assign.
next_scan_id: u32,
/// Configuration.
config: ScanStoreConfig,
/// Last stored pose (for distance/rotation filtering).
last_stored_pose: Option<Pose2D>,
}
impl ScanStore {
/// Create a new empty scan store.
pub fn new(config: ScanStoreConfig) -> Self {
Self {
scans: Vec::new(),
next_scan_id: 0,
config,
last_stored_pose: None,
}
}
/// Create with default configuration.
pub fn with_defaults() -> Self {
Self::new(ScanStoreConfig::default())
}
/// Get the configuration.
pub fn config(&self) -> &ScanStoreConfig {
&self.config
}
/// Get the number of stored scans.
#[inline]
pub fn len(&self) -> usize {
self.scans.len()
}
/// Check if the store is empty.
#[inline]
pub fn is_empty(&self) -> bool {
self.scans.is_empty()
}
/// Get the next scan ID that will be assigned.
#[inline]
pub fn next_scan_id(&self) -> u32 {
self.next_scan_id
}
/// Add a scan to the store.
///
/// Returns the assigned scan_id, or None if the scan was filtered
/// (due to min_distance/min_rotation constraints).
pub fn add_scan(
&mut self,
odometry: Pose2D,
estimated_pose: Pose2D,
points: &PointCloud2D,
features_extracted: usize,
match_confidence: f32,
) -> Option<u32> {
// Check distance/rotation filter
if let Some(last_pose) = self.last_stored_pose {
let dx = estimated_pose.x - last_pose.x;
let dy = estimated_pose.y - last_pose.y;
let dist = (dx * dx + dy * dy).sqrt();
let dtheta = crate::core::math::angle_diff(estimated_pose.theta, last_pose.theta).abs();
// Determine which constraints are active (> 0 means enabled)
let distance_check_enabled = self.config.min_distance > 0.0;
let rotation_check_enabled = self.config.min_rotation > 0.0;
let passes_distance = dist >= self.config.min_distance;
let passes_rotation = dtheta >= self.config.min_rotation;
// Filter logic:
// - If only distance filter: require distance
// - If only rotation filter: require rotation
// - If both filters: require either (OR semantics)
// - If no filters: store all
let should_store = match (distance_check_enabled, rotation_check_enabled) {
(false, false) => true,
(true, false) => passes_distance,
(false, true) => passes_rotation,
(true, true) => passes_distance || passes_rotation,
};
if !should_store {
return None;
}
}
// Enforce max_scans limit
if let Some(max) = self.config.max_scans {
while self.scans.len() >= max {
self.scans.remove(0); // Remove oldest
}
}
let scan_id = self.next_scan_id;
self.next_scan_id += 1;
let stored_points = if self.config.store_points {
points.iter().collect()
} else {
Vec::new()
};
let scan = StoredScan::new(
scan_id,
odometry,
estimated_pose,
stored_points,
features_extracted,
match_confidence,
);
self.last_stored_pose = Some(estimated_pose);
self.scans.push(scan);
Some(scan_id)
}
/// Add a scan from raw points (not PointCloud2D).
pub fn add_scan_from_points(
&mut self,
odometry: Pose2D,
estimated_pose: Pose2D,
points: &[Point2D],
features_extracted: usize,
match_confidence: f32,
) -> Option<u32> {
// Check distance/rotation filter
if let Some(last_pose) = self.last_stored_pose {
let dx = estimated_pose.x - last_pose.x;
let dy = estimated_pose.y - last_pose.y;
let dist = (dx * dx + dy * dy).sqrt();
let dtheta = crate::core::math::angle_diff(estimated_pose.theta, last_pose.theta).abs();
// Determine which constraints are active (> 0 means enabled)
let distance_check_enabled = self.config.min_distance > 0.0;
let rotation_check_enabled = self.config.min_rotation > 0.0;
let passes_distance = dist >= self.config.min_distance;
let passes_rotation = dtheta >= self.config.min_rotation;
let should_store = match (distance_check_enabled, rotation_check_enabled) {
(false, false) => true,
(true, false) => passes_distance,
(false, true) => passes_rotation,
(true, true) => passes_distance || passes_rotation,
};
if !should_store {
return None;
}
}
// Enforce max_scans limit
if let Some(max) = self.config.max_scans {
while self.scans.len() >= max {
self.scans.remove(0);
}
}
let scan_id = self.next_scan_id;
self.next_scan_id += 1;
let stored_points = if self.config.store_points {
points.to_vec()
} else {
Vec::new()
};
let scan = StoredScan::new(
scan_id,
odometry,
estimated_pose,
stored_points,
features_extracted,
match_confidence,
);
self.last_stored_pose = Some(estimated_pose);
self.scans.push(scan);
Some(scan_id)
}
/// Get a scan by its ID.
pub fn get(&self, scan_id: u32) -> Option<&StoredScan> {
self.scans.iter().find(|s| s.scan_id == scan_id)
}
/// Get a scan by index (0 = oldest).
pub fn get_by_index(&self, index: usize) -> Option<&StoredScan> {
self.scans.get(index)
}
/// Get the most recent scan.
pub fn latest(&self) -> Option<&StoredScan> {
self.scans.last()
}
/// Get the oldest scan.
pub fn oldest(&self) -> Option<&StoredScan> {
self.scans.first()
}
/// Iterate over all stored scans (oldest first).
pub fn iter(&self) -> impl Iterator<Item = &StoredScan> {
self.scans.iter()
}
/// Iterate over scans in reverse order (newest first).
pub fn iter_rev(&self) -> impl Iterator<Item = &StoredScan> {
self.scans.iter().rev()
}
/// Iterate over scans within a pose range.
///
/// Returns scans whose estimated_pose is within `radius` of `center`.
pub fn iter_near(&self, center: Point2D, radius: f32) -> impl Iterator<Item = &StoredScan> {
let radius_sq = radius * radius;
self.scans.iter().filter(move |s| {
let dx = s.estimated_pose.x - center.x;
let dy = s.estimated_pose.y - center.y;
dx * dx + dy * dy <= radius_sq
})
}
/// Get all scans as a slice.
pub fn scans(&self) -> &[StoredScan] {
&self.scans
}
/// Get all estimated poses.
pub fn poses(&self) -> Vec<Pose2D> {
self.scans.iter().map(|s| s.estimated_pose).collect()
}
/// Get the trajectory as a list of points.
pub fn trajectory(&self) -> Vec<Point2D> {
self.scans
.iter()
.map(|s| Point2D::new(s.estimated_pose.x, s.estimated_pose.y))
.collect()
}
/// Get all world-frame points from all scans.
///
/// Warning: This can be memory-intensive for large stores.
pub fn all_world_points(&self) -> Vec<Point2D> {
let total_points: usize = self.scans.iter().map(|s| s.point_count()).sum();
let mut result = Vec::with_capacity(total_points);
for scan in &self.scans {
for p in scan.points() {
result.push(scan.estimated_pose.transform_point(*p));
}
}
result
}
/// Get world-frame points with scan IDs.
///
/// Returns (point, scan_id) pairs for all stored points.
pub fn all_world_points_with_ids(&self) -> Vec<(Point2D, u32)> {
let total_points: usize = self.scans.iter().map(|s| s.point_count()).sum();
let mut result = Vec::with_capacity(total_points);
for scan in &self.scans {
for p in scan.points() {
result.push((scan.estimated_pose.transform_point(*p), scan.scan_id));
}
}
result
}
/// Update the estimated pose for a scan (after loop closure correction).
///
/// Returns true if the scan was found and updated.
pub fn update_pose(&mut self, scan_id: u32, new_pose: Pose2D) -> bool {
if let Some(scan) = self.scans.iter_mut().find(|s| s.scan_id == scan_id) {
scan.estimated_pose = new_pose;
true
} else {
false
}
}
/// Apply pose corrections to multiple scans.
///
/// Takes a map of scan_id -> corrected_pose.
/// Returns the number of scans updated.
pub fn apply_pose_corrections(
&mut self,
corrections: &std::collections::HashMap<u32, Pose2D>,
) -> usize {
let mut updated = 0;
for scan in &mut self.scans {
if let Some(new_pose) = corrections.get(&scan.scan_id) {
scan.estimated_pose = *new_pose;
updated += 1;
}
}
updated
}
/// Clear all stored scans.
pub fn clear(&mut self) {
self.scans.clear();
self.last_stored_pose = None;
// Note: next_scan_id is NOT reset to allow ID continuity
}
/// Reset the store completely (including scan ID counter).
pub fn reset(&mut self) {
self.scans.clear();
self.next_scan_id = 0;
self.last_stored_pose = None;
}
/// Total number of points stored across all scans.
pub fn total_points(&self) -> usize {
self.scans.iter().map(|s| s.point_count()).sum()
}
/// Memory usage statistics.
pub fn memory_stats(&self) -> ScanStoreStats {
let total_points = self.total_points();
let point_bytes = total_points * std::mem::size_of::<Point2D>();
let scan_overhead = self.scans.len() * std::mem::size_of::<StoredScan>();
let vec_overhead = self.scans.capacity() * std::mem::size_of::<StoredScan>();
ScanStoreStats {
scan_count: self.scans.len(),
total_points,
point_bytes,
total_bytes: point_bytes + scan_overhead + vec_overhead,
avg_points_per_scan: if self.scans.is_empty() {
0.0
} else {
total_points as f32 / self.scans.len() as f32
},
}
}
}
impl Default for ScanStore {
fn default() -> Self {
Self::with_defaults()
}
}
/// Memory usage statistics for ScanStore.
#[derive(Clone, Debug)]
pub struct ScanStoreStats {
/// Number of stored scans.
pub scan_count: usize,
/// Total number of points across all scans.
pub total_points: usize,
/// Memory used by points (bytes).
pub point_bytes: usize,
/// Total estimated memory usage (bytes).
pub total_bytes: usize,
/// Average points per scan.
pub avg_points_per_scan: f32,
}
impl ScanStoreStats {
/// Get memory usage in kilobytes.
pub fn total_kb(&self) -> f32 {
self.total_bytes as f32 / 1024.0
}
/// Get memory usage in megabytes.
pub fn total_mb(&self) -> f32 {
self.total_bytes as f32 / (1024.0 * 1024.0)
}
}
impl std::fmt::Display for ScanStoreStats {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"ScanStore: {} scans, {} points, {:.2} KB",
self.scan_count,
self.total_points,
self.total_kb()
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_points(n: usize) -> Vec<Point2D> {
(0..n)
.map(|i| Point2D::new(i as f32 * 0.1, 0.0))
.collect()
}
fn make_point_cloud(n: usize) -> PointCloud2D {
let xs: Vec<f32> = (0..n).map(|i| i as f32 * 0.1).collect();
let ys: Vec<f32> = vec![0.0; n];
PointCloud2D { xs, ys }
}
#[test]
fn test_stored_scan_creation() {
let scan = StoredScan::new(
0,
Pose2D::identity(),
Pose2D::new(1.0, 0.0, 0.0),
make_test_points(10),
5,
0.95,
);
assert_eq!(scan.scan_id, 0);
assert_eq!(scan.point_count(), 10);
assert_eq!(scan.features_extracted, 5);
assert!((scan.match_confidence - 0.95).abs() < 0.001);
}
#[test]
fn test_stored_scan_world_points() {
let points = vec![Point2D::new(1.0, 0.0), Point2D::new(0.0, 1.0)];
let pose = Pose2D::new(10.0, 20.0, std::f32::consts::FRAC_PI_2); // 90° rotation
let scan = StoredScan::new(0, Pose2D::identity(), pose, points, 0, 1.0);
let world = scan.world_points();
assert_eq!(world.len(), 2);
// (1, 0) rotated 90° + translated = (10 + 0, 20 + 1) = (10, 21)
assert!((world[0].x - 10.0).abs() < 0.01);
assert!((world[0].y - 21.0).abs() < 0.01);
// (0, 1) rotated 90° + translated = (10 - 1, 20 + 0) = (9, 20)
assert!((world[1].x - 9.0).abs() < 0.01);
assert!((world[1].y - 20.0).abs() < 0.01);
}
#[test]
fn test_scan_store_basic() {
let mut store = ScanStore::with_defaults();
assert!(store.is_empty());
assert_eq!(store.len(), 0);
let cloud = make_point_cloud(50);
let id = store.add_scan(
Pose2D::identity(),
Pose2D::new(1.0, 0.0, 0.0),
&cloud,
3,
0.9,
);
assert_eq!(id, Some(0));
assert_eq!(store.len(), 1);
assert!(!store.is_empty());
let scan = store.get(0).unwrap();
assert_eq!(scan.point_count(), 50);
}
#[test]
fn test_scan_store_max_scans() {
let config = ScanStoreConfig::default().with_max_scans(3);
let mut store = ScanStore::new(config);
for i in 0..5 {
let cloud = make_point_cloud(10);
store.add_scan(
Pose2D::identity(),
Pose2D::new(i as f32, 0.0, 0.0),
&cloud,
0,
1.0,
);
}
assert_eq!(store.len(), 3);
// Oldest scans (0, 1) should be removed
assert!(store.get(0).is_none());
assert!(store.get(1).is_none());
assert!(store.get(2).is_some());
assert!(store.get(3).is_some());
assert!(store.get(4).is_some());
}
#[test]
fn test_scan_store_distance_filter() {
let config = ScanStoreConfig::default().with_min_distance(0.5);
let mut store = ScanStore::new(config);
let cloud = make_point_cloud(10);
// First scan always stored
let id1 = store.add_scan(
Pose2D::identity(),
Pose2D::new(0.0, 0.0, 0.0),
&cloud,
0,
1.0,
);
assert!(id1.is_some());
// Second scan too close, filtered
let id2 = store.add_scan(
Pose2D::identity(),
Pose2D::new(0.1, 0.0, 0.0),
&cloud,
0,
1.0,
);
assert!(id2.is_none());
// Third scan far enough
let id3 = store.add_scan(
Pose2D::identity(),
Pose2D::new(1.0, 0.0, 0.0),
&cloud,
0,
1.0,
);
assert!(id3.is_some());
assert_eq!(store.len(), 2);
}
#[test]
fn test_scan_store_rotation_filter() {
let config = ScanStoreConfig::default().with_min_rotation(0.5);
let mut store = ScanStore::new(config);
let cloud = make_point_cloud(10);
store.add_scan(
Pose2D::identity(),
Pose2D::new(0.0, 0.0, 0.0),
&cloud,
0,
1.0,
);
// Small rotation, filtered
let id = store.add_scan(
Pose2D::identity(),
Pose2D::new(0.0, 0.0, 0.1),
&cloud,
0,
1.0,
);
assert!(id.is_none());
// Large rotation, stored
let id = store.add_scan(
Pose2D::identity(),
Pose2D::new(0.0, 0.0, 1.0),
&cloud,
0,
1.0,
);
assert!(id.is_some());
}
#[test]
fn test_scan_store_no_points() {
let config = ScanStoreConfig::default().with_store_points(false);
let mut store = ScanStore::new(config);
let cloud = make_point_cloud(100);
store.add_scan(Pose2D::identity(), Pose2D::identity(), &cloud, 0, 1.0);
let scan = store.latest().unwrap();
assert_eq!(scan.point_count(), 0); // Points not stored
}
#[test]
fn test_scan_store_trajectory() {
let mut store = ScanStore::with_defaults();
for i in 0..5 {
let cloud = make_point_cloud(10);
store.add_scan(
Pose2D::identity(),
Pose2D::new(i as f32, i as f32 * 2.0, 0.0),
&cloud,
0,
1.0,
);
}
let traj = store.trajectory();
assert_eq!(traj.len(), 5);
assert!((traj[2].x - 2.0).abs() < 0.01);
assert!((traj[2].y - 4.0).abs() < 0.01);
}
#[test]
fn test_scan_store_iter_near() {
let mut store = ScanStore::with_defaults();
let cloud = make_point_cloud(10);
// Add scans at various positions
for i in 0..10 {
store.add_scan(
Pose2D::identity(),
Pose2D::new(i as f32, 0.0, 0.0),
&cloud,
0,
1.0,
);
}
// Find scans near (5, 0) with radius 1.5
let near: Vec<_> = store.iter_near(Point2D::new(5.0, 0.0), 1.5).collect();
assert_eq!(near.len(), 3); // scans at x=4, 5, 6
}
#[test]
fn test_scan_store_update_pose() {
let mut store = ScanStore::with_defaults();
let cloud = make_point_cloud(10);
store.add_scan(
Pose2D::identity(),
Pose2D::new(1.0, 0.0, 0.0),
&cloud,
0,
1.0,
);
let new_pose = Pose2D::new(1.5, 0.5, 0.1);
assert!(store.update_pose(0, new_pose));
let scan = store.get(0).unwrap();
assert!((scan.estimated_pose.x - 1.5).abs() < 0.01);
assert!((scan.estimated_pose.y - 0.5).abs() < 0.01);
}
#[test]
fn test_scan_store_memory_stats() {
let mut store = ScanStore::with_defaults();
for _ in 0..10 {
let cloud = make_point_cloud(100);
store.add_scan(Pose2D::identity(), Pose2D::identity(), &cloud, 0, 1.0);
}
let stats = store.memory_stats();
assert_eq!(stats.scan_count, 10);
assert_eq!(stats.total_points, 1000);
assert!((stats.avg_points_per_scan - 100.0).abs() < 0.1);
assert!(stats.total_kb() > 0.0);
}
#[test]
fn test_scan_store_all_world_points() {
let mut store = ScanStore::with_defaults();
// Add two scans at different positions
let cloud1 = make_point_cloud(5); // Points at (0,0), (0.1,0), ...
store.add_scan(
Pose2D::identity(),
Pose2D::new(10.0, 0.0, 0.0), // Translate by (10, 0)
&cloud1,
0,
1.0,
);
let cloud2 = make_point_cloud(5);
store.add_scan(
Pose2D::identity(),
Pose2D::new(20.0, 0.0, 0.0), // Translate by (20, 0)
&cloud2,
0,
1.0,
);
let all_points = store.all_world_points();
assert_eq!(all_points.len(), 10);
// First 5 points should be around x=10
assert!((all_points[0].x - 10.0).abs() < 0.01);
// Last 5 points should be around x=20
assert!((all_points[5].x - 20.0).abs() < 0.01);
}
#[test]
fn test_scan_store_clear_and_reset() {
let mut store = ScanStore::with_defaults();
let cloud = make_point_cloud(10);
store.add_scan(Pose2D::identity(), Pose2D::identity(), &cloud, 0, 1.0);
store.add_scan(Pose2D::identity(), Pose2D::identity(), &cloud, 0, 1.0);
assert_eq!(store.next_scan_id(), 2);
store.clear();
assert!(store.is_empty());
assert_eq!(store.next_scan_id(), 2); // ID preserved
store.add_scan(Pose2D::identity(), Pose2D::identity(), &cloud, 0, 1.0);
assert_eq!(store.latest().unwrap().scan_id, 2);
store.reset();
assert!(store.is_empty());
assert_eq!(store.next_scan_id(), 0); // ID reset
}
}
+3
View File
@@ -139,6 +139,9 @@ pub use extraction::{LineExtractor, SplitMergeExtractor};
pub use loop_closure::LoopDetector;
pub use matching::ScanMatcher;
// Re-export scan storage for exploration
pub use integration::{ScanStore, ScanStoreConfig, ScanStoreStats, StoredScan};
// ─────────────────────────────────────────────────────────────────────────────
// Map Trait and Supporting Types
// ─────────────────────────────────────────────────────────────────────────────
+268 -1
View File
@@ -2,11 +2,14 @@
use std::time::Instant;
use crate::config::ExplorationConfig;
use crate::core::{Bounds, Point2D, PointCloud2D, Pose2D};
use crate::extraction::{detect_corners, extract_lines, extract_lines_hybrid};
use crate::features::{Corner2D, FeatureSet, Line2D};
use crate::integration::{
batch_merge, create_new_line, find_associations, find_unmatched_scan_lines,
associate_points_to_lines, batch_merge, create_new_line, find_associations,
find_unmatched_scan_lines, refit_line, PointAssociationConfig, RefitConfig, RefitResult,
RefitStats, ScanStore, ScanStoreConfig,
};
use crate::loop_closure::{LoopClosure, LoopClosureDetector};
use crate::matching::PointToLineIcp;
@@ -158,6 +161,13 @@ pub struct VectorMap {
/// Pre-allocated scratch space for observe pipeline (zero-allocation).
observe_scratch: ObserveScratchSpace,
/// Scan storage for exploration mode.
/// When enabled, stores raw scan data for point-cloud-based line re-fitting.
scan_store: Option<ScanStore>,
/// Exploration configuration (None if exploration mode disabled).
exploration_config: Option<ExplorationConfig>,
}
impl VectorMap {
@@ -175,6 +185,8 @@ impl VectorMap {
loop_closures: Vec::new(),
icp_matcher,
observe_scratch: ObserveScratchSpace::default_capacity(),
scan_store: None,
exploration_config: None,
}
}
@@ -230,6 +242,75 @@ impl VectorMap {
self.loop_closures.clear();
}
// ─────────────────────────────────────────────────────────────────────────
// Exploration Mode
// ─────────────────────────────────────────────────────────────────────────
/// Enable exploration mode with the given configuration.
///
/// In exploration mode, VectorMap stores raw scan data and periodically
/// re-fits lines from accumulated point clouds, eliminating first-scan bias.
///
/// # Arguments
/// * `config` - Exploration configuration
///
/// # Example
/// ```rust,ignore
/// use vastu_map::config::ExplorationConfig;
///
/// let config = ExplorationConfig::default()
/// .with_refit_interval(10)
/// .with_gap_threshold(0.3);
///
/// map.enable_exploration_mode(config);
/// ```
pub fn enable_exploration_mode(&mut self, config: ExplorationConfig) {
let scan_config = ScanStoreConfig::default();
self.scan_store = Some(ScanStore::new(scan_config));
self.exploration_config = Some(config);
}
/// Enable exploration mode with custom scan store configuration.
///
/// Allows configuring max scans, distance/rotation filtering, etc.
pub fn enable_exploration_mode_with_scan_config(
&mut self,
exploration_config: ExplorationConfig,
scan_config: ScanStoreConfig,
) {
self.scan_store = Some(ScanStore::new(scan_config));
self.exploration_config = Some(exploration_config);
}
/// Disable exploration mode and return the accumulated scan store.
///
/// Returns the ScanStore containing all accumulated scans, or None
/// if exploration mode was not enabled.
pub fn disable_exploration_mode(&mut self) -> Option<ScanStore> {
self.exploration_config = None;
self.scan_store.take()
}
/// Check if exploration mode is enabled.
pub fn is_exploration_mode(&self) -> bool {
self.exploration_config.is_some()
}
/// Get the exploration configuration (if exploration mode is enabled).
pub fn exploration_config(&self) -> Option<&ExplorationConfig> {
self.exploration_config.as_ref()
}
/// Get the scan store (if exploration mode is enabled).
pub fn scan_store(&self) -> Option<&ScanStore> {
self.scan_store.as_ref()
}
/// Get mutable access to the scan store (if exploration mode is enabled).
pub fn scan_store_mut(&mut self) -> Option<&mut ScanStore> {
self.scan_store.as_mut()
}
/// Add a line to the map.
pub fn add_line(&mut self, line: Line2D) {
self.line_store.add(line);
@@ -336,6 +417,17 @@ impl VectorMap {
self.current_pose = final_pose;
// ─── Scan Storage (Exploration Mode) ───
if let Some(ref mut store) = self.scan_store {
store.add_scan(
odometry,
final_pose,
scan,
0, // features_extracted - will be updated after extraction
confidence,
);
}
// ─── Feature Extraction ───
// (robot_frame_points already filled in scratch above)
let features = self.extract_features_timed(&final_pose, &mut timing);
@@ -347,6 +439,18 @@ impl VectorMap {
(0, 0)
};
// ─── Periodic Line Re-fitting (Exploration Mode) ───
if let Some(ref config) = self.exploration_config {
if config.refit_interval > 0 && self.observation_count % config.refit_interval == 0 {
log::debug!(
"Triggering line re-fit at observation {}",
self.observation_count
);
// Trigger line re-fitting from accumulated scans
let _ = self.refit_lines_from_scans();
}
}
// ─── Loop Closure ───
let loop_closure_detected = if self.config.loop_closure_enabled {
let lc_start = Instant::now();
@@ -396,6 +500,17 @@ impl VectorMap {
// Fill scratch with robot frame points (used by feature extraction and loop closure)
self.observe_scratch.fill_robot_frame_points(scan);
// ─── Scan Storage (Exploration Mode) ───
if let Some(ref mut store) = self.scan_store {
store.add_scan(
odometry,
odometry, // First scan: odometry = estimated pose
scan,
0, // features_extracted - unknown at this point
0.0, // confidence - no ICP for first scan
);
}
// Extract features from robot-frame scan, transform to world using odometry
let features = self.extract_features_timed(&odometry, &mut timing);
@@ -536,6 +651,158 @@ impl VectorMap {
fn update_corners(&mut self) {
self.corners = detect_corners(self.line_store.lines(), &self.config.corner);
}
/// Optimize map by merging coplanar line segments.
///
/// Call this periodically (e.g., after N observations) to clean up
/// redundant line segments that represent the same wall.
///
/// # Arguments
/// * `config` - Configuration for coplanar merging thresholds
///
/// # Returns
/// Number of lines merged (removed from the map)
///
/// # Example
/// ```rust,ignore
/// use vastu_map::integration::CoplanarMergeConfig;
///
/// // After exploration...
/// let config = CoplanarMergeConfig::default()
/// .with_max_angle_diff(0.0175) // 1 degree
/// .with_max_perpendicular_dist(0.02); // 2cm
/// let merged = map.optimize_lines(&config);
/// println!("Merged {} redundant lines", merged);
/// ```
pub fn optimize_lines(
&mut self,
config: &crate::integration::CoplanarMergeConfig,
) -> usize {
let merged = crate::integration::optimize_coplanar_lines(self.line_store.lines_mut(), config);
if merged > 0 {
// Rebuild auxiliary structures
self.line_store.rebuild_all();
// Update corners based on new line set
self.update_corners();
}
merged
}
// ─────────────────────────────────────────────────────────────────────────
// Line Re-fitting from Accumulated Scans
// ─────────────────────────────────────────────────────────────────────────
/// Re-fit all map lines from accumulated scan points.
///
/// This method uses points from the ScanStore to re-fit line geometry,
/// eliminating first-scan bias. Call this periodically during exploration.
///
/// Requires exploration mode to be enabled.
///
/// # Returns
/// Statistics about the re-fitting operation, or None if exploration mode
/// is not enabled.
///
/// # Example
/// ```rust,ignore
/// if map.is_exploration_mode() && map.observation_count() % 10 == 0 {
/// if let Some(stats) = map.refit_lines_from_scans() {
/// println!("Refitted lines: {}/{} success rate",
/// stats.lines_refitted_single + stats.lines_split,
/// stats.total_processed());
/// }
/// }
/// ```
pub fn refit_lines_from_scans(&mut self) -> Option<RefitStats> {
// Get exploration config and scan store
let exploration_config = self.exploration_config.as_ref()?;
let scan_store = self.scan_store.as_ref()?;
if self.line_store.is_empty() || scan_store.is_empty() {
return Some(RefitStats::default());
}
// Build association config from exploration config
let assoc_config = PointAssociationConfig::default()
.with_max_distance(exploration_config.max_point_distance);
// Associate all stored points with map lines
let associations = associate_points_to_lines(
scan_store,
self.line_store.lines(),
self.line_store.index(),
&assoc_config,
);
log::debug!(
"Refit: {} scans, {} lines, {} points associated ({:.1}% rate)",
scan_store.len(),
self.line_store.lines().len(),
associations.total_associated,
associations.association_rate() * 100.0
);
// Build robot pose map for weighted fitting
let robot_poses: std::collections::HashMap<u32, Pose2D> = scan_store
.iter()
.map(|scan| (scan.scan_id, scan.estimated_pose))
.collect();
// Build refit config from exploration config
let refit_config = RefitConfig::default()
.with_min_points(exploration_config.min_points_per_line)
.with_gap_threshold(exploration_config.gap_threshold)
.with_noise_model(exploration_config.noise_model);
// Re-fit each line
let mut stats = RefitStats::default();
let mut new_lines: Vec<Line2D> = Vec::new();
for (line_idx, associated_points) in associations.line_points.iter().enumerate() {
let original = &self.line_store.lines()[line_idx];
let result = refit_line(
associated_points,
original,
&robot_poses,
&refit_config,
);
match result {
RefitResult::Single(line) => {
new_lines.push(line);
stats.lines_refitted_single += 1;
}
RefitResult::Split(lines) => {
stats.total_segments_created += lines.len();
new_lines.extend(lines);
stats.lines_split += 1;
}
RefitResult::Insufficient => {
// Keep original line
new_lines.push(original.clone());
stats.lines_insufficient += 1;
}
}
}
// Replace all lines and rebuild
*self.line_store.lines_mut() = new_lines;
self.line_store.rebuild_all();
self.update_corners();
log::debug!(
"Refit complete: {} single, {} split, {} insufficient, {} new segments",
stats.lines_refitted_single,
stats.lines_split,
stats.lines_insufficient,
stats.total_segments_created
);
Some(stats)
}
}
impl Map for VectorMap {
+44 -12
View File
@@ -9,8 +9,10 @@ use sangam_io::devices::mock::map_loader::SimulationMap;
use sangam_io::devices::mock::noise::NoiseGenerator;
use sangam_io::devices::mock::physics::PhysicsState;
use vastu_map::config::ExplorationConfig;
use vastu_map::core::Pose2D;
use vastu_map::core::math::normalize_angle;
use vastu_map::integration::{ScanStore, ScanStoreConfig};
use vastu_map::odometry::WheelOdometry;
use vastu_map::query::PathPlanningConfig;
use vastu_map::{Map, VectorMap, VectorMapConfig};
@@ -166,9 +168,6 @@ pub struct TestResult {
pub sim_time: f32,
}
/// Interval for storing lidar scans in trajectory history.
const SCAN_STORAGE_INTERVAL: usize = 5;
/// Per-observation record for trajectory visualization.
#[derive(Clone, Debug)]
pub struct ObservationRecord {
@@ -246,6 +245,8 @@ pub struct TestHarness {
left_encoder_ticks: i32,
/// Simulated right encoder ticks (accumulated)
right_encoder_ticks: i32,
/// Scan storage for visualization (stores ALL scans)
scan_store: ScanStore,
}
impl TestHarness {
@@ -277,6 +278,9 @@ impl TestHarness {
let wheel_odometry =
WheelOdometry::new(config.robot.wheel_base, config.robot.ticks_per_meter);
// Create scan store for visualization (stores all scans)
let scan_store = ScanStore::new(ScanStoreConfig::default());
Ok(Self {
config,
map,
@@ -296,6 +300,7 @@ impl TestHarness {
wheel_odometry,
left_encoder_ticks: 0,
right_encoder_ticks: 0,
scan_store,
})
}
@@ -305,6 +310,25 @@ impl TestHarness {
self
}
/// Enable exploration mode on the SLAM map.
///
/// In exploration mode, VectorMap stores raw scan data and periodically
/// re-fits lines from accumulated point clouds, eliminating first-scan bias.
///
/// # Arguments
/// * `config` - Exploration configuration (uses default if None)
///
/// # Example
/// ```rust,ignore
/// let harness = TestHarness::new(HarnessConfig::simple_room())?
/// .with_exploration_mode(None); // Use default config
/// ```
pub fn with_exploration_mode(mut self, config: Option<ExplorationConfig>) -> Self {
let exploration_config = config.unwrap_or_default();
self.slam.enable_exploration_mode(exploration_config);
self
}
/// Run simulation along a path.
pub fn run_path(&mut self, segments: &[PathSegment]) -> TestResult {
for segment in segments {
@@ -425,6 +449,15 @@ impl TestHarness {
self.last_physics_pose = current_truth; // Update for metrics computation
self.observations += 1;
// Store scan in scan store for visualization (stores ALL scans)
self.scan_store.add_scan(
odometry,
result.pose,
&cloud,
result.features_extracted,
result.confidence,
);
// Record ICP convergence stats
self.convergence_stats
.record(result.icp_iterations, result.icp_converged);
@@ -433,15 +466,9 @@ impl TestHarness {
self.timing_stats.record(&result.timing);
// Record to trajectory history for visualization
// Store scan at intervals to avoid excessive memory usage
let scan_to_store = if self.observations % SCAN_STORAGE_INTERVAL == 0 {
Some(cloud.clone())
} else {
None
};
// Note: We no longer store scans here since ScanStore handles it
self.trajectory
.record(current_truth, result.pose, result.confidence, scan_to_store);
.record(current_truth, result.pose, result.confidence, None);
log::debug!(
"Observation {}: truth=({:.2}, {:.2}, {:.1}°), slam=({:.2}, {:.2}, {:.1}°), conf={:.2}, icp_iters={}",
@@ -527,7 +554,7 @@ impl TestHarness {
&self.map,
final_pose,
ground_truth_pose,
self.last_scan_robot_frame.as_ref(),
&self.scan_store,
&self.trajectory,
&metrics,
);
@@ -685,6 +712,11 @@ impl TestHarness {
pub fn observation_count(&self) -> usize {
self.observations
}
/// Get scan store for visualization.
pub fn scan_store(&self) -> &ScanStore {
&self.scan_store
}
}
/// Result of a single simulation step.
@@ -26,8 +26,10 @@
use crate::{HarnessConfig, TestHarness};
use vastu_map::Map;
use vastu_map::config::ExplorationConfig as MapExplorationConfig;
use vastu_map::core::Point2D;
use vastu_map::exploration::{CollisionEvent, CollisionType, ExplorationConfig, VelocityCommand};
use vastu_map::integration::CoplanarMergeConfig;
/// Maximum exploration cycles before timeout.
const MAX_CYCLES: usize = 200;
@@ -57,8 +59,12 @@ pub struct ExplorationTestResult {
pub collisions: usize,
/// Number of virtual walls added.
pub virtual_walls: usize,
/// Final map line count.
/// Map line count before optimization.
pub map_lines_before: usize,
/// Final map line count (after optimization).
pub map_lines: usize,
/// Number of lines merged during optimization.
pub lines_merged: usize,
/// Initial frontier count.
pub initial_frontiers: usize,
/// Final frontier count (should be 0 on success).
@@ -86,11 +92,19 @@ mod tests {
// Setup harness with medium_room map and visualization
let harness_config = HarnessConfig::medium_room();
// Enable VectorMap exploration mode for point-cloud-based line re-fitting
// This will re-fit lines every 10 observations using accumulated scan data
let map_exploration_config = MapExplorationConfig::default()
.with_refit_interval(10)
.with_gap_threshold(0.3);
let mut harness = TestHarness::new(harness_config)
.expect("Failed to create harness")
.with_exploration_mode(Some(map_exploration_config))
.with_visualization("results/exploration_test.svg");
// Create exploration config
// Create robot exploration config (for path following)
let exploration_config = ExplorationConfig::default()
.with_max_linear_speed(0.3)
.with_max_angular_speed(1.0)
@@ -107,7 +121,10 @@ mod tests {
println!("Cycles: {}", result.cycles);
println!("Collisions handled: {}", result.collisions);
println!("Virtual walls added: {}", result.virtual_walls);
println!("Final map lines: {}", result.map_lines);
println!(
"Map lines: {} before optimization -> {} after ({} merged)",
result.map_lines_before, result.map_lines, result.lines_merged
);
println!(
"Frontiers: {} initial -> {} remaining",
result.initial_frontiers, result.remaining_frontiers
@@ -317,6 +334,25 @@ mod tests {
);
}
// Get line count before optimization
let map_lines_before = harness.slam().lines().len();
// Optimize coplanar lines with tight thresholds:
// - max_angle_diff: 1 degree = 0.01745 rad
// - max_perpendicular_dist: 2cm = 0.02m
let coplanar_config = CoplanarMergeConfig::default()
.with_max_angle_diff(3.0 * std::f32::consts::PI / 180.0) // 1 degree
.with_max_perpendicular_dist(0.05); // 2cm
let lines_merged = harness.slam_mut().optimize_lines(&coplanar_config);
println!(
"\n Line optimization: {} lines -> {} lines ({} merged)",
map_lines_before,
harness.slam().lines().len(),
lines_merged
);
// Finalize to generate visualization
let _test_result = harness.finalize();
@@ -326,7 +362,9 @@ mod tests {
cycles,
collisions,
virtual_walls,
map_lines_before,
map_lines: harness.slam().lines().len(),
lines_merged,
initial_frontiers,
remaining_frontiers: harness.slam().frontiers().len(),
}
+55 -53
View File
@@ -17,7 +17,8 @@ use svg::node::element::{
};
use vastu_map::VectorMap;
use vastu_map::core::{Point2D, PointCloud2D, Pose2D};
use vastu_map::core::{Point2D, Pose2D};
use vastu_map::integration::ScanStore;
use crate::harness::TrajectoryHistory;
use crate::metrics::TestMetrics;
@@ -91,7 +92,7 @@ impl Visualizer {
map: &SimulationMap,
final_pose: Pose2D,
truth_pose: Pose2D,
last_scan: Option<&PointCloud2D>,
scan_store: &ScanStore,
trajectory: &TrajectoryHistory,
metrics: &TestMetrics,
) {
@@ -125,12 +126,13 @@ impl Visualizer {
// Layer 1: Map walls (gray)
doc = doc.add(self.render_map_walls(map, height as f32));
// Layer 2: Scan history (semi-transparent)
doc = doc.add(self.render_scan_history(trajectory, height as f32));
// Layer 2: All scans from scan store (semi-transparent)
doc = doc.add(self.render_all_scans(scan_store, height as f32));
// Layer 3: Final lidar scan (full opacity)
if let Some(scan) = last_scan {
doc = doc.add(self.render_lidar_scan(scan, final_pose, height as f32));
// Layer 3: Final lidar scan (full opacity) - use last scan from store
if let Some(last_scan) = scan_store.latest() {
let world_points = last_scan.world_points();
doc = doc.add(self.render_world_points(&world_points, height as f32));
}
// Layer 4: Detected walls (teal)
@@ -277,29 +279,55 @@ impl Visualizer {
group
}
/// Render lidar scans at key points along the trajectory.
fn render_scan_history(&self, trajectory: &TrajectoryHistory, svg_height: f32) -> Group {
/// Render all scans from the scan store.
fn render_all_scans(&self, scan_store: &ScanStore, svg_height: f32) -> Group {
let mut group = Group::new().set("id", "scan_history");
for obs in &trajectory.observations {
if let Some(ref scan) = obs.scan {
// Transform scan from robot frame to world frame
let world_scan = scan.transform(&obs.slam_pose);
for i in 0..world_scan.len() {
let point = Point2D::new(world_scan.xs[i], world_scan.ys[i]);
let (sx, sy) = self.transform_point(point, 0.0, 0.0, svg_height);
let circle = Circle::new()
.set("cx", sx)
.set("cy", sy)
.set("r", 2.0)
.set("fill", colors::LIDAR_SCAN_HISTORY)
.set("opacity", SCAN_HISTORY_OPACITY);
group = group.add(circle);
}
// Skip the last scan as it will be rendered at full opacity separately
let scan_count = scan_store.len();
for (idx, scan) in scan_store.iter().enumerate() {
// Skip the last scan (rendered separately at full opacity)
if idx == scan_count - 1 {
continue;
}
// Get world-frame points directly from stored scan
let world_points = scan.world_points();
for point in world_points {
let (sx, sy) = self.transform_point(point, 0.0, 0.0, svg_height);
let circle = Circle::new()
.set("cx", sx)
.set("cy", sy)
.set("r", 2.0)
.set("fill", colors::LIDAR_SCAN_HISTORY)
.set("opacity", SCAN_HISTORY_OPACITY);
group = group.add(circle);
}
}
group
}
/// Render world-frame points (for final scan).
fn render_world_points(&self, points: &[Point2D], svg_height: f32) -> Group {
let mut group = Group::new().set("id", "lidar_scan");
for point in points {
let (sx, sy) = self.transform_point(*point, 0.0, 0.0, svg_height);
let circle = Circle::new()
.set("cx", sx)
.set("cy", sy)
.set("r", 3.0)
.set("fill", colors::LIDAR_SCAN)
.set("stroke", colors::TRUTH_PATH)
.set("stroke-width", 0.5)
.set("opacity", 0.8);
group = group.add(circle);
}
group
@@ -515,32 +543,6 @@ impl Visualizer {
group
}
/// Render lidar scan points transformed to world frame.
fn render_lidar_scan(&self, scan: &PointCloud2D, pose: Pose2D, svg_height: f32) -> Group {
let mut group = Group::new().set("id", "lidar_scan");
// Transform scan from robot frame to world frame
let world_scan = scan.transform(&pose);
for i in 0..world_scan.len() {
let point = Point2D::new(world_scan.xs[i], world_scan.ys[i]);
let (sx, sy) = self.transform_point(point, 0.0, 0.0, svg_height);
let circle = Circle::new()
.set("cx", sx)
.set("cy", sy)
.set("r", 3.0)
.set("fill", colors::LIDAR_SCAN)
.set("stroke", colors::TRUTH_PATH)
.set("stroke-width", 0.5)
.set("opacity", 0.8);
group = group.add(circle);
}
group
}
fn render_poses(&self, estimated: Pose2D, truth: Pose2D, svg_height: f32) -> Group {
let mut group = Group::new().set("id", "poses");