306 lines
11 KiB
C#
306 lines
11 KiB
C#
namespace StellaOps.ElkSharp;
|
|
|
|
internal static partial class ElkEdgeRouterIterative
|
|
{
|
|
/// <summary>
|
|
/// When two edges converge on the same target face with inadequate
|
|
/// 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,
|
|
ElkPositionedNode[] nodes,
|
|
ElkLayoutDirection direction)
|
|
{
|
|
if (direction != ElkLayoutDirection.LeftToRight)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
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)
|
|
{
|
|
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.OrderBy(e =>
|
|
{
|
|
var path = ExtractPath(e);
|
|
return path.Count >= 2 ? path[^2].Y : 0d;
|
|
}).ToArray();
|
|
|
|
if (groupEdges.Length < 2)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// 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--)
|
|
{
|
|
if (Math.Abs(paths[i][j].Y - paths[i][j + 1].Y) <= 2d)
|
|
{
|
|
approachYs[i] = paths[i][j].Y;
|
|
approachSegIndices[i] = j;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (approachSegIndices.Any(idx => idx < 0))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Compute the current gap and required spread.
|
|
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;
|
|
}
|
|
|
|
var spreadNeeded = minClearance - currentGap + 8d;
|
|
var halfSpread = spreadNeeded / 2d;
|
|
|
|
// 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++)
|
|
{
|
|
var edgeIdx = which == 0 ? upperIdx : lowerIdx;
|
|
var delta = which == 0 ? -halfSpread : halfSpread;
|
|
var segIdx = approachSegIndices[edgeIdx];
|
|
var newY = approachYs[edgeIdx] + delta;
|
|
|
|
var globalIdx = Array.FindIndex(edges, e =>
|
|
string.Equals(e.Id, groupEdges[edgeIdx].Id, StringComparison.Ordinal));
|
|
if (globalIdx < 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var path = paths[edgeIdx];
|
|
var newPath = BuildTargetJoinSpreadPath(path, segIdx, newY);
|
|
if (newPath.Count == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
result = ReplaceEdgePath(result, edges, globalIdx, edges[globalIdx], newPath);
|
|
}
|
|
|
|
ElkLayoutDiagnostics.LogProgress(
|
|
$"Target-join spread: {groupEdges[0].Id}+{groupEdges[1].Id} gap={currentGap:F0}→{currentGap + spreadNeeded:F0}px");
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|