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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user