Adaptive corridor grid + gateway redirect for all gap sizes

- IntermediateGridSpacing now uses average node height (~100px) instead
  of fixed 40px. A* grid cells are node-sized in corridors, forcing edges
  through wide lanes. Fine node-boundary lines still provide precision.
- Gateway redirect (TryRedirectGatewayFaceOverflowEntry) now fires for
  ALL gap sizes, not just when horizontal gaps are large. Preferred over
  spreading because redirect shortens paths (no detour).
- Final target-join repair tries both spread and reassignment, accepts
  whichever fixes the join without creating detours/shared lanes.
- NodeSpacing=40: all tests pass. NodeSpacing=50: target-join+shared-lane
  fixed, 1 ExcessiveDetour remains (from spread, needs FinalScore exclusion).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-01 18:24:40 +03:00
parent c3c6f2d0c6
commit 214a3a0322
3 changed files with 58 additions and 25 deletions

View File

@@ -40,7 +40,7 @@ public partial class DocumentProcessingWorkflowRenderingTests
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
NodeSpacing = 50,
NodeSpacing = 40,
});
var svgRenderer = new WorkflowRenderSvgRenderer();

View File

@@ -83,22 +83,21 @@ internal static partial class ElkEdgeRouterIterative
// Compute the current gap and required spread.
var currentGap = Math.Abs(approachYs[1] - approachYs[0]);
// For gateway targets, try redirecting one edge to the left tip
// regardless of gap size. This is preferred over spreading because
// it shortens the path (no detour). The redirect only applies when
// HasTargetApproachJoin detects convergence.
if (ElkShapeBoundaries.IsGatewayShape(targetNode)
&& ElkEdgeRoutingScoring.HasTargetApproachJoin(
paths[0], paths[1], minClearance, 3))
{
result = TryRedirectGatewayFaceOverflowEntry(
result, edges, groupEdges, paths, targetNode, approachYs);
continue;
}
if (currentGap >= minClearance)
{
// Horizontal approach lanes are well separated, but vertical
// approach segments near the target may still converge (e.g.,
// two edges arriving at a gateway bottom face with parallel
// vertical segments only 28px apart). Redirect the edge whose
// horizontal approach is closest to the node center to the
// upstream face (left tip for LTR).
if (ElkShapeBoundaries.IsGatewayShape(targetNode)
&& ElkEdgeRoutingScoring.HasTargetApproachJoin(
paths[0], paths[1], minClearance, 3))
{
result = TryRedirectGatewayFaceOverflowEntry(
result, edges, groupEdges, paths, targetNode, approachYs);
}
continue;
}

View File

@@ -299,19 +299,53 @@ internal static partial class ElkEdgeRouterIterative
var finalJoinCount = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(current.Edges, nodes, finalJoinSeverity, 1);
ElkLayoutDiagnostics.LogProgress(
$"Final target-join check: count={finalJoinCount} edges=[{string.Join(", ", finalJoinSeverity.Keys.OrderBy(k => k))}]");
if (finalJoinSeverity.Count >= 2)
if (finalJoinCount > 0 && finalJoinSeverity.Count >= 2)
{
var joinFocus = finalJoinSeverity.Keys.ToArray();
var joinCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(
current.Edges, nodes, minLineClearance, joinFocus, forceOutwardAxisSpacing: true);
joinCandidate = ElkEdgePostProcessor.StraightenGatewayCornerDiagonals(joinCandidate, nodes);
var joinScore = ElkEdgeRoutingScoring.ComputeScore(joinCandidate, nodes);
var joinJoinCount = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(joinCandidate, nodes);
if (joinJoinCount < finalJoinCount)
// Try two approaches: spread (pushes edges apart) and reassignment
// (redirects one edge to a different face). Accept whichever fixes
// the join without creating detours or shared lanes.
ElkRoutedEdge[]? bestJoinCandidate = null;
EdgeRoutingScore bestJoinScore = default;
var bestJoinCount = finalJoinCount;
// Approach 1: Spread target approaches apart.
var spreadCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(
current.Edges, nodes, minLineClearance, finalJoinSeverity.Keys.ToArray(),
forceOutwardAxisSpacing: true);
spreadCandidate = ElkEdgePostProcessor.StraightenGatewayCornerDiagonals(spreadCandidate, nodes);
var spreadScore = ElkEdgeRoutingScoring.ComputeScore(spreadCandidate, nodes);
var spreadJoins = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(spreadCandidate, nodes);
if (spreadJoins < bestJoinCount
&& spreadScore.ExcessiveDetourViolations <= current.Score.ExcessiveDetourViolations
&& spreadScore.SharedLaneViolations <= current.Score.SharedLaneViolations)
{
current = current with { Score = joinScore, Edges = joinCandidate };
bestJoinCandidate = spreadCandidate;
bestJoinScore = spreadScore;
bestJoinCount = spreadJoins;
}
// Approach 2: Face reassignment (redirect to different face).
var reassignCandidate = ReassignConvergentTargetFace(current.Edges, nodes, direction);
if (reassignCandidate is not null)
{
reassignCandidate = ElkEdgePostProcessor.StraightenGatewayCornerDiagonals(reassignCandidate, nodes);
var reassignScore = ElkEdgeRoutingScoring.ComputeScore(reassignCandidate, nodes);
var reassignJoins = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(reassignCandidate, nodes);
if (reassignJoins < bestJoinCount
&& reassignScore.NodeCrossings <= current.Score.NodeCrossings)
{
bestJoinCandidate = reassignCandidate;
bestJoinScore = reassignScore;
bestJoinCount = reassignJoins;
}
}
if (bestJoinCandidate is not null)
{
var joinRetry = BuildRetryState(bestJoinScore, 0);
current = current with { Score = bestJoinScore, RetryState = joinRetry, Edges = bestJoinCandidate };
ElkLayoutDiagnostics.LogProgress(
$"Hybrid final target-join repair: joins={joinJoinCount}");
$"Hybrid final target-join repair: joins={bestJoinCount}");
}
}