Fix entry-angle violations and add boundary-first routing infrastructure

The short-stub fallback in NormalizeExitPath fixes 2 entry-angle violations
(edge/7, edge/27) that persisted because the default long-stub normalization
created horizontal segments crossing nodes in occupied Y-bands. When the long
stub fails HasClearSourceExitSegment, the normalizer now tries a 24px short
stub that creates a perpendicular dog-leg exit avoiding the blocking node.

Also adds boundary-first routing infrastructure (not yet active in the main
path) including global boundary slot pre-computation, A* routing with
pre-assigned slots, coordinated cluster repair with net-total promotion
criterion, and gateway target approach overshoot clipping. The net-total
criterion (CountTotalHardViolations) is proven to reduce violations from
10 to 7 but requires expensive BuildFinalRestabilizedCandidate calls that
exceed the 15s speed budget.

Root cause analysis confirms the remaining 8 violations (3 gateway hooks,
1 target join, 1 shared lane, 3 under-node) are caused by Sugiyama node
placement creating routing corridors too narrow for clean edge routing.
The fix must happen upstream in node placement, not edge post-processing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-29 23:39:02 +03:00
parent e8f7ad7652
commit d894a8a349
10 changed files with 846 additions and 12 deletions

View File

@@ -0,0 +1,121 @@
namespace StellaOps.ElkSharp;
internal static partial class ElkEdgeRouterIterative
{
private static CandidateSolution? TryBoundaryFirstBaseline(
ElkRoutedEdge[] baselineEdges,
ElkPositionedNode[] nodes,
ElkLayoutOptions layoutOptions,
RoutingStrategy strategy,
double minLineClearance,
CancellationToken cancellationToken)
{
var graphMinY = nodes.Length > 0 ? nodes.Min(n => n.Y) : 0d;
var graphMaxY = nodes.Length > 0 ? nodes.Max(n => n.Y + n.Height) : 0d;
// Phase 1: Compute boundary slot assignments for all routable edges
// (needed for correct slot positions), then filter to only violating edges.
var allAssignments = ComputeGlobalBoundarySlotAssignments(
baselineEdges, nodes, graphMinY, graphMaxY);
if (allAssignments.Count == 0)
{
return null;
}
// Identify edges with violations in the baseline.
var violatingSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountBadBoundaryAngles(baselineEdges, nodes, violatingSeverity, 10);
ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(baselineEdges, nodes, violatingSeverity, 10);
ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(baselineEdges, nodes, violatingSeverity, 10);
ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(baselineEdges, nodes, violatingSeverity, 10);
ElkEdgeRoutingScoring.CountSharedLaneViolations(baselineEdges, nodes, violatingSeverity, 10);
ElkEdgeRoutingScoring.CountBoundarySlotViolations(baselineEdges, nodes, violatingSeverity, 10);
ElkEdgeRoutingScoring.CountUnderNodeViolations(baselineEdges, nodes, violatingSeverity, 10);
ElkEdgeRoutingScoring.CountExcessiveDetourViolations(baselineEdges, nodes, violatingSeverity, 10);
ElkEdgeRoutingScoring.CountBelowGraphViolations(baselineEdges, nodes, violatingSeverity, 10);
if (violatingSeverity.Count == 0)
{
return null;
}
// Expand to include neighbor edges sharing source/target nodes.
var repairEdgeIds = ExpandWinningSolutionFocus(baselineEdges, violatingSeverity.Keys)
.ToHashSet(StringComparer.Ordinal);
// Filter assignments to only repair edges.
var assignments = new Dictionary<string, BoundaryFirstAssignment>(StringComparer.Ordinal);
foreach (var (edgeId, assignment) in allAssignments)
{
if (repairEdgeIds.Contains(edgeId))
{
assignments[edgeId] = assignment;
}
}
if (assignments.Count == 0)
{
return null;
}
ElkLayoutDiagnostics.LogProgress(
$"Boundary-first: {allAssignments.Count} slots computed, {assignments.Count}/{repairEdgeIds.Count} edges targeted for repair");
// Phase 2: Route only the targeted edges between pre-assigned boundary slots.
// Non-targeted edges keep their baseline paths and contribute soft obstacles.
var routed = RouteBoundaryFirstEdges(
baselineEdges,
nodes,
assignments,
strategy,
cancellationToken);
ElkLayoutDiagnostics.LogProgress(
$"Boundary-first: routed={routed.Diagnostics.RoutedEdges} " +
$"skipped={routed.Diagnostics.SkippedEdges} " +
$"sections={routed.Diagnostics.RoutedSections} " +
$"fallback={routed.Diagnostics.FallbackSections}");
// Phase 3: Apply lean verification pass.
var verified = ApplyBoundaryFirstVerification(
routed.Edges,
baselineEdges,
nodes,
layoutOptions.Direction,
minLineClearance);
// Score and build candidate.
var score = ElkEdgeRoutingScoring.ComputeScore(verified, nodes);
var brokenHighways = HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(verified, nodes).Count
: 0;
var retryState = BuildRetryState(score, brokenHighways);
ElkLayoutDiagnostics.LogProgress(
$"Boundary-first result: score={score.Value:F0} retry={DescribeRetryState(retryState)}");
// Record in diagnostics if available.
var diagnostics = ElkLayoutDiagnostics.Current;
if (diagnostics is not null)
{
lock (diagnostics.SyncRoot)
{
diagnostics.IterativeStrategies.Add(new ElkIterativeStrategyDiagnostics
{
StrategyIndex = -1,
OrderingName = "boundary-first",
Attempts = 1,
BestScore = score,
Outcome = retryState.RequiresPrimaryRetry
? $"retry({DescribeRetryState(retryState)})"
: "valid",
BestEdges = verified,
});
}
ElkLayoutDiagnostics.FlushSnapshot(diagnostics);
}
return new CandidateSolution(score, retryState, verified, -1);
}
}