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> /// <summary>
/// When two edges converge on the same target face with inadequate /// When two edges converge on the same target face with inadequate
/// Y-separation, redirects the outer edge (further from target) to /// Y-separation, spreads their approach lanes apart to create adequate
/// the target's right face (for LTR layout). This eliminates the /// clearance. Pushes the lower edge DOWN and/or the upper edge UP by
/// target-join violation by separating approach paths to different faces. /// the minimum amount needed to reach minClearance separation.
/// </summary> /// </summary>
private static ElkRoutedEdge[]? ReassignConvergentTargetFace( private static ElkRoutedEdge[]? ReassignConvergentTargetFace(
ElkRoutedEdge[] edges, ElkRoutedEdge[] edges,
@@ -19,6 +19,10 @@ internal static partial class ElkEdgeRouterIterative
} }
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); 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); var joinSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(edges, nodes, joinSeverity, 1); ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(edges, nodes, joinSeverity, 1);
if (joinSeverity.Count < 2) if (joinSeverity.Count < 2)
@@ -42,86 +46,89 @@ internal static partial class ElkEdgeRouterIterative
continue; 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) if (groupEdges.Length < 2)
{ {
continue; continue;
} }
// Find the edge that's furthest from the target (longest approach). // Get approach Y-positions (the horizontal segment Y before the target).
// This is the one to redirect to the right face. var paths = groupEdges.Select(ExtractPath).ToArray();
var outerEdge = groupEdges var approachYs = new double[groupEdges.Length];
.OrderByDescending(e => 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); if (Math.Abs(paths[i][j].Y - paths[i][j + 1].Y) <= 2d)
return path.Count > 0 {
? Math.Abs(path[0].X - targetNode.X) approachYs[i] = paths[i][j].Y;
: 0d; approachSegIndices[i] = j;
}) break;
.Last(); // The CLOSEST one gets redirected to right face }
// (shorter path adjustment) }
}
var outerPath = ExtractPath(outerEdge); if (approachSegIndices.Any(idx => idx < 0))
if (outerPath.Count < 2)
{ {
continue; continue;
} }
// Redirect: approach the target's right face instead of bottom. // Compute the current gap and required spread.
var rightFaceX = targetNode.X + targetNode.Width; var currentGap = Math.Abs(approachYs[1] - approachYs[0]);
var rightFaceY = targetNode.Y + (targetNode.Height / 2d); if (currentGap >= minClearance)
var outerIndex = Array.FindIndex(edges, e =>
string.Equals(e.Id, outerEdge.Id, StringComparison.Ordinal));
if (outerIndex < 0)
{ {
continue; continue;
} }
// Build new path: keep everything up to the last horizontal segment, var spreadNeeded = minClearance - currentGap + 8d;
// then redirect to the right face. var halfSpread = spreadNeeded / 2d;
var newPath = new List<ElkPoint>();
var redirected = false;
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. var globalIdx = Array.FindIndex(edges, e =>
if (i == outerPath.Count - 3 string.Equals(e.Id, groupEdges[edgeIdx].Id, StringComparison.Ordinal));
&& Math.Abs(outerPath[i].Y - outerPath[i + 1].Y) <= 2d) if (globalIdx < 0)
{ {
// Redirect: extend horizontal past the right face, continue;
// 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 path = paths[edgeIdx];
var sourceY = newPath[^1].Y; var newPath = new List<ElkPoint>(path.Count);
newPath.Add(new ElkPoint { X = pastRightX, Y = sourceY }); for (var i = 0; i < path.Count; i++)
newPath.Add(new ElkPoint { X = pastRightX, Y = rightFaceY }); {
newPath.Add(new ElkPoint { X = rightFaceX, Y = rightFaceY }); 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( 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; return result;

View File

@@ -249,11 +249,19 @@ internal static class ElkEdgeRoutingScoring
} }
var graphMaxY = nodes.Max(node => node.Y + node.Height); var graphMaxY = nodes.Max(node => node.Y + node.Height);
var graphMinY = nodes.Min(node => node.Y);
var disallowedBottomY = graphMaxY + 4d; var disallowedBottomY = graphMaxY + 4d;
var count = 0; var count = 0;
foreach (var edge in edges) 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; var edgeViolation = false;
foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge)) foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge))
{ {