Initial commit

This commit is contained in:
2026-07-03 16:31:37 +07:00
commit 899c7c637d
1939 changed files with 641750 additions and 0 deletions

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