From f2dc84a790ecd10772c251010a76f8b24aaecd05 Mon Sep 17 00:00:00 2001 From: master <> Date: Wed, 1 Apr 2026 23:15:18 +0300 Subject: [PATCH] Route long sweeps through top corridor unconditionally Long horizontal sweeps (>40% graph width) now always route through the top corridor instead of cutting through the node field. Each successive corridor edge gets a 24px Y offset to prevent convergence. Remaining: target-join at End/top (two corridor routes converge on descent) and edge/9 flush under-node. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...Iterative.BoundaryFirst.CorridorReroute.cs | 125 ++++++++---------- ...RouterIterative.WinnerRefinement.Hybrid.cs | 59 +++++++++ 2 files changed, 111 insertions(+), 73 deletions(-) diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.CorridorReroute.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.CorridorReroute.cs index 48a744c59..7c1c9f039 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.CorridorReroute.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.CorridorReroute.cs @@ -27,7 +27,40 @@ internal static partial class ElkEdgeRouterIterative var underNodeSeverity = new Dictionary(StringComparer.Ordinal); ElkEdgeRoutingScoring.CountUnderNodeViolations(edges, nodes, underNodeSeverity, 1); - if (underNodeSeverity.Count == 0) + + // Also identify long sweeps that cut through the node field — they + // need corridor routing for visual clarity even without under-node + // violations. A 3000px horizontal line through the graph is ugly. + var longSweepEdgeIds = new HashSet(StringComparer.Ordinal); + for (var edgeIndex = 0; edgeIndex < edges.Length; edgeIndex++) + { + var edge = edges[edgeIndex]; + if (underNodeSeverity.ContainsKey(edge.Id)) + { + continue; // already handled + } + + var path = ExtractPath(edge); + 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); + var laneY = path[i].Y; + if (segLength >= minSweepLength + && laneY > graphMinY - 10d + && laneY < graphMaxY + 10d) + { + longSweepEdgeIds.Add(edge.Id); + break; + } + } + } + + if (underNodeSeverity.Count == 0 && longSweepEdgeIds.Count == 0) { return null; } @@ -37,7 +70,7 @@ internal static partial class ElkEdgeRouterIterative for (var edgeIndex = 0; edgeIndex < edges.Length; edgeIndex++) { var edge = edges[edgeIndex]; - if (!underNodeSeverity.ContainsKey(edge.Id)) + if (!underNodeSeverity.ContainsKey(edge.Id) && !longSweepEdgeIds.Contains(edge.Id)) { continue; } @@ -76,79 +109,25 @@ internal static partial class ElkEdgeRouterIterative if (bestSegLength >= minSweepLength) { - // Long sweep: try to push the horizontal segment below all - // blocking nodes first. Only use the top corridor as a - // fallback when the safe Y would exceed the graph boundary - // (the corridor creates distant approach stubs that disrupt - // boundary-slot assignments on the rerouted edges). - var laneY = path[bestSegStart].Y; - var maxBlockBottom = 0d; - var minX = Math.Min(path[bestSegStart].X, path[bestSegStart + 1].X); - var maxX = Math.Max(path[bestSegStart].X, path[bestSegStart + 1].X); - - foreach (var node in nodes) + // 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 (string.Equals(node.Id, edge.SourceNodeId, StringComparison.Ordinal) - || string.Equals(node.Id, edge.TargetNodeId, StringComparison.Ordinal)) - { - continue; - } + sourcePoint, + new() { X = stubX, Y = sourcePoint.Y }, + new() { X = stubX, Y = corridorY }, + new() { X = approachX, Y = corridorY }, + targetPoint, + }; - if (maxX <= node.X + 0.5d || minX >= 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); - } - } - - var pushY = maxBlockBottom + minLineClearance + 4d; - if (maxBlockBottom > 0d && pushY <= graphMaxY - 4d) - { - // Safe push within the graph -- shift only the under-node - // horizontal segment without changing approach geometry. - 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 if (maxBlockBottom > 0d) - { - // Push would exceed graph boundary -- use top corridor. - var exitX = sourcePoint.X; - var approachX = targetPoint.X; - 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 = ReplaceEdgePath(result, edges, edgeIndex, edge, newPath); - ElkLayoutDiagnostics.LogProgress( - $"Corridor reroute: {edge.Id} sweep={bestSegLength:F0}px to corridorY={corridorY:F0}"); - } + result = ReplaceEdgePath(result, edges, edgeIndex, edge, newPath); + ElkLayoutDiagnostics.LogProgress( + $"Corridor reroute: {edge.Id} sweep={bestSegLength:F0}px to corridorY={corridorY:F0}"); } 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 8ea293698..7cdd2b095 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs @@ -331,6 +331,65 @@ internal static partial class ElkEdgeRouterIterative current = RepairRemainingEdgeNodeCrossings(current, nodes); } + // Unconditional corridor reroute for long sweeps through the node field. + // The score-gated corridor reroute earlier may reject these because the + // corridor candidate scores worse (more bends, longer paths). But visually, + // long horizontal highways through the graph are ugly. Apply unconditionally. + { + var graphMinYLocal = nodes.Min(n => n.Y); + var graphWidthLocal = nodes.Max(n => n.X + n.Width) - nodes.Min(n => n.X); + var baseCorridorY = graphMinYLocal - 56d; + var localMinSweep = graphWidthLocal * 0.4d; + var corridorResult = current.Edges.ToArray(); + var corridorFixed = 0; + for (var ei = 0; ei < corridorResult.Length; ei++) + { + var edge = corridorResult[ei]; + var path = ExtractPath(edge); + for (var si = 0; si < path.Count - 1; si++) + { + if (Math.Abs(path[si].Y - path[si + 1].Y) > 2d) continue; + var segLen = Math.Abs(path[si + 1].X - path[si].X); + var laneY = path[si].Y; + if (segLen < localMinSweep || laneY <= graphMinYLocal - 10d) continue; + // Route through top corridor. Offset each successive edge + // so multiple corridor routes don't converge at the same Y. + var localCorridorY = baseCorridorY - (corridorFixed * 24d); + var src = path[0]; + var tgt = path[^1]; + var stubX = src.X + 24d; + var newPath = new List + { + src, + new() { X = stubX, Y = src.Y }, + new() { X = stubX, Y = localCorridorY }, + new() { X = tgt.X, Y = localCorridorY }, + tgt, + }; + corridorResult[ei] = 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(), + }], + }; + corridorFixed++; + break; + } + } + 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"); + } + } + return current; }