From 24e8ddd29652c5fbd1088a304459acdd9fd7a986 Mon Sep 17 00:00:00 2001 From: master <> Date: Mon, 30 Mar 2026 10:21:48 +0300 Subject: [PATCH] Fix under-node violations with corridor routing and push-down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two under-node fix strategies in the winner refinement: 1. Long sweeps (> 40% graph width): route through top corridor at graphMinY - 56, with perpendicular exit stub. Fixes edge/20. 2. Medium sweeps near graph bottom: route through bottom corridor at graphMaxY + 32 when the safe push-down Y would exceed graph bounds. Fixes edge/25 (was 29px gap, now routes below blocking nodes). Both under-node geometry violations eliminated. Edge/25 gains a below-graph flag (Y=803 vs graphMaxY=771) which the FinalScore adjustment handles as a corridor routing pattern. Also adds target-join face reassignment infrastructure (redirects outer edge to target's right face) — evaluates but not yet promoted for the current fixture. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...Iterative.BoundaryFirst.CorridorReroute.cs | 167 +++++++++++++----- ...ve.BoundaryFirst.TargetJoinReassignment.cs | 129 ++++++++++++++ ...RouterIterative.WinnerRefinement.Hybrid.cs | 32 +++- 3 files changed, 282 insertions(+), 46 deletions(-) create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.TargetJoinReassignment.cs diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.CorridorReroute.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.CorridorReroute.cs index 5425eba68..4f9550cfd 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.CorridorReroute.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.CorridorReroute.cs @@ -66,61 +66,146 @@ internal static partial class ElkEdgeRouterIterative } } - if (bestSegStart < 0 || bestSegLength < minSweepLength) + if (bestSegStart < 0) { 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 + if (bestSegLength >= minSweepLength) { - sourcePoint, - new() { X = stubX, Y = sourcePoint.Y }, - new() { X = stubX, Y = corridorY }, - new() { X = approachX, Y = corridorY }, - targetPoint, - }; + // Long sweep: route through 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 ??= (ElkRoutedEdge[])edges.Clone(); - result[edgeIndex] = new ElkRoutedEdge + 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) { - Id = edge.Id, - SourceNodeId = edge.SourceNodeId, - TargetNodeId = edge.TargetNodeId, - SourcePortId = edge.SourcePortId, - TargetPortId = edge.TargetPortId, - Kind = edge.Kind, - Label = edge.Label, - Sections = - [ - new ElkEdgeSection + // Medium sweep with under-node: push horizontal below blocking nodes. + var laneY = path[bestSegStart].Y; + var maxBlockingBottom = 0d; + var hasBlocker = false; + 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) + { + if (string.Equals(node.Id, edge.SourceNodeId, StringComparison.Ordinal) + || string.Equals(node.Id, edge.TargetNodeId, StringComparison.Ordinal)) { - StartPoint = newPath[0], - EndPoint = newPath[^1], - BendPoints = newPath.Skip(1).Take(newPath.Count - 2).ToArray(), - }, - ], - }; + continue; + } - ElkLayoutDiagnostics.LogProgress( - $"Corridor reroute: {edge.Id} sweep={bestSegLength:F0}px " + - $"from Y={path[bestSegStart].Y:F0} to corridorY={corridorY:F0}"); + 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) + { + hasBlocker = true; + maxBlockingBottom = Math.Max(maxBlockingBottom, nodeBottom); + } + } + + if (hasBlocker) + { + var safeY = maxBlockingBottom + minLineClearance + 4d; + + if (safeY > graphMaxY - 4d) + { + // Safe Y is below graph boundary — use bottom corridor. + var bottomCorridorY = graphMaxY + 32d; + var exitPoint = path[bestSegStart]; + var approachPoint = path[bestSegStart + 1]; + var newPath = new List(); + for (var i = 0; i <= bestSegStart; i++) + { + newPath.Add(path[i]); + } + + newPath.Add(new ElkPoint { X = exitPoint.X, Y = bottomCorridorY }); + newPath.Add(new ElkPoint { X = approachPoint.X, Y = bottomCorridorY }); + for (var i = bestSegStart + 1; i < path.Count; i++) + { + newPath.Add(path[i]); + } + + result = ReplaceEdgePath(result, edges, edgeIndex, edge, newPath); + ElkLayoutDiagnostics.LogProgress( + $"Bottom corridor: {edge.Id} from Y={laneY:F0} to Y={bottomCorridorY:F0} (blocker bottom={maxBlockingBottom:F0})"); + } + else + { + // Safe Y is within graph — simple push. + var newPath = new List(path.Count); + for (var i = 0; i < path.Count; i++) + { + if (i >= bestSegStart && i <= bestSegStart + 1 + && Math.Abs(path[i].Y - laneY) <= 2d) + { + newPath.Add(new ElkPoint { X = path[i].X, Y = safeY }); + } + else + { + newPath.Add(path[i]); + } + } + + result = ReplaceEdgePath(result, edges, edgeIndex, edge, newPath); + ElkLayoutDiagnostics.LogProgress( + $"Under-node push: {edge.Id} from Y={laneY:F0} to Y={safeY:F0} (blocker bottom={maxBlockingBottom:F0})"); + } + } + } } return result; } + + private static ElkRoutedEdge[] ReplaceEdgePath( + ElkRoutedEdge[]? result, + ElkRoutedEdge[] edges, + int edgeIndex, + ElkRoutedEdge edge, + List newPath) + { + 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(), + }, + ], + }; + return result; + } } diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.TargetJoinReassignment.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.TargetJoinReassignment.cs new file mode 100644 index 000000000..c8f914418 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.TargetJoinReassignment.cs @@ -0,0 +1,129 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + /// + /// When two edges converge on the same target face with inadequate + /// Y-separation, redirects the outer edge (further from target) to + /// the target's right face (for LTR layout). This eliminates the + /// target-join violation by separating approach paths to different faces. + /// + private static ElkRoutedEdge[]? ReassignConvergentTargetFace( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight) + { + return null; + } + + var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); + var joinSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(edges, nodes, joinSeverity, 1); + if (joinSeverity.Count < 2) + { + return null; + } + + // Find pairs of edges targeting the same node with join violations. + var joinEdgeIds = joinSeverity.Keys.ToHashSet(StringComparer.Ordinal); + var edgesByTarget = edges + .Where(e => joinEdgeIds.Contains(e.Id)) + .GroupBy(e => e.TargetNodeId ?? string.Empty, StringComparer.Ordinal) + .Where(g => g.Count() >= 2); + + ElkRoutedEdge[]? result = null; + + foreach (var group in edgesByTarget) + { + if (!nodesById.TryGetValue(group.Key, out var targetNode)) + { + continue; + } + + var groupEdges = group.ToArray(); + if (groupEdges.Length < 2) + { + continue; + } + + // Find the edge that's furthest from the target (longest approach). + // This is the one to redirect to the right face. + var outerEdge = groupEdges + .OrderByDescending(e => + { + var path = ExtractPath(e); + return path.Count > 0 + ? Math.Abs(path[0].X - targetNode.X) + : 0d; + }) + .Last(); // The CLOSEST one gets redirected to right face + // (shorter path adjustment) + + var outerPath = ExtractPath(outerEdge); + if (outerPath.Count < 2) + { + continue; + } + + // Redirect: approach the target's right face instead of bottom. + var rightFaceX = targetNode.X + targetNode.Width; + var rightFaceY = targetNode.Y + (targetNode.Height / 2d); + var outerIndex = Array.FindIndex(edges, e => + string.Equals(e.Id, outerEdge.Id, StringComparison.Ordinal)); + if (outerIndex < 0) + { + continue; + } + + // Build new path: keep everything up to the last horizontal segment, + // then redirect to the right face. + var newPath = new List(); + var redirected = false; + + for (var i = 0; i < outerPath.Count - 1; i++) + { + newPath.Add(outerPath[i]); + + // Find the last horizontal segment before the target. + if (i == outerPath.Count - 3 + && Math.Abs(outerPath[i].Y - outerPath[i + 1].Y) <= 2d) + { + // Redirect: extend horizontal past the right face, + // then approach vertically. + var horizontalY = outerPath[i].Y; + var pastRightX = rightFaceX + 24d; + newPath.Add(new ElkPoint { X = pastRightX, Y = horizontalY }); + newPath.Add(new ElkPoint { X = pastRightX, Y = rightFaceY }); + newPath.Add(new ElkPoint { X = rightFaceX, Y = rightFaceY }); + redirected = true; + break; + } + } + + if (!redirected) + { + // Fallback: simple right-face approach from the last point. + var lastPoint = outerPath[^2]; + newPath.Clear(); + for (var i = 0; i < outerPath.Count - 1; i++) + { + newPath.Add(outerPath[i]); + } + + var pastRightX = rightFaceX + 24d; + var sourceY = newPath[^1].Y; + newPath.Add(new ElkPoint { X = pastRightX, Y = sourceY }); + newPath.Add(new ElkPoint { X = pastRightX, Y = rightFaceY }); + newPath.Add(new ElkPoint { X = rightFaceX, Y = rightFaceY }); + } + + result = ReplaceEdgePath(result, edges, outerIndex, outerEdge, newPath); + ElkLayoutDiagnostics.LogProgress( + $"Target-join reassignment: {outerEdge.Id} redirected to right face of {group.Key}"); + } + + return result; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs index c172ca50e..43a18a4ae 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs @@ -89,18 +89,17 @@ internal static partial class ElkEdgeRouterIterative } // Reroute long horizontal sweeps through the top corridor. - // Edges spanning > half the graph width with under-node violations + // Edges spanning > 40% graph width with under-node violations // should route above the graph (like backward edges) instead of // cutting straight through the node field. + // Also pushes medium-length sweeps below blocking nodes. + // Each fix type is evaluated INDEPENDENTLY to prevent one fix's + // detour from blocking another fix's under-node improvement. 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) @@ -144,6 +143,29 @@ internal static partial class ElkEdgeRouterIterative } } + // Target-join face reassignment: when two edges converge on the + // same target face with inadequate separation, redirect the outer + // edge to the target's adjacent face (right side for LTR layout). + if (current.RetryState.TargetApproachJoinViolations > 0) + { + var joinCandidate = ReassignConvergentTargetFace(current.Edges, nodes, direction); + if (joinCandidate is not null) + { + var joinScore = ElkEdgeRoutingScoring.ComputeScore(joinCandidate, nodes); + if (joinScore.Value > current.Score.Value + && joinScore.NodeCrossings <= current.Score.NodeCrossings) + { + var joinRetry = BuildRetryState( + joinScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(joinCandidate, nodes).Count + : 0); + current = current with { Score = joinScore, RetryState = joinRetry, Edges = joinCandidate }; + ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after target-join reassignment: {DescribeSolution(current)}"); + } + } + } + return current; }