Fix under-node violations with corridor routing and push-down

Two under-node fix strategies in the winner refinement:

1. Long sweeps (> 40% graph width): route through top corridor at
   graphMinY - 56, with perpendicular exit stub. Fixes edge/20.

2. Medium sweeps near graph bottom: route through bottom corridor at
   graphMaxY + 32 when the safe push-down Y would exceed graph bounds.
   Fixes edge/25 (was 29px gap, now routes below blocking nodes).

Both under-node geometry violations eliminated. Edge/25 gains a
below-graph flag (Y=803 vs graphMaxY=771) which the FinalScore
adjustment handles as a corridor routing pattern.

Also adds target-join face reassignment infrastructure (redirects
outer edge to target's right face) — evaluates but not yet promoted
for the current fixture.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-30 10:21:48 +03:00
parent 77bb608325
commit 24e8ddd296
3 changed files with 282 additions and 46 deletions

View File

@@ -66,61 +66,146 @@ internal static partial class ElkEdgeRouterIterative
}
}
if (bestSegStart < 0 || bestSegLength < minSweepLength)
if (bestSegStart < 0)
{
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>
if (bestSegLength >= minSweepLength)
{
sourcePoint,
new() { X = stubX, Y = sourcePoint.Y },
new() { X = stubX, Y = corridorY },
new() { X = approachX, Y = corridorY },
targetPoint,
};
// Long sweep: route through top corridor.
var exitX = sourcePoint.X;
var approachX = targetPoint.X;
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
result = ReplaceEdgePath(result, edges, edgeIndex, edge, newPath);
ElkLayoutDiagnostics.LogProgress(
$"Corridor reroute: {edge.Id} sweep={bestSegLength:F0}px to corridorY={corridorY:F0}");
}
else if (bestSegLength >= 500d)
{
Id = edge.Id,
SourceNodeId = edge.SourceNodeId,
TargetNodeId = edge.TargetNodeId,
SourcePortId = edge.SourcePortId,
TargetPortId = edge.TargetPortId,
Kind = edge.Kind,
Label = edge.Label,
Sections =
[
new ElkEdgeSection
// Medium sweep with under-node: push horizontal below blocking nodes.
var laneY = path[bestSegStart].Y;
var maxBlockingBottom = 0d;
var hasBlocker = false;
var minX = Math.Min(path[bestSegStart].X, path[bestSegStart + 1].X);
var maxX = Math.Max(path[bestSegStart].X, path[bestSegStart + 1].X);
foreach (var node in nodes)
{
if (string.Equals(node.Id, edge.SourceNodeId, StringComparison.Ordinal)
|| string.Equals(node.Id, edge.TargetNodeId, StringComparison.Ordinal))
{
StartPoint = newPath[0],
EndPoint = newPath[^1],
BendPoints = newPath.Skip(1).Take(newPath.Count - 2).ToArray(),
},
],
};
continue;
}
ElkLayoutDiagnostics.LogProgress(
$"Corridor reroute: {edge.Id} sweep={bestSegLength:F0}px " +
$"from Y={path[bestSegStart].Y:F0} to corridorY={corridorY:F0}");
if (maxX <= node.X + 0.5d || minX >= node.X + node.Width - 0.5d)
{
continue;
}
var nodeBottom = node.Y + node.Height;
var gap = laneY - nodeBottom;
if (gap > -4d && gap < minLineClearance)
{
hasBlocker = true;
maxBlockingBottom = Math.Max(maxBlockingBottom, nodeBottom);
}
}
if (hasBlocker)
{
var safeY = maxBlockingBottom + minLineClearance + 4d;
if (safeY > graphMaxY - 4d)
{
// Safe Y is below graph boundary — use bottom corridor.
var bottomCorridorY = graphMaxY + 32d;
var exitPoint = path[bestSegStart];
var approachPoint = path[bestSegStart + 1];
var newPath = new List<ElkPoint>();
for (var i = 0; i <= bestSegStart; i++)
{
newPath.Add(path[i]);
}
newPath.Add(new ElkPoint { X = exitPoint.X, Y = bottomCorridorY });
newPath.Add(new ElkPoint { X = approachPoint.X, Y = bottomCorridorY });
for (var i = bestSegStart + 1; i < path.Count; i++)
{
newPath.Add(path[i]);
}
result = ReplaceEdgePath(result, edges, edgeIndex, edge, newPath);
ElkLayoutDiagnostics.LogProgress(
$"Bottom corridor: {edge.Id} from Y={laneY:F0} to Y={bottomCorridorY:F0} (blocker bottom={maxBlockingBottom:F0})");
}
else
{
// Safe Y is within graph — simple push.
var newPath = new List<ElkPoint>(path.Count);
for (var i = 0; i < path.Count; i++)
{
if (i >= bestSegStart && i <= bestSegStart + 1
&& Math.Abs(path[i].Y - laneY) <= 2d)
{
newPath.Add(new ElkPoint { X = path[i].X, Y = safeY });
}
else
{
newPath.Add(path[i]);
}
}
result = ReplaceEdgePath(result, edges, edgeIndex, edge, newPath);
ElkLayoutDiagnostics.LogProgress(
$"Under-node push: {edge.Id} from Y={laneY:F0} to Y={safeY:F0} (blocker bottom={maxBlockingBottom:F0})");
}
}
}
}
return result;
}
private static ElkRoutedEdge[] ReplaceEdgePath(
ElkRoutedEdge[]? result,
ElkRoutedEdge[] edges,
int edgeIndex,
ElkRoutedEdge edge,
List<ElkPoint> newPath)
{
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(),
},
],
};
return result;
}
}

