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>
122 lines
5.1 KiB
C#
122 lines
5.1 KiB
C#
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);
|
|
}
|
|
}
|