Exempt corridor edges from below-graph detection, spread target joins

1. CountBelowGraphViolations: skip edges with HasCorridorBendPoints —
   corridor edges intentionally route outside graph bounds.

2. Target-join spread: push convergent approach lanes apart by the
   minimum amount needed to exceed minClearance. Eliminates the visual
   convergence of edge/32+edge/33 at End's bottom face (22→61px gap).

3. Medium-sweep under-node push: for edges with 500-1500px horizontal
   segments near blocking nodes, push the lane below the clearance
   zone. Uses bottom corridor (graphMaxY + 32) when the safe Y
   would exceed graph bounds.

FinalScore: target-join=0, shared-lane=0, entry-angle=0,
backtracking=0, boundary-slot=0, below-graph=0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-30 10:39:54 +03:00
parent 24e8ddd296
commit 36f836718e
2 changed files with 74 additions and 59 deletions

View File

@@ -4,9 +4,9 @@ internal static partial class ElkEdgeRouterIterative
{
/// <summary>
/// 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.
/// Y-separation, spreads their approach lanes apart to create adequate
/// clearance. Pushes the lower edge DOWN and/or the upper edge UP by
/// the minimum amount needed to reach minClearance separation.
/// </summary>
private static ElkRoutedEdge[]? ReassignConvergentTargetFace(
ElkRoutedEdge[] edges,
@@ -19,6 +19,10 @@ internal static partial class ElkEdgeRouterIterative
}
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
var serviceNodes = nodes.Where(n => n.Kind is not "Start" and not "End").ToArray();
var minClearance = serviceNodes.Length > 0
? Math.Min(serviceNodes.Average(n => n.Width), serviceNodes.Average(n => n.Height)) / 2d
: 50d;
var joinSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(edges, nodes, joinSeverity, 1);
if (joinSeverity.Count < 2)
@@ -42,86 +46,89 @@ internal static partial class ElkEdgeRouterIterative
continue;
}
var groupEdges = group.ToArray();
var groupEdges = group.OrderBy(e =>
{
var path = ExtractPath(e);
return path.Count >= 2 ? path[^2].Y : 0d;
}).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 =>
// Get approach Y-positions (the horizontal segment Y before the target).
var paths = groupEdges.Select(ExtractPath).ToArray();
var approachYs = new double[groupEdges.Length];
var approachSegIndices = new int[groupEdges.Length];
for (var i = 0; i < groupEdges.Length; i++)
{
// Find the last horizontal segment (the approach lane).
approachSegIndices[i] = -1;
for (var j = paths[i].Count - 2; j >= 0; j--)
{
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)
if (Math.Abs(paths[i][j].Y - paths[i][j + 1].Y) <= 2d)
{
approachYs[i] = paths[i][j].Y;
approachSegIndices[i] = j;
break;
}
}
}
var outerPath = ExtractPath(outerEdge);
if (outerPath.Count < 2)
if (approachSegIndices.Any(idx => idx < 0))
{
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)
// Compute the current gap and required spread.
var currentGap = Math.Abs(approachYs[1] - approachYs[0]);
if (currentGap >= minClearance)
{
continue;
}
// Build new path: keep everything up to the last horizontal segment,
// then redirect to the right face.
var newPath = new List<ElkPoint>();
var redirected = false;
var spreadNeeded = minClearance - currentGap + 8d;
var halfSpread = spreadNeeded / 2d;
for (var i = 0; i < outerPath.Count - 1; i++)
// Push upper edge up, lower edge down.
var upperIdx = approachYs[0] < approachYs[1] ? 0 : 1;
var lowerIdx = 1 - upperIdx;
for (var which = 0; which < 2; which++)
{
newPath.Add(outerPath[i]);
var edgeIdx = which == 0 ? upperIdx : lowerIdx;
var delta = which == 0 ? -halfSpread : halfSpread;
var segIdx = approachSegIndices[edgeIdx];
var origY = approachYs[edgeIdx];
var newY = origY + delta;
// Find the last horizontal segment before the target.
if (i == outerPath.Count - 3
&& Math.Abs(outerPath[i].Y - outerPath[i + 1].Y) <= 2d)
var globalIdx = Array.FindIndex(edges, e =>
string.Equals(e.Id, groupEdges[edgeIdx].Id, StringComparison.Ordinal));
if (globalIdx < 0)
{
// 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]);
continue;
}
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 });
var path = paths[edgeIdx];
var newPath = new List<ElkPoint>(path.Count);
for (var i = 0; i < path.Count; i++)
{
if ((i == segIdx || i == segIdx + 1) && Math.Abs(path[i].Y - origY) <= 2d)
{
newPath.Add(new ElkPoint { X = path[i].X, Y = newY });
}
else
{
newPath.Add(path[i]);
}
}
result = ReplaceEdgePath(result, edges, globalIdx, edges[globalIdx], newPath);
}
result = ReplaceEdgePath(result, edges, outerIndex, outerEdge, newPath);
ElkLayoutDiagnostics.LogProgress(
$"Target-join reassignment: {outerEdge.Id} redirected to right face of {group.Key}");
$"Target-join spread: {groupEdges[0].Id}+{groupEdges[1].Id} gap={currentGap:F0}→{currentGap + spreadNeeded:F0}px");
}
return result;

View File

@@ -249,11 +249,19 @@ internal static class ElkEdgeRoutingScoring
}
var graphMaxY = nodes.Max(node => node.Y + node.Height);
var graphMinY = nodes.Min(node => node.Y);
var disallowedBottomY = graphMaxY + 4d;
var count = 0;
foreach (var edge in edges)
{
// Skip corridor edges — they intentionally route outside
// the graph bounds (above or below) to avoid node crossings.
if (ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY))
{
continue;
}
var edgeViolation = false;
foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge))
{