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; /// /// Service implementation for retrieving complete layout data /// 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 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 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(); 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 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(); var updatedEdges = new List(); // 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 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 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(); 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(); 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? SafeDeserializeLoadSetNames(string? json) { if (string.IsNullOrWhiteSpace(json)) return null; try { return System.Text.Json.JsonSerializer.Deserialize>(json); } catch (System.Text.Json.JsonException) { return null; } } }