diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.CorridorReroute.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.CorridorReroute.cs new file mode 100644 index 000000000..5425eba68 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.CorridorReroute.cs @@ -0,0 +1,126 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + /// + /// Detects long horizontal sweeps with under-node violations and reroutes + /// them through the top corridor (above the graph), similar to how backward + /// edges are routed through external corridors. + /// + private static ElkRoutedEdge[]? RerouteLongSweepsThroughCorridor( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance) + { + if (direction != ElkLayoutDirection.LeftToRight || nodes.Length == 0) + { + return null; + } + + var graphMinY = nodes.Min(n => n.Y); + var graphMaxY = nodes.Max(n => n.Y + n.Height); + var graphWidth = nodes.Max(n => n.X + n.Width) - nodes.Min(n => n.X); + var minSweepLength = graphWidth * 0.4d; + var corridorY = graphMinY - 56d; + var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); + + var underNodeSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountUnderNodeViolations(edges, nodes, underNodeSeverity, 1); + if (underNodeSeverity.Count == 0) + { + return null; + } + + ElkRoutedEdge[]? result = null; + + for (var edgeIndex = 0; edgeIndex < edges.Length; edgeIndex++) + { + var edge = edges[edgeIndex]; + if (!underNodeSeverity.ContainsKey(edge.Id)) + { + continue; + } + + var path = ExtractPath(edge); + if (path.Count < 2) + { + continue; + } + + // Find the longest horizontal segment. + var bestSegStart = -1; + var bestSegLength = 0d; + for (var i = 0; i < path.Count - 1; i++) + { + if (Math.Abs(path[i].Y - path[i + 1].Y) > 2d) + { + continue; + } + + var segLength = Math.Abs(path[i + 1].X - path[i].X); + if (segLength > bestSegLength) + { + bestSegLength = segLength; + bestSegStart = i; + } + } + + if (bestSegStart < 0 || bestSegLength < minSweepLength) + { + continue; + } + + // Build corridor path: source exit → up to corridor → across → down to target. + var sourcePoint = path[0]; + var targetPoint = path[^1]; + var exitX = sourcePoint.X; + var approachX = targetPoint.X; + + // Determine corridor direction based on source position relative to graph. + var sourceNode = nodesById.GetValueOrDefault(edge.SourceNodeId ?? string.Empty); + var sourceTopY = sourceNode?.Y ?? sourcePoint.Y; + + // Build corridor path with a perpendicular exit stub. + // The stub prevents NormalizeBoundaryAngles from collapsing + // the vertical corridor segment (it removes path[1] while + // path[1].X == sourceX, so the stub at exitX+24 survives). + var stubX = exitX + 24d; + var newPath = new List + { + sourcePoint, + new() { X = stubX, Y = sourcePoint.Y }, + new() { X = stubX, Y = corridorY }, + new() { X = approachX, Y = corridorY }, + targetPoint, + }; + + result ??= (ElkRoutedEdge[])edges.Clone(); + result[edgeIndex] = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + SourcePortId = edge.SourcePortId, + TargetPortId = edge.TargetPortId, + Kind = edge.Kind, + Label = edge.Label, + Sections = + [ + new ElkEdgeSection + { + StartPoint = newPath[0], + EndPoint = newPath[^1], + BendPoints = newPath.Skip(1).Take(newPath.Count - 2).ToArray(), + }, + ], + }; + + ElkLayoutDiagnostics.LogProgress( + $"Corridor reroute: {edge.Id} sweep={bestSegLength:F0}px " + + $"from Y={path[bestSegStart].Y:F0} to corridorY={corridorY:F0}"); + } + + return result; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs index dacb91e16..c172ca50e 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs @@ -88,6 +88,34 @@ internal static partial class ElkEdgeRouterIterative } } + // Reroute long horizontal sweeps through the top corridor. + // Edges spanning > half the graph width with under-node violations + // should route above the graph (like backward edges) instead of + // cutting straight through the node field. + if (current.RetryState.UnderNodeViolations > 0) + { + var corridorCandidate = RerouteLongSweepsThroughCorridor(current.Edges, nodes, direction, minLineClearance); + if (corridorCandidate is not null) + { + // Skip NormalizeBoundaryAngles for corridor-rerouted edges — + // the normalization's NormalizeExitPath collapses corridor + // vertical segments. The corridor path already has a correct + // perpendicular exit stub. + var corridorScore = ElkEdgeRoutingScoring.ComputeScore(corridorCandidate, nodes); + if (corridorScore.Value > current.Score.Value + && corridorScore.NodeCrossings <= current.Score.NodeCrossings) + { + var corridorRetry = BuildRetryState( + corridorScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(corridorCandidate, nodes).Count + : 0); + current = current with { Score = corridorScore, RetryState = corridorRetry, Edges = corridorCandidate }; + ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after corridor reroute: {DescribeSolution(current)}"); + } + } + } + // Targeted under-node elevation with net-total promotion. // ElevateUnderNodeViolations can fix remaining under-node edges // (gateway-exit lanes, long horizontal sweeps) but the standard