View File

@@ -0,0 +1,129 @@
namespace StellaOps.ElkSharp;
internal static partial class ElkEdgeRouterIterative
{
/// <summary>
/// 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.
/// </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 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.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 =>
{
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)
var outerPath = ExtractPath(outerEdge);
if (outerPath.Count < 2)
{
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)
{
continue;
}
// Build new path: keep everything up to the last horizontal segment,
// then redirect to the right face.
var newPath = new List<ElkPoint>();
var redirected = false;
for (var i = 0; i < outerPath.Count - 1; i++)
{
newPath.Add(outerPath[i]);
// Find the last horizontal segment before the target.
if (i == outerPath.Count - 3
&& Math.Abs(outerPath[i].Y - outerPath[i + 1].Y) <= 2d)
{
// 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]);
}
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 });
}
result = ReplaceEdgePath(result, edges, outerIndex, outerEdge, newPath);
ElkLayoutDiagnostics.LogProgress(
$"Target-join reassignment: {outerEdge.Id} redirected to right face of {group.Key}");
}
return result;
}
}

View File

@@ -89,18 +89,17 @@ internal static partial class ElkEdgeRouterIterative
}
// Reroute long horizontal sweeps through the top corridor.
// Edges spanning > half the graph width with under-node violations
// Edges spanning > 40% graph width with under-node violations
// should route above the graph (like backward edges) instead of
// cutting straight through the node field.
// Also pushes medium-length sweeps below blocking nodes.
// Each fix type is evaluated INDEPENDENTLY to prevent one fix's
// detour from blocking another fix's under-node improvement.
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)
@@ -144,6 +143,29 @@ internal static partial class ElkEdgeRouterIterative
}
}
// Target-join face reassignment: when two edges converge on the
// same target face with inadequate separation, redirect the outer
// edge to the target's adjacent face (right side for LTR layout).
if (current.RetryState.TargetApproachJoinViolations > 0)
{
var joinCandidate = ReassignConvergentTargetFace(current.Edges, nodes, direction);
if (joinCandidate is not null)
{
var joinScore = ElkEdgeRoutingScoring.ComputeScore(joinCandidate, nodes);
if (joinScore.Value > current.Score.Value
&& joinScore.NodeCrossings <= current.Score.NodeCrossings)
{
var joinRetry = BuildRetryState(
joinScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(joinCandidate, nodes).Count
: 0);
current = current with { Score = joinScore, RetryState = joinRetry, Edges = joinCandidate };
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after target-join reassignment: {DescribeSolution(current)}");
}
}
}
return current;
}