Initial commit

This commit is contained in:
2026-07-13 09:25:40 +07:00
parent c08ff54676
commit bccfb156d7
1938 changed files with 641646 additions and 0 deletions

View File

@@ -0,0 +1,261 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using RobotNet10.MapEditor.Shared.DTOs.Edge;
using RobotNet10.MapEditor.Shared.DTOs.Node;
using RobotNet10.MapEditor.Shared.DTOs.Requests;
using RobotNet10.MapEditor.Shared.DTOs.Responses;
using RobotNet10.MapManager.Services;
using System.Text.Json;
namespace RobotNet10.MapManager.Controllers;
/// <summary>
/// Controller for managing edges with complex node detection and cascade delete logic
/// </summary>
[ApiController]
[Route("api/edges")]
[Authorize]
public class EdgesController(
IEdgeService edgeService,
ILogger<EdgesController> logger) : ControllerBase
{
private readonly IEdgeService _edgeService = edgeService;
private readonly ILogger<EdgesController> _logger = logger;
/// <summary>
/// Get all edges for a layout level
/// </summary>
/// <param name="layoutLevelId">Layout level ID</param>
/// <returns>List of edges with nodes and vehicle properties</returns>
[HttpGet("level/{layoutLevelId}")]
[ProducesResponseType(typeof(List<EdgeDto>), 200)]
public async Task<ActionResult<List<EdgeDto>>> GetEdgesByLevel(Guid layoutLevelId)
{
var edges = await _edgeService.GetEdgesByLevelAsync(layoutLevelId, includeNodes: true, includeVehicleProperties: true);
var dtos = edges.Select(MapToDto).ToList();
return Ok(dtos);
}
/// <summary>
/// Get edge by ID
/// </summary>
/// <param name="edgeId">Edge database ID</param>
/// <returns>Edge details with nodes and vehicle properties</returns>
[HttpGet("{edgeId}")]
[ProducesResponseType(typeof(EdgeDto), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<EdgeDto>> GetEdge(Guid edgeId)
{
var edge = await _edgeService.GetByIdAsync(edgeId, includeNodes: true, includeVehicleProperties: true);
if (edge == null)
{
return NotFound(CreateErrorResponse($"Edge with ID '{edgeId}' not found", "EDGE_NOT_FOUND"));
}
return Ok(MapToDto(edge));
}
/// <summary>
/// Create edge with automatic node detection/creation
/// If start/end point is within NodeProximityRadius (default 0.35m) of existing node, connect to that node
/// Otherwise, create new node at exact coordinates
/// </summary>
/// <param name="request">Edge creation request with coordinates in METERS</param>
/// <returns>Created edge with connected nodes</returns>
[HttpPost]
[ProducesResponseType(typeof(EdgeDto), 201)]
[ProducesResponseType(400)]
public async Task<ActionResult<EdgeDto>> CreateEdge([FromBody] CreateEdgeRequest request)
{
try
{
var edge = await _edgeService.CreateAsync(request);
var dto = MapToDto(edge);
return CreatedAtAction(
nameof(GetEdge),
new { edgeId = edge.Id },
dto);
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Failed to create edge");
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
}
}
/// <summary>
/// Update edge properties
/// </summary>
/// <param name="edgeId">Edge database ID</param>
/// <param name="request">Update request</param>
/// <returns>Updated edge</returns>
[HttpPut("{edgeId}")]
[ProducesResponseType(typeof(EdgeDto), 200)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<EdgeDto>> UpdateEdge(
Guid edgeId,
[FromBody] UpdateEdgeRequest request)
{
try
{
var edge = await _edgeService.UpdateAsync(edgeId, request);
return Ok(MapToDto(edge));
}
catch (InvalidOperationException ex)
{
if(_logger.IsEnabled(LogLevel.Warning))_logger.LogWarning(ex, "Failed to update edge: {edgeId}", edgeId);
if (ex.Message.Contains("not found"))
return NotFound(CreateErrorResponse(ex.Message, "EDGE_NOT_FOUND"));
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
}
}
/// <summary>
/// Delete edge
/// Cascade deletes orphan nodes (nodes not connected to any other edge)
/// Also deletes StationInteractionNodes referencing orphan nodes
/// </summary>
/// <param name="edgeId">Edge database ID</param>
/// <returns>No content on success</returns>
[HttpDelete("{edgeId}")]
[ProducesResponseType(204)]
[ProducesResponseType(404)]
public async Task<IActionResult> DeleteEdge(Guid edgeId)
{
var deleted = await _edgeService.DeleteAsync(edgeId);
if (!deleted)
{
return NotFound(CreateErrorResponse($"Edge with ID '{edgeId}' not found", "EDGE_NOT_FOUND"));
}
return NoContent();
}
/// <summary>
/// Delete multiple edges in a transaction
/// All edges are deleted or none (transaction)
/// Cascade deletes orphan nodes and StationInteractionNodes
/// </summary>
/// <param name="request">Batch delete request with edge IDs</param>
/// <returns>No content on success</returns>
[HttpDelete("batch")]
[ProducesResponseType(204)]
[ProducesResponseType(400)]
public async Task<IActionResult> DeleteEdgesBatch([FromBody] DeleteEdgesRequest request)
{
if (request.EdgeIds == null || request.EdgeIds.Count == 0)
{
return BadRequest(CreateErrorResponse("No edge IDs provided", "VALIDATION_ERROR"));
}
try
{
await _edgeService.DeleteBatchAsync(request.EdgeIds);
return NoContent();
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Failed to batch delete edges");
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to batch delete edges");
return StatusCode(500, CreateErrorResponse("Internal server error while deleting edges", "INTERNAL_ERROR"));
}
}
// Helper method to map entity to DTO
private static EdgeDto MapToDto(Data.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 ? new NodeDto
{
Id = edge.StartNode.Id,
NodeId = edge.StartNode.NodeId,
NodeName = edge.StartNode.NodeName,
X = edge.StartNode.X,
Y = edge.StartNode.Y
} : null,
EndNode = edge.EndNode != null ? new NodeDto
{
Id = edge.EndNode.Id,
NodeId = edge.EndNode.NodeId,
NodeName = edge.EndNode.NodeName,
X = edge.EndNode.X,
Y = edge.EndNode.Y
} : 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 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; }
}
private static ErrorResponseDto CreateErrorResponse(
string error,
string? errorCode = null,
Dictionary<string, object>? details = null)
{
return new ErrorResponseDto
{
Error = error,
ErrorCode = errorCode,
Details = details
};
}
}

View File

@@ -0,0 +1,143 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using RobotNet10.MapEditor.Shared.DTOs.Responses;
using RobotNet10.MapManager.Services;
namespace RobotNet10.MapManager.Controllers;
/// <summary>
/// Controller for managing background images for layout levels
/// </summary>
[ApiController]
[Route("api/images")]
[Authorize]
public class ImagesController(
IImageStorageService imageStorageService,
ILogger<ImagesController> logger) : ControllerBase
{
private readonly IImageStorageService _imageStorageService = imageStorageService;
private readonly ILogger<ImagesController> _logger = logger;
/// <summary>
/// Get background image for a layout level
/// </summary>
/// <param name="layoutLevelId">Layout level ID</param>
/// <returns>Image file (PNG)</returns>
[HttpGet("layout/{layoutLevelId}")]
[ProducesResponseType(typeof(FileStreamResult), 200)]
[ProducesResponseType(404)]
public async Task<IActionResult> GetLayoutImage(Guid layoutLevelId)
{
try
{
var imageStream = await _imageStorageService.GetImageAsync(layoutLevelId);
if (imageStream == null)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning("Image not found for layout level {LevelId}", layoutLevelId);
return NotFound(CreateErrorResponse($"Image not found for layout level '{layoutLevelId}'", "IMAGE_NOT_FOUND"));
}
return File(imageStream, "image/png", $"{layoutLevelId}.png");
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Error retrieving image for layout level {LevelId}", layoutLevelId);
return StatusCode(500, CreateErrorResponse("Internal server error while retrieving image", "INTERNAL_ERROR"));
}
}
/// <summary>
/// Upload or replace background image for a layout level
/// </summary>
/// <param name="layoutLevelId">Layout level ID</param>
/// <param name="file">Image file (PNG format)</param>
/// <returns>Success message</returns>
[HttpPost("layout/{layoutLevelId}")]
[ProducesResponseType(200)]
[ProducesResponseType(400)]
public async Task<IActionResult> UploadLayoutImage(Guid layoutLevelId, IFormFile file)
{
// Validate file
if (file == null || file.Length == 0)
{
return BadRequest(CreateErrorResponse("No file provided", "VALIDATION_ERROR"));
}
// Validate file type
if (!file.ContentType.Equals("image/png", StringComparison.OrdinalIgnoreCase))
{
return BadRequest(CreateErrorResponse("Only PNG images are supported", "VALIDATION_ERROR"));
}
// Validate file size (max 10MB)
const long maxFileSize = 10 * 1024 * 1024; // 10MB
if (file.Length > maxFileSize)
{
return BadRequest(CreateErrorResponse($"File size exceeds maximum of {maxFileSize / 1024 / 1024}MB", "VALIDATION_ERROR"));
}
try
{
using var stream = file.OpenReadStream();
await _imageStorageService.SaveImageAsync(layoutLevelId, stream);
return Ok(new
{
message = "Image uploaded successfully",
layoutLevelId,
fileName = $"{layoutLevelId}.png",
size = file.Length
});
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Error uploading image for layout level {LevelId}", layoutLevelId);
return StatusCode(500, CreateErrorResponse("Internal server error while uploading image", "INTERNAL_ERROR"));
}
}
/// <summary>
/// Delete background image for a layout level
/// </summary>
/// <param name="layoutLevelId">Layout level ID</param>
/// <returns>No content on success</returns>
[HttpDelete("layout/{layoutLevelId}")]
[ProducesResponseType(204)]
[ProducesResponseType(404)]
public async Task<IActionResult> DeleteLayoutImage(Guid layoutLevelId)
{
try
{
var deleted = await _imageStorageService.DeleteImageAsync(layoutLevelId);
if (!deleted)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning("Image not found for deletion: {LevelId}", layoutLevelId);
return NotFound(CreateErrorResponse($"Image not found for layout level '{layoutLevelId}'", "IMAGE_NOT_FOUND"));
}
return NoContent();
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Error deleting image for layout level {LevelId}", layoutLevelId);
return StatusCode(500, CreateErrorResponse("Internal server error while deleting image", "INTERNAL_ERROR"));
}
}
private static ErrorResponseDto CreateErrorResponse(
string error,
string? errorCode = null,
Dictionary<string, object>? details = null)
{
return new ErrorResponseDto
{
Error = error,
ErrorCode = errorCode,
Details = details
};
}
}

View File

