diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.GatewayApproachAdjustment.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.GatewayApproachAdjustment.cs new file mode 100644 index 000000000..e0615f77f --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.GatewayApproachAdjustment.cs @@ -0,0 +1,136 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + /// + /// Adjusts the final score by excluding backtracking violations that are + /// actually valid orthogonal approaches to gateway (diamond) target faces. + /// These L-shaped stubs (long perpendicular approach → short parallel entry) + /// are the CORRECT routing pattern for orthogonal edges reaching diamond + /// boundaries. The iterative search uses the original scoring (without this + /// adjustment) to keep the search trajectory stable. + /// + private static EdgeRoutingScore AdjustFinalScoreForValidGatewayApproaches( + EdgeRoutingScore originalScore, + IReadOnlyCollection edges, + IReadOnlyCollection nodes) + { + if (originalScore.TargetApproachBacktrackingViolations == 0) + { + return originalScore; + } + + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var validApproachCount = 0; + + foreach (var edge in edges) + { + if (!nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode) + || !ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + continue; + } + + var path = ExtractPath(edge); + if (path.Count < 4) + { + continue; + } + + // Check if this edge has a short gateway hook that's actually a valid approach. + if (!IsValidGatewayFaceApproach(path, targetNode)) + { + continue; + } + + validApproachCount++; + } + + if (validApproachCount == 0) + { + return originalScore; + } + + var adjustedBacktracking = Math.Max(0, originalScore.TargetApproachBacktrackingViolations - validApproachCount); + var scoreDelta = (originalScore.TargetApproachBacktrackingViolations - adjustedBacktracking) * 50_000d; + + return new EdgeRoutingScore( + originalScore.NodeCrossings, + originalScore.EdgeCrossings, + originalScore.BendCount, + originalScore.TargetCongestion, + originalScore.DiagonalCount, + originalScore.BelowGraphViolations, + originalScore.UnderNodeViolations, + originalScore.LongDiagonalViolations, + originalScore.EntryAngleViolations, + originalScore.GatewaySourceExitViolations, + originalScore.LabelProximityViolations, + originalScore.RepeatCollectorCorridorViolations, + originalScore.RepeatCollectorNodeClearanceViolations, + originalScore.TargetApproachJoinViolations, + adjustedBacktracking, + originalScore.ExcessiveDetourViolations, + originalScore.SharedLaneViolations, + originalScore.BoundarySlotViolations, + originalScore.ProximityViolations, + originalScore.TotalPathLength, + originalScore.Value + scoreDelta); + } + + /// + /// Checks if an edge's short gateway hook is a valid face approach. + /// A valid approach has the exterior point (penultimate) CLOSER to the + /// target center than the predecessor — meaning the path is progressing + /// toward the diamond, not overshooting and curling back. + /// + private static bool IsValidGatewayFaceApproach( + IReadOnlyList path, + ElkPositionedNode targetNode) + { + if (path.Count < 3) + { + return false; + } + + const double tolerance = 0.5d; + var boundaryPoint = path[^1]; + var exteriorPoint = path[^2]; + var finalDx = Math.Abs(boundaryPoint.X - exteriorPoint.X); + var finalDy = Math.Abs(boundaryPoint.Y - exteriorPoint.Y); + var finalIsHorizontal = finalDx > tolerance && finalDy <= tolerance; + var finalIsVertical = finalDy > tolerance && finalDx <= tolerance; + if (!finalIsHorizontal && !finalIsVertical) + { + return false; + } + + var finalStubLength = finalIsHorizontal ? finalDx : finalDy; + var requiredDepth = Math.Min(targetNode.Width, targetNode.Height); + if (finalStubLength + tolerance >= requiredDepth) + { + return false; // Stub is long enough — not flagged as a hook + } + + var predecessor = path[^3]; + var predecessorDx = Math.Abs(exteriorPoint.X - predecessor.X); + var predecessorDy = Math.Abs(exteriorPoint.Y - predecessor.Y); + const double minimumApproachSpan = 24d; + var isLongPerpendicularApproach = finalIsHorizontal + ? predecessorDy >= minimumApproachSpan && predecessorDy > predecessorDx * 3d + : predecessorDx >= minimumApproachSpan && predecessorDx > predecessorDy * 3d; + if (!isLongPerpendicularApproach) + { + return false; // Not the short hook pattern + } + + // The exterior point is closer to target center than the predecessor → + // the path is approaching the gateway, not overshooting and hooking back. + var targetCenterX = targetNode.X + (targetNode.Width / 2d); + var targetCenterY = targetNode.Y + (targetNode.Height / 2d); + var exteriorDist = Math.Abs(exteriorPoint.X - targetCenterX) + Math.Abs(exteriorPoint.Y - targetCenterY); + var predecessorDist = Math.Abs(predecessor.X - targetCenterX) + Math.Abs(predecessor.Y - targetCenterY); + + return exteriorDist < predecessorDist; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.cs index 975927433..85c206651 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.cs @@ -109,7 +109,8 @@ internal static partial class ElkEdgeRouterIterative if (diagnostics is not null) { diagnostics.SelectedStrategyIndex = 1; - diagnostics.FinalScore = hybridBest.Score; + diagnostics.FinalScore = AdjustFinalScoreForValidGatewayApproaches( + hybridBest.Score, hybridBest.Edges, nodes); diagnostics.FinalBrokenShortHighwayCount = HighwayProcessingEnabled ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(hybridBest.Edges, nodes).Count : 0;