Reroute long horizontal sweeps through top corridor

Detects horizontal segments > 40% of graph width with under-node
violations and reroutes them through the top corridor (Y = graphMinY
- 56), similar to backward edge routing. The corridor path includes a
24px perpendicular exit stub that survives NormalizeBoundaryAngles
without being collapsed.

Fixes edge/20 (3076px horizontal sweep from Load Configuration to End)
which previously crossed 10 layers at Y=201, passing under intermediate
nodes. Now routes above the graph at Y=-24.

Remaining geometry violations: 2 (target-join edge/32+33, under-node
edge/25).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-30 09:34:04 +03:00
parent 7e62f9c0c4
commit 77bb608325
2 changed files with 154 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
namespace StellaOps.ElkSharp;
internal static partial class ElkEdgeRouterIterative
{
/// <summary>
/// Detects long horizontal sweeps with under-node violations and reroutes
/// them through the top corridor (above the graph), similar to how backward
/// edges are routed through external corridors.
/// </summary>
private static ElkRoutedEdge[]? RerouteLongSweepsThroughCorridor(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
ElkLayoutDirection direction,
double minLineClearance)
{
if (direction != ElkLayoutDirection.LeftToRight || nodes.Length == 0)
{
return null;
}
var graphMinY = nodes.Min(n => n.Y);
var graphMaxY = nodes.Max(n => n.Y + n.Height);
var graphWidth = nodes.Max(n => n.X + n.Width) - nodes.Min(n => n.X);
var minSweepLength = graphWidth * 0.4d;
var corridorY = graphMinY - 56d;
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
var underNodeSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountUnderNodeViolations(edges, nodes, underNodeSeverity, 1);
if (underNodeSeverity.Count == 0)
{
return null;
}
ElkRoutedEdge[]? result = null;
for (var edgeIndex = 0; edgeIndex < edges.Length; edgeIndex++)
{
var edge = edges[edgeIndex];
if (!underNodeSeverity.ContainsKey(edge.Id))
{
continue;
}
var path = ExtractPath(edge);
if (path.Count < 2)
{
continue;
}
// Find the longest horizontal segment.
var bestSegStart = -1;
var bestSegLength = 0d;
for (var i = 0; i < path.Count - 1; i++)
{
if (Math.Abs(path[i].Y - path[i + 1].Y) > 2d)
{
continue;
}
var segLength = Math.Abs(path[i + 1].X - path[i].X);
if (segLength > bestSegLength)
{
bestSegLength = segLength;
bestSegStart = i;
}
}
if (bestSegStart < 0 || bestSegLength < minSweepLength)
{
continue;
}
// Build corridor path: source exit → up to corridor → across → down to target.
var sourcePoint = path[0];
var targetPoint = path[^1];
var exitX = sourcePoint.X;
var approachX = targetPoint.X;
// Determine corridor direction based on source position relative to graph.
var sourceNode = nodesById.GetValueOrDefault(edge.SourceNodeId ?? string.Empty);
var sourceTopY = sourceNode?.Y ?? sourcePoint.Y;
// Build corridor path with a perpendicular exit stub.
// The stub prevents NormalizeBoundaryAngles from collapsing
// the vertical corridor segment (it removes path[1] while
// path[1].X == sourceX, so the stub at exitX+24 survives).
var stubX = exitX + 24d;
var newPath = new List<ElkPoint>
{
sourcePoint,
new() { X = stubX, Y = sourcePoint.Y },
new() { X = stubX, Y = corridorY },
new() { X = approachX, Y = corridorY },
targetPoint,
};
result ??= (ElkRoutedEdge[])edges.Clone();
result[edgeIndex] = new ElkRoutedEdge
{
Id = edge.Id,
SourceNodeId = edge.SourceNodeId,
TargetNodeId = edge.TargetNodeId,
SourcePortId = edge.SourcePortId,
TargetPortId = edge.TargetPortId,
Kind = edge.Kind,
Label = edge.Label,
Sections =
[
new ElkEdgeSection
{
StartPoint = newPath[0],
EndPoint = newPath[^1],
BendPoints = newPath.Skip(1).Take(newPath.Count - 2).ToArray(),
},
],
};
ElkLayoutDiagnostics.LogProgress(
$"Corridor reroute: {edge.Id} sweep={bestSegLength:F0}px " +
$"from Y={path[bestSegStart].Y:F0} to corridorY={corridorY:F0}");
}
return result;
}
}

View File

@@ -88,6 +88,34 @@ internal static partial class ElkEdgeRouterIterative
}
}
// Reroute long horizontal sweeps through the top corridor.
// Edges spanning > half the graph width with under-node violations
// should route above the graph (like backward edges) instead of
// cutting straight through the node field.
if (current.RetryState.UnderNodeViolations > 0)
{
var corridorCandidate = RerouteLongSweepsThroughCorridor(current.Edges, nodes, direction, minLineClearance);
if (corridorCandidate is not null)
{
// Skip NormalizeBoundaryAngles for corridor-rerouted edges —
// the normalization's NormalizeExitPath collapses corridor
// vertical segments. The corridor path already has a correct
// perpendicular exit stub.
var corridorScore = ElkEdgeRoutingScoring.ComputeScore(corridorCandidate, nodes);
if (corridorScore.Value > current.Score.Value
&& corridorScore.NodeCrossings <= current.Score.NodeCrossings)
{
var corridorRetry = BuildRetryState(
corridorScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(corridorCandidate, nodes).Count
: 0);
current = current with { Score = corridorScore, RetryState = corridorRetry, Edges = corridorCandidate };
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after corridor reroute: {DescribeSolution(current)}");
}
}
}
// Targeted under-node elevation with net-total promotion.
// ElevateUnderNodeViolations can fix remaining under-node edges
// (gateway-exit lanes, long horizontal sweeps) but the standard