Initial commit
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RobotNet.VDA5050;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing edges with complex node detection logic
|
||||
/// </summary>
|
||||
public class EdgeService(
|
||||
MapDbContext context,
|
||||
INodeService nodeService,
|
||||
LayoutLevelNamingService namingService) : IEdgeService
|
||||
{
|
||||
private readonly MapDbContext _context = context;
|
||||
private readonly INodeService _nodeService = nodeService;
|
||||
private readonly LayoutLevelNamingService _namingService = namingService;
|
||||
|
||||
public async Task<Edge> CreateAsync(CreateEdgeRequest request)
|
||||
{
|
||||
// Get editor settings for validation and proximity radius
|
||||
var settings = await _context.LayoutLevelEditorSettings
|
||||
.FirstOrDefaultAsync(s => s.LevelId == request.LayoutLevelId);
|
||||
|
||||
var proximityRadius = settings?.NodeProximityRadius ?? 0.35;
|
||||
var minEdgeLength = settings?.EdgeMinLengthCreate ?? 0.1;
|
||||
|
||||
// Calculate edge length
|
||||
var dx = request.X2 - request.X1;
|
||||
var dy = request.Y2 - request.Y1;
|
||||
var edgeLength = Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Validate edge length
|
||||
if (edgeLength < minEdgeLength)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Edge length ({edgeLength:F3}m) is less than minimum required ({minEdgeLength:F3}m)");
|
||||
}
|
||||
|
||||
using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
// Find or create start node
|
||||
var (startNode, startNodeIsNew) = await FindOrCreateNodeAsync(
|
||||
request.LayoutLevelId,
|
||||
request.X1,
|
||||
request.Y1,
|
||||
proximityRadius);
|
||||
|
||||
// Find or create end node
|
||||
var (endNode, endNodeIsNew) = await FindOrCreateNodeAsync(
|
||||
request.LayoutLevelId,
|
||||
request.X2,
|
||||
request.Y2,
|
||||
proximityRadius);
|
||||
|
||||
// Validate coordinates within bounds
|
||||
if (!await _nodeService.ValidateCoordinatesAsync(request.LayoutLevelId, startNode.X, startNode.Y))
|
||||
{
|
||||
await CleanupNewNodesAsync(startNode, startNodeIsNew, endNode, endNodeIsNew);
|
||||
throw new InvalidOperationException($"Start coordinates ({startNode.X}, {startNode.Y}) are outside valid bounds");
|
||||
}
|
||||
|
||||
if (!await _nodeService.ValidateCoordinatesAsync(request.LayoutLevelId, endNode.X, endNode.Y))
|
||||
{
|
||||
await CleanupNewNodesAsync(startNode, startNodeIsNew, endNode, endNodeIsNew);
|
||||
throw new InvalidOperationException($"End coordinates ({endNode.X}, {endNode.Y}) are outside valid bounds");
|
||||
}
|
||||
|
||||
if (await _context.Edges.AnyAsync(e => e.StartNodeId == startNode.Id && e.EndNodeId == endNode.Id))
|
||||
{
|
||||
await CleanupNewNodesAsync(startNode, startNodeIsNew, endNode, endNodeIsNew);
|
||||
throw new InvalidOperationException($"Edge with StartNode {startNode.NodeId} and EndNode {endNode.NodeId} already exists");
|
||||
}
|
||||
|
||||
// Generate edge name if not provided
|
||||
var edgeName = request.EdgeName;
|
||||
if (string.IsNullOrEmpty(edgeName) && settings?.EdgeNameAutoGenerate == true)
|
||||
{
|
||||
edgeName = await _namingService.GenerateEdgeNameAsync(request.LayoutLevelId);
|
||||
}
|
||||
|
||||
// Generate unique EdgeId
|
||||
var edgeId = Guid.NewGuid().ToString("N")[..16];
|
||||
|
||||
// Create edge
|
||||
var edge = new Edge
|
||||
{
|
||||
LevelId = request.LayoutLevelId,
|
||||
EdgeId = edgeId,
|
||||
EdgeName = edgeName,
|
||||
EdgeDescription = request.EdgeDescription,
|
||||
StartNodeId = startNode.Id,
|
||||
EndNodeId = endNode.Id,
|
||||
};
|
||||
|
||||
_context.Edges.Add(edge);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Add vehicle properties if provided
|
||||
if (request.VehicleProperties != null && request.VehicleProperties.Count != 0)
|
||||
{
|
||||
foreach (var propDto in request.VehicleProperties)
|
||||
{
|
||||
var prop = new EdgeVehicleProperty
|
||||
{
|
||||
EdgeId = edge.Id,
|
||||
VehicleTypeId = propDto.VehicleTypeId,
|
||||
VehicleOrientation = propDto.VehicleOrientation,
|
||||
OrientationType = propDto.OrientationType,
|
||||
RotationAllowed = propDto.RotationAllowed,
|
||||
RotationAtStartNodeAllowed = propDto.RotationAtStartNodeAllowed,
|
||||
RotationAtEndNodeAllowed = propDto.RotationAtEndNodeAllowed,
|
||||
MaxSpeed = propDto.MaxSpeed,
|
||||
MaxRotationSpeed = propDto.MaxRotationSpeed,
|
||||
MinHeight = propDto.MinHeight,
|
||||
MaxHeight = propDto.MaxHeight,
|
||||
LoadRestriction_Unloaded = propDto.LoadRestriction?.Unloaded,
|
||||
LoadRestriction_Loaded = propDto.LoadRestriction?.Loaded,
|
||||
LoadRestriction_LoadSetNames = propDto.LoadRestriction?.LoadSetNames != null && propDto.LoadRestriction.LoadSetNames.Count > 0
|
||||
? System.Text.Json.JsonSerializer.Serialize(propDto.LoadRestriction.LoadSetNames)
|
||||
: null,
|
||||
TrajectoryDegree = propDto.TrajectoryDegree,
|
||||
TrajectoryControlPoint1X = propDto.TrajectoryControlPoint1X,
|
||||
TrajectoryControlPoint1Y = propDto.TrajectoryControlPoint1Y,
|
||||
TrajectoryControlPoint2X = propDto.TrajectoryControlPoint2X,
|
||||
TrajectoryControlPoint2Y = propDto.TrajectoryControlPoint2Y,
|
||||
CorridorLeftWidth = propDto.CorridorLeftWidth,
|
||||
CorridorRightWidth = propDto.CorridorRightWidth,
|
||||
CorridorRefPoint = propDto.CorridorRefPoint
|
||||
};
|
||||
_context.EdgeVehicleProperties.Add(prop);
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await transaction.CommitAsync();
|
||||
|
||||
// Reload with full details
|
||||
return (await GetByIdAsync(edge.Id, includeNodes: true, includeVehicleProperties: true))!;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(Node Node, bool IsNew)> FindOrCreateNodeAsync(Guid layoutLevelId, double x, double y, double proximityRadius)
|
||||
{
|
||||
// Find nodes within proximity radius
|
||||
var nearbyNodes = await _nodeService.FindNodesNearCoordinatesAsync(layoutLevelId, x, y, proximityRadius);
|
||||
|
||||
if (nearbyNodes.Count != 0)
|
||||
{
|
||||
// Use closest existing node
|
||||
return (nearbyNodes.First(), false);
|
||||
}
|
||||
|
||||
// Create new node at exact coordinates
|
||||
var nodeId = Guid.NewGuid().ToString("N")[..16];
|
||||
var nodeName = await _namingService.GenerateNodeNameAsync(layoutLevelId);
|
||||
|
||||
var newNode = new Node
|
||||
{
|
||||
LevelId = layoutLevelId,
|
||||
NodeId = nodeId,
|
||||
NodeName = nodeName,
|
||||
X = x,
|
||||
Y = y
|
||||
};
|
||||
|
||||
_context.Nodes.Add(newNode);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return (newNode, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only remove nodes that were newly created during this operation, not pre-existing ones.
|
||||
/// </summary>
|
||||
private async Task CleanupNewNodesAsync(Node startNode, bool startNodeIsNew, Node endNode, bool endNodeIsNew)
|
||||
{
|
||||
if (startNodeIsNew) _context.Nodes.Remove(startNode);
|
||||
if (endNodeIsNew) _context.Nodes.Remove(endNode);
|
||||
if (startNodeIsNew || endNodeIsNew) await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<List<Edge>> GetEdgesByLevelAsync(Guid layoutLevelId, bool includeNodes = true, bool includeVehicleProperties = true)
|
||||
{
|
||||
var query = _context.Edges.Where(e => e.LevelId == layoutLevelId);
|
||||
|
||||
if (includeNodes)
|
||||
{
|
||||
query = query.Include(e => e.StartNode).Include(e => e.EndNode);
|
||||
}
|
||||
|
||||
if (includeVehicleProperties)
|
||||
{
|
||||
query = query.Include(e => e.VehicleProperties).ThenInclude(vp => vp.VehicleType);
|
||||
}
|
||||
|
||||
return await query.OrderBy(e => e.EdgeId).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Edge?> GetByIdAsync(Guid edgeId, bool includeNodes = true, bool includeVehicleProperties = true)
|
||||
{
|
||||
var query = _context.Edges.Where(e => e.Id == edgeId);
|
||||
|
||||
if (includeNodes)
|
||||
{
|
||||
query = query.Include(e => e.StartNode).Include(e => e.EndNode);
|
||||
}
|
||||
|
||||
if (includeVehicleProperties)
|
||||
{
|
||||
query = query.Include(e => e.VehicleProperties).ThenInclude(vp => vp.VehicleType);
|
||||
}
|
||||
|
||||
return await query.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<Edge> UpdateAsync(Guid edgeId, UpdateEdgeRequest request)
|
||||
{
|
||||
var edge = await GetByIdAsync(edgeId, includeNodes: false, includeVehicleProperties: true) ??
|
||||
throw new InvalidOperationException($"Edge with ID '{edgeId}' not found");
|
||||
|
||||
// Update properties
|
||||
if (request.EdgeName != null)
|
||||
edge.EdgeName = request.EdgeName;
|
||||
|
||||
if (request.EdgeDescription != null)
|
||||
edge.EdgeDescription = request.EdgeDescription;
|
||||
|
||||
// Update vehicle properties if provided
|
||||
if (request.VehicleProperties != null)
|
||||
{
|
||||
// Remove existing properties
|
||||
var existingProps = await _context.EdgeVehicleProperties
|
||||
.Where(evp => evp.EdgeId == edgeId)
|
||||
.ToListAsync();
|
||||
_context.EdgeVehicleProperties.RemoveRange(existingProps);
|
||||
|
||||
// Add new properties
|
||||
foreach (var propDto in request.VehicleProperties)
|
||||
{
|
||||
var prop = new EdgeVehicleProperty
|
||||
{
|
||||
EdgeId = edgeId,
|
||||
VehicleTypeId = propDto.VehicleTypeId,
|
||||
VehicleOrientation = propDto.VehicleOrientation,
|
||||
OrientationType = propDto.OrientationType,
|
||||
RotationAllowed = propDto.RotationAllowed,
|
||||
RotationAtStartNodeAllowed = propDto.RotationAtStartNodeAllowed,
|
||||
RotationAtEndNodeAllowed = propDto.RotationAtEndNodeAllowed,
|
||||
MaxSpeed = propDto.MaxSpeed,
|
||||
MaxRotationSpeed = propDto.MaxRotationSpeed,
|
||||
MinHeight = propDto.MinHeight,
|
||||
MaxHeight = propDto.MaxHeight,
|
||||
LoadRestriction_Unloaded = propDto.LoadRestriction?.Unloaded,
|
||||
LoadRestriction_Loaded = propDto.LoadRestriction?.Loaded,
|
||||
LoadRestriction_LoadSetNames = propDto.LoadRestriction?.LoadSetNames != null && propDto.LoadRestriction.LoadSetNames.Count > 0
|
||||
? System.Text.Json.JsonSerializer.Serialize(propDto.LoadRestriction.LoadSetNames)
|
||||
: null,
|
||||
TrajectoryDegree = propDto.TrajectoryDegree,
|
||||
TrajectoryControlPoint1X = propDto.TrajectoryControlPoint1X,
|
||||
TrajectoryControlPoint1Y = propDto.TrajectoryControlPoint1Y,
|
||||
TrajectoryControlPoint2X = propDto.TrajectoryControlPoint2X,
|
||||
TrajectoryControlPoint2Y = propDto.TrajectoryControlPoint2Y,
|
||||
CorridorLeftWidth = propDto.CorridorLeftWidth,
|
||||
CorridorRightWidth = propDto.CorridorRightWidth,
|
||||
CorridorRefPoint = propDto.CorridorRefPoint
|
||||
};
|
||||
_context.EdgeVehicleProperties.Add(prop);
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return (await GetByIdAsync(edgeId, includeNodes: true, includeVehicleProperties: true))!;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid edgeId)
|
||||
{
|
||||
var edge = await GetByIdAsync(edgeId, includeNodes: false, includeVehicleProperties: false);
|
||||
if (edge == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete edge
|
||||
_context.Edges.Remove(edge);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Check and delete orphan nodes
|
||||
await DeleteOrphanNodeAsync(edge.StartNodeId);
|
||||
await DeleteOrphanNodeAsync(edge.EndNodeId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task DeleteOrphanNodeAsync(Guid nodeId)
|
||||
{
|
||||
// Check if node is still referenced by any edge
|
||||
var hasEdges = await _context.Edges
|
||||
.AnyAsync(e => e.StartNodeId == nodeId || e.EndNodeId == nodeId);
|
||||
|
||||
if (!hasEdges)
|
||||
{
|
||||
// Node is orphan, delete it and its interaction nodes
|
||||
var stationInteractions = await _context.StationInteractionNodes
|
||||
.Where(sin => sin.NodeId == nodeId)
|
||||
.ToListAsync();
|
||||
_context.StationInteractionNodes.RemoveRange(stationInteractions);
|
||||
|
||||
var node = await _context.Nodes.FindAsync(nodeId);
|
||||
if (node != null)
|
||||
{
|
||||
_context.Nodes.Remove(node);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteBatchAsync(List<Guid> edgeIds)
|
||||
{
|
||||
using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var nodesToCheck = new HashSet<Guid>();
|
||||
|
||||
foreach (var edgeId in edgeIds)
|
||||
{
|
||||
var edge = await _context.Edges.FindAsync(edgeId);
|
||||
if (edge != null)
|
||||
{
|
||||
nodesToCheck.Add(edge.StartNodeId);
|
||||
nodesToCheck.Add(edge.EndNodeId);
|
||||
|
||||
_context.Edges.Remove(edge);
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Check and delete orphan nodes
|
||||
foreach (var nodeId in nodesToCheck)
|
||||
{
|
||||
await DeleteOrphanNodeAsync(nodeId);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RobotNet10.StorageManager;
|
||||
using SixLabors.ImageSharp;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// FileSystem-based image storage implementation using StorageManager
|
||||
/// Stores images in local folder with naming: {layoutLevelId}.png
|
||||
/// </summary>
|
||||
public class FileSystemImageStorageService : IImageStorageService, IDisposable
|
||||
{
|
||||
private readonly ILogger<FileSystemImageStorageService> _logger;
|
||||
private readonly StorageManager.StorageManager _storageManager;
|
||||
private const string ImagePath = "layoutImages"; // Empty path means files are stored directly in LocalFolder
|
||||
private const string ContentType = "image/png";
|
||||
|
||||
public FileSystemImageStorageService(IOptionsMonitor<StorageConfig> optionsSnapshot, ILogger<FileSystemImageStorageService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
var config = optionsSnapshot.Get("LayoutImages");
|
||||
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
_storageManager = new StorageManager.StorageManager(config);
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
_logger.LogInformation("FileSystemImageStorageService initialized");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetObjectName(Guid layoutLevelId) => layoutLevelId.ToString();
|
||||
|
||||
public async Task SaveImageAsync(Guid layoutLevelId, Stream imageStream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var objectName = GetObjectName(layoutLevelId);
|
||||
|
||||
try
|
||||
{
|
||||
// Reset stream position if seekable
|
||||
if (imageStream.CanSeek)
|
||||
{
|
||||
imageStream.Position = 0;
|
||||
}
|
||||
|
||||
// Get stream size - handle cases where Length might not be available
|
||||
long size = imageStream.Length;
|
||||
|
||||
// If size is 0 or stream doesn't support Length, copy to MemoryStream
|
||||
if (size == 0 || !imageStream.CanSeek)
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
await imageStream.CopyToAsync(memoryStream, cancellationToken);
|
||||
size = memoryStream.Length;
|
||||
memoryStream.Position = 0;
|
||||
|
||||
await _storageManager.UploadAsync(ImagePath, objectName, memoryStream, size, ContentType, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Stream has valid length and is seekable, use directly
|
||||
await _storageManager.UploadAsync(ImagePath, objectName, imageStream, size, ContentType, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Failed to save image for layout level {LevelId}", layoutLevelId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Stream?> GetImageAsync(Guid layoutLevelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var objectName = GetObjectName(layoutLevelId);
|
||||
|
||||
try
|
||||
{
|
||||
var exists = await _storageManager.ExistsAsync(ImagePath, objectName, cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning("Image not found for layout level {LevelId}", layoutLevelId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var stream = await _storageManager.GetFileAsync(ImagePath, objectName, cancellationToken);
|
||||
return stream;
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning("Image not found for layout level {LevelId}", layoutLevelId);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Failed to read image for layout level {LevelId}", layoutLevelId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteImageAsync(Guid layoutLevelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var objectName = GetObjectName(layoutLevelId);
|
||||
|
||||
try
|
||||
{
|
||||
var exists = await _storageManager.ExistsAsync(ImagePath, objectName, cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning("Image not found for deletion: {LevelId}", layoutLevelId);
|
||||
return false;
|
||||
}
|
||||
|
||||
await _storageManager.DeleteAsync(ImagePath, objectName, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Failed to delete image for layout level {LevelId}", layoutLevelId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ImageExistsAsync(Guid layoutLevelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var objectName = GetObjectName(layoutLevelId);
|
||||
return await _storageManager.ExistsAsync(ImagePath, objectName, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<(int width, int height)> GetImageDimensionsAsync(Stream imageStream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Reset stream position if seekable
|
||||
if (imageStream.CanSeek)
|
||||
{
|
||||
imageStream.Position = 0;
|
||||
}
|
||||
|
||||
// Load image to get dimensions
|
||||
using var image = await Image.LoadAsync(imageStream, cancellationToken);
|
||||
|
||||
return (image.Width, image.Height);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to extract image dimensions");
|
||||
throw new InvalidOperationException("Invalid image format or corrupted file", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_storageManager?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing edges
|
||||
/// </summary>
|
||||
public interface IEdgeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Create edge with automatic node detection/creation
|
||||
/// </summary>
|
||||
Task<Edge> CreateAsync(CreateEdgeRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Get all edges for a layout level
|
||||
/// </summary>
|
||||
Task<List<Edge>> GetEdgesByLevelAsync(Guid layoutLevelId, bool includeNodes = true, bool includeVehicleProperties = true);
|
||||
|
||||
/// <summary>
|
||||
/// Get edge by ID
|
||||
/// </summary>
|
||||
Task<Edge?> GetByIdAsync(Guid edgeId, bool includeNodes = true, bool includeVehicleProperties = true);
|
||||
|
||||
/// <summary>
|
||||
/// Update edge
|
||||
/// </summary>
|
||||
Task<Edge> UpdateAsync(Guid edgeId, UpdateEdgeRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Delete edge (cascade delete orphan nodes)
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(Guid edgeId);
|
||||
|
||||
/// <summary>
|
||||
/// Delete multiple edges in a transaction
|
||||
/// </summary>
|
||||
Task DeleteBatchAsync(List<Guid> edgeIds);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for image storage operations (Minio or FileSystem)
|
||||
/// </summary>
|
||||
public interface IImageStorageService
|
||||
{
|
||||
/// <summary>
|
||||
/// Save image for a layout level
|
||||
/// </summary>
|
||||
/// <param name="layoutLevelId">Layout level ID</param>
|
||||
/// <param name="imageStream">Image stream (PNG format)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
Task SaveImageAsync(Guid layoutLevelId, Stream imageStream, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get image for a layout level
|
||||
/// </summary>
|
||||
/// <param name="layoutLevelId">Layout level ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Image stream or null if not found</returns>
|
||||
Task<Stream?> GetImageAsync(Guid layoutLevelId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete image for a layout level
|
||||
/// </summary>
|
||||
/// <param name="layoutLevelId">Layout level ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
Task<bool> DeleteImageAsync(Guid layoutLevelId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if image exists for a layout level
|
||||
/// </summary>
|
||||
/// <param name="layoutLevelId">Layout level ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
Task<bool> ImageExistsAsync(Guid layoutLevelId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extract image dimensions from stream
|
||||
/// </summary>
|
||||
/// <param name="imageStream">Image stream (PNG format)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Tuple of (width, height) in pixels</returns>
|
||||
Task<(int width, int height)> GetImageDimensionsAsync(Stream imageStream, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using RobotNet10.MapEditor.Shared.DTOs.LayoutData;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Responses;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for retrieving complete layout data (nodes, edges, stations)
|
||||
/// </summary>
|
||||
public interface ILayoutDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Get complete layout data for a layout level
|
||||
/// Includes all nodes, edges, and stations with full nested properties
|
||||
/// </summary>
|
||||
Task<LayoutDataDto> GetLayoutDataAsync(Guid layoutLevelId);
|
||||
|
||||
/// <summary>
|
||||
/// Merge multiple nodes into one node at center position
|
||||
/// </summary>
|
||||
Task<MergeNodesResponse> MergeNodesAsync(MergeNodesRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Split a node into multiple nodes (one for each connected edge)
|
||||
/// </summary>
|
||||
Task<SplitNodeResponse> SplitNodeAsync(SplitNodeRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Save all layout changes (nodes and edges) in a batch operation
|
||||
/// Uses transaction to ensure atomicity
|
||||
/// </summary>
|
||||
Task<SaveLayoutDataResponse> SaveLayoutDataAsync(SaveLayoutDataRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Copy selected nodes and edges with an offset
|
||||
/// Creates new nodes and edges at offset positions
|
||||
/// </summary>
|
||||
Task<CopyNodesResponse> CopyNodesAsync(CopyNodesRequest request);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing layouts, versions, and levels
|
||||
/// </summary>
|
||||
public interface ILayoutService
|
||||
{
|
||||
// ==========================================
|
||||
// LAYOUT OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
Task<Layout> CreateLayoutAsync(CreateLayoutRequest request);
|
||||
Task<List<Layout>> SearchLayoutsAsync(string? searchText);
|
||||
Task<Layout?> GetLayoutByIdAsync(Guid layoutId);
|
||||
Task<Layout?> GetLayoutByLayoutIdAsync(string layoutId);
|
||||
Task<Layout?> GetLayoutByNameAsync(string layoutName);
|
||||
Task<Layout> UpdateLayoutAsync(Guid layoutId, UpdateLayoutRequest request);
|
||||
Task<bool> DeleteLayoutAsync(Guid layoutId);
|
||||
Task<Layout> ActivateLayoutAsync(Guid layoutId);
|
||||
Task<Layout> DeactivateLayoutAsync(Guid layoutId);
|
||||
|
||||
// ==========================================
|
||||
// VERSION OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
Task<LayoutVersion> CreateVersionAsync(Guid layoutId, CreateLayoutVersionRequest request);
|
||||
Task<List<LayoutVersion>> GetVersionsAsync(Guid layoutId);
|
||||
Task<LayoutVersion?> GetVersionAsync(Guid versionId);
|
||||
Task<LayoutVersion> UpdateVersionAsync(Guid versionId, UpdateLayoutRequest request);
|
||||
Task<bool> DeleteVersionAsync(Guid versionId);
|
||||
|
||||
// ==========================================
|
||||
// LEVEL OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
Task<LayoutLevel> CreateLevelAsync(Guid versionId, CreateLayoutLevelRequest request);
|
||||
Task<List<LayoutLevel>> GetLevelsAsync(Guid versionId);
|
||||
Task<LayoutLevel?> GetLevelAsync(Guid levelId);
|
||||
Task<LayoutLevel> UpdateLevelAsync(Guid levelId, UpdateLayoutLevelRequest request);
|
||||
Task<bool> DeleteLevelAsync(Guid levelId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for querying map data (nodes and edges) by VehicleType
|
||||
/// </summary>
|
||||
public interface IMapQueryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Get nodes filtered by VehicleType
|
||||
/// Returns only nodes that have NodeVehicleProperties for the specified VehicleType
|
||||
/// </summary>
|
||||
Task<List<Node>> GetNodesByVehicleTypeAsync(Guid vehicleTypeId);
|
||||
|
||||
/// <summary>
|
||||
/// Get edges filtered by VehicleType
|
||||
/// Returns only edges that have EdgeVehicleProperties for the specified VehicleType
|
||||
/// </summary>
|
||||
Task<List<Edge>> GetEdgesByVehicleTypeAsync(Guid vehicleTypeId);
|
||||
|
||||
/// <summary>
|
||||
/// Get nodes filtered by VehicleType and LevelId
|
||||
/// Returns only nodes that have NodeVehicleProperties for the specified VehicleType and belong to the specified level
|
||||
/// </summary>
|
||||
Task<List<Node>> GetNodesByVehicleTypeAndLevelAsync(Guid vehicleTypeId, Guid levelId);
|
||||
|
||||
/// <summary>
|
||||
/// Get edges filtered by VehicleType and LevelId
|
||||
/// Returns only edges that have EdgeVehicleProperties for the specified VehicleType and belong to the specified level
|
||||
/// </summary>
|
||||
Task<List<Edge>> GetEdgesByVehicleTypeAndLevelAsync(Guid vehicleTypeId, Guid levelId);
|
||||
|
||||
/// <summary>
|
||||
/// Get total count of nodes across all levels
|
||||
/// </summary>
|
||||
Task<int> GetTotalNodesCountAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Get total count of edges across all levels
|
||||
/// </summary>
|
||||
Task<int> GetTotalEdgesCountAsync();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Node;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing nodes
|
||||
/// </summary>
|
||||
public interface INodeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Get all nodes for a layout level
|
||||
/// </summary>
|
||||
Task<List<Node>> GetNodesByLevelAsync(Guid layoutLevelId, bool includeVehicleProperties = true);
|
||||
|
||||
/// <summary>
|
||||
/// Get node by ID
|
||||
/// </summary>
|
||||
Task<Node?> GetByIdAsync(Guid nodeId, bool includeVehicleProperties = true);
|
||||
|
||||
/// <summary>
|
||||
/// Update node
|
||||
/// </summary>
|
||||
Task<Node> UpdateAsync(Guid nodeId, UpdateNodeRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Validate if coordinates are within bounds
|
||||
/// </summary>
|
||||
Task<bool> ValidateCoordinatesAsync(Guid layoutLevelId, double x, double y);
|
||||
|
||||
/// <summary>
|
||||
/// Find nodes within proximity radius of given coordinates
|
||||
/// </summary>
|
||||
Task<List<Node>> FindNodesNearCoordinatesAsync(Guid layoutLevelId, double x, double y, double radius);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing stations
|
||||
/// </summary>
|
||||
public interface IStationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a new station
|
||||
/// </summary>
|
||||
Task<Station> CreateAsync(CreateStationRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Get all stations for a layout level
|
||||
/// </summary>
|
||||
Task<List<Station>> GetStationsByLevelAsync(Guid layoutLevelId, bool includeInteractionNodes = true);
|
||||
|
||||
/// <summary>
|
||||
/// Get station by ID
|
||||
/// </summary>
|
||||
Task<Station?> GetByIdAsync(Guid stationId, bool includeInteractionNodes = true);
|
||||
|
||||
/// <summary>
|
||||
/// Update station
|
||||
/// </summary>
|
||||
Task<Station> UpdateAsync(Guid stationId, UpdateStationRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Delete station (cascade delete interaction nodes, but NOT the linked nodes)
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(Guid stationId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing vehicle types
|
||||
/// </summary>
|
||||
public interface IVehicleTypeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a new vehicle type
|
||||
/// </summary>
|
||||
Task<VehicleType> CreateAsync(string vehicleTypeId, string vehicleTypeName, string? description, string? specifications, string? actions);
|
||||
|
||||
/// <summary>
|
||||
/// Get all vehicle types
|
||||
/// </summary>
|
||||
Task<List<VehicleType>> GetAllAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Get vehicle type by database ID
|
||||
/// </summary>
|
||||
Task<VehicleType?> GetByIdAsync(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Get vehicle type by VehicleTypeId string
|
||||
/// </summary>
|
||||
Task<VehicleType?> GetByVehicleTypeIdAsync(string vehicleTypeId);
|
||||
|
||||
/// <summary>
|
||||
/// Update vehicle type
|
||||
/// </summary>
|
||||
Task<VehicleType> UpdateAsync(Guid id, string? vehicleTypeName, string? description, string? specifications, string? actions, bool? isActive);
|
||||
|
||||
/// <summary>
|
||||
/// Delete vehicle type
|
||||
/// </summary>
|
||||
/// <returns>True if deleted, false if not found or has references</returns>
|
||||
Task<bool> DeleteAsync(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Check if vehicle type ID already exists
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(string vehicleTypeId);
|
||||
|
||||
/// <summary>
|
||||
/// Search vehicle types by query string
|
||||
/// Searches in VehicleTypeId and VehicleTypeName (case-insensitive, contains)
|
||||
/// </summary>
|
||||
Task<List<VehicleType>> SearchAsync(string query);
|
||||
|
||||
/// <summary>
|
||||
/// Get vehicle types filtered by active status
|
||||
/// </summary>
|
||||
Task<List<VehicleType>> GetByActiveStatusAsync(bool isActive);
|
||||
|
||||
/// <summary>
|
||||
/// Get usage information for a vehicle type
|
||||
/// </summary>
|
||||
Task<VehicleTypeUsageInfo> GetUsageInfoAsync(Guid id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,999 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RobotNet.VDA5050;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Edge;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.LayoutData;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Node;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Responses;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Station;
|
||||
using RobotNet10.MapManager.Data;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for retrieving complete layout data
|
||||
/// </summary>
|
||||
public class LayoutDataService(
|
||||
MapDbContext context,
|
||||
LayoutLevelNamingService namingService,
|
||||
IEdgeService edgeService,
|
||||
INodeService nodeService) : ILayoutDataService
|
||||
{
|
||||
private readonly MapDbContext _context = context;
|
||||
private readonly LayoutLevelNamingService _namingService = namingService;
|
||||
private readonly IEdgeService _edgeService = edgeService;
|
||||
private readonly INodeService _nodeService = nodeService;
|
||||
|
||||
public async Task<LayoutDataDto> GetLayoutDataAsync(Guid layoutLevelId)
|
||||
{
|
||||
// Get all nodes with vehicle properties
|
||||
var nodes = await _context.Nodes
|
||||
.Where(n => n.LevelId == layoutLevelId)
|
||||
.Include(n => n.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.OrderBy(n => n.NodeId)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
|
||||
// Get all edges with vehicle properties and related nodes
|
||||
var edges = await _context.Edges
|
||||
.Where(e => e.LevelId == layoutLevelId)
|
||||
.Include(e => e.StartNode)
|
||||
.Include(e => e.EndNode)
|
||||
.Include(e => e.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.OrderBy(e => e.EdgeId)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
|
||||
// Get all stations with interaction nodes
|
||||
var stations = await _context.Stations
|
||||
.Where(s => s.LevelId == layoutLevelId)
|
||||
.Include(s => s.InteractionNodes)
|
||||
.ThenInclude(sin => sin.Node)
|
||||
.ThenInclude(n => n.VehicleProperties)
|
||||
.OrderBy(s => s.StationId)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
|
||||
var dto = new LayoutDataDto
|
||||
{
|
||||
LayoutLevelId = layoutLevelId,
|
||||
Nodes = [.. nodes.Select(MapNodeToDto)],
|
||||
Edges = [.. edges.Select(MapEdgeToDto)],
|
||||
Stations = [.. stations.Select(MapStationToDto)]
|
||||
};
|
||||
return dto;
|
||||
}
|
||||
|
||||
// Helper methods to map entities to DTOs
|
||||
private static NodeDto MapNodeToDto(Node node)
|
||||
{
|
||||
return new NodeDto
|
||||
{
|
||||
Id = node.Id,
|
||||
LevelId = node.LevelId,
|
||||
NodeId = node.NodeId,
|
||||
NodeName = node.NodeName,
|
||||
NodeDescription = node.NodeDescription,
|
||||
MapId = node.MapId,
|
||||
X = node.X,
|
||||
Y = node.Y,
|
||||
VehicleProperties = node.VehicleProperties?.Select(vp => new NodeVehiclePropertyDto
|
||||
{
|
||||
Id = vp.Id,
|
||||
NodeId = vp.NodeId,
|
||||
VehicleTypeId = vp.VehicleTypeId,
|
||||
VehicleTypeIdString = vp.VehicleType?.VehicleTypeId,
|
||||
Theta = vp.Theta,
|
||||
Actions = vp.Actions,
|
||||
AllowedDeviationXY = vp.AllowedDeviationXY,
|
||||
AllowedDeviationTheta = vp.AllowedDeviationTheta
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static EdgeDto MapEdgeToDto(Edge edge)
|
||||
{
|
||||
return new EdgeDto
|
||||
{
|
||||
Id = edge.Id,
|
||||
LevelId = edge.LevelId,
|
||||
EdgeId = edge.EdgeId,
|
||||
EdgeName = edge.EdgeName,
|
||||
EdgeDescription = edge.EdgeDescription,
|
||||
StartNodeId = edge.StartNodeId,
|
||||
EndNodeId = edge.EndNodeId,
|
||||
StartNode = edge.StartNode != null ? MapNodeToDto(edge.StartNode) : null,
|
||||
EndNode = edge.EndNode != null ? MapNodeToDto(edge.EndNode) : null,
|
||||
VehicleProperties = edge.VehicleProperties?.Select(vp => new EdgeVehiclePropertyDto
|
||||
{
|
||||
Id = vp.Id,
|
||||
EdgeId = vp.EdgeId,
|
||||
VehicleTypeId = vp.VehicleTypeId,
|
||||
VehicleTypeIdString = vp.VehicleType?.VehicleTypeId,
|
||||
VehicleOrientation = vp.VehicleOrientation,
|
||||
OrientationType = vp.OrientationType,
|
||||
RotationAllowed = vp.RotationAllowed,
|
||||
RotationAtStartNodeAllowed = vp.RotationAtStartNodeAllowed,
|
||||
RotationAtEndNodeAllowed = vp.RotationAtEndNodeAllowed,
|
||||
MaxSpeed = vp.MaxSpeed,
|
||||
MaxRotationSpeed = vp.MaxRotationSpeed,
|
||||
MinHeight = vp.MinHeight,
|
||||
MaxHeight = vp.MaxHeight,
|
||||
LoadRestriction = (vp.LoadRestriction_Unloaded.HasValue || vp.LoadRestriction_Loaded.HasValue || !string.IsNullOrWhiteSpace(vp.LoadRestriction_LoadSetNames))
|
||||
? new LoadRestrictionDto
|
||||
{
|
||||
Unloaded = vp.LoadRestriction_Unloaded,
|
||||
Loaded = vp.LoadRestriction_Loaded,
|
||||
LoadSetNames = SafeDeserializeLoadSetNames(vp.LoadRestriction_LoadSetNames)
|
||||
}
|
||||
: null,
|
||||
TrajectoryDegree = vp.TrajectoryDegree,
|
||||
TrajectoryControlPoint1X = vp.TrajectoryControlPoint1X,
|
||||
TrajectoryControlPoint1Y = vp.TrajectoryControlPoint1Y,
|
||||
TrajectoryControlPoint2X = vp.TrajectoryControlPoint2X,
|
||||
TrajectoryControlPoint2Y = vp.TrajectoryControlPoint2Y,
|
||||
CorridorLeftWidth = vp.CorridorLeftWidth,
|
||||
CorridorRightWidth = vp.CorridorRightWidth,
|
||||
CorridorRefPoint = vp.CorridorRefPoint
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static StationDto MapStationToDto(Station station)
|
||||
{
|
||||
return new StationDto
|
||||
{
|
||||
Id = station.Id,
|
||||
LevelId = station.LevelId,
|
||||
StationId = station.StationId,
|
||||
StationName = station.StationName,
|
||||
StationDescription = station.StationDescription,
|
||||
StationHeight = station.StationHeight,
|
||||
X = station.X,
|
||||
Y = station.Y,
|
||||
Theta = station.Theta,
|
||||
InteractionNodes = station.InteractionNodes?.Select(sin => new StationInteractionNodeDto
|
||||
{
|
||||
Id = sin.Id,
|
||||
StationId = sin.StationId,
|
||||
NodeId = sin.NodeId,
|
||||
Node = sin.Node != null ? MapNodeToDto(sin.Node) : null
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// MERGE/SPLIT OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
public async Task<MergeNodesResponse> MergeNodesAsync(MergeNodesRequest request)
|
||||
{
|
||||
if (request.NodeIds.Count < 2)
|
||||
{
|
||||
throw new InvalidOperationException("Need at least 2 nodes to merge");
|
||||
}
|
||||
|
||||
// Load nodes with all related data
|
||||
var nodesToMerge = await _context.Nodes
|
||||
.Where(n => request.NodeIds.Contains(n.Id) && n.LevelId == request.LevelId)
|
||||
.Include(n => n.VehicleProperties)
|
||||
.Include(n => n.StationInteractions)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
|
||||
if (nodesToMerge.Count != request.NodeIds.Count)
|
||||
{
|
||||
throw new InvalidOperationException("Some nodes not found or belong to different level");
|
||||
}
|
||||
|
||||
// Get editor settings for validation
|
||||
var settings = await _context.LayoutLevelEditorSettings
|
||||
.FirstOrDefaultAsync(s => s.LevelId == request.LevelId);
|
||||
var proximityRadius = settings?.NodeProximityRadius ?? 0.35;
|
||||
|
||||
// Check distances between nodes
|
||||
var maxDistance = 0.0;
|
||||
for (int i = 0; i < nodesToMerge.Count; i++)
|
||||
{
|
||||
for (int j = i + 1; j < nodesToMerge.Count; j++)
|
||||
{
|
||||
var dx = nodesToMerge[i].X - nodesToMerge[j].X;
|
||||
var dy = nodesToMerge[i].Y - nodesToMerge[j].Y;
|
||||
var distance = Math.Sqrt(dx * dx + dy * dy);
|
||||
maxDistance = Math.Max(maxDistance, distance);
|
||||
}
|
||||
}
|
||||
|
||||
// If distance exceeds proximity radius, throw exception (frontend will show confirmation)
|
||||
if (maxDistance > proximityRadius)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Maximum distance between nodes ({maxDistance:F3}m) exceeds proximity radius ({proximityRadius:F3}m). " +
|
||||
"Please confirm merge operation.");
|
||||
}
|
||||
|
||||
// Check stations: if multiple nodes have stations, throw error
|
||||
var nodesWithStations = nodesToMerge
|
||||
.Where(n => n.StationInteractions.Count != 0)
|
||||
.ToList();
|
||||
|
||||
if (nodesWithStations.Count > 1)
|
||||
{
|
||||
var stationIds = nodesWithStations
|
||||
.SelectMany(n => n.StationInteractions.Select(sin => sin.StationId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot merge nodes: Multiple nodes have stations. " +
|
||||
$"Found {nodesWithStations.Count} nodes with {stationIds.Count} different station(s). " +
|
||||
"Please remove stations from some nodes before merging.");
|
||||
}
|
||||
|
||||
// Calculate center position
|
||||
var centerX = request.CenterX ?? nodesToMerge.Average(n => n.X);
|
||||
var centerY = request.CenterY ?? nodesToMerge.Average(n => n.Y);
|
||||
|
||||
// Get all edges connected to these nodes
|
||||
var connectedEdges = await _context.Edges
|
||||
.Where(e => e.LevelId == request.LevelId &&
|
||||
(request.NodeIds.Contains(e.StartNodeId) || request.NodeIds.Contains(e.EndNodeId)))
|
||||
.Include(e => e.VehicleProperties)
|
||||
.ToListAsync();
|
||||
|
||||
// Start transaction
|
||||
using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
// Create merged node
|
||||
var nodeId = Guid.NewGuid().ToString("N")[..16];
|
||||
var nodeName = await _namingService.GenerateNodeNameAsync(request.LevelId);
|
||||
|
||||
var mergedNode = new Node
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
LevelId = request.LevelId,
|
||||
NodeId = nodeId,
|
||||
NodeName = nodeName,
|
||||
X = centerX,
|
||||
Y = centerY,
|
||||
NodeDescription = $"Merged from {nodesToMerge.Count} nodes"
|
||||
};
|
||||
|
||||
_context.Nodes.Add(mergedNode);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Merge vehicle properties from all nodes
|
||||
var allVehicleProperties = nodesToMerge
|
||||
.SelectMany(n => n.VehicleProperties)
|
||||
.GroupBy(vp => vp.VehicleTypeId)
|
||||
.Select(g => g.First()) // Take first property for each vehicle type (or merge logic can be enhanced)
|
||||
.ToList();
|
||||
|
||||
foreach (var vp in allVehicleProperties)
|
||||
{
|
||||
var newVp = new NodeVehicleProperty
|
||||
{
|
||||
NodeId = mergedNode.Id,
|
||||
VehicleTypeId = vp.VehicleTypeId,
|
||||
Theta = vp.Theta,
|
||||
Actions = vp.Actions,
|
||||
AllowedDeviationXY = vp.AllowedDeviationXY,
|
||||
AllowedDeviationTheta = vp.AllowedDeviationTheta
|
||||
};
|
||||
_context.NodeVehicleProperties.Add(newVp);
|
||||
}
|
||||
|
||||
// Update edges: change StartNodeId or EndNodeId to merged node
|
||||
var updatedEdges = new List<Edge>();
|
||||
foreach (var edge in connectedEdges)
|
||||
{
|
||||
var wasStartNode = request.NodeIds.Contains(edge.StartNodeId);
|
||||
var wasEndNode = request.NodeIds.Contains(edge.EndNodeId);
|
||||
|
||||
if (wasStartNode && wasEndNode)
|
||||
{
|
||||
// Both nodes are being merged - this becomes a self-loop, delete it
|
||||
_context.Edges.Remove(edge);
|
||||
}
|
||||
else if (wasStartNode)
|
||||
{
|
||||
edge.StartNodeId = mergedNode.Id;
|
||||
updatedEdges.Add(edge);
|
||||
}
|
||||
else if (wasEndNode)
|
||||
{
|
||||
edge.EndNodeId = mergedNode.Id;
|
||||
updatedEdges.Add(edge);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle station: if one node had station, assign to merged node
|
||||
if (nodesWithStations.Count == 1)
|
||||
{
|
||||
var nodeWithStation = nodesWithStations[0];
|
||||
var stationInteractions = nodeWithStation.StationInteractions.ToList();
|
||||
|
||||
foreach (var sin in stationInteractions)
|
||||
{
|
||||
// Update StationInteractionNode to point to merged node
|
||||
sin.NodeId = mergedNode.Id;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete old nodes (they will be orphaned after edge updates)
|
||||
_context.Nodes.RemoveRange(nodesToMerge);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
// Reload merged node with all properties for response
|
||||
var reloadedMergedNode = await _context.Nodes
|
||||
.Where(n => n.Id == mergedNode.Id)
|
||||
.Include(n => n.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync();
|
||||
|
||||
// Reload updated edges for response
|
||||
var reloadedEdges = await _context.Edges
|
||||
.Where(e => updatedEdges.Select(ue => ue.Id).Contains(e.Id))
|
||||
.Include(e => e.StartNode)
|
||||
.Include(e => e.EndNode)
|
||||
.Include(e => e.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
|
||||
return new MergeNodesResponse
|
||||
{
|
||||
MergedNode = MapNodeToDto(reloadedMergedNode),
|
||||
UpdatedEdges = [.. reloadedEdges.Select(MapEdgeToDto)],
|
||||
DeletedNodeIds = [.. nodesToMerge.Select(n => n.Id)]
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SplitNodeResponse> SplitNodeAsync(SplitNodeRequest request)
|
||||
{
|
||||
// Load node with all related data
|
||||
var nodeToSplit = await _context.Nodes
|
||||
.Where(n => n.Id == request.NodeId && n.LevelId == request.LevelId)
|
||||
.Include(n => n.VehicleProperties)
|
||||
.Include(n => n.StationInteractions)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync() ?? throw new InvalidOperationException($"Node with ID '{request.NodeId}' not found");
|
||||
|
||||
// Get all edges connected to this node
|
||||
var connectedEdges = await _context.Edges
|
||||
.Where(e => e.LevelId == request.LevelId &&
|
||||
(e.StartNodeId == request.NodeId || e.EndNodeId == request.NodeId))
|
||||
.Include(e => e.VehicleProperties)
|
||||
.Include(e => e.StartNode)
|
||||
.Include(e => e.EndNode)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
|
||||
// Validate: node must have at least 2 edges
|
||||
if (connectedEdges.Count < 2)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot split node: Node must have at least 2 connected edges. " +
|
||||
$"Found {connectedEdges.Count} edge(s).");
|
||||
}
|
||||
|
||||
var offsetDistance = request.OffsetDistance ?? 0.1; // Default 10cm
|
||||
|
||||
// Start transaction
|
||||
using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
var newNodes = new List<Node>();
|
||||
var updatedEdges = new List<Edge>();
|
||||
|
||||
// Create a new node for each edge
|
||||
foreach (var (edge, index) in connectedEdges.Select((e, i) => (e, i)))
|
||||
{
|
||||
// Calculate offset position (perpendicular to edge direction)
|
||||
var otherNodeId = edge.StartNodeId == request.NodeId ? edge.EndNodeId : edge.StartNodeId;
|
||||
var otherNode = edge.StartNodeId == request.NodeId ? edge.EndNode : edge.StartNode;
|
||||
|
||||
double offsetX, offsetY;
|
||||
if (otherNode != null)
|
||||
{
|
||||
// Calculate perpendicular offset
|
||||
var dx = otherNode.X - nodeToSplit.X;
|
||||
var dy = otherNode.Y - nodeToSplit.Y;
|
||||
var length = Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (length > 0.001)
|
||||
{
|
||||
// Perpendicular vector (rotate 90 degrees counter-clockwise)
|
||||
var perpX = -dy / length * offsetDistance;
|
||||
var perpY = dx / length * offsetDistance;
|
||||
offsetX = nodeToSplit.X + perpX;
|
||||
offsetY = nodeToSplit.Y + perpY;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: circular offset
|
||||
var angle = (2 * Math.PI * index) / connectedEdges.Count;
|
||||
offsetX = nodeToSplit.X + offsetDistance * Math.Cos(angle);
|
||||
offsetY = nodeToSplit.Y + offsetDistance * Math.Sin(angle);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Circular offset
|
||||
var angle = (2 * Math.PI * index) / connectedEdges.Count;
|
||||
offsetX = nodeToSplit.X + offsetDistance * Math.Cos(angle);
|
||||
offsetY = nodeToSplit.Y + offsetDistance * Math.Sin(angle);
|
||||
}
|
||||
|
||||
// Create new node
|
||||
var newNodeId = Guid.NewGuid().ToString("N")[..16];
|
||||
var newNodeName = await _namingService.GenerateNodeNameAsync(request.LevelId);
|
||||
|
||||
var newNode = new Node
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
LevelId = request.LevelId,
|
||||
NodeId = newNodeId,
|
||||
NodeName = newNodeName,
|
||||
X = offsetX,
|
||||
Y = offsetY,
|
||||
NodeDescription = $"Split from node {nodeToSplit.NodeId}"
|
||||
};
|
||||
|
||||
_context.Nodes.Add(newNode);
|
||||
await _context.SaveChangesAsync(); // Save to get ID
|
||||
|
||||
// Copy vehicle properties from original node
|
||||
foreach (var vp in nodeToSplit.VehicleProperties)
|
||||
{
|
||||
var newVp = new NodeVehicleProperty
|
||||
{
|
||||
NodeId = newNode.Id,
|
||||
VehicleTypeId = vp.VehicleTypeId,
|
||||
Theta = vp.Theta,
|
||||
Actions = vp.Actions,
|
||||
AllowedDeviationXY = vp.AllowedDeviationXY,
|
||||
AllowedDeviationTheta = vp.AllowedDeviationTheta
|
||||
};
|
||||
_context.NodeVehicleProperties.Add(newVp);
|
||||
}
|
||||
|
||||
newNodes.Add(newNode);
|
||||
|
||||
// Update edge to point to new node
|
||||
if (edge.StartNodeId == request.NodeId)
|
||||
{
|
||||
edge.StartNodeId = newNode.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
edge.EndNodeId = newNode.Id;
|
||||
}
|
||||
updatedEdges.Add(edge);
|
||||
}
|
||||
|
||||
// Handle station: assign to specified node or first node
|
||||
if (nodeToSplit.StationInteractions.Count != 0)
|
||||
{
|
||||
var targetNodeId = request.StationNodeId ?? newNodes[0].Id;
|
||||
var targetNode = newNodes.FirstOrDefault(n => n.Id == targetNodeId) ?? throw new InvalidOperationException($"Target node ID '{request.StationNodeId}' not found in new nodes");
|
||||
var stationInteractions = nodeToSplit.StationInteractions.ToList();
|
||||
foreach (var sin in stationInteractions)
|
||||
{
|
||||
sin.NodeId = targetNode.Id;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete original node
|
||||
_context.Nodes.Remove(nodeToSplit);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
// Reload new nodes with all properties for response
|
||||
var reloadedNewNodes = await _context.Nodes
|
||||
.Where(n => newNodes.Select(nn => nn.Id).Contains(n.Id))
|
||||
.Include(n => n.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.ToListAsync();
|
||||
|
||||
// Reload updated edges for response
|
||||
var reloadedEdges = await _context.Edges
|
||||
.Where(e => updatedEdges.Select(ue => ue.Id).Contains(e.Id))
|
||||
.Include(e => e.StartNode)
|
||||
.Include(e => e.EndNode)
|
||||
.Include(e => e.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.ToListAsync();
|
||||
|
||||
return new SplitNodeResponse
|
||||
{
|
||||
NewNodes = [.. reloadedNewNodes.Select(MapNodeToDto)],
|
||||
UpdatedEdges = [.. reloadedEdges.Select(MapEdgeToDto)],
|
||||
DeletedNodeId = nodeToSplit.Id
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SaveLayoutDataResponse> SaveLayoutDataAsync(SaveLayoutDataRequest request)
|
||||
{
|
||||
var response = new SaveLayoutDataResponse
|
||||
{
|
||||
Success = true,
|
||||
NodesUpdated = 0,
|
||||
EdgesUpdated = 0
|
||||
};
|
||||
|
||||
using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
// Batch load all needed nodes in ONE query
|
||||
var nodeIds = request.Nodes.Select(n => n.Id).ToList();
|
||||
var existingNodes = await _context.Nodes
|
||||
.Where(n => nodeIds.Contains(n.Id) && n.LevelId == request.LayoutLevelId)
|
||||
.ToDictionaryAsync(n => n.Id);
|
||||
|
||||
// Batch load all node vehicle properties in ONE query
|
||||
var existingNodeVehicleProps = await _context.NodeVehicleProperties
|
||||
.Where(nvp => nodeIds.Contains(nvp.NodeId))
|
||||
.ToListAsync();
|
||||
var nodeVehiclePropsLookup = existingNodeVehicleProps.GroupBy(p => p.NodeId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// Update nodes
|
||||
foreach (var nodeItem in request.Nodes)
|
||||
{
|
||||
if (!existingNodes.TryGetValue(nodeItem.Id, out var node))
|
||||
{
|
||||
// Node not found - skip (Option D: Force Overwrite)
|
||||
response.SkippedNodeIds.Add(nodeItem.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update position if provided
|
||||
if (nodeItem.X.HasValue)
|
||||
node.X = nodeItem.X.Value;
|
||||
if (nodeItem.Y.HasValue)
|
||||
node.Y = nodeItem.Y.Value;
|
||||
|
||||
// Update other properties
|
||||
if (nodeItem.NodeName != null)
|
||||
node.NodeName = nodeItem.NodeName;
|
||||
if (nodeItem.NodeDescription != null)
|
||||
node.NodeDescription = nodeItem.NodeDescription;
|
||||
if (nodeItem.MapId != null)
|
||||
node.MapId = nodeItem.MapId;
|
||||
|
||||
// Update vehicle properties if provided
|
||||
if (nodeItem.VehicleProperties != null)
|
||||
{
|
||||
// Remove existing properties
|
||||
if (nodeVehiclePropsLookup.TryGetValue(nodeItem.Id, out var existingProps))
|
||||
{
|
||||
_context.NodeVehicleProperties.RemoveRange(existingProps);
|
||||
}
|
||||
|
||||
// Add new properties
|
||||
foreach (var propDto in nodeItem.VehicleProperties)
|
||||
{
|
||||
var prop = new NodeVehicleProperty
|
||||
{
|
||||
NodeId = nodeItem.Id,
|
||||
VehicleTypeId = propDto.VehicleTypeId,
|
||||
Theta = propDto.Theta,
|
||||
Actions = propDto.Actions,
|
||||
AllowedDeviationXY = propDto.AllowedDeviationXY,
|
||||
AllowedDeviationTheta = propDto.AllowedDeviationTheta
|
||||
};
|
||||
_context.NodeVehicleProperties.Add(prop);
|
||||
}
|
||||
}
|
||||
|
||||
response.NodesUpdated++;
|
||||
}
|
||||
|
||||
// Batch load all needed edges in ONE query
|
||||
var edgeIds = request.Edges.Select(e => e.Id).ToList();
|
||||
var existingEdges = await _context.Edges
|
||||
.Where(e => edgeIds.Contains(e.Id) && e.LevelId == request.LayoutLevelId)
|
||||
.ToDictionaryAsync(e => e.Id);
|
||||
|
||||
// Batch load all edge vehicle properties in ONE query
|
||||
var existingEdgeVehicleProps = await _context.EdgeVehicleProperties
|
||||
.Where(evp => edgeIds.Contains(evp.EdgeId))
|
||||
.ToListAsync();
|
||||
var edgeVehiclePropsLookup = existingEdgeVehicleProps.GroupBy(p => p.EdgeId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// Update edges
|
||||
foreach (var edgeItem in request.Edges)
|
||||
{
|
||||
if (!existingEdges.TryGetValue(edgeItem.Id, out var edge))
|
||||
{
|
||||
// Edge not found - skip (Option D: Force Overwrite)
|
||||
response.SkippedEdgeIds.Add(edgeItem.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update properties
|
||||
if (edgeItem.EdgeName != null)
|
||||
edge.EdgeName = edgeItem.EdgeName;
|
||||
if (edgeItem.EdgeDescription != null)
|
||||
edge.EdgeDescription = edgeItem.EdgeDescription;
|
||||
|
||||
// Update vehicle properties if provided
|
||||
if (edgeItem.VehicleProperties != null)
|
||||
{
|
||||
// Remove existing properties (from batch-loaded lookup)
|
||||
if (edgeVehiclePropsLookup.TryGetValue(edgeItem.Id, out var existingProps))
|
||||
{
|
||||
_context.EdgeVehicleProperties.RemoveRange(existingProps);
|
||||
}
|
||||
|
||||
// Add new properties
|
||||
foreach (var propDto in edgeItem.VehicleProperties)
|
||||
{
|
||||
var prop = new EdgeVehicleProperty
|
||||
{
|
||||
EdgeId = edgeItem.Id,
|
||||
VehicleTypeId = propDto.VehicleTypeId,
|
||||
VehicleOrientation = propDto.VehicleOrientation,
|
||||
OrientationType = propDto.OrientationType,
|
||||
RotationAllowed = propDto.RotationAllowed,
|
||||
RotationAtStartNodeAllowed = propDto.RotationAtStartNodeAllowed,
|
||||
RotationAtEndNodeAllowed = propDto.RotationAtEndNodeAllowed,
|
||||
MaxSpeed = propDto.MaxSpeed,
|
||||
MaxRotationSpeed = propDto.MaxRotationSpeed,
|
||||
MinHeight = propDto.MinHeight,
|
||||
MaxHeight = propDto.MaxHeight,
|
||||
LoadRestriction_Unloaded = propDto.LoadRestriction?.Unloaded,
|
||||
LoadRestriction_Loaded = propDto.LoadRestriction?.Loaded,
|
||||
LoadRestriction_LoadSetNames = propDto.LoadRestriction?.LoadSetNames != null && propDto.LoadRestriction.LoadSetNames.Count > 0
|
||||
? System.Text.Json.JsonSerializer.Serialize(propDto.LoadRestriction.LoadSetNames)
|
||||
: null,
|
||||
TrajectoryDegree = propDto.TrajectoryDegree,
|
||||
TrajectoryControlPoint1X = propDto.TrajectoryControlPoint1X,
|
||||
TrajectoryControlPoint1Y = propDto.TrajectoryControlPoint1Y,
|
||||
TrajectoryControlPoint2X = propDto.TrajectoryControlPoint2X,
|
||||
TrajectoryControlPoint2Y = propDto.TrajectoryControlPoint2Y,
|
||||
CorridorLeftWidth = propDto.CorridorLeftWidth,
|
||||
CorridorRightWidth = propDto.CorridorRightWidth,
|
||||
CorridorRefPoint = propDto.CorridorRefPoint
|
||||
};
|
||||
_context.EdgeVehicleProperties.Add(prop);
|
||||
}
|
||||
}
|
||||
|
||||
response.EdgesUpdated++;
|
||||
}
|
||||
|
||||
// Save all changes in transaction
|
||||
await _context.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
response.Success = false;
|
||||
response.ErrorMessage = ex.Message;
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CopyNodesResponse> CopyNodesAsync(CopyNodesRequest request)
|
||||
{
|
||||
var response = new CopyNodesResponse
|
||||
{
|
||||
Success = true
|
||||
};
|
||||
|
||||
using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
// Load source nodes and edges from database
|
||||
var sourceNodes = await _context.Nodes
|
||||
.Where(n => request.NodeIds.Contains(n.Id) && n.LevelId == request.LayoutLevelId)
|
||||
.Include(n => n.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
|
||||
var sourceEdges = await _context.Edges
|
||||
.Where(e => request.EdgeIds.Contains(e.Id) && e.LevelId == request.LayoutLevelId)
|
||||
.Include(e => e.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
|
||||
if (sourceNodes.Count == 0)
|
||||
{
|
||||
response.Success = false;
|
||||
response.ErrorMessage = "No nodes found to copy";
|
||||
return response;
|
||||
}
|
||||
|
||||
// Get editor settings for validation
|
||||
var settings = await _context.LayoutLevelEditorSettings
|
||||
.FirstOrDefaultAsync(s => s.LevelId == request.LayoutLevelId);
|
||||
|
||||
// Step 1: Create all new nodes with offset
|
||||
var nodeIdMapping = new Dictionary<Guid, Guid>();
|
||||
|
||||
foreach (var sourceNode in sourceNodes)
|
||||
{
|
||||
var newX = sourceNode.X + request.OffsetX;
|
||||
var newY = sourceNode.Y + request.OffsetY;
|
||||
|
||||
// Validate coordinates (same validation as in CreateEdge)
|
||||
if (!await _nodeService.ValidateCoordinatesAsync(request.LayoutLevelId, newX, newY))
|
||||
{
|
||||
throw new InvalidOperationException($"Coordinates ({newX}, {newY}) are outside valid bounds");
|
||||
}
|
||||
|
||||
// Generate NodeId and NodeName (same as in FindOrCreateNodeAsync)
|
||||
var nodeId = Guid.NewGuid().ToString("N")[..16];
|
||||
var nodeName = sourceNode.NodeName;
|
||||
if (string.IsNullOrEmpty(nodeName) && settings?.NodeNameAutoGenerate == true)
|
||||
{
|
||||
nodeName = await _namingService.GenerateNodeNameAsync(request.LayoutLevelId);
|
||||
}
|
||||
|
||||
// Create new node directly
|
||||
var newNode = new Node
|
||||
{
|
||||
LevelId = request.LayoutLevelId,
|
||||
NodeId = nodeId,
|
||||
NodeName = nodeName,
|
||||
NodeDescription = sourceNode.NodeDescription,
|
||||
MapId = sourceNode.MapId,
|
||||
X = newX,
|
||||
Y = newY
|
||||
};
|
||||
|
||||
_context.Nodes.Add(newNode);
|
||||
await _context.SaveChangesAsync(); // Save to get the new node's Id
|
||||
|
||||
// Copy vehicle properties
|
||||
if (sourceNode.VehicleProperties != null && sourceNode.VehicleProperties.Count > 0)
|
||||
{
|
||||
foreach (var sourceProp in sourceNode.VehicleProperties)
|
||||
{
|
||||
var newProp = new NodeVehicleProperty
|
||||
{
|
||||
NodeId = newNode.Id,
|
||||
VehicleTypeId = sourceProp.VehicleTypeId,
|
||||
Theta = sourceProp.Theta,
|
||||
Actions = sourceProp.Actions,
|
||||
AllowedDeviationXY = sourceProp.AllowedDeviationXY,
|
||||
AllowedDeviationTheta = sourceProp.AllowedDeviationTheta
|
||||
};
|
||||
_context.NodeVehicleProperties.Add(newProp);
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Store mapping
|
||||
nodeIdMapping[sourceNode.Id] = newNode.Id;
|
||||
|
||||
// Reload node with vehicle properties for response
|
||||
var reloadedNode = await _nodeService.GetByIdAsync(newNode.Id, includeVehicleProperties: true);
|
||||
if (reloadedNode != null)
|
||||
{
|
||||
response.NewNodes.Add(MapNodeToDto(reloadedNode));
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Create all new edges using the node ID mapping
|
||||
var processedEdges = new HashSet<Guid>();
|
||||
|
||||
foreach (var sourceEdge in sourceEdges)
|
||||
{
|
||||
// Skip if already processed
|
||||
if (processedEdges.Contains(sourceEdge.Id))
|
||||
continue;
|
||||
|
||||
// Only copy edges where both start and end nodes are in the selection
|
||||
if (nodeIdMapping.TryGetValue(sourceEdge.StartNodeId, out var newStartNodeId) &&
|
||||
nodeIdMapping.TryGetValue(sourceEdge.EndNodeId, out var newEndNodeId))
|
||||
{
|
||||
// Get new nodes to calculate edge length for validation
|
||||
var newStartNode = await _nodeService.GetByIdAsync(newStartNodeId, includeVehicleProperties: false);
|
||||
var newEndNode = await _nodeService.GetByIdAsync(newEndNodeId, includeVehicleProperties: false);
|
||||
|
||||
if (newStartNode != null && newEndNode != null)
|
||||
{
|
||||
// Validate edge length (same validation as in CreateEdge)
|
||||
var dx = newEndNode.X - newStartNode.X;
|
||||
var dy = newEndNode.Y - newStartNode.Y;
|
||||
var edgeLength = Math.Sqrt(dx * dx + dy * dy);
|
||||
var minEdgeLength = settings?.EdgeMinLengthCreate ?? 0.1;
|
||||
|
||||
if (edgeLength < minEdgeLength)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Edge length ({edgeLength:F3}m) is less than minimum required ({minEdgeLength:F3}m)");
|
||||
}
|
||||
|
||||
// Check if this edge has a reverse edge (2-way edge)
|
||||
var reverseEdge = sourceEdges.FirstOrDefault(e =>
|
||||
e.Id != sourceEdge.Id &&
|
||||
e.StartNodeId == sourceEdge.EndNodeId &&
|
||||
e.EndNodeId == sourceEdge.StartNodeId);
|
||||
|
||||
// Copy the forward edge
|
||||
var edgeId = Guid.NewGuid().ToString("N")[..16];
|
||||
var edgeName = sourceEdge.EdgeName;
|
||||
if (string.IsNullOrEmpty(edgeName) && settings?.EdgeNameAutoGenerate == true)
|
||||
{
|
||||
edgeName = await _namingService.GenerateEdgeNameAsync(request.LayoutLevelId);
|
||||
}
|
||||
|
||||
var newEdge = new Edge
|
||||
{
|
||||
LevelId = request.LayoutLevelId,
|
||||
EdgeId = edgeId,
|
||||
EdgeName = edgeName,
|
||||
EdgeDescription = sourceEdge.EdgeDescription,
|
||||
StartNodeId = newStartNodeId,
|
||||
EndNodeId = newEndNodeId
|
||||
};
|
||||
|
||||
_context.Edges.Add(newEdge);
|
||||
await _context.SaveChangesAsync(); // Save to get the new edge's Id
|
||||
|
||||
// Copy vehicle properties
|
||||
if (sourceEdge.VehicleProperties != null && sourceEdge.VehicleProperties.Count > 0)
|
||||
{
|
||||
foreach (var sourceProp in sourceEdge.VehicleProperties)
|
||||
{
|
||||
var newProp = new EdgeVehicleProperty
|
||||
{
|
||||
EdgeId = newEdge.Id,
|
||||
VehicleTypeId = sourceProp.VehicleTypeId,
|
||||
VehicleOrientation = sourceProp.VehicleOrientation,
|
||||
OrientationType = sourceProp.OrientationType,
|
||||
RotationAllowed = sourceProp.RotationAllowed,
|
||||
RotationAtStartNodeAllowed = sourceProp.RotationAtStartNodeAllowed,
|
||||
RotationAtEndNodeAllowed = sourceProp.RotationAtEndNodeAllowed,
|
||||
MaxSpeed = sourceProp.MaxSpeed,
|
||||
MaxRotationSpeed = sourceProp.MaxRotationSpeed,
|
||||
MinHeight = sourceProp.MinHeight,
|
||||
MaxHeight = sourceProp.MaxHeight,
|
||||
LoadRestriction_Unloaded = sourceProp.LoadRestriction_Unloaded,
|
||||
LoadRestriction_Loaded = sourceProp.LoadRestriction_Loaded,
|
||||
LoadRestriction_LoadSetNames = sourceProp.LoadRestriction_LoadSetNames,
|
||||
TrajectoryDegree = sourceProp.TrajectoryDegree,
|
||||
TrajectoryControlPoint1X = sourceProp.TrajectoryControlPoint1X + request.OffsetX,
|
||||
TrajectoryControlPoint1Y = sourceProp.TrajectoryControlPoint1Y + request.OffsetY,
|
||||
TrajectoryControlPoint2X = sourceProp.TrajectoryControlPoint2X + request.OffsetX,
|
||||
TrajectoryControlPoint2Y = sourceProp.TrajectoryControlPoint2Y + request.OffsetY,
|
||||
CorridorLeftWidth = sourceProp.CorridorLeftWidth,
|
||||
CorridorRightWidth = sourceProp.CorridorRightWidth,
|
||||
CorridorRefPoint = sourceProp.CorridorRefPoint,
|
||||
};
|
||||
_context.EdgeVehicleProperties.Add(newProp);
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Reload edge with full details for response
|
||||
var reloadedEdge = await _edgeService.GetByIdAsync(newEdge.Id, includeNodes: true, includeVehicleProperties: true);
|
||||
if (reloadedEdge != null)
|
||||
{
|
||||
response.NewEdges.Add(MapEdgeToDto(reloadedEdge));
|
||||
}
|
||||
processedEdges.Add(sourceEdge.Id);
|
||||
|
||||
// If it's a 2-way edge, copy the reverse edge too
|
||||
if (reverseEdge != null && !processedEdges.Contains(reverseEdge.Id))
|
||||
{
|
||||
var reverseEdgeId = Guid.NewGuid().ToString("N")[..16];
|
||||
var reverseEdgeName = reverseEdge.EdgeName;
|
||||
if (string.IsNullOrEmpty(reverseEdgeName) && settings?.EdgeNameAutoGenerate == true)
|
||||
{
|
||||
reverseEdgeName = await _namingService.GenerateEdgeNameAsync(request.LayoutLevelId);
|
||||
}
|
||||
|
||||
var newReverseEdge = new Edge
|
||||
{
|
||||
LevelId = request.LayoutLevelId,
|
||||
EdgeId = reverseEdgeId,
|
||||
EdgeName = reverseEdgeName,
|
||||
EdgeDescription = reverseEdge.EdgeDescription,
|
||||
StartNodeId = newEndNodeId,
|
||||
EndNodeId = newStartNodeId
|
||||
};
|
||||
|
||||
_context.Edges.Add(newReverseEdge);
|
||||
await _context.SaveChangesAsync(); // Save to get the new edge's Id
|
||||
|
||||
// Copy vehicle properties for reverse edge
|
||||
if (reverseEdge.VehicleProperties != null && reverseEdge.VehicleProperties.Count > 0)
|
||||
{
|
||||
foreach (var sourceProp in reverseEdge.VehicleProperties)
|
||||
{
|
||||
var newProp = new EdgeVehicleProperty
|
||||
{
|
||||
EdgeId = newReverseEdge.Id,
|
||||
VehicleTypeId = sourceProp.VehicleTypeId,
|
||||
VehicleOrientation = sourceProp.VehicleOrientation,
|
||||
OrientationType = sourceProp.OrientationType,
|
||||
RotationAllowed = sourceProp.RotationAllowed,
|
||||
RotationAtStartNodeAllowed = sourceProp.RotationAtStartNodeAllowed,
|
||||
RotationAtEndNodeAllowed = sourceProp.RotationAtEndNodeAllowed,
|
||||
MaxSpeed = sourceProp.MaxSpeed,
|
||||
MaxRotationSpeed = sourceProp.MaxRotationSpeed,
|
||||
MinHeight = sourceProp.MinHeight,
|
||||
MaxHeight = sourceProp.MaxHeight,
|
||||
LoadRestriction_Unloaded = sourceProp.LoadRestriction_Unloaded,
|
||||
LoadRestriction_Loaded = sourceProp.LoadRestriction_Loaded,
|
||||
LoadRestriction_LoadSetNames = sourceProp.LoadRestriction_LoadSetNames,
|
||||
TrajectoryDegree = sourceProp.TrajectoryDegree,
|
||||
TrajectoryControlPoint1X = sourceProp.TrajectoryControlPoint1X + request.OffsetX,
|
||||
TrajectoryControlPoint1Y = sourceProp.TrajectoryControlPoint1Y + request.OffsetY,
|
||||
TrajectoryControlPoint2X = sourceProp.TrajectoryControlPoint2X + request.OffsetX,
|
||||
TrajectoryControlPoint2Y = sourceProp.TrajectoryControlPoint2Y + request.OffsetY,
|
||||
CorridorLeftWidth = sourceProp.CorridorLeftWidth,
|
||||
CorridorRightWidth = sourceProp.CorridorRightWidth,
|
||||
CorridorRefPoint = sourceProp.CorridorRefPoint,
|
||||
};
|
||||
_context.EdgeVehicleProperties.Add(newProp);
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Reload reverse edge with full details for response
|
||||
var reloadedReverseEdge = await _edgeService.GetByIdAsync(newReverseEdge.Id, includeNodes: true, includeVehicleProperties: true);
|
||||
if (reloadedReverseEdge != null)
|
||||
{
|
||||
response.NewEdges.Add(MapEdgeToDto(reloadedReverseEdge));
|
||||
}
|
||||
processedEdges.Add(reverseEdge.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set node ID mapping in response
|
||||
response.NodeIdMapping = nodeIdMapping;
|
||||
|
||||
await transaction.CommitAsync();
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
response.Success = false;
|
||||
response.ErrorMessage = ex.Message;
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string>? SafeDeserializeLoadSetNames(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<List<string>>(json); }
|
||||
catch (System.Text.Json.JsonException) { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating unique node and edge names using 8-character GUIDs
|
||||
/// Concurrent-safe and optimized for Import/Export scenarios
|
||||
/// </summary>
|
||||
public class LayoutLevelNamingService(MapDbContext context)
|
||||
{
|
||||
private readonly MapDbContext _context = context;
|
||||
|
||||
private const int GUID_LENGTH = 8;
|
||||
private const int MAX_RETRIES = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Generate unique node name using 8-character GUID
|
||||
/// Format: "Node_a7f2e3b1"
|
||||
/// </summary>
|
||||
/// <param name="levelId">Layout level ID</param>
|
||||
/// <returns>Generated node name or empty string if auto-generate is disabled</returns>
|
||||
/// <exception cref="InvalidOperationException">If cannot generate unique name after retries</exception>
|
||||
public async Task<string> GenerateNodeNameAsync(Guid levelId)
|
||||
{
|
||||
var settings = await GetOrCreateSettingsAsync(levelId);
|
||||
|
||||
if (!settings.NodeNameAutoGenerate)
|
||||
return string.Empty;
|
||||
|
||||
// Try to generate unique name with retries
|
||||
for (int attempt = 0; attempt < MAX_RETRIES; attempt++)
|
||||
{
|
||||
var guid = Guid.NewGuid().ToString("N"); // No hyphens
|
||||
var shortGuid = guid[..GUID_LENGTH];
|
||||
var nodeName = $"N_{shortGuid}";
|
||||
|
||||
// Check uniqueness (indexed query, very fast)
|
||||
bool exists = await _context.Nodes
|
||||
.AnyAsync(n => n.LevelId == levelId && n.NodeName == nodeName);
|
||||
|
||||
if (!exists) return nodeName;
|
||||
}
|
||||
|
||||
// Extremely unlikely to reach here (probability < 0.00001%)
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to generate unique node name after {MAX_RETRIES} attempts. " +
|
||||
"This is extremely unlikely. Please contact system administrator.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate unique edge name using 8-character GUID
|
||||
/// Format: "Edge_a7f2e3b1"
|
||||
/// </summary>
|
||||
/// <param name="levelId">Layout level ID</param>
|
||||
/// <returns>Generated edge name or empty string if auto-generate is disabled</returns>
|
||||
/// <exception cref="InvalidOperationException">If cannot generate unique name after retries</exception>
|
||||
public async Task<string> GenerateEdgeNameAsync(Guid levelId)
|
||||
{
|
||||
var settings = await GetOrCreateSettingsAsync(levelId);
|
||||
|
||||
if (!settings.EdgeNameAutoGenerate)
|
||||
return string.Empty;
|
||||
|
||||
// Try to generate unique name with retries
|
||||
for (int attempt = 0; attempt < MAX_RETRIES; attempt++)
|
||||
{
|
||||
var guid = Guid.NewGuid().ToString("N"); // No hyphens
|
||||
var shortGuid = guid.Substring(0, GUID_LENGTH);
|
||||
var edgeName = $"E_{shortGuid}";
|
||||
|
||||
// Check uniqueness (indexed query, very fast)
|
||||
bool exists = await _context.Edges
|
||||
.AnyAsync(e => e.LevelId == levelId && e.EdgeName == edgeName);
|
||||
|
||||
if (!exists) return edgeName;
|
||||
}
|
||||
|
||||
// Extremely unlikely to reach here (probability < 0.00001%)
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to generate unique edge name after {MAX_RETRIES} attempts. " +
|
||||
"This is extremely unlikely. Please contact system administrator.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Preview example generated names
|
||||
/// </summary>
|
||||
/// <param name="count">Number of examples to generate</param>
|
||||
/// <returns>Array of example names</returns>
|
||||
public static string[] PreviewNodeNames(int count = 5)
|
||||
{
|
||||
var examples = new string[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var guid = Guid.NewGuid().ToString("N");
|
||||
examples[i] = $"Node_{guid.Substring(0, GUID_LENGTH)}";
|
||||
}
|
||||
return examples;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Preview example generated edge names
|
||||
/// </summary>
|
||||
/// <param name="count">Number of examples to generate</param>
|
||||
/// <returns>Array of example names</returns>
|
||||
public static string[] PreviewEdgeNames(int count = 5)
|
||||
{
|
||||
var examples = new string[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var guid = Guid.NewGuid().ToString("N");
|
||||
examples[i] = $"Edge_{guid.Substring(0, GUID_LENGTH)}";
|
||||
}
|
||||
return examples;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get or create editor settings for a layout level
|
||||
/// </summary>
|
||||
private async Task<LayoutLevelEditorSettings> GetOrCreateSettingsAsync(Guid levelId)
|
||||
{
|
||||
var settings = await _context.LayoutLevelEditorSettings
|
||||
.FirstOrDefaultAsync(s => s.LevelId == levelId);
|
||||
|
||||
if (settings == null)
|
||||
{
|
||||
// Auto-create settings with defaults if not exists
|
||||
settings = new LayoutLevelEditorSettings
|
||||
{
|
||||
LevelId = levelId,
|
||||
// Defaults are set in the entity class
|
||||
};
|
||||
|
||||
_context.LayoutLevelEditorSettings.Add(settings);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update editor settings for a layout level
|
||||
/// </summary>
|
||||
public async Task UpdateSettingsAsync(Guid levelId, Action<LayoutLevelEditorSettings> updateAction)
|
||||
{
|
||||
var settings = await GetOrCreateSettingsAsync(levelId);
|
||||
updateAction(settings);
|
||||
settings.ModifiedDate = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current editor settings (read-only)
|
||||
/// </summary>
|
||||
public async Task<LayoutLevelEditorSettings?> GetSettingsAsync(Guid levelId)
|
||||
{
|
||||
return await _context.LayoutLevelEditorSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.LevelId == levelId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get collision statistics (for monitoring)
|
||||
/// </summary>
|
||||
public async Task<(int TotalNodes, int TotalEdges)> GetLevelStatisticsAsync(Guid levelId)
|
||||
{
|
||||
var nodeCount = await _context.Nodes.CountAsync(n => n.LevelId == levelId);
|
||||
var edgeCount = await _context.Edges.CountAsync(e => e.LevelId == levelId);
|
||||
return (nodeCount, edgeCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing layouts, versions, and levels
|
||||
/// </summary>
|
||||
public class LayoutService(MapDbContext context) : ILayoutService
|
||||
{
|
||||
private readonly MapDbContext _context = context;
|
||||
|
||||
// ==========================================
|
||||
// LAYOUT OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
public async Task<Layout> CreateLayoutAsync(CreateLayoutRequest request)
|
||||
{
|
||||
// Check if LayoutId already exists
|
||||
var exists = await _context.Layouts.AnyAsync(l => l.LayoutId == request.LayoutId);
|
||||
if (exists)
|
||||
{
|
||||
throw new InvalidOperationException($"Layout with ID '{request.LayoutId}' already exists");
|
||||
}
|
||||
|
||||
var layout = new Layout
|
||||
{
|
||||
LayoutId = request.LayoutId,
|
||||
LayoutName = request.LayoutName,
|
||||
Description = request.Description,
|
||||
IsActive = false,
|
||||
CreatedDate = DateTime.UtcNow,
|
||||
ModifiedDate = DateTime.UtcNow,
|
||||
CreatedBy = request.CreatedBy
|
||||
};
|
||||
|
||||
_context.Layouts.Add(layout);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
public async Task<List<Layout>> SearchLayoutsAsync(string? searchText)
|
||||
{
|
||||
var query = _context.Layouts.Include(l => l.Versions)
|
||||
.ThenInclude(v => v.Levels)
|
||||
.ThenInclude(l => l.EditorSettings)
|
||||
.AsSplitQuery()
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchText))
|
||||
{
|
||||
var search = searchText.ToLower();
|
||||
query = query.Where(l =>
|
||||
l.LayoutId.ToLower().Contains(search) ||
|
||||
l.LayoutName.ToLower().Contains(search));
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderByDescending(l => l.ModifiedDate)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Layout?> GetLayoutByIdAsync(Guid layoutId)
|
||||
{
|
||||
return await _context.Layouts
|
||||
.Include(l => l.Versions)
|
||||
.ThenInclude(v => v.Levels)
|
||||
.ThenInclude(l => l.EditorSettings)
|
||||
.FirstOrDefaultAsync(l => l.Id == layoutId);
|
||||
}
|
||||
|
||||
public async Task<Layout?> GetLayoutByLayoutIdAsync(string layoutId)
|
||||
{
|
||||
return await _context.Layouts
|
||||
.Include(l => l.Versions)
|
||||
.ThenInclude(v => v.Levels)
|
||||
.ThenInclude(l => l.EditorSettings)
|
||||
.FirstOrDefaultAsync(l => l.LayoutId == layoutId);
|
||||
}
|
||||
|
||||
public async Task<Layout?> GetLayoutByNameAsync(string layoutName)
|
||||
{
|
||||
return await _context.Layouts
|
||||
.Include(l => l.Versions)
|
||||
.ThenInclude(v => v.Levels)
|
||||
.ThenInclude(l => l.EditorSettings)
|
||||
.FirstOrDefaultAsync(l => l.LayoutName == layoutName);
|
||||
}
|
||||
|
||||
public async Task<Layout> UpdateLayoutAsync(Guid layoutId, UpdateLayoutRequest request)
|
||||
{
|
||||
var layout = await GetLayoutByIdAsync(layoutId) ??
|
||||
throw new InvalidOperationException($"Layout with ID '{layoutId}' not found");
|
||||
|
||||
layout.LayoutName = request.LayoutName;
|
||||
layout.Description = request.Description;
|
||||
layout.ModifiedDate = DateTime.UtcNow;
|
||||
layout.ModifiedBy = request.ModifiedBy;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteLayoutAsync(Guid layoutId)
|
||||
{
|
||||
var layout = await GetLayoutByIdAsync(layoutId);
|
||||
if (layout == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if layout is active
|
||||
if (layout.IsActive)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot delete active layout '{layout.LayoutId}'. Deactivate it first.");
|
||||
}
|
||||
|
||||
// Hard delete - EF Core cascade will handle:
|
||||
// Layout → Versions → Levels → Nodes/Edges/Stations → Properties
|
||||
_context.Layouts.Remove(layout);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Layout> ActivateLayoutAsync(Guid layoutId)
|
||||
{
|
||||
var layout = await GetLayoutByIdAsync(layoutId) ??
|
||||
throw new InvalidOperationException($"Layout with ID '{layoutId}' not found");
|
||||
|
||||
layout.IsActive = true;
|
||||
layout.ModifiedDate = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
public async Task<Layout> DeactivateLayoutAsync(Guid layoutId)
|
||||
{
|
||||
var layout = await GetLayoutByIdAsync(layoutId) ??
|
||||
throw new InvalidOperationException($"Layout with ID '{layoutId}' not found");
|
||||
layout.IsActive = false;
|
||||
layout.ModifiedDate = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VERSION OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
public async Task<LayoutVersion> CreateVersionAsync(Guid layoutId, CreateLayoutVersionRequest request)
|
||||
{
|
||||
var layout = await GetLayoutByIdAsync(layoutId) ??
|
||||
throw new InvalidOperationException($"Layout with ID '{layoutId}' not found");
|
||||
|
||||
// Check if version already exists
|
||||
var exists = await _context.LayoutVersions
|
||||
.AnyAsync(v => v.LayoutId == layoutId && v.Version == request.Version);
|
||||
if (exists)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Version '{request.Version}' already exists for layout '{layout.LayoutId}'");
|
||||
}
|
||||
|
||||
var version = new LayoutVersion
|
||||
{
|
||||
LayoutId = layoutId,
|
||||
Version = request.Version,
|
||||
LayoutDescription = request.LayoutDescription,
|
||||
CreatedBy = request.CreatedBy,
|
||||
CreatedDate = DateTime.UtcNow,
|
||||
IsActive = false // New versions start as inactive
|
||||
};
|
||||
|
||||
_context.LayoutVersions.Add(version);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
public async Task<List<LayoutVersion>> GetVersionsAsync(Guid layoutId)
|
||||
{
|
||||
return await _context.LayoutVersions
|
||||
.Where(v => v.LayoutId == layoutId)
|
||||
.Include(v => v.Levels)
|
||||
.ThenInclude(l => l.EditorSettings)
|
||||
.OrderByDescending(v => v.CreatedDate)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<LayoutVersion?> GetVersionAsync(Guid versionId)
|
||||
{
|
||||
return await _context.LayoutVersions
|
||||
.Include(v => v.Layout)
|
||||
.Include(v => v.Levels)
|
||||
.ThenInclude(l => l.EditorSettings)
|
||||
.FirstOrDefaultAsync(v => v.Id == versionId);
|
||||
}
|
||||
|
||||
public async Task<LayoutVersion> UpdateVersionAsync(Guid versionId, UpdateLayoutRequest request)
|
||||
{
|
||||
var version = await GetVersionAsync(versionId) ??
|
||||
throw new InvalidOperationException($"Version with ID '{versionId}' not found");
|
||||
version.LayoutDescription = request.Description;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteVersionAsync(Guid versionId)
|
||||
{
|
||||
var version = await GetVersionAsync(versionId);
|
||||
if (version == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if parent layout is active
|
||||
if (version.Layout.IsActive)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot delete version from active layout '{version.Layout.LayoutId}'. " +
|
||||
"Deactivate the layout first.");
|
||||
}
|
||||
|
||||
// Hard delete - cascade will handle levels and all nested data
|
||||
_context.LayoutVersions.Remove(version);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// LEVEL OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
public async Task<LayoutLevel> CreateLevelAsync(Guid versionId, CreateLayoutLevelRequest request)
|
||||
{
|
||||
var version = await GetVersionAsync(versionId) ??
|
||||
throw new InvalidOperationException($"Version with ID '{versionId}' not found");
|
||||
|
||||
// Check if level already exists
|
||||
var exists = await _context.LayoutLevels
|
||||
.AnyAsync(l => l.VersionId == versionId && l.LayoutLevelId == request.LayoutLevelId);
|
||||
if (exists)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Level '{request.LayoutLevelId}' already exists in this version");
|
||||
}
|
||||
|
||||
var level = new LayoutLevel
|
||||
{
|
||||
VersionId = versionId,
|
||||
LayoutLevelId = request.LayoutLevelId,
|
||||
LevelOrder = request.LevelOrder
|
||||
};
|
||||
|
||||
_context.LayoutLevels.Add(level);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Create editor settings if coordinate system provided
|
||||
if (request.CoordinateSystem != null)
|
||||
{
|
||||
var settings = new LayoutLevelEditorSettings
|
||||
{
|
||||
LevelId = level.Id,
|
||||
OriginX = request.CoordinateSystem.OriginX,
|
||||
OriginY = request.CoordinateSystem.OriginY,
|
||||
Resolution = request.CoordinateSystem.Resolution,
|
||||
BoundsMinX = request.CoordinateSystem.BoundsMinX,
|
||||
BoundsMaxX = request.CoordinateSystem.BoundsMaxX,
|
||||
BoundsMinY = request.CoordinateSystem.BoundsMinY,
|
||||
BoundsMaxY = request.CoordinateSystem.BoundsMaxY,
|
||||
ImageWidth = request.CoordinateSystem.ImageWidth,
|
||||
ImageHeight = request.CoordinateSystem.ImageHeight,
|
||||
CreatedDate = DateTime.UtcNow,
|
||||
ModifiedDate = DateTime.UtcNow
|
||||
};
|
||||
_context.LayoutLevelEditorSettings.Add(settings);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return level;
|
||||
}
|
||||
|
||||
public async Task<List<LayoutLevel>> GetLevelsAsync(Guid versionId)
|
||||
{
|
||||
return await _context.LayoutLevels
|
||||
.Where(l => l.VersionId == versionId)
|
||||
.Include(l => l.EditorSettings)
|
||||
.OrderBy(l => l.LevelOrder)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<LayoutLevel?> GetLevelAsync(Guid levelId)
|
||||
{
|
||||
return await _context.LayoutLevels
|
||||
.Include(l => l.Version)
|
||||
.ThenInclude(v => v.Layout)
|
||||
.Include(l => l.EditorSettings)
|
||||
.FirstOrDefaultAsync(l => l.Id == levelId);
|
||||
}
|
||||
|
||||
public async Task<LayoutLevel> UpdateLevelAsync(Guid levelId, UpdateLayoutLevelRequest request)
|
||||
{
|
||||
var level = await GetLevelAsync(levelId) ?? throw new InvalidOperationException($"Level with ID '{levelId}' not found");
|
||||
|
||||
// Update level properties
|
||||
if (request.LayoutLevelId != null)
|
||||
level.LayoutLevelId = request.LayoutLevelId;
|
||||
|
||||
if (request.LevelOrder.HasValue)
|
||||
level.LevelOrder = request.LevelOrder.Value;
|
||||
|
||||
// Get or create editor settings
|
||||
var settings = await _context.LayoutLevelEditorSettings
|
||||
.FirstOrDefaultAsync(s => s.LevelId == levelId);
|
||||
|
||||
if (settings == null)
|
||||
{
|
||||
// Create new settings
|
||||
settings = new LayoutLevelEditorSettings
|
||||
{
|
||||
LevelId = levelId,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
_context.LayoutLevelEditorSettings.Add(settings);
|
||||
}
|
||||
|
||||
// Update coordinate system if provided
|
||||
if (request.CoordinateSystem != null)
|
||||
{
|
||||
settings.OriginX = request.CoordinateSystem.OriginX;
|
||||
settings.OriginY = request.CoordinateSystem.OriginY;
|
||||
settings.Resolution = request.CoordinateSystem.Resolution;
|
||||
settings.BoundsMinX = request.CoordinateSystem.BoundsMinX;
|
||||
settings.BoundsMaxX = request.CoordinateSystem.BoundsMaxX;
|
||||
settings.BoundsMinY = request.CoordinateSystem.BoundsMinY;
|
||||
settings.BoundsMaxY = request.CoordinateSystem.BoundsMaxY;
|
||||
settings.ImageWidth = request.CoordinateSystem.ImageWidth;
|
||||
settings.ImageHeight = request.CoordinateSystem.ImageHeight;
|
||||
}
|
||||
|
||||
// Update editor settings if provided
|
||||
if (request.EditorSettings != null)
|
||||
{
|
||||
if (request.EditorSettings.EdgeMinLengthCreate.HasValue)
|
||||
settings.EdgeMinLengthCreate = request.EditorSettings.EdgeMinLengthCreate.Value;
|
||||
|
||||
if (request.EditorSettings.EdgeNameAutoGenerate.HasValue)
|
||||
settings.EdgeNameAutoGenerate = request.EditorSettings.EdgeNameAutoGenerate.Value;
|
||||
|
||||
if (request.EditorSettings.NodeNameAutoGenerate.HasValue)
|
||||
settings.NodeNameAutoGenerate = request.EditorSettings.NodeNameAutoGenerate.Value;
|
||||
|
||||
if (request.EditorSettings.NodeProximityRadius.HasValue)
|
||||
settings.NodeProximityRadius = request.EditorSettings.NodeProximityRadius.Value;
|
||||
}
|
||||
|
||||
// Update modified date if any settings were changed
|
||||
if (request.CoordinateSystem != null || request.EditorSettings != null)
|
||||
{
|
||||
settings.ModifiedDate = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return level;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteLevelAsync(Guid levelId)
|
||||
{
|
||||
var level = await GetLevelAsync(levelId);
|
||||
if (level == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if parent layout is active
|
||||
if (level.Version.Layout.IsActive)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot delete level from active layout '{level.Version.Layout.LayoutId}'. " +
|
||||
"Deactivate the layout first.");
|
||||
}
|
||||
|
||||
// Hard delete - cascade will handle all nested data
|
||||
_context.LayoutLevels.Remove(level);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for querying map data (nodes and edges) by VehicleType
|
||||
/// </summary>
|
||||
public class MapQueryService(MapDbContext context) : IMapQueryService
|
||||
{
|
||||
private readonly MapDbContext _context = context;
|
||||
|
||||
public async Task<List<Node>> GetNodesByVehicleTypeAsync(Guid vehicleTypeId)
|
||||
{
|
||||
var filteredNodes = await _context.Nodes
|
||||
.Include(n => n.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.Where(n => n.VehicleProperties.Any(vp => vp.VehicleTypeId == vehicleTypeId))
|
||||
.OrderBy(n => n.NodeId)
|
||||
.ToListAsync();
|
||||
|
||||
return filteredNodes;
|
||||
}
|
||||
|
||||
public async Task<List<Edge>> GetEdgesByVehicleTypeAsync(Guid vehicleTypeId)
|
||||
{
|
||||
var filteredEdges = await _context.Edges
|
||||
.Include(e => e.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.Include(e => e.StartNode)
|
||||
.Include(e => e.EndNode)
|
||||
.Where(e => e.VehicleProperties.Any(vp => vp.VehicleTypeId == vehicleTypeId))
|
||||
.OrderBy(e => e.EdgeId)
|
||||
.ToListAsync();
|
||||
|
||||
return filteredEdges;
|
||||
}
|
||||
|
||||
public async Task<List<Node>> GetNodesByVehicleTypeAndLevelAsync(Guid vehicleTypeId, Guid levelId)
|
||||
{
|
||||
var filteredNodes = await _context.Nodes
|
||||
.Include(n => n.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.Where(n => n.LevelId == levelId &&
|
||||
n.VehicleProperties.Any(vp => vp.VehicleTypeId == vehicleTypeId))
|
||||
.OrderBy(n => n.NodeId)
|
||||
.ToListAsync();
|
||||
|
||||
return filteredNodes;
|
||||
}
|
||||
|
||||
public async Task<List<Edge>> GetEdgesByVehicleTypeAndLevelAsync(Guid vehicleTypeId, Guid levelId)
|
||||
{
|
||||
var filteredEdges = await _context.Edges
|
||||
.Include(e => e.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.Include(e => e.StartNode)
|
||||
.Include(e => e.EndNode)
|
||||
.Where(e => e.LevelId == levelId &&
|
||||
e.VehicleProperties.Any(vp => vp.VehicleTypeId == vehicleTypeId))
|
||||
.OrderBy(e => e.EdgeId)
|
||||
.ToListAsync();
|
||||
|
||||
return filteredEdges;
|
||||
}
|
||||
|
||||
public async Task<int> GetTotalNodesCountAsync()
|
||||
{
|
||||
return await _context.Nodes.CountAsync();
|
||||
}
|
||||
|
||||
public async Task<int> GetTotalEdgesCountAsync()
|
||||
{
|
||||
return await _context.Edges.CountAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing nodes
|
||||
/// </summary>
|
||||
public class NodeService(MapDbContext context) : INodeService
|
||||
{
|
||||
private readonly MapDbContext _context = context;
|
||||
|
||||
public async Task<List<Node>> GetNodesByLevelAsync(Guid layoutLevelId, bool includeVehicleProperties = true)
|
||||
{
|
||||
var query = _context.Nodes.Where(n => n.LevelId == layoutLevelId);
|
||||
|
||||
if (includeVehicleProperties)
|
||||
{
|
||||
query = query.Include(n => n.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType);
|
||||
}
|
||||
|
||||
return await query.OrderBy(n => n.NodeId).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Node?> GetByIdAsync(Guid nodeId, bool includeVehicleProperties = true)
|
||||
{
|
||||
var query = _context.Nodes.Where(n => n.Id == nodeId);
|
||||
|
||||
if (includeVehicleProperties)
|
||||
{
|
||||
query = query.Include(n => n.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType);
|
||||
}
|
||||
|
||||
return await query.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<Node> UpdateAsync(Guid nodeId, UpdateNodeRequest request)
|
||||
{
|
||||
var node = await GetByIdAsync(nodeId, includeVehicleProperties: true) ??
|
||||
throw new InvalidOperationException($"Node with ID '{nodeId}' not found");
|
||||
|
||||
// Update coordinates if provided
|
||||
if (request.X.HasValue)
|
||||
{
|
||||
// Validate bounds
|
||||
if (!await ValidateCoordinatesAsync(node.LevelId, request.X.Value, node.Y))
|
||||
{
|
||||
throw new InvalidOperationException($"Coordinates ({request.X.Value}, {node.Y}) are outside valid bounds");
|
||||
}
|
||||
node.X = request.X.Value;
|
||||
}
|
||||
|
||||
if (request.Y.HasValue)
|
||||
{
|
||||
// Validate bounds
|
||||
if (!await ValidateCoordinatesAsync(node.LevelId, node.X, request.Y.Value))
|
||||
{
|
||||
throw new InvalidOperationException($"Coordinates ({node.X}, {request.Y.Value}) are outside valid bounds");
|
||||
}
|
||||
node.Y = request.Y.Value;
|
||||
}
|
||||
|
||||
// Update other properties
|
||||
if (request.NodeName != null)
|
||||
node.NodeName = request.NodeName;
|
||||
|
||||
if (request.NodeDescription != null)
|
||||
node.NodeDescription = request.NodeDescription;
|
||||
|
||||
if (request.MapId != null)
|
||||
node.MapId = request.MapId;
|
||||
|
||||
// Update vehicle properties if provided
|
||||
if (request.VehicleProperties != null)
|
||||
{
|
||||
// Remove existing properties
|
||||
var existingProps = await _context.NodeVehicleProperties
|
||||
.Where(nvp => nvp.NodeId == nodeId)
|
||||
.ToListAsync();
|
||||
_context.NodeVehicleProperties.RemoveRange(existingProps);
|
||||
|
||||
// Add new properties
|
||||
foreach (var propDto in request.VehicleProperties)
|
||||
{
|
||||
var prop = new NodeVehicleProperty
|
||||
{
|
||||
NodeId = nodeId,
|
||||
VehicleTypeId = propDto.VehicleTypeId,
|
||||
Theta = propDto.Theta,
|
||||
Actions = propDto.Actions,
|
||||
AllowedDeviationXY = propDto.AllowedDeviationXY,
|
||||
AllowedDeviationTheta = propDto.AllowedDeviationTheta
|
||||
};
|
||||
_context.NodeVehicleProperties.Add(prop);
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Reload with vehicle properties
|
||||
return (await GetByIdAsync(nodeId, includeVehicleProperties: true))!;
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateCoordinatesAsync(Guid layoutLevelId, double x, double y)
|
||||
{
|
||||
var settings = await _context.LayoutLevelEditorSettings
|
||||
.FirstOrDefaultAsync(s => s.LevelId == layoutLevelId);
|
||||
|
||||
if (settings == null)
|
||||
return true; // No bounds set, allow any coordinates
|
||||
|
||||
// Check bounds
|
||||
if (settings.BoundsMinX.HasValue && x < settings.BoundsMinX.Value)
|
||||
return false;
|
||||
|
||||
if (settings.BoundsMaxX.HasValue && x > settings.BoundsMaxX.Value)
|
||||
return false;
|
||||
|
||||
if (settings.BoundsMinY.HasValue && y < settings.BoundsMinY.Value)
|
||||
return false;
|
||||
|
||||
if (settings.BoundsMaxY.HasValue && y > settings.BoundsMaxY.Value)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<List<Node>> FindNodesNearCoordinatesAsync(Guid layoutLevelId, double x, double y, double radius)
|
||||
{
|
||||
// Pre-filter with bounding box at database level, then refine with Euclidean distance in-memory
|
||||
var nodes = await _context.Nodes
|
||||
.Where(n => n.LevelId == layoutLevelId
|
||||
&& n.X >= x - radius && n.X <= x + radius
|
||||
&& n.Y >= y - radius && n.Y <= y + radius)
|
||||
.ToListAsync();
|
||||
|
||||
return [.. nodes
|
||||
.Where(n =>
|
||||
{
|
||||
var dx = n.X - x;
|
||||
var dy = n.Y - y;
|
||||
return dx * dx + dy * dy <= radius * radius;
|
||||
})
|
||||
.OrderBy(n => (n.X - x) * (n.X - x) + (n.Y - y) * (n.Y - y))];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing stations
|
||||
/// </summary>
|
||||
public class StationService(MapDbContext context) : IStationService
|
||||
{
|
||||
private readonly MapDbContext _context = context;
|
||||
|
||||
public async Task<Station> CreateAsync(CreateStationRequest request)
|
||||
{
|
||||
// Check if station ID already exists in this level
|
||||
var exists = await _context.Stations
|
||||
.AnyAsync(s => s.LevelId == request.LayoutLevelId && s.StationId == request.StationId);
|
||||
|
||||
if (exists)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Station with ID '{request.StationId}' already exists in this layout level");
|
||||
}
|
||||
|
||||
using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
var station = new Station
|
||||
{
|
||||
LevelId = request.LayoutLevelId,
|
||||
StationId = request.StationId,
|
||||
StationName = request.StationName,
|
||||
StationDescription = request.StationDescription,
|
||||
StationHeight = request.StationHeight,
|
||||
X = request.X,
|
||||
Y = request.Y,
|
||||
Theta = request.Theta
|
||||
};
|
||||
|
||||
_context.Stations.Add(station);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Add interaction nodes if provided
|
||||
if (request.InteractionNodeIds != null && request.InteractionNodeIds.Count != 0)
|
||||
{
|
||||
foreach (var nodeId in request.InteractionNodeIds)
|
||||
{
|
||||
var interactionNode = new StationInteractionNode
|
||||
{
|
||||
StationId = station.Id,
|
||||
NodeId = nodeId
|
||||
};
|
||||
_context.StationInteractionNodes.Add(interactionNode);
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await transaction.CommitAsync();
|
||||
|
||||
// Reload with interaction nodes
|
||||
return (await GetByIdAsync(station.Id, includeInteractionNodes: true))!;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Station>> GetStationsByLevelAsync(Guid layoutLevelId, bool includeInteractionNodes = true)
|
||||
{
|
||||
var query = _context.Stations.Where(s => s.LevelId == layoutLevelId);
|
||||
|
||||
if (includeInteractionNodes)
|
||||
{
|
||||
query = query
|
||||
.Include(s => s.InteractionNodes)
|
||||
.ThenInclude(sin => sin.Node);
|
||||
}
|
||||
|
||||
return await query.OrderBy(s => s.StationId).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Station?> GetByIdAsync(Guid stationId, bool includeInteractionNodes = true)
|
||||
{
|
||||
var query = _context.Stations.Where(s => s.Id == stationId);
|
||||
|
||||
if (includeInteractionNodes)
|
||||
{
|
||||
query = query
|
||||
.Include(s => s.InteractionNodes)
|
||||
.ThenInclude(sin => sin.Node)
|
||||
.ThenInclude(n => n.VehicleProperties);
|
||||
}
|
||||
|
||||
return await query.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<Station> UpdateAsync(Guid stationId, UpdateStationRequest request)
|
||||
{
|
||||
var station = await GetByIdAsync(stationId, includeInteractionNodes: true) ??
|
||||
throw new InvalidOperationException($"Station with ID '{stationId}' not found");
|
||||
|
||||
// Update properties
|
||||
if (request.StationName != null)
|
||||
station.StationName = request.StationName;
|
||||
|
||||
if (request.StationDescription != null)
|
||||
station.StationDescription = request.StationDescription;
|
||||
|
||||
if (request.StationHeight.HasValue)
|
||||
station.StationHeight = request.StationHeight.Value;
|
||||
|
||||
if (request.X.HasValue)
|
||||
station.X = request.X.Value;
|
||||
|
||||
if (request.Y.HasValue)
|
||||
station.Y = request.Y.Value;
|
||||
|
||||
if (request.Theta.HasValue)
|
||||
station.Theta = request.Theta.Value;
|
||||
|
||||
// Update interaction nodes if provided
|
||||
if (request.InteractionNodeIds != null)
|
||||
{
|
||||
// Remove existing interaction nodes
|
||||
var existingInteractionNodes = await _context.StationInteractionNodes
|
||||
.Where(sin => sin.StationId == stationId)
|
||||
.ToListAsync();
|
||||
_context.StationInteractionNodes.RemoveRange(existingInteractionNodes);
|
||||
|
||||
// Add new interaction nodes
|
||||
foreach (var nodeId in request.InteractionNodeIds)
|
||||
{
|
||||
// Verify node exists
|
||||
var nodeExists = await _context.Nodes.AnyAsync(n => n.Id == nodeId);
|
||||
if (!nodeExists)
|
||||
{
|
||||
throw new InvalidOperationException($"Node with ID '{nodeId}' not found");
|
||||
}
|
||||
|
||||
var interactionNode = new StationInteractionNode
|
||||
{
|
||||
StationId = stationId,
|
||||
NodeId = nodeId
|
||||
};
|
||||
_context.StationInteractionNodes.Add(interactionNode);
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Reload with interaction nodes
|
||||
return (await GetByIdAsync(stationId, includeInteractionNodes: true))!;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid stationId)
|
||||
{
|
||||
var station = await GetByIdAsync(stationId, includeInteractionNodes: false);
|
||||
if (station == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete interaction nodes (cascade will handle this, but explicit for clarity)
|
||||
var interactionNodes = await _context.StationInteractionNodes
|
||||
.Where(sin => sin.StationId == stationId)
|
||||
.ToListAsync();
|
||||
_context.StationInteractionNodes.RemoveRange(interactionNodes);
|
||||
|
||||
// Delete station
|
||||
_context.Stations.Remove(station);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing vehicle types
|
||||
/// </summary>
|
||||
public class VehicleTypeService(MapDbContext context) : IVehicleTypeService
|
||||
{
|
||||
private readonly MapDbContext _context = context;
|
||||
|
||||
public async Task<VehicleType> CreateAsync(string vehicleTypeId, string vehicleTypeName, string? description, string? specifications, string? actions)
|
||||
{
|
||||
// Check if already exists
|
||||
if (await ExistsAsync(vehicleTypeId))
|
||||
{
|
||||
throw new InvalidOperationException($"Vehicle type with ID '{vehicleTypeId}' already exists");
|
||||
}
|
||||
|
||||
var vehicleType = new VehicleType
|
||||
{
|
||||
VehicleTypeId = vehicleTypeId,
|
||||
VehicleTypeName = vehicleTypeName,
|
||||
Description = description,
|
||||
Specifications = specifications,
|
||||
Actions = actions,
|
||||
IsActive = true,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.VehicleTypes.Add(vehicleType);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return vehicleType;
|
||||
}
|
||||
|
||||
public async Task<List<VehicleType>> GetAllAsync()
|
||||
{
|
||||
return await _context.VehicleTypes
|
||||
.OrderBy(v => v.VehicleTypeName)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<VehicleType?> GetByIdAsync(Guid id)
|
||||
{
|
||||
return await _context.VehicleTypes
|
||||
.FirstOrDefaultAsync(v => v.Id == id);
|
||||
}
|
||||
|
||||
public async Task<VehicleType?> GetByVehicleTypeIdAsync(string vehicleTypeId)
|
||||
{
|
||||
return await _context.VehicleTypes
|
||||
.FirstOrDefaultAsync(v => v.VehicleTypeId == vehicleTypeId);
|
||||
}
|
||||
|
||||
public async Task<VehicleType> UpdateAsync(Guid id, string? vehicleTypeName, string? description, string? specifications, string? actions, bool? isActive)
|
||||
{
|
||||
var vehicleType = await GetByIdAsync(id) ??
|
||||
throw new InvalidOperationException($"Vehicle type with ID '{id}' not found");
|
||||
if (vehicleTypeName != null)
|
||||
vehicleType.VehicleTypeName = vehicleTypeName;
|
||||
|
||||
if (description != null)
|
||||
vehicleType.Description = description;
|
||||
|
||||
if (specifications != null)
|
||||
vehicleType.Specifications = specifications;
|
||||
|
||||
if (actions != null)
|
||||
vehicleType.Actions = actions;
|
||||
|
||||
if (isActive.HasValue)
|
||||
vehicleType.IsActive = isActive.Value;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return vehicleType;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id)
|
||||
{
|
||||
var vehicleType = await GetByIdAsync(id);
|
||||
if (vehicleType == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if vehicle type is referenced
|
||||
var hasNodeReferences = await _context.NodeVehicleProperties
|
||||
.AnyAsync(nvp => nvp.VehicleTypeId == id);
|
||||
|
||||
var hasEdgeReferences = await _context.EdgeVehicleProperties
|
||||
.AnyAsync(evp => evp.VehicleTypeId == id);
|
||||
|
||||
if (hasNodeReferences || hasEdgeReferences)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot delete vehicle type '{vehicleType.VehicleTypeId}' because it is referenced by nodes or edges");
|
||||
}
|
||||
|
||||
_context.VehicleTypes.Remove(vehicleType);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(string vehicleTypeId)
|
||||
{
|
||||
return await _context.VehicleTypes
|
||||
.AnyAsync(v => v.VehicleTypeId == vehicleTypeId);
|
||||
}
|
||||
|
||||
public async Task<List<VehicleType>> SearchAsync(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return await GetAllAsync();
|
||||
}
|
||||
|
||||
var lowerQuery = query.ToLowerInvariant();
|
||||
|
||||
return await _context.VehicleTypes
|
||||
.Where(v =>
|
||||
v.VehicleTypeId.ToLower().Contains(lowerQuery) ||
|
||||
v.VehicleTypeName.ToLower().Contains(lowerQuery))
|
||||
.OrderBy(v => v.VehicleTypeName)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<VehicleType>> GetByActiveStatusAsync(bool isActive)
|
||||
{
|
||||
return await _context.VehicleTypes
|
||||
.Where(v => v.IsActive == isActive)
|
||||
.OrderBy(v => v.VehicleTypeName)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<VehicleTypeUsageInfo> GetUsageInfoAsync(Guid id)
|
||||
{
|
||||
var vehicleType = await GetByIdAsync(id);
|
||||
if (vehicleType == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Vehicle type with ID '{id}' not found");
|
||||
}
|
||||
|
||||
var nodePropertiesCount = await _context.NodeVehicleProperties
|
||||
.CountAsync(nvp => nvp.VehicleTypeId == id);
|
||||
|
||||
var edgePropertiesCount = await _context.EdgeVehicleProperties
|
||||
.CountAsync(evp => evp.VehicleTypeId == id);
|
||||
|
||||
return new VehicleTypeUsageInfo
|
||||
{
|
||||
VehicleTypeId = vehicleType.Id,
|
||||
VehicleTypeIdString = vehicleType.VehicleTypeId,
|
||||
VehicleTypeName = vehicleType.VehicleTypeName,
|
||||
NodePropertiesCount = nodePropertiesCount,
|
||||
EdgePropertiesCount = edgePropertiesCount
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Vehicle type usage information
|
||||
/// </summary>
|
||||
public class VehicleTypeUsageInfo
|
||||
{
|
||||
public Guid VehicleTypeId { get; set; }
|
||||
public string VehicleTypeIdString { get; set; } = string.Empty;
|
||||
public string VehicleTypeName { get; set; } = string.Empty;
|
||||
public int NodePropertiesCount { get; set; }
|
||||
public int EdgePropertiesCount { get; set; }
|
||||
public int TotalUsageCount => NodePropertiesCount + EdgePropertiesCount;
|
||||
public bool CanDelete => TotalUsageCount == 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user