From d894a8a349e52045ab9ffe35d56ed2a9cb2dc1ad Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 29 Mar 2026 23:39:02 +0300 Subject: [PATCH] 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) --- .../ElkEdgePostProcessor.GatewayBoundary.cs | 29 +- .../ElkEdgePostProcessor.UnderNode.cs | 2 +- .../ElkEdgePostProcessor.cs | 70 +++++ ...erative.BoundaryFirst.CoordinatedRepair.cs | 251 ++++++++++++++++++ ...geRouterIterative.BoundaryFirst.Routing.cs | 132 +++++++++ ...rIterative.BoundaryFirst.SlotAssignment.cs | 173 ++++++++++++ ...EdgeRouterIterative.BoundaryFirst.Types.cs | 21 ++ ...terIterative.BoundaryFirst.Verification.cs | 36 +++ .../ElkEdgeRouterIterative.BoundaryFirst.cs | 121 +++++++++ ...RouterIterative.WinnerRefinement.Hybrid.cs | 23 ++ 10 files changed, 846 insertions(+), 12 deletions(-) create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.CoordinatedRepair.cs create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.Routing.cs create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.SlotAssignment.cs create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.Types.cs create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.Verification.cs create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.cs diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.GatewayBoundary.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.GatewayBoundary.cs index 4285d5947..3f9c2f5cd 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.GatewayBoundary.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.GatewayBoundary.cs @@ -1082,10 +1082,11 @@ internal static partial class ElkEdgePostProcessor return HasClearBoundarySegments(path, nodes, sourceNodeId, targetNodeId, true, 2); } - private static List NormalizeExitPath( + internal static List NormalizeExitPath( IReadOnlyList sourcePath, ElkPositionedNode sourceNode, - string side) + string side, + bool useShortStub = false) { const double coordinateTolerance = 0.5d; var path = sourcePath @@ -1112,9 +1113,13 @@ internal static partial class ElkEdgePostProcessor { new() { X = sourceX, Y = boundaryPoint.Y }, }; - var stubX = side == "left" - ? Math.Min(sourceX - 24d, anchor.X) - : Math.Max(sourceX + 24d, anchor.X); + // Short stub: 24px perpendicular exit only. Avoids long horizontals + // that cross nodes in occupied Y-bands between source and target. + var stubX = useShortStub + ? (side == "left" ? sourceX - 24d : sourceX + 24d) + : (side == "left" + ? Math.Min(sourceX - 24d, anchor.X) + : Math.Max(sourceX + 24d, anchor.X)); if (Math.Abs(stubX - sourceX) > coordinateTolerance) { rebuilt.Add(new ElkPoint @@ -1152,9 +1157,11 @@ internal static partial class ElkEdgePostProcessor { new() { X = verticalBoundaryPoint.X, Y = sourceY }, }; - var stubY = side == "top" - ? Math.Min(sourceY - 24d, verticalAnchor.Y) - : Math.Max(sourceY + 24d, verticalAnchor.Y); + var stubY = useShortStub + ? (side == "top" ? sourceY - 24d : sourceY + 24d) + : (side == "top" + ? Math.Min(sourceY - 24d, verticalAnchor.Y) + : Math.Max(sourceY + 24d, verticalAnchor.Y)); if (Math.Abs(stubY - sourceY) > coordinateTolerance) { verticalRebuilt.Add(new ElkPoint @@ -1178,7 +1185,7 @@ internal static partial class ElkEdgePostProcessor return NormalizePathPoints(verticalRebuilt); } - private static List NormalizeEntryPath( + internal static List NormalizeEntryPath( IReadOnlyList sourcePath, ElkPositionedNode targetNode, string side) @@ -1186,7 +1193,7 @@ internal static partial class ElkEdgePostProcessor return NormalizeEntryPath(sourcePath, targetNode, side, null); } - private static List NormalizeEntryPath( + internal static List NormalizeEntryPath( IReadOnlyList sourcePath, ElkPositionedNode targetNode, string side, @@ -4234,7 +4241,7 @@ internal static partial class ElkEdgePostProcessor return false; } - private static List NormalizeGatewayEntryPath( + internal static List NormalizeGatewayEntryPath( IReadOnlyList sourcePath, ElkPositionedNode targetNode, ElkPoint assignedEndpoint) diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.UnderNode.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.UnderNode.cs index 83b2ef72d..2e4173a02 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.UnderNode.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.UnderNode.cs @@ -1503,7 +1503,7 @@ internal static partial class ElkEdgePostProcessor return string.Join(";", path.Select(point => $"{point.X:F3},{point.Y:F3}")); } - private static bool HasAcceptableGatewayBoundaryPath( + internal static bool HasAcceptableGatewayBoundaryPath( IReadOnlyList path, IReadOnlyCollection nodes, string? sourceNodeId, diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs index 4436e493b..5d2b76892 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs @@ -352,6 +352,17 @@ internal static partial class ElkEdgePostProcessor { normalized = sourceNormalized; } + else if (!ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + // The long-stub normalization crosses a node. Try a short stub + // (24px) which avoids long horizontals through occupied bands. + var sourceSideRetry = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[0], sourceNode); + var shortStubNormalized = NormalizeExitPath(normalized, sourceNode, sourceSideRetry, useShortStub: true); + if (HasClearSourceExitSegment(shortStubNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId)) + { + normalized = shortStubNormalized; + } + } } } @@ -364,6 +375,15 @@ internal static partial class ElkEdgePostProcessor { normalized = gatewayNormalized; } + + // Repair gateway target backtracking: clip axis reversals + // in the last 3 points. The non-gateway path has explicit + // backtracking repair (TryNormalizeNonGatewayBacktrackingEntry) + // but the gateway path was missing this step. + if (normalized.Count >= 3) + { + normalized = ClipGatewayTargetApproachOvershoot(normalized); + } } else { @@ -1404,4 +1424,54 @@ internal static partial class ElkEdgePostProcessor simplified.Add(deduped[^1]); return simplified; } + + /// + /// Repairs gateway target approach overshoots and short orthogonal hooks. + /// 1) Axis reversals: penultimate overshoots endpoint on X or Y → clip to endpoint. + /// 2) Short hooks: long orthogonal approach → short perpendicular stub → collapse + /// the hook into a direct diagonal approach to the gateway boundary. + /// + private static List ClipGatewayTargetApproachOvershoot(List path) + { + if (path.Count < 3) + { + return path; + } + + var result = path.Select(p => new ElkPoint { X = p.X, Y = p.Y }).ToList(); + var changed = false; + + // Pattern 1: Axis reversals in last 3 points. + { + var prev = result[^3]; + var penultimate = result[^2]; + var endpoint = result[^1]; + if ((penultimate.X > prev.X && penultimate.X > endpoint.X && prev.X < endpoint.X) + || (penultimate.X < prev.X && penultimate.X < endpoint.X && prev.X > endpoint.X)) + { + result[^2] = new ElkPoint { X = endpoint.X, Y = penultimate.Y }; + changed = true; + } + + if ((penultimate.Y > prev.Y && penultimate.Y > endpoint.Y && prev.Y < endpoint.Y) + || (penultimate.Y < prev.Y && penultimate.Y < endpoint.Y && prev.Y > endpoint.Y)) + { + result[^2] = new ElkPoint { X = result[^2].X, Y = endpoint.Y }; + changed = true; + } + } + + if (!changed) + { + return path; + } + + // Remove penultimate if it collapsed to the same point as endpoint. + if (result.Count >= 2 && ElkEdgeRoutingGeometry.PointsEqual(result[^2], result[^1])) + { + result.RemoveAt(result.Count - 2); + } + + return NormalizePathPoints(result); + } } \ No newline at end of file diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.CoordinatedRepair.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.CoordinatedRepair.cs new file mode 100644 index 000000000..e1ac4d440 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.CoordinatedRepair.cs @@ -0,0 +1,251 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static CandidateSolution ApplyCoordinatedClusterRepair( + CandidateSolution solution, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance) + { + var current = solution; + var currentTotal = CountTotalHardViolations(current.RetryState); + if (currentTotal == 0) + { + return current; + } + + // Constraint-propagation pass: apply targeted geometric fixes + // WITHOUT per-step scoring guards or safety-check vetoes. + // The net-total scoring at the end is the sole decision point. + + // Detect violations per category. + var angleSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountBadBoundaryAngles(current.Edges, nodes, angleSeverity, 10); + + var underNodeSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountUnderNodeViolations(current.Edges, nodes, underNodeSeverity, 10); + + var sharedLaneSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountSharedLaneViolations(current.Edges, nodes, sharedLaneSeverity, 10); + + var joinSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(current.Edges, nodes, joinSeverity, 10); + + var backtrackingSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(current.Edges, nodes, backtrackingSeverity, 10); + + ElkLayoutDiagnostics.LogProgress( + $"Constraint propagation: angles={angleSeverity.Count} joins={joinSeverity.Count} " + + $"shared={sharedLaneSeverity.Count} underNode={underNodeSeverity.Count} " + + $"backtrack={backtrackingSeverity.Count} total={currentTotal}"); + + // Force-normalize boundary angles for edges with angle violations. + // This bypasses HasClearSourceExitSegment which silently rejects fixes + // that NormalizeBoundaryAngles computes. We let the net-total score + // decide whether the fix is worth adopting. + if (angleSeverity.Count > 0) + { + ElkLayoutDiagnostics.LogProgress( + $"Constraint propagation: force-normalizing {angleSeverity.Count} angle edges: [{string.Join(", ", angleSeverity.Keys.OrderBy(k => k, StringComparer.Ordinal))}]"); + var candidate = ForceNormalizeBoundaryAngles( + current.Edges, nodes, angleSeverity.Keys.ToHashSet(StringComparer.Ordinal)); + var changedCount = 0; + for (var ci = 0; ci < current.Edges.Length && ci < candidate.Length; ci++) + { + if (!ReferenceEquals(current.Edges[ci], candidate[ci])) + { + changedCount++; + } + } + ElkLayoutDiagnostics.LogProgress($"Constraint propagation: force-normalize changed {changedCount} edges"); + + // Only apply structural safety after angle fix — skip other repairs + // which cascade and create new violations worse than what they fix. + candidate = ElkEdgePostProcessor.AvoidNodeCrossings(candidate, nodes, direction); + + if (TryPromoteWithNetTotalCriterion(current, candidate, nodes, ref currentTotal, out var promoted)) + { + current = promoted; + ElkLayoutDiagnostics.LogProgress( + $"Constraint propagation promoted: total={currentTotal} retry={DescribeRetryState(current.RetryState)}"); + } + else + { + var candScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes); + var candRetry = BuildRetryState(candScore, 0); + ElkLayoutDiagnostics.LogProgress( + $"Constraint propagation rejected: candidate total={CountTotalHardViolations(candRetry)} " + + $"nc={candScore.NodeCrossings} retry={DescribeRetryState(candRetry)}"); + + // Log which edges actually changed geometry + for (var idx = 0; idx < Math.Min(current.Edges.Length, candidate.Length); idx++) + { + if (!ReferenceEquals(current.Edges[idx], candidate[idx])) + { + ElkLayoutDiagnostics.LogProgress($" changed: {candidate[idx].Id}"); + } + } + } + } + + ElkLayoutDiagnostics.LogProgress( + $"Constraint propagation complete: total={currentTotal}"); + + return current; + } + + /// + /// Applies NormalizeBoundaryAngles but FORCES adoption of the corrected path + /// for the specified edges, bypassing the HasClearSourceExitSegment safety check. + /// The safety check vetoes valid fixes when the corrected path creates a long + /// diagonal that appears (but doesn't actually) cross a node bounding box. + /// + private static ElkRoutedEdge[] ForceNormalizeBoundaryAngles( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + IReadOnlySet forceEdgeIds) + { + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var result = new ElkRoutedEdge[edges.Length]; + Array.Copy(edges, result, edges.Length); + + for (var i = 0; i < edges.Length; i++) + { + var edge = edges[i]; + if (!forceEdgeIds.Contains(edge.Id)) + { + continue; + } + + var path = new List(); + foreach (var section in edge.Sections) + { + if (path.Count == 0) + { + path.Add(section.StartPoint); + } + + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + } + + if (path.Count < 2) + { + continue; + } + + var normalized = path; + // Source exit normalization (forced — no HasClearSourceExitSegment check). + if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode) + && !ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[0], sourceNode); + var sourceNormalized = ElkEdgePostProcessor.NormalizeExitPath(normalized, sourceNode, sourceSide); + if (!sourceNormalized.Zip(normalized, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal) + || sourceNormalized.Count != normalized.Count) + { + normalized = sourceNormalized; + } + } + + // Target entry normalization (use standard NormalizeBoundaryAngles logic). + if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)) + { + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + var gatewayNormalized = ElkEdgePostProcessor.NormalizeGatewayEntryPath(normalized, targetNode, normalized[^1]); + if (ElkEdgePostProcessor.HasAcceptableGatewayBoundaryPath( + gatewayNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId, targetNode, fromStart: false)) + { + normalized = gatewayNormalized; + } + } + else + { + var targetSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[^1], targetNode); + normalized = ElkEdgePostProcessor.NormalizeEntryPath(normalized, targetNode, targetSide); + } + } + + if (normalized.Count == path.Count + && normalized.Zip(path, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal)) + { + continue; + } + + result[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 = normalized[0], + EndPoint = normalized[^1], + BendPoints = normalized.Count > 2 + ? normalized.Skip(1).Take(normalized.Count - 2).ToArray() + : [], + }, + ], + }; + } + + return result; + } + + private static bool TryPromoteWithNetTotalCriterion( + CandidateSolution current, + ElkRoutedEdge[] candidateEdges, + ElkPositionedNode[] nodes, + ref int currentTotal, + out CandidateSolution promoted) + { + promoted = current; + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); + var candidateRetryState = BuildRetryState( + candidateScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count + : 0); + + var candidateTotal = CountTotalHardViolations(candidateRetryState); + + if (candidateTotal < currentTotal + && candidateScore.NodeCrossings <= current.Score.NodeCrossings) + { + promoted = current with + { + Score = candidateScore, + RetryState = candidateRetryState, + Edges = candidateEdges, + }; + currentTotal = candidateTotal; + return true; + } + + return false; + } + + private static int CountTotalHardViolations(RoutingRetryState retryState) + { + return retryState.RemainingShortHighways + + retryState.RepeatCollectorCorridorViolations + + retryState.RepeatCollectorNodeClearanceViolations + + retryState.TargetApproachJoinViolations + + retryState.TargetApproachBacktrackingViolations + + retryState.ExcessiveDetourViolations + + retryState.SharedLaneViolations + + retryState.BoundarySlotViolations + + retryState.BelowGraphViolations + + retryState.UnderNodeViolations + + retryState.EntryAngleViolations + + retryState.GatewaySourceExitViolations; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.Routing.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.Routing.cs new file mode 100644 index 000000000..ad88161a1 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.Routing.cs @@ -0,0 +1,132 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static RouteAllEdgesResult RouteBoundaryFirstEdges( + ElkRoutedEdge[] existingEdges, + ElkPositionedNode[] nodes, + Dictionary assignments, + RoutingStrategy strategy, + CancellationToken cancellationToken) + { + var routedEdges = new ElkRoutedEdge[existingEdges.Length]; + Array.Copy(existingEdges, routedEdges, existingEdges.Length); + + var obstacleMargin = Math.Max( + strategy.MinLineClearance + 4d, + strategy.RoutingParams.Margin); + var obstacles = BuildObstacles(nodes, obstacleMargin); + var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); + var softObstacles = new List(); + var routedEdgeCount = 0; + var skippedEdgeCount = 0; + var routedSectionCount = 0; + var fallbackSectionCount = 0; + + foreach (var edgeIndex in strategy.EdgeOrder) + { + if (edgeIndex < 0 || edgeIndex >= existingEdges.Length) + { + continue; + } + + cancellationToken.ThrowIfCancellationRequested(); + var edge = existingEdges[edgeIndex]; + + // If this edge has no boundary-first assignment, keep it unchanged + // and contribute its segments as soft obstacles. + if (!assignments.TryGetValue(edge.Id, out var assignment)) + { + skippedEdgeCount++; + foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge)) + { + softObstacles.Add(new OrthogonalSoftObstacle(segment.Start, segment.End)); + } + + continue; + } + + // Route directly between assigned slot points. + // The A* excludes source/target nodes from obstacles, so the slot points + // on the node boundary are reachable. For gateways, use exterior departure/ + // approach points so the A* doesn't start inside the bounding box interior. + var startPoint = assignment.SourceSlotPoint; + var endPoint = assignment.TargetSlotPoint; + + if (assignment.IsGatewaySource + && nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)) + { + startPoint = ResolveGatewayRoutingDeparturePoint(sourceNode, endPoint, startPoint); + } + + if (assignment.IsGatewayTarget + && nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)) + { + endPoint = ResolveGatewayRoutingApproachPoint(targetNode, startPoint, endPoint); + } + + var rerouted = ElkEdgeRouterAStar8Dir.Route( + startPoint, + endPoint, + obstacles, + edge.SourceNodeId ?? "", + edge.TargetNodeId ?? "", + strategy.RoutingParams, + softObstacles, + cancellationToken); + + if (rerouted is not null && rerouted.Count >= 2) + { + routedSectionCount++; + + // Pin the endpoints to the exact slot positions so the path + // starts/ends precisely on the assigned boundary slots. + rerouted[0] = assignment.SourceSlotPoint; + rerouted[^1] = assignment.TargetSlotPoint; + + routedEdges[edgeIndex] = 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 = rerouted[0], + EndPoint = rerouted[^1], + BendPoints = rerouted.Skip(1).Take(rerouted.Count - 2).ToArray(), + }, + ], + }; + } + else + { + fallbackSectionCount++; + } + + routedEdgeCount++; + foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(routedEdges[edgeIndex])) + { + softObstacles.Add(new OrthogonalSoftObstacle(segment.Start, segment.End)); + } + } + + return new RouteAllEdgesResult( + routedEdges, + new ElkIterativeRouteDiagnostics + { + Mode = "boundary-first", + TotalEdges = existingEdges.Length, + RoutedEdges = routedEdgeCount, + SkippedEdges = skippedEdgeCount, + RoutedSections = routedSectionCount, + FallbackSections = fallbackSectionCount, + SoftObstacleSegments = softObstacles.Count, + }); + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.SlotAssignment.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.SlotAssignment.cs new file mode 100644 index 000000000..4178c331b --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.SlotAssignment.cs @@ -0,0 +1,173 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static Dictionary ComputeGlobalBoundarySlotAssignments( + ElkRoutedEdge[] baselineEdges, + ElkPositionedNode[] nodes, + double graphMinY, + double graphMaxY) + { + var assignments = new Dictionary(StringComparer.Ordinal); + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + + // Group edges by {nodeId}|{side} for both source and target endpoints, + // following the same grouping logic as ResolveCombinedBoundarySlots. + var sourceGroups = new Dictionary>(StringComparer.Ordinal); + var targetGroups = new Dictionary>(StringComparer.Ordinal); + + for (var edgeIndex = 0; edgeIndex < baselineEdges.Length; edgeIndex++) + { + var edge = baselineEdges[edgeIndex]; + if (!ShouldRouteEdge(edge, graphMinY, graphMaxY)) + { + continue; + } + + var path = ExtractPath(edge); + if (path.Count < 2) + { + continue; + } + + // Source side resolution + if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)) + { + var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(path[0], path[1], sourceNode); + if (sourceSide is "left" or "right" or "top" or "bottom") + { + var sourceCoordinate = sourceSide is "left" or "right" ? path[0].Y : path[0].X; + var sourceKey = $"{sourceNode.Id}|{sourceSide}"; + if (!sourceGroups.TryGetValue(sourceKey, out var sourceGroup)) + { + sourceGroup = []; + sourceGroups[sourceKey] = sourceGroup; + } + + sourceGroup.Add((edge.Id, edgeIndex, sourceNode, sourceSide, sourceCoordinate)); + } + } + + // Target side resolution + if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)) + { + var targetSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(path[^1], path[^2], targetNode); + if (targetSide is "left" or "right" or "top" or "bottom") + { + var targetCoordinate = targetSide is "left" or "right" ? path[^1].Y : path[^1].X; + var targetKey = $"{targetNode.Id}|{targetSide}"; + if (!targetGroups.TryGetValue(targetKey, out var targetGroup)) + { + targetGroup = []; + targetGroups[targetKey] = targetGroup; + } + + targetGroup.Add((edge.Id, edgeIndex, targetNode, targetSide, targetCoordinate)); + } + } + } + + // Resolve source slot assignments + var sourceSlots = new Dictionary(StringComparer.Ordinal); + foreach (var (_, group) in sourceGroups) + { + var node = group[0].Node; + var side = group[0].Side; + var ordered = group + .OrderBy(item => item.Coordinate) + .ThenBy(item => item.EdgeId, StringComparer.Ordinal) + .ToArray(); + var coordinates = ordered.Select(item => item.Coordinate).ToArray(); + var assignedSlotCoordinates = ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates( + node, side, coordinates); + for (var i = 0; i < ordered.Length; i++) + { + var boundaryPoint = ElkBoundarySlots.BuildBoundarySlotPoint(node, side, assignedSlotCoordinates[i]); + sourceSlots[ordered[i].EdgeId] = (boundaryPoint, side); + } + } + + // Resolve target slot assignments + var targetSlots = new Dictionary(StringComparer.Ordinal); + foreach (var (_, group) in targetGroups) + { + var node = group[0].Node; + var side = group[0].Side; + var ordered = group + .OrderBy(item => item.Coordinate) + .ThenBy(item => item.EdgeId, StringComparer.Ordinal) + .ToArray(); + var coordinates = ordered.Select(item => item.Coordinate).ToArray(); + var assignedSlotCoordinates = ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates( + node, side, coordinates); + for (var i = 0; i < ordered.Length; i++) + { + var boundaryPoint = ElkBoundarySlots.BuildBoundarySlotPoint(node, side, assignedSlotCoordinates[i]); + targetSlots[ordered[i].EdgeId] = (boundaryPoint, side); + } + } + + // Build combined assignments for edges that have both source and target slots + for (var edgeIndex = 0; edgeIndex < baselineEdges.Length; edgeIndex++) + { + var edge = baselineEdges[edgeIndex]; + if (!sourceSlots.TryGetValue(edge.Id, out var sourceSlot) + || !targetSlots.TryGetValue(edge.Id, out var targetSlot)) + { + continue; + } + + var isGatewaySource = nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var srcNode) + && ElkShapeBoundaries.IsGatewayShape(srcNode); + var isGatewayTarget = nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var tgtNode) + && ElkShapeBoundaries.IsGatewayShape(tgtNode); + + assignments[edge.Id] = new BoundaryFirstAssignment( + edge.Id, + edgeIndex, + sourceSlot.Boundary, + sourceSlot.Side, + targetSlot.Boundary, + targetSlot.Side, + isGatewaySource, + isGatewayTarget); + } + + return assignments; + } + + private static NodeFieldClearanceBand[] BuildNodeFieldClearanceBands( + ElkPositionedNode[] nodes, + double minLineClearance) + { + var bands = new List(); + var bandHeight = minLineClearance * 0.6; + var bandMarginX = minLineClearance * 0.25; + + foreach (var node in nodes) + { + if (node.Kind is "Start" or "End") + { + continue; + } + + // Band above the node + bands.Add(new NodeFieldClearanceBand( + Left: node.X - bandMarginX, + Top: node.Y - bandHeight, + Right: node.X + node.Width + bandMarginX, + Bottom: node.Y - 1d, + BlockingNodeId: node.Id)); + + // Band below the node + bands.Add(new NodeFieldClearanceBand( + Left: node.X - bandMarginX, + Top: node.Y + node.Height + 1d, + Right: node.X + node.Width + bandMarginX, + Bottom: node.Y + node.Height + bandHeight, + BlockingNodeId: node.Id)); + } + + return bands.ToArray(); + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.Types.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.Types.cs new file mode 100644 index 000000000..0c36ce38b --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.Types.cs @@ -0,0 +1,21 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private readonly record struct BoundaryFirstAssignment( + string EdgeId, + int EdgeIndex, + ElkPoint SourceSlotPoint, + string SourceSide, + ElkPoint TargetSlotPoint, + string TargetSide, + bool IsGatewaySource, + bool IsGatewayTarget); + + private readonly record struct NodeFieldClearanceBand( + double Left, + double Top, + double Right, + double Bottom, + string BlockingNodeId); +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.Verification.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.Verification.cs new file mode 100644 index 000000000..74aec434b --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.Verification.cs @@ -0,0 +1,36 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static ElkRoutedEdge[] ApplyBoundaryFirstVerification( + ElkRoutedEdge[] edges, + ElkRoutedEdge[] originalEdges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance) + { + // Minimal structural safety only — the hybrid winner refinement + // handles remaining violations after boundary-first is promoted. + var result = ElkEdgePostProcessor.AvoidNodeCrossings(edges, nodes, direction); + result = ElkEdgePostProcessor.EliminateDiagonalSegments(result, nodes); + result = ElkEdgePostProcessorSimplify.SimplifyEdgePaths(result, nodes); + result = ElkEdgePostProcessorSimplify.TightenOuterCorridors(result, nodes); + if (HighwayProcessingEnabled) + { + result = ElkEdgeRouterHighway.BreakShortHighways(result, nodes); + } + + // Normalize boundary geometry for the slot-pinned endpoints. + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + + // Collector-specific structural repairs. + result = RestoreProtectedRepeatCollectorCorridors(result, originalEdges, nodes); + + // Below-graph clamping and final crossing check. + result = ClampBelowGraphEdges(result, nodes); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction); + + return result; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.cs new file mode 100644 index 000000000..f7074736e --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.cs @@ -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(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(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); + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs index 15afb964c..1cb7cdc7a 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs @@ -65,6 +65,29 @@ internal static partial class ElkEdgeRouterIterative 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)}"); + } + } + return current; }