Each corridor edge enters End at a distinct Y position (1/n+1 fraction) so the highways are visually traceable all the way to the terminus. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
806 lines
38 KiB
C#
806 lines
38 KiB
C#
namespace StellaOps.ElkSharp;
|
|
|
|
internal static partial class ElkEdgeRouterIterative
|
|
{
|
|
private static CandidateSolution RefineHybridWinningSolution(
|
|
CandidateSolution best,
|
|
ElkPositionedNode[] nodes,
|
|
ElkLayoutDirection direction,
|
|
double minLineClearance,
|
|
bool preferLowWaveRuntimePolish = false)
|
|
{
|
|
static string DescribeSolution(CandidateSolution solution)
|
|
{
|
|
return $"score={solution.Score.Value:F0} retry={DescribeRetryState(solution.RetryState)}";
|
|
}
|
|
|
|
var current = best;
|
|
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement start: {DescribeSolution(current)}");
|
|
|
|
if (current.RetryState.UnderNodeViolations > 0)
|
|
{
|
|
current = ApplyFinalDirectUnderNodePolish(current, nodes, minLineClearance);
|
|
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after under-node polish: {DescribeSolution(current)}");
|
|
}
|
|
|
|
if (current.RetryState.UnderNodeViolations > 0
|
|
|| current.RetryState.TargetApproachJoinViolations > 0)
|
|
{
|
|
current = ApplyFinalProtectedLocalBundlePolish(current, nodes, minLineClearance);
|
|
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after local-bundle polish: {DescribeSolution(current)}");
|
|
}
|
|
|
|
if (current.RetryState.SharedLaneViolations > 0
|
|
|| current.RetryState.TargetApproachJoinViolations > 0)
|
|
{
|
|
current = ApplyFinalSharedLanePolish(
|
|
current,
|
|
nodes,
|
|
direction,
|
|
minLineClearance,
|
|
preferLeanTerminalCleanup: preferLowWaveRuntimePolish);
|
|
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after shared-lane polish: {DescribeSolution(current)}");
|
|
}
|
|
|
|
if (current.RetryState.BoundarySlotViolations > 0
|
|
|| current.RetryState.GatewaySourceExitViolations > 0
|
|
|| current.RetryState.EntryAngleViolations > 0)
|
|
{
|
|
current = ApplyFinalBoundarySlotPolish(current, nodes, direction, minLineClearance, maxRounds: 1);
|
|
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after boundary-slot polish: {DescribeSolution(current)}");
|
|
}
|
|
|
|
if (current.RetryState.ExcessiveDetourViolations > 0
|
|
|| (!preferLowWaveRuntimePolish && current.RetryState.GatewaySourceExitViolations > 0))
|
|
{
|
|
current = ApplyWinnerDetourPolish(current, nodes, direction, minLineClearance);
|
|
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after detour polish: {DescribeSolution(current)}");
|
|
}
|
|
|
|
if (HasHybridHardRulePressure(current.RetryState))
|
|
{
|
|
current = preferLowWaveRuntimePolish
|
|
? ApplyHybridLeanPostSlotHardRulePolish(current, nodes, direction, minLineClearance)
|
|
: ApplyFinalPostSlotHardRulePolish(current, nodes, direction, minLineClearance, maxRounds: 1);
|
|
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after post-slot hard-rule polish: {DescribeSolution(current)}");
|
|
}
|
|
|
|
// Final gateway backtracking repair: run NormalizeBoundaryAngles one
|
|
// last time to catch gateway target overshoots that earlier pipeline
|
|
// steps may have re-introduced. Accept with net-total comparison.
|
|
if (current.RetryState.TargetApproachBacktrackingViolations > 0
|
|
|| current.RetryState.EntryAngleViolations > 0)
|
|
{
|
|
var finalNormalized = ElkEdgePostProcessor.NormalizeBoundaryAngles(current.Edges, nodes);
|
|
finalNormalized = ElkEdgePostProcessor.NormalizeSourceExitAngles(finalNormalized, nodes);
|
|
var finalScore = ElkEdgeRoutingScoring.ComputeScore(finalNormalized, nodes);
|
|
var finalRetry = BuildRetryState(
|
|
finalScore,
|
|
HighwayProcessingEnabled
|
|
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(finalNormalized, nodes).Count
|
|
: 0);
|
|
var currentHard = CountTotalHardViolations(current.RetryState);
|
|
var finalHard = CountTotalHardViolations(finalRetry);
|
|
if (finalHard < currentHard && finalScore.NodeCrossings <= current.Score.NodeCrossings)
|
|
{
|
|
current = current with { Score = finalScore, RetryState = finalRetry, Edges = finalNormalized };
|
|
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after final normalization: {DescribeSolution(current)}");
|
|
}
|
|
}
|
|
|
|
// Reroute long horizontal sweeps through the top corridor.
|
|
// Edges spanning > 40% graph width with under-node violations
|
|
// should route above the graph (like backward edges) instead of
|
|
// cutting straight through the node field.
|
|
// Also pushes medium-length sweeps below blocking nodes.
|
|
// Each fix type is evaluated INDEPENDENTLY to prevent one fix's
|
|
// detour from blocking another fix's under-node improvement.
|
|
if (current.RetryState.UnderNodeViolations > 0)
|
|
{
|
|
var corridorCandidate = RerouteLongSweepsThroughCorridor(current.Edges, nodes, direction, minLineClearance);
|
|
if (corridorCandidate is not null)
|
|
{
|
|
corridorCandidate = FinalizeHybridCorridorCandidate(
|
|
corridorCandidate,
|
|
nodes,
|
|
minLineClearance);
|
|
var corridorScore = ElkEdgeRoutingScoring.ComputeScore(corridorCandidate, nodes);
|
|
if (corridorScore.Value > current.Score.Value
|
|
&& corridorScore.NodeCrossings <= current.Score.NodeCrossings)
|
|
{
|
|
var corridorRetry = BuildRetryState(
|
|
corridorScore,
|
|
HighwayProcessingEnabled
|
|
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(corridorCandidate, nodes).Count
|
|
: 0);
|
|
current = current with { Score = corridorScore, RetryState = corridorRetry, Edges = corridorCandidate };
|
|
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after corridor reroute: {DescribeSolution(current)}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Targeted under-node elevation with net-total promotion.
|
|
// ElevateUnderNodeViolations can fix remaining under-node edges
|
|
// (gateway-exit lanes, long horizontal sweeps) but the standard
|
|
// promotion gating blocks it because elevation increases path
|
|
// length (detour). Net-total allows the tradeoff: under-node
|
|
// fix (100K savings) outweighs detour cost (50K).
|
|
if (current.RetryState.UnderNodeViolations > 0)
|
|
{
|
|
var elevated = ElkEdgePostProcessor.ElevateUnderNodeViolations(
|
|
current.Edges, nodes, minLineClearance);
|
|
elevated = ElkEdgePostProcessor.NormalizeBoundaryAngles(elevated, nodes);
|
|
elevated = ElkEdgePostProcessor.NormalizeSourceExitAngles(elevated, nodes);
|
|
var elevatedScore = ElkEdgeRoutingScoring.ComputeScore(elevated, nodes);
|
|
var elevatedRetry = BuildRetryState(
|
|
elevatedScore,
|
|
HighwayProcessingEnabled
|
|
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(elevated, nodes).Count
|
|
: 0);
|
|
// Use weighted comparison: under-node (100K) is worth more than
|
|
// detour (50K), so trading 1 under-node for 1 detour is a net win.
|
|
if (elevatedScore.Value > current.Score.Value
|
|
&& elevatedScore.NodeCrossings <= current.Score.NodeCrossings)
|
|
{
|
|
current = current with { Score = elevatedScore, RetryState = elevatedRetry, Edges = elevated };
|
|
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after under-node elevation: {DescribeSolution(current)}");
|
|
}
|
|
}
|
|
|
|
// Target-join face reassignment: when two edges converge on the
|
|
// same target face with inadequate separation, redirect the outer
|
|
// edge to the target's adjacent face (right side for LTR layout).
|
|
if (current.RetryState.TargetApproachJoinViolations > 0)
|
|
{
|
|
var joinCandidate = ReassignConvergentTargetFace(current.Edges, nodes, direction);
|
|
if (joinCandidate is not null)
|
|
{
|
|
var joinScore = ElkEdgeRoutingScoring.ComputeScore(joinCandidate, nodes);
|
|
if (joinScore.Value > current.Score.Value
|
|
&& joinScore.NodeCrossings <= current.Score.NodeCrossings)
|
|
{
|
|
var joinRetry = BuildRetryState(
|
|
joinScore,
|
|
HighwayProcessingEnabled
|
|
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(joinCandidate, nodes).Count
|
|
: 0);
|
|
current = current with { Score = joinScore, RetryState = joinRetry, Edges = joinCandidate };
|
|
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after target-join reassignment: {DescribeSolution(current)}");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (current.RetryState.TargetApproachJoinViolations > 0)
|
|
{
|
|
var joinSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
|
|
ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(current.Edges, nodes, joinSeverity, 10);
|
|
var focusEdgeIds = joinSeverity
|
|
.OrderByDescending(pair => pair.Value)
|
|
.ThenBy(pair => pair.Key, StringComparer.Ordinal)
|
|
.Take(MaxWinnerPolishBatchedRootEdges + 1)
|
|
.Select(pair => pair.Key)
|
|
.ToArray();
|
|
focusEdgeIds = ExpandTargetApproachJoinRepairSet(
|
|
focusEdgeIds,
|
|
current.Edges,
|
|
nodes,
|
|
minLineClearance);
|
|
if (focusEdgeIds.Length > 0)
|
|
{
|
|
var focusedJoinCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(
|
|
current.Edges,
|
|
nodes,
|
|
minLineClearance,
|
|
focusEdgeIds,
|
|
forceOutwardAxisSpacing: true);
|
|
focusedJoinCandidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(
|
|
focusedJoinCandidate,
|
|
nodes,
|
|
minLineClearance,
|
|
focusEdgeIds);
|
|
focusedJoinCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(
|
|
focusedJoinCandidate,
|
|
nodes,
|
|
minLineClearance,
|
|
focusEdgeIds);
|
|
focusedJoinCandidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(focusedJoinCandidate, nodes);
|
|
focusedJoinCandidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(focusedJoinCandidate, nodes);
|
|
|
|
var focusedJoinScore = ElkEdgeRoutingScoring.ComputeScore(focusedJoinCandidate, nodes);
|
|
if (focusedJoinScore.Value > current.Score.Value
|
|
&& focusedJoinScore.NodeCrossings <= current.Score.NodeCrossings)
|
|
{
|
|
var focusedJoinRetry = BuildRetryState(
|
|
focusedJoinScore,
|
|
HighwayProcessingEnabled
|
|
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(focusedJoinCandidate, nodes).Count
|
|
: 0);
|
|
current = current with
|
|
{
|
|
Score = focusedJoinScore,
|
|
RetryState = focusedJoinRetry,
|
|
Edges = focusedJoinCandidate,
|
|
};
|
|
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after focused target-join polish: {DescribeSolution(current)}");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (current.RetryState.UnderNodeViolations > 0
|
|
|| current.RetryState.TargetApproachJoinViolations > 0)
|
|
{
|
|
current = ApplyFinalProtectedLocalBundlePolish(current, nodes, minLineClearance);
|
|
current = ApplyFinalDirectUnderNodePolish(current, nodes, minLineClearance);
|
|
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after late under-node polish: {DescribeSolution(current)}");
|
|
}
|
|
|
|
if (current.RetryState.ExcessiveDetourViolations > 0)
|
|
{
|
|
current = ApplyWinnerDetourPolish(current, nodes, direction, minLineClearance);
|
|
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after late detour polish: {DescribeSolution(current)}");
|
|
}
|
|
|
|
current = ApplyFinalGatewayArtifactPolish(current, nodes, minLineClearance);
|
|
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after gateway artifact polish: {DescribeSolution(current)}");
|
|
|
|
// Final boundary-slot snap: run AFTER the gateway artifact polish
|
|
// so that normalization passes inside the gateway polish do not
|
|
// shift endpoints off the slot lattice after snapping. The gateway
|
|
// artifact polish ends with NormalizeBoundaryAngles +
|
|
// NormalizeSourceExitAngles, which is the root cause of the
|
|
// boundary-slot violations when snap ran before it.
|
|
// Final boundary-slot snap is expensive (~39s). Skip in the low-wave
|
|
// speed path since the FinalScore already handles boundary-slot
|
|
// exclusions. The full-wave path runs it for maximum quality.
|
|
if (current.RetryState.BoundarySlotViolations > 0 && !preferLowWaveRuntimePolish)
|
|
{
|
|
current = ApplyFinalBoundarySlotPolish(current, nodes, direction, minLineClearance, maxRounds: 1);
|
|
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after final boundary-slot snap: {DescribeSolution(current)}");
|
|
}
|
|
|
|
// Eliminate large diagonal segments that may survive through the
|
|
// iterative optimization (the hybrid baseline runs EliminateDiagonalSegments
|
|
// but subsequent pipeline passes can re-introduce diagonals).
|
|
if (current.Score.LongDiagonalViolations > 0)
|
|
{
|
|
var eliminated = ElkEdgePostProcessor.EliminateDiagonalSegments(current.Edges, nodes);
|
|
var elimScore = ElkEdgeRoutingScoring.ComputeScore(eliminated, nodes);
|
|
if (elimScore.LongDiagonalViolations < current.Score.LongDiagonalViolations
|
|
&& elimScore.NodeCrossings <= current.Score.NodeCrossings)
|
|
{
|
|
current = current with { Score = elimScore, Edges = eliminated };
|
|
}
|
|
}
|
|
|
|
// Straighten short diagonal stubs at gateway boundary vertices.
|
|
var straightened = ElkEdgePostProcessor.StraightenGatewayCornerDiagonals(current.Edges, nodes);
|
|
if (!ReferenceEquals(straightened, current.Edges))
|
|
{
|
|
var straightenedScore = ElkEdgeRoutingScoring.ComputeScore(straightened, nodes);
|
|
current = current with { Score = straightenedScore, Edges = straightened };
|
|
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after gateway diagonal straightening: {DescribeSolution(current)}");
|
|
}
|
|
|
|
// Per-edge gateway fixes: only run when the gateway artifact polish
|
|
// left remaining artifacts. Skip the expensive per-edge scoring when
|
|
// artifacts are already clean.
|
|
var postArtifactState = EvaluateGatewayArtifacts(current.Edges, nodes, out var postFocus);
|
|
if (!postArtifactState.IsClean && postFocus.Length > 0)
|
|
{
|
|
current = ApplyPerEdgeGatewayFaceRedirect(current, nodes, minLineClearance, postFocus);
|
|
current = ApplyPerEdgeGatewayScoringFix(current, nodes);
|
|
current = RepairRemainingEdgeNodeCrossings(current, nodes);
|
|
}
|
|
|
|
// Unconditional corridor reroute: move long sweeps to top corridor
|
|
// BEFORE the final target-join check so the join detection sees the
|
|
// corridored paths and can spread the corridor approach stubs.
|
|
{
|
|
var graphMinYLocal = nodes.Min(n => n.Y);
|
|
var graphWidthLocal = nodes.Max(n => n.X + n.Width) - nodes.Min(n => n.X);
|
|
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
|
|
// Keep corridor close to graph for visual readability. 24px is
|
|
// enough clearance for the perpendicular exit stub.
|
|
var baseCorridorY = graphMinYLocal - 24d;
|
|
var localMinSweep = graphWidthLocal * 0.4d;
|
|
var corridorResult = current.Edges.ToArray();
|
|
var corridorFixed = 0;
|
|
for (var ei = 0; ei < corridorResult.Length; ei++)
|
|
{
|
|
var edge = corridorResult[ei];
|
|
var cpath = ExtractPath(edge);
|
|
for (var si = 0; si < cpath.Count - 1; si++)
|
|
{
|
|
if (Math.Abs(cpath[si].Y - cpath[si + 1].Y) > 2d) continue;
|
|
var segLen = Math.Abs(cpath[si + 1].X - cpath[si].X);
|
|
var laneY = cpath[si].Y;
|
|
if (segLen < localMinSweep || laneY <= graphMinYLocal - 10d) continue;
|
|
// 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;
|
|
|
|
// Find the target node to determine approach geometry.
|
|
var tgtNode = nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var tn) ? tn : null;
|
|
List<ElkPoint> newPath;
|
|
if (tgtNode is not null && tgtNode.Kind is "End")
|
|
{
|
|
// Enter End from the right side: corridor goes past End,
|
|
// descends to End's center Y, approaches from right.
|
|
// This avoids the ugly long vertical drop from corridor.
|
|
// Offset both X and Y for each corridor edge so they
|
|
// enter End at distinct positions (visually traceable).
|
|
var rightApproachX = tgtNode.X + tgtNode.Width + 24d + (corridorFixed * (nodeSizeClearance + 4d));
|
|
// Spread entry points across the right face. First edge
|
|
// enters at 1/3 from top, second at 2/3, etc.
|
|
var slotFraction = (corridorFixed + 1d) / (corridorFixed + 2d);
|
|
var centerY = tgtNode.Y + (tgtNode.Height * slotFraction);
|
|
newPath =
|
|
[
|
|
src,
|
|
new() { X = stubX, Y = src.Y },
|
|
new() { X = stubX, Y = localCorridorY },
|
|
new() { X = rightApproachX, Y = localCorridorY },
|
|
new() { X = rightApproachX, Y = centerY },
|
|
new() { X = tgtNode.X + tgtNode.Width, Y = centerY },
|
|
];
|
|
}
|
|
else
|
|
{
|
|
newPath =
|
|
[
|
|
src,
|
|
new() { X = stubX, Y = src.Y },
|
|
new() { X = stubX, Y = localCorridorY },
|
|
new() { X = tgt.X, Y = localCorridorY },
|
|
tgt,
|
|
];
|
|
}
|
|
corridorResult[ei] = new ElkRoutedEdge
|
|
{
|
|
Id = edge.Id, SourceNodeId = edge.SourceNodeId, TargetNodeId = edge.TargetNodeId,
|
|
SourcePortId = edge.SourcePortId, TargetPortId = edge.TargetPortId,
|
|
Kind = edge.Kind, Label = edge.Label,
|
|
Sections = [new ElkEdgeSection
|
|
{
|
|
StartPoint = newPath[0], EndPoint = newPath[^1],
|
|
BendPoints = newPath.Skip(1).Take(newPath.Count - 2).ToArray(),
|
|
}],
|
|
};
|
|
corridorFixed++;
|
|
break;
|
|
}
|
|
}
|
|
if (corridorFixed > 0)
|
|
{
|
|
var corridorScore = ElkEdgeRoutingScoring.ComputeScore(corridorResult, nodes);
|
|
// 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");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Final target-join repair: the per-edge gateway fixes and corridor
|
|
// reroute may create new target-join convergences.
|
|
var finalJoinSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
|
|
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 (finalJoinCount > 0 && finalJoinSeverity.Count >= 2)
|
|
{
|
|
// Spread target approaches apart. Accept if it fixes the join
|
|
// regardless of detour (the FinalScore excludes spread-induced
|
|
// detours for edges sharing a target with join partners).
|
|
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
|
|
&& joinScore.NodeCrossings <= current.Score.NodeCrossings)
|
|
{
|
|
var joinRetry = BuildRetryState(joinScore, 0);
|
|
current = current with { Score = joinScore, RetryState = joinRetry, Edges = joinCandidate };
|
|
ElkLayoutDiagnostics.LogProgress(
|
|
$"Hybrid final target-join repair: joins={joinJoinCount}");
|
|
}
|
|
}
|
|
|
|
// Second per-edge gateway pass: the target-join spread may create new
|
|
// face mismatches. Run the redirect again to clean up.
|
|
var postSpreadArtifacts = EvaluateGatewayArtifacts(current.Edges, nodes, out var postSpreadFocus);
|
|
if (!postSpreadArtifacts.IsClean && postSpreadFocus.Length > 0)
|
|
{
|
|
current = ApplyPerEdgeGatewayFaceRedirect(current, nodes, minLineClearance, postSpreadFocus);
|
|
current = ApplyPerEdgeGatewayScoringFix(current, nodes);
|
|
current = RepairRemainingEdgeNodeCrossings(current, nodes);
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies gateway face redirects one edge at a time, validating each
|
|
/// individually against hard-rule regressions. Bulk processing creates
|
|
/// cascading conflicts (19+ boundary-slot violations), but single-edge
|
|
/// fixes are often safe because they don't interact with each other.
|
|
/// </summary>
|
|
private static CandidateSolution ApplyPerEdgeGatewayFaceRedirect(
|
|
CandidateSolution solution,
|
|
ElkPositionedNode[] nodes,
|
|
double minLineClearance,
|
|
string[] focusEdgeIds)
|
|
{
|
|
var current = solution;
|
|
var accepted = 0;
|
|
|
|
foreach (var edgeId in focusEdgeIds)
|
|
{
|
|
// Apply face redirect to this single edge.
|
|
var candidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(
|
|
current.Edges, nodes, [edgeId]);
|
|
if (ReferenceEquals(candidate, current.Edges))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Straighten corner diagonals but skip full normalization — running
|
|
// NormalizeBoundaryAngles on ALL edges after a single-edge redirect
|
|
// moves other edges' endpoints off their boundary slots, creating
|
|
// 19+ boundary-slot violations.
|
|
candidate = ElkEdgePostProcessor.StraightenGatewayCornerDiagonals(candidate, nodes);
|
|
|
|
// Cheap validation: only check node crossings and shared lanes for
|
|
// the modified edge, instead of full ComputeScore on all edges.
|
|
// Full scoring per edge is O(edges^2) and causes 2min+ regression.
|
|
var modifiedEdge = candidate.First(e => string.Equals(e.Id, edgeId, StringComparison.Ordinal));
|
|
var crossings = ElkEdgeRoutingScoring.CountEdgeNodeCrossings([modifiedEdge], nodes, null);
|
|
var sharedLanes = ElkEdgeRoutingScoring.CountSharedLaneViolations([modifiedEdge], nodes);
|
|
if (crossings > 0 || sharedLanes > 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Full score for accepted candidates only (amortized cost).
|
|
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes);
|
|
var candidateRetry = BuildRetryState(candidateScore, 0);
|
|
var candidateGatewaySourceBetter =
|
|
candidateRetry.GatewaySourceExitViolations <= current.RetryState.GatewaySourceExitViolations;
|
|
var backtrackingAcceptable =
|
|
candidateRetry.TargetApproachBacktrackingViolations <= current.RetryState.TargetApproachBacktrackingViolations + 1
|
|
&& candidateGatewaySourceBetter;
|
|
var boundarySlotAcceptable =
|
|
candidateRetry.BoundarySlotViolations <= current.RetryState.BoundarySlotViolations + 3
|
|
&& candidateGatewaySourceBetter;
|
|
var leniently = current.RetryState with
|
|
{
|
|
TargetApproachBacktrackingViolations = backtrackingAcceptable
|
|
? candidateRetry.TargetApproachBacktrackingViolations
|
|
: current.RetryState.TargetApproachBacktrackingViolations,
|
|
BoundarySlotViolations = boundarySlotAcceptable
|
|
? candidateRetry.BoundarySlotViolations
|
|
: current.RetryState.BoundarySlotViolations,
|
|
};
|
|
if (HasHardRuleRegression(candidateRetry, leniently)
|
|
|| candidateScore.NodeCrossings > current.Score.NodeCrossings)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Single-edge fix is clean — accept it.
|
|
current = current with
|
|
{
|
|
Score = candidateScore,
|
|
RetryState = candidateRetry,
|
|
Edges = candidate,
|
|
};
|
|
accepted++;
|
|
}
|
|
|
|
if (accepted > 0)
|
|
{
|
|
ElkLayoutDiagnostics.LogProgress(
|
|
$"Hybrid per-edge gateway redirect: {accepted}/{focusEdgeIds.Length} accepted, score={current.Score.Value:F0} retry={DescribeRetryState(current.RetryState)}");
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
/// <summary>
|
|
/// For gateway source edges where a shorter clean exit path is available
|
|
/// (HasClearGatewaySourceScoringOpportunity), applies the scoring candidate
|
|
/// one edge at a time with lenient hard-rule validation.
|
|
/// </summary>
|
|
private static CandidateSolution ApplyPerEdgeGatewayScoringFix(
|
|
CandidateSolution solution,
|
|
ElkPositionedNode[] nodes)
|
|
{
|
|
var current = solution;
|
|
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
|
|
var accepted = 0;
|
|
|
|
for (var i = 0; i < current.Edges.Length; i++)
|
|
{
|
|
var edge = current.Edges[i];
|
|
if (!nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)
|
|
|| !ElkShapeBoundaries.IsGatewayShape(sourceNode))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var path = ExtractPath(edge);
|
|
if (!ElkEdgePostProcessor.TryBuildGatewaySourceScoringCandidate(
|
|
path, sourceNode, nodes, edge.SourceNodeId, edge.TargetNodeId,
|
|
out var scoringCandidate)
|
|
|| scoringCandidate.Count < 2)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Build the candidate edge array with the scoring fix applied.
|
|
var candidateEdges = current.Edges.ToArray();
|
|
candidateEdges[i] = new ElkRoutedEdge
|
|
{
|
|
Id = edge.Id,
|
|
SourceNodeId = edge.SourceNodeId,
|
|
TargetNodeId = edge.TargetNodeId,
|
|
SourcePortId = edge.SourcePortId,
|
|
TargetPortId = edge.TargetPortId,
|
|
Kind = edge.Kind,
|
|
Label = edge.Label,
|
|
Sections =
|
|
[
|
|
new ElkEdgeSection
|
|
{
|
|
StartPoint = scoringCandidate[0],
|
|
EndPoint = scoringCandidate[^1],
|
|
BendPoints = scoringCandidate.Skip(1).Take(scoringCandidate.Count - 2).ToArray(),
|
|
},
|
|
],
|
|
};
|
|
|
|
candidateEdges = ElkEdgePostProcessor.StraightenGatewayCornerDiagonals(candidateEdges, nodes);
|
|
|
|
// Cheap validation first: reject if modified edge creates crossings/shared lanes.
|
|
var modifiedScoringEdge = candidateEdges[i];
|
|
if (ElkEdgeRoutingScoring.CountEdgeNodeCrossings([modifiedScoringEdge], nodes, null) > 0
|
|
|| ElkEdgeRoutingScoring.CountSharedLaneViolations([modifiedScoringEdge], nodes) > 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Full score only for candidates that pass the cheap check.
|
|
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes);
|
|
var candidateRetry = BuildRetryState(candidateScore, 0);
|
|
var backtrackingOk = candidateRetry.TargetApproachBacktrackingViolations
|
|
<= current.RetryState.TargetApproachBacktrackingViolations + 1;
|
|
var boundarySlotOk = candidateRetry.BoundarySlotViolations
|
|
<= current.RetryState.BoundarySlotViolations + 3;
|
|
var leniently = current.RetryState with
|
|
{
|
|
TargetApproachBacktrackingViolations = backtrackingOk
|
|
? candidateRetry.TargetApproachBacktrackingViolations
|
|
: current.RetryState.TargetApproachBacktrackingViolations,
|
|
BoundarySlotViolations = boundarySlotOk
|
|
? candidateRetry.BoundarySlotViolations
|
|
: current.RetryState.BoundarySlotViolations,
|
|
};
|
|
if (HasHardRuleRegression(candidateRetry, leniently)
|
|
|| candidateScore.NodeCrossings > current.Score.NodeCrossings)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
current = current with
|
|
{
|
|
Score = candidateScore,
|
|
RetryState = candidateRetry,
|
|
Edges = candidateEdges,
|
|
};
|
|
accepted++;
|
|
}
|
|
|
|
if (accepted > 0)
|
|
{
|
|
ElkLayoutDiagnostics.LogProgress(
|
|
$"Hybrid per-edge gateway scoring fix: {accepted} accepted, score={current.Score.Value:F0} retry={DescribeRetryState(current.RetryState)}");
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pushes horizontal edge segments that cross through unrelated nodes
|
|
/// above or below the blocking node. Only adjusts the segment's Y
|
|
/// coordinate — no path rebuild or normalization (cosmetic fix).
|
|
/// </summary>
|
|
private static CandidateSolution RepairRemainingEdgeNodeCrossings(
|
|
CandidateSolution solution,
|
|
ElkPositionedNode[] nodes)
|
|
{
|
|
var current = solution;
|
|
var result = current.Edges.ToArray();
|
|
var repaired = 0;
|
|
|
|
for (var ei = 0; ei < result.Length; ei++)
|
|
{
|
|
var edge = result[ei];
|
|
var path = ExtractPath(edge);
|
|
if (path.Count < 2)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
List<ElkPoint>? newPath = null;
|
|
for (var si = 0; si < path.Count - 1; si++)
|
|
{
|
|
var p1 = newPath?[si] ?? path[si];
|
|
var p2 = newPath?[si + 1] ?? path[si + 1];
|
|
if (Math.Abs(p1.Y - p2.Y) > 2d)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
foreach (var node in nodes)
|
|
{
|
|
if (string.Equals(node.Id, edge.SourceNodeId, StringComparison.Ordinal)
|
|
|| string.Equals(node.Id, edge.TargetNodeId, StringComparison.Ordinal))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (p1.Y <= node.Y || p1.Y >= node.Y + node.Height)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (Math.Max(p1.X, p2.X) <= node.X || Math.Min(p1.X, p2.X) >= node.X + node.Width)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Push above the node.
|
|
var pushY = node.Y - 1d;
|
|
newPath ??= path.Select(p => new ElkPoint { X = p.X, Y = p.Y }).ToList();
|
|
for (var pi = si; pi <= si + 1 && pi < newPath.Count; pi++)
|
|
{
|
|
if (Math.Abs(newPath[pi].Y - p1.Y) <= 2d)
|
|
{
|
|
newPath[pi] = new ElkPoint { X = newPath[pi].X, Y = pushY };
|
|
}
|
|
}
|
|
|
|
repaired++;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (newPath is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
result[ei] = new ElkRoutedEdge
|
|
{
|
|
Id = edge.Id,
|
|
SourceNodeId = edge.SourceNodeId,
|
|
TargetNodeId = edge.TargetNodeId,
|
|
SourcePortId = edge.SourcePortId,
|
|
TargetPortId = edge.TargetPortId,
|
|
Kind = edge.Kind,
|
|
Label = edge.Label,
|
|
Sections =
|
|
[
|
|
new ElkEdgeSection
|
|
{
|
|
StartPoint = newPath[0],
|
|
EndPoint = newPath[^1],
|
|
BendPoints = newPath.Skip(1).Take(newPath.Count - 2).ToArray(),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
if (repaired > 0)
|
|
{
|
|
var repairedScore = ElkEdgeRoutingScoring.ComputeScore(result, nodes);
|
|
current = current with { Score = repairedScore, Edges = result };
|
|
ElkLayoutDiagnostics.LogProgress(
|
|
$"Hybrid edge-node crossing repair: {repaired} fixed, score={repairedScore.Value:F0}");
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
private static ElkRoutedEdge[] FinalizeHybridCorridorCandidate(
|
|
ElkRoutedEdge[] candidate,
|
|
ElkPositionedNode[] nodes,
|
|
double minLineClearance)
|
|
{
|
|
var stabilized = ClampBelowGraphEdges(candidate, nodes);
|
|
var focusSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
|
|
ElkEdgeRoutingScoring.CountUnderNodeViolations(stabilized, nodes, focusSeverity, 10);
|
|
ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(stabilized, nodes, focusSeverity, 10);
|
|
if (focusSeverity.Count == 0)
|
|
{
|
|
return stabilized;
|
|
}
|
|
|
|
var focusEdgeIds = focusSeverity
|
|
.OrderByDescending(pair => pair.Value)
|
|
.ThenBy(pair => pair.Key, StringComparer.Ordinal)
|
|
.Take(MaxWinnerPolishBatchedRootEdges + 1)
|
|
.Select(pair => pair.Key)
|
|
.ToArray();
|
|
focusEdgeIds = ExpandTargetApproachJoinRepairSet(
|
|
focusEdgeIds,
|
|
stabilized,
|
|
nodes,
|
|
minLineClearance);
|
|
if (focusEdgeIds.Length == 0)
|
|
{
|
|
return stabilized;
|
|
}
|
|
|
|
var focusedCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(
|
|
stabilized,
|
|
nodes,
|
|
minLineClearance,
|
|
focusEdgeIds);
|
|
focusedCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(
|
|
focusedCandidate,
|
|
nodes,
|
|
minLineClearance,
|
|
focusEdgeIds,
|
|
forceOutwardAxisSpacing: true);
|
|
focusedCandidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(
|
|
focusedCandidate,
|
|
nodes,
|
|
minLineClearance,
|
|
focusEdgeIds);
|
|
focusedCandidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(
|
|
focusedCandidate,
|
|
nodes,
|
|
minLineClearance,
|
|
focusEdgeIds);
|
|
focusedCandidate = ClampBelowGraphEdges(focusedCandidate, nodes, focusEdgeIds);
|
|
focusedCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
|
|
focusedCandidate,
|
|
nodes,
|
|
minLineClearance,
|
|
focusEdgeIds,
|
|
enforceAllNodeEndpoints: true);
|
|
focusedCandidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(focusedCandidate, nodes);
|
|
focusedCandidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(focusedCandidate, nodes);
|
|
|
|
return ChoosePreferredHardRuleLayout(stabilized, focusedCandidate, nodes);
|
|
}
|
|
|
|
private static bool HasHybridHardRulePressure(RoutingRetryState retryState)
|
|
{
|
|
return retryState.RemainingShortHighways > 0
|
|
|| retryState.RepeatCollectorCorridorViolations > 0
|
|
|| retryState.RepeatCollectorNodeClearanceViolations > 0
|
|
|| retryState.TargetApproachJoinViolations > 0
|
|
|| retryState.TargetApproachBacktrackingViolations > 0
|
|
|| retryState.ExcessiveDetourViolations > 0
|
|
|| retryState.SharedLaneViolations > 0
|
|
|| retryState.BoundarySlotViolations > 0
|
|
|| retryState.BelowGraphViolations > 0
|
|
|| retryState.UnderNodeViolations > 0
|
|
|| retryState.EntryAngleViolations > 0
|
|
|| retryState.GatewaySourceExitViolations > 0;
|
|
}
|
|
}
|