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:
@@ -66,61 +66,146 @@ internal static partial class ElkEdgeRouterIterative
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bestSegStart < 0 || bestSegLength < minSweepLength)
|
if (bestSegStart < 0)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build corridor path: source exit → up to corridor → across → down to target.
|
|
||||||
var sourcePoint = path[0];
|
var sourcePoint = path[0];
|
||||||
var targetPoint = path[^1];
|
var targetPoint = path[^1];
|
||||||
var exitX = sourcePoint.X;
|
|
||||||
var approachX = targetPoint.X;
|
|
||||||
|
|
||||||
// Determine corridor direction based on source position relative to graph.
|
if (bestSegLength >= minSweepLength)
|
||||||
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,
|
// Long sweep: route through top corridor.
|
||||||
new() { X = stubX, Y = sourcePoint.Y },
|
var exitX = sourcePoint.X;
|
||||||
new() { X = stubX, Y = corridorY },
|
var approachX = targetPoint.X;
|
||||||
new() { X = approachX, Y = corridorY },
|
var stubX = exitX + 24d;
|
||||||
targetPoint,
|
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 = ReplaceEdgePath(result, edges, edgeIndex, edge, newPath);
|
||||||
result[edgeIndex] = new ElkRoutedEdge
|
ElkLayoutDiagnostics.LogProgress(
|
||||||
|
$"Corridor reroute: {edge.Id} sweep={bestSegLength:F0}px to corridorY={corridorY:F0}");
|
||||||
|
}
|
||||||
|
else if (bestSegLength >= 500d)
|
||||||
{
|
{
|
||||||
Id = edge.Id,
|
// Medium sweep with under-node: push horizontal below blocking nodes.
|
||||||
SourceNodeId = edge.SourceNodeId,
|
var laneY = path[bestSegStart].Y;
|
||||||
TargetNodeId = edge.TargetNodeId,
|
var maxBlockingBottom = 0d;
|
||||||
SourcePortId = edge.SourcePortId,
|
var hasBlocker = false;
|
||||||
TargetPortId = edge.TargetPortId,
|
var minX = Math.Min(path[bestSegStart].X, path[bestSegStart + 1].X);
|
||||||
Kind = edge.Kind,
|
var maxX = Math.Max(path[bestSegStart].X, path[bestSegStart + 1].X);
|
||||||
Label = edge.Label,
|
|
||||||
Sections =
|
foreach (var node in nodes)
|
||||||
[
|
{
|
||||||
new ElkEdgeSection
|
if (string.Equals(node.Id, edge.SourceNodeId, StringComparison.Ordinal)
|
||||||
|
|| string.Equals(node.Id, edge.TargetNodeId, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
StartPoint = newPath[0],
|
continue;
|
||||||
EndPoint = newPath[^1],
|
}
|
||||||
BendPoints = newPath.Skip(1).Take(newPath.Count - 2).ToArray(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
ElkLayoutDiagnostics.LogProgress(
|
if (maxX <= node.X + 0.5d || minX >= node.X + node.Width - 0.5d)
|
||||||
$"Corridor reroute: {edge.Id} sweep={bestSegLength:F0}px " +
|
{
|
||||||
$"from Y={path[bestSegStart].Y:F0} to corridorY={corridorY:F0}");
|
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;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,18 +89,17 @@ internal static partial class ElkEdgeRouterIterative
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reroute long horizontal sweeps through the top corridor.
|
// 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
|
// should route above the graph (like backward edges) instead of
|
||||||
// cutting straight through the node field.
|
// 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)
|
if (current.RetryState.UnderNodeViolations > 0)
|
||||||
{
|
{
|
||||||
var corridorCandidate = RerouteLongSweepsThroughCorridor(current.Edges, nodes, direction, minLineClearance);
|
var corridorCandidate = RerouteLongSweepsThroughCorridor(current.Edges, nodes, direction, minLineClearance);
|
||||||
if (corridorCandidate is not null)
|
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);
|
var corridorScore = ElkEdgeRoutingScoring.ComputeScore(corridorCandidate, nodes);
|
||||||
if (corridorScore.Value > current.Score.Value
|
if (corridorScore.Value > current.Score.Value
|
||||||
&& corridorScore.NodeCrossings <= current.Score.NodeCrossings)
|
&& 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;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user