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:
@@ -40,7 +40,7 @@ public partial class DocumentProcessingWorkflowRenderingTests
|
|||||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||||
{
|
{
|
||||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||||
NodeSpacing = 50,
|
NodeSpacing = 40,
|
||||||
});
|
});
|
||||||
|
|
||||||
var svgRenderer = new WorkflowRenderSvgRenderer();
|
var svgRenderer = new WorkflowRenderSvgRenderer();
|
||||||
|
|||||||
@@ -83,22 +83,21 @@ internal static partial class ElkEdgeRouterIterative
|
|||||||
|
|
||||||
// Compute the current gap and required spread.
|
// Compute the current gap and required spread.
|
||||||
var currentGap = Math.Abs(approachYs[1] - approachYs[0]);
|
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)
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -299,19 +299,53 @@ internal static partial class ElkEdgeRouterIterative
|
|||||||
var finalJoinCount = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(current.Edges, nodes, finalJoinSeverity, 1);
|
var finalJoinCount = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(current.Edges, nodes, finalJoinSeverity, 1);
|
||||||
ElkLayoutDiagnostics.LogProgress(
|
ElkLayoutDiagnostics.LogProgress(
|
||||||
$"Final target-join check: count={finalJoinCount} edges=[{string.Join(", ", finalJoinSeverity.Keys.OrderBy(k => k))}]");
|
$"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();
|
// Try two approaches: spread (pushes edges apart) and reassignment
|
||||||
var joinCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(
|
// (redirects one edge to a different face). Accept whichever fixes
|
||||||
current.Edges, nodes, minLineClearance, joinFocus, forceOutwardAxisSpacing: true);
|
// the join without creating detours or shared lanes.
|
||||||
joinCandidate = ElkEdgePostProcessor.StraightenGatewayCornerDiagonals(joinCandidate, nodes);
|
ElkRoutedEdge[]? bestJoinCandidate = null;
|
||||||
var joinScore = ElkEdgeRoutingScoring.ComputeScore(joinCandidate, nodes);
|
EdgeRoutingScore bestJoinScore = default;
|
||||||
var joinJoinCount = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(joinCandidate, nodes);
|
var bestJoinCount = finalJoinCount;
|
||||||
if (joinJoinCount < 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(
|
ElkLayoutDiagnostics.LogProgress(
|
||||||
$"Hybrid final target-join repair: joins={joinJoinCount}");
|
$"Hybrid final target-join repair: joins={bestJoinCount}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user