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:
master
2026-04-02 07:53:13 +03:00
parent f4df1c1274
commit fef0f63c5c
3 changed files with 58 additions and 22 deletions

View File

@@ -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)
{

View File

@@ -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");
}
}
}

View File

@@ -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