using CartographerSharp.IO;
using CartographerSharp.Mapping;
using CartographerSharp.Transform;
using RobotNet10.RobotApp.Shared;
using RobotNet10.Shared.Geometry;
using RobotNet10.Shared.Localization;
using System.Text.Json;
using RobotNet10.RobotApp.SLAM.Cartographer;
namespace RobotNet10.RobotApp.SLAM.Cartographer.Helpers;
///
/// Handles map saving workflow including scan matching, optimization, and file I/O
///
public class MapSaveProcessor
{
#region Fields and Constructor
private readonly CartographerConfiguration _config;
private readonly ILogger _logger;
private readonly string _mapsDirectory;
private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true };
public MapSaveProcessor(
CartographerConfiguration config,
ILogger logger)
{
_config = config ?? throw new ArgumentNullException(nameof(config));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Get maps directory from configuration
_mapsDirectory = Path.GetFullPath(_config.MapStorage.Directory);
Directory.CreateDirectory(_mapsDirectory);
}
#endregion
#region Save Workflow
///
/// Execute full save map workflow with optimization and file I/O
///
/// Name of the map to save
/// MapBuilder containing map data
/// Active trajectory ID to finish (-1 if none)
/// Progress callback (total, current, percent)
/// Optional new origin to transform the map before saving (e.g., wall-aligned pose)
/// Cancellation token
public async Task SaveMapAsync(
string mapName,
IMapBuilder mapBuilder,
int trajectoryId,
Func progressCallback,
Pose? newOrigin,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(mapBuilder);
ArgumentNullException.ThrowIfNull(mapName);
try
{
_logger.LogInformation("MapSaveProcessor: Starting map save process for: {MapName}", mapName);
// Wait for active scan matching to complete
await WaitForScanMatchingAsync(cancellationToken);
// Track total progress across all phases
// Phase 1: Initial work queue drain (0-20%)
// Phase 2: Finish trajectory (20-40%)
// Phase 3: RunFinalOptimization - work queue + constraint builder (40-90%)
// Phase 4: Final scan matching + file saving (90-100%)
// Finish trajectory if active
if (trajectoryId >= 0)
{
// Phase 1: Drain work queue before finishing trajectory (0-20%)
_logger.LogDebug("MapSaveProcessor: Phase 1 - Draining work queue before finishing trajectory");
await DrainWorkQueueWithProgressAsync(mapBuilder, progressCallback, 0, 20, cancellationToken);
// Phase 2: Finish trajectory (20-40%)
_logger.LogDebug("MapSaveProcessor: Phase 2 - Finishing trajectory ID: {TrajectoryId}", trajectoryId);
await progressCallback(100, 20, 20);
await FinishTrajectoryAsync(mapBuilder, trajectoryId, cancellationToken);
await progressCallback(100, 40, 40);
}
else
{
// No trajectory to finish, skip to phase 3
await progressCallback(100, 40, 40);
}
_logger.LogDebug("MapSaveProcessor: Finishing all active trajectories");
// Finish all other active trajectories
await mapBuilder.FinishAllActiveTrajectoriesAsync(cancellationToken);
// Phase 3: Run final optimization (40-90%)
// RunFinalOptimization internally calls WaitForAllComputations which:
// - Drains work queue
// - Waits for constraint builder to finish
// - Runs optimization
cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug("MapSaveProcessor: Phase 3 - Running final pose graph optimization");
// Start optimization in background task and track progress
var optimizationTask = Task.Run(() => mapBuilder.PoseGraph.RunFinalOptimization(), cancellationToken);
// Track progress while optimization is running
await TrackOptimizationProgressAsync(mapBuilder, progressCallback, 40, 90, optimizationTask, cancellationToken);
// Phase 4: Final scan matching + file saving (90-100%)
_logger.LogDebug("MapSaveProcessor: Phase 4 - Final scan matching and file saving");
await progressCallback(100, 90, 90);
// Final wait for scan matching
await WaitForScanMatchingAsync(cancellationToken);
// Get map path
var mapPath = GetMapPath(mapName);
// Validate map before saving
var (isValid, errorMessage) = ValidateMapBeforeSave(mapBuilder, mapName, mapPath);
if (!isValid)
{
throw new InvalidOperationException($"Map validation failed: {errorMessage}");
}
_logger.LogDebug("MapSaveProcessor: Saving pbstream and metadata to: {MapPath}", mapPath);
// Save pbstream file
SavePbstream(mapBuilder, mapPath);
// Transform origin if newOrigin is provided (e.g., wall alignment)
IMapBuilder mapBuilderForMetadata = mapBuilder;
if (newOrigin.HasValue && !PbstreamTransformHelper.IsIdentityPose(newOrigin.Value))
{
_logger.LogDebug("MapSaveProcessor: Transforming map origin with wall-aligned pose");
var pbstreamPath = Path.Combine(mapPath, "map.pbstream");
// For wall alignment, we need to apply the INVERSE of the compensation pose.
// The compensation angle represents "how much the robot should rotate to align the wall",
// but TransformToMap uses the inverse when rendering (via TransformToMapInverse).
// So we invert the pose here to get the correct rotation direction in the final map.
var poseRigid = PbstreamTransformHelper.PoseToRigid3d(newOrigin.Value);
var invertedRigid = poseRigid.Inverse();
var invertedPose = new Pose
{
Position = new RobotNet10.Shared.Numbers.Vector3(
invertedRigid.Translation.X,
invertedRigid.Translation.Y,
invertedRigid.Translation.Z),
Orientation = new RobotNet10.Shared.Numbers.Quaternion(
invertedRigid.Rotation.X,
invertedRigid.Rotation.Y,
invertedRigid.Rotation.Z,
invertedRigid.Rotation.W)
};
PbstreamTransformHelper.TransformPbstreamOrigin(pbstreamPath, invertedPose, _logger);
// Reload MapBuilder from transformed pbstream
var loadResult = new MapCartographerLoadResult
{
PbstreamPath = pbstreamPath,
MapPath = mapPath,
SavedConfig = null // Will use current config
};
mapBuilderForMetadata = MapBuilderHelper.CreateMapBuilderFromLoadResult(
loadResult, _config, _logger, loadFrozenState: false);
}
_logger.LogDebug("MapSaveProcessor: Generating occupancy grid and saving metadata");
// Generate occupancy grid and save metadata
var saveResult = await SaveMapMetadataAsync(mapName, mapBuilderForMetadata, mapPath, cancellationToken);
// Dispose reloaded MapBuilder if it was created
if (mapBuilderForMetadata != mapBuilder && mapBuilderForMetadata is IDisposable disposable)
{
disposable.Dispose();
}
// Final progress update (100%)
await progressCallback(100, 100, 100);
_logger.LogInformation("MapSaveProcessor: Map saved successfully: {MapName}", mapName);
return saveResult;
}
catch (OperationCanceledException)
{
_logger.LogWarning("MapSaveProcessor: Map save cancelled for: {MapName}", mapName);
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "MapSaveProcessor: Failed to save map: {MapName}", mapName);
throw;
}
}
#endregion
#region Progress Tracking
///
/// Wait for active scan matching operations to complete
///
private static async Task WaitForScanMatchingAsync(CancellationToken cancellationToken, int checkIntervalMs = 100)
{
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var activeCount = CartographerSharp.Mapping.Internal.D2D.ScanMatching.CeresScanMatcher2D.GetActiveScanMatchingCount();
if (activeCount == 0)
break;
await Task.Delay(checkIntervalMs, cancellationToken);
}
}
///
/// Finish active trajectory and wait for completion
///
private static async Task FinishTrajectoryAsync(IMapBuilder mapBuilder, int trajectoryId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
// Wait for scan matching to complete before finishing trajectory
// Wait for scan matching to complete
await WaitForScanMatchingAsync(cancellationToken);
// Finish trajectory
cancellationToken.ThrowIfCancellationRequested();
// FinishTrajectory is called synchronously
mapBuilder.FinishTrajectory(trajectoryId);
// Wait for trajectory to finish asynchronously
// Wait for trajectory to finish
await mapBuilder.WaitForTrajectoryFinishedAsync(trajectoryId, cancellationToken);
// Trajectory finished successfully
}
///
/// Drain work queue with progress updates mapped to a percentage range.
///
private static async Task DrainWorkQueueWithProgressAsync(
IMapBuilder mapBuilder,
Func progressCallback,
int progressStart,
int progressEnd,
CancellationToken cancellationToken)
{
var poseGraph = mapBuilder.PoseGraph;
var initialQueueCount = poseGraph.WorkQueueCount;
if (initialQueueCount == 0)
{
await progressCallback(100, progressEnd, progressEnd);
return;
}
var progressRange = progressEnd - progressStart;
while (poseGraph.WorkQueueCount > 0)
{
cancellationToken.ThrowIfCancellationRequested();
var currentQueueCount = poseGraph.WorkQueueCount;
var processed = initialQueueCount - currentQueueCount;
// Calculate progress within the range
int percentComplete;
if (initialQueueCount > 0)
{
var phaseProgress = (double)processed / initialQueueCount;
percentComplete = progressStart + (int)(phaseProgress * progressRange);
}
else
{
percentComplete = progressEnd;
}
percentComplete = Math.Clamp(percentComplete, progressStart, progressEnd);
await progressCallback(initialQueueCount, processed, percentComplete);
await Task.Delay(500, cancellationToken);
}
await progressCallback(initialQueueCount, initialQueueCount, progressEnd);
}
///
/// Track optimization progress (work queue + constraint builder) while optimization task is running.
///
private static async Task TrackOptimizationProgressAsync(
IMapBuilder mapBuilder,
Func progressCallback,
int progressStart,
int progressEnd,
Task optimizationTask,
CancellationToken cancellationToken)
{
var poseGraph = mapBuilder.PoseGraph;
var progressRange = progressEnd - progressStart;
// Capture the remaining work at the start of tracking.
// Progress is calculated based on how much of this remaining work has been completed,
// so the remaining portion at call time = 100%.
var initialWorkQueueCount = poseGraph.WorkQueueCount;
var initialConstraintTasksFinished = poseGraph.ConstraintTasksFinished;
var initialConstraintTasksTotal = poseGraph.ConstraintTasksTotal;
var remainingConstraintTasks = initialConstraintTasksTotal - initialConstraintTasksFinished;
// Tracking optimization progress
var lastLogTime = DateTime.UtcNow;
while (!optimizationTask.IsCompleted)
{
cancellationToken.ThrowIfCancellationRequested();
var workQueueCount = poseGraph.WorkQueueCount;
var constraintTasksTotal = poseGraph.ConstraintTasksTotal;
var constraintTasksFinished = poseGraph.ConstraintTasksFinished;
// Recalculate remaining tasks as total may grow during processing
var currentRemainingTotal = constraintTasksTotal - initialConstraintTasksFinished;
var effectiveRemaining = Math.Max(remainingConstraintTasks, currentRemainingTotal);
// Calculate progress based on remaining work from the start of tracking:
// 1. Work queue drain progress (30% of phase)
double workQueueProgress;
if (initialWorkQueueCount > 0)
{
var workQueueProcessed = initialWorkQueueCount - workQueueCount;
workQueueProgress = Math.Min(1.0, (double)workQueueProcessed / initialWorkQueueCount);
}
else
{
workQueueProgress = 1.0; // No work queue items at start
}
// 2. Constraint task progress (70% of phase)
double constraintProgress;
if (effectiveRemaining > 0)
{
var newlyFinished = constraintTasksFinished - initialConstraintTasksFinished;
constraintProgress = Math.Min(1.0, (double)newlyFinished / effectiveRemaining);
}
else
{
constraintProgress = 1.0; // No constraint tasks to process
}
// Combined progress weighted by actual work amounts
var totalWork = initialWorkQueueCount + effectiveRemaining;
var combinedProgress = totalWork > 0
? (workQueueProgress * initialWorkQueueCount + constraintProgress * effectiveRemaining) / totalWork
: 1.0;
var percentComplete = progressStart + (int)(combinedProgress * progressRange);
percentComplete = Math.Clamp(percentComplete, progressStart, progressEnd);
await progressCallback(constraintTasksTotal, constraintTasksFinished, percentComplete);
// Update last log time periodically (logging removed to reduce noise)
if ((DateTime.UtcNow - lastLogTime).TotalSeconds >= 10)
{
lastLogTime = DateTime.UtcNow;
}
// Check more frequently for quicker updates
await Task.Delay(1000, cancellationToken);
}
// Wait for optimization task to complete (may throw if cancelled)
await optimizationTask;
await progressCallback(poseGraph.ConstraintTasksTotal, poseGraph.ConstraintTasksFinished, progressEnd);
// Optimization completed successfully
}
#endregion
#region File I/O
///
/// Save pbstream file from MapBuilder state
///
private void SavePbstream(IMapBuilder mapBuilder, string mapPath)
{
Directory.CreateDirectory(mapPath);
var pbstreamPath = Path.Combine(mapPath, "map.pbstream");
var includeUnfinished = _config.MapStorage.IncludeUnfinishedSubmaps;
try
{
using var writer = new ProtoStreamWriter(pbstreamPath);
mapBuilder.SerializeState(includeUnfinishedSubmaps: includeUnfinished, writer);
if (!writer.Close())
{
throw new InvalidOperationException($"ProtoStreamWriter.Close() failed for {pbstreamPath}");
}
_logger.LogInformation("MapSaveProcessor: Pbstream saved to: {Path}", pbstreamPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "MapSaveProcessor: Failed to save pbstream to: {Path}", pbstreamPath);
throw new InvalidOperationException($"Failed to save pbstream: {ex.Message}", ex);
}
}
///
/// Generate occupancy grid and save metadata files (PGM, PNG, JSON).
/// Can be used for both new map creation and map update (e.g., after transform).
///
/// Map name for sanitization
/// MapBuilder containing the map data
/// Directory path to save files
/// Cancellation token
/// The map path
public async Task SaveMapMetadataAsync(
string mapName,
IMapBuilder mapBuilder,
string mapPath,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
// Generate occupancy grid
var (occupancyGrid, submapCount) = GenerateOccupancyGridForSave(mapBuilder, _config.MapStorage.OccupancyGridResolution);
var poseGraph = mapBuilder.PoseGraph;
var trajectoryNodePoses = poseGraph.GetTrajectoryNodePoses();
var trajectoryNodeCount = trajectoryNodePoses?.Count ?? 0;
var finalSubmapCount = submapCount;
if (finalSubmapCount == 0)
{
var allSubmapData = poseGraph.GetAllSubmapData();
finalSubmapCount = allSubmapData?.Count ?? 0;
}
// Save occupancy grid files if available
if (occupancyGrid != null)
{
await OccupancyGridFileHelper.SaveAllFormatsAsync(occupancyGrid, mapPath, cancellationToken, _logger);
}
else
{
_logger.LogWarning("MapSaveProcessor: No occupancy grid generated, skipping PGM/PNG/JPG files");
}
// Calculate bounds and size (in meters)
var bounds = new BoundingBox();
var size = new MapSize();
if (occupancyGrid != null)
{
var originX = occupancyGrid.Origin.Position.X;
var originY = occupancyGrid.Origin.Position.Y;
var resolution = occupancyGrid.Resolution;
// Size in meters = pixels * resolution
var widthMeters = occupancyGrid.Width * resolution;
var heightMeters = occupancyGrid.Height * resolution;
size = new MapSize(widthMeters, heightMeters);
bounds = new BoundingBox
{
MinX = originX,
MinY = originY,
MaxX = originX + widthMeters,
MaxY = originY + heightMeters
};
}
// Create metadata
var sanitizedMapName = MapNameHelper.Sanitize(mapName);
var metadata = new MapCartographerInfo
{
Name = sanitizedMapName,
FolderPath = mapPath,
CreatedDate = DateTime.UtcNow,
Resolution = occupancyGrid?.Resolution ?? _config.MapStorage.OccupancyGridResolution,
Size = size,
Origin = occupancyGrid?.Origin ?? new Pose
{
Position = new RobotNet10.Shared.Numbers.Vector3(0, 0, 0),
Orientation = new RobotNet10.Shared.Numbers.Quaternion(0, 0, 0, 1)
},
Bounds = bounds,
TrajectoryNodeCount = trajectoryNodeCount,
MapConfig = MapBuilderHelper.CreateConfigSnapshot(_config)
};
// Save metadata JSON
var metadataPath = Path.Combine(mapPath, "map.json");
await File.WriteAllTextAsync(metadataPath, JsonSerializer.Serialize(metadata, SerializerOptions), cancellationToken);
return mapPath;
}
#endregion
#region Validation
///
/// Get map path from map name
///
private string GetMapPath(string mapName)
{
var sanitized = MapNameHelper.Sanitize(mapName);
return Path.Combine(_mapsDirectory, sanitized);
}
///
/// Validate map before saving
///
private (bool IsValid, string? ErrorMessage) ValidateMapBeforeSave(IMapBuilder mapBuilder, string mapName, string mapPath)
{
try
{
var poseGraph = mapBuilder.PoseGraph;
var trajectoryStates = poseGraph.GetTrajectoryStates();
var trajectoryNodePoses = poseGraph.GetTrajectoryNodePoses();
var allSubmapData = poseGraph.GetAllSubmapData();
// Check at least 1 trajectory
if (trajectoryStates.Count == 0)
{
return (false, "No trajectories found. Cannot save empty map.");
}
// Check at least 1 node
if (trajectoryNodePoses.Count == 0)
{
return (false, "No trajectory nodes found. Cannot save map without nodes.");
}
// Check at least 1 submap
if (allSubmapData.Count == 0)
{
return (false, "No submaps found. Cannot save map without submaps.");
}
// Validate map name
var sanitizedMapName = MapNameHelper.Sanitize(mapName);
if (string.IsNullOrWhiteSpace(sanitizedMapName))
{
return (false, $"Invalid map name: '{mapName}' (sanitized to empty string)");
}
// Check poses for NaN/Infinity
int invalidPoseCount = 0;
foreach (var nodePose in trajectoryNodePoses)
{
var globalPose = nodePose.Data.GlobalPose;
if (!globalPose.IsValid())
{
invalidPoseCount++;
}
}
if (invalidPoseCount > 0)
{
return (false, $"Found {invalidPoseCount} trajectory node(s) with invalid poses (NaN/Infinity or invalid quaternion).");
}
// Check submap poses for NaN/Infinity
int invalidSubmapPoseCount = 0;
foreach (var submapPose in poseGraph.GetAllSubmapPoses())
{
var pose = submapPose.Data.Pose;
if (!pose.IsValid())
{
invalidSubmapPoseCount++;
}
}
if (invalidSubmapPoseCount > 0)
{
return (false, $"Found {invalidSubmapPoseCount} submap(s) with invalid poses (NaN/Infinity or invalid quaternion).");
}
// Validate occupancy grid resolution
if (_config.MapStorage.OccupancyGridResolution <= 0)
{
return (false, $"Invalid occupancy grid resolution: {_config.MapStorage.OccupancyGridResolution}. Must be > 0.");
}
// All validations passed
return (true, string.Empty);
}
catch (Exception ex)
{
return (false, $"Validation error: {ex.Message}");
}
}
#endregion
#region Grid Generation
///
/// Generate occupancy grid from MapBuilder for save
///
private (OccupancyGrid? Grid, int SubmapCount) GenerateOccupancyGridForSave(IMapBuilder mapBuilder, double targetResolution)
{
try
{
var padding = _config.MapStorage.MapPadding;
// Use the same Generate() dispatch as OccupancyGridManager (UI display)
// to ensure saved files match what the user sees on the UI.
// Generate() respects OccupancyGridConfiguration.MergeStrategy (default: LogOddsSum).
var occupancyGrid = OccupancyGridGenerator.Generate(
mapBuilder, targetResolution, padding, _logger, _config.OccupancyGrid);
if (occupancyGrid == null)
{
_logger.LogWarning("MapSaveProcessor: Occupancy grid generation returned null");
return (null, 0);
}
// Get submap count for result
var submapCount = mapBuilder.PoseGraph.GetAllSubmapData()?.Count ?? 0;
return (occupancyGrid, submapCount);
}
catch (Exception ex)
{
_logger.LogError(ex, "MapSaveProcessor: Failed to generate occupancy grid from MapBuilder");
return (null, 0);
}
}
#endregion
}