diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.TargetJoinReassignment.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.TargetJoinReassignment.cs
index c8f914418..bd5d677d2 100644
--- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.TargetJoinReassignment.cs
+++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.TargetJoinReassignment.cs
@@ -4,9 +4,9 @@ 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.
+ /// 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.
///
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(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();
- 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(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;
diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.cs
index 5edbdb2ad..7d9de80dd 100644
--- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.cs
+++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.cs
@@ -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))
{