Fix corridor reroute: push-first for under-node, corridor for visual
Restored push-first approach for long sweeps WITH under-node violations (NodeSpacing=40 needs small Y adjustments, not corridor routing). Corridor-only for visual sweeps WITHOUT under-node violations (handled by unconditional corridor in winner refinement). Corridor offset uses node-size clearance + 4px (not spacing-scaled) to avoid repeat-collector conflicts. Gated on no new repeat-collector or node-crossing regressions. Both NodeSpacing=40 and NodeSpacing=50 pass all 44+ assertions. NodeSpacing=50 set as test default (visually cleaner, 56s vs 2m43s). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -109,25 +109,52 @@ internal static partial class ElkEdgeRouterIterative
|
||||
|
||||
if (bestSegLength >= minSweepLength)
|
||||
{
|
||||
// Long sweep (>40% graph width): route through the top corridor.
|
||||
// These are "highway" edges that span the graph and look ugly
|
||||
// cutting through the node field. The corridor moves them above
|
||||
// the graph for clean visual separation.
|
||||
var exitX = sourcePoint.X;
|
||||
var approachX = targetPoint.X;
|
||||
var stubX = exitX + 24d;
|
||||
var newPath = new List<ElkPoint>
|
||||
if (underNodeSeverity.ContainsKey(edge.Id))
|
||||
{
|
||||
sourcePoint,
|
||||
new() { X = stubX, Y = sourcePoint.Y },
|
||||
new() { X = stubX, Y = corridorY },
|
||||
new() { X = approachX, Y = corridorY },
|
||||
targetPoint,
|
||||
};
|
||||
// Under-node long sweep: push below blocking nodes first.
|
||||
var laneY = path[bestSegStart].Y;
|
||||
var maxBlockBottom = 0d;
|
||||
var pushMinX = Math.Min(path[bestSegStart].X, path[bestSegStart + 1].X);
|
||||
var pushMaxX = 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))
|
||||
continue;
|
||||
if (pushMaxX <= node.X + 0.5d || pushMinX >= node.X + node.Width - 0.5d)
|
||||
continue;
|
||||
var nodeBottom = node.Y + node.Height;
|
||||
var gap = laneY - nodeBottom;
|
||||
if (gap > -4d && gap < minLineClearance)
|
||||
maxBlockBottom = Math.Max(maxBlockBottom, nodeBottom);
|
||||
}
|
||||
|
||||
result = ReplaceEdgePath(result, edges, edgeIndex, edge, newPath);
|
||||
ElkLayoutDiagnostics.LogProgress(
|
||||
$"Corridor reroute: {edge.Id} sweep={bestSegLength:F0}px to corridorY={corridorY:F0}");
|
||||
if (maxBlockBottom > 0d)
|
||||
{
|
||||
var pushY = maxBlockBottom + minLineClearance + 4d;
|
||||
if (pushY <= graphMaxY - 4d)
|
||||
{
|
||||
var newPath = new List<ElkPoint>(path.Count);
|
||||
for (var pi = 0; pi < path.Count; pi++)
|
||||
{
|
||||
if (pi >= bestSegStart && pi <= bestSegStart + 1
|
||||
&& Math.Abs(path[pi].Y - laneY) <= 2d)
|
||||
newPath.Add(new ElkPoint { X = path[pi].X, Y = pushY });
|
||||
else
|
||||
newPath.Add(path[pi]);
|
||||
}
|
||||
|
||||
result = ReplaceEdgePath(result, edges, edgeIndex, edge, newPath);
|
||||
ElkLayoutDiagnostics.LogProgress(
|
||||
$"Under-node push: {edge.Id} from Y={laneY:F0} to Y={pushY:F0} (blocker bottom={maxBlockBottom:F0})");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Visual-only long sweep: corridor route (handled by
|
||||
// unconditional corridor in the winner refinement).
|
||||
}
|
||||
}
|
||||
else if (bestSegLength >= 500d)
|
||||
{
|
||||
|
||||
@@ -312,7 +312,11 @@ internal static partial class ElkEdgeRouterIterative
|
||||
var segLen = Math.Abs(cpath[si + 1].X - cpath[si].X);
|
||||
var laneY = cpath[si].Y;
|
||||
if (segLen < localMinSweep || laneY <= graphMinYLocal - 10d) continue;
|
||||
var localCorridorY = baseCorridorY - (corridorFixed * 24d);
|
||||
// Offset must exceed the target-join detection threshold
|
||||
// (node-size clearance, not spacing-scaled) so parallel
|
||||
// corridor segments aren't flagged as joins.
|
||||
var nodeSizeClearance = ElkEdgeRoutingScoring.ResolveNodeSizeClearance(nodes);
|
||||
var localCorridorY = baseCorridorY - (corridorFixed * (nodeSizeClearance + 4d));
|
||||
var src = cpath[0];
|
||||
var tgt = cpath[^1];
|
||||
var stubX = src.X + 24d;
|
||||
@@ -342,9 +346,14 @@ internal static partial class ElkEdgeRouterIterative
|
||||
if (corridorFixed > 0)
|
||||
{
|
||||
var corridorScore = ElkEdgeRoutingScoring.ComputeScore(corridorResult, nodes);
|
||||
current = current with { Score = corridorScore, Edges = corridorResult };
|
||||
ElkLayoutDiagnostics.LogProgress(
|
||||
$"Unconditional corridor reroute: {corridorFixed} edges to corridor");
|
||||
// Accept only if no new repeat-collector or node-crossing regressions.
|
||||
if (corridorScore.RepeatCollectorCorridorViolations <= current.Score.RepeatCollectorCorridorViolations
|
||||
&& corridorScore.NodeCrossings <= current.Score.NodeCrossings)
|
||||
{
|
||||
current = current with { Score = corridorScore, Edges = corridorResult };
|
||||
ElkLayoutDiagnostics.LogProgress(
|
||||
$"Unconditional corridor reroute: {corridorFixed} edges to corridor");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -247,7 +247,7 @@ internal static partial class ElkEdgeRoutingScoring
|
||||
/// face-related detections (target-join, boundary-slot) where the gap
|
||||
/// depends on node face geometry, not inter-node routing corridors.
|
||||
/// </summary>
|
||||
private static double ResolveNodeSizeClearance(IReadOnlyCollection<ElkPositionedNode> nodes)
|
||||
internal static double ResolveNodeSizeClearance(IReadOnlyCollection<ElkPositionedNode> nodes)
|
||||
{
|
||||
var serviceNodes = nodes.Where(n => n.Kind is not "Start" and not "End").ToArray();
|
||||
return serviceNodes.Length > 0
|
||||
|
||||
Reference in New Issue
Block a user