ElkSharp: gateway face overflow redirect, under-node push-first routing, boundary-slot snap
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,20 @@ internal static partial class ElkEdgeRouterIterative
|
||||
var currentGap = Math.Abs(approachYs[1] - approachYs[0]);
|
||||
if (currentGap >= minClearance)
|
||||
{
|
||||
// Horizontal approach lanes are well separated, but vertical
|
||||
// approach segments near the target may still converge (e.g.,
|
||||
// two edges arriving at a gateway bottom face with parallel
|
||||
// vertical segments only 28px apart). Redirect the edge whose
|
||||
// horizontal approach is closest to the node center to the
|
||||
// upstream face (left tip for LTR).
|
||||
if (ElkShapeBoundaries.IsGatewayShape(targetNode)
|
||||
&& ElkEdgeRoutingScoring.HasTargetApproachJoin(
|
||||
paths[0], paths[1], minClearance, 3))
|
||||
{
|
||||
result = TryRedirectGatewayFaceOverflowEntry(
|
||||
result, edges, groupEdges, paths, targetNode, approachYs);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -100,8 +114,7 @@ internal static partial class ElkEdgeRouterIterative
|
||||
var edgeIdx = which == 0 ? upperIdx : lowerIdx;
|
||||
var delta = which == 0 ? -halfSpread : halfSpread;
|
||||
var segIdx = approachSegIndices[edgeIdx];
|
||||
var origY = approachYs[edgeIdx];
|
||||
var newY = origY + delta;
|
||||
var newY = approachYs[edgeIdx] + delta;
|
||||
|
||||
var globalIdx = Array.FindIndex(edges, e =>
|
||||
string.Equals(e.Id, groupEdges[edgeIdx].Id, StringComparison.Ordinal));
|
||||
@@ -111,17 +124,10 @@ internal static partial class ElkEdgeRouterIterative
|
||||
}
|
||||
|
||||
var path = paths[edgeIdx];
|
||||
var newPath = new List<ElkPoint>(path.Count);
|
||||
for (var i = 0; i < path.Count; i++)
|
||||
var newPath = BuildTargetJoinSpreadPath(path, segIdx, newY);
|
||||
if (newPath.Count == 0)
|
||||
{
|
||||
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]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
result = ReplaceEdgePath(result, edges, globalIdx, edges[globalIdx], newPath);
|
||||
@@ -133,4 +139,167 @@ internal static partial class ElkEdgeRouterIterative
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<ElkPoint> BuildTargetJoinSpreadPath(
|
||||
IReadOnlyList<ElkPoint> path,
|
||||
int approachSegmentIndex,
|
||||
double newY)
|
||||
{
|
||||
if (approachSegmentIndex < 0 || approachSegmentIndex >= path.Count - 1)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var segmentStart = path[approachSegmentIndex];
|
||||
var segmentEnd = path[approachSegmentIndex + 1];
|
||||
var segmentLength = Math.Abs(segmentEnd.X - segmentStart.X);
|
||||
if (segmentLength <= 4d)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var inset = Math.Clamp(segmentLength / 4d, 12d, 24d);
|
||||
var transitionX = segmentEnd.X >= segmentStart.X
|
||||
? Math.Max(segmentStart.X + 4d, segmentEnd.X - inset)
|
||||
: Math.Min(segmentStart.X - 4d, segmentEnd.X + inset);
|
||||
if (Math.Abs(transitionX - segmentStart.X) <= 2d
|
||||
|| Math.Abs(segmentEnd.X - transitionX) <= 2d)
|
||||
{
|
||||
transitionX = (segmentStart.X + segmentEnd.X) / 2d;
|
||||
}
|
||||
|
||||
var newPath = new List<ElkPoint>(path.Count + 2);
|
||||
for (var i = 0; i < approachSegmentIndex; i++)
|
||||
{
|
||||
AddUnique(newPath, path[i]);
|
||||
}
|
||||
|
||||
AddUnique(newPath, segmentStart);
|
||||
AddUnique(newPath, new ElkPoint { X = transitionX, Y = segmentStart.Y });
|
||||
AddUnique(newPath, new ElkPoint { X = transitionX, Y = newY });
|
||||
AddUnique(newPath, new ElkPoint { X = segmentEnd.X, Y = newY });
|
||||
|
||||
for (var i = approachSegmentIndex + 2; i < path.Count; i++)
|
||||
{
|
||||
AddUnique(newPath, path[i]);
|
||||
}
|
||||
|
||||
return newPath;
|
||||
}
|
||||
|
||||
private static void AddUnique(List<ElkPoint> points, ElkPoint point)
|
||||
{
|
||||
if (points.Count > 0 && ElkEdgeRoutingGeometry.PointsEqual(points[^1], point))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
points.Add(new ElkPoint { X = point.X, Y = point.Y });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When two edges converge on a gateway face with insufficient room for
|
||||
/// proper slot spacing, redirects the edge whose horizontal approach is
|
||||
/// closest to the node center Y to the left tip vertex (for LTR layout).
|
||||
/// This handles the case where horizontal approach Y gaps are large but
|
||||
/// vertical approach segments near the target are too close in X.
|
||||
/// </summary>
|
||||
private static ElkRoutedEdge[]? TryRedirectGatewayFaceOverflowEntry(
|
||||
ElkRoutedEdge[]? result,
|
||||
ElkRoutedEdge[] edges,
|
||||
ElkRoutedEdge[] groupEdges,
|
||||
IReadOnlyList<ElkPoint>[] paths,
|
||||
ElkPositionedNode targetNode,
|
||||
double[] approachYs)
|
||||
{
|
||||
if (groupEdges.Length < 2)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var centerY = targetNode.Y + targetNode.Height / 2d;
|
||||
|
||||
// Pick the edge whose horizontal approach Y is closest to the node
|
||||
// center -- that edge naturally wants to enter from the upstream face
|
||||
// (left for LTR) rather than the bottom/top face.
|
||||
var redirectIdx = -1;
|
||||
var bestDistToCenter = double.MaxValue;
|
||||
for (var i = 0; i < groupEdges.Length; i++)
|
||||
{
|
||||
var dist = Math.Abs(approachYs[i] - centerY);
|
||||
if (dist < bestDistToCenter)
|
||||
{
|
||||
bestDistToCenter = dist;
|
||||
redirectIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (redirectIdx < 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var redirectPath = paths[redirectIdx];
|
||||
var leftTipX = targetNode.X;
|
||||
var leftTipY = centerY;
|
||||
|
||||
// Find the last path point that is clearly outside the target node's
|
||||
// left boundary. Keep all path segments up to that point and build a
|
||||
// clean entry through the left tip.
|
||||
var lastOutsideIdx = -1;
|
||||
for (var j = redirectPath.Count - 1; j >= 0; j--)
|
||||
{
|
||||
if (redirectPath[j].X < leftTipX - 4d)
|
||||
{
|
||||
lastOutsideIdx = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastOutsideIdx < 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Build the redirected path: keep everything up to the last outside
|
||||
// point, then route horizontally to a stub 24px left of the tip,
|
||||
// bend vertically to the tip Y, and enter at the tip. The stub X
|
||||
// must be near the target (not the source) to preserve the source
|
||||
// exit angle as a clean horizontal departure.
|
||||
var outsidePoint = redirectPath[lastOutsideIdx];
|
||||
var stubX = leftTipX - 24d;
|
||||
var newPath = new List<ElkPoint>(lastOutsideIdx + 5);
|
||||
for (var j = 0; j <= lastOutsideIdx; j++)
|
||||
{
|
||||
AddUnique(newPath, redirectPath[j]);
|
||||
}
|
||||
|
||||
// Horizontal approach to the stub X (preserves source exit angle).
|
||||
if (Math.Abs(outsidePoint.X - stubX) > 2d)
|
||||
{
|
||||
AddUnique(newPath, new ElkPoint { X = stubX, Y = outsidePoint.Y });
|
||||
}
|
||||
|
||||
// Vertical transition to the tip Y.
|
||||
var currentY = newPath.Count > 0 ? newPath[^1].Y : outsidePoint.Y;
|
||||
if (Math.Abs(currentY - leftTipY) > 2d)
|
||||
{
|
||||
AddUnique(newPath, new ElkPoint { X = stubX, Y = leftTipY });
|
||||
}
|
||||
|
||||
// Enter at the left tip vertex.
|
||||
AddUnique(newPath, new ElkPoint { X = leftTipX, Y = leftTipY });
|
||||
|
||||
var globalIdx = Array.FindIndex(edges, e =>
|
||||
string.Equals(e.Id, groupEdges[redirectIdx].Id, StringComparison.Ordinal));
|
||||
if (globalIdx < 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
result = ReplaceEdgePath(result, edges, globalIdx, edges[globalIdx], newPath);
|
||||
ElkLayoutDiagnostics.LogProgress(
|
||||
$"Gateway face redirect: {groupEdges[redirectIdx].Id} to left tip of {targetNode.Id}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user