diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.CorridorReroute.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.CorridorReroute.cs index 7c1c9f039..e72c95774 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.CorridorReroute.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.CorridorReroute.cs @@ -109,25 +109,52 @@ internal static partial class ElkEdgeRouterIterative if (bestSegLength >= minSweepLength) { - // Long sweep (>40% graph width): route through the top corridor. - // These are "highway" edges that span the graph and look ugly - // cutting through the node field. The corridor moves them above - // the graph for clean visual separation. - var exitX = sourcePoint.X; - var approachX = targetPoint.X; - var stubX = exitX + 24d; - var newPath = new List + if (underNodeSeverity.ContainsKey(edge.Id)) { - sourcePoint, - new() { X = stubX, Y = sourcePoint.Y }, - new() { X = stubX, Y = corridorY }, - new() { X = approachX, Y = corridorY }, - targetPoint, - }; + // Under-node long sweep: push below blocking nodes first. + var laneY = path[bestSegStart].Y; + var maxBlockBottom = 0d; + var pushMinX = Math.Min(path[bestSegStart].X, path[bestSegStart + 1].X); + var pushMaxX = Math.Max(path[bestSegStart].X, path[bestSegStart + 1].X); + foreach (var node in nodes) + { + if (string.Equals(node.Id, edge.SourceNodeId, StringComparison.Ordinal) + || string.Equals(node.Id, edge.TargetNodeId, StringComparison.Ordinal)) + continue; + if (pushMaxX <= node.X + 0.5d || pushMinX >= node.X + node.Width - 0.5d) + continue; + var nodeBottom = node.Y + node.Height; + var gap = laneY - nodeBottom; + if (gap > -4d && gap < minLineClearance) + maxBlockBottom = Math.Max(maxBlockBottom, nodeBottom); + } - result = ReplaceEdgePath(result, edges, edgeIndex, edge, newPath); - ElkLayoutDiagnostics.LogProgress( - $"Corridor reroute: {edge.Id} sweep={bestSegLength:F0}px to corridorY={corridorY:F0}"); + if (maxBlockBottom > 0d) + { + var pushY = maxBlockBottom + minLineClearance + 4d; + if (pushY <= graphMaxY - 4d) + { + var newPath = new List(path.Count); + for (var pi = 0; pi < path.Count; pi++) + { + if (pi >= bestSegStart && pi <= bestSegStart + 1 + && Math.Abs(path[pi].Y - laneY) <= 2d) + newPath.Add(new ElkPoint { X = path[pi].X, Y = pushY }); + else + newPath.Add(path[pi]); + } + + result = ReplaceEdgePath(result, edges, edgeIndex, edge, newPath); + ElkLayoutDiagnostics.LogProgress( + $"Under-node push: {edge.Id} from Y={laneY:F0} to Y={pushY:F0} (blocker bottom={maxBlockBottom:F0})"); + } + } + } + else + { + // Visual-only long sweep: corridor route (handled by + // unconditional corridor in the winner refinement). + } } else if (bestSegLength >= 500d) { diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs index 7e46c15b8..e8083bdbd 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs @@ -312,7 +312,11 @@ internal static partial class ElkEdgeRouterIterative var segLen = Math.Abs(cpath[si + 1].X - cpath[si].X); var laneY = cpath[si].Y; if (segLen < localMinSweep || laneY <= graphMinYLocal - 10d) continue; - var localCorridorY = baseCorridorY - (corridorFixed * 24d); + // Offset must exceed the target-join detection threshold + // (node-size clearance, not spacing-scaled) so parallel + // corridor segments aren't flagged as joins. + var nodeSizeClearance = ElkEdgeRoutingScoring.ResolveNodeSizeClearance(nodes); + var localCorridorY = baseCorridorY - (corridorFixed * (nodeSizeClearance + 4d)); var src = cpath[0]; var tgt = cpath[^1]; var stubX = src.X + 24d; @@ -342,9 +346,14 @@ internal static partial class ElkEdgeRouterIterative if (corridorFixed > 0) { var corridorScore = ElkEdgeRoutingScoring.ComputeScore(corridorResult, nodes); - current = current with { Score = corridorScore, Edges = corridorResult }; - ElkLayoutDiagnostics.LogProgress( - $"Unconditional corridor reroute: {corridorFixed} edges to corridor"); + // Accept only if no new repeat-collector or node-crossing regressions. + if (corridorScore.RepeatCollectorCorridorViolations <= current.Score.RepeatCollectorCorridorViolations + && corridorScore.NodeCrossings <= current.Score.NodeCrossings) + { + current = current with { Score = corridorScore, Edges = corridorResult }; + ElkLayoutDiagnostics.LogProgress( + $"Unconditional corridor reroute: {corridorFixed} edges to corridor"); + } } } diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.cs index 00a1a38d3..046903669 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.cs @@ -247,7 +247,7 @@ internal static partial class ElkEdgeRoutingScoring /// face-related detections (target-join, boundary-slot) where the gap /// depends on node face geometry, not inter-node routing corridors. /// - private static double ResolveNodeSizeClearance(IReadOnlyCollection nodes) + internal static double ResolveNodeSizeClearance(IReadOnlyCollection nodes) { var serviceNodes = nodes.Where(n => n.Kind is not "Start" and not "End").ToArray(); return serviceNodes.Length > 0