@@ -0,0 +1,171 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using RobotNet10.MapEditor.Shared.DTOs.LayoutData;
using RobotNet10.MapEditor.Shared.DTOs.Requests;
using RobotNet10.MapEditor.Shared.DTOs.Responses;
using RobotNet10.MapManager.Services;
namespace RobotNet10.MapManager.Controllers;
/// <summary>
/// Controller for retrieving complete layout data and merge/split operations
/// </summary>
[ApiController]
[Route("api/layout-data")]
[Authorize]
public class LayoutDataController(
ILayoutDataService layoutDataService,
ILogger<LayoutDataController> logger) : ControllerBase
{
private readonly ILayoutDataService _layoutDataService = layoutDataService;
private readonly ILogger<LayoutDataController> _logger = logger;
/// <summary>
/// Get complete layout data for a layout level
/// Returns all nodes, edges, and stations with full nested properties
/// </summary>
/// <param name="layoutLevelId">Layout level ID</param>
/// <returns>Complete layout data</returns>
[HttpGet("{layoutLevelId}")]
[ProducesResponseType(typeof(LayoutDataDto), 200)]
public async Task<ActionResult<LayoutDataDto>> GetLayoutData(Guid layoutLevelId)
{
try
{
var data = await _layoutDataService.GetLayoutDataAsync(layoutLevelId);
return Ok(data);
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Error retrieving layout data for level {LevelId}", layoutLevelId);
return StatusCode(500, CreateErrorResponse("Internal server error while retrieving layout data", "INTERNAL_ERROR"));
}
}
/// <summary>
/// Merge multiple nodes into one node at center position
/// </summary>
/// <param name="request">Merge nodes request</param>
/// <returns>Merge result with merged node, updated edges, and deleted node IDs</returns>
[HttpPost("merge-nodes")]
[ProducesResponseType(typeof(MergeNodesResponse), 200)]
[ProducesResponseType(400)]
public async Task<ActionResult<MergeNodesResponse>> MergeNodes([FromBody] MergeNodesRequest request)
{
try
{
var result = await _layoutDataService.MergeNodesAsync(request);
return Ok(result);
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Failed to merge nodes");
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error merging nodes");
return StatusCode(500, CreateErrorResponse("Internal server error while merging nodes", "INTERNAL_ERROR"));
}
}
/// <summary>
/// Split a node into multiple nodes (one for each connected edge)
/// </summary>
/// <param name="request">Split node request</param>
/// <returns>Split result with new nodes, updated edges, and deleted node ID</returns>
[HttpPost("split-node")]
[ProducesResponseType(typeof(SplitNodeResponse), 200)]
[ProducesResponseType(400)]
public async Task<ActionResult<SplitNodeResponse>> SplitNode([FromBody] SplitNodeRequest request)
{
try
{
var result = await _layoutDataService.SplitNodeAsync(request);
return Ok(result);
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Failed to split node");
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error splitting node");
return StatusCode(500, CreateErrorResponse("Internal server error while splitting node", "INTERNAL_ERROR"));
}
}
/// <summary>
/// Save all layout changes (nodes and edges) in a batch operation
/// Uses transaction to ensure atomicity
/// </summary>
/// <param name="request">Save request with nodes and edges to update</param>
/// <returns>Save result with counts and any skipped items</returns>
[HttpPost("save")]
[ProducesResponseType(typeof(SaveLayoutDataResponse), 200)]
[ProducesResponseType(400)]
public async Task<ActionResult<SaveLayoutDataResponse>> SaveLayoutData([FromBody] SaveLayoutDataRequest request)
{
try
{
var result = await _layoutDataService.SaveLayoutDataAsync(request);
if (!result.Success)
{
return BadRequest(result);
}
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving layout data");
return StatusCode(500, CreateErrorResponse("Internal server error while saving layout data", "INTERNAL_ERROR"));
}
}
/// <summary>
/// Copy selected nodes and edges with an offset
/// Creates new nodes and edges at offset positions
/// </summary>
/// <param name="request">Copy request with node IDs, edge IDs, and offset</param>
/// <returns>Copy result with newly created nodes and edges</returns>
[HttpPost("copy-nodes")]
[ProducesResponseType(typeof(CopyNodesResponse), 200)]
[ProducesResponseType(400)]
public async Task<ActionResult<CopyNodesResponse>> CopyNodes([FromBody] CopyNodesRequest request)
{
try
{
var result = await _layoutDataService.CopyNodesAsync(request);
if (!result.Success)
{
return BadRequest(result);
}
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error copying nodes");
return StatusCode(500, CreateErrorResponse("Internal server error while copying nodes", "INTERNAL_ERROR"));
}
}
private static ErrorResponseDto CreateErrorResponse(
string error,
string? errorCode = null,
Dictionary<string, object>? details = null)
{
return new ErrorResponseDto
{
Error = error,
ErrorCode = errorCode,
Details = details
};
}
}

View File

@@ -0,0 +1,673 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using RobotNet10.MapEditor.Shared.DTOs.Layout;
using RobotNet10.MapEditor.Shared.DTOs.Requests;
using RobotNet10.MapEditor.Shared.DTOs.Responses;
using RobotNet10.MapEditor.Shared.Models;
using RobotNet10.MapManager.Services;
namespace RobotNet10.MapManager.Controllers;
/// <summary>
/// Controller for managing layouts, versions, and levels
/// </summary>
[ApiController]
[Route("api/layouts")]
[Authorize]
public class LayoutManagerController(
ILayoutService layoutService,
IImageStorageService imageStorageService,
ILogger<LayoutManagerController> logger) : ControllerBase
{
private readonly ILayoutService _layoutService = layoutService;
private readonly IImageStorageService _imageStorageService = imageStorageService;
private readonly ILogger<LayoutManagerController> _logger = logger;
// ==========================================
// LAYOUT OPERATIONS
// ==========================================
/// <summary>
/// Create a new layout
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(LayoutDto), 201)]
[ProducesResponseType(400)]
public async Task<ActionResult<LayoutDto>> CreateLayout([FromBody] CreateLayoutRequest request)
{
try
{
var layout = await _layoutService.CreateLayoutAsync(request);
var dto = MapLayoutToDto(layout);
return CreatedAtAction(
nameof(GetLayout),
new { layoutId = layout.Id },
dto);
}
catch (InvalidOperationException ex)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to create layout: {LayoutId}", request.LayoutId);
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
}
}
/// <summary>
/// Search layouts by text
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(List<LayoutDto>), 200)]
public async Task<ActionResult<List<LayoutDto>>> SearchLayouts([FromQuery] string? search)
{
var layouts = await _layoutService.SearchLayoutsAsync(search);
var dtos = layouts.Select(MapLayoutToDto).ToList();
return Ok(dtos);
}
/// <summary>
/// Get layout by database ID
/// </summary>
[HttpGet("{layoutId:guid}")]
[ProducesResponseType(typeof(LayoutDto), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<LayoutDto>> GetLayout(Guid layoutId)
{
var layout = await _layoutService.GetLayoutByIdAsync(layoutId);
if (layout == null)
{
return NotFound(CreateErrorResponse($"Layout with ID '{layoutId}' not found", "LAYOUT_NOT_FOUND"));
}
return Ok(MapLayoutToDto(layout));
}
/// <summary>
/// Get layout by layout ID string
/// </summary>
[HttpGet("by-id/{layoutId}")]
[ProducesResponseType(typeof(LayoutDto), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<LayoutDto>> GetLayoutByLayoutId(string layoutId)
{
var layout = await _layoutService.GetLayoutByLayoutIdAsync(layoutId);
if (layout == null)
{
return NotFound(CreateErrorResponse($"Layout with ID '{layoutId}' not found", "LAYOUT_NOT_FOUND"));
}
return Ok(MapLayoutToDto(layout));
}
/// <summary>
/// Get layout by name
/// </summary>
[HttpGet("by-name/{layoutName}")]
[ProducesResponseType(typeof(LayoutDto), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<LayoutDto>> GetLayoutByName(string layoutName)
{
var layout = await _layoutService.GetLayoutByNameAsync(layoutName);
if (layout == null)
{
return NotFound(CreateErrorResponse($"Layout with name '{layoutName}' not found", "LAYOUT_NOT_FOUND"));
}
return Ok(MapLayoutToDto(layout));
}
/// <summary>
/// Update layout
/// </summary>
[HttpPut("{layoutId:guid}")]
[ProducesResponseType(typeof(LayoutDto), 200)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<LayoutDto>> UpdateLayout(
Guid layoutId,
[FromBody] UpdateLayoutRequest request)
{
try
{
var layout = await _layoutService.UpdateLayoutAsync(layoutId, request);
return Ok(MapLayoutToDto(layout));
}
catch (InvalidOperationException ex)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to update layout: {LayoutId}", layoutId);
if (ex.Message.Contains("not found"))
return NotFound(CreateErrorResponse(ex.Message, "LAYOUT_NOT_FOUND"));
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
}
}
/// <summary>
/// Delete layout (must be deactivated first)
/// Hard delete with cascade
/// </summary>
[HttpDelete("{layoutId:guid}")]
[ProducesResponseType(204)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<IActionResult> DeleteLayout(Guid layoutId)
{
try
{
var deleted = await _layoutService.DeleteLayoutAsync(layoutId);
if (!deleted)
{
return NotFound(CreateErrorResponse($"Layout with ID '{layoutId}' not found", "LAYOUT_NOT_FOUND"));
}
return NoContent();
}
catch (InvalidOperationException ex)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to delete layout: {LayoutId}", layoutId);
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
}
}
/// <summary>
/// Activate layout
/// </summary>
[HttpPost("{layoutId:guid}/activate")]
[ProducesResponseType(typeof(LayoutDto), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<LayoutDto>> ActivateLayout(Guid layoutId)
{
try
{
var layout = await _layoutService.ActivateLayoutAsync(layoutId);
return Ok(MapLayoutToDto(layout));
}
catch (InvalidOperationException ex)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to activate layout: {LayoutId}", layoutId);
return NotFound(CreateErrorResponse(ex.Message, "LAYOUT_NOT_FOUND"));
}
}
/// <summary>
/// Deactivate layout
/// </summary>
[HttpPost("{layoutId:guid}/deactivate")]
[ProducesResponseType(typeof(LayoutDto), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<LayoutDto>> DeactivateLayout(Guid layoutId)
{
try
{
var layout = await _layoutService.DeactivateLayoutAsync(layoutId);
return Ok(MapLayoutToDto(layout));
}
catch (InvalidOperationException ex)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to deactivate layout: {LayoutId}", layoutId);
return NotFound(CreateErrorResponse(ex.Message, "LAYOUT_NOT_FOUND"));
}
}
// ==========================================
// VERSION OPERATIONS
// ==========================================
/// <summary>
/// Create new version for a layout
/// </summary>
[HttpPost("{layoutId:guid}/versions")]
[ProducesResponseType(typeof(LayoutVersionDto), 201)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<LayoutVersionDto>> CreateVersion(
Guid layoutId,
[FromBody] CreateLayoutVersionRequest request)
{
try
{
var version = await _layoutService.CreateVersionAsync(layoutId, request);
var dto = MapVersionToDto(version);
return CreatedAtAction(
nameof(GetVersion),
new { versionId = version.Id },
dto);
}
catch (InvalidOperationException ex)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to create version for layout: {LayoutId}", layoutId);
if (ex.Message.Contains("not found"))
return NotFound(CreateErrorResponse(ex.Message, "LAYOUT_NOT_FOUND"));
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
}
}
/// <summary>
/// Get all versions for a layout
/// </summary>
[HttpGet("{layoutId:guid}/versions")]
[ProducesResponseType(typeof(List<LayoutVersionDto>), 200)]
public async Task<ActionResult<List<LayoutVersionDto>>> GetVersions(Guid layoutId)
{
var versions = await _layoutService.GetVersionsAsync(layoutId);
var dtos = versions.Select(MapVersionToDto).ToList();
return Ok(dtos);
}
/// <summary>
/// Get version by ID
/// </summary>
[HttpGet("versions/{versionId:guid}")]
[ProducesResponseType(typeof(LayoutVersionDto), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<LayoutVersionDto>> GetVersion(Guid versionId)
{
var version = await _layoutService.GetVersionAsync(versionId);
if (version == null)
{
return NotFound(CreateErrorResponse($"Version with ID '{versionId}' not found", "VERSION_NOT_FOUND"));
}
return Ok(MapVersionToDto(version));
}
/// <summary>
/// Update version
/// </summary>
[HttpPut("versions/{versionId:guid}")]
[ProducesResponseType(typeof(LayoutVersionDto), 200)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<LayoutVersionDto>> UpdateVersion(
Guid versionId,
[FromBody] UpdateLayoutRequest request)
{
try
{
var version = await _layoutService.UpdateVersionAsync(versionId, request);
return Ok(MapVersionToDto(version));
}
catch (InvalidOperationException ex)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to update version: {VersionId}", versionId);
if (ex.Message.Contains("not found"))
return NotFound(CreateErrorResponse(ex.Message, "VERSION_NOT_FOUND"));
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
}
}
/// <summary>
/// Delete version (layout must be deactivated first)
/// </summary>
[HttpDelete("versions/{versionId:guid}")]
[ProducesResponseType(204)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<IActionResult> DeleteVersion(Guid versionId)
{
try
{
var deleted = await _layoutService.DeleteVersionAsync(versionId);
if (!deleted)
{
return NotFound(CreateErrorResponse($"Version with ID '{versionId}' not found", "VERSION_NOT_FOUND"));
}
return NoContent();
}
catch (InvalidOperationException ex)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to delete version: {VersionId}", versionId);
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
}
}
// ==========================================
// LEVEL OPERATIONS
// ==========================================
/// <summary>
/// Create new level for a version
/// </summary>
[HttpPost("versions/{versionId:guid}/levels")]
[ProducesResponseType(typeof(LayoutLevelDto), 201)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<LayoutLevelDto>> CreateLevel(
Guid versionId,
[FromBody] CreateLayoutLevelRequest request)
{
try
{
var level = await _layoutService.CreateLevelAsync(versionId, request);
var dto = MapLevelToDto(level);
return CreatedAtAction(
nameof(GetLevel),
new { levelId = level.Id },
dto);
}
catch (InvalidOperationException ex)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to create level for version: {VersionId}", versionId);
if (ex.Message.Contains("not found"))
return NotFound(CreateErrorResponse(ex.Message, "VERSION_NOT_FOUND"));
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
}
}
/// <summary>
/// Create a new layout level with background image in a single request
/// </summary>
/// <param name="versionId">Version ID</param>
/// <param name="layoutLevelId">Layout level identifier string</param>
/// <param name="levelOrder">Level order</param>
/// <param name="resolution">Resolution in meters per pixel</param>
/// <param name="originX">Origin X coordinate in meters</param>
/// <param name="originY">Origin Y coordinate in meters</param>
/// <param name="file">Background image file (PNG format, required)</param>
/// <returns>Created level with image metadata</returns>
[HttpPost("versions/{versionId:guid}/levels/with-image")]
[ProducesResponseType(typeof(LayoutLevelDto), 201)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<LayoutLevelDto>> CreateLevelWithImage(
Guid versionId,
[FromForm] string layoutLevelId,
[FromForm] int levelOrder,
[FromForm] double resolution,
[FromForm] double originX,
[FromForm] double originY,
IFormFile file)
{
// Validate file
if (file == null || file.Length == 0)
{
return BadRequest(CreateErrorResponse("Image file is required", "VALIDATION_ERROR"));
}
// Validate file type
if (!file.ContentType.Equals("image/png", StringComparison.OrdinalIgnoreCase))
{
return BadRequest(CreateErrorResponse("Only PNG images are supported", "VALIDATION_ERROR"));
}
// Validate file size (max 10MB)
const long maxFileSize = 10 * 1024 * 1024;
if (file.Length > maxFileSize)
{
return BadRequest(CreateErrorResponse($"File size exceeds maximum of {maxFileSize / 1024 / 1024}MB", "VALIDATION_ERROR"));
}
try
{
// Step 1: Extract image dimensions
int imageWidth, imageHeight;
using (var stream = file.OpenReadStream())
{
(imageWidth, imageHeight) = await _imageStorageService.GetImageDimensionsAsync(stream);
}
// Step 2: Create level with complete coordinate system info
var request = new CreateLayoutLevelRequest
{
LayoutLevelId = layoutLevelId,
LevelOrder = levelOrder,
CoordinateSystem = new CoordinateSystemInfo
{
Resolution = resolution,
OriginX = originX,
OriginY = originY,
ImageWidth = imageWidth,
ImageHeight = imageHeight,
// Calculate bounds based on image size
BoundsMinX = originX,
BoundsMaxX = imageWidth * resolution + originX,
BoundsMinY = originY,
BoundsMaxY = imageHeight * resolution + originY
}
};
var level = await _layoutService.CreateLevelAsync(versionId, request);
// Step 3: Upload image
try
{
using var stream = file.OpenReadStream();
await _imageStorageService.SaveImageAsync(level.Id, stream);
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Failed to save image after creating level {LevelId}, attempting rollback", level.Id);
// Attempt to delete the created level to maintain consistency
try
{
await _layoutService.DeleteLevelAsync(level.Id);
}
catch (Exception rollbackEx)
{
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(rollbackEx, "Failed to rollback level creation for {LevelId}", level.Id);
}
return StatusCode(500, CreateErrorResponse("Failed to save image. Level creation was rolled back.", "INTERNAL_ERROR"));
}
var dto = MapLevelToDto(level);
return CreatedAtAction(
nameof(GetLevel),
new { levelId = level.Id },
dto);
}
catch (InvalidOperationException ex)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to create level with image for version: {VersionId}", versionId);
if (ex.Message.Contains("not found"))
return NotFound(CreateErrorResponse(ex.Message, "VERSION_NOT_FOUND"));
if (ex.Message.Contains("Invalid image") || ex.Message.Contains("corrupted"))
return BadRequest(CreateErrorResponse("Invalid or corrupted image file", "VALIDATION_ERROR"));
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Unexpected error creating level with image for version {VersionId}", versionId);
return StatusCode(500, CreateErrorResponse("Internal server error while creating level with image", "INTERNAL_ERROR"));
}
}
/// <summary>
/// Get all levels for a version
/// </summary>
[HttpGet("versions/{versionId:guid}/levels")]
[ProducesResponseType(typeof(List<LayoutLevelDto>), 200)]
public async Task<ActionResult<List<LayoutLevelDto>>> GetLevels(Guid versionId)
{
var levels = await _layoutService.GetLevelsAsync(versionId);
var dtos = levels.Select(MapLevelToDto).ToList();
return Ok(dtos);
}
/// <summary>
/// Get level by ID
/// </summary>
[HttpGet("levels/{levelId:guid}")]
[ProducesResponseType(typeof(LayoutLevelDto), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<LayoutLevelDto>> GetLevel(Guid levelId)
{
var level = await _layoutService.GetLevelAsync(levelId);
if (level == null)
{
return NotFound(CreateErrorResponse($"Level with ID '{levelId}' not found", "LEVEL_NOT_FOUND"));
}
return Ok(MapLevelToDto(level));
}
/// <summary>
/// Update level
/// </summary>
[HttpPut("levels/{levelId:guid}")]
[ProducesResponseType(typeof(LayoutLevelDto), 200)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<LayoutLevelDto>> UpdateLevel(
Guid levelId,
[FromBody] UpdateLayoutLevelRequest request)
{
try
{
var level = await _layoutService.UpdateLevelAsync(levelId, request);
return Ok(MapLevelToDto(level));
}
catch (InvalidOperationException ex)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to update level: {LevelId}", levelId);
if (ex.Message.Contains("not found"))
return NotFound(CreateErrorResponse(ex.Message, "LEVEL_NOT_FOUND"));
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
}
}
/// <summary>
/// Delete level (layout must be deactivated first)
/// </summary>
[HttpDelete("levels/{levelId:guid}")]
[ProducesResponseType(204)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<IActionResult> DeleteLevel(Guid levelId)
{
try
{
var deleted = await _layoutService.DeleteLevelAsync(levelId);
if (!deleted)
{
return NotFound(CreateErrorResponse($"Level with ID '{levelId}' not found", "LEVEL_NOT_FOUND"));
}
return NoContent();
}
catch (InvalidOperationException ex)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to delete level: {LevelId}", levelId);
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
}
}
// ==========================================
// HELPER METHODS
// ==========================================
private static LayoutDto MapLayoutToDto(Data.Layout layout)
{
return new LayoutDto
{
Id = layout.Id,
LayoutId = layout.LayoutId,
LayoutName = layout.LayoutName,
Description = layout.Description,
IsActive = layout.IsActive,
CreatedDate = layout.CreatedDate,
ModifiedDate = layout.ModifiedDate,
CreatedBy = layout.CreatedBy,
ModifiedBy = layout.ModifiedBy,
Versions = layout.Versions?.Select(MapVersionToDto).ToList()
};
}
private static LayoutVersionDto MapVersionToDto(Data.LayoutVersion version)
{
return new LayoutVersionDto
{
Id = version.Id,
LayoutId = version.LayoutId,
Version = version.Version,
LayoutDescription = version.LayoutDescription,
CreatedBy = version.CreatedBy,
CreatedDate = version.CreatedDate,
IsActive = version.IsActive,
Levels = version.Levels?.Select(MapLevelToDto).ToList()
};
}
private static LayoutLevelDto MapLevelToDto(Data.LayoutLevel level)
{
return new LayoutLevelDto
{
Id = level.Id,
VersionId = level.VersionId,
LayoutLevelId = level.LayoutLevelId,
LevelOrder = level.LevelOrder,
EditorSettings = level.EditorSettings != null ? MapEditorSettingsToDto(level.EditorSettings) : null
};
}
private static LayoutLevelEditorSettingsDto MapEditorSettingsToDto(Data.LayoutLevelEditorSettings settings)
{
return new LayoutLevelEditorSettingsDto
{
Id = settings.Id,
LevelId = settings.LevelId,
EdgeMinLengthCreate = settings.EdgeMinLengthCreate,
EdgeNameAutoGenerate = settings.EdgeNameAutoGenerate,
NodeNameAutoGenerate = settings.NodeNameAutoGenerate,
NodeProximityRadius = settings.NodeProximityRadius,
OriginX = settings.OriginX,
OriginY = settings.OriginY,
Resolution = settings.Resolution,
BoundsMinX = settings.BoundsMinX,
BoundsMaxX = settings.BoundsMaxX,
BoundsMinY = settings.BoundsMinY,
BoundsMaxY = settings.BoundsMaxY,
ImageWidth = settings.ImageWidth,
ImageHeight = settings.ImageHeight,
CreatedDate = settings.CreatedDate,
ModifiedDate = settings.ModifiedDate
};
}
private static ErrorResponseDto CreateErrorResponse(
string error,
string? errorCode = null,
Dictionary<string, object>? details = null)
{
return new ErrorResponseDto
{
Error = error,
ErrorCode = errorCode,
Details = details
};
}
}

View File

@@ -0,0 +1,129 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using RobotNet10.MapEditor.Shared.DTOs.Node;
using RobotNet10.MapEditor.Shared.DTOs.Requests;
using RobotNet10.MapEditor.Shared.DTOs.Responses;
using RobotNet10.MapManager.Services;
namespace RobotNet10.MapManager.Controllers;
/// <summary>
/// Controller for managing nodes
/// </summary>
[ApiController]
[Route("api/nodes")]
[Authorize]
public class NodesController(
INodeService nodeService,
ILogger<NodesController> logger) : ControllerBase
{
private readonly INodeService _nodeService = nodeService;
private readonly ILogger<NodesController> _logger = logger;
/// <summary>
/// Get all nodes for a layout level
/// </summary>
/// <param name="layoutLevelId">Layout level ID</param>
/// <returns>List of nodes with vehicle properties</returns>
[HttpGet("level/{layoutLevelId}")]
[ProducesResponseType(typeof(List<NodeDto>), 200)]
public async Task<ActionResult<List<NodeDto>>> GetNodesByLevel(Guid layoutLevelId)
{
var nodes = await _nodeService.GetNodesByLevelAsync(layoutLevelId, includeVehicleProperties: true);
var dtos = nodes.Select(MapToDto).ToList();
return Ok(dtos);
}
/// <summary>
/// Get node by ID
/// </summary>
/// <param name="nodeId">Node database ID</param>
/// <returns>Node details with vehicle properties</returns>
[HttpGet("{nodeId}")]
[ProducesResponseType(typeof(NodeDto), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<NodeDto>> GetNode(Guid nodeId)
{
var node = await _nodeService.GetByIdAsync(nodeId, includeVehicleProperties: true);
if (node == null)
{
return NotFound(CreateErrorResponse($"Node with ID '{nodeId}' not found", "NODE_NOT_FOUND"));
}
return Ok(MapToDto(node));
}
/// <summary>
/// Update node
/// </summary>
/// <param name="nodeId">Node database ID</param>
/// <param name="request">Update request</param>
/// <returns>Updated node</returns>
[HttpPut("{nodeId}")]
[ProducesResponseType(typeof(NodeDto), 200)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<NodeDto>> UpdateNode(
Guid nodeId,
[FromBody] UpdateNodeRequest request)
{
try
{
var node = await _nodeService.UpdateAsync(nodeId, request);
return Ok(MapToDto(node));
}
catch (InvalidOperationException ex)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to update node: {NodeId}", nodeId);
if (ex.Message.Contains("not found"))
return NotFound(CreateErrorResponse(ex.Message, "NODE_NOT_FOUND"));
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
}
}
// Helper method to map entity to DTO
private static NodeDto MapToDto(Data.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 ErrorResponseDto CreateErrorResponse(
string error,
string? errorCode = null,
Dictionary<string, object>? details = null)
{
return new ErrorResponseDto
{
Error = error,
ErrorCode = errorCode,
Details = details
};
}
}

View File

@@ -0,0 +1,189 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
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.Services;
namespace RobotNet10.MapManager.Controllers;
/// <summary>
/// Controller for managing stations
/// </summary>
[ApiController]
[Route("api/stations")]
[Authorize]
public class StationsController(
IStationService stationService,
ILogger<StationsController> logger) : ControllerBase
{
private readonly IStationService _stationService = stationService;
private readonly ILogger<StationsController> _logger = logger;
/// <summary>
/// Create a new station
/// </summary>
/// <param name="request">Station creation request</param>
/// <returns>Created station</returns>
[HttpPost]
[ProducesResponseType(typeof(StationDto), 201)]
[ProducesResponseType(400)]
public async Task<ActionResult<StationDto>> CreateStation([FromBody] CreateStationRequest request)
{
try
{
var station = await _stationService.CreateAsync(request);
var dto = MapToDto(station);
return CreatedAtAction(
nameof(GetStation),
new { stationId = station.Id },
dto);
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Failed to create station: {StationId}", request.StationId);
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
}
}
/// <summary>
/// Get all stations for a layout level
/// </summary>
/// <param name="layoutLevelId">Layout level ID</param>
/// <returns>List of stations with interaction nodes</returns>
[HttpGet("level/{layoutLevelId}")]
[ProducesResponseType(typeof(List<StationDto>), 200)]
public async Task<ActionResult<List<StationDto>>> GetStationsByLevel(Guid layoutLevelId)
{
var stations = await _stationService.GetStationsByLevelAsync(layoutLevelId, includeInteractionNodes: true);
var dtos = stations.Select(MapToDto).ToList();
return Ok(dtos);
}
/// <summary>
/// Get station by ID
/// </summary>
/// <param name="stationId">Station database ID</param>
/// <returns>Station details with interaction nodes</returns>
[HttpGet("{stationId}")]
[ProducesResponseType(typeof(StationDto), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<StationDto>> GetStation(Guid stationId)
{
var station = await _stationService.GetByIdAsync(stationId, includeInteractionNodes: true);
if (station == null)
{
return NotFound(CreateErrorResponse($"Station with ID '{stationId}' not found", "STATION_NOT_FOUND"));
}
return Ok(MapToDto(station));
}
/// <summary>
/// Update station
/// </summary>
/// <param name="stationId">Station database ID</param>
/// <param name="request">Update request</param>
/// <returns>Updated station</returns>
[HttpPut("{stationId}")]
[ProducesResponseType(typeof(StationDto), 200)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<StationDto>> UpdateStation(
Guid stationId,
[FromBody] UpdateStationRequest request)
{
try
{
var station = await _stationService.UpdateAsync(stationId, request);
return Ok(MapToDto(station));
}
catch (InvalidOperationException ex)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to update station: {StationId}", stationId);
if (ex.Message.Contains("not found"))
return NotFound(CreateErrorResponse(ex.Message, "STATION_NOT_FOUND"));
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
}
}
/// <summary>
/// Delete station
/// Deletes station and cascade deletes interaction nodes (but NOT the linked nodes)
/// </summary>
/// <param name="stationId">Station database ID</param>
/// <returns>No content on success</returns>
[HttpDelete("{stationId}")]
[ProducesResponseType(204)]
[ProducesResponseType(404)]
public async Task<IActionResult> DeleteStation(Guid stationId)
{
var deleted = await _stationService.DeleteAsync(stationId);
if (!deleted)
{
return NotFound(CreateErrorResponse($"Station with ID '{stationId}' not found", "STATION_NOT_FOUND"));
}
return NoContent();
}
// Helper method to map entity to DTO
private static StationDto MapToDto(Data.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 ? new NodeDto
{
Id = sin.Node.Id,
NodeId = sin.Node.NodeId,
NodeName = sin.Node.NodeName,
X = sin.Node.X,
Y = sin.Node.Y,
VehicleProperties = sin.Node.VehicleProperties?.Select(vp => new NodeVehiclePropertyDto
{
Id = vp.Id,
VehicleTypeId = vp.VehicleTypeId,
Theta = vp.Theta,
Actions = vp.Actions
}).ToList()
} : null
}).ToList()
};
}
private static ErrorResponseDto CreateErrorResponse(
string error,
string? errorCode = null,
Dictionary<string, object>? details = null)
{
return new ErrorResponseDto
{
Error = error,
ErrorCode = errorCode,
Details = details
};
}
}

View File

@@ -0,0 +1,306 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using RobotNet10.MapEditor.Shared.DTOs.VehicleType;
using RobotNet10.MapEditor.Shared.DTOs.Requests;
using RobotNet10.MapEditor.Shared.DTOs.Responses;
using RobotNet10.MapManager.Services;
namespace RobotNet10.MapManager.Controllers;
/// <summary>
/// Controller for managing vehicle types
/// </summary>
[ApiController]
[Route("api/vehicles")]
[Authorize]
public class VehiclesManagerController(
IVehicleTypeService vehicleTypeService,
ILogger<VehiclesManagerController> logger) : ControllerBase
{
private readonly IVehicleTypeService _vehicleTypeService = vehicleTypeService;
private readonly ILogger<VehiclesManagerController> _logger = logger;
/// <summary>
/// Create a new vehicle type
/// </summary>
/// <param name="request">Vehicle type creation request</param>
/// <returns>Created vehicle type</returns>
[HttpPost]
[ProducesResponseType(typeof(VehicleTypeDto), 201)]
[ProducesResponseType(400)]
public async Task<ActionResult<VehicleTypeDto>> CreateVehicleType([FromBody] CreateVehicleTypeRequest request)
{
try
{
var vehicleType = await _vehicleTypeService.CreateAsync(
request.VehicleTypeId,
request.VehicleTypeName,
request.Description,
request.Specifications,
request.Actions);
var dto = MapToDto(vehicleType);
return CreatedAtAction(
nameof(GetVehicleType),
new { vehicleTypeId = vehicleType.Id },
dto);
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Failed to create vehicle type: {VehicleTypeId}", request.VehicleTypeId);
var errorCode = ex.Message.Contains("already exists")
? "VEHICLE_TYPE_ALREADY_EXISTS"
: "VALIDATION_ERROR";
return BadRequest(CreateErrorResponse(ex.Message, errorCode));
}
}
/// <summary>
/// Get all vehicle types
/// </summary>
/// <param name="isActive">Optional filter by active status</param>
/// <returns>List of vehicle types</returns>
[HttpGet]
[ProducesResponseType(typeof(List<VehicleTypeDto>), 200)]
public async Task<ActionResult<List<VehicleTypeDto>>> GetAllVehicleTypes([FromQuery] bool? isActive)
{
List<Data.VehicleType> vehicleTypes;
if (isActive.HasValue)
{
vehicleTypes = await _vehicleTypeService.GetByActiveStatusAsync(isActive.Value);
}
else
{
vehicleTypes = await _vehicleTypeService.GetAllAsync();
}
var dtos = vehicleTypes.Select(MapToDto).ToList();
return Ok(dtos);
}
/// <summary>
/// Get vehicle type by database ID
/// </summary>
/// <param name="vehicleTypeId">Vehicle type database ID</param>
/// <returns>Vehicle type details</returns>
[HttpGet("{vehicleTypeId}")]
[ProducesResponseType(typeof(VehicleTypeDto), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<VehicleTypeDto>> GetVehicleType(Guid vehicleTypeId)
{
var vehicleType = await _vehicleTypeService.GetByIdAsync(vehicleTypeId);
if (vehicleType == null)
{
return NotFound(CreateErrorResponse(
$"Vehicle type with ID '{vehicleTypeId}' not found",
"VEHICLE_TYPE_NOT_FOUND"));
}
return Ok(MapToDto(vehicleType));
}
/// <summary>
/// Get vehicle type by VehicleTypeId string
/// </summary>
/// <param name="vehicleTypeId">Vehicle type identifier string (e.g., "AMR-T800")</param>
/// <returns>Vehicle type details</returns>
[HttpGet("vehicleTypeId/{vehicleTypeId}")]
[ProducesResponseType(typeof(VehicleTypeDto), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<VehicleTypeDto>> GetVehicleTypeByStringId(string vehicleTypeId)
{
var vehicleType = await _vehicleTypeService.GetByVehicleTypeIdAsync(vehicleTypeId);
if (vehicleType == null)
{
return NotFound(CreateErrorResponse(
$"Vehicle type with VehicleTypeId '{vehicleTypeId}' not found",
"VEHICLE_TYPE_NOT_FOUND"));
}
return Ok(MapToDto(vehicleType));
}
/// <summary>
/// Search vehicle types by query string
/// Searches in VehicleTypeId and VehicleTypeName (case-insensitive)
/// </summary>
/// <param name="query">Search query</param>
/// <returns>List of matching vehicle types</returns>
[HttpGet("search")]
[ProducesResponseType(typeof(List<VehicleTypeDto>), 200)]
public async Task<ActionResult<List<VehicleTypeDto>>> SearchVehicleTypes([FromQuery] string query)
{
if (string.IsNullOrWhiteSpace(query))
{
return BadRequest(CreateErrorResponse(
"Query parameter is required",
"VALIDATION_ERROR"));
}
var vehicleTypes = await _vehicleTypeService.SearchAsync(query);
var dtos = vehicleTypes.Select(MapToDto).ToList();
return Ok(dtos);
}
/// <summary>
/// Get usage information for a vehicle type
/// </summary>
/// <param name="id">Vehicle type database ID</param>
/// <returns>Usage information</returns>
[HttpGet("{id}/usage")]
[ProducesResponseType(typeof(VehicleTypeUsageInfoDto), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<VehicleTypeUsageInfoDto>> GetVehicleTypeUsage(Guid id)
{
try
{
var usageInfo = await _vehicleTypeService.GetUsageInfoAsync(id);
var dto = MapUsageInfoToDto(usageInfo);
return Ok(dto);
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Failed to get usage info for vehicle type: {Id}", id);
return NotFound(CreateErrorResponse(ex.Message, "VEHICLE_TYPE_NOT_FOUND"));
}
}
/// <summary>
/// Update vehicle type
/// </summary>
/// <param name="vehicleTypeId">Vehicle type database ID</param>
/// <param name="request">Update request</param>
/// <returns>Updated vehicle type</returns>
[HttpPut("{vehicleTypeId}")]
[ProducesResponseType(typeof(VehicleTypeDto), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<VehicleTypeDto>> UpdateVehicleType(
Guid vehicleTypeId,
[FromBody] UpdateVehicleTypeRequest request)
{
try
{
var vehicleType = await _vehicleTypeService.UpdateAsync(
vehicleTypeId,
request.VehicleTypeName,
request.Description,
request.Specifications,
request.Actions,
request.IsActive);
return Ok(MapToDto(vehicleType));
}
catch (InvalidOperationException ex)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to update vehicle type: {VehicleTypeId}", vehicleTypeId);
return NotFound(CreateErrorResponse(ex.Message, "VEHICLE_TYPE_NOT_FOUND"));
}
}
/// <summary>
/// Delete vehicle type
/// </summary>
/// <param name="vehicleTypeId">Vehicle type database ID</param>
/// <returns>No content on success</returns>
[HttpDelete("{vehicleTypeId}")]
[ProducesResponseType(204)]
[ProducesResponseType(404)]
[ProducesResponseType(400)]
public async Task<IActionResult> DeleteVehicleType(Guid vehicleTypeId)
{
try
{
var deleted = await _vehicleTypeService.DeleteAsync(vehicleTypeId);
if (!deleted)
{
return NotFound(CreateErrorResponse(
$"Vehicle type with ID '{vehicleTypeId}' not found",
"VEHICLE_TYPE_NOT_FOUND"));
}
return NoContent();
}
catch (InvalidOperationException ex)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to delete vehicle type: {VehicleTypeId}", vehicleTypeId);
var errorCode = ex.Message.Contains("referenced")
? "VEHICLE_TYPE_IN_USE"
: "VALIDATION_ERROR";
var details = new Dictionary<string, object>();
if (errorCode == "VEHICLE_TYPE_IN_USE")
{
// Try to get usage info for details
try
{
var usageInfo = await _vehicleTypeService.GetUsageInfoAsync(vehicleTypeId);
details["nodePropertiesCount"] = usageInfo.NodePropertiesCount;
details["edgePropertiesCount"] = usageInfo.EdgePropertiesCount;
details["totalUsageCount"] = usageInfo.TotalUsageCount;
}
catch
{
// Ignore if we can't get usage info
}
}
return BadRequest(CreateErrorResponse(ex.Message, errorCode, details));
}
}
// Helper method to map entity to DTO
private static VehicleTypeDto MapToDto(Data.VehicleType vehicleType)
{
return new VehicleTypeDto
{
Id = vehicleType.Id,
VehicleTypeId = vehicleType.VehicleTypeId,
VehicleTypeName = vehicleType.VehicleTypeName,
Description = vehicleType.Description,
Specifications = vehicleType.Specifications,
Actions = vehicleType.Actions,
IsActive = vehicleType.IsActive,
CreatedDate = vehicleType.CreatedDate
};
}
// Helper method to map usage info to DTO
private static VehicleTypeUsageInfoDto MapUsageInfoToDto(VehicleTypeUsageInfo usageInfo)
{
return new VehicleTypeUsageInfoDto
{
VehicleTypeId = usageInfo.VehicleTypeId,
VehicleTypeIdString = usageInfo.VehicleTypeIdString,
VehicleTypeName = usageInfo.VehicleTypeName,
NodePropertiesCount = usageInfo.NodePropertiesCount,
EdgePropertiesCount = usageInfo.EdgePropertiesCount,
TotalUsageCount = usageInfo.TotalUsageCount,
CanDelete = usageInfo.CanDelete
};
}
// Helper method to create error response
private static ErrorResponseDto CreateErrorResponse(
string error,
string? errorCode = null,
Dictionary<string, object>? details = null)
{
return new ErrorResponseDto
{
Error = error,
ErrorCode = errorCode,
Details = details
};
}
}

View File

@@ -0,0 +1,68 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RobotNet10.MapManager.Data;
/// <summary>
/// Edge entity - Paths between nodes
/// Maps to VDMA LIF: layouts[].edges[]
/// </summary>
[Table("Edges")]
public class Edge
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; }
/// <summary>
/// Parent level reference
/// </summary>
[Required]
public Guid LevelId { get; set; }
/// <summary>
/// Edge identifier (VDMA LIF: edgeId) - Required
/// Unique within level
/// </summary>
[Required]
[StringLength(128)]
public string EdgeId { get; set; } = string.Empty;
/// <summary>
/// Start node reference (VDMA LIF: startNodeId) - Required
/// </summary>
[Required]
public Guid StartNodeId { get; set; }
/// <summary>
/// End node reference (VDMA LIF: endNodeId) - Required
/// </summary>
[Required]
public Guid EndNodeId { get; set; }
/// <summary>
/// Edge name - Extension field (NOT in VDMA LIF)
/// For UI/display purposes only
/// </summary>
[StringLength(256)]
public string? EdgeName { get; set; }
/// <summary>
/// Edge description - Extension field (NOT in VDMA LIF)
/// For UI/display purposes only
/// </summary>
public string? EdgeDescription { get; set; }
// Navigation properties
[ForeignKey(nameof(LevelId))]
public virtual LayoutLevel Level { get; set; } = null!;
[ForeignKey(nameof(StartNodeId))]
public virtual Node StartNode { get; set; } = null!;
[ForeignKey(nameof(EndNodeId))]
public virtual Node EndNode { get; set; } = null!;
public virtual ICollection<EdgeVehicleProperty> VehicleProperties { get; set; } = new List<EdgeVehicleProperty>();
}

View File

@@ -0,0 +1,170 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using RobotNet.VDA5050.Type;
namespace RobotNet10.MapManager.Data;
/// <summary>
/// Edge vehicle property - Vehicle-specific properties for edges
/// Maps to VDMA LIF: edge.vehicleTypeEdgeProperties[]
/// </summary>
[Table("EdgeVehicleProperties")]
public class EdgeVehicleProperty
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; }
/// <summary>
/// Edge reference
/// </summary>
[Required]
public Guid EdgeId { get; set; }
/// <summary>
/// Vehicle type reference
/// </summary>
[Required]
public Guid VehicleTypeId { get; set; }
/// <summary>
/// Vehicle orientation while traversing edge (VDMA LIF: vehicleOrientation) - Optional
/// In degrees, range: [0.0 ... 360.0]
/// </summary>
public double? VehicleOrientation { get; set; }
/// <summary>
/// Type of orientation (VDMA LIF: orientationType) - Optional
/// Values: GLOBAL, TANGENTIAL
/// </summary>
public OrientationType? OrientationType { get; set; }
/// <summary>
/// Is rotation allowed while on edge (VDMA LIF: rotationAllowed) - Optional
/// </summary>
public bool? RotationAllowed { get; set; }
/// <summary>
/// Rotation allowed at start node (VDMA LIF: rotationAtStartNodeAllowed) - Optional
/// Values: NONE, CCW, CW, BOTH
/// </summary>
public RotationDirection? RotationAtStartNodeAllowed { get; set; }
/// <summary>
/// Rotation allowed at end node (VDMA LIF: rotationAtEndNodeAllowed) - Optional
/// Values: NONE, CCW, CW, BOTH
/// </summary>
public RotationDirection? RotationAtEndNodeAllowed { get; set; }
/// <summary>
/// Maximum speed allowed on edge (VDMA LIF: maxSpeed) - Optional
/// In meters per second, range: [0.0 ... double.MaxValue]
/// </summary>
public double? MaxSpeed { get; set; }
/// <summary>
/// Maximum rotation speed allowed (VDMA LIF: maxRotationSpeed) - Optional
/// In radians per second, range: [0.0 ... double.MaxValue]
/// </summary>
public double? MaxRotationSpeed { get; set; }
/// <summary>
/// Minimum height of vehicle on edge (VDMA LIF: minHeight) - Optional
/// In meters, range: [0.0 ... double.MaxValue]
/// </summary>
public double? MinHeight { get; set; }
/// <summary>
/// Maximum height of vehicle on edge (VDMA LIF: maxHeight) - Optional
/// In meters, range: [0.0 ... double.MaxValue]
/// </summary>
public double? MaxHeight { get; set; }
/// <summary>
/// Can edge be traversed without load (VDMA LIF: loadRestriction.unloaded) - Optional
/// </summary>
public bool? LoadRestriction_Unloaded { get; set; }
/// <summary>
/// Can edge be traversed with load (VDMA LIF: loadRestriction.loaded) - Optional
/// </summary>
public bool? LoadRestriction_Loaded { get; set; }
/// <summary>
/// Load set names allowed on edge (VDMA LIF: loadRestriction.loadSetNames) - Optional
/// JSON array of strings: ["pallet", "box"]
/// </summary>
public string? LoadRestriction_LoadSetNames { get; set; }
/// <summary>
/// Trajectory degree (NURBS curve degree) - Optional
/// Values: 1 (linear), 2 (quadratic), 3 (cubic)
/// </summary>
public int? TrajectoryDegree { get; set; }
/// <summary>
/// Trajectory control point 1 X coordinate (meters) - Optional
/// Used for degree 2 and 3 curves
/// </summary>
public double? TrajectoryControlPoint1X { get; set; }
/// <summary>
/// Trajectory control point 1 Y coordinate (meters) - Optional
/// Used for degree 2 and 3 curves
/// </summary>
public double? TrajectoryControlPoint1Y { get; set; }
/// <summary>
/// Trajectory control point 2 X coordinate (meters) - Optional
/// Used for degree 3 curves only
/// </summary>
public double? TrajectoryControlPoint2X { get; set; }
/// <summary>
/// Trajectory control point 2 Y coordinate (meters) - Optional
/// Used for degree 3 curves only
/// </summary>
public double? TrajectoryControlPoint2Y { get; set; }
/// <summary>
/// Actions that vehicle can perform at this edge (VDMA LIF: actions) - Optional
/// JSON array format:
/// [
/// {
/// "actionType": "pick",
/// "actionDescription": "...",
/// "requirementType": "REQUIRED",
/// "blockingType": "HARD",
/// "actionParameters": [{"key": "...", "value": "..."}]
/// }
/// ]
/// </summary>
public string? Actions { get; set; }
/// <summary>
/// Corridor left width (meters) - Optional
/// Defines the width of the corridor to the left related to the trajectory
/// </summary>
public double? CorridorLeftWidth { get; set; }
/// <summary>
/// Corridor right width (meters) - Optional
/// Defines the width of the corridor to the right related to the trajectory
/// </summary>
public double? CorridorRightWidth { get; set; }
/// <summary>
/// Corridor reference point (VDA5050: corridorRefPoint) - Optional
/// Defines whether the boundaries are valid for the kinematic center or the contour of the vehicle
/// Values: KINEMATICCENTER, CONTOUR
/// </summary>
public CorridorRefPoint? CorridorRefPoint { get; set; }
// Navigation properties
[ForeignKey(nameof(EdgeId))]
public virtual Edge Edge { get; set; } = null!;
[ForeignKey(nameof(VehicleTypeId))]
public virtual VehicleType VehicleType { get; set; } = null!;
}

View File

@@ -0,0 +1,69 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RobotNet10.MapManager.Data;
/// <summary>
/// Layout entity - Root level representing a Building/Facility
/// Maps to VDMA LIF: layouts[].layoutId
/// </summary>
[Table("Layouts")]
public class Layout
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; }
/// <summary>
/// Unique layout identifier (VDMA LIF: layoutId)
/// </summary>
[Required]
[StringLength(128)]
public string LayoutId { get; set; } = string.Empty;
/// <summary>
/// Layout name (VDMA LIF: layoutName)
/// </summary>
[Required]
[StringLength(256)]
public string LayoutName { get; set; } = string.Empty;
/// <summary>
/// Description
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Is layout currently active/operational
/// </summary>
[Required]
public bool IsActive { get; set; } = true;
/// <summary>
/// Created date
/// </summary>
[Required]
public DateTime CreatedDate { get; set; } = DateTime.UtcNow;
/// <summary>
/// Last modified date
/// </summary>
[Required]
public DateTime ModifiedDate { get; set; } = DateTime.UtcNow;
/// <summary>
/// Creator
/// </summary>
[StringLength(256)]
public string? CreatedBy { get; set; }
/// <summary>
/// Last modifier
/// </summary>
[StringLength(256)]
public string? ModifiedBy { get; set; }
// Navigation properties
public virtual ICollection<LayoutVersion> Versions { get; set; } = new List<LayoutVersion>();
}

View File

@@ -0,0 +1,51 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RobotNet10.MapManager.Data;
/// <summary>
/// Layout level entity - Represents floors/levels within a version
/// Maps to VDMA LIF: layouts[].layoutLevelId
/// </summary>
[Table("LayoutLevels")]
public class LayoutLevel
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; }
/// <summary>
/// Parent version reference
/// </summary>
[Required]
public Guid VersionId { get; set; }
/// <summary>
/// Level identifier (VDMA LIF: layoutLevelId)
/// Example: "floor_1", "floor_2", "basement"
/// </summary>
[Required]
[StringLength(64)]
public string LayoutLevelId { get; set; } = string.Empty;
/// <summary>
/// Display order (for sorting in UI)
/// Example: Basement=-1, Floor1=0, Floor2=1
/// </summary>
[Required]
public int LevelOrder { get; set; } = 0;
// Navigation properties
[ForeignKey(nameof(VersionId))]
public virtual LayoutVersion Version { get; set; } = null!;
public virtual ICollection<Node> Nodes { get; set; } = new List<Node>();
public virtual ICollection<Edge> Edges { get; set; } = new List<Edge>();
public virtual ICollection<Station> Stations { get; set; } = new List<Station>();
/// <summary>
/// Editor-specific settings (UI extensions, not part of VDMA LIF)
/// </summary>
public virtual LayoutLevelEditorSettings? EditorSettings { get; set; }
}

View File

@@ -0,0 +1,164 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RobotNet10.MapManager.Data;
/// <summary>
/// Editor-specific settings for each layout level
/// NOT part of VDMA LIF standard - UI/Editor extensions only
/// </summary>
[Table("LayoutLevelEditorSettings")]
public class LayoutLevelEditorSettings
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; }
/// <summary>
/// Parent layout level reference (1-to-1 relationship)
/// </summary>
[Required]
public Guid LevelId { get; set; }
// ==========================================
// EDGE SETTINGS
// ==========================================
/// <summary>
/// Minimum edge length (in METERS) required to create an edge
/// Prevents accidental creation of very short edges
/// Default: 0.1 meters (10 cm)
/// </summary>
[Required]
public double EdgeMinLengthCreate { get; set; } = 0.5;
/// <summary>
/// Auto-generate edge names when creating new edges
/// Uses 8-character GUID: "Edge_a7f2e3b1"
/// </summary>
[Required]
public bool EdgeNameAutoGenerate { get; set; } = false;
// ==========================================
// NODE SETTINGS
// ==========================================
/// <summary>
/// Auto-generate node names when creating new nodes
/// Uses 8-character GUID: "Node_a7f2e3b1"
/// </summary>
[Required]
public bool NodeNameAutoGenerate { get; set; } = true;
/// <summary>
/// Node proximity radius (in METERS) for edge creation
/// When creating an edge, if start/end point is within this radius of an existing node,
/// the edge will connect to that existing node instead of creating a new one
/// Default: 0.35 meters (35 cm)
/// </summary>
[Required]
public double NodeProximityRadius { get; set; } = 0.35;
// ==========================================
// COORDINATE SYSTEM SETTINGS
// ==========================================
/// <summary>
/// World coordinate origin X in METERS
/// Defines where the world coordinate system (0, 0) is located
/// Default: 0.0
/// </summary>
[Required]
public double OriginX { get; set; } = 0.0;
/// <summary>
/// World coordinate origin Y in METERS
/// Defines where the world coordinate system (0, 0) is located
/// Default: 0.0
/// </summary>
[Required]
public double OriginY { get; set; } = 0.0;
/// <summary>
/// Resolution: Meters per pixel
/// Conversion factor between world coordinates (meters) and image coordinates (pixels)
/// Example: 0.05 means 1 pixel = 5 cm in real world
/// Default: 0.05 (5 cm per pixel)
/// </summary>
[Required]
public double Resolution { get; set; } = 0.05;
// ==========================================
// COORDINATE BOUNDS (World Coordinates)
// ==========================================
/// <summary>
/// Minimum X boundary in METERS (world coordinates)
/// Defines the leftmost valid coordinate for this level
/// Optional: null means no limit
/// </summary>
public double? BoundsMinX { get; set; }
/// <summary>
/// Maximum X boundary in METERS (world coordinates)
/// Defines the rightmost valid coordinate for this level
/// Optional: null means no limit
/// </summary>
public double? BoundsMaxX { get; set; }
/// <summary>
/// Minimum Y boundary in METERS (world coordinates)
/// Defines the bottom valid coordinate for this level
/// Optional: null means no limit
/// </summary>
public double? BoundsMinY { get; set; }
/// <summary>
/// Maximum Y boundary in METERS (world coordinates)
/// Defines the top valid coordinate for this level
/// Optional: null means no limit
/// </summary>
public double? BoundsMaxY { get; set; }
// ==========================================
// BACKGROUND IMAGE SETTINGS
// ==========================================
/// <summary>
/// Background image width in PIXELS
/// Represents the actual pixel dimensions of the image file
/// Used for rendering and coordinate conversion
/// </summary>
public double? ImageWidth { get; set; }
/// <summary>
/// Background image height in PIXELS
/// Represents the actual pixel dimensions of the image file
/// Used for rendering and coordinate conversion
/// </summary>
public double? ImageHeight { get; set; }
// ==========================================
// METADATA
// ==========================================
/// <summary>
/// When these settings were created
/// </summary>
[Required]
public DateTime CreatedDate { get; set; } = DateTime.UtcNow;
/// <summary>
/// Last time these settings were modified
/// </summary>
[Required]
public DateTime ModifiedDate { get; set; } = DateTime.UtcNow;
// ==========================================
// NAVIGATION PROPERTIES
// ==========================================
[ForeignKey(nameof(LevelId))]
public virtual LayoutLevel Level { get; set; } = null!;
}

View File

@@ -0,0 +1,62 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RobotNet10.MapManager.Data;
/// <summary>
/// Layout version entity - Version history for each layout
/// Maps to VDMA LIF: layouts[].layoutVersion
/// </summary>
[Table("LayoutVersions")]
public class LayoutVersion
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; }
/// <summary>
/// Parent layout reference
/// </summary>
[Required]
public Guid LayoutId { get; set; }
/// <summary>
/// Version number (VDMA LIF: layoutVersion)
/// Suggested: "1", "2", "3" or "1.0", "1.1"
/// </summary>
[Required]
[StringLength(32)]
public string Version { get; set; } = string.Empty;
/// <summary>
/// Version description (VDMA LIF: layoutDescription)
/// </summary>
public string? LayoutDescription { get; set; }
/// <summary>
/// Creator of this version
/// </summary>
[StringLength(256)]
public string? CreatedBy { get; set; }
/// <summary>
/// Creation date
/// </summary>
[Required]
public DateTime CreatedDate { get; set; } = DateTime.UtcNow;
/// <summary>
/// Is this the active version?
/// Only ONE version can be active per layout
/// Active version is READ-ONLY
/// </summary>
[Required]
public bool IsActive { get; set; } = true;
// Navigation properties
[ForeignKey(nameof(LayoutId))]
public virtual Layout Layout { get; set; } = null!;
public virtual ICollection<LayoutLevel> Levels { get; set; } = new List<LayoutLevel>();
}

View File

@@ -0,0 +1,197 @@
using Microsoft.EntityFrameworkCore;
namespace RobotNet10.MapManager.Data;
/// <summary>
/// Database context for VDMA LIF 1.0.0 Map Manager
/// </summary>
public class MapDbContext(DbContextOptions<MapDbContext> options) : DbContext(options)
{
// DbSets
public DbSet<Layout> Layouts { get; set; } = null!;
public DbSet<LayoutVersion> LayoutVersions { get; set; } = null!;
public DbSet<LayoutLevel> LayoutLevels { get; set; } = null!;
public DbSet<VehicleType> VehicleTypes { get; set; } = null!;
public DbSet<Node> Nodes { get; set; } = null!;
public DbSet<Edge> Edges { get; set; } = null!;
public DbSet<Station> Stations { get; set; } = null!;
public DbSet<StationInteractionNode> StationInteractionNodes { get; set; } = null!;
public DbSet<NodeVehicleProperty> NodeVehicleProperties { get; set; } = null!;
public DbSet<EdgeVehicleProperty> EdgeVehicleProperties { get; set; } = null!;
public DbSet<LayoutLevelEditorSettings> LayoutLevelEditorSettings { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Layout configuration
modelBuilder.Entity<Layout>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.LayoutId).IsUnique();
entity.HasIndex(e => e.IsActive);
entity.HasMany(e => e.Versions)
.WithOne(e => e.Layout)
.HasForeignKey(e => e.LayoutId)
.OnDelete(DeleteBehavior.Cascade);
});
// LayoutVersion configuration
modelBuilder.Entity<LayoutVersion>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.LayoutId, e.Version }).IsUnique();
entity.HasIndex(e => e.IsActive);
entity.HasMany(e => e.Levels)
.WithOne(e => e.Version)
.HasForeignKey(e => e.VersionId)
.OnDelete(DeleteBehavior.Cascade);
});
// LayoutLevel configuration
modelBuilder.Entity<LayoutLevel>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.VersionId, e.LayoutLevelId }).IsUnique();
entity.HasIndex(e => e.LevelOrder);
entity.HasMany(e => e.Nodes)
.WithOne(e => e.Level)
.HasForeignKey(e => e.LevelId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasMany(e => e.Edges)
.WithOne(e => e.Level)
.HasForeignKey(e => e.LevelId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasMany(e => e.Stations)
.WithOne(e => e.Level)
.HasForeignKey(e => e.LevelId)
.OnDelete(DeleteBehavior.Cascade);
});
// VehicleType configuration
modelBuilder.Entity<VehicleType>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.VehicleTypeId).IsUnique();
entity.HasIndex(e => e.IsActive);
entity.HasMany(e => e.NodeVehicleProperties)
.WithOne(e => e.VehicleType)
.HasForeignKey(e => e.VehicleTypeId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasMany(e => e.EdgeVehicleProperties)
.WithOne(e => e.VehicleType)
.HasForeignKey(e => e.VehicleTypeId)
.OnDelete(DeleteBehavior.Cascade);
});
// Node configuration
modelBuilder.Entity<Node>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.LevelId, e.NodeId }).IsUnique();
entity.HasIndex(e => e.NodeId);
entity.HasIndex(e => e.MapId);
entity.HasIndex(e => new { e.X, e.Y });
entity.HasMany(e => e.VehicleProperties)
.WithOne(e => e.Node)
.HasForeignKey(e => e.NodeId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasMany(e => e.OutgoingEdges)
.WithOne(e => e.StartNode)
.HasForeignKey(e => e.StartNodeId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasMany(e => e.IncomingEdges)
.WithOne(e => e.EndNode)
.HasForeignKey(e => e.EndNodeId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasMany(e => e.StationInteractions)
.WithOne(e => e.Node)
.HasForeignKey(e => e.NodeId)
.OnDelete(DeleteBehavior.Restrict);
});
// Edge configuration
modelBuilder.Entity<Edge>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.LevelId, e.EdgeId }).IsUnique();
entity.HasIndex(e => e.EdgeId);
entity.HasIndex(e => e.StartNodeId);
entity.HasIndex(e => e.EndNodeId);
entity.ToTable(t => t.HasCheckConstraint("CK_Edges_DifferentNodes", "[StartNodeId] <> [EndNodeId]"));
entity.HasMany(e => e.VehicleProperties)
.WithOne(e => e.Edge)
.HasForeignKey(e => e.EdgeId)
.OnDelete(DeleteBehavior.Cascade);
});
// Station configuration
modelBuilder.Entity<Station>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.LevelId, e.StationId }).IsUnique();
entity.HasIndex(e => e.StationId);
entity.HasMany(e => e.InteractionNodes)
.WithOne(e => e.Station)
.HasForeignKey(e => e.StationId)
.OnDelete(DeleteBehavior.Cascade);
});
// StationInteractionNode configuration
modelBuilder.Entity<StationInteractionNode>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.StationId, e.NodeId }).IsUnique();
entity.HasIndex(e => e.StationId);
entity.HasIndex(e => e.NodeId);
});
// NodeVehicleProperty configuration
modelBuilder.Entity<NodeVehicleProperty>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.NodeId, e.VehicleTypeId }).IsUnique();
entity.HasIndex(e => e.NodeId);
entity.HasIndex(e => e.VehicleTypeId);
});
// EdgeVehicleProperty configuration
modelBuilder.Entity<EdgeVehicleProperty>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.EdgeId, e.VehicleTypeId }).IsUnique();
entity.HasIndex(e => e.EdgeId);
entity.HasIndex(e => e.VehicleTypeId);
});
// LayoutLevelEditorSettings configuration (UI extensions - not VDMA LIF)
modelBuilder.Entity<LayoutLevelEditorSettings>(entity =>
{
entity.HasKey(e => e.Id);
// 1-to-1 relationship with LayoutLevel (unique index on LevelId)
entity.HasIndex(e => e.LevelId).IsUnique();
entity.HasOne(e => e.Level)
.WithOne(l => l.EditorSettings)
.HasForeignKey<LayoutLevelEditorSettings>(e => e.LevelId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}

View File

@@ -0,0 +1,70 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RobotNet10.MapManager.Data;
/// <summary>
/// Node entity - Waypoints/nodes on the map
/// Maps to VDMA LIF: layouts[].nodes[]
/// </summary>
[Table("Nodes")]
public class Node
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; }
/// <summary>
/// Parent level reference
/// </summary>
[Required]
public Guid LevelId { get; set; }
/// <summary>
/// Node identifier (VDMA LIF: nodeId) - Required
/// Unique within level
/// </summary>
[Required]
[StringLength(128)]
public string NodeId { get; set; } = string.Empty;
/// <summary>
/// Node name (VDMA LIF: nodeName) - Optional
/// </summary>
[StringLength(256)]
public string? NodeName { get; set; }
/// <summary>
/// Node description (VDMA LIF: nodeDescription) - Optional
/// </summary>
public string? NodeDescription { get; set; }
/// <summary>
/// Map identifier (VDMA LIF: mapId) - Optional
/// Reference to map image or data
/// </summary>
[StringLength(128)]
public string? MapId { get; set; }
/// <summary>
/// X coordinate in meters (VDMA LIF: nodePosition.x) - Required
/// </summary>
[Required]
public double X { get; set; }
/// <summary>
/// Y coordinate in meters (VDMA LIF: nodePosition.y) - Required
/// </summary>
[Required]
public double Y { get; set; }
// Navigation properties
[ForeignKey(nameof(LevelId))]
public virtual LayoutLevel Level { get; set; } = null!;
public virtual ICollection<NodeVehicleProperty> VehicleProperties { get; set; } = new List<NodeVehicleProperty>();
public virtual ICollection<Edge> OutgoingEdges { get; set; } = new List<Edge>();
public virtual ICollection<Edge> IncomingEdges { get; set; } = new List<Edge>();
public virtual ICollection<StationInteractionNode> StationInteractions { get; set; } = new List<StationInteractionNode>();
}

View File

@@ -0,0 +1,74 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RobotNet10.MapManager.Data;
/// <summary>
/// Node vehicle property - Vehicle-specific properties for nodes
/// Maps to VDMA LIF: node.vehicleTypeNodeProperties[]
/// </summary>
[Table("NodeVehicleProperties")]
public class NodeVehicleProperty
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; }
/// <summary>
/// Node reference
/// </summary>
[Required]
public Guid NodeId { get; set; }
/// <summary>
/// Vehicle type reference
/// </summary>
[Required]
public Guid VehicleTypeId { get; set; }
/// <summary>
/// Absolute orientation of vehicle on node (VDMA LIF: theta) - Optional
/// In radians, range: [-Pi ... Pi]
/// </summary>
public double? Theta { get; set; }
/// <summary>
/// Actions that vehicle can perform at this node (VDMA LIF: actions) - Optional
/// JSON array format:
/// [
/// {
/// "actionType": "pick",
/// "actionDescription": "...",
/// "requirementType": "REQUIRED",
/// "blockingType": "HARD",
/// "actionParameters": [{"key": "...", "value": "..."}]
/// }
/// ]
/// </summary>
public string? Actions { get; set; }
/// <summary>
/// Allowed deviation radius in meters (VDA5050: allowedDeviationXY) - Optional
/// Indicates how exact an AGV has to drive over a node in order for it to count as traversed.
/// If = 0: no deviation is allowed (no deviation means within the normal tolerance of the AGV manufacturer).
/// If > 0: allowed deviation-radius in meters. If the AGV passes a node within the deviation-radius, the node is considered to have been traversed.
/// Minimum: 0
/// </summary>
public double? AllowedDeviationXY { get; set; }
/// <summary>
/// Allowed deviation of theta angle in radians (VDA5050: allowedDeviationTheta) - Optional
/// Indicates how big the deviation of theta angle can be.
/// The lowest acceptable angle is theta - allowedDeviationTheta and the highest acceptable angle is theta + allowedDeviationTheta.
/// Range: [0.0 ... 3.141592654] (0 to Pi)
/// </summary>
public double? AllowedDeviationTheta { get; set; }
// Navigation properties
[ForeignKey(nameof(NodeId))]
public virtual Node Node { get; set; } = null!;
[ForeignKey(nameof(VehicleTypeId))]
public virtual VehicleType VehicleType { get; set; } = null!;
}

View File

@@ -0,0 +1,71 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RobotNet10.MapManager.Data;
/// <summary>
/// Station entity - Interaction points for vehicles
/// Maps to VDMA LIF: layouts[].stations[]
/// </summary>
[Table("Stations")]
public class Station
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; }
/// <summary>
/// Parent level reference
/// </summary>
[Required]
public Guid LevelId { get; set; }
/// <summary>
/// Station identifier (VDMA LIF: stationId) - Required
/// Unique within level
/// </summary>
[Required]
[StringLength(128)]
public string StationId { get; set; } = string.Empty;
/// <summary>
/// Station name (VDMA LIF: stationName) - Optional
/// </summary>
[StringLength(256)]
public string? StationName { get; set; }
/// <summary>
/// Station description (VDMA LIF: stationDescription) - Optional
/// </summary>
public string? StationDescription { get; set; }
/// <summary>
/// Station height in meters (VDMA LIF: stationHeight) - Optional
/// </summary>
public double? StationHeight { get; set; }
/// <summary>
/// X coordinate in meters (VDMA LIF: stationPosition.x) - Required
/// </summary>
[Required]
public double X { get; set; }
/// <summary>
/// Y coordinate in meters (VDMA LIF: stationPosition.y) - Required
/// </summary>
[Required]
public double Y { get; set; }
/// <summary>
/// Theta orientation in radians (VDMA LIF: stationPosition.theta) - Optional
/// Range: [-Pi ... Pi]
/// </summary>
public double? Theta { get; set; }
// Navigation properties
[ForeignKey(nameof(LevelId))]
public virtual LayoutLevel Level { get; set; } = null!;
public virtual ICollection<StationInteractionNode> InteractionNodes { get; set; } = new List<StationInteractionNode>();
}

View File

@@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RobotNet10.MapManager.Data;
/// <summary>
/// Station interaction node - Junction table linking stations to nodes
/// Maps to VDMA LIF: station.interactionNodeIds[]
/// </summary>
[Table("StationInteractionNodes")]
public class StationInteractionNode
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; }
/// <summary>
/// Station reference
/// </summary>
[Required]
public Guid StationId { get; set; }
/// <summary>
/// Node reference
/// </summary>
[Required]
public Guid NodeId { get; set; }
// Navigation properties
[ForeignKey(nameof(StationId))]
public virtual Station Station { get; set; } = null!;
[ForeignKey(nameof(NodeId))]
public virtual Node Node { get; set; } = null!;
}

View File

@@ -0,0 +1,74 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RobotNet10.MapManager.Data;
/// <summary>
/// Vehicle type entity - Master data for vehicle types
/// Used in vehicleTypeNodeProperties and vehicleTypeEdgeProperties
/// </summary>
[Table("VehicleTypes")]
public class VehicleType
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; }
/// <summary>
/// Vehicle type identifier (VDMA LIF: vehicleTypeId)
/// Example: "AMR-T800", "AMR-F100", "Forklift-X1"
/// </summary>
[Required]
[StringLength(64)]
public string VehicleTypeId { get; set; } = string.Empty;
/// <summary>
/// Vehicle type name
/// </summary>
[Required]
[StringLength(256)]
public string VehicleTypeName { get; set; } = string.Empty;
/// <summary>
/// Description
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Additional specifications (JSON)
/// For future extensions
/// </summary>
public string? Specifications { get; set; }
/// <summary>
/// Is vehicle type active
/// </summary>
[Required]
public bool IsActive { get; set; } = true;
/// <summary>
/// Actions default that vehicle can perform (VDMA LIF: actions) - Optional
/// JSON array format:
/// [
/// {
/// "actionType": "pick",
/// "actionDescription": "...",
/// "requirementType": "REQUIRED", // Enum: REQUIRED, CONDITIONAL, OPTIONAL (RequirementType enum)
/// "blockingType": "HARD",
/// "actionParameters": [{"key": "...", "value": "..."}]
/// }
/// ]
/// </summary>
public string? Actions { get; set; }
/// <summary>
/// Created date
/// </summary>
[Required]
public DateTime CreatedDate { get; set; } = DateTime.UtcNow;
// Navigation properties
public virtual ICollection<NodeVehicleProperty> NodeVehicleProperties { get; set; } = new List<NodeVehicleProperty>();
public virtual ICollection<EdgeVehicleProperty> EdgeVehicleProperties { get; set; } = new List<EdgeVehicleProperty>();
}

View File

@@ -0,0 +1,88 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using RobotNet10.MapManager.Data;
using RobotNet10.MapManager.Services;
using RobotNet10.StorageManager;
namespace RobotNet10.MapManager.Extensions;
/// <summary>
/// Extension methods for registering MapManager services
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Add MapManager services to the DI container
/// </summary>
/// <param name="services">Service collection</param>
/// <param name="configuration">Configuration</param>
/// <param name="dbContextOptions">action register DbContext</param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
public static IServiceCollection AddMapManager(this IServiceCollection services,
IConfiguration configuration,
Action<DbContextOptionsBuilder> dbContextOptions,
string configSectionName = "LayoutImage")
{
// Register DbContext
services.AddDbContext<MapDbContext>(dbContextOptions);
services.Configure<StorageConfig>("LayoutImages", options =>
{
configuration.GetSection(configSectionName).Bind(options);
});
services.AddSingleton<IImageStorageService, FileSystemImageStorageService>();
// Register Business Services
services.AddScoped<ILayoutService, LayoutService>();
services.AddScoped<IVehicleTypeService, VehicleTypeService>();
services.AddScoped<INodeService, NodeService>();
services.AddScoped<IEdgeService, EdgeService>();
services.AddScoped<IStationService, StationService>();
services.AddScoped<ILayoutDataService, LayoutDataService>();
services.AddScoped<IMapQueryService, MapQueryService>();
services.AddScoped<LayoutLevelNamingService>();
return services;
}
/// <summary>
/// Checks MapManager database connectivity
/// Call this after app.Build() to verify database is accessible
/// Note: Does NOT apply migrations or seed data - only checks connectivity
/// </summary>
/// <param name="services">Service provider</param>
/// <returns>Task</returns>
public static async Task SeedMapManagerAsync(this IServiceProvider services)
{
using var scope = services.CreateScope();
var serviceProvider = scope.ServiceProvider;
var logger = serviceProvider.GetRequiredService<ILogger<MapDbContext>>();
try
{
var context = serviceProvider.GetRequiredService<MapDbContext>();
logger.LogInformation("Skipping automatic MapManager migration at startup. Running connectivity check only.");
// Check if database can be connected
logger.LogInformation("Checking MapManager database connection...");
var canConnect = await context.Database.CanConnectAsync();
if (canConnect)
{
logger.LogInformation("MapManager database is accessible");
}
else
{
logger.LogWarning("MapManager database is not accessible. Please ensure database is created.");
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error occurred while checking MapManager database");
throw;
}
}
}

View File

@@ -0,0 +1,181 @@
# RobotNet10.MapManager
## Overview
Entity Framework Core module for managing AGV/AMR maps according to **VDMA LIF (Layout Interchange Format) 1.0.0** standard.
## Status
**Phase 1 Complete: Database Foundation**
- [x] Entity classes generated (10 entities)
- [x] DbContext created with full configurations
- [x] EF Core packages installed (v9.0.0)
- [x] Design documentation finalized
## Entity Classes
### Hierarchy (Layout → Version → Level)
1. **Layout.cs** - Root entity (Building/Facility)
2. **LayoutVersion.cs** - Version history
3. **LayoutLevel.cs** - Floors/Levels
### Master Data
4. **VehicleType.cs** - Vehicle types (AMR-T800, etc.)
### Map Elements
5. **Node.cs** - Waypoints (VDMA LIF compliant)
6. **Edge.cs** - Paths (with EdgeName/EdgeDescription extensions)
7. **Station.cs** - Interaction points
### Junction Tables
8. **StationInteractionNode.cs** - Station ↔ Node mapping
9. **NodeVehicleProperty.cs** - Node × VehicleType properties
10. **EdgeVehicleProperty.cs** - Edge × VehicleType properties
### Database Context
- **MapDbContext.cs** - EF Core DbContext with all configurations
## Key Features
### VDMA LIF Compliance
- ✅ 100% adherence to lif-schema.json
- ✅ Proper JSON serialization for import/export
- ✅ Support for vehicleTypeNodeProperties and vehicleTypeEdgeProperties
### Version Control
- ✅ Multiple versions per layout
- ✅ Only ONE active version per layout
- ✅ Active version is READ-ONLY
### Multi-Level Support
- ✅ Multiple levels (floors) per version
- ✅ LevelOrder for flexible sorting
- ✅ Each level exports as separate layout entry in JSON
### VehicleType Customization
- ✅ Per-vehicle properties for nodes and edges
- ✅ Actions stored as JSON in NodeVehicleProperties
- ✅ NURBS trajectory support in EdgeVehicleProperties
## Database Schema
```
Layouts (10 tables)
├── Layouts → LayoutVersions → LayoutLevels
├── VehicleTypes
├── Nodes (with NodeVehicleProperties)
├── Edges (with EdgeVehicleProperties)
└── Stations (with StationInteractionNodes)
```
**Total Indexes:** ~25 (PKs, FKs, composite indexes)
## Next Steps
### Phase 2: Migrations
```bash
# Add EF Core tools (if not installed)
dotnet tool install --global dotnet-ef
# Create initial migration
dotnet ef migrations add InitialCreate --project srcs/RobotNet10/Commons/RobotNet10.MapManager
# Update database
dotnet ef database update --project srcs/RobotNet10/Commons/RobotNet10.MapManager
```
### Phase 3: Import/Export Services
- [ ] Create VDMA LIF JSON models
- [ ] Implement Export service (DB → JSON)
- [ ] Implement Import service (JSON → DB)
- [ ] Validation against lif-schema.json
### Phase 4: Integration
- [ ] API endpoints for MapEditor
- [ ] Version management
- [ ] VehicleType management
## Documentation
- **DATABASE_DESIGN.md** - Complete schema documentation
- **docs/MapEditor/V2-DangNV/DATABASE_DESIGN_DISCUSSION.md** - Design discussion summary
- **lif-schema.json** - VDMA LIF JSON schema reference
## Configuration
### Connection Strings
**SQL Server:**
```json
"ConnectionStrings": {
"MapManagerDb": "Server=localhost;Database=RobotNetMaps;Trusted_Connection=True;TrustServerCertificate=True;"
}
```
**SQLite:**
```json
"ConnectionStrings": {
"MapManagerDb": "Data Source=robotnet_maps.db"
}
```
### Program.cs
```csharp
using Microsoft.EntityFrameworkCore;
using RobotNet10.MapManager.Data;
var builder = WebApplication.CreateBuilder(args);
// Add MapDbContext
builder.Services.AddDbContext<MapDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("MapManagerDb"))
// or options.UseSqlite(builder.Configuration.GetConnectionString("MapManagerDb"))
);
var app = builder.Build();
app.Run();
```
## Scale Targets
- 50 Layouts
- 10 Versions per Layout
- 10 Levels per Version
- 1,000 Nodes per Level
- 999 Edges per Level
- 10 VehicleTypes
**Total:** ~5M Nodes, ~5M Edges
## Performance Optimizations
- ✅ Strategic indexing (25+ indexes)
- ✅ Designed for partitioning (by LayoutId or LevelId)
- ✅ Caching strategy identified
- ✅ Pagination support planned
## References
- **VDMA LIF Specification:** FuI_Guideline_LIF_GB_final.pdf
- **Entity Framework Core:** https://docs.microsoft.com/ef/core/
- **VDMA LIF Schema:** lif-schema.json
---
**Version:** 1.0
**Date:** 2024-11-26
**Status:** Phase 1 Complete ✅

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RobotNet10.StorageManager\RobotNet10.StorageManager.csproj" />
<ProjectReference Include="..\..\Shared\RobotNet10.MapEditor.Shared\RobotNet10.MapEditor.Shared.csproj" />
<ProjectReference Include="..\..\Shared\RobotNet.VDA5050\RobotNet.VDA5050.csproj" />
</ItemGroup>
</Project>

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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; }
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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))];
}
}

View File

@@ -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;
}
}

View File

@@ -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
};
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,27 @@
{
"ConnectionStrings": {
"MapDatabase": "Data Source=maps.db"
},
"ImageStorage": {
"StorageType": "FileSystem",
"Minio": {
"Endpoint": "localhost:9000",
"AccessKey": "minioadmin",
"SecretKey": "minioadmin",
"BucketName": "map-images",
"UseSSL": false
},
"FileSystem": {
"FolderName": "MapImages"
}
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"RobotNet10.MapManager": "Information"
}
}
}

View File

@@ -0,0 +1,404 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "LIF Layout Interchange Format",
"type": "object",
"properties": {
"metaInformation": {
"type": "object",
"description": "Contains metadata about the project and the LIF file.",
"properties": {
"projectIdentification": {
"type": "string",
"description": "Human-readable name of the project (e.g., for display purposes)."
},
"creator": {
"type": "string",
"description": "Creator of the LIF file (e.g., name of company or person)."
},
"exportTimestamp": {
"type": "string",
"format": "date-time",
"description": "The timestamp at which this LIF file was created/updated/modified. Format is ISO8601 in UTC."
},
"lifVersion": {
"type": "string",
"description": "Version of the LIF file format. Follows semantic versioning (Major.Minor.Patch)."
}
},
"required": [
"projectIdentification",
"creator",
"exportTimestamp",
"lifVersion"
]
},
"layouts": {
"type": "array",
"description": "Collection of layouts used in the facility by the driverless transport system. All layouts refer to the same global origin.",
"items": {
"type": "object",
"properties": {
"layoutId": {
"type": "string",
"description": "Unique identifier for the layout."
},
"layoutName": {
"type": "string",
"description": "Name of the layout."
},
"layoutVersion": {
"type": "string",
"description": "Version number of the layout. It is suggested that this be an integer, represented as a string, incremented with each change, starting at 1."
},
"layoutLevelId": {
"type": "string",
"description": "Unique identifier for the layout level."
},
"layoutDescription": {
"type": "string",
"description": "Description of the layout. *Optional*."
},
"nodes": {
"type": "array",
"description": "List of nodes in the layout. Nodes are locations where vehicles can navigate to.",
"items": {
"type": "object",
"properties": {
"nodeId": {
"type": "string",
"description": "Unique identifier for the node."
},
"nodeName": {
"type": "string",
"description": "Name of the node. *Optional*."
},
"nodeDescription": {
"type": "string",
"description": "Description of the node. *Optional*."
},
"mapId": {
"type": "string",
"description": "Identifier for the map that this node belongs to. *Optional*."
},
"nodePosition": {
"type": "object",
"description": "Position of the node on the map (in meters).",
"properties": {
"x": {
"type": "number",
"description": "X coordinate of the node in meters. Range: [float64.min ... float64.max]"
},
"y": {
"type": "number",
"description": "Y coordinate of the node in meters. Range: [float64.min... float64.max]"
}
},
"required": [
"x",
"y"
]
},
"vehicleTypeNodeProperties": {
"type": "array",
"description": "Vehicle-specific properties related to the node.",
"items": {
"type": "object",
"properties": {
"vehicleTypeId": {
"type": "string",
"description": "Identifier for the vehicle type."
},
"theta": {
"type": "number",
"description": "Absolute orientation of the vehicle on the node in reference to the global origins rotation. Range: [-Pi ... Pi]"
},
"actions": {
"type": "array",
"description": "List of actions that the vehicle can perform at the node. *Optional*.",
"items": {
"type": "object",
"properties": {
"actionType": {
"type": "string",
"description": "Type of action (e.g., move, load, unload)."
},
"actionDescription": {
"type": "string",
"description": "Description of the action. *Optional*."
},
"required": {
"type": "boolean",
"description": "Whether the action is mandatory."
},
"blockingType": {
"type": "string",
"description": "Specifies if the action is blocking (HARD or SOFT)."
},
"actionParameters": {
"type": "array",
"description": "Parameters associated with the action. *Optional*.",
"items": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "Key of the action parameter."
},
"value": {
"type": "string",
"description": "Value of the action parameter."
}
},
"required": [
"key",
"value"
]
}
}
},
"required": [
"actionType",
"blockingType"
]
}
}
},
"required": [
"vehicleTypeId"
]
}
}
},
"required": [
"nodeId",
"nodePosition",
"vehicleTypeNodeProperties"
]
}
},
"edges": {
"type": "array",
"description": "List of edges in the layout. Edges represent paths between nodes.",
"items": {
"type": "object",
"properties": {
"edgeId": {
"type": "string",
"description": "Unique identifier for the edge."
},
"startNodeId": {
"type": "string",
"description": "ID of the starting node for this edge."
},
"endNodeId": {
"type": "string",
"description": "ID of the ending node for this edge."
},
"vehicleTypeEdgeProperties": {
"type": "array",
"description": "Vehicle-specific properties for the edge.",
"items": {
"type": "object",
"properties": {
"vehicleTypeId": {
"type": "string",
"description": "Identifier for the vehicle type."
},
"vehicleOrientation": {
"type": "number",
"description": "Orientation of the vehicle while traversing the edge, in degrees. Range: [0.0 ... 360.0]"
},
"orientationType": {
"type": "string",
"description": "Type of orientation (e.g., TANGENTIAL)."
},
"rotationAllowed": {
"type": "boolean",
"description": "Indicates if rotation is allowed while on the edge. *Optional*."
},
"rotationAtStartNodeAllowed": {
"type": "string",
"description": "Specifies if rotation is allowed at the start node. *Optional*."
},
"rotationAtEndNodeAllowed": {
"type": "string",
"description": "Specifies if rotation is allowed at the end node. *Optional*."
},
"maxSpeed": {
"type": "number",
"description": "Maximum speed allowed on this edge in meters per second. Range: [0.0 ... float64.max]"
},
"maxRotationSpeed": {
"type": "number",
"description": "Maximum rotation speed allowed on this edge in radians per second. *Optional*. Range: [0.0 ... float64.max]"
},
"minHeight": {
"type": "number",
"description": "Minimum height of the vehicle on this edge in meters. *Optional*. Range: [0.0 ... float64.max]"
},
"maxHeight": {
"type": "number",
"description": "Maximum height of the vehicle on this edge in meters. *Optional*. Range: [0.0 ... float64.max]"
},
"loadRestriction": {
"type": "object",
"description": "Load restrictions for this edge. *Optional*.",
"properties": {
"unloaded": {
"type": "boolean",
"description": "Indicates if the edge can be traversed without a load."
},
"loaded": {
"type": "boolean",
"description": "Indicates if the edge can be traversed with a load."
},
"loadSetNames": {
"type": "array",
"description": "Names of the load sets allowed on this edge. *Optional*.",
"items": {
"type": "string"
}
}
},
"required": [
"unloaded",
"loaded"
]
},
"trajectory": {
"type": "object",
"description": "Trajectory information for this edge, if applicable. *Optional*.",
"properties": {
"degree": {
"type": "integer",
"description": "Degree of the trajectory curve. Default is 3. Range: [1 ... 3]",
"default": 3
},
"knotVector": {
"type": "array",
"description": "Knot vector for the trajectory.",
"items": {
"type": "number"
}
},
"controlPoints": {
"type": "array",
"description": "Control points defining the trajectory.",
"items": {
"type": "object",
"properties": {
"x": {
"type": "number",
"description": "X coordinate of the control point in meters."
},
"y": {
"type": "number",
"description": "Y coordinate of the control point in meters."
},
"weight": {
"type": "number",
"description": "The weight with which this control point pulls on the curve. When not defined, the default is 1.0. Range: [0.0 ... float64.max]",
"default": 1.0
}
},
"required": [
"x",
"y"
]
}
}
},
"required": [
"knotVector",
"controlPoints"
]
}
},
"required": [
"vehicleTypeId"
]
}
}
},
"required": [
"edgeId",
"startNodeId",
"endNodeId",
"vehicleTypeEdgeProperties"
]
}
},
"stations": {
"type": "array",
"description": "List of stations in the layout where vehicles perform specific actions.",
"items": {
"type": "object",
"properties": {
"stationId": {
"type": "string",
"description": "Unique identifier for the station."
},
"interactionNodeIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of node IDs where the station interacts."
},
"stationName": {
"type": "string",
"description": "Name of the station. *Optional*."
},
"stationDescription": {
"type": "string",
"description": "Description of the station. *Optional*."
},
"stationHeight": {
"type": "number",
"description": "Height of the station, if applicable, in meters. *Optional*. Range: [0.0 ... float64.max]"
},
"stationPosition": {
"type": "object",
"description": "Position of the station on the map (in meters).",
"properties": {
"x": {
"type": "number",
"description": "X coordinate of the station in meters. Range: [float64.min ... float64.max]"
},
"y": {
"type": "number",
"description": "Y coordinate of the station in meters. Range: [float64.min ... float64.max]"
},
"theta": {
"type": "number",
"description": "Orientation of the station. Unit: radians. Range: [-Pi ... Pi]"
}
},
"required": [
"x",
"y"
]
}
},
"required": [
"stationId",
"interactionNodeIds"
]
}
}
},
"required": [
"layoutId",
"layoutVersion",
"nodes",
"edges",
"stations"
]
}
}
},
"required": [
"metaInformation",
"layouts"
]
}