diff --git a/docs/implplan/SPRINT_20260329_007_ElkSharp_document_render_speed.md b/docs/implplan/SPRINT_20260329_007_ElkSharp_document_render_speed.md new file mode 100644 index 000000000..028ca15e3 --- /dev/null +++ b/docs/implplan/SPRINT_20260329_007_ElkSharp_document_render_speed.md @@ -0,0 +1,92 @@ +# Sprint 20260329-007 - ElkSharp Document Render Speed + +## Topic & Scope +- Reduce the actual document-processing render path to a sub-15-second generation target for the current document fixture. +- Promote the deterministic hybrid iterative router into the real `LeftToRight` workflow-rendering path without changing Sugiyama-owned node geometry. +- Increase safe parallel repair-candidate construction to use all available CPU cores while preserving deterministic commit order. +- Working directory: `src/__Libraries/StellaOps.ElkSharp/`. +- Expected evidence: renderer-path config changes, focused speed measurements on the document-processing tests, and updated sprint execution logs. + +## Dependencies & Concurrency +- Depends on `docs/implplan/SPRINT_20260329_006_ElkSharp_hybrid_iterative_routing.md` for the hybrid router baseline. +- Safe cross-module edits for this sprint are limited to: + - `src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkSharp/` + - `src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/` + - `docs/workflow/` + - `docs/implplan/` + +## Documentation Prerequisites +- `docs/code-of-conduct/CODE_OF_CONDUCT.md` +- `docs/code-of-conduct/TESTING_PRACTICES.md` +- `docs/workflow/ENGINE.md` +- `src/__Libraries/StellaOps.ElkSharp/AGENTS.md` +- `docs/implplan/SPRINT_20260329_006_ElkSharp_hybrid_iterative_routing.md` + +## Delivery Tracker + +### TASK-001 - Route actual document renders through the hybrid deterministic path +Status: DONE +Dependency: none +Owners: Implementer +Task description: +- Make the workflow renderer use the hybrid iterative router by default for `LeftToRight` document renders so real document-processing output stops paying the legacy multi-strategy brute-force cost. +- Preserve `TopToBottom` behavior and keep Sugiyama-owned geometry contracts unchanged. + +Completion criteria: +- [x] `ElkSharpWorkflowRenderLayoutEngine` explicitly configures hybrid deterministic iterative routing for `LeftToRight` renders +- [x] `TopToBottom` stays on the legacy path +- [x] Focused hybrid parity tests still pass after the renderer-path change + +### TASK-002 - Use full-core parallel repair-candidate builds safely +Status: DONE +Dependency: TASK-001 +Owners: Implementer +Task description: +- Remove the artificial low CPU cap in iterative local-repair candidate building. +- Keep build work parallel only for independent candidate construction; merge/apply order must remain deterministic. + +Completion criteria: +- [x] Iterative config accepts a full-core parallel repair-build budget +- [x] Parallel repair-build scheduling honors the requested budget up to available processor count +- [x] Deterministic hybrid parity tests still pass with the higher parallelism budget + +### TASK-003 - Reduce the remaining winner-terminal speed hotspot +Status: DOING +Dependency: TASK-002 +Owners: Implementer +Task description: +- Re-measure the actual document-processing layout path after the hybrid/default and full-core changes. +- If the document is still above target, optimize the winner post-slot hard-rule fallback and terminal-closure path without weakening hard-rule quality gates. + +Completion criteria: +- [x] The layout-only document-processing test completes in under 15 seconds +- [x] Any new winner-terminal/finalization optimization keeps deterministic ordering and focused regression coverage intact +- [ ] The rendered document artifact test passes on the same fast-path configuration +- [x] Sprint execution log records before/after timings for the document-processing path + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-29 | Sprint created to track actual document-processing render speed work after hybrid routing landed behind an opt-in flag. | Implementer | +| 2026-03-29 | Workflow renderer now defaults `LeftToRight` document renders to hybrid deterministic iterative routing with `MaxRepairWaves=1` and `MaxParallelRepairBuilds=Environment.ProcessorCount`; focused hybrid/renderer tests stayed green (`5/5`). | Implementer | +| 2026-03-29 | Real layout-only document test hit `14.49s` wall time (`15.12s` total test time) on the reverted low-wave hybrid path, meeting the raw layout-only speed target for that fixture. | Implementer | +| 2026-03-29 | Document-quality checks still failed on the same low-wave path: `DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotBacktrackIntoCheckResult`, `...ShouldKeepDecisionSourceExitsOnDiscreteBoundarySlots`, and `...ShouldProducePngWithZeroNodeCrossings` due to residual entry-angle / target-approach / boundary-slot defects. | Implementer | +| 2026-03-29 | Added hybrid attempt diagnostics so artifact tests now observe targeted local-repair attempts in hybrid mode; experimental exact-restabilization speed fixes were reverted after they pushed layout-only runtime back above target without clearing the geometry regressions. | Implementer | +| 2026-03-29 | Narrowed the hybrid low-wave winner-refinement path: focused terminal closure now caps rounds for restricted passes, shared-lane polish uses lean hybrid cleanup in low-wave mode, hybrid post-slot focus sets are capped deterministically, and tiny restricted boundary-slot repairs now stop after normalization instead of paying full detour closure. | Implementer | +| 2026-03-29 | Current speed gate on the actual document fixture is `13.81s` wall time (`14.36s` total test time) for `DocumentProcessingWorkflow_WhenLayoutOnly_ShouldProduceFinitePositions`; focused guards `...ShouldNotBacktrackIntoCheckResult` and `...ShouldKeepDecisionSourceExitsOnDiscreteBoundarySlots` both still pass on the same fast path. | Implementer | +| 2026-03-29 | The rendered artifact regression is faster on the same configuration (optimize phase ~`11.4s`, total test `16.27s`) but still fails with the pre-existing geometry defects: entry angles `2`, boundary angles on `edge/7` and `edge/27`, target join `edge/32+edge/33`, shared lane `edge/3+edge/4`, and under-node `edge/15`, `edge/20`, `edge/25`. | Implementer | + +## Decisions & Risks +- The speed target is for the real document-processing render path, not synthetic hybrid-only stress tests. +- Hybrid mode may become the default only for `LeftToRight` workflow renders in this sprint; `TopToBottom` remains on the legacy path until parity is explicitly proven. +- Full-core usage applies to candidate construction only. Candidate commits and final route application must remain single-threaded and deterministic. +- The current best-performing configuration uses one hybrid wave and full-core candidate builds. That path is fast enough on the layout-only document test but still violates boundary-slot, target-approach backtracking, and entry-angle quality checks on the document fixture. +- The current best-performing configuration now meets the `<15s` layout-only document gate by using a narrower low-wave winner-refinement path, but the rendered artifact test still fails on the same entry-angle, target-join, shared-lane, and under-node offenders. +- Attempt-level hybrid diagnostics are now emitted into `ElkLayoutDiagnostics.IterativeStrategies`, restoring visibility into targeted local-repair activity for artifact tests. +- Reintroducing broader exact restabilization in the winner-terminal path raised the real document runtime into the `34s+` range without clearing the remaining geometry regressions, so that branch was reverted rather than left in the workspace. +- Tiny focused boundary-slot repairs now use an ultra-lean restricted path that stops after normalization. This is acceptable for the speed gate because the remaining quality failures are unchanged, but it is still a tradeoff that should be revisited once the artifact-quality defects are fixed. + +## Next Checkpoints +- Keep the single-wave hybrid renderer path and full-core local-repair budget in place while targeting the residual artifact offenders `edge/7`, `edge/27`, `edge/32+edge/33`, `edge/3+edge/4`, and `edge/15`/`edge/20`/`edge/25` with narrower exact repairs. +- Re-run the rendered artifact regression together with the layout-only speed gate and the two focused document guards after each targeted fix. +- Only close the sprint once the document fixture satisfies both the `<15s` layout-only gate and the document-quality checks under the same configuration. diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkSharp/ElkSharpWorkflowRenderLayoutEngine.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkSharp/ElkSharpWorkflowRenderLayoutEngine.cs index d432d8615..be02fb317 100644 --- a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkSharp/ElkSharpWorkflowRenderLayoutEngine.cs +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkSharp/ElkSharpWorkflowRenderLayoutEngine.cs @@ -23,31 +23,48 @@ public sealed class ElkSharpWorkflowRenderLayoutEngine : INamedWorkflowRenderGra ArgumentNullException.ThrowIfNull(graph); request ??= new WorkflowRenderLayoutRequest(); + var direction = request.Direction == WorkflowRenderLayoutDirection.LeftToRight + ? ElkLayoutDirection.LeftToRight + : ElkLayoutDirection.TopToBottom; + var effort = request.Effort switch + { + WorkflowRenderLayoutEffort.Draft => ElkLayoutEffort.Draft, + WorkflowRenderLayoutEffort.Balanced => ElkLayoutEffort.Balanced, + _ => ElkLayoutEffort.Best, + }; var elkGraph = new ElkGraph { Id = graph.Id, Nodes = graph.Nodes.Select(MapNode).ToArray(), Edges = graph.Edges.Select(MapEdge).ToArray(), }; + var layoutOptions = new ElkLayoutOptions + { + Direction = direction, + NodeSpacing = request.NodeSpacing, + LayerSpacing = request.LayerSpacing, + Effort = effort, + OrderingIterations = request.OrderingIterations, + PlacementIterations = request.PlacementIterations, + }; + if (direction == ElkLayoutDirection.LeftToRight + && effort == ElkLayoutEffort.Best) + { + layoutOptions = layoutOptions with + { + IterativeRouting = new IterativeRoutingOptions + { + Enabled = true, + Mode = IterativeRoutingMode.HybridDeterministic, + MaxRepairWaves = 1, + MaxParallelRepairBuilds = Math.Max(1, Environment.ProcessorCount), + }, + }; + } var elkResult = await elkLayoutEngine.LayoutAsync( elkGraph, - new ElkLayoutOptions - { - Direction = request.Direction == WorkflowRenderLayoutDirection.LeftToRight - ? ElkLayoutDirection.LeftToRight - : ElkLayoutDirection.TopToBottom, - NodeSpacing = request.NodeSpacing, - LayerSpacing = request.LayerSpacing, - Effort = request.Effort switch - { - WorkflowRenderLayoutEffort.Draft => ElkLayoutEffort.Draft, - WorkflowRenderLayoutEffort.Balanced => ElkLayoutEffort.Balanced, - _ => ElkLayoutEffort.Best, - }, - OrderingIterations = request.OrderingIterations, - PlacementIterations = request.PlacementIterations, - }, + layoutOptions, cancellationToken); return new WorkflowRenderLayoutResult diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkCompoundLayout.BoundaryCrossings.cs b/src/__Libraries/StellaOps.ElkSharp/ElkCompoundLayout.BoundaryCrossings.cs new file mode 100644 index 000000000..a95ad8bb4 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkCompoundLayout.BoundaryCrossings.cs @@ -0,0 +1,282 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkCompoundLayout +{ + private static ElkRoutedEdge[] InsertCompoundBoundaryCrossings( + IReadOnlyCollection routedEdges, + IReadOnlyDictionary positionedNodes, + ElkCompoundHierarchy hierarchy) + { + return routedEdges + .Select(edge => + { + var path = ExtractPath(edge); + if (path.Count < 2) + { + return edge; + } + + var lcaNodeId = hierarchy.GetLowestCommonAncestor(edge.SourceNodeId, edge.TargetNodeId); + var sourceAncestorIds = hierarchy.GetAncestorIdsNearestFirst(edge.SourceNodeId) + .TakeWhile(ancestorNodeId => !string.Equals(ancestorNodeId, lcaNodeId, StringComparison.Ordinal)) + .ToArray(); + var targetAncestorIds = hierarchy.GetAncestorIdsNearestFirst(edge.TargetNodeId) + .TakeWhile(ancestorNodeId => !string.Equals(ancestorNodeId, lcaNodeId, StringComparison.Ordinal)) + .ToArray(); + if (sourceAncestorIds.Length == 0 && targetAncestorIds.Length == 0) + { + return edge; + } + + var rebuiltPath = path.ToList(); + var startSegmentIndex = 0; + foreach (var ancestorNodeId in sourceAncestorIds) + { + if (!positionedNodes.TryGetValue(ancestorNodeId, out var ancestorNode)) + { + continue; + } + + if (!TryFindBoundaryTransitionFromStart(rebuiltPath, ancestorNode, startSegmentIndex, out var insertIndex, out var boundaryPoint)) + { + continue; + } + + rebuiltPath.Insert(insertIndex, boundaryPoint); + startSegmentIndex = insertIndex; + } + + var endSegmentIndex = rebuiltPath.Count - 2; + foreach (var ancestorNodeId in targetAncestorIds) + { + if (!positionedNodes.TryGetValue(ancestorNodeId, out var ancestorNode)) + { + continue; + } + + if (!TryFindBoundaryTransitionFromEnd(rebuiltPath, ancestorNode, endSegmentIndex, out var insertIndex, out var boundaryPoint)) + { + continue; + } + + rebuiltPath.Insert(insertIndex, boundaryPoint); + endSegmentIndex = insertIndex - 2; + } + + return BuildEdgeWithPath(edge, rebuiltPath); + }) + .ToArray(); + } + + private static List ExtractPath(ElkRoutedEdge edge) + { + var path = new List(); + foreach (var section in edge.Sections) + { + AppendPoint(path, section.StartPoint); + foreach (var bendPoint in section.BendPoints) + { + AppendPoint(path, bendPoint); + } + + AppendPoint(path, section.EndPoint); + } + + return path; + } + + private static ElkRoutedEdge BuildEdgeWithPath(ElkRoutedEdge edge, IReadOnlyList path) + { + var normalizedPath = NormalizePath(path); + if (normalizedPath.Count < 2) + { + return edge; + } + + return edge with + { + Sections = + [ + new ElkEdgeSection + { + StartPoint = normalizedPath[0], + EndPoint = normalizedPath[^1], + BendPoints = normalizedPath.Count > 2 + ? normalizedPath.Skip(1).Take(normalizedPath.Count - 2).ToArray() + : [], + }, + ], + }; + } + + private static bool TryFindBoundaryTransitionFromStart( + IReadOnlyList path, + ElkPositionedNode boundaryNode, + int startSegmentIndex, + out int insertIndex, + out ElkPoint boundaryPoint) + { + insertIndex = -1; + boundaryPoint = default!; + for (var segmentIndex = Math.Max(0, startSegmentIndex); segmentIndex < path.Count - 1; segmentIndex++) + { + var from = path[segmentIndex]; + var to = path[segmentIndex + 1]; + if (!IsInsideOrOn(boundaryNode, from) || IsInsideOrOn(boundaryNode, to)) + { + continue; + } + + if (!TryResolveBoundaryTransition(boundaryNode, from, to, exitBoundary: true, out boundaryPoint)) + { + continue; + } + + insertIndex = segmentIndex + 1; + return true; + } + + return false; + } + + private static bool TryFindBoundaryTransitionFromEnd( + IReadOnlyList path, + ElkPositionedNode boundaryNode, + int startSegmentIndex, + out int insertIndex, + out ElkPoint boundaryPoint) + { + insertIndex = -1; + boundaryPoint = default!; + for (var segmentIndex = Math.Min(startSegmentIndex, path.Count - 2); segmentIndex >= 0; segmentIndex--) + { + var from = path[segmentIndex]; + var to = path[segmentIndex + 1]; + if (IsInsideOrOn(boundaryNode, from) || !IsInsideOrOn(boundaryNode, to)) + { + continue; + } + + if (!TryResolveBoundaryTransition(boundaryNode, from, to, exitBoundary: false, out boundaryPoint)) + { + continue; + } + + insertIndex = segmentIndex + 1; + return true; + } + + return false; + } + + private static bool TryResolveBoundaryTransition( + ElkPositionedNode boundaryNode, + ElkPoint from, + ElkPoint to, + bool exitBoundary, + out ElkPoint boundaryPoint) + { + boundaryPoint = default!; + if (!Clip(boundaryNode, from, to, out var enterScale, out var exitScale)) + { + return false; + } + + var scale = exitBoundary ? exitScale : enterScale; + scale = Math.Clamp(scale, 0d, 1d); + boundaryPoint = new ElkPoint + { + X = from.X + ((to.X - from.X) * scale), + Y = from.Y + ((to.Y - from.Y) * scale), + }; + return true; + } + + private static bool Clip( + ElkPositionedNode node, + ElkPoint from, + ElkPoint to, + out double enterScale, + out double exitScale) + { + enterScale = 0d; + exitScale = 1d; + + var deltaX = to.X - from.X; + var deltaY = to.Y - from.Y; + return ClipTest(-deltaX, from.X - node.X, ref enterScale, ref exitScale) + && ClipTest(deltaX, (node.X + node.Width) - from.X, ref enterScale, ref exitScale) + && ClipTest(-deltaY, from.Y - node.Y, ref enterScale, ref exitScale) + && ClipTest(deltaY, (node.Y + node.Height) - from.Y, ref enterScale, ref exitScale); + + static bool ClipTest(double p, double q, ref double enter, ref double exit) + { + if (Math.Abs(p) <= 0.0001d) + { + return q >= -0.0001d; + } + + var ratio = q / p; + if (p < 0d) + { + if (ratio > exit) + { + return false; + } + + if (ratio > enter) + { + enter = ratio; + } + } + else + { + if (ratio < enter) + { + return false; + } + + if (ratio < exit) + { + exit = ratio; + } + } + + return true; + } + } + + private static bool IsInsideOrOn(ElkPositionedNode node, ElkPoint point) + { + const double tolerance = 0.01d; + return point.X >= node.X - tolerance + && point.X <= node.X + node.Width + tolerance + && point.Y >= node.Y - tolerance + && point.Y <= node.Y + node.Height + tolerance; + } + + private static List NormalizePath(IReadOnlyList path) + { + var normalized = new List(path.Count); + foreach (var point in path) + { + AppendPoint(normalized, point); + } + + return normalized; + } + + private static void AppendPoint(ICollection path, ElkPoint point) + { + if (path.Count > 0 && path.Last() is { } previousPoint && AreSamePoint(previousPoint, point)) + { + return; + } + + path.Add(new ElkPoint { X = point.X, Y = point.Y }); + } + + private static bool AreSamePoint(ElkPoint left, ElkPoint right) => + Math.Abs(left.X - right.X) <= 0.01d + && Math.Abs(left.Y - right.Y) <= 0.01d; +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkCompoundLayout.Ordering.cs b/src/__Libraries/StellaOps.ElkSharp/ElkCompoundLayout.Ordering.cs new file mode 100644 index 000000000..c4f87fbf0 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkCompoundLayout.Ordering.cs @@ -0,0 +1,157 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkCompoundLayout +{ + private static ElkNode[][] OptimizeLayerOrderingForHierarchy( + ElkNode[][] initialLayers, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary inputOrder, + int iterationCount, + ElkCompoundHierarchy hierarchy, + IReadOnlySet dummyNodeIds) + { + var layers = initialLayers + .Select(layer => layer.ToList()) + .ToArray(); + var effectiveIterations = Math.Max(1, iterationCount); + + for (var iteration = 0; iteration < effectiveIterations; iteration++) + { + for (var layerIndex = 1; layerIndex < layers.Length; layerIndex++) + { + OrderLayerWithHierarchy(layers, layerIndex, incomingNodeIds, inputOrder, hierarchy, dummyNodeIds); + } + + for (var layerIndex = layers.Length - 2; layerIndex >= 0; layerIndex--) + { + OrderLayerWithHierarchy(layers, layerIndex, outgoingNodeIds, inputOrder, hierarchy, dummyNodeIds); + } + } + + return layers + .Select(layer => layer.ToArray()) + .ToArray(); + } + + private static void OrderLayerWithHierarchy( + IReadOnlyList> layers, + int layerIndex, + IReadOnlyDictionary> adjacentNodeIds, + IReadOnlyDictionary inputOrder, + ElkCompoundHierarchy hierarchy, + IReadOnlySet dummyNodeIds) + { + var currentLayer = layers[layerIndex]; + if (currentLayer.Count == 0) + { + return; + } + + var positions = ElkNodeOrdering.BuildNodeOrderPositions(layers); + var layerNodesById = currentLayer.ToDictionary(node => node.Id, StringComparer.Ordinal); + var realNodeIdsInLayer = currentLayer + .Where(node => !dummyNodeIds.Contains(node.Id)) + .Select(node => node.Id) + .ToHashSet(StringComparer.Ordinal); + + var orderedRealIds = OrderNodesForSubtree( + null, + hierarchy, + realNodeIdsInLayer, + adjacentNodeIds, + positions, + inputOrder); + var orderedDummies = currentLayer + .Where(node => dummyNodeIds.Contains(node.Id)) + .OrderBy(node => ElkNodeOrdering.ResolveOrderingRank(node.Id, adjacentNodeIds, positions)) + .ThenBy(node => positions[node.Id]) + .ThenBy(node => inputOrder[node.Id]) + .ToArray(); + + currentLayer.Clear(); + foreach (var nodeId in orderedRealIds) + { + currentLayer.Add(layerNodesById[nodeId]); + } + + currentLayer.AddRange(orderedDummies); + } + + private static List OrderNodesForSubtree( + string? parentNodeId, + ElkCompoundHierarchy hierarchy, + IReadOnlySet realNodeIdsInLayer, + IReadOnlyDictionary> adjacentNodeIds, + IReadOnlyDictionary positions, + IReadOnlyDictionary inputOrder) + { + var blocks = new List(); + foreach (var childNodeId in hierarchy.GetChildIds(parentNodeId)) + { + if (realNodeIdsInLayer.Contains(childNodeId)) + { + blocks.Add(BuildBlock([childNodeId])); + continue; + } + + if (!hierarchy.IsCompoundNode(childNodeId)) + { + continue; + } + + var descendantNodeIds = OrderNodesForSubtree( + childNodeId, + hierarchy, + realNodeIdsInLayer, + adjacentNodeIds, + positions, + inputOrder); + if (descendantNodeIds.Count == 0) + { + continue; + } + + blocks.Add(BuildBlock(descendantNodeIds)); + } + + return blocks + .OrderBy(block => block.Rank) + .ThenBy(block => block.MinCurrentPosition) + .ThenBy(block => block.MinInputOrder) + .SelectMany(block => block.NodeIds) + .ToList(); + + HierarchyOrderBlock BuildBlock(IReadOnlyList nodeIds) + { + var ranks = new List(); + foreach (var nodeId in nodeIds) + { + var nodeRank = ElkNodeOrdering.ResolveOrderingRank(nodeId, adjacentNodeIds, positions); + if (!double.IsInfinity(nodeRank)) + { + ranks.Add(nodeRank); + } + } + + ranks.Sort(); + var rank = ranks.Count switch + { + 0 => double.PositiveInfinity, + _ when ranks.Count % 2 == 1 => ranks[ranks.Count / 2], + _ => (ranks[(ranks.Count / 2) - 1] + ranks[ranks.Count / 2]) / 2d, + }; + return new HierarchyOrderBlock( + nodeIds, + rank, + nodeIds.Min(nodeId => positions.GetValueOrDefault(nodeId, int.MaxValue)), + nodeIds.Min(nodeId => inputOrder.GetValueOrDefault(nodeId, int.MaxValue))); + } + } + + private readonly record struct HierarchyOrderBlock( + IReadOnlyList NodeIds, + double Rank, + int MinCurrentPosition, + int MinInputOrder); +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkCompoundLayout.Positioning.cs b/src/__Libraries/StellaOps.ElkSharp/ElkCompoundLayout.Positioning.cs new file mode 100644 index 000000000..a64575955 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkCompoundLayout.Positioning.cs @@ -0,0 +1,130 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkCompoundLayout +{ + private static Dictionary BuildCompoundPositionedNodes( + IReadOnlyCollection graphNodes, + ElkCompoundHierarchy hierarchy, + IReadOnlyDictionary positionedVisibleNodes, + ElkLayoutOptions options) + { + var nodesById = graphNodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var compoundNodes = new Dictionary(StringComparer.Ordinal); + foreach (var pair in positionedVisibleNodes) + { + if (!hierarchy.IsLayoutVisibleNode(pair.Key)) + { + continue; + } + + compoundNodes[pair.Key] = pair.Value; + } + + foreach (var nodeId in hierarchy.GetNonLeafNodeIdsByDescendingDepth()) + { + var childBounds = hierarchy.GetChildIds(nodeId) + .Select(childNodeId => compoundNodes[childNodeId]) + .ToArray(); + var contentLeft = childBounds.Min(node => node.X); + var contentTop = childBounds.Min(node => node.Y); + var contentRight = childBounds.Max(node => node.X + node.Width); + var contentBottom = childBounds.Max(node => node.Y + node.Height); + + var desiredWidth = (contentRight - contentLeft) + (options.CompoundPadding * 2d); + var desiredHeight = (contentBottom - contentTop) + options.CompoundHeaderHeight + (options.CompoundPadding * 2d); + var width = Math.Max(nodesById[nodeId].Width, desiredWidth); + var height = Math.Max(nodesById[nodeId].Height, desiredHeight); + var x = contentLeft - options.CompoundPadding - ((width - desiredWidth) / 2d); + var y = contentTop - options.CompoundHeaderHeight - options.CompoundPadding - ((height - desiredHeight) / 2d); + + compoundNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode( + nodesById[nodeId], + x, + y, + options.Direction) with + { + Width = width, + Height = height, + }; + } + + return compoundNodes; + } + + private static bool TryResolveNegativeCoordinateShift( + IReadOnlyDictionary positionedNodes, + out double shiftX, + out double shiftY) + { + shiftX = 0d; + shiftY = 0d; + if (positionedNodes.Count == 0) + { + return false; + } + + var minX = positionedNodes.Values.Min(node => node.X); + var minY = positionedNodes.Values.Min(node => node.Y); + if (minX >= -0.01d && minY >= -0.01d) + { + return false; + } + + shiftX = minX < 0d ? -minX : 0d; + shiftY = minY < 0d ? -minY : 0d; + return shiftX > 0d || shiftY > 0d; + } + + private static Dictionary ShiftNodes( + IReadOnlyCollection sourceNodes, + IReadOnlyDictionary positionedNodes, + double shiftX, + double shiftY, + ElkLayoutDirection direction) + { + var sourceNodesById = sourceNodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + return positionedNodes.ToDictionary( + pair => pair.Key, + pair => + { + var shifted = ElkLayoutHelpers.CreatePositionedNode( + sourceNodesById[pair.Key], + pair.Value.X + shiftX, + pair.Value.Y + shiftY, + direction); + return shifted with + { + Width = pair.Value.Width, + Height = pair.Value.Height, + }; + }, + StringComparer.Ordinal); + } + + private static ElkRoutedEdge[] ShiftEdges( + IReadOnlyCollection routedEdges, + double shiftX, + double shiftY) + { + return routedEdges + .Select(edge => new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + SourcePortId = edge.SourcePortId, + TargetPortId = edge.TargetPortId, + Kind = edge.Kind, + Label = edge.Label, + Sections = edge.Sections.Select(section => new ElkEdgeSection + { + StartPoint = new ElkPoint { X = section.StartPoint.X + shiftX, Y = section.StartPoint.Y + shiftY }, + EndPoint = new ElkPoint { X = section.EndPoint.X + shiftX, Y = section.EndPoint.Y + shiftY }, + BendPoints = section.BendPoints + .Select(point => new ElkPoint { X = point.X + shiftX, Y = point.Y + shiftY }) + .ToArray(), + }).ToArray(), + }) + .ToArray(); + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkCompoundLayout.cs b/src/__Libraries/StellaOps.ElkSharp/ElkCompoundLayout.cs index 0946797a1..75f9e9e91 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkCompoundLayout.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkCompoundLayout.cs @@ -1,6 +1,5 @@ namespace StellaOps.ElkSharp; - -internal static class ElkCompoundLayout +internal static partial class ElkCompoundLayout { internal static ElkLayoutResult Layout( ElkGraph graph, @@ -298,560 +297,4 @@ internal static class ElkCompoundLayout }; } - private static ElkNode[][] OptimizeLayerOrderingForHierarchy( - ElkNode[][] initialLayers, - IReadOnlyDictionary> incomingNodeIds, - IReadOnlyDictionary> outgoingNodeIds, - IReadOnlyDictionary inputOrder, - int iterationCount, - ElkCompoundHierarchy hierarchy, - IReadOnlySet dummyNodeIds) - { - var layers = initialLayers - .Select(layer => layer.ToList()) - .ToArray(); - var effectiveIterations = Math.Max(1, iterationCount); - - for (var iteration = 0; iteration < effectiveIterations; iteration++) - { - for (var layerIndex = 1; layerIndex < layers.Length; layerIndex++) - { - OrderLayerWithHierarchy(layers, layerIndex, incomingNodeIds, inputOrder, hierarchy, dummyNodeIds); - } - - for (var layerIndex = layers.Length - 2; layerIndex >= 0; layerIndex--) - { - OrderLayerWithHierarchy(layers, layerIndex, outgoingNodeIds, inputOrder, hierarchy, dummyNodeIds); - } - } - - return layers - .Select(layer => layer.ToArray()) - .ToArray(); - } - - private static void OrderLayerWithHierarchy( - IReadOnlyList> layers, - int layerIndex, - IReadOnlyDictionary> adjacentNodeIds, - IReadOnlyDictionary inputOrder, - ElkCompoundHierarchy hierarchy, - IReadOnlySet dummyNodeIds) - { - var currentLayer = layers[layerIndex]; - if (currentLayer.Count == 0) - { - return; - } - - var positions = ElkNodeOrdering.BuildNodeOrderPositions(layers); - var layerNodesById = currentLayer.ToDictionary(node => node.Id, StringComparer.Ordinal); - var realNodeIdsInLayer = currentLayer - .Where(node => !dummyNodeIds.Contains(node.Id)) - .Select(node => node.Id) - .ToHashSet(StringComparer.Ordinal); - - var orderedRealIds = OrderNodesForSubtree( - null, - hierarchy, - realNodeIdsInLayer, - adjacentNodeIds, - positions, - inputOrder); - var orderedDummies = currentLayer - .Where(node => dummyNodeIds.Contains(node.Id)) - .OrderBy(node => ElkNodeOrdering.ResolveOrderingRank(node.Id, adjacentNodeIds, positions)) - .ThenBy(node => positions[node.Id]) - .ThenBy(node => inputOrder[node.Id]) - .ToArray(); - - currentLayer.Clear(); - foreach (var nodeId in orderedRealIds) - { - currentLayer.Add(layerNodesById[nodeId]); - } - - currentLayer.AddRange(orderedDummies); - } - - private static List OrderNodesForSubtree( - string? parentNodeId, - ElkCompoundHierarchy hierarchy, - IReadOnlySet realNodeIdsInLayer, - IReadOnlyDictionary> adjacentNodeIds, - IReadOnlyDictionary positions, - IReadOnlyDictionary inputOrder) - { - var blocks = new List(); - foreach (var childNodeId in hierarchy.GetChildIds(parentNodeId)) - { - if (realNodeIdsInLayer.Contains(childNodeId)) - { - blocks.Add(BuildBlock([childNodeId])); - continue; - } - - if (!hierarchy.IsCompoundNode(childNodeId)) - { - continue; - } - - var descendantNodeIds = OrderNodesForSubtree( - childNodeId, - hierarchy, - realNodeIdsInLayer, - adjacentNodeIds, - positions, - inputOrder); - if (descendantNodeIds.Count == 0) - { - continue; - } - - blocks.Add(BuildBlock(descendantNodeIds)); - } - - return blocks - .OrderBy(block => block.Rank) - .ThenBy(block => block.MinCurrentPosition) - .ThenBy(block => block.MinInputOrder) - .SelectMany(block => block.NodeIds) - .ToList(); - - HierarchyOrderBlock BuildBlock(IReadOnlyList nodeIds) - { - var ranks = new List(); - foreach (var nodeId in nodeIds) - { - var nodeRank = ElkNodeOrdering.ResolveOrderingRank(nodeId, adjacentNodeIds, positions); - if (!double.IsInfinity(nodeRank)) - { - ranks.Add(nodeRank); - } - } - - ranks.Sort(); - var rank = ranks.Count switch - { - 0 => double.PositiveInfinity, - _ when ranks.Count % 2 == 1 => ranks[ranks.Count / 2], - _ => (ranks[(ranks.Count / 2) - 1] + ranks[ranks.Count / 2]) / 2d, - }; - return new HierarchyOrderBlock( - nodeIds, - rank, - nodeIds.Min(nodeId => positions.GetValueOrDefault(nodeId, int.MaxValue)), - nodeIds.Min(nodeId => inputOrder.GetValueOrDefault(nodeId, int.MaxValue))); - } - } - - private static ElkRoutedEdge[] InsertCompoundBoundaryCrossings( - IReadOnlyCollection routedEdges, - IReadOnlyDictionary positionedNodes, - ElkCompoundHierarchy hierarchy) - { - return routedEdges - .Select(edge => - { - var path = ExtractPath(edge); - if (path.Count < 2) - { - return edge; - } - - var lcaNodeId = hierarchy.GetLowestCommonAncestor(edge.SourceNodeId, edge.TargetNodeId); - var sourceAncestorIds = hierarchy.GetAncestorIdsNearestFirst(edge.SourceNodeId) - .TakeWhile(ancestorNodeId => !string.Equals(ancestorNodeId, lcaNodeId, StringComparison.Ordinal)) - .ToArray(); - var targetAncestorIds = hierarchy.GetAncestorIdsNearestFirst(edge.TargetNodeId) - .TakeWhile(ancestorNodeId => !string.Equals(ancestorNodeId, lcaNodeId, StringComparison.Ordinal)) - .ToArray(); - if (sourceAncestorIds.Length == 0 && targetAncestorIds.Length == 0) - { - return edge; - } - - var rebuiltPath = path.ToList(); - var startSegmentIndex = 0; - foreach (var ancestorNodeId in sourceAncestorIds) - { - if (!positionedNodes.TryGetValue(ancestorNodeId, out var ancestorNode)) - { - continue; - } - - if (!TryFindBoundaryTransitionFromStart(rebuiltPath, ancestorNode, startSegmentIndex, out var insertIndex, out var boundaryPoint)) - { - continue; - } - - rebuiltPath.Insert(insertIndex, boundaryPoint); - startSegmentIndex = insertIndex; - } - - var endSegmentIndex = rebuiltPath.Count - 2; - foreach (var ancestorNodeId in targetAncestorIds) - { - if (!positionedNodes.TryGetValue(ancestorNodeId, out var ancestorNode)) - { - continue; - } - - if (!TryFindBoundaryTransitionFromEnd(rebuiltPath, ancestorNode, endSegmentIndex, out var insertIndex, out var boundaryPoint)) - { - continue; - } - - rebuiltPath.Insert(insertIndex, boundaryPoint); - endSegmentIndex = insertIndex - 2; - } - - return BuildEdgeWithPath(edge, rebuiltPath); - }) - .ToArray(); - } - - private static List ExtractPath(ElkRoutedEdge edge) - { - var path = new List(); - foreach (var section in edge.Sections) - { - AppendPoint(path, section.StartPoint); - foreach (var bendPoint in section.BendPoints) - { - AppendPoint(path, bendPoint); - } - - AppendPoint(path, section.EndPoint); - } - - return path; - } - - private static ElkRoutedEdge BuildEdgeWithPath(ElkRoutedEdge edge, IReadOnlyList path) - { - var normalizedPath = NormalizePath(path); - if (normalizedPath.Count < 2) - { - return edge; - } - - return edge with - { - Sections = - [ - new ElkEdgeSection - { - StartPoint = normalizedPath[0], - EndPoint = normalizedPath[^1], - BendPoints = normalizedPath.Count > 2 - ? normalizedPath.Skip(1).Take(normalizedPath.Count - 2).ToArray() - : [], - }, - ], - }; - } - - private static bool TryFindBoundaryTransitionFromStart( - IReadOnlyList path, - ElkPositionedNode boundaryNode, - int startSegmentIndex, - out int insertIndex, - out ElkPoint boundaryPoint) - { - insertIndex = -1; - boundaryPoint = default!; - for (var segmentIndex = Math.Max(0, startSegmentIndex); segmentIndex < path.Count - 1; segmentIndex++) - { - var from = path[segmentIndex]; - var to = path[segmentIndex + 1]; - if (!IsInsideOrOn(boundaryNode, from) || IsInsideOrOn(boundaryNode, to)) - { - continue; - } - - if (!TryResolveBoundaryTransition(boundaryNode, from, to, exitBoundary: true, out boundaryPoint)) - { - continue; - } - - insertIndex = segmentIndex + 1; - return true; - } - - return false; - } - - private static bool TryFindBoundaryTransitionFromEnd( - IReadOnlyList path, - ElkPositionedNode boundaryNode, - int startSegmentIndex, - out int insertIndex, - out ElkPoint boundaryPoint) - { - insertIndex = -1; - boundaryPoint = default!; - for (var segmentIndex = Math.Min(startSegmentIndex, path.Count - 2); segmentIndex >= 0; segmentIndex--) - { - var from = path[segmentIndex]; - var to = path[segmentIndex + 1]; - if (IsInsideOrOn(boundaryNode, from) || !IsInsideOrOn(boundaryNode, to)) - { - continue; - } - - if (!TryResolveBoundaryTransition(boundaryNode, from, to, exitBoundary: false, out boundaryPoint)) - { - continue; - } - - insertIndex = segmentIndex + 1; - return true; - } - - return false; - } - - private static bool TryResolveBoundaryTransition( - ElkPositionedNode boundaryNode, - ElkPoint from, - ElkPoint to, - bool exitBoundary, - out ElkPoint boundaryPoint) - { - boundaryPoint = default!; - if (!Clip(boundaryNode, from, to, out var enterScale, out var exitScale)) - { - return false; - } - - var scale = exitBoundary ? exitScale : enterScale; - scale = Math.Clamp(scale, 0d, 1d); - boundaryPoint = new ElkPoint - { - X = from.X + ((to.X - from.X) * scale), - Y = from.Y + ((to.Y - from.Y) * scale), - }; - return true; - } - - private static bool Clip( - ElkPositionedNode node, - ElkPoint from, - ElkPoint to, - out double enterScale, - out double exitScale) - { - enterScale = 0d; - exitScale = 1d; - - var deltaX = to.X - from.X; - var deltaY = to.Y - from.Y; - return ClipTest(-deltaX, from.X - node.X, ref enterScale, ref exitScale) - && ClipTest(deltaX, (node.X + node.Width) - from.X, ref enterScale, ref exitScale) - && ClipTest(-deltaY, from.Y - node.Y, ref enterScale, ref exitScale) - && ClipTest(deltaY, (node.Y + node.Height) - from.Y, ref enterScale, ref exitScale); - - static bool ClipTest(double p, double q, ref double enter, ref double exit) - { - if (Math.Abs(p) <= 0.0001d) - { - return q >= -0.0001d; - } - - var ratio = q / p; - if (p < 0d) - { - if (ratio > exit) - { - return false; - } - - if (ratio > enter) - { - enter = ratio; - } - } - else - { - if (ratio < enter) - { - return false; - } - - if (ratio < exit) - { - exit = ratio; - } - } - - return true; - } - } - - private static bool IsInsideOrOn(ElkPositionedNode node, ElkPoint point) - { - const double tolerance = 0.01d; - return point.X >= node.X - tolerance - && point.X <= node.X + node.Width + tolerance - && point.Y >= node.Y - tolerance - && point.Y <= node.Y + node.Height + tolerance; - } - - private static List NormalizePath(IReadOnlyList path) - { - var normalized = new List(path.Count); - foreach (var point in path) - { - AppendPoint(normalized, point); - } - - return normalized; - } - - private static void AppendPoint(ICollection path, ElkPoint point) - { - if (path.Count > 0 && path.Last() is { } previousPoint && AreSamePoint(previousPoint, point)) - { - return; - } - - path.Add(new ElkPoint { X = point.X, Y = point.Y }); - } - - private static bool AreSamePoint(ElkPoint left, ElkPoint right) => - Math.Abs(left.X - right.X) <= 0.01d - && Math.Abs(left.Y - right.Y) <= 0.01d; - - private static Dictionary BuildCompoundPositionedNodes( - IReadOnlyCollection graphNodes, - ElkCompoundHierarchy hierarchy, - IReadOnlyDictionary positionedVisibleNodes, - ElkLayoutOptions options) - { - var nodesById = graphNodes.ToDictionary(node => node.Id, StringComparer.Ordinal); - var compoundNodes = new Dictionary(StringComparer.Ordinal); - foreach (var pair in positionedVisibleNodes) - { - if (!hierarchy.IsLayoutVisibleNode(pair.Key)) - { - continue; - } - - compoundNodes[pair.Key] = pair.Value; - } - - foreach (var nodeId in hierarchy.GetNonLeafNodeIdsByDescendingDepth()) - { - var childBounds = hierarchy.GetChildIds(nodeId) - .Select(childNodeId => compoundNodes[childNodeId]) - .ToArray(); - var contentLeft = childBounds.Min(node => node.X); - var contentTop = childBounds.Min(node => node.Y); - var contentRight = childBounds.Max(node => node.X + node.Width); - var contentBottom = childBounds.Max(node => node.Y + node.Height); - - var desiredWidth = (contentRight - contentLeft) + (options.CompoundPadding * 2d); - var desiredHeight = (contentBottom - contentTop) + options.CompoundHeaderHeight + (options.CompoundPadding * 2d); - var width = Math.Max(nodesById[nodeId].Width, desiredWidth); - var height = Math.Max(nodesById[nodeId].Height, desiredHeight); - var x = contentLeft - options.CompoundPadding - ((width - desiredWidth) / 2d); - var y = contentTop - options.CompoundHeaderHeight - options.CompoundPadding - ((height - desiredHeight) / 2d); - - compoundNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode( - nodesById[nodeId], - x, - y, - options.Direction) with - { - Width = width, - Height = height, - }; - } - - return compoundNodes; - } - - private static bool TryResolveNegativeCoordinateShift( - IReadOnlyDictionary positionedNodes, - out double shiftX, - out double shiftY) - { - shiftX = 0d; - shiftY = 0d; - if (positionedNodes.Count == 0) - { - return false; - } - - var minX = positionedNodes.Values.Min(node => node.X); - var minY = positionedNodes.Values.Min(node => node.Y); - if (minX >= -0.01d && minY >= -0.01d) - { - return false; - } - - shiftX = minX < 0d ? -minX : 0d; - shiftY = minY < 0d ? -minY : 0d; - return shiftX > 0d || shiftY > 0d; - } - - private static Dictionary ShiftNodes( - IReadOnlyCollection sourceNodes, - IReadOnlyDictionary positionedNodes, - double shiftX, - double shiftY, - ElkLayoutDirection direction) - { - var sourceNodesById = sourceNodes.ToDictionary(node => node.Id, StringComparer.Ordinal); - return positionedNodes.ToDictionary( - pair => pair.Key, - pair => - { - var shifted = ElkLayoutHelpers.CreatePositionedNode( - sourceNodesById[pair.Key], - pair.Value.X + shiftX, - pair.Value.Y + shiftY, - direction); - return shifted with - { - Width = pair.Value.Width, - Height = pair.Value.Height, - }; - }, - StringComparer.Ordinal); - } - - private static ElkRoutedEdge[] ShiftEdges( - IReadOnlyCollection routedEdges, - double shiftX, - double shiftY) - { - return routedEdges - .Select(edge => new ElkRoutedEdge - { - Id = edge.Id, - SourceNodeId = edge.SourceNodeId, - TargetNodeId = edge.TargetNodeId, - SourcePortId = edge.SourcePortId, - TargetPortId = edge.TargetPortId, - Kind = edge.Kind, - Label = edge.Label, - Sections = edge.Sections.Select(section => new ElkEdgeSection - { - StartPoint = new ElkPoint { X = section.StartPoint.X + shiftX, Y = section.StartPoint.Y + shiftY }, - EndPoint = new ElkPoint { X = section.EndPoint.X + shiftX, Y = section.EndPoint.Y + shiftY }, - BendPoints = section.BendPoints - .Select(point => new ElkPoint { X = point.X + shiftX, Y = point.Y + shiftY }) - .ToArray(), - }).ToArray(), - }) - .ToArray(); - } - - private readonly record struct HierarchyOrderBlock( - IReadOnlyList NodeIds, - double Rank, - int MinCurrentPosition, - int MinInputOrder); } diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.UnderNode.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.UnderNode.cs index ea6a0e08b..83b2ef72d 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.UnderNode.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.UnderNode.cs @@ -1585,6 +1585,7 @@ internal static partial class ElkEdgePostProcessor return path.Count >= 2 && !ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, path[^2]) && !HasExcessiveGatewayDiagonalLength(path, targetNode) + && !HasShortGatewayTargetOrthogonalHook(path, targetNode) && ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, path[^1], path[^2]); } @@ -2616,4 +2617,4 @@ internal static partial class ElkEdgePostProcessor return CloneEdgeWithKind(edge, AppendInternalKindMarker(edge.Kind, ProtectedUnderNodeKindMarker)); } -} \ No newline at end of file +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorCorridor.Safety.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorCorridor.Safety.cs new file mode 100644 index 000000000..5f1abd8ca --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorCorridor.Safety.cs @@ -0,0 +1,144 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgePostProcessorCorridor +{ + internal static double FindSafeVerticalX( + double anchorX, + double anchorY, + double corridorY, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + string sourceId, + string targetId, + double clearanceMargin = 0d) + { + var minY = Math.Min(anchorY, corridorY); + var maxY = Math.Max(anchorY, corridorY); + + var blocked = false; + foreach (var obstacle in obstacles) + { + if (obstacle.Id == sourceId || obstacle.Id == targetId) + { + continue; + } + + if (anchorX > obstacle.Left - clearanceMargin + && anchorX < obstacle.Right + clearanceMargin + && maxY > obstacle.Top - clearanceMargin + && minY < obstacle.Bottom + clearanceMargin) + { + blocked = true; + break; + } + } + + if (!blocked) + { + return anchorX; + } + + var candidateRight = anchorX; + var candidateLeft = anchorX; + var searchStep = Math.Max(24d, clearanceMargin * 0.5d); + for (var attempt = 0; attempt < 20; attempt++) + { + candidateRight += searchStep; + if (!IsVerticalBlocked(candidateRight, minY, maxY, obstacles, sourceId, targetId, clearanceMargin)) + { + return candidateRight; + } + + candidateLeft -= searchStep; + if (!IsVerticalBlocked(candidateLeft, minY, maxY, obstacles, sourceId, targetId, clearanceMargin)) + { + return candidateLeft; + } + } + + return anchorX; + } + + private static bool IsVerticalBlocked( + double x, + double minY, + double maxY, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + string sourceId, + string targetId, + double clearanceMargin) + { + foreach (var obstacle in obstacles) + { + if (obstacle.Id == sourceId || obstacle.Id == targetId) + { + continue; + } + + if (x > obstacle.Left - clearanceMargin + && x < obstacle.Right + clearanceMargin + && maxY > obstacle.Top - clearanceMargin + && minY < obstacle.Bottom + clearanceMargin) + { + return true; + } + } + + return false; + } + + private static bool SegmentViolatesObstacleClearance( + ElkPoint start, + ElkPoint end, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + string sourceId, + string targetId, + double clearanceMargin) + { + if (clearanceMargin <= 0d) + { + return false; + } + + var horizontal = Math.Abs(start.Y - end.Y) <= 0.5d; + var vertical = Math.Abs(start.X - end.X) <= 0.5d; + if (!horizontal && !vertical) + { + return false; + } + + foreach (var obstacle in obstacles) + { + if (obstacle.Id == sourceId || obstacle.Id == targetId) + { + continue; + } + + if (horizontal) + { + var minX = Math.Min(start.X, end.X); + var maxX = Math.Max(start.X, end.X); + if (start.Y > obstacle.Top - clearanceMargin + && start.Y < obstacle.Bottom + clearanceMargin + && maxX > obstacle.Left - clearanceMargin + && minX < obstacle.Right + clearanceMargin) + { + return true; + } + + continue; + } + + var minY = Math.Min(start.Y, end.Y); + var maxY = Math.Max(start.Y, end.Y); + if (start.X > obstacle.Left - clearanceMargin + && start.X < obstacle.Right + clearanceMargin + && maxY > obstacle.Top - clearanceMargin + && minY < obstacle.Bottom + clearanceMargin) + { + return true; + } + } + + return false; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorCorridor.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorCorridor.cs index fc66f75ca..1013adaf1 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorCorridor.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorCorridor.cs @@ -1,22 +1,25 @@ namespace StellaOps.ElkSharp; -internal static class ElkEdgePostProcessorCorridor +internal static partial class ElkEdgePostProcessorCorridor { internal static ElkEdgeSection? ReroutePreservingCorridor( ElkEdgeSection section, (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, - string sourceId, string targetId, double margin, - double graphMinY, double graphMaxY) + string sourceId, + string targetId, + double margin, + double graphMinY, + double graphMaxY) { - var pts = new List { section.StartPoint }; - pts.AddRange(section.BendPoints); - pts.Add(section.EndPoint); + var points = new List { section.StartPoint }; + points.AddRange(section.BendPoints); + points.Add(section.EndPoint); var firstCorridorIndex = -1; var lastCorridorIndex = -1; - for (var i = 0; i < pts.Count; i++) + for (var i = 0; i < points.Count; i++) { - if (pts[i].Y < graphMinY - 8d || pts[i].Y > graphMaxY + 8d) + if (points[i].Y < graphMinY - 8d || points[i].Y > graphMaxY + 8d) { if (firstCorridorIndex < 0) { @@ -27,12 +30,12 @@ internal static class ElkEdgePostProcessorCorridor } } - if (firstCorridorIndex < 0 || firstCorridorIndex == 0 && lastCorridorIndex == pts.Count - 1) + if (firstCorridorIndex < 0 || firstCorridorIndex == 0 && lastCorridorIndex == points.Count - 1) { return null; } - var corridorY = pts[firstCorridorIndex].Y; + var corridorY = points[firstCorridorIndex].Y; var isAboveCorridor = corridorY < graphMinY - 8d; var clearanceMargin = Math.Max(margin, 40d); var result = new List(); @@ -44,8 +47,8 @@ internal static class ElkEdgePostProcessorCorridor var preCorridorHasCrossing = false; for (var i = 0; i < firstCorridorIndex; i++) { - if (!ElkEdgePostProcessor.SegmentCrossesObstacle(pts[i], pts[i + 1], obstacles, sourceId, targetId) - && !SegmentViolatesObstacleClearance(pts[i], pts[i + 1], obstacles, sourceId, targetId, clearanceMargin)) + if (!ElkEdgePostProcessor.SegmentCrossesObstacle(points[i], points[i + 1], obstacles, sourceId, targetId) + && !SegmentViolatesObstacleClearance(points[i], points[i + 1], obstacles, sourceId, targetId, clearanceMargin)) { continue; } @@ -56,7 +59,7 @@ internal static class ElkEdgePostProcessorCorridor if (preCorridorHasCrossing) { - var entryTarget = pts[firstCorridorIndex]; + var entryTarget = points[firstCorridorIndex]; var entryPath = ElkEdgePostProcessorAStar.RerouteWithGridAStar( section.StartPoint, entryTarget, @@ -75,9 +78,9 @@ internal static class ElkEdgePostProcessorCorridor for (var i = 0; i < firstCorridorIndex; i++) { var last = result.Count > 0 ? result[^1] : (ElkPoint?)null; - if (last is null || Math.Abs(last.X - pts[i].X) > 0.01d || Math.Abs(last.Y - pts[i].Y) > 0.01d) + if (last is null || Math.Abs(last.X - points[i].X) > 0.01d || Math.Abs(last.Y - points[i].Y) > 0.01d) { - result.Add(pts[i]); + result.Add(points[i]); } } @@ -92,7 +95,7 @@ internal static class ElkEdgePostProcessorCorridor } else { - var entryTarget = pts[firstCorridorIndex]; + var entryTarget = points[firstCorridorIndex]; var entryPath = ElkEdgePostProcessorAStar.RerouteWithGridAStar( section.StartPoint, entryTarget, obstacles, sourceId, targetId, margin); if (entryPath is not null && entryPath.Count >= 2) @@ -103,41 +106,41 @@ internal static class ElkEdgePostProcessorCorridor { for (var i = 0; i <= firstCorridorIndex; i++) { - result.Add(pts[i]); + result.Add(points[i]); } } } } else { - result.Add(pts[0]); + result.Add(points[0]); } for (var i = firstCorridorIndex; i <= lastCorridorIndex; i++) { var last = result.Count > 0 ? result[^1] : (ElkPoint?)null; - if (last is null || Math.Abs(last.X - pts[i].X) > 0.01d || Math.Abs(last.Y - pts[i].Y) > 0.01d) + if (last is null || Math.Abs(last.X - points[i].X) > 0.01d || Math.Abs(last.Y - points[i].Y) > 0.01d) { - result.Add(pts[i]); + result.Add(points[i]); } } - if (lastCorridorIndex < pts.Count - 1) + if (lastCorridorIndex < points.Count - 1) { if (isAboveCorridor) { - for (var i = lastCorridorIndex + 1; i < pts.Count; i++) + for (var i = lastCorridorIndex + 1; i < points.Count; i++) { var last = result.Count > 0 ? result[^1] : (ElkPoint?)null; - if (last is null || Math.Abs(last.X - pts[i].X) > 0.01d || Math.Abs(last.Y - pts[i].Y) > 0.01d) + if (last is null || Math.Abs(last.X - points[i].X) > 0.01d || Math.Abs(last.Y - points[i].Y) > 0.01d) { - result.Add(pts[i]); + result.Add(points[i]); } } } else { - var exitSource = pts[lastCorridorIndex]; + var exitSource = points[lastCorridorIndex]; var exitPath = ElkEdgePostProcessorAStar.RerouteWithGridAStar( exitSource, section.EndPoint, obstacles, sourceId, targetId, margin); if (exitPath is not null && exitPath.Count >= 2) @@ -149,9 +152,9 @@ internal static class ElkEdgePostProcessorCorridor } else { - for (var i = lastCorridorIndex + 1; i < pts.Count; i++) + for (var i = lastCorridorIndex + 1; i < points.Count; i++) { - result.Add(pts[i]); + result.Add(points[i]); } } } @@ -169,138 +172,4 @@ internal static class ElkEdgePostProcessorCorridor BendPoints = result.Skip(1).Take(result.Count - 2).ToArray(), }; } - - internal static double FindSafeVerticalX( - double anchorX, double anchorY, double corridorY, - (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, - string sourceId, string targetId, - double clearanceMargin = 0d) - { - var minY = Math.Min(anchorY, corridorY); - var maxY = Math.Max(anchorY, corridorY); - - var blocked = false; - foreach (var ob in obstacles) - { - if (ob.Id == sourceId || ob.Id == targetId) - { - continue; - } - - if (anchorX > ob.Left - clearanceMargin - && anchorX < ob.Right + clearanceMargin - && maxY > ob.Top - clearanceMargin - && minY < ob.Bottom + clearanceMargin) - { - blocked = true; - break; - } - } - - if (!blocked) - { - return anchorX; - } - - var candidateRight = anchorX; - var candidateLeft = anchorX; - var searchStep = Math.Max(24d, clearanceMargin * 0.5d); - for (var attempt = 0; attempt < 20; attempt++) - { - candidateRight += searchStep; - if (!IsVerticalBlocked(candidateRight, minY, maxY, obstacles, sourceId, targetId, clearanceMargin)) - { - return candidateRight; - } - - candidateLeft -= searchStep; - if (!IsVerticalBlocked(candidateLeft, minY, maxY, obstacles, sourceId, targetId, clearanceMargin)) - { - return candidateLeft; - } - } - - return anchorX; - } - - private static bool IsVerticalBlocked( - double x, double minY, double maxY, - (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, - string sourceId, string targetId, - double clearanceMargin) - { - foreach (var ob in obstacles) - { - if (ob.Id == sourceId || ob.Id == targetId) - { - continue; - } - - if (x > ob.Left - clearanceMargin - && x < ob.Right + clearanceMargin - && maxY > ob.Top - clearanceMargin - && minY < ob.Bottom + clearanceMargin) - { - return true; - } - } - - return false; - } - - private static bool SegmentViolatesObstacleClearance( - ElkPoint start, - ElkPoint end, - (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, - string sourceId, - string targetId, - double clearanceMargin) - { - if (clearanceMargin <= 0d) - { - return false; - } - - var horizontal = Math.Abs(start.Y - end.Y) <= 0.5d; - var vertical = Math.Abs(start.X - end.X) <= 0.5d; - if (!horizontal && !vertical) - { - return false; - } - - foreach (var ob in obstacles) - { - if (ob.Id == sourceId || ob.Id == targetId) - { - continue; - } - - if (horizontal) - { - var minX = Math.Min(start.X, end.X); - var maxX = Math.Max(start.X, end.X); - if (start.Y > ob.Top - clearanceMargin - && start.Y < ob.Bottom + clearanceMargin - && maxX > ob.Left - clearanceMargin - && minX < ob.Right + clearanceMargin) - { - return true; - } - - continue; - } - - var minY = Math.Min(start.Y, end.Y); - var maxY = Math.Max(start.Y, end.Y); - if (start.X > ob.Left - clearanceMargin - && start.X < ob.Right + clearanceMargin - && maxY > ob.Top - clearanceMargin - && minY < ob.Bottom + clearanceMargin) - { - return true; - } - } - - return false; - } } diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorSimplify.OuterCorridors.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorSimplify.OuterCorridors.cs new file mode 100644 index 000000000..826343792 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorSimplify.OuterCorridors.cs @@ -0,0 +1,214 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgePostProcessorSimplify +{ + internal static ElkRoutedEdge[] TightenOuterCorridors( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes) + { + if (nodes.Length == 0) return edges; + + var graphMinY = nodes.Min(n => n.Y); + var graphMaxY = nodes.Max(n => n.Y + n.Height); + var serviceNodes = nodes.Where(n => n.Kind is not "Start" and not "End").ToArray(); + var minLineClearance = serviceNodes.Length > 0 + ? Math.Min(serviceNodes.Average(n => n.Width), serviceNodes.Average(n => n.Height)) / 2d + : 50d; + var minMargin = Math.Max(12d, minLineClearance + 4d); + var laneGap = Math.Max(8d, minLineClearance + 4d); + + var outerEdges = new List<(int Index, double CorridorY, bool IsAbove)>(); + for (var i = 0; i < edges.Length; i++) + { + var aboveYs = new List(); + var belowYs = new List(); + foreach (var section in edges[i].Sections) + { + foreach (var bp in section.BendPoints) + { + if (bp.Y < graphMinY - 8d) + { + aboveYs.Add(bp.Y); + } + else if (bp.Y > graphMaxY + 8d) + { + belowYs.Add(bp.Y); + } + } + } + + if (aboveYs.Count > 0) + { + outerEdges.Add((i, aboveYs.Min(), true)); + } + + if (belowYs.Count > 0) + { + outerEdges.Add((i, belowYs.Max(), false)); + } + } + + if (outerEdges.Count == 0) return edges; + + NormalizeCorridorYValues(outerEdges, edges, graphMinY, graphMaxY); + + var aboveLanes = outerEdges.Where(e => e.IsAbove) + .GroupBy(e => Math.Round(e.CorridorY, 1)) + .OrderBy(g => g.Key) + .ToArray(); + var belowLanes = outerEdges.Where(e => !e.IsAbove) + .GroupBy(e => Math.Round(e.CorridorY, 1)) + .OrderByDescending(g => g.Key) + .ToArray(); + + var result = edges.ToArray(); + var shifts = new Dictionary(); + + for (var lane = 0; lane < aboveLanes.Length; lane++) + { + var targetY = graphMinY - minMargin - (lane * laneGap); + var currentY = aboveLanes[lane].Key; + var shift = targetY - currentY; + if (Math.Abs(shift) > 2d) + { + foreach (var entry in aboveLanes[lane]) + { + shifts[entry.Index] = shift; + } + } + } + + for (var lane = 0; lane < belowLanes.Length; lane++) + { + var targetY = graphMaxY + minMargin + (lane * laneGap); + var currentY = belowLanes[lane].Key; + var shift = targetY - currentY; + if (Math.Abs(shift) > 2d) + { + foreach (var entry in belowLanes[lane]) + { + shifts[entry.Index] = shift; + } + } + } + + foreach (var (edgeIndex, shift) in shifts) + { + var edge = result[edgeIndex]; + var newSections = new List(); + foreach (var section in edge.Sections) + { + var newBendPoints = section.BendPoints.Select(bp => + { + if ((shift < 0 && bp.Y < graphMinY - 4d) || (shift > 0 && bp.Y > graphMaxY + 4d) + || (shift > 0 && bp.Y < graphMinY - 4d) || (shift < 0 && bp.Y > graphMaxY + 4d)) + { + return new ElkPoint { X = bp.X, Y = bp.Y + shift }; + } + return bp; + }).ToArray(); + + newSections.Add(new ElkEdgeSection + { + StartPoint = section.StartPoint, + EndPoint = section.EndPoint, + BendPoints = newBendPoints, + }); + } + + result[edgeIndex] = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Label = edge.Label, + Sections = newSections, + }; + } + + return result; + } + + private static void NormalizeCorridorYValues( + List<(int Index, double CorridorY, bool IsAbove)> outerEdges, + ElkRoutedEdge[] edges, + double graphMinY, + double graphMaxY) + { + const double mergeThreshold = 6d; + var groups = new List>(); + var sorted = outerEdges.OrderBy(e => e.CorridorY).ToArray(); + foreach (var entry in sorted) + { + var merged = false; + foreach (var group in groups) + { + var groupY = outerEdges[group[0]].CorridorY; + if (Math.Abs(entry.CorridorY - groupY) <= mergeThreshold && entry.IsAbove == outerEdges[group[0]].IsAbove) + { + group.Add(outerEdges.IndexOf(entry)); + merged = true; + break; + } + } + + if (!merged) + { + groups.Add([outerEdges.IndexOf(entry)]); + } + } + + foreach (var group in groups) + { + if (group.Count <= 1) + { + continue; + } + + var targetY = outerEdges[group[0]].CorridorY; + for (var gi = 1; gi < group.Count; gi++) + { + var idx = group[gi]; + var edgeIndex = outerEdges[idx].Index; + var currentY = outerEdges[idx].CorridorY; + var shift = targetY - currentY; + if (Math.Abs(shift) < 0.5d) + { + continue; + } + + var edge = edges[edgeIndex]; + var newSections = new List(); + foreach (var section in edge.Sections) + { + var newBendPoints = section.BendPoints.Select(bp => + { + if (Math.Abs(bp.Y - currentY) < 2d) + { + return new ElkPoint { X = bp.X, Y = targetY }; + } + + return bp; + }).ToArray(); + + newSections.Add(new ElkEdgeSection + { + StartPoint = section.StartPoint, + EndPoint = section.EndPoint, + BendPoints = newBendPoints, + }); + } + + edges[edgeIndex] = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Label = edge.Label, + Sections = newSections, + }; + outerEdges[idx] = (edgeIndex, targetY, outerEdges[idx].IsAbove); + } + } + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorSimplify.Shortcuts.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorSimplify.Shortcuts.cs new file mode 100644 index 000000000..ae9b82f7c --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorSimplify.Shortcuts.cs @@ -0,0 +1,132 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgePostProcessorSimplify +{ + internal static bool SegmentClearsObstacles( + ElkPoint p1, ElkPoint p2, + (double L, double T, double R, double B, string Id)[] obstacles, + HashSet excludeIds) + { + var isH = Math.Abs(p1.Y - p2.Y) < 1d; + var isV = Math.Abs(p1.X - p2.X) < 1d; + if (!isH && !isV) return true; + + foreach (var ob in obstacles) + { + if (excludeIds.Contains(ob.Id)) continue; + if (isH && p1.Y > ob.T && p1.Y < ob.B) + { + if (Math.Max(p1.X, p2.X) > ob.L && Math.Min(p1.X, p2.X) < ob.R) return false; + } + else if (isV && p1.X > ob.L && p1.X < ob.R) + { + if (Math.Max(p1.Y, p2.Y) > ob.T && Math.Min(p1.Y, p2.Y) < ob.B) return false; + } + } + return true; + } + + private static bool TryApplyOrthogonalShortcut( + List points, + (double L, double T, double R, double B, string Id)[] obstacles, + HashSet excludeIds) + { + if (points.Count < 4) + { + return false; + } + + for (var startIndex = 0; startIndex < points.Count - 2; startIndex++) + { + for (var endIndex = points.Count - 1; endIndex >= startIndex + 2; endIndex--) + { + var start = points[startIndex]; + var end = points[endIndex]; + var existingLength = ComputeSubpathLength(points, startIndex, endIndex); + + foreach (var shortcut in BuildShortcutCandidates(start, end)) + { + if (shortcut.Count < 2) + { + continue; + } + + if (!ShortcutClearsObstacles(shortcut, obstacles, excludeIds)) + { + continue; + } + + var shortcutLength = ComputePathLength(shortcut); + if (shortcutLength >= existingLength - 8d) + { + continue; + } + + points.RemoveRange(startIndex + 1, endIndex - startIndex - 1); + if (shortcut.Count > 2) + { + points.InsertRange(startIndex + 1, shortcut.Skip(1).Take(shortcut.Count - 2)); + } + + return true; + } + } + } + + return false; + } + + private static IReadOnlyList> BuildShortcutCandidates(ElkPoint start, ElkPoint end) + { + var candidates = new List>(); + if (Math.Abs(start.X - end.X) < 1d || Math.Abs(start.Y - end.Y) < 1d) + { + candidates.Add([start, end]); + return candidates; + } + + var corner1 = new ElkPoint { X = start.X, Y = end.Y }; + var corner2 = new ElkPoint { X = end.X, Y = start.Y }; + candidates.Add([start, corner1, end]); + candidates.Add([start, corner2, end]); + return candidates; + } + + private static bool ShortcutClearsObstacles( + IReadOnlyList shortcut, + (double L, double T, double R, double B, string Id)[] obstacles, + HashSet excludeIds) + { + for (var i = 0; i < shortcut.Count - 1; i++) + { + if (!SegmentClearsObstacles(shortcut[i], shortcut[i + 1], obstacles, excludeIds)) + { + return false; + } + } + + return true; + } + + private static double ComputeSubpathLength(IReadOnlyList points, int startIndex, int endIndex) + { + var length = 0d; + for (var i = startIndex; i < endIndex; i++) + { + length += ElkEdgeRoutingGeometry.ComputeSegmentLength(points[i], points[i + 1]); + } + + return length; + } + + private static double ComputePathLength(IReadOnlyList points) + { + var length = 0d; + for (var i = 0; i < points.Count - 1; i++) + { + length += ElkEdgeRoutingGeometry.ComputeSegmentLength(points[i], points[i + 1]); + } + + return length; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorSimplify.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorSimplify.cs index 81d7cb5e7..89b46b314 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorSimplify.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorSimplify.cs @@ -1,6 +1,6 @@ namespace StellaOps.ElkSharp; -internal static class ElkEdgePostProcessorSimplify +internal static partial class ElkEdgePostProcessorSimplify { internal static ElkRoutedEdge[] SimplifyEdgePaths( ElkRoutedEdge[] edges, @@ -108,341 +108,4 @@ internal static class ElkEdgePostProcessorSimplify return result; } - internal static ElkRoutedEdge[] TightenOuterCorridors( - ElkRoutedEdge[] edges, - ElkPositionedNode[] nodes) - { - if (nodes.Length == 0) return edges; - - var graphMinY = nodes.Min(n => n.Y); - var graphMaxY = nodes.Max(n => n.Y + n.Height); - var serviceNodes = nodes.Where(n => n.Kind is not "Start" and not "End").ToArray(); - var minLineClearance = serviceNodes.Length > 0 - ? Math.Min(serviceNodes.Average(n => n.Width), serviceNodes.Average(n => n.Height)) / 2d - : 50d; - var minMargin = Math.Max(12d, minLineClearance + 4d); - var laneGap = Math.Max(8d, minLineClearance + 4d); - - var outerEdges = new List<(int Index, double CorridorY, bool IsAbove)>(); - for (var i = 0; i < edges.Length; i++) - { - var aboveYs = new List(); - var belowYs = new List(); - foreach (var section in edges[i].Sections) - { - foreach (var bp in section.BendPoints) - { - if (bp.Y < graphMinY - 8d) - { - aboveYs.Add(bp.Y); - } - else if (bp.Y > graphMaxY + 8d) - { - belowYs.Add(bp.Y); - } - } - } - - if (aboveYs.Count > 0) - { - outerEdges.Add((i, aboveYs.Min(), true)); - } - - if (belowYs.Count > 0) - { - outerEdges.Add((i, belowYs.Max(), false)); - } - } - - if (outerEdges.Count == 0) return edges; - - NormalizeCorridorYValues(outerEdges, edges, graphMinY, graphMaxY); - - var aboveLanes = outerEdges.Where(e => e.IsAbove) - .GroupBy(e => Math.Round(e.CorridorY, 1)) - .OrderBy(g => g.Key) - .ToArray(); - var belowLanes = outerEdges.Where(e => !e.IsAbove) - .GroupBy(e => Math.Round(e.CorridorY, 1)) - .OrderByDescending(g => g.Key) - .ToArray(); - - var result = edges.ToArray(); - var shifts = new Dictionary(); - - for (var lane = 0; lane < aboveLanes.Length; lane++) - { - var targetY = graphMinY - minMargin - (lane * laneGap); - var currentY = aboveLanes[lane].Key; - var shift = targetY - currentY; - if (Math.Abs(shift) > 2d) - { - foreach (var entry in aboveLanes[lane]) - { - shifts[entry.Index] = shift; - } - } - } - - for (var lane = 0; lane < belowLanes.Length; lane++) - { - var targetY = graphMaxY + minMargin + (lane * laneGap); - var currentY = belowLanes[lane].Key; - var shift = targetY - currentY; - if (Math.Abs(shift) > 2d) - { - foreach (var entry in belowLanes[lane]) - { - shifts[entry.Index] = shift; - } - } - } - - foreach (var (edgeIndex, shift) in shifts) - { - var edge = result[edgeIndex]; - var boundary = shift > 0 ? graphMaxY : graphMinY; - var newSections = new List(); - foreach (var section in edge.Sections) - { - var newBendPoints = section.BendPoints.Select(bp => - { - if ((shift < 0 && bp.Y < graphMinY - 4d) || (shift > 0 && bp.Y > graphMaxY + 4d) - || (shift > 0 && bp.Y < graphMinY - 4d) || (shift < 0 && bp.Y > graphMaxY + 4d)) - { - return new ElkPoint { X = bp.X, Y = bp.Y + shift }; - } - return bp; - }).ToArray(); - - newSections.Add(new ElkEdgeSection - { - StartPoint = section.StartPoint, - EndPoint = section.EndPoint, - BendPoints = newBendPoints, - }); - } - - result[edgeIndex] = new ElkRoutedEdge - { - Id = edge.Id, - SourceNodeId = edge.SourceNodeId, - TargetNodeId = edge.TargetNodeId, - Label = edge.Label, - Sections = newSections, - }; - } - - return result; - } - - internal static bool SegmentClearsObstacles( - ElkPoint p1, ElkPoint p2, - (double L, double T, double R, double B, string Id)[] obstacles, - HashSet excludeIds) - { - var isH = Math.Abs(p1.Y - p2.Y) < 1d; - var isV = Math.Abs(p1.X - p2.X) < 1d; - if (!isH && !isV) return true; - - foreach (var ob in obstacles) - { - if (excludeIds.Contains(ob.Id)) continue; - if (isH && p1.Y > ob.T && p1.Y < ob.B) - { - if (Math.Max(p1.X, p2.X) > ob.L && Math.Min(p1.X, p2.X) < ob.R) return false; - } - else if (isV && p1.X > ob.L && p1.X < ob.R) - { - if (Math.Max(p1.Y, p2.Y) > ob.T && Math.Min(p1.Y, p2.Y) < ob.B) return false; - } - } - return true; - } - - private static bool TryApplyOrthogonalShortcut( - List points, - (double L, double T, double R, double B, string Id)[] obstacles, - HashSet excludeIds) - { - if (points.Count < 4) - { - return false; - } - - for (var startIndex = 0; startIndex < points.Count - 2; startIndex++) - { - for (var endIndex = points.Count - 1; endIndex >= startIndex + 2; endIndex--) - { - var start = points[startIndex]; - var end = points[endIndex]; - var existingLength = ComputeSubpathLength(points, startIndex, endIndex); - - foreach (var shortcut in BuildShortcutCandidates(start, end)) - { - if (shortcut.Count < 2) - { - continue; - } - - if (!ShortcutClearsObstacles(shortcut, obstacles, excludeIds)) - { - continue; - } - - var shortcutLength = ComputePathLength(shortcut); - if (shortcutLength >= existingLength - 8d) - { - continue; - } - - points.RemoveRange(startIndex + 1, endIndex - startIndex - 1); - if (shortcut.Count > 2) - { - points.InsertRange(startIndex + 1, shortcut.Skip(1).Take(shortcut.Count - 2)); - } - - return true; - } - } - } - - return false; - } - - private static IReadOnlyList> BuildShortcutCandidates(ElkPoint start, ElkPoint end) - { - var candidates = new List>(); - if (Math.Abs(start.X - end.X) < 1d || Math.Abs(start.Y - end.Y) < 1d) - { - candidates.Add([start, end]); - return candidates; - } - - var corner1 = new ElkPoint { X = start.X, Y = end.Y }; - var corner2 = new ElkPoint { X = end.X, Y = start.Y }; - candidates.Add([start, corner1, end]); - candidates.Add([start, corner2, end]); - return candidates; - } - - private static bool ShortcutClearsObstacles( - IReadOnlyList shortcut, - (double L, double T, double R, double B, string Id)[] obstacles, - HashSet excludeIds) - { - for (var i = 0; i < shortcut.Count - 1; i++) - { - if (!SegmentClearsObstacles(shortcut[i], shortcut[i + 1], obstacles, excludeIds)) - { - return false; - } - } - - return true; - } - - private static double ComputeSubpathLength(IReadOnlyList points, int startIndex, int endIndex) - { - var length = 0d; - for (var i = startIndex; i < endIndex; i++) - { - length += ElkEdgeRoutingGeometry.ComputeSegmentLength(points[i], points[i + 1]); - } - - return length; - } - - private static double ComputePathLength(IReadOnlyList points) - { - var length = 0d; - for (var i = 0; i < points.Count - 1; i++) - { - length += ElkEdgeRoutingGeometry.ComputeSegmentLength(points[i], points[i + 1]); - } - - return length; - } - - private static void NormalizeCorridorYValues( - List<(int Index, double CorridorY, bool IsAbove)> outerEdges, - ElkRoutedEdge[] edges, - double graphMinY, double graphMaxY) - { - const double mergeThreshold = 6d; - var groups = new List>(); - var sorted = outerEdges.OrderBy(e => e.CorridorY).ToArray(); - foreach (var entry in sorted) - { - var merged = false; - foreach (var group in groups) - { - var groupY = outerEdges[group[0]].CorridorY; - if (Math.Abs(entry.CorridorY - groupY) <= mergeThreshold && entry.IsAbove == outerEdges[group[0]].IsAbove) - { - group.Add(outerEdges.IndexOf(entry)); - merged = true; - break; - } - } - - if (!merged) - { - groups.Add([outerEdges.IndexOf(entry)]); - } - } - - foreach (var group in groups) - { - if (group.Count <= 1) - { - continue; - } - - var targetY = outerEdges[group[0]].CorridorY; - for (var gi = 1; gi < group.Count; gi++) - { - var idx = group[gi]; - var edgeIndex = outerEdges[idx].Index; - var currentY = outerEdges[idx].CorridorY; - var shift = targetY - currentY; - if (Math.Abs(shift) < 0.5d) - { - continue; - } - - var edge = edges[edgeIndex]; - var newSections = new List(); - foreach (var section in edge.Sections) - { - var newBendPoints = section.BendPoints.Select(bp => - { - if (Math.Abs(bp.Y - currentY) < 2d) - { - return new ElkPoint { X = bp.X, Y = targetY }; - } - - return bp; - }).ToArray(); - - newSections.Add(new ElkEdgeSection - { - StartPoint = section.StartPoint, - EndPoint = section.EndPoint, - BendPoints = newBendPoints, - }); - } - - edges[edgeIndex] = new ElkRoutedEdge - { - Id = edge.Id, - SourceNodeId = edge.SourceNodeId, - TargetNodeId = edge.TargetNodeId, - Label = edge.Label, - Sections = newSections, - }; - outerEdges[idx] = (edgeIndex, targetY, outerEdges[idx].IsAbove); - } - } - } } diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouteRefiner.Helpers.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouteRefiner.Helpers.cs new file mode 100644 index 000000000..51e5b4f05 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouteRefiner.Helpers.cs @@ -0,0 +1,89 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouteRefiner +{ + private static bool IsBetterCandidate(EdgeRoutingScore candidate, EdgeRoutingScore baseline) + { + if (candidate.NodeCrossings != baseline.NodeCrossings) + { + return candidate.NodeCrossings < baseline.NodeCrossings; + } + + if (candidate.EdgeCrossings != baseline.EdgeCrossings) + { + return candidate.EdgeCrossings < baseline.EdgeCrossings; + } + + return candidate.Value > baseline.Value + 0.01d; + } + + private static bool CanRefineEdge(ElkRoutedEdge edge, IReadOnlyCollection nodes) + { + if (!string.IsNullOrWhiteSpace(edge.SourcePortId) || !string.IsNullOrWhiteSpace(edge.TargetPortId)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(edge.Kind) + && edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (nodes.Count == 0) + { + return false; + } + + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + if (ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY)) + { + return false; + } + + return !ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label); + } + + private static IReadOnlyList BuildSoftObstacles( + IReadOnlyCollection edges, + string excludedEdgeId) + { + return ElkEdgeRoutingGeometry.FlattenSegments(edges) + .Where(segment => !string.Equals(segment.EdgeId, excludedEdgeId, StringComparison.Ordinal)) + .Select(segment => new OrthogonalSoftObstacle(segment.Start, segment.End)) + .ToArray(); + } + + private static IEnumerable BuildTrials(EdgeRefinementOptions options) + { + foreach (var template in TrialTemplates) + { + var softObstacleWeight = options.SoftObstacleWeight <= 0d + ? 0d + : Math.Max(options.SoftObstacleWeight, template.SoftObstacleWeight); + yield return new OrthogonalAStarOptions( + Math.Max(options.BaseObstacleMargin, template.Margin), + template.BendPenalty, + softObstacleWeight, + Math.Max(8d, options.SoftObstacleClearance)); + } + } + + private static EdgeRefinementOptions ResolveOptions(ElkLayoutOptions layoutOptions) + { + var requested = layoutOptions.EdgeRefinement ?? new EdgeRefinementOptions(); + var enabled = requested.Enabled ?? layoutOptions.Effort == ElkLayoutEffort.Best; + + return new EdgeRefinementOptions + { + Enabled = enabled, + MaxGlobalPasses = Math.Max(0, requested.MaxGlobalPasses), + MaxTrialsPerProblemEdge = Math.Max(1, requested.MaxTrialsPerProblemEdge), + MaxProblemEdgesPerPass = Math.Max(1, requested.MaxProblemEdgesPerPass), + BaseObstacleMargin = Math.Max(8d, requested.BaseObstacleMargin), + SoftObstacleWeight = Math.Max(0d, requested.SoftObstacleWeight), + SoftObstacleClearance = Math.Max(8d, requested.SoftObstacleClearance), + }; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouteRefiner.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouteRefiner.cs index 92d7351e6..a622a6d63 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouteRefiner.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouteRefiner.cs @@ -1,6 +1,6 @@ namespace StellaOps.ElkSharp; -internal static class ElkEdgeRouteRefiner +internal static partial class ElkEdgeRouteRefiner { private static readonly OrthogonalAStarOptions[] TrialTemplates = [ @@ -251,89 +251,4 @@ internal static class ElkEdgeRouteRefiner improvedScore = bestLocalScore; return true; } - - private static bool IsBetterCandidate(EdgeRoutingScore candidate, EdgeRoutingScore baseline) - { - if (candidate.NodeCrossings != baseline.NodeCrossings) - { - return candidate.NodeCrossings < baseline.NodeCrossings; - } - - if (candidate.EdgeCrossings != baseline.EdgeCrossings) - { - return candidate.EdgeCrossings < baseline.EdgeCrossings; - } - - return candidate.Value > baseline.Value + 0.01d; - } - - private static bool CanRefineEdge(ElkRoutedEdge edge, IReadOnlyCollection nodes) - { - if (!string.IsNullOrWhiteSpace(edge.SourcePortId) || !string.IsNullOrWhiteSpace(edge.TargetPortId)) - { - return false; - } - - if (!string.IsNullOrWhiteSpace(edge.Kind) - && edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (nodes.Count == 0) - { - return false; - } - - var graphMinY = nodes.Min(node => node.Y); - var graphMaxY = nodes.Max(node => node.Y + node.Height); - if (ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY)) - { - return false; - } - - return !ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label); - } - - private static IReadOnlyList BuildSoftObstacles( - IReadOnlyCollection edges, - string excludedEdgeId) - { - return ElkEdgeRoutingGeometry.FlattenSegments(edges) - .Where(segment => !string.Equals(segment.EdgeId, excludedEdgeId, StringComparison.Ordinal)) - .Select(segment => new OrthogonalSoftObstacle(segment.Start, segment.End)) - .ToArray(); - } - - private static IEnumerable BuildTrials(EdgeRefinementOptions options) - { - foreach (var template in TrialTemplates) - { - var softObstacleWeight = options.SoftObstacleWeight <= 0d - ? 0d - : Math.Max(options.SoftObstacleWeight, template.SoftObstacleWeight); - yield return new OrthogonalAStarOptions( - Math.Max(options.BaseObstacleMargin, template.Margin), - template.BendPenalty, - softObstacleWeight, - Math.Max(8d, options.SoftObstacleClearance)); - } - } - - private static EdgeRefinementOptions ResolveOptions(ElkLayoutOptions layoutOptions) - { - var requested = layoutOptions.EdgeRefinement ?? new EdgeRefinementOptions(); - var enabled = requested.Enabled ?? layoutOptions.Effort == ElkLayoutEffort.Best; - - return new EdgeRefinementOptions - { - Enabled = enabled, - MaxGlobalPasses = Math.Max(0, requested.MaxGlobalPasses), - MaxTrialsPerProblemEdge = Math.Max(1, requested.MaxTrialsPerProblemEdge), - MaxProblemEdgesPerPass = Math.Max(1, requested.MaxProblemEdgesPerPass), - BaseObstacleMargin = Math.Max(8d, requested.BaseObstacleMargin), - SoftObstacleWeight = Math.Max(0d, requested.SoftObstacleWeight), - SoftObstacleClearance = Math.Max(8d, requested.SoftObstacleClearance), - }; - } } diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.DummyReconstruction.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.DummyReconstruction.cs new file mode 100644 index 000000000..1e26c403a --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.DummyReconstruction.cs @@ -0,0 +1,175 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouter +{ + internal static Dictionary ReconstructDummyEdges( + IReadOnlyCollection originalEdges, + DummyNodeResult dummyResult, + IReadOnlyDictionary positionedNodes, + IReadOnlyDictionary augmentedNodesById, + ElkLayoutDirection direction, + GraphBounds graphBounds, + IReadOnlyDictionary edgeChannels, + IReadOnlyDictionary layerBoundariesByNodeId) + { + var edgesWithChains = originalEdges + .Where(edge => dummyResult.EdgeDummyChains.ContainsKey(edge.Id)) + .ToArray(); + + var incomingByTarget = edgesWithChains + .GroupBy(edge => edge.TargetNodeId, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.OrderBy(edge => + { + var sourceNode = positionedNodes[edge.SourceNodeId]; + return direction == ElkLayoutDirection.LeftToRight + ? sourceNode.Y + (sourceNode.Height / 2d) + : sourceNode.X + (sourceNode.Width / 2d); + }).ToArray(), StringComparer.Ordinal); + + var outgoingBySource = edgesWithChains + .GroupBy(edge => edge.SourceNodeId, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.OrderBy(edge => + { + var targetNode = positionedNodes[edge.TargetNodeId]; + return direction == ElkLayoutDirection.LeftToRight + ? targetNode.Y + (targetNode.Height / 2d) + : targetNode.X + (targetNode.Width / 2d); + }).ToArray(), StringComparer.Ordinal); + + var reconstructed = new Dictionary(StringComparer.Ordinal); + + foreach (var edge in edgesWithChains) + { + if (!dummyResult.EdgeDummyChains.TryGetValue(edge.Id, out var chain) || chain.Count == 0) + { + continue; + } + + var channel = edgeChannels.GetValueOrDefault(edge.Id); + var useCorridorRouting = channel.RouteMode != EdgeRouteMode.Direct + || !string.IsNullOrWhiteSpace(edge.SourcePortId) + || !string.IsNullOrWhiteSpace(edge.TargetPortId); + if (useCorridorRouting) + { + reconstructed[edge.Id] = RouteEdge( + edge, + augmentedNodesById, + positionedNodes, + direction, + graphBounds, + channel, + layerBoundariesByNodeId); + continue; + } + + var sourceNode = positionedNodes[edge.SourceNodeId]; + var targetNode = positionedNodes[edge.TargetNodeId]; + var sourceGroup = outgoingBySource.GetValueOrDefault(edge.SourceNodeId); + var targetGroup = incomingByTarget.GetValueOrDefault(edge.TargetNodeId); + + if (ShouldRouteLongEdgeViaDirectRouter(edge, sourceNode, targetNode, sourceGroup, targetGroup, positionedNodes, direction)) + { + reconstructed[edge.Id] = RouteEdge( + edge, + augmentedNodesById, + positionedNodes, + direction, + graphBounds, + channel, + layerBoundariesByNodeId); + continue; + } + + var bendPoints = new List(); + foreach (var dummyId in chain) + { + if (positionedNodes.TryGetValue(dummyId, out var dummyPosition)) + { + var centerY = dummyPosition.Y + (dummyPosition.Height / 2d); + if (channel.RouteMode == EdgeRouteMode.Direct + && (centerY > graphBounds.MaxY + 8d || centerY < graphBounds.MinY - 8d)) + { + continue; + } + + bendPoints.Add(new ElkPoint + { + X = dummyPosition.X + (dummyPosition.Width / 2d), + Y = centerY, + }); + } + } + + var sourceExitY = ElkEdgeRouterGrouping.ResolveGroupedAnchorCoordinate(sourceNode, edge, sourceGroup, positionedNodes, isSource: true, direction); + var targetEntryY = ElkEdgeRouterGrouping.ResolveGroupedAnchorCoordinate(targetNode, edge, targetGroup, positionedNodes, isSource: false, direction); + + var targetCenter = new ElkPoint + { + X = targetNode.X + (targetNode.Width / 2d), + Y = targetNode.Y + (targetNode.Height / 2d), + }; + var sourceAnchor = ElkEdgeRouterAnchors.ComputeSmartAnchor( + sourceNode, + targetCenter, + true, + sourceExitY, + sourceGroup?.Length ?? 1, + direction); + var targetAnchor = ElkEdgeRouterAnchors.ComputeSmartAnchor( + targetNode, + bendPoints.Count > 0 ? bendPoints[^1] : null, + false, + targetEntryY, + targetGroup?.Length ?? 1, + direction); + + if (direction == ElkLayoutDirection.LeftToRight + && (string.Equals(targetNode.Kind, "End", StringComparison.OrdinalIgnoreCase) + || string.Equals(targetNode.Kind, "Join", StringComparison.OrdinalIgnoreCase))) + { + var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); + var targetCenterY = targetNode.Y + (targetNode.Height / 2d); + var verticalOffset = sourceCenterY - targetCenterY; + var sideThreshold = targetNode.Height * 0.4d; + if (verticalOffset < -sideThreshold) + { + targetAnchor = ElkEdgeRouterAnchors.ResolvePreferredAnchorPoint( + targetNode, sourceNode.X + sourceNode.Width, targetNode.Y - 256d, "NORTH", direction); + var approachOffset = 24d; + var approachX = targetNode.X + (targetNode.Width / 2d); + bendPoints.Add(new ElkPoint { X = approachX, Y = targetNode.Y - approachOffset }); + } + else if (verticalOffset > sideThreshold) + { + targetAnchor = ElkEdgeRouterAnchors.ResolvePreferredAnchorPoint( + targetNode, sourceNode.X + sourceNode.Width, targetNode.Y + targetNode.Height + 256d, "SOUTH", direction); + var approachOffset = 24d; + var approachX = targetNode.X + (targetNode.Width / 2d); + bendPoints.Add(new ElkPoint { X = approachX, Y = targetNode.Y + targetNode.Height + approachOffset }); + } + } + + reconstructed[edge.Id] = 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 = sourceAnchor, + EndPoint = targetAnchor, + BendPoints = bendPoints, + }, + ], + }; + } + + return reconstructed; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.cs index 349a546a5..d4f20bf87 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.cs @@ -1,6 +1,6 @@ namespace StellaOps.ElkSharp; -internal static class ElkEdgeRouter +internal static partial class ElkEdgeRouter { internal static ElkRoutedEdge RouteEdge( ElkEdge edge, @@ -143,167 +143,6 @@ internal static class ElkEdgeRouter }; } - internal static Dictionary ReconstructDummyEdges( - IReadOnlyCollection originalEdges, - DummyNodeResult dummyResult, - IReadOnlyDictionary positionedNodes, - IReadOnlyDictionary augmentedNodesById, - ElkLayoutDirection direction, - GraphBounds graphBounds, - IReadOnlyDictionary edgeChannels, - IReadOnlyDictionary layerBoundariesByNodeId) - { - var edgesWithChains = originalEdges - .Where(e => dummyResult.EdgeDummyChains.ContainsKey(e.Id)) - .ToArray(); - - var incomingByTarget = edgesWithChains - .GroupBy(e => e.TargetNodeId, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => g.OrderBy(e => - { - var s = positionedNodes[e.SourceNodeId]; - return direction == ElkLayoutDirection.LeftToRight - ? s.Y + (s.Height / 2d) - : s.X + (s.Width / 2d); - }).ToArray(), StringComparer.Ordinal); - - var outgoingBySource = edgesWithChains - .GroupBy(e => e.SourceNodeId, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => g.OrderBy(e => - { - var t = positionedNodes[e.TargetNodeId]; - return direction == ElkLayoutDirection.LeftToRight - ? t.Y + (t.Height / 2d) - : t.X + (t.Width / 2d); - }).ToArray(), StringComparer.Ordinal); - - var reconstructed = new Dictionary(StringComparer.Ordinal); - - foreach (var edge in edgesWithChains) - { - if (!dummyResult.EdgeDummyChains.TryGetValue(edge.Id, out var chain) || chain.Count == 0) - { - continue; - } - - var channel = edgeChannels.GetValueOrDefault(edge.Id); - var useCorridorRouting = channel.RouteMode != EdgeRouteMode.Direct - || !string.IsNullOrWhiteSpace(edge.SourcePortId) - || !string.IsNullOrWhiteSpace(edge.TargetPortId); - if (useCorridorRouting) - { - reconstructed[edge.Id] = RouteEdge( - edge, - augmentedNodesById, - positionedNodes, - direction, - graphBounds, - channel, - layerBoundariesByNodeId); - continue; - } - - var sourceNode = positionedNodes[edge.SourceNodeId]; - var targetNode = positionedNodes[edge.TargetNodeId]; - var sourceGroup = outgoingBySource.GetValueOrDefault(edge.SourceNodeId); - var targetGroup = incomingByTarget.GetValueOrDefault(edge.TargetNodeId); - - if (ShouldRouteLongEdgeViaDirectRouter(edge, sourceNode, targetNode, sourceGroup, targetGroup, positionedNodes, direction)) - { - reconstructed[edge.Id] = RouteEdge( - edge, - augmentedNodesById, - positionedNodes, - direction, - graphBounds, - channel, - layerBoundariesByNodeId); - continue; - } - - var bendPoints = new List(); - foreach (var dummyId in chain) - { - if (positionedNodes.TryGetValue(dummyId, out var dummyPos)) - { - var centerY = dummyPos.Y + (dummyPos.Height / 2d); - if (channel.RouteMode == EdgeRouteMode.Direct - && (centerY > graphBounds.MaxY + 8d || centerY < graphBounds.MinY - 8d)) - { - continue; - } - - bendPoints.Add(new ElkPoint - { - X = dummyPos.X + (dummyPos.Width / 2d), - Y = centerY, - }); - } - } - - var sourceExitY = ElkEdgeRouterGrouping.ResolveGroupedAnchorCoordinate(sourceNode, edge, sourceGroup, positionedNodes, isSource: true, direction); - var targetEntryY = ElkEdgeRouterGrouping.ResolveGroupedAnchorCoordinate(targetNode, edge, targetGroup, positionedNodes, isSource: false, direction); - - var targetCenter = new ElkPoint - { - X = targetNode.X + (targetNode.Width / 2d), - Y = targetNode.Y + (targetNode.Height / 2d), - }; - var sourceAnchor = ElkEdgeRouterAnchors.ComputeSmartAnchor(sourceNode, targetCenter, - true, sourceExitY, sourceGroup?.Length ?? 1, direction); - var targetAnchor = ElkEdgeRouterAnchors.ComputeSmartAnchor(targetNode, bendPoints.Count > 0 ? bendPoints[^1] : null, - false, targetEntryY, targetGroup?.Length ?? 1, direction); - - if (direction == ElkLayoutDirection.LeftToRight - && (string.Equals(targetNode.Kind, "End", StringComparison.OrdinalIgnoreCase) - || string.Equals(targetNode.Kind, "Join", StringComparison.OrdinalIgnoreCase))) - { - var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); - var targetCenterY = targetNode.Y + (targetNode.Height / 2d); - var verticalOffset = sourceCenterY - targetCenterY; - var sideThreshold = targetNode.Height * 0.4d; - if (verticalOffset < -sideThreshold) - { - targetAnchor = ElkEdgeRouterAnchors.ResolvePreferredAnchorPoint( - targetNode, sourceNode.X + sourceNode.Width, targetNode.Y - 256d, "NORTH", direction); - var approachOffset = 24d; - var approachX = targetNode.X + (targetNode.Width / 2d); - bendPoints.Add(new ElkPoint { X = approachX, Y = targetNode.Y - approachOffset }); - } - else if (verticalOffset > sideThreshold) - { - targetAnchor = ElkEdgeRouterAnchors.ResolvePreferredAnchorPoint( - targetNode, sourceNode.X + sourceNode.Width, targetNode.Y + targetNode.Height + 256d, "SOUTH", direction); - var approachOffset = 24d; - var approachX = targetNode.X + (targetNode.Width / 2d); - bendPoints.Add(new ElkPoint { X = approachX, Y = targetNode.Y + targetNode.Height + approachOffset }); - } - } - - reconstructed[edge.Id] = 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 = sourceAnchor, - EndPoint = targetAnchor, - BendPoints = bendPoints, - }, - ], - }; - } - - return reconstructed; - } - internal static bool ShouldRouteLongEdgeViaDirectRouter( ElkEdge edge, ElkPositionedNode sourceNode, diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAStar8Dir.Costs.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAStar8Dir.Costs.cs new file mode 100644 index 000000000..8db69a19d --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAStar8Dir.Costs.cs @@ -0,0 +1,135 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterAStar8Dir +{ + private static double ResolveMaxDiagonalStepLength( + IReadOnlyCollection<(double Left, double Top, double Right, double Bottom, string Id)> obstacles) + { + if (obstacles.Count == 0) + { + return 256d; + } + + var averageWidth = obstacles.Average(obstacle => obstacle.Right - obstacle.Left); + var averageHeight = obstacles.Average(obstacle => obstacle.Bottom - obstacle.Top); + var averageShapeSize = (averageWidth + averageHeight) / 2d; + return Math.Max(96d, averageShapeSize * 2d); + } + + private static double ComputeBendPenalty(int curDir, int newDir, double bendPenalty) + { + if (curDir == 0 || curDir == newDir) + { + return 0d; + } + + if ((curDir <= 2 && newDir <= 2) || (curDir >= 3 && newDir >= 3)) + { + return bendPenalty; + } + + return bendPenalty / 2d; + } + + private static double ComputeSoftObstacleCost( + double x1, + double y1, + double x2, + double y2, + SoftObstacleInfo[] softObstacles, + AStarRoutingParams routingParams) + { + if (routingParams.SoftObstacleWeight <= 0d || softObstacles.Length == 0) + { + return 0d; + } + + var candidateStart = new ElkPoint { X = x1, Y = y1 }; + var candidateEnd = new ElkPoint { X = x2, Y = y2 }; + var candidateIsHorizontal = Math.Abs(y2 - y1) < 2d; + var candidateIsVertical = Math.Abs(x2 - x1) < 2d; + var candidateMinX = Math.Min(x1, x2); + var candidateMaxX = Math.Max(x1, x2); + var candidateMinY = Math.Min(y1, y2); + var candidateMaxY = Math.Max(y1, y2); + var expandedMinX = candidateMinX - routingParams.SoftObstacleClearance; + var expandedMaxX = candidateMaxX + routingParams.SoftObstacleClearance; + var expandedMinY = candidateMinY - routingParams.SoftObstacleClearance; + var expandedMaxY = candidateMaxY + routingParams.SoftObstacleClearance; + var cost = 0d; + + foreach (var obstacle in softObstacles) + { + if (expandedMaxX < obstacle.MinX + || expandedMinX > obstacle.MaxX + || expandedMaxY < obstacle.MinY + || expandedMinY > obstacle.MaxY) + { + continue; + } + + if (ElkEdgeRoutingGeometry.SegmentsIntersect(candidateStart, candidateEnd, obstacle.Start, obstacle.End)) + { + cost += 120d * routingParams.SoftObstacleWeight; + continue; + } + + var distance = ComputeParallelDistance( + x1, + y1, + x2, + y2, + candidateIsHorizontal, + candidateIsVertical, + obstacle, + routingParams.SoftObstacleClearance); + + if (distance >= 0d) + { + var factor = 1d - (distance / routingParams.SoftObstacleClearance); + cost += 60d * factor * factor * routingParams.SoftObstacleWeight; + } + } + + return cost; + } + + private static double ComputeParallelDistance( + double x1, + double y1, + double x2, + double y2, + bool candidateIsHorizontal, + bool candidateIsVertical, + SoftObstacleInfo obstacle, + double clearance) + { + if (candidateIsHorizontal && obstacle.IsHorizontal) + { + var distance = Math.Abs(y1 - obstacle.Start.Y); + if (distance >= clearance) + { + return -1d; + } + + var overlapMin = Math.Max(Math.Min(x1, x2), obstacle.MinX); + var overlapMax = Math.Min(Math.Max(x1, x2), obstacle.MaxX); + return overlapMax > overlapMin + 1d ? distance : -1d; + } + + if (candidateIsVertical && obstacle.IsVertical) + { + var distance = Math.Abs(x1 - obstacle.Start.X); + if (distance >= clearance) + { + return -1d; + } + + var overlapMin = Math.Max(Math.Min(y1, y2), obstacle.MinY); + var overlapMax = Math.Min(Math.Max(y1, y2), obstacle.MaxY); + return overlapMax > overlapMin + 1d ? distance : -1d; + } + + return -1d; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAStar8Dir.Grid.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAStar8Dir.Grid.cs new file mode 100644 index 000000000..945141855 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAStar8Dir.Grid.cs @@ -0,0 +1,225 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterAStar8Dir +{ + private static BlockedSegments BuildBlockedSegments( + double[] xArr, + double[] yArr, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + string sourceId, + string targetId) + { + var xCount = xArr.Length; + var yCount = yArr.Length; + var verticalBlocked = new bool[xCount * Math.Max(0, yCount - 1)]; + var horizontalBlocked = new bool[Math.Max(0, xCount - 1) * yCount]; + + foreach (var obstacle in obstacles) + { + if (obstacle.Id == sourceId || obstacle.Id == targetId) + { + continue; + } + + var verticalXStart = Math.Max(0, LowerBoundExclusive(xArr, obstacle.Left)); + var verticalXEnd = Math.Min(xCount - 1, UpperBoundExclusive(xArr, obstacle.Right) - 1); + if (verticalXStart <= verticalXEnd) + { + var verticalYStart = Math.Max(0, LowerBound(yArr, obstacle.Top) - 1); + var verticalYEnd = Math.Min(yCount - 2, UpperBound(yArr, obstacle.Bottom) - 1); + for (var ix = verticalXStart; ix <= verticalXEnd; ix++) + { + for (var iy = verticalYStart; iy <= verticalYEnd; iy++) + { + if (yArr[iy + 1] > obstacle.Top && yArr[iy] < obstacle.Bottom) + { + verticalBlocked[(ix * (yCount - 1)) + iy] = true; + } + } + } + } + + var horizontalYStart = Math.Max(0, LowerBoundExclusive(yArr, obstacle.Top)); + var horizontalYEnd = Math.Min(yCount - 1, UpperBoundExclusive(yArr, obstacle.Bottom) - 1); + if (horizontalYStart <= horizontalYEnd) + { + var horizontalXStart = Math.Max(0, LowerBound(xArr, obstacle.Left) - 1); + var horizontalXEnd = Math.Min(xCount - 2, UpperBound(xArr, obstacle.Right) - 1); + for (var iy = horizontalYStart; iy <= horizontalYEnd; iy++) + { + for (var ix = horizontalXStart; ix <= horizontalXEnd; ix++) + { + if (xArr[ix + 1] > obstacle.Left && xArr[ix] < obstacle.Right) + { + horizontalBlocked[(ix * yCount) + iy] = true; + } + } + } + } + } + + return new BlockedSegments(xCount, yCount, verticalBlocked, horizontalBlocked); + } + + private static SoftObstacleInfo[] BuildSoftObstacleInfos(IReadOnlyList softObstacles) + { + if (softObstacles.Count == 0) + { + return []; + } + + var infos = new SoftObstacleInfo[softObstacles.Count]; + for (var i = 0; i < softObstacles.Count; i++) + { + var obstacle = softObstacles[i]; + infos[i] = new SoftObstacleInfo( + obstacle.Start, + obstacle.End, + Math.Min(obstacle.Start.X, obstacle.End.X), + Math.Max(obstacle.Start.X, obstacle.End.X), + Math.Min(obstacle.Start.Y, obstacle.End.Y), + Math.Max(obstacle.Start.Y, obstacle.End.Y), + Math.Abs(obstacle.Start.Y - obstacle.End.Y) < 2d, + Math.Abs(obstacle.Start.X - obstacle.End.X) < 2d); + } + + return infos; + } + + private static int LowerBound(double[] values, double target) + { + var low = 0; + var high = values.Length; + while (low < high) + { + var mid = low + ((high - low) / 2); + if (values[mid] < target) + { + low = mid + 1; + } + else + { + high = mid; + } + } + + return low; + } + + private static int UpperBound(double[] values, double target) + { + var low = 0; + var high = values.Length; + while (low < high) + { + var mid = low + ((high - low) / 2); + if (values[mid] <= target) + { + low = mid + 1; + } + else + { + high = mid; + } + } + + return low; + } + + private static int LowerBoundExclusive(double[] values, double target) + { + var low = 0; + var high = values.Length; + while (low < high) + { + var mid = low + ((high - low) / 2); + if (values[mid] <= target) + { + low = mid + 1; + } + else + { + high = mid; + } + } + + return low; + } + + private static int UpperBoundExclusive(double[] values, double target) + { + var low = 0; + var high = values.Length; + while (low < high) + { + var mid = low + ((high - low) / 2); + if (values[mid] < target) + { + low = mid + 1; + } + else + { + high = mid; + } + } + + return low; + } + + private static void AddIntermediateLines(SortedSet coords, double spacing) + { + var arr = coords.ToArray(); + for (var i = 0; i < arr.Length - 1; i++) + { + var gap = arr[i + 1] - arr[i]; + if (gap <= spacing * 2d) + { + continue; + } + + var count = (int)(gap / spacing); + var step = gap / (count + 1); + for (var j = 1; j <= count; j++) + { + coords.Add(arr[i] + j * step); + } + } + } + + private readonly record struct SoftObstacleInfo( + ElkPoint Start, + ElkPoint End, + double MinX, + double MaxX, + double MinY, + double MaxY, + bool IsHorizontal, + bool IsVertical); + + private readonly record struct BlockedSegments( + int XCount, + int YCount, + bool[] VerticalBlocked, + bool[] HorizontalBlocked) + { + internal bool IsVerticalBlocked(int ix, int iy) + { + if (ix < 0 || ix >= XCount || iy < 0 || iy >= YCount - 1) + { + return true; + } + + return VerticalBlocked[(ix * (YCount - 1)) + iy]; + } + + internal bool IsHorizontalBlocked(int ix, int iy) + { + if (ix < 0 || ix >= XCount - 1 || iy < 0 || iy >= YCount) + { + return true; + } + + return HorizontalBlocked[(ix * YCount) + iy]; + } + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAStar8Dir.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAStar8Dir.cs index 6a036ae58..be65fea44 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAStar8Dir.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAStar8Dir.cs @@ -1,11 +1,11 @@ namespace StellaOps.ElkSharp; -internal static class ElkEdgeRouterAStar8Dir +internal static partial class ElkEdgeRouterAStar8Dir { // E, W, S, N, NE, SW, SE, NW private static readonly int[] Dx = [1, -1, 0, 0, 1, -1, 1, -1]; private static readonly int[] Dy = [0, 0, 1, -1, -1, 1, 1, -1]; - // Direction codes: 1=horizontal, 2=vertical, 3=diagonal45(NE/SW), 4=diagonal135(SE/NW) + // Direction codes: 1=horizontal, 2=vertical, 3=diagonal45 (NE/SW), 4=diagonal135 (SE/NW) private static readonly int[] DirCodes = [1, 1, 2, 2, 3, 3, 4, 4]; internal static List? Route( @@ -20,17 +20,17 @@ internal static class ElkEdgeRouterAStar8Dir { var xs = new SortedSet { start.X, end.X }; var ys = new SortedSet { start.Y, end.Y }; - foreach (var ob in obstacles) + foreach (var obstacle in obstacles) { - if (ob.Id == sourceId || ob.Id == targetId) + if (obstacle.Id == sourceId || obstacle.Id == targetId) { continue; } - xs.Add(ob.Left - routingParams.Margin); - xs.Add(ob.Right + routingParams.Margin); - ys.Add(ob.Top - routingParams.Margin); - ys.Add(ob.Bottom + routingParams.Margin); + xs.Add(obstacle.Left - routingParams.Margin); + xs.Add(obstacle.Right + routingParams.Margin); + ys.Add(obstacle.Top - routingParams.Margin); + ys.Add(obstacle.Bottom + routingParams.Margin); } if (routingParams.IntermediateGridSpacing > 0d) @@ -108,30 +108,27 @@ internal static class ElkEdgeRouterAStar8Dir var cameFrom = new int[stateCount]; Array.Fill(cameFrom, -1); - // Side-aware entry angle: block moves parallel to the target's entry side - // Vertical side (left/right) → block vertical (dir=2), force horizontal approach - // Horizontal side (top/bottom) → block horizontal (dir=1), force vertical approach var blockedEntryDir = 0; if (routingParams.EnforceEntryAngle) { - foreach (var ob in obstacles) + foreach (var obstacle in obstacles) { - if (ob.Id != targetId) + if (obstacle.Id != targetId) { continue; } - var nodeLeft = ob.Left + routingParams.Margin; - var nodeRight = ob.Right - routingParams.Margin; - var nodeTop = ob.Top + routingParams.Margin; - var nodeBottom = ob.Bottom - routingParams.Margin; + var nodeLeft = obstacle.Left + routingParams.Margin; + var nodeRight = obstacle.Right - routingParams.Margin; + var nodeTop = obstacle.Top + routingParams.Margin; + var nodeBottom = obstacle.Bottom - routingParams.Margin; if (Math.Abs(end.X - nodeLeft) < 2d || Math.Abs(end.X - nodeRight) < 2d) { - blockedEntryDir = 2; // vertical side → block vertical + blockedEntryDir = 2; } else if (Math.Abs(end.Y - nodeTop) < 2d || Math.Abs(end.Y - nodeBottom) < 2d) { - blockedEntryDir = 1; // horizontal side → block horizontal + blockedEntryDir = 1; } break; @@ -200,23 +197,16 @@ internal static class ElkEdgeRouterAStar8Dir continue; } } - else - { - if (IsBlockedOrthogonal(curIx, curIy, nx, ny)) - { - continue; - } - } - - var newDir = DirCodes[d]; - - // Side-aware entry angle: block parallel moves into end cell - if (blockedEntryDir > 0 && nx == endIx && ny == endIy && newDir == blockedEntryDir) + else if (IsBlockedOrthogonal(curIx, curIy, nx, ny)) { continue; } - var bend = ComputeBendPenalty(curDir, newDir, routingParams.BendPenalty); + var newDir = DirCodes[d]; + if (blockedEntryDir > 0 && nx == endIx && ny == endIy && newDir == blockedEntryDir) + { + continue; + } double dist; if (isDiagonal) @@ -228,6 +218,7 @@ internal static class ElkEdgeRouterAStar8Dir { continue; } + dist = diagonalStepLength + routingParams.DiagonalPenalty; } else @@ -235,10 +226,10 @@ internal static class ElkEdgeRouterAStar8Dir dist = Math.Abs(xArr[nx] - xArr[curIx]) + Math.Abs(yArr[ny] - yArr[curIy]); } + var bend = ComputeBendPenalty(curDir, newDir, routingParams.BendPenalty); var softCost = ComputeSoftObstacleCost( xArr[curIx], yArr[curIy], xArr[nx], yArr[ny], softObstacleInfos, routingParams); - var tentativeG = gScore[current] + dist + bend + softCost; var neighborState = StateId(nx, ny, newDir); @@ -254,317 +245,33 @@ internal static class ElkEdgeRouterAStar8Dir return null; } - private static double ResolveMaxDiagonalStepLength( - IReadOnlyCollection<(double Left, double Top, double Right, double Bottom, string Id)> obstacles) - { - if (obstacles.Count == 0) - { - return 256d; - } - - var averageWidth = obstacles.Average(obstacle => obstacle.Right - obstacle.Left); - var averageHeight = obstacles.Average(obstacle => obstacle.Bottom - obstacle.Top); - var averageShapeSize = (averageWidth + averageHeight) / 2d; - return Math.Max(96d, averageShapeSize * 2d); - } - - private static double ComputeBendPenalty(int curDir, int newDir, double bendPenalty) - { - if (curDir == 0 || curDir == newDir) - { - return 0d; - } - - // H↔V = 90° bend, diag↔diag (opposite types) = 90° bend - if ((curDir <= 2 && newDir <= 2) || (curDir >= 3 && newDir >= 3)) - { - return bendPenalty; - } - - // ortho↔diag = 45° bend - return bendPenalty / 2d; - } - - private static double ComputeSoftObstacleCost( - double x1, double y1, double x2, double y2, - SoftObstacleInfo[] softObstacles, - AStarRoutingParams routingParams) - { - if (routingParams.SoftObstacleWeight <= 0d || softObstacles.Length == 0) - { - return 0d; - } - - var candidateStart = new ElkPoint { X = x1, Y = y1 }; - var candidateEnd = new ElkPoint { X = x2, Y = y2 }; - var candidateIsH = Math.Abs(y2 - y1) < 2d; - var candidateIsV = Math.Abs(x2 - x1) < 2d; - var candidateMinX = Math.Min(x1, x2); - var candidateMaxX = Math.Max(x1, x2); - var candidateMinY = Math.Min(y1, y2); - var candidateMaxY = Math.Max(y1, y2); - var expandedMinX = candidateMinX - routingParams.SoftObstacleClearance; - var expandedMaxX = candidateMaxX + routingParams.SoftObstacleClearance; - var expandedMinY = candidateMinY - routingParams.SoftObstacleClearance; - var expandedMaxY = candidateMaxY + routingParams.SoftObstacleClearance; - var cost = 0d; - - foreach (var obstacle in softObstacles) - { - if (expandedMaxX < obstacle.MinX - || expandedMinX > obstacle.MaxX - || expandedMaxY < obstacle.MinY - || expandedMinY > obstacle.MaxY) - { - continue; - } - - if (ElkEdgeRoutingGeometry.SegmentsIntersect(candidateStart, candidateEnd, obstacle.Start, obstacle.End)) - { - cost += 120d * routingParams.SoftObstacleWeight; - continue; - } - - // Graduated proximity: closer = exponentially more expensive - var dist = ComputeParallelDistance( - x1, y1, x2, y2, candidateIsH, candidateIsV, - obstacle, - routingParams.SoftObstacleClearance); - - if (dist >= 0d) - { - var factor = 1d - (dist / routingParams.SoftObstacleClearance); - cost += 60d * factor * factor * routingParams.SoftObstacleWeight; - } - } - - return cost; - } - - private static double ComputeParallelDistance( - double x1, double y1, double x2, double y2, - bool candidateIsH, bool candidateIsV, - SoftObstacleInfo obstacle, - double clearance) - { - if (candidateIsH && obstacle.IsHorizontal) - { - var dist = Math.Abs(y1 - obstacle.Start.Y); - if (dist >= clearance) - { - return -1d; - } - - var overlapMin = Math.Max(Math.Min(x1, x2), obstacle.MinX); - var overlapMax = Math.Min(Math.Max(x1, x2), obstacle.MaxX); - return overlapMax > overlapMin + 1d ? dist : -1d; - } - - if (candidateIsV && obstacle.IsVertical) - { - var dist = Math.Abs(x1 - obstacle.Start.X); - if (dist >= clearance) - { - return -1d; - } - - var overlapMin = Math.Max(Math.Min(y1, y2), obstacle.MinY); - var overlapMax = Math.Min(Math.Max(y1, y2), obstacle.MaxY); - return overlapMax > overlapMin + 1d ? dist : -1d; - } - - return -1d; - } - - private static BlockedSegments BuildBlockedSegments( + private static List ReconstructPath( + int endState, + int[] cameFrom, double[] xArr, double[] yArr, - (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, - string sourceId, - string targetId) - { - var xCount = xArr.Length; - var yCount = yArr.Length; - var verticalBlocked = new bool[xCount * Math.Max(0, yCount - 1)]; - var horizontalBlocked = new bool[Math.Max(0, xCount - 1) * yCount]; - - foreach (var obstacle in obstacles) - { - if (obstacle.Id == sourceId || obstacle.Id == targetId) - { - continue; - } - - var verticalXStart = Math.Max(0, LowerBoundExclusive(xArr, obstacle.Left)); - var verticalXEnd = Math.Min(xCount - 1, UpperBoundExclusive(xArr, obstacle.Right) - 1); - if (verticalXStart <= verticalXEnd) - { - var verticalYStart = Math.Max(0, LowerBound(yArr, obstacle.Top) - 1); - var verticalYEnd = Math.Min(yCount - 2, UpperBound(yArr, obstacle.Bottom) - 1); - for (var ix = verticalXStart; ix <= verticalXEnd; ix++) - { - for (var iy = verticalYStart; iy <= verticalYEnd; iy++) - { - if (yArr[iy + 1] > obstacle.Top && yArr[iy] < obstacle.Bottom) - { - verticalBlocked[(ix * (yCount - 1)) + iy] = true; - } - } - } - } - - var horizontalYStart = Math.Max(0, LowerBoundExclusive(yArr, obstacle.Top)); - var horizontalYEnd = Math.Min(yCount - 1, UpperBoundExclusive(yArr, obstacle.Bottom) - 1); - if (horizontalYStart <= horizontalYEnd) - { - var horizontalXStart = Math.Max(0, LowerBound(xArr, obstacle.Left) - 1); - var horizontalXEnd = Math.Min(xCount - 2, UpperBound(xArr, obstacle.Right) - 1); - for (var iy = horizontalYStart; iy <= horizontalYEnd; iy++) - { - for (var ix = horizontalXStart; ix <= horizontalXEnd; ix++) - { - if (xArr[ix + 1] > obstacle.Left && xArr[ix] < obstacle.Right) - { - horizontalBlocked[(ix * yCount) + iy] = true; - } - } - } - } - } - - return new BlockedSegments(xCount, yCount, verticalBlocked, horizontalBlocked); - } - - private static SoftObstacleInfo[] BuildSoftObstacleInfos(IReadOnlyList softObstacles) - { - if (softObstacles.Count == 0) - { - return []; - } - - var infos = new SoftObstacleInfo[softObstacles.Count]; - for (var i = 0; i < softObstacles.Count; i++) - { - var obstacle = softObstacles[i]; - infos[i] = new SoftObstacleInfo( - obstacle.Start, - obstacle.End, - Math.Min(obstacle.Start.X, obstacle.End.X), - Math.Max(obstacle.Start.X, obstacle.End.X), - Math.Min(obstacle.Start.Y, obstacle.End.Y), - Math.Max(obstacle.Start.Y, obstacle.End.Y), - Math.Abs(obstacle.Start.Y - obstacle.End.Y) < 2d, - Math.Abs(obstacle.Start.X - obstacle.End.X) < 2d); - } - - return infos; - } - - private static int LowerBound(double[] values, double target) - { - var low = 0; - var high = values.Length; - while (low < high) - { - var mid = low + ((high - low) / 2); - if (values[mid] < target) - { - low = mid + 1; - } - else - { - high = mid; - } - } - - return low; - } - - private static int UpperBound(double[] values, double target) - { - var low = 0; - var high = values.Length; - while (low < high) - { - var mid = low + ((high - low) / 2); - if (values[mid] <= target) - { - low = mid + 1; - } - else - { - high = mid; - } - } - - return low; - } - - private static int LowerBoundExclusive(double[] values, double target) - { - var low = 0; - var high = values.Length; - while (low < high) - { - var mid = low + ((high - low) / 2); - if (values[mid] <= target) - { - low = mid + 1; - } - else - { - high = mid; - } - } - - return low; - } - - private static int UpperBoundExclusive(double[] values, double target) - { - var low = 0; - var high = values.Length; - while (low < high) - { - var mid = low + ((high - low) / 2); - if (values[mid] < target) - { - low = mid + 1; - } - else - { - high = mid; - } - } - - return low; - } - - private static List ReconstructPath( - int endState, int[] cameFrom, - double[] xArr, double[] yArr, - int yCount, int dirCount) + int yCount, + int dirCount) { var path = new List(); var state = endState; while (state >= 0) { - var sIy = (state / dirCount) % yCount; - var sIx = (state / dirCount) / yCount; - path.Add(new ElkPoint { X = xArr[sIx], Y = yArr[sIy] }); + var stateY = (state / dirCount) % yCount; + var stateX = (state / dirCount) / yCount; + path.Add(new ElkPoint { X = xArr[stateX], Y = yArr[stateY] }); state = cameFrom[state]; } path.Reverse(); - // Simplify: remove collinear points (same direction between consecutive segments) var simplified = new List { path[0] }; for (var i = 1; i < path.Count - 1; i++) { - var prev = simplified[^1]; + var previous = simplified[^1]; var next = path[i + 1]; - var dx1 = Math.Sign(path[i].X - prev.X); - var dy1 = Math.Sign(path[i].Y - prev.Y); + var dx1 = Math.Sign(path[i].X - previous.X); + var dy1 = Math.Sign(path[i].Y - previous.Y); var dx2 = Math.Sign(next.X - path[i].X); var dy2 = Math.Sign(next.Y - path[i].Y); if (dx1 != dx2 || dy1 != dy2) @@ -576,61 +283,4 @@ internal static class ElkEdgeRouterAStar8Dir simplified.Add(path[^1]); return simplified; } - - private static void AddIntermediateLines(SortedSet coords, double spacing) - { - var arr = coords.ToArray(); - for (var i = 0; i < arr.Length - 1; i++) - { - var gap = arr[i + 1] - arr[i]; - if (gap <= spacing * 2d) - { - continue; - } - - var count = (int)(gap / spacing); - var step = gap / (count + 1); - for (var j = 1; j <= count; j++) - { - coords.Add(arr[i] + j * step); - } - } - } - - private readonly record struct SoftObstacleInfo( - ElkPoint Start, - ElkPoint End, - double MinX, - double MaxX, - double MinY, - double MaxY, - bool IsHorizontal, - bool IsVertical); - - private readonly record struct BlockedSegments( - int XCount, - int YCount, - bool[] VerticalBlocked, - bool[] HorizontalBlocked) - { - internal bool IsVerticalBlocked(int ix, int iy) - { - if (ix < 0 || ix >= XCount || iy < 0 || iy >= YCount - 1) - { - return true; - } - - return VerticalBlocked[(ix * (YCount - 1)) + iy]; - } - - internal bool IsHorizontalBlocked(int ix, int iy) - { - if (ix < 0 || ix >= XCount - 1 || iy < 0 || iy >= YCount) - { - return true; - } - - return HorizontalBlocked[(ix * YCount) + iy]; - } - } } diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterHighway.Groups.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterHighway.Groups.cs new file mode 100644 index 000000000..3d91efafc --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterHighway.Groups.cs @@ -0,0 +1,194 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterHighway +{ + private static Dictionary> BuildTargetSideGroups( + IReadOnlyList edges, + IReadOnlyDictionary nodesById, + double graphMinY, + double graphMaxY) + { + var edgesByTargetSide = new Dictionary>(StringComparer.Ordinal); + for (var i = 0; i < edges.Count; i++) + { + var edge = edges[i]; + if (!ShouldProcessEdge(edge, graphMinY, graphMaxY)) + { + continue; + } + + if (!nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)) + { + continue; + } + + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + continue; + } + + var path = ExtractFullPath(edge); + if (path.Count < 2) + { + continue; + } + + var side = ElkEdgeRoutingGeometry.ResolveBoundarySide(path[^1], targetNode); + var key = $"{targetNode.Id}|{side}"; + if (!edgesByTargetSide.TryGetValue(key, out var list)) + { + list = []; + edgesByTargetSide[key] = list; + } + + list.Add(i); + } + + return edgesByTargetSide; + } + + private static GroupEvaluation? EvaluateTargetSideGroup( + IReadOnlyList edges, + IReadOnlyList edgeIndices, + ElkPositionedNode targetNode, + string side, + double minLineClearance) + { + var members = edgeIndices + .Select(index => CreateMember(edges[index], index, side)) + .Where(member => member.Path.Count >= 2) + .OrderBy(member => member.EdgeId, StringComparer.Ordinal) + .ToList(); + if (members.Count < 2) + { + return null; + } + + var pairMetrics = ComputePairMetrics(members); + var actualGap = ComputeMinEndpointGap(members); + var requiredGap = ElkBoundarySlots.ResolveRequiredBoundarySlotGap( + targetNode, + side, + members.Count, + minLineClearance); + var requiresSpread = (actualGap + CoordinateTolerance) < requiredGap + && !pairMetrics.AllPairsApplicable; + if (!requiresSpread && pairMetrics.ShortestSharedRatio < MinHighwayRatio) + { + requiresSpread = pairMetrics.HasSharedSegment; + } + + var diagnostic = new ElkHighwayDiagnostics + { + TargetNodeId = targetNode.Id, + SharedAxis = side, + SharedCoord = Math.Round(members.Average(member => member.EndpointCoord), 1), + EdgeIds = members.Select(member => member.EdgeId).ToArray(), + MinRatio = pairMetrics.HasSharedSegment + ? Math.Round(pairMetrics.ShortestSharedRatio, 3) + : 0d, + WasBroken = requiresSpread, + Reason = requiresSpread + ? pairMetrics.HasSharedSegment && pairMetrics.ShortestSharedRatio < MinHighwayRatio + ? $"shared ratio {pairMetrics.ShortestSharedRatio:F2} < {MinHighwayRatio:F2}" + : $"gap {actualGap:F0}px < required gap {requiredGap:F0}px" + : pairMetrics.AllPairsApplicable + ? $"shared ratio {pairMetrics.ShortestSharedRatio:F2} >= {MinHighwayRatio:F2}" + : $"gap {actualGap:F0}px >= required gap {requiredGap:F0}px", + }; + + return new GroupEvaluation(members, diagnostic); + } + + private static HighwayPairMetrics ComputePairMetrics(IReadOnlyList members) + { + var shortestSharedRatio = double.MaxValue; + var hasSharedSegment = false; + var allPairsApplicable = true; + + for (var i = 0; i < members.Count; i++) + { + for (var j = i + 1; j < members.Count; j++) + { + var sharedLength = ElkEdgeRoutingGeometry.ComputeLongestSharedApproachSegmentLength( + members[i].Path, + members[j].Path); + if (sharedLength <= 1d) + { + allPairsApplicable = false; + continue; + } + + hasSharedSegment = true; + var shortestPath = Math.Min(members[i].PathLength, members[j].PathLength); + if (shortestPath <= 1d) + { + allPairsApplicable = false; + continue; + } + + var ratio = sharedLength / shortestPath; + shortestSharedRatio = Math.Min(shortestSharedRatio, ratio); + if (ratio < MinHighwayRatio) + { + allPairsApplicable = false; + } + } + } + + return new HighwayPairMetrics( + HasSharedSegment: hasSharedSegment, + AllPairsApplicable: allPairsApplicable && hasSharedSegment, + ShortestSharedRatio: hasSharedSegment ? shortestSharedRatio : 0d); + } + + private static double ComputeMinEndpointGap(IReadOnlyList members) + { + var coords = members + .Select(member => member.EndpointCoord) + .OrderBy(value => value) + .ToArray(); + if (coords.Length < 2) + { + return double.MaxValue; + } + + var minGap = double.MaxValue; + for (var i = 1; i < coords.Length; i++) + { + minGap = Math.Min(minGap, coords[i] - coords[i - 1]); + } + + return minGap; + } + + private static HighwayMember CreateMember(ElkRoutedEdge edge, int index, string side) + { + var path = ExtractFullPath(edge); + var endpointCoord = side is "left" or "right" + ? path[^1].Y + : path[^1].X; + return new HighwayMember( + Index: index, + EdgeId: edge.Id, + Path: path, + PathLength: ElkEdgeRoutingGeometry.ComputePathLength(edge), + EndpointCoord: endpointCoord); + } + + private readonly record struct HighwayMember( + int Index, + string EdgeId, + List Path, + double PathLength, + double EndpointCoord); + + private readonly record struct HighwayPairMetrics( + bool HasSharedSegment, + bool AllPairsApplicable, + double ShortestSharedRatio); + + private readonly record struct GroupEvaluation( + IReadOnlyList Members, + ElkHighwayDiagnostics Diagnostic); +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterHighway.Paths.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterHighway.Paths.cs new file mode 100644 index 000000000..6721de4fa --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterHighway.Paths.cs @@ -0,0 +1,170 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterHighway +{ + private static double[] BuildSlotCoordinates( + ElkPositionedNode targetNode, + string side, + int count, + double minLineClearance) + { + return ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates(targetNode, side, count); + } + + private static List AdjustPathToTargetSlot( + IReadOnlyList path, + ElkPositionedNode targetNode, + string side, + double slotCoord, + double minLineClearance) + { + var adjusted = path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (adjusted.Count < 2) + { + return adjusted; + } + + if (side is "left" or "right") + { + var targetX = side == "left" + ? targetNode.X + : targetNode.X + targetNode.Width; + + while (adjusted.Count >= 3 && Math.Abs(adjusted[^2].X - targetX) <= CoordinateTolerance) + { + adjusted.RemoveAt(adjusted.Count - 2); + } + + var anchor = adjusted[^2]; + var rebuilt = adjusted.Take(adjusted.Count - 1).ToList(); + if (Math.Abs(anchor.X - targetX) <= CoordinateTolerance) + { + var offset = Math.Max(24d, minLineClearance / 2d); + rebuilt.Add(new ElkPoint + { + X = side == "left" ? targetX - offset : targetX + offset, + Y = anchor.Y, + }); + anchor = rebuilt[^1]; + } + + if (Math.Abs(anchor.Y - slotCoord) > CoordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = anchor.X, Y = slotCoord }); + } + + rebuilt.Add(new ElkPoint { X = targetX, Y = slotCoord }); + return NormalizePath(rebuilt); + } + + var targetY = side == "top" + ? targetNode.Y + : targetNode.Y + targetNode.Height; + while (adjusted.Count >= 3 && Math.Abs(adjusted[^2].Y - targetY) <= CoordinateTolerance) + { + adjusted.RemoveAt(adjusted.Count - 2); + } + + var verticalAnchor = adjusted[^2]; + var verticalRebuilt = adjusted.Take(adjusted.Count - 1).ToList(); + if (Math.Abs(verticalAnchor.Y - targetY) <= CoordinateTolerance) + { + var offset = Math.Max(24d, minLineClearance / 2d); + verticalRebuilt.Add(new ElkPoint + { + X = verticalAnchor.X, + Y = side == "top" ? targetY - offset : targetY + offset, + }); + verticalAnchor = verticalRebuilt[^1]; + } + + if (Math.Abs(verticalAnchor.X - slotCoord) > CoordinateTolerance) + { + verticalRebuilt.Add(new ElkPoint { X = slotCoord, Y = verticalAnchor.Y }); + } + + verticalRebuilt.Add(new ElkPoint { X = slotCoord, Y = targetY }); + return NormalizePath(verticalRebuilt); + } + + private static List NormalizePath(IReadOnlyList path) + { + var deduped = new List(); + foreach (var point in path) + { + if (deduped.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(deduped[^1], point)) + { + deduped.Add(point); + } + } + + if (deduped.Count <= 2) + { + return deduped; + } + + var simplified = new List { deduped[0] }; + for (var i = 1; i < deduped.Count - 1; i++) + { + var previous = simplified[^1]; + var current = deduped[i]; + var next = deduped[i + 1]; + var sameX = Math.Abs(previous.X - current.X) <= CoordinateTolerance + && Math.Abs(current.X - next.X) <= CoordinateTolerance; + var sameY = Math.Abs(previous.Y - current.Y) <= CoordinateTolerance + && Math.Abs(current.Y - next.Y) <= CoordinateTolerance; + if (!sameX && !sameY) + { + simplified.Add(current); + } + } + + simplified.Add(deduped[^1]); + return simplified; + } + + private static void WriteBackPath(ElkRoutedEdge[] result, int edgeIndex, IReadOnlyList path) + { + var edge = result[edgeIndex]; + result[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 = path[0], + EndPoint = path[^1], + BendPoints = path.Count > 2 + ? path.Skip(1).Take(path.Count - 2).ToArray() + : [], + }, + ], + }; + } + + private static List ExtractFullPath(ElkRoutedEdge edge) + { + 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); + } + + return path; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterHighway.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterHighway.cs index 48ff8fe8e..c92f1aaa1 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterHighway.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterHighway.cs @@ -1,6 +1,6 @@ namespace StellaOps.ElkSharp; -internal static class ElkEdgeRouterHighway +internal static partial class ElkEdgeRouterHighway { private const double MinHighwayRatio = 2d / 5d; private const double BoundaryInset = 4d; @@ -143,366 +143,4 @@ internal static class ElkEdgeRouterHighway WriteBackPath(result, ordered[i].Index, adjustedPath); } } - - private static Dictionary> BuildTargetSideGroups( - IReadOnlyList edges, - IReadOnlyDictionary nodesById, - double graphMinY, - double graphMaxY) - { - var edgesByTargetSide = new Dictionary>(StringComparer.Ordinal); - for (var i = 0; i < edges.Count; i++) - { - var edge = edges[i]; - if (!ShouldProcessEdge(edge, graphMinY, graphMaxY)) - { - continue; - } - - if (!nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)) - { - continue; - } - - if (ElkShapeBoundaries.IsGatewayShape(targetNode)) - { - continue; - } - - var path = ExtractFullPath(edge); - if (path.Count < 2) - { - continue; - } - - var side = ElkEdgeRoutingGeometry.ResolveBoundarySide(path[^1], targetNode); - var key = $"{targetNode.Id}|{side}"; - if (!edgesByTargetSide.TryGetValue(key, out var list)) - { - list = []; - edgesByTargetSide[key] = list; - } - - list.Add(i); - } - - return edgesByTargetSide; - } - - private static GroupEvaluation? EvaluateTargetSideGroup( - IReadOnlyList edges, - IReadOnlyList edgeIndices, - ElkPositionedNode targetNode, - string side, - double minLineClearance) - { - var members = edgeIndices - .Select(index => CreateMember(edges[index], index, targetNode, side)) - .Where(member => member.Path.Count >= 2) - .OrderBy(member => member.EdgeId, StringComparer.Ordinal) - .ToList(); - if (members.Count < 2) - { - return null; - } - - var pairMetrics = ComputePairMetrics(members); - var actualGap = ComputeMinEndpointGap(members, side); - var requiredGap = ElkBoundarySlots.ResolveRequiredBoundarySlotGap( - targetNode, - side, - members.Count, - minLineClearance); - var requiresSpread = (actualGap + CoordinateTolerance) < requiredGap - && !pairMetrics.AllPairsApplicable; - if (!requiresSpread && pairMetrics.ShortestSharedRatio < MinHighwayRatio) - { - requiresSpread = pairMetrics.HasSharedSegment; - } - - var diagnostic = new ElkHighwayDiagnostics - { - TargetNodeId = targetNode.Id, - SharedAxis = side, - SharedCoord = Math.Round(members.Average(member => member.EndpointCoord), 1), - EdgeIds = members.Select(member => member.EdgeId).ToArray(), - MinRatio = pairMetrics.HasSharedSegment - ? Math.Round(pairMetrics.ShortestSharedRatio, 3) - : 0d, - WasBroken = requiresSpread, - Reason = requiresSpread - ? pairMetrics.HasSharedSegment && pairMetrics.ShortestSharedRatio < MinHighwayRatio - ? $"shared ratio {pairMetrics.ShortestSharedRatio:F2} < {MinHighwayRatio:F2}" - : $"gap {actualGap:F0}px < required gap {requiredGap:F0}px" - : pairMetrics.AllPairsApplicable - ? $"shared ratio {pairMetrics.ShortestSharedRatio:F2} >= {MinHighwayRatio:F2}" - : $"gap {actualGap:F0}px >= required gap {requiredGap:F0}px", - }; - - return new GroupEvaluation(members, diagnostic); - } - - private static HighwayPairMetrics ComputePairMetrics(IReadOnlyList members) - { - var shortestSharedRatio = double.MaxValue; - var hasSharedSegment = false; - var allPairsApplicable = true; - - for (var i = 0; i < members.Count; i++) - { - for (var j = i + 1; j < members.Count; j++) - { - var sharedLength = ElkEdgeRoutingGeometry.ComputeLongestSharedApproachSegmentLength( - members[i].Path, - members[j].Path); - if (sharedLength <= 1d) - { - allPairsApplicable = false; - continue; - } - - hasSharedSegment = true; - var shortestPath = Math.Min(members[i].PathLength, members[j].PathLength); - if (shortestPath <= 1d) - { - allPairsApplicable = false; - continue; - } - - var ratio = sharedLength / shortestPath; - shortestSharedRatio = Math.Min(shortestSharedRatio, ratio); - if (ratio < MinHighwayRatio) - { - allPairsApplicable = false; - } - } - } - - return new HighwayPairMetrics( - HasSharedSegment: hasSharedSegment, - AllPairsApplicable: allPairsApplicable && hasSharedSegment, - ShortestSharedRatio: hasSharedSegment ? shortestSharedRatio : 0d); - } - - private static double ComputeMinEndpointGap(IReadOnlyList members, string side) - { - var coords = members - .Select(member => member.EndpointCoord) - .OrderBy(value => value) - .ToArray(); - if (coords.Length < 2) - { - return double.MaxValue; - } - - var minGap = double.MaxValue; - for (var i = 1; i < coords.Length; i++) - { - minGap = Math.Min(minGap, coords[i] - coords[i - 1]); - } - - return minGap; - } - - private static double[] BuildSlotCoordinates( - ElkPositionedNode targetNode, - string side, - int count, - double minLineClearance) - { - return ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates(targetNode, side, count); - } - - private static List AdjustPathToTargetSlot( - IReadOnlyList path, - ElkPositionedNode targetNode, - string side, - double slotCoord, - double minLineClearance) - { - var adjusted = path - .Select(point => new ElkPoint { X = point.X, Y = point.Y }) - .ToList(); - if (adjusted.Count < 2) - { - return adjusted; - } - - if (side is "left" or "right") - { - var targetX = side == "left" - ? targetNode.X - : targetNode.X + targetNode.Width; - - while (adjusted.Count >= 3 && Math.Abs(adjusted[^2].X - targetX) <= CoordinateTolerance) - { - adjusted.RemoveAt(adjusted.Count - 2); - } - - var anchor = adjusted[^2]; - var rebuilt = adjusted.Take(adjusted.Count - 1).ToList(); - if (Math.Abs(anchor.X - targetX) <= CoordinateTolerance) - { - var offset = Math.Max(24d, minLineClearance / 2d); - rebuilt.Add(new ElkPoint - { - X = side == "left" ? targetX - offset : targetX + offset, - Y = anchor.Y, - }); - anchor = rebuilt[^1]; - } - - if (Math.Abs(anchor.Y - slotCoord) > CoordinateTolerance) - { - rebuilt.Add(new ElkPoint { X = anchor.X, Y = slotCoord }); - } - - rebuilt.Add(new ElkPoint { X = targetX, Y = slotCoord }); - return NormalizePath(rebuilt); - } - - var targetY = side == "top" - ? targetNode.Y - : targetNode.Y + targetNode.Height; - while (adjusted.Count >= 3 && Math.Abs(adjusted[^2].Y - targetY) <= CoordinateTolerance) - { - adjusted.RemoveAt(adjusted.Count - 2); - } - - var verticalAnchor = adjusted[^2]; - var verticalRebuilt = adjusted.Take(adjusted.Count - 1).ToList(); - if (Math.Abs(verticalAnchor.Y - targetY) <= CoordinateTolerance) - { - var offset = Math.Max(24d, minLineClearance / 2d); - verticalRebuilt.Add(new ElkPoint - { - X = verticalAnchor.X, - Y = side == "top" ? targetY - offset : targetY + offset, - }); - verticalAnchor = verticalRebuilt[^1]; - } - - if (Math.Abs(verticalAnchor.X - slotCoord) > CoordinateTolerance) - { - verticalRebuilt.Add(new ElkPoint { X = slotCoord, Y = verticalAnchor.Y }); - } - - verticalRebuilt.Add(new ElkPoint { X = slotCoord, Y = targetY }); - return NormalizePath(verticalRebuilt); - } - - private static List NormalizePath(IReadOnlyList path) - { - var deduped = new List(); - foreach (var point in path) - { - if (deduped.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(deduped[^1], point)) - { - deduped.Add(point); - } - } - - if (deduped.Count <= 2) - { - return deduped; - } - - var simplified = new List { deduped[0] }; - for (var i = 1; i < deduped.Count - 1; i++) - { - var previous = simplified[^1]; - var current = deduped[i]; - var next = deduped[i + 1]; - var sameX = Math.Abs(previous.X - current.X) <= CoordinateTolerance - && Math.Abs(current.X - next.X) <= CoordinateTolerance; - var sameY = Math.Abs(previous.Y - current.Y) <= CoordinateTolerance - && Math.Abs(current.Y - next.Y) <= CoordinateTolerance; - if (!sameX && !sameY) - { - simplified.Add(current); - } - } - - simplified.Add(deduped[^1]); - return simplified; - } - - private static HighwayMember CreateMember( - ElkRoutedEdge edge, - int index, - ElkPositionedNode targetNode, - string side) - { - var path = ExtractFullPath(edge); - var endpointCoord = side is "left" or "right" - ? path[^1].Y - : path[^1].X; - return new HighwayMember( - Index: index, - EdgeId: edge.Id, - Edge: edge, - Path: path, - PathLength: ElkEdgeRoutingGeometry.ComputePathLength(edge), - EndpointCoord: endpointCoord); - } - - private static void WriteBackPath(ElkRoutedEdge[] result, int edgeIndex, IReadOnlyList path) - { - var edge = result[edgeIndex]; - result[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 = path[0], - EndPoint = path[^1], - BendPoints = path.Count > 2 - ? path.Skip(1).Take(path.Count - 2).ToArray() - : [], - }, - ], - }; - } - - private static List ExtractFullPath(ElkRoutedEdge edge) - { - 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); - } - - return path; - } - - private readonly record struct HighwayMember( - int Index, - string EdgeId, - ElkRoutedEdge Edge, - List Path, - double PathLength, - double EndpointCoord); - - private readonly record struct HighwayPairMetrics( - bool HasSharedSegment, - bool AllPairsApplicable, - double ShortestSharedRatio); - - private readonly record struct GroupEvaluation( - IReadOnlyList Members, - ElkHighwayDiagnostics Diagnostic); } diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.CollectorNormalization.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.CollectorNormalization.cs new file mode 100644 index 000000000..aa048d468 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.CollectorNormalization.cs @@ -0,0 +1,274 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static double ScoreProtectedCollectorGatewaySourceExitCandidate( + IReadOnlyList path, + ElkPositionedNode sourceNode, + ElkPoint exitReference) + { + var score = 0d; + if (path.Count < 2) + { + return double.PositiveInfinity; + } + + var boundary = path[0]; + var adjacent = path[1]; + var centerX = sourceNode.X + (sourceNode.Width / 2d); + var centerY = sourceNode.Y + (sourceNode.Height / 2d); + var desiredDx = exitReference.X - centerX; + var desiredDy = exitReference.Y - centerY; + var boundaryDx = boundary.X - centerX; + var boundaryDy = boundary.Y - centerY; + var firstDx = adjacent.X - boundary.X; + var firstDy = adjacent.Y - boundary.Y; + const double tolerance = 0.5d; + + var dominantHorizontal = Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.15d && Math.Sign(desiredDx) != 0; + var dominantVertical = Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.15d && Math.Sign(desiredDy) != 0; + if (dominantHorizontal) + { + if (Math.Sign(firstDx) != Math.Sign(desiredDx) || Math.Abs(firstDx) <= tolerance) + { + score += 100_000d; + } + + if (Math.Abs(boundaryDy) > sourceNode.Height * 0.28d) + { + score += 25_000d; + } + + score += Math.Abs(boundaryDy) * 6d; + } + else if (dominantVertical) + { + if (Math.Sign(firstDy) != Math.Sign(desiredDy) || Math.Abs(firstDy) <= tolerance) + { + score += 100_000d; + } + + if (Math.Abs(boundaryDx) > sourceNode.Width * 0.28d) + { + score += 25_000d; + } + + score += Math.Abs(boundaryDx) * 6d; + } + + if (ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, boundary, 8d)) + { + score += 4_000d; + } + + var length = 0d; + for (var i = 1; i < path.Count; i++) + { + length += ElkEdgeRoutingGeometry.ComputeSegmentLength(path[i - 1], path[i]); + } + + return score + + length + + (Math.Max(0, path.Count - 2) * 6d); + } + + private static List NormalizeProtectedCollectorTail( + IReadOnlyList path, + double graphMinY, + double graphMaxY) + { + if (path.Count < 5) + { + return path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + const double corridorTolerance = 8d; + const double coordinateTolerance = 0.5d; + var firstCorridorIndex = -1; + var lastCorridorIndex = -1; + for (var i = 0; i < path.Count; i++) + { + if (path[i].Y < graphMinY - corridorTolerance || path[i].Y > graphMaxY + corridorTolerance) + { + if (firstCorridorIndex < 0) + { + firstCorridorIndex = i; + } + + lastCorridorIndex = i; + } + } + + if (firstCorridorIndex < 0 + || lastCorridorIndex <= firstCorridorIndex + || lastCorridorIndex >= path.Count - 1 + || path[firstCorridorIndex].Y > graphMinY - corridorTolerance) + { + return path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + var desiredDx = path[^1].X - path[0].X; + if (Math.Abs(desiredDx) <= coordinateTolerance) + { + return path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + var preCorridorHorizontalIndex = -1; + for (var i = 1; i < lastCorridorIndex; i++) + { + if (Math.Abs(path[i].Y - path[i + 1].Y) > coordinateTolerance + || Math.Abs(path[i].X - path[i + 1].X) <= coordinateTolerance) + { + continue; + } + + var horizontalDelta = path[i + 1].X - path[i].X; + if (Math.Sign(horizontalDelta) == 0 || Math.Sign(horizontalDelta) == Math.Sign(desiredDx)) + { + continue; + } + + preCorridorHorizontalIndex = i; + break; + } + + if (preCorridorHorizontalIndex >= 0) + { + var rebuiltPrefix = path + .Take(preCorridorHorizontalIndex + 1) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + var rewrittenCorridorY = path[lastCorridorIndex].Y; + var rewrittenReentryPoint = path[lastCorridorIndex + 1]; + + if (Math.Abs(rebuiltPrefix[^1].Y - rewrittenCorridorY) > coordinateTolerance) + { + rebuiltPrefix.Add(new ElkPoint + { + X = rebuiltPrefix[^1].X, + Y = rewrittenCorridorY, + }); + } + + if (Math.Abs(rebuiltPrefix[^1].X - rewrittenReentryPoint.X) > coordinateTolerance) + { + rebuiltPrefix.Add(new ElkPoint + { + X = rewrittenReentryPoint.X, + Y = rewrittenCorridorY, + }); + } + + for (var i = lastCorridorIndex + 1; i < path.Count; i++) + { + rebuiltPrefix.Add(new ElkPoint { X = path[i].X, Y = path[i].Y }); + } + + return NormalizeCollectorPoints(rebuiltPrefix); + } + + var firstHorizontalIndex = -1; + for (var i = firstCorridorIndex; i < lastCorridorIndex; i++) + { + if (Math.Abs(path[i].Y - path[i + 1].Y) <= coordinateTolerance + && Math.Abs(path[i].X - path[i + 1].X) > coordinateTolerance) + { + firstHorizontalIndex = i; + break; + } + } + + if (firstHorizontalIndex < 0) + { + return path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + var firstHorizontalDelta = path[firstHorizontalIndex + 1].X - path[firstHorizontalIndex].X; + if (Math.Sign(firstHorizontalDelta) == 0 || Math.Sign(firstHorizontalDelta) == Math.Sign(desiredDx)) + { + return path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + var rebuilt = path + .Take(firstCorridorIndex + 1) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + var targetCorridorY = path[lastCorridorIndex].Y; + var reentryPoint = path[lastCorridorIndex + 1]; + + if (Math.Abs(rebuilt[^1].Y - targetCorridorY) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint + { + X = rebuilt[^1].X, + Y = targetCorridorY, + }); + } + + if (Math.Abs(rebuilt[^1].X - reentryPoint.X) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint + { + X = reentryPoint.X, + Y = targetCorridorY, + }); + } + + for (var i = lastCorridorIndex + 1; i < path.Count; i++) + { + rebuilt.Add(new ElkPoint { X = path[i].X, Y = path[i].Y }); + } + + return NormalizeCollectorPoints(rebuilt); + } + + private static ElkPoint? BuildOrthogonalCollectorCorner(ElkPoint from, ElkPoint to) + { + const double tolerance = 0.5d; + if (Math.Abs(from.X - to.X) <= tolerance || Math.Abs(from.Y - to.Y) <= tolerance) + { + return null; + } + + return Math.Abs(to.Y - from.Y) >= Math.Abs(to.X - from.X) + ? new ElkPoint { X = from.X, Y = to.Y } + : new ElkPoint { X = to.X, Y = from.Y }; + } + + private static List NormalizeCollectorPoints(IReadOnlyList points) + { + const double coordinateTolerance = 0.5d; + var deduped = new List(); + foreach (var point in points) + { + if (deduped.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(deduped[^1], point)) + { + deduped.Add(point); + } + } + + if (deduped.Count <= 2) + { + return deduped; + } + + var simplified = new List { deduped[0] }; + for (var i = 1; i < deduped.Count - 1; i++) + { + var previous = simplified[^1]; + var current = deduped[i]; + var next = deduped[i + 1]; + var sameX = Math.Abs(previous.X - current.X) <= coordinateTolerance + && Math.Abs(current.X - next.X) <= coordinateTolerance; + var sameY = Math.Abs(previous.Y - current.Y) <= coordinateTolerance + && Math.Abs(current.Y - next.Y) <= coordinateTolerance; + if (!sameX && !sameY) + { + simplified.Add(current); + } + } + + simplified.Add(deduped[^1]); + return simplified; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.Chooser.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.Chooser.cs new file mode 100644 index 000000000..57e32eebf --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.Chooser.cs @@ -0,0 +1,220 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static ElkRoutedEdge[] ApplyGuardedFocusedHardRulePass( + ElkRoutedEdge[] current, + ElkPositionedNode[] nodes, + Func pass) + { + var candidate = pass(current); + return ElkEdgeRoutingScoring.CountBoundarySlotViolations(current, nodes) > 0 + ? ChoosePreferredBoundarySlotRepairLayout(current, candidate, nodes) + : ChoosePreferredHardRuleLayout(current, candidate, nodes); + } + + private static ElkRoutedEdge[] ChoosePreferredBoundarySlotRepairLayout( + ElkRoutedEdge[] baseline, + ElkRoutedEdge[] candidate, + ElkPositionedNode[] nodes) + { + if (ReferenceEquals(candidate, baseline)) + { + return baseline; + } + + var baselineScore = ElkEdgeRoutingScoring.ComputeScore(baseline, nodes); + var baselineRetryState = BuildRetryState( + baselineScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(baseline, nodes).Count + : 0); + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes); + var candidateRetryState = BuildRetryState( + candidateScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count + : 0); + + if (!IsBetterBoundarySlotRepairCandidate( + candidateScore, + candidateRetryState, + baselineScore, + baselineRetryState)) + { + return baseline; + } + + // Boundary-slot repair is staged ahead of other soft cleanups. Once a + // candidate legitimately reduces boundary-slot violations without + // introducing a blocking hard regression, keep it alive so the later + // shared-lane / detour passes can recover any temporary soft tradeoff. + if (candidateRetryState.BoundarySlotViolations < baselineRetryState.BoundarySlotViolations) + { + return candidate; + } + + var retryComparison = CompareRetryStates(candidateRetryState, baselineRetryState); + if (retryComparison < 0) + { + return candidate; + } + + if (retryComparison > 0) + { + return baseline; + } + + if (candidateScore.NodeCrossings != baselineScore.NodeCrossings) + { + return candidateScore.NodeCrossings < baselineScore.NodeCrossings + ? candidate + : baseline; + } + + return candidateScore.Value > baselineScore.Value + ? candidate + : baseline; + } + + private static ElkRoutedEdge[] ChoosePreferredSharedLanePolishLayout( + ElkRoutedEdge[] baseline, + ElkRoutedEdge[] candidate, + ElkPositionedNode[] nodes) + { + if (ReferenceEquals(candidate, baseline)) + { + return baseline; + } + + var baselineScore = ElkEdgeRoutingScoring.ComputeScore(baseline, nodes); + var baselineRetryState = BuildRetryState( + baselineScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(baseline, nodes).Count + : 0); + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes); + var candidateRetryState = BuildRetryState( + candidateScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count + : 0); + + if (!IsBetterSharedLanePolishCandidate( + candidateScore, + candidateRetryState, + baselineScore, + baselineRetryState)) + { + return baseline; + } + + if (candidateRetryState.SharedLaneViolations < baselineRetryState.SharedLaneViolations) + { + return candidate; + } + + var retryComparison = CompareRetryStates(candidateRetryState, baselineRetryState); + if (retryComparison < 0) + { + return candidate; + } + + if (retryComparison > 0) + { + return baseline; + } + + if (candidateScore.NodeCrossings != baselineScore.NodeCrossings) + { + return candidateScore.NodeCrossings < baselineScore.NodeCrossings + ? candidate + : baseline; + } + + return candidateScore.Value > baselineScore.Value + ? candidate + : baseline; + } + + private static bool IsBetterBoundarySlotRepairCandidate( + EdgeRoutingScore candidateScore, + RoutingRetryState candidateRetryState, + EdgeRoutingScore baselineScore, + RoutingRetryState baselineRetryState) + { + if (candidateRetryState.BoundarySlotViolations < baselineRetryState.BoundarySlotViolations) + { + return candidateScore.NodeCrossings <= baselineScore.NodeCrossings + && !HasBlockingBoundarySlotPromotionRegression(candidateRetryState, baselineRetryState); + } + + return IsBetterCandidate(candidateScore, candidateRetryState, baselineScore, baselineRetryState); + } + + private static bool IsBetterSharedLanePolishCandidate( + EdgeRoutingScore candidateScore, + RoutingRetryState candidateRetryState, + EdgeRoutingScore baselineScore, + RoutingRetryState baselineRetryState) + { + if (candidateRetryState.SharedLaneViolations < baselineRetryState.SharedLaneViolations) + { + return candidateScore.NodeCrossings <= baselineScore.NodeCrossings + && !HasBlockingSharedLanePromotionRegression(candidateRetryState, baselineRetryState); + } + + return IsBetterCandidate(candidateScore, candidateRetryState, baselineScore, baselineRetryState); + } + + private static ElkRoutedEdge[] ChoosePreferredHardRuleLayout( + ElkRoutedEdge[] baseline, + ElkRoutedEdge[] candidate, + ElkPositionedNode[] nodes) + { + if (ReferenceEquals(candidate, baseline)) + { + return baseline; + } + + var baselineScore = ElkEdgeRoutingScoring.ComputeScore(baseline, nodes); + var baselineRetryState = BuildRetryState( + baselineScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(baseline, nodes).Count + : 0); + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes); + var candidateRetryState = BuildRetryState( + candidateScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count + : 0); + + if (HasHardRuleRegression(candidateRetryState, baselineRetryState)) + { + return baseline; + } + + var retryComparison = CompareRetryStates(candidateRetryState, baselineRetryState); + if (retryComparison < 0) + { + return candidate; + } + + if (retryComparison > 0) + { + return baseline; + } + + if (candidateScore.NodeCrossings != baselineScore.NodeCrossings) + { + return candidateScore.NodeCrossings < baselineScore.NodeCrossings + ? candidate + : baseline; + } + + return candidateScore.Value > baselineScore.Value + ? candidate + : baseline; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.Detours.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.Detours.cs new file mode 100644 index 000000000..9293ba16f --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.Detours.cs @@ -0,0 +1,207 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static ElkRoutedEdge[] ApplyFinalDetourPolish( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + double minLineClearance, + IReadOnlyCollection? restrictedEdgeIds) + { + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); + var result = edges; + + for (var round = 0; round < 3; round++) + { + var detourSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountExcessiveDetourViolations(result, nodes, detourSeverity, 10); + if (detourSeverity.Count == 0) + { + break; + } + + var currentScore = ElkEdgeRoutingScoring.ComputeScore(result, nodes); + var currentRetryState = BuildRetryState( + currentScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(result, nodes).Count + : 0); + + var improved = false; + foreach (var edgeId in detourSeverity + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Key)) + { + if (restrictedSet is not null && !restrictedSet.Contains(edgeId)) + { + continue; + } + + var focused = (IReadOnlyCollection)[edgeId]; + var candidateEdges = ComposeTransactionalFinalDetourCandidate( + result, + nodes, + minLineClearance, + focused); + candidateEdges = ChoosePreferredHardRuleLayout(result, candidateEdges, nodes); + if (ReferenceEquals(candidateEdges, result)) + { + continue; + } + + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); + var candidateRetryState = BuildRetryState( + candidateScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count + : 0); + + var improvedDetours = candidateRetryState.ExcessiveDetourViolations < currentRetryState.ExcessiveDetourViolations; + if (HasHardRuleRegression(candidateRetryState, currentRetryState) + || (!improvedDetours + && !IsBetterBoundarySlotRepairCandidate( + candidateScore, + candidateRetryState, + currentScore, + currentRetryState))) + { + continue; + } + + result = candidateEdges; + improved = true; + break; + } + + if (!improved) + { + break; + } + } + + return result; + } + + private static bool TryPromoteFinalDetourCandidate( + ElkRoutedEdge[] baselineEdges, + ElkRoutedEdge[] candidateEdges, + ElkPositionedNode[] nodes, + EdgeRoutingScore baselineScore, + RoutingRetryState baselineRetryState, + out ElkRoutedEdge[] promotedEdges) + { + promotedEdges = baselineEdges; + if (ReferenceEquals(candidateEdges, baselineEdges)) + { + return false; + } + + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); + var candidateRetryState = BuildRetryState( + candidateScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count + : 0); + + var improvedDetours = candidateRetryState.ExcessiveDetourViolations < baselineRetryState.ExcessiveDetourViolations; + var improvedGatewaySource = candidateRetryState.GatewaySourceExitViolations < baselineRetryState.GatewaySourceExitViolations; + if (HasHardRuleRegression(candidateRetryState, baselineRetryState) + || (!(improvedDetours || improvedGatewaySource) + && !IsBetterBoundarySlotRepairCandidate( + candidateScore, + candidateRetryState, + baselineScore, + baselineRetryState))) + { + return false; + } + + promotedEdges = candidateEdges; + return true; + } + + private static ElkRoutedEdge[] ComposeTransactionalFinalDetourCandidate( + ElkRoutedEdge[] baseline, + ElkPositionedNode[] nodes, + double minLineClearance, + IReadOnlyCollection focusedEdgeIds) + { + var candidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(baseline, nodes, focusedEdgeIds); + if (ReferenceEquals(candidate, baseline)) + { + return baseline; + } + + candidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(candidate, nodes, minLineClearance, focusedEdgeIds); + candidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(candidate, nodes, minLineClearance, focusedEdgeIds); + candidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(candidate, nodes, minLineClearance, focusedEdgeIds); + candidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(candidate, nodes, minLineClearance, focusedEdgeIds); + candidate = ClampBelowGraphEdges(candidate, nodes, focusedEdgeIds); + candidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(candidate, nodes, minLineClearance, focusedEdgeIds); + candidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(candidate, nodes, minLineClearance, focusedEdgeIds); + candidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(candidate, nodes, minLineClearance, focusedEdgeIds); + candidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(candidate, nodes, focusedEdgeIds); + candidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(candidate, nodes, focusedEdgeIds); + candidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(candidate, nodes, minLineClearance, focusedEdgeIds); + candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( + candidate, + nodes, + minLineClearance, + focusedEdgeIds, + enforceAllNodeEndpoints: true); + candidate = ApplyPostSlotDetourClosure(candidate, nodes, minLineClearance, focusedEdgeIds); + candidate = ApplyLateBoundarySlotRestabilization(candidate, nodes, minLineClearance, focusedEdgeIds); + candidate = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(candidate, nodes, minLineClearance, focusedEdgeIds); + candidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts(candidate, nodes, minLineClearance, focusedEdgeIds); + candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( + candidate, + nodes, + minLineClearance, + focusedEdgeIds, + enforceAllNodeEndpoints: true); + candidate = ApplyPostSlotDetourClosure(candidate, nodes, minLineClearance, focusedEdgeIds); + return candidate; + } + + internal static ElkRoutedEdge[] ApplyPostSlotDetourClosure( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + double minLineClearance, + IReadOnlyCollection? restrictedEdgeIds = null) + { + var candidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(edges, nodes, restrictedEdgeIds); + if (ReferenceEquals(candidate, edges)) + { + return edges; + } + + candidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes); + candidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes); + candidate = ElkEdgePostProcessor.SpreadSourceDepartureJoins(candidate, nodes, minLineClearance, restrictedEdgeIds); + candidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(candidate, nodes, minLineClearance, restrictedEdgeIds); + candidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(candidate, nodes, minLineClearance, restrictedEdgeIds); + candidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(candidate, nodes, minLineClearance, restrictedEdgeIds); + candidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(candidate, nodes, restrictedEdgeIds); + candidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(candidate, nodes, restrictedEdgeIds); + candidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(candidate, nodes, minLineClearance, restrictedEdgeIds); + candidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(candidate, nodes, minLineClearance, restrictedEdgeIds); + candidate = ClampBelowGraphEdges(candidate, nodes, restrictedEdgeIds); + candidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes); + candidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes); + candidate = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(candidate, nodes, minLineClearance, restrictedEdgeIds); + candidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts(candidate, nodes, minLineClearance, restrictedEdgeIds); + candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( + candidate, + nodes, + minLineClearance, + restrictedEdgeIds, + enforceAllNodeEndpoints: true); + + return ElkEdgeRoutingScoring.CountBoundarySlotViolations(edges, nodes) > 0 + ? ChoosePreferredBoundarySlotRepairLayout(edges, candidate, nodes) + : ChoosePreferredHardRuleLayout(edges, candidate, nodes); + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.HybridBaseline.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.HybridBaseline.cs new file mode 100644 index 000000000..95a6b4ee4 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.HybridBaseline.cs @@ -0,0 +1,49 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static ElkRoutedEdge[] ApplyLeanHybridBaselinePostProcessing( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutOptions layoutOptions) + { + var result = ElkEdgePostProcessor.AvoidNodeCrossings(edges, nodes, layoutOptions.Direction); + result = ElkEdgePostProcessor.EliminateDiagonalSegments(result, nodes); + result = ElkEdgePostProcessorSimplify.SimplifyEdgePaths(result, nodes); + result = ElkEdgePostProcessorSimplify.TightenOuterCorridors(result, nodes); + if (HighwayProcessingEnabled) + { + result = ElkEdgeRouterHighway.BreakShortHighways(result, nodes); + } + + var serviceNodes = nodes.Where(n => n.Kind is not "Start" and not "End").ToArray(); + var minLineClearance = serviceNodes.Length > 0 + ? Math.Min(serviceNodes.Average(n => n.Width), serviceNodes.Average(n => n.Height)) / 2d + : 50d; + + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance); + result = RestoreProtectedRepeatCollectorCorridors(result, edges, nodes); + result = ApplyHybridTerminalRuleCleanupRound( + result, + nodes, + layoutOptions.Direction, + minLineClearance); + result = ChoosePreferredBoundarySlotRepairLayout( + result, + ApplyPostSlotDetourClosure(result, nodes, minLineClearance), + nodes); + result = ChoosePreferredBoundarySlotRepairLayout( + result, + ElkEdgePostProcessor.SnapBoundarySlotAssignments( + result, + nodes, + minLineClearance, + enforceAllNodeEndpoints: true), + nodes); + + return result; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.TerminalCleanup.Closure.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.TerminalCleanup.Closure.cs new file mode 100644 index 000000000..d9254711f --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.TerminalCleanup.Closure.cs @@ -0,0 +1,236 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static ElkRoutedEdge[] CloseRemainingTerminalViolations( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance, + IReadOnlyCollection? restrictedEdgeIds) + { + var result = edges; + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); + var maxRounds = restrictedSet switch + { + { Count: > 0 and <= MaxLateRestabilizedClosureFocusEdges } => 1, + { Count: > 0 } => 2, + _ => 4, + }; + ElkLayoutDiagnostics.LogProgress( + $"Terminal closure start: restricted={restrictedEdgeIds?.Count ?? 0} rounds={maxRounds}"); + + for (var round = 0; round < maxRounds; round++) + { + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} pressure scan start"); + var severityByEdgeId = new Dictionary(StringComparer.Ordinal); + var previousHardPressure = + ElkEdgeRoutingScoring.CountBadBoundaryAngles(result, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(result, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(result, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountSharedLaneViolations(result, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountBoundarySlotViolations(result, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountBelowGraphViolations(result, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountUnderNodeViolations(result, nodes, severityByEdgeId, 10); + var previousLengthPressure = 0; + if (previousHardPressure == 0) + { + previousLengthPressure = + ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(result, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountExcessiveDetourViolations(result, nodes, severityByEdgeId, 10); + } + ElkLayoutDiagnostics.LogProgress( + $"Terminal closure round {round + 1} pressure scan done: hard={previousHardPressure} length={previousLengthPressure} severity={severityByEdgeId.Count}"); + + var previousScore = ElkEdgeRoutingScoring.ComputeScore(result, nodes); + var previousRetryState = BuildRetryState( + previousScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(result, nodes).Count + : 0); + ElkLayoutDiagnostics.LogProgress( + $"Terminal closure round {round + 1} retry state ready: {DescribeRetryState(previousRetryState)}"); + if (previousHardPressure == 0 && previousLengthPressure == 0) + { + break; + } + + var focusEdgeIds = severityByEdgeId.Keys + .Where(edgeId => restrictedSet is null || restrictedSet.Contains(edgeId)) + .OrderBy(edgeId => edgeId, StringComparer.Ordinal) + .ToArray(); + if (focusEdgeIds.Length == 0) + { + break; + } + ElkLayoutDiagnostics.LogProgress( + $"Terminal closure round {round + 1} focus ready: count={focusEdgeIds.Length}"); + + var focused = (IReadOnlyCollection)focusEdgeIds; + var candidate = result; + if (previousHardPressure > 0 + && ShouldPreferCompactFocusedTerminalClosure(previousRetryState, focusEdgeIds.Length)) + { + ElkLayoutDiagnostics.LogProgress( + $"Terminal closure round {round + 1} compact hard-pass start"); + candidate = ApplyCompactFocusedTerminalClosure( + candidate, + nodes, + direction, + minLineClearance, + focused); + ElkLayoutDiagnostics.LogProgress( + $"Terminal closure round {round + 1} compact hard-pass complete"); + } + else if (previousHardPressure > 0) + { + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} hard-pass block start"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-boundary"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-source-exit"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateSharedLaneConflicts(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-shared-lanes-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-repeat-collector-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkRepeatCollectorCorridors.SeparateSharedLanes(current, nodes, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repeat-collector-shared-lanes-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadSourceDepartureJoins(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-source-joins-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-mixed-faces-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.ElevateUnderNodeViolations(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after elevate-under-node-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after elevate-repeat-collector-clearance-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(current, nodes, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after prefer-shortest-shortcuts-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ClampBelowGraphEdges(current, nodes, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after clamp-below-graph-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repair-boundary-target-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadTargetApproachJoins(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-target-joins-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-feeder-bands-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(current, nodes, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after finalize-gateway-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.AvoidNodeCrossings(current, nodes, direction, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after avoid-node-crossings-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.ElevateUnderNodeViolations(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after elevate-under-node-2"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-boundary-2"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-source-exit-2"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateSharedLaneConflicts(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-shared-lanes-2"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-repeat-collector-2"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkRepeatCollectorCorridors.SeparateSharedLanes(current, nodes, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repeat-collector-shared-lanes-2"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-boundary-3"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-source-exit-3"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadSourceDepartureJoins(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-source-joins-2"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-mixed-faces-2"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repair-boundary-target-2"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadTargetApproachJoins(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-target-joins-2"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-feeder-bands-2"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ClampBelowGraphEdges(current, nodes, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after clamp-below-graph-2"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.AvoidNodeCrossings(current, nodes, direction, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after avoid-node-crossings-2"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.ElevateUnderNodeViolations(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after elevate-under-node-3"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-boundary-4"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-source-exit-4"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadSourceDepartureJoins(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-source-joins-3"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-mixed-faces-3"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateSharedLaneConflicts(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-shared-lanes-3"); + } + else + { + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} length-pass block start"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(current, nodes, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after prefer-shortest-shortcuts-length-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repair-boundary-target-length-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadTargetApproachJoins(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-target-joins-length-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-feeder-bands-length-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeDecisionTargetEntries(current, nodes, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after finalize-decision-targets-length-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(current, nodes, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after finalize-gateway-length-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ClampBelowGraphEdges(current, nodes, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after clamp-below-graph-length-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.AvoidNodeCrossings(current, nodes, direction, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after avoid-node-crossings-length-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-boundary-length-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-source-exit-length-1"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(current, nodes, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after prefer-shortest-shortcuts-length-2"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repair-boundary-target-length-2"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadTargetApproachJoins(current, nodes, minLineClearance, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-target-joins-length-2"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeDecisionTargetEntries(current, nodes, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after finalize-decision-targets-length-2"); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(current, nodes, focused)); + ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after finalize-gateway-length-2"); + } + + var currentHardPressure = + ElkEdgeRoutingScoring.CountBadBoundaryAngles(candidate, nodes) + + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(candidate, nodes) + + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(candidate, nodes) + + ElkEdgeRoutingScoring.CountSharedLaneViolations(candidate, nodes) + + ElkEdgeRoutingScoring.CountBoundarySlotViolations(candidate, nodes) + + ElkEdgeRoutingScoring.CountBelowGraphViolations(candidate, nodes) + + ElkEdgeRoutingScoring.CountUnderNodeViolations(candidate, nodes); + var currentLengthPressure = + ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(candidate, nodes) + + ElkEdgeRoutingScoring.CountExcessiveDetourViolations(candidate, nodes); + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes); + var candidateRetryState = BuildRetryState( + candidateScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count + : 0); + var improvedBoundarySlots = candidateRetryState.BoundarySlotViolations < previousRetryState.BoundarySlotViolations; + var rejectedByRegression = improvedBoundarySlots + ? candidateScore.NodeCrossings > previousScore.NodeCrossings + || HasBlockingBoundarySlotPromotionRegression(candidateRetryState, previousRetryState) + : HasHardRuleRegression(candidateRetryState, previousRetryState); + var madeProgress = improvedBoundarySlots + || (previousHardPressure > 0 + ? currentHardPressure < previousHardPressure + : currentLengthPressure < previousLengthPressure); + if (rejectedByRegression || !madeProgress) + { + break; + } + + result = candidate; + } + + return result; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.TerminalCleanup.Hybrid.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.TerminalCleanup.Hybrid.cs new file mode 100644 index 000000000..44b9a2203 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.TerminalCleanup.Hybrid.cs @@ -0,0 +1,39 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static ElkRoutedEdge[] ApplyHybridTerminalRuleCleanupRound( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance, + IReadOnlyCollection? restrictedEdgeIds = null) + { + var result = edges; + result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkRepeatCollectorCorridors.SeparateSharedLanes(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes, restrictedEdgeIds); + result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.SnapBoundarySlotAssignments( + result, + nodes, + minLineClearance, + restrictedEdgeIds, + enforceAllNodeEndpoints: true); + return result; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.TerminalCleanup.Round.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.TerminalCleanup.Round.cs new file mode 100644 index 000000000..27cf644e9 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.TerminalCleanup.Round.cs @@ -0,0 +1,114 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static ElkRoutedEdge[] ApplyTerminalRuleCleanupRound( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance, + IReadOnlyCollection? restrictedEdgeIds = null) + { + var result = edges; + result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkRepeatCollectorCorridors.SeparateSharedLanes(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes, restrictedEdgeIds); + result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes, restrictedEdgeIds); + result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + // Final late-stage verification: source/target boundary normalization can collapse + // lanes back onto the same node face, so restabilize the local geometry once more. + result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes, restrictedEdgeIds); + result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + // Final hard-rule restabilization after the last normalize pass: the final + // boundary normalization can still pull target slots and horizontal lanes back + // into a bad state, so re-apply the local rule fixers once more before scoring. + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds); + result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = CloseRemainingTerminalViolations(result, nodes, direction, minLineClearance, restrictedEdgeIds); + var lateDetourShortcuts = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(result, nodes, restrictedEdgeIds); + result = ElkEdgeRoutingScoring.CountBoundarySlotViolations(result, nodes) > 0 + ? ChoosePreferredBoundarySlotRepairLayout(result, lateDetourShortcuts, nodes) + : ChoosePreferredHardRuleLayout(result, lateDetourShortcuts, nodes); + result = ApplyFinalDetourPolish(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SnapBoundarySlotAssignments( + result, + nodes, + minLineClearance, + restrictedEdgeIds, + enforceAllNodeEndpoints: true); + result = ApplyPostSlotDetourClosure(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SnapBoundarySlotAssignments( + result, + nodes, + minLineClearance, + restrictedEdgeIds, + enforceAllNodeEndpoints: true); + return result; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.cs index 5178a44bc..859b72a37 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Finalization.cs @@ -9,8 +9,14 @@ internal static partial class ElkEdgeRouterIterative private static ElkRoutedEdge[] ApplyPostProcessing( ElkRoutedEdge[] edges, ElkPositionedNode[] nodes, - ElkLayoutOptions layoutOptions) + ElkLayoutOptions layoutOptions, + bool preferHybridTerminalCleanup = false) { + if (preferHybridTerminalCleanup) + { + return ApplyLeanHybridBaselinePostProcessing(edges, nodes, layoutOptions); + } + var result = ElkEdgePostProcessor.AvoidNodeCrossings(edges, nodes, layoutOptions.Direction); result = ElkEdgePostProcessor.EliminateDiagonalSegments(result, nodes); result = ElkEdgePostProcessorSimplify.SimplifyEdgePaths(result, nodes); @@ -143,11 +149,17 @@ internal static partial class ElkEdgeRouterIterative var retryState = BuildRetryState(score, remainingBrokenHighways); if (retryState.RequiresBlockingRetry || retryState.RequiresLengthRetry) { - var stabilized = ApplyTerminalRuleCleanupRound( - result, - nodes, - layoutOptions.Direction, - minLineClearance); + var stabilized = preferHybridTerminalCleanup + ? ApplyHybridTerminalRuleCleanupRound( + result, + nodes, + layoutOptions.Direction, + minLineClearance) + : ApplyTerminalRuleCleanupRound( + result, + nodes, + layoutOptions.Direction, + minLineClearance); var stabilizedScore = ElkEdgeRoutingScoring.ComputeScore(stabilized, nodes); var stabilizedBrokenHighways = HighwayProcessingEnabled ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(stabilized, nodes).Count @@ -165,760 +177,4 @@ internal static partial class ElkEdgeRouterIterative return result; } - - private static ElkRoutedEdge[] ApplyTerminalRuleCleanupRound( - ElkRoutedEdge[] edges, - ElkPositionedNode[] nodes, - ElkLayoutDirection direction, - double minLineClearance, - IReadOnlyCollection? restrictedEdgeIds = null) - { - var result = edges; - result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(result, nodes, restrictedEdgeIds); - result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); - result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); - result = ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkRepeatCollectorCorridors.SeparateSharedLanes(result, nodes, restrictedEdgeIds); - result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(result, nodes, restrictedEdgeIds); - result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes, restrictedEdgeIds); - result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds); - result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds); - result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); - result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); - result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes, restrictedEdgeIds); - result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds); - result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds); - result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); - result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); - // Final late-stage verification: source/target boundary normalization can collapse - // lanes back onto the same node face, so restabilize the local geometry once more. - result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); - result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds); - result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds); - result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); - result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); - result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); - result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); - result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(result, nodes, restrictedEdgeIds); - result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes, restrictedEdgeIds); - result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds); - result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds); - result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); - result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); - result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); - // Final hard-rule restabilization after the last normalize pass: the final - // boundary normalization can still pull target slots and horizontal lanes back - // into a bad state, so re-apply the local rule fixers once more before scoring. - result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); - result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds); - result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds); - result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds); - result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); - result = CloseRemainingTerminalViolations(result, nodes, direction, minLineClearance, restrictedEdgeIds); - var lateDetourShortcuts = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(result, nodes, restrictedEdgeIds); - result = ElkEdgeRoutingScoring.CountBoundarySlotViolations(result, nodes) > 0 - ? ChoosePreferredBoundarySlotRepairLayout(result, lateDetourShortcuts, nodes) - : ChoosePreferredHardRuleLayout(result, lateDetourShortcuts, nodes); - result = ApplyFinalDetourPolish(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SnapBoundarySlotAssignments( - result, - nodes, - minLineClearance, - restrictedEdgeIds, - enforceAllNodeEndpoints: true); - result = ApplyPostSlotDetourClosure(result, nodes, minLineClearance, restrictedEdgeIds); - result = ElkEdgePostProcessor.SnapBoundarySlotAssignments( - result, - nodes, - minLineClearance, - restrictedEdgeIds, - enforceAllNodeEndpoints: true); - return result; - } - - private static ElkRoutedEdge[] ApplyFinalDetourPolish( - ElkRoutedEdge[] edges, - ElkPositionedNode[] nodes, - double minLineClearance, - IReadOnlyCollection? restrictedEdgeIds) - { - var restrictedSet = restrictedEdgeIds is null - ? null - : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); - var result = edges; - - for (var round = 0; round < 3; round++) - { - var detourSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountExcessiveDetourViolations(result, nodes, detourSeverity, 10); - if (detourSeverity.Count == 0) - { - break; - } - - var currentScore = ElkEdgeRoutingScoring.ComputeScore(result, nodes); - var currentRetryState = BuildRetryState( - currentScore, - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(result, nodes).Count - : 0); - - var improved = false; - foreach (var edgeId in detourSeverity - .OrderByDescending(pair => pair.Value) - .ThenBy(pair => pair.Key, StringComparer.Ordinal) - .Select(pair => pair.Key)) - { - if (restrictedSet is not null && !restrictedSet.Contains(edgeId)) - { - continue; - } - - var focused = (IReadOnlyCollection)[edgeId]; - var candidateEdges = ComposeTransactionalFinalDetourCandidate( - result, - nodes, - minLineClearance, - focused); - candidateEdges = ChoosePreferredHardRuleLayout(result, candidateEdges, nodes); - if (ReferenceEquals(candidateEdges, result)) - { - continue; - } - - var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); - var candidateRetryState = BuildRetryState( - candidateScore, - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count - : 0); - - var improvedDetours = candidateRetryState.ExcessiveDetourViolations < currentRetryState.ExcessiveDetourViolations; - if (HasHardRuleRegression(candidateRetryState, currentRetryState) - || (!improvedDetours - && !IsBetterBoundarySlotRepairCandidate( - candidateScore, - candidateRetryState, - currentScore, - currentRetryState))) - { - continue; - } - - result = candidateEdges; - improved = true; - break; - } - - if (!improved) - { - break; - } - } - - return result; - } - - private static bool TryPromoteFinalDetourCandidate( - ElkRoutedEdge[] baselineEdges, - ElkRoutedEdge[] candidateEdges, - ElkPositionedNode[] nodes, - EdgeRoutingScore baselineScore, - RoutingRetryState baselineRetryState, - out ElkRoutedEdge[] promotedEdges) - { - promotedEdges = baselineEdges; - if (ReferenceEquals(candidateEdges, baselineEdges)) - { - return false; - } - - var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); - var candidateRetryState = BuildRetryState( - candidateScore, - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count - : 0); - - var improvedDetours = candidateRetryState.ExcessiveDetourViolations < baselineRetryState.ExcessiveDetourViolations; - var improvedGatewaySource = candidateRetryState.GatewaySourceExitViolations < baselineRetryState.GatewaySourceExitViolations; - if (HasHardRuleRegression(candidateRetryState, baselineRetryState) - || (!(improvedDetours || improvedGatewaySource) - && !IsBetterBoundarySlotRepairCandidate( - candidateScore, - candidateRetryState, - baselineScore, - baselineRetryState))) - { - return false; - } - - promotedEdges = candidateEdges; - return true; - } - - private static ElkRoutedEdge[] ComposeTransactionalFinalDetourCandidate( - ElkRoutedEdge[] baseline, - ElkPositionedNode[] nodes, - double minLineClearance, - IReadOnlyCollection focusedEdgeIds) - { - var candidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(baseline, nodes, focusedEdgeIds); - if (ReferenceEquals(candidate, baseline)) - { - return baseline; - } - - candidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(candidate, nodes, minLineClearance, focusedEdgeIds); - candidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(candidate, nodes, minLineClearance, focusedEdgeIds); - candidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(candidate, nodes, minLineClearance, focusedEdgeIds); - candidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(candidate, nodes, minLineClearance, focusedEdgeIds); - candidate = ClampBelowGraphEdges(candidate, nodes, focusedEdgeIds); - candidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(candidate, nodes, minLineClearance, focusedEdgeIds); - candidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(candidate, nodes, minLineClearance, focusedEdgeIds); - candidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(candidate, nodes, minLineClearance, focusedEdgeIds); - candidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(candidate, nodes, focusedEdgeIds); - candidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(candidate, nodes, focusedEdgeIds); - candidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(candidate, nodes, minLineClearance, focusedEdgeIds); - candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( - candidate, - nodes, - minLineClearance, - focusedEdgeIds, - enforceAllNodeEndpoints: true); - candidate = ApplyPostSlotDetourClosure(candidate, nodes, minLineClearance, focusedEdgeIds); - candidate = ApplyLateBoundarySlotRestabilization(candidate, nodes, minLineClearance, focusedEdgeIds); - candidate = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(candidate, nodes, minLineClearance, focusedEdgeIds); - candidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts(candidate, nodes, minLineClearance, focusedEdgeIds); - candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( - candidate, - nodes, - minLineClearance, - focusedEdgeIds, - enforceAllNodeEndpoints: true); - candidate = ApplyPostSlotDetourClosure(candidate, nodes, minLineClearance, focusedEdgeIds); - return candidate; - } - - internal static ElkRoutedEdge[] ApplyPostSlotDetourClosure( - ElkRoutedEdge[] edges, - ElkPositionedNode[] nodes, - double minLineClearance, - IReadOnlyCollection? restrictedEdgeIds = null) - { - var candidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(edges, nodes, restrictedEdgeIds); - if (ReferenceEquals(candidate, edges)) - { - return edges; - } - - candidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes); - candidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes); - candidate = ElkEdgePostProcessor.SpreadSourceDepartureJoins(candidate, nodes, minLineClearance, restrictedEdgeIds); - candidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(candidate, nodes, minLineClearance, restrictedEdgeIds); - candidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(candidate, nodes, minLineClearance, restrictedEdgeIds); - candidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(candidate, nodes, minLineClearance, restrictedEdgeIds); - candidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(candidate, nodes, restrictedEdgeIds); - candidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(candidate, nodes, restrictedEdgeIds); - candidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(candidate, nodes, minLineClearance, restrictedEdgeIds); - candidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(candidate, nodes, minLineClearance, restrictedEdgeIds); - candidate = ClampBelowGraphEdges(candidate, nodes, restrictedEdgeIds); - candidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes); - candidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes); - candidate = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(candidate, nodes, minLineClearance, restrictedEdgeIds); - candidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts(candidate, nodes, minLineClearance, restrictedEdgeIds); - candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( - candidate, - nodes, - minLineClearance, - restrictedEdgeIds, - enforceAllNodeEndpoints: true); - - return ElkEdgeRoutingScoring.CountBoundarySlotViolations(edges, nodes) > 0 - ? ChoosePreferredBoundarySlotRepairLayout(edges, candidate, nodes) - : ChoosePreferredHardRuleLayout(edges, candidate, nodes); - } - - private static ElkRoutedEdge[] CloseRemainingTerminalViolations( - ElkRoutedEdge[] edges, - ElkPositionedNode[] nodes, - ElkLayoutDirection direction, - double minLineClearance, - IReadOnlyCollection? restrictedEdgeIds) - { - var result = edges; - var restrictedSet = restrictedEdgeIds is null - ? null - : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); - ElkLayoutDiagnostics.LogProgress( - $"Terminal closure start: restricted={restrictedEdgeIds?.Count ?? 0}"); - - for (var round = 0; round < 4; round++) - { - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} pressure scan start"); - var severityByEdgeId = new Dictionary(StringComparer.Ordinal); - var previousHardPressure = - ElkEdgeRoutingScoring.CountBadBoundaryAngles(result, nodes, severityByEdgeId, 10) - + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(result, nodes, severityByEdgeId, 10) - + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(result, nodes, severityByEdgeId, 10) - + ElkEdgeRoutingScoring.CountSharedLaneViolations(result, nodes, severityByEdgeId, 10) - + ElkEdgeRoutingScoring.CountBoundarySlotViolations(result, nodes, severityByEdgeId, 10) - + ElkEdgeRoutingScoring.CountBelowGraphViolations(result, nodes, severityByEdgeId, 10) - + ElkEdgeRoutingScoring.CountUnderNodeViolations(result, nodes, severityByEdgeId, 10); - var previousLengthPressure = 0; - if (previousHardPressure == 0) - { - previousLengthPressure = - ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(result, nodes, severityByEdgeId, 10) - + ElkEdgeRoutingScoring.CountExcessiveDetourViolations(result, nodes, severityByEdgeId, 10); - } - ElkLayoutDiagnostics.LogProgress( - $"Terminal closure round {round + 1} pressure scan done: hard={previousHardPressure} length={previousLengthPressure} severity={severityByEdgeId.Count}"); - - var previousScore = ElkEdgeRoutingScoring.ComputeScore(result, nodes); - var previousRetryState = BuildRetryState( - previousScore, - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(result, nodes).Count - : 0); - ElkLayoutDiagnostics.LogProgress( - $"Terminal closure round {round + 1} retry state ready: {DescribeRetryState(previousRetryState)}"); - if (previousHardPressure == 0 && previousLengthPressure == 0) - { - break; - } - - var focusEdgeIds = severityByEdgeId.Keys - .Where(edgeId => restrictedSet is null || restrictedSet.Contains(edgeId)) - .OrderBy(edgeId => edgeId, StringComparer.Ordinal) - .ToArray(); - if (focusEdgeIds.Length == 0) - { - break; - } - ElkLayoutDiagnostics.LogProgress( - $"Terminal closure round {round + 1} focus ready: count={focusEdgeIds.Length}"); - - var focused = (IReadOnlyCollection)focusEdgeIds; - var candidate = result; - if (previousHardPressure > 0 - && ShouldPreferCompactFocusedTerminalClosure(previousRetryState, focusEdgeIds.Length)) - { - ElkLayoutDiagnostics.LogProgress( - $"Terminal closure round {round + 1} compact hard-pass start"); - candidate = ApplyCompactFocusedTerminalClosure( - candidate, - nodes, - direction, - minLineClearance, - focused); - ElkLayoutDiagnostics.LogProgress( - $"Terminal closure round {round + 1} compact hard-pass complete"); - } - else if (previousHardPressure > 0) - { - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} hard-pass block start"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-boundary"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-source-exit"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateSharedLaneConflicts(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-shared-lanes-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-repeat-collector-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkRepeatCollectorCorridors.SeparateSharedLanes(current, nodes, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repeat-collector-shared-lanes-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadSourceDepartureJoins(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-source-joins-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-mixed-faces-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.ElevateUnderNodeViolations(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after elevate-under-node-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after elevate-repeat-collector-clearance-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(current, nodes, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after prefer-shortest-shortcuts-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ClampBelowGraphEdges(current, nodes, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after clamp-below-graph-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repair-boundary-target-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadTargetApproachJoins(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-target-joins-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-feeder-bands-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(current, nodes, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after finalize-gateway-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.AvoidNodeCrossings(current, nodes, direction, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after avoid-node-crossings-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.ElevateUnderNodeViolations(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after elevate-under-node-2"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-boundary-2"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-source-exit-2"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateSharedLaneConflicts(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-shared-lanes-2"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-repeat-collector-2"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkRepeatCollectorCorridors.SeparateSharedLanes(current, nodes, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repeat-collector-shared-lanes-2"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-boundary-3"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-source-exit-3"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadSourceDepartureJoins(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-source-joins-2"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-mixed-faces-2"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repair-boundary-target-2"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadTargetApproachJoins(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-target-joins-2"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-feeder-bands-2"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ClampBelowGraphEdges(current, nodes, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after clamp-below-graph-2"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.AvoidNodeCrossings(current, nodes, direction, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after avoid-node-crossings-2"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.ElevateUnderNodeViolations(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after elevate-under-node-3"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-boundary-4"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-source-exit-4"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadSourceDepartureJoins(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-source-joins-3"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-mixed-faces-3"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateSharedLaneConflicts(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-shared-lanes-3"); - } - else - { - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} length-pass block start"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(current, nodes, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after prefer-shortest-shortcuts-length-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repair-boundary-target-length-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadTargetApproachJoins(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-target-joins-length-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-feeder-bands-length-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeDecisionTargetEntries(current, nodes, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after finalize-decision-targets-length-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(current, nodes, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after finalize-gateway-length-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ClampBelowGraphEdges(current, nodes, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after clamp-below-graph-length-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.AvoidNodeCrossings(current, nodes, direction, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after avoid-node-crossings-length-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-boundary-length-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-source-exit-length-1"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(current, nodes, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after prefer-shortest-shortcuts-length-2"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repair-boundary-target-length-2"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadTargetApproachJoins(current, nodes, minLineClearance, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-target-joins-length-2"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeDecisionTargetEntries(current, nodes, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after finalize-decision-targets-length-2"); - candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(current, nodes, focused)); - ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after finalize-gateway-length-2"); - } - - var currentHardPressure = - ElkEdgeRoutingScoring.CountBadBoundaryAngles(candidate, nodes) - + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(candidate, nodes) - + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(candidate, nodes) - + ElkEdgeRoutingScoring.CountSharedLaneViolations(candidate, nodes) - + ElkEdgeRoutingScoring.CountBoundarySlotViolations(candidate, nodes) - + ElkEdgeRoutingScoring.CountBelowGraphViolations(candidate, nodes) - + ElkEdgeRoutingScoring.CountUnderNodeViolations(candidate, nodes); - var currentLengthPressure = - ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(candidate, nodes) - + ElkEdgeRoutingScoring.CountExcessiveDetourViolations(candidate, nodes); - var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes); - var candidateRetryState = BuildRetryState( - candidateScore, - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count - : 0); - var improvedBoundarySlots = candidateRetryState.BoundarySlotViolations < previousRetryState.BoundarySlotViolations; - var rejectedByRegression = improvedBoundarySlots - ? candidateScore.NodeCrossings > previousScore.NodeCrossings - || HasBlockingBoundarySlotPromotionRegression(candidateRetryState, previousRetryState) - : HasHardRuleRegression(candidateRetryState, previousRetryState); - var madeProgress = improvedBoundarySlots - || (previousHardPressure > 0 - ? currentHardPressure < previousHardPressure - : currentLengthPressure < previousLengthPressure); - if (rejectedByRegression || !madeProgress) - { - break; - } - - result = candidate; - } - - return result; - } - - private static ElkRoutedEdge[] ApplyGuardedFocusedHardRulePass( - ElkRoutedEdge[] current, - ElkPositionedNode[] nodes, - Func pass) - { - var candidate = pass(current); - return ElkEdgeRoutingScoring.CountBoundarySlotViolations(current, nodes) > 0 - ? ChoosePreferredBoundarySlotRepairLayout(current, candidate, nodes) - : ChoosePreferredHardRuleLayout(current, candidate, nodes); - } - - private static ElkRoutedEdge[] ChoosePreferredBoundarySlotRepairLayout( - ElkRoutedEdge[] baseline, - ElkRoutedEdge[] candidate, - ElkPositionedNode[] nodes) - { - if (ReferenceEquals(candidate, baseline)) - { - return baseline; - } - - var baselineScore = ElkEdgeRoutingScoring.ComputeScore(baseline, nodes); - var baselineRetryState = BuildRetryState( - baselineScore, - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(baseline, nodes).Count - : 0); - var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes); - var candidateRetryState = BuildRetryState( - candidateScore, - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count - : 0); - - if (!IsBetterBoundarySlotRepairCandidate( - candidateScore, - candidateRetryState, - baselineScore, - baselineRetryState)) - { - return baseline; - } - - // Boundary-slot repair is staged ahead of other soft cleanups. Once a - // candidate legitimately reduces boundary-slot violations without - // introducing a blocking hard regression, keep it alive so the later - // shared-lane / detour passes can recover any temporary soft tradeoff. - if (candidateRetryState.BoundarySlotViolations < baselineRetryState.BoundarySlotViolations) - { - return candidate; - } - - var retryComparison = CompareRetryStates(candidateRetryState, baselineRetryState); - if (retryComparison < 0) - { - return candidate; - } - - if (retryComparison > 0) - { - return baseline; - } - - if (candidateScore.NodeCrossings != baselineScore.NodeCrossings) - { - return candidateScore.NodeCrossings < baselineScore.NodeCrossings - ? candidate - : baseline; - } - - return candidateScore.Value > baselineScore.Value - ? candidate - : baseline; - } - - private static ElkRoutedEdge[] ChoosePreferredSharedLanePolishLayout( - ElkRoutedEdge[] baseline, - ElkRoutedEdge[] candidate, - ElkPositionedNode[] nodes) - { - if (ReferenceEquals(candidate, baseline)) - { - return baseline; - } - - var baselineScore = ElkEdgeRoutingScoring.ComputeScore(baseline, nodes); - var baselineRetryState = BuildRetryState( - baselineScore, - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(baseline, nodes).Count - : 0); - var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes); - var candidateRetryState = BuildRetryState( - candidateScore, - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count - : 0); - - if (!IsBetterSharedLanePolishCandidate( - candidateScore, - candidateRetryState, - baselineScore, - baselineRetryState)) - { - return baseline; - } - - if (candidateRetryState.SharedLaneViolations < baselineRetryState.SharedLaneViolations) - { - return candidate; - } - - var retryComparison = CompareRetryStates(candidateRetryState, baselineRetryState); - if (retryComparison < 0) - { - return candidate; - } - - if (retryComparison > 0) - { - return baseline; - } - - if (candidateScore.NodeCrossings != baselineScore.NodeCrossings) - { - return candidateScore.NodeCrossings < baselineScore.NodeCrossings - ? candidate - : baseline; - } - - return candidateScore.Value > baselineScore.Value - ? candidate - : baseline; - } - - private static bool IsBetterBoundarySlotRepairCandidate( - EdgeRoutingScore candidateScore, - RoutingRetryState candidateRetryState, - EdgeRoutingScore baselineScore, - RoutingRetryState baselineRetryState) - { - if (candidateRetryState.BoundarySlotViolations < baselineRetryState.BoundarySlotViolations) - { - return candidateScore.NodeCrossings <= baselineScore.NodeCrossings - && !HasBlockingBoundarySlotPromotionRegression(candidateRetryState, baselineRetryState); - } - - return IsBetterCandidate(candidateScore, candidateRetryState, baselineScore, baselineRetryState); - } - - private static bool IsBetterSharedLanePolishCandidate( - EdgeRoutingScore candidateScore, - RoutingRetryState candidateRetryState, - EdgeRoutingScore baselineScore, - RoutingRetryState baselineRetryState) - { - if (candidateRetryState.SharedLaneViolations < baselineRetryState.SharedLaneViolations) - { - return candidateScore.NodeCrossings <= baselineScore.NodeCrossings - && !HasBlockingSharedLanePromotionRegression(candidateRetryState, baselineRetryState); - } - - return IsBetterCandidate(candidateScore, candidateRetryState, baselineScore, baselineRetryState); - } - - private static ElkRoutedEdge[] ChoosePreferredHardRuleLayout( - ElkRoutedEdge[] baseline, - ElkRoutedEdge[] candidate, - ElkPositionedNode[] nodes) - { - if (ReferenceEquals(candidate, baseline)) - { - return baseline; - } - - var baselineScore = ElkEdgeRoutingScoring.ComputeScore(baseline, nodes); - var baselineRetryState = BuildRetryState( - baselineScore, - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(baseline, nodes).Count - : 0); - var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes); - var candidateRetryState = BuildRetryState( - candidateScore, - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count - : 0); - - if (HasHardRuleRegression(candidateRetryState, baselineRetryState)) - { - return baseline; - } - - var retryComparison = CompareRetryStates(candidateRetryState, baselineRetryState); - if (retryComparison < 0) - { - return candidate; - } - - if (retryComparison > 0) - { - return baseline; - } - - if (candidateScore.NodeCrossings != baselineScore.NodeCrossings) - { - return candidateScore.NodeCrossings < baselineScore.NodeCrossings - ? candidate - : baseline; - } - - return candidateScore.Value > baselineScore.Value - ? candidate - : baseline; - } - -} \ No newline at end of file +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.GeometryHelpers.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.GeometryHelpers.cs new file mode 100644 index 000000000..9ecb7e989 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.GeometryHelpers.cs @@ -0,0 +1,153 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static List NormalizePolyline(IReadOnlyList points) + { + var result = new List(points.Count); + foreach (var point in points) + { + if (result.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(result[^1], point)) + { + result.Add(point); + } + } + + if (result.Count < 3) + { + return result; + } + + var collapsed = new List { result[0] }; + for (var i = 1; i < result.Count - 1; i++) + { + var previous = collapsed[^1]; + var current = result[i]; + var next = result[i + 1]; + var sameX = Math.Abs(previous.X - current.X) < 0.5d && Math.Abs(current.X - next.X) < 0.5d; + var sameY = Math.Abs(previous.Y - current.Y) < 0.5d && Math.Abs(current.Y - next.Y) < 0.5d; + if (!sameX && !sameY) + { + collapsed.Add(current); + } + } + + collapsed.Add(result[^1]); + return collapsed; + } + + private static void AddUniqueCoordinate(ICollection coordinates, double candidate, double tolerance = 0.5d) + { + foreach (var coordinate in coordinates) + { + if (Math.Abs(coordinate - candidate) <= tolerance) + { + return; + } + } + + coordinates.Add(candidate); + } + + private static (double Left, double Top, double Right, double Bottom, string Id)[] BuildObstacles( + ElkPositionedNode[] nodes, + double margin) + { + return nodes.Select(node => ( + Left: node.X - margin, + Top: node.Y - margin, + Right: node.X + node.Width + margin, + Bottom: node.Y + node.Height + margin, + Id: node.Id + )).ToArray(); + } + + private static ElkRoutedEdge[] ClampBelowGraphEdges( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + IReadOnlyCollection? restrictedEdgeIds = null) + { + if (edges.Length == 0 || nodes.Length == 0) + { + return edges; + } + + var graphMaxY = nodes.Max(node => node.Y + node.Height); + var limitY = graphMaxY + 4d; + var obstacles = BuildObstacles(nodes, 0d); + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); + var result = edges.ToArray(); + + for (var i = 0; i < result.Length; i++) + { + var edge = result[i]; + if (restrictedSet is not null && !restrictedSet.Contains(edge.Id)) + { + continue; + } + + var path = ExtractPath(edge) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (path.Count < 3) + { + continue; + } + + var changed = false; + for (var pointIndex = 1; pointIndex < path.Count - 1; pointIndex++) + { + if (path[pointIndex].Y <= limitY) + { + continue; + } + + path[pointIndex] = new ElkPoint + { + X = path[pointIndex].X, + Y = limitY, + }; + changed = true; + } + + if (!changed) + { + continue; + } + + var normalized = NormalizePolyline(path); + var candidate = 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() + : [], + }, + ], + }; + + if (EdgeCrossesNode(candidate, obstacles)) + { + continue; + } + + result[i] = candidate; + } + + return result; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Hybrid.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Hybrid.cs new file mode 100644 index 000000000..6eff4c991 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.Hybrid.cs @@ -0,0 +1,522 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private readonly record struct HybridRepairBatch( + string[] EdgeIds, + string[] ConflictKeys); + + private static CandidateSolution OptimizeHybrid( + ElkRoutedEdge[] baselineProcessed, + EdgeRoutingScore baselineProcessedScore, + RoutingRetryState baselineRetryState, + ElkPositionedNode[] nodes, + ElkLayoutOptions layoutOptions, + IterativeRoutingConfig config, + double minLineClearance, + CancellationToken cancellationToken) + { + var strategy = BuildHybridStrategy(baselineProcessed, nodes, baselineProcessedScore, baselineRetryState, minLineClearance); + var current = new CandidateSolution(baselineProcessedScore, baselineRetryState, baselineProcessed, 0); + var diagnostics = ElkLayoutDiagnostics.Current; + var attemptCounter = 0; + ElkIterativeStrategyDiagnostics? liveStrategyDiagnostics = null; + if (diagnostics is not null) + { + liveStrategyDiagnostics = new ElkIterativeStrategyDiagnostics + { + StrategyIndex = 1, + OrderingName = "hybrid", + Attempts = 0, + TotalDurationMs = 0d, + BestScore = baselineProcessedScore, + Outcome = $"baseline({DescribeRetryState(baselineRetryState)})", + RegisteredLive = true, + BestEdges = baselineProcessed, + }; + lock (diagnostics.SyncRoot) + { + diagnostics.IterativeStrategies.Add(liveStrategyDiagnostics); + } + + ElkLayoutDiagnostics.FlushSnapshot(diagnostics); + } + ElkLayoutDiagnostics.LogProgress( + $"Hybrid routing start: score={current.Score.Value:F0} retry={DescribeRetryState(current.RetryState)} waves={config.MaxRepairWaves}"); + + for (var wave = 0; wave < config.MaxRepairWaves; wave++) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!current.RetryState.RequiresPrimaryRetry && current.Score.EdgeCrossings == 0) + { + ElkLayoutDiagnostics.LogProgress($"Hybrid routing converged before wave {wave + 1}"); + break; + } + + var repairPlan = BuildRepairPlan( + current.Edges, + nodes, + current.Score, + current.RetryState, + strategy, + wave + 1); + if (repairPlan is null || repairPlan.Value.EdgeIds.Length == 0) + { + ElkLayoutDiagnostics.LogProgress($"Hybrid routing wave {wave + 1}: no repair plan"); + break; + } + + var batches = BuildHybridRepairBatches(current.Edges, nodes, repairPlan.Value); + if (batches.Count == 0) + { + ElkLayoutDiagnostics.LogProgress($"Hybrid routing wave {wave + 1}: no independent repair batches"); + break; + } + + var waveImproved = false; + for (var batchIndex = 0; batchIndex < batches.Count; batchIndex++) + { + cancellationToken.ThrowIfCancellationRequested(); + var batch = batches[batchIndex]; + var batchPlan = BuildHybridBatchPlan(repairPlan.Value, batch.EdgeIds); + if (batchPlan.EdgeIds.Length == 0) + { + continue; + } + + ElkLayoutDiagnostics.LogProgress( + $"Hybrid routing wave {wave + 1} batch {batchIndex + 1}/{batches.Count}: " + + $"edges=[{string.Join(", ", batchPlan.EdgeIds)}] keys=[{string.Join(", ", batch.ConflictKeys)}]"); + + var attemptStopwatch = System.Diagnostics.Stopwatch.StartNew(); + if (!TryApplyHybridRepairBatch( + current, + nodes, + layoutOptions.Direction, + config, + strategy, + batchPlan, + cancellationToken, + out var promoted, + out var attempted, + out var routeDiagnostics)) + { + attemptStopwatch.Stop(); + attemptCounter++; + RecordHybridAttempt( + diagnostics, + liveStrategyDiagnostics, + attemptCounter, + attemptStopwatch.Elapsed.TotalMilliseconds, + attempted.Score, + BuildHybridAttemptOutcome(attempted.RetryState, improved: false), + routeDiagnostics, + attempted.Edges); + continue; + } + + attemptStopwatch.Stop(); + attemptCounter++; + RecordHybridAttempt( + diagnostics, + liveStrategyDiagnostics, + attemptCounter, + attemptStopwatch.Elapsed.TotalMilliseconds, + attempted.Score, + BuildHybridAttemptOutcome(attempted.RetryState, improved: true), + routeDiagnostics, + attempted.Edges); + current = promoted; + waveImproved = true; + } + + if (waveImproved) + { + ElkLayoutDiagnostics.LogProgress( + $"Hybrid routing wave {wave + 1} improved: score={current.Score.Value:F0} retry={DescribeRetryState(current.RetryState)}"); + continue; + } + + if (!current.RetryState.RequiresPrimaryRetry && current.Score.EdgeCrossings == 0) + { + break; + } + + ElkLayoutDiagnostics.LogProgress( + $"Hybrid routing wave {wave + 1} stalled; adapting strategy for retry={DescribeRetryState(current.RetryState)}"); + strategy.AdaptForViolations(current.Score, wave, current.RetryState); + } + + if (config.MaxRepairWaves <= 1 + && (current.RetryState.RequiresPrimaryRetry || current.Score.EdgeCrossings > 0)) + { + var focusedRepairStopwatch = System.Diagnostics.Stopwatch.StartNew(); + var focusedRepair = TryApplyVerifiedIssueRepairRound( + current.Edges, + nodes, + config.ObstacleMargin, + strategy, + current.RetryState, + layoutOptions.Direction, + cancellationToken, + config.MaxParallelRepairBuilds, + trustIndependentParallelBuilds: true, + useHybridCleanup: true); + focusedRepairStopwatch.Stop(); + if (focusedRepair is { } repaired) + { + var improved = IsBetterBoundarySlotRepairCandidate( + repaired.Score, + repaired.RetryState, + current.Score, + current.RetryState); + attemptCounter++; + RecordHybridAttempt( + diagnostics, + liveStrategyDiagnostics, + attemptCounter, + focusedRepairStopwatch.Elapsed.TotalMilliseconds, + repaired.Score, + BuildHybridAttemptOutcome(repaired.RetryState, improved), + repaired.RouteDiagnostics, + repaired.Edges); + if (improved) + { + current = current with + { + Score = repaired.Score, + RetryState = repaired.RetryState, + Edges = repaired.Edges, + }; + ElkLayoutDiagnostics.LogProgress( + $"Hybrid focused post-wave repair improved: score={current.Score.Value:F0} retry={DescribeRetryState(current.RetryState)}"); + } + } + } + + current = RefineHybridWinningSolution( + current, + nodes, + layoutOptions.Direction, + minLineClearance, + preferLowWaveRuntimePolish: config.MaxRepairWaves <= 2); + if (liveStrategyDiagnostics is not null) + { + lock (diagnostics!.SyncRoot) + { + liveStrategyDiagnostics.Attempts = attemptCounter; + liveStrategyDiagnostics.BestScore = current.Score; + liveStrategyDiagnostics.BestEdges = current.Edges; + liveStrategyDiagnostics.Outcome = current.RetryState.RequiresPrimaryRetry + ? $"retry({DescribeRetryState(current.RetryState)})" + : "valid"; + } + + ElkLayoutDiagnostics.FlushSnapshot(diagnostics!); + } + ElkLayoutDiagnostics.LogProgress( + $"Hybrid routing complete: score={current.Score.Value:F0} retry={DescribeRetryState(current.RetryState)}"); + return current; + } + + private static RoutingStrategy BuildHybridStrategy( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + EdgeRoutingScore baselineScore, + RoutingRetryState baselineRetryState, + double minLineClearance) + { + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var connectionCount = new Dictionary(StringComparer.Ordinal); + foreach (var edge in edges) + { + var sourceId = edge.SourceNodeId ?? string.Empty; + var targetId = edge.TargetNodeId ?? string.Empty; + connectionCount[sourceId] = connectionCount.GetValueOrDefault(sourceId) + 1; + connectionCount[targetId] = connectionCount.GetValueOrDefault(targetId) + 1; + } + + var useConnectedOrdering = baselineRetryState.RequiresBlockingRetry + || baselineRetryState.SharedLaneViolations > 0 + || baselineRetryState.TargetApproachJoinViolations > 0; + var edgeOrder = useConnectedOrdering + ? OrderByMostConnectedFirst(edges, connectionCount) + : OrderByLongestFirst(edges, nodesById); + var routingParams = baselineRetryState.RequiresBlockingRetry + ? new AStarRoutingParams(18d, 400d, 600d, 3.0d, minLineClearance, 40d, true) + : new AStarRoutingParams(18d, 200d, 500d, 2.0d, minLineClearance, 40d, true); + var strategy = new RoutingStrategy + { + EdgeOrder = edgeOrder, + BaseLineClearance = minLineClearance, + MinLineClearance = minLineClearance, + RoutingParams = routingParams, + }; + + if (baselineRetryState.RequiresPrimaryRetry || baselineScore.EdgeCrossings > 0) + { + strategy.AdaptForViolations(baselineScore, 0, baselineRetryState); + } + + return strategy; + } + + private static IReadOnlyList BuildHybridRepairBatches( + IReadOnlyCollection edges, + IReadOnlyCollection nodes, + RepairPlan repairPlan) + { + var edgesById = edges.ToDictionary(edge => edge.Id, StringComparer.Ordinal); + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var orderedBatches = new List<(List EdgeIds, HashSet ConflictKeys)>(); + + foreach (var edgeId in repairPlan.EdgeIds) + { + if (!edgesById.TryGetValue(edgeId, out var edge)) + { + continue; + } + + var conflictKeys = GetHybridConflictKeys(edge, nodesById); + var assigned = false; + foreach (var batch in orderedBatches) + { + if (batch.ConflictKeys.Overlaps(conflictKeys)) + { + continue; + } + + batch.EdgeIds.Add(edgeId); + batch.ConflictKeys.UnionWith(conflictKeys); + assigned = true; + break; + } + + if (assigned) + { + continue; + } + + orderedBatches.Add(( + EdgeIds: [edgeId], + ConflictKeys: new HashSet(conflictKeys, StringComparer.Ordinal))); + } + + return orderedBatches + .Select(batch => new HybridRepairBatch( + batch.EdgeIds.ToArray(), + batch.ConflictKeys.OrderBy(key => key, StringComparer.Ordinal).ToArray())) + .ToArray(); + } + + private static string[] GetHybridConflictKeys( + ElkRoutedEdge edge, + IReadOnlyDictionary nodesById) + { + var keys = new HashSet(StringComparer.Ordinal); + var sourceId = edge.SourceNodeId ?? string.Empty; + var targetId = edge.TargetNodeId ?? string.Empty; + if (!string.IsNullOrEmpty(sourceId)) + { + keys.Add($"source:{sourceId}"); + } + + if (!string.IsNullOrEmpty(targetId)) + { + keys.Add($"target:{targetId}"); + } + + if (!string.IsNullOrEmpty(sourceId) && !string.IsNullOrEmpty(targetId)) + { + keys.Add($"pair:{sourceId}->{targetId}"); + } + + var path = ExtractPath(edge); + if (path.Count >= 2 + && nodesById.TryGetValue(sourceId, out var sourceNode)) + { + var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(path[0], path[1], sourceNode); + keys.Add($"source-side:{sourceId}:{sourceSide}"); + if (ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + keys.Add($"gateway-source:{sourceId}"); + } + } + + if (path.Count >= 2 + && nodesById.TryGetValue(targetId, out var targetNode)) + { + var previousPoint = path[^2]; + var targetSide = ResolveEntrySide(path[^1], previousPoint, targetNode); + keys.Add($"target-side:{targetId}:{targetSide}"); + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + keys.Add($"gateway-target:{targetId}"); + } + } + + if (ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label)) + { + keys.Add($"collector:{sourceId}:{targetId}"); + } + + return keys.OrderBy(key => key, StringComparer.Ordinal).ToArray(); + } + + private static RepairPlan BuildHybridBatchPlan(RepairPlan repairPlan, IReadOnlyCollection batchEdgeIds) + { + var batchEdgeSet = batchEdgeIds.ToHashSet(StringComparer.Ordinal); + var edgeIds = repairPlan.EdgeIds.Where(batchEdgeSet.Contains).ToArray(); + var preferredShortestEdgeIds = repairPlan.PreferredShortestEdgeIds.Where(batchEdgeSet.Contains).ToArray(); + var routeRepairEdgeIds = repairPlan.RouteRepairEdgeIds.Where(batchEdgeSet.Contains).ToArray(); + var edgeIndexById = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < repairPlan.EdgeIds.Length && i < repairPlan.EdgeIndices.Length; i++) + { + edgeIndexById[repairPlan.EdgeIds[i]] = repairPlan.EdgeIndices[i]; + } + + var edgeIndices = edgeIds + .Select(edgeId => edgeIndexById.GetValueOrDefault(edgeId, -1)) + .Where(index => index >= 0) + .ToArray(); + + return new RepairPlan( + edgeIndices, + edgeIds, + preferredShortestEdgeIds, + routeRepairEdgeIds, + repairPlan.Reasons); + } + + private static bool TryApplyHybridRepairBatch( + CandidateSolution current, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + IterativeRoutingConfig config, + RoutingStrategy strategy, + RepairPlan repairPlan, + CancellationToken cancellationToken, + out CandidateSolution promoted, + out CandidateSolution attempted, + out ElkIterativeRouteDiagnostics routeDiagnostics) + { + promoted = current; + attempted = current; + var restrictedEdgeIds = repairPlan.EdgeIds; + var routed = RepairPenalizedEdges( + current.Edges, + nodes, + config.ObstacleMargin, + strategy, + repairPlan, + cancellationToken, + config.MaxParallelRepairBuilds, + trustIndependentParallelBuilds: true); + routeDiagnostics = routed.Diagnostics; + var candidateEdges = ApplyHybridTerminalRuleCleanupRound( + routed.Edges, + nodes, + direction, + strategy.MinLineClearance, + restrictedEdgeIds); + candidateEdges = ChoosePreferredHardRuleLayout(current.Edges, candidateEdges, nodes); + + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); + var remainingBrokenHighways = HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count + : 0; + var candidateRetryState = BuildRetryState(candidateScore, remainingBrokenHighways); + + if (candidateRetryState.RequiresBlockingRetry || candidateRetryState.RequiresLengthRetry) + { + var focusedRepair = TryApplyVerifiedIssueRepairRound( + candidateEdges, + nodes, + config.ObstacleMargin, + strategy, + candidateRetryState, + direction, + cancellationToken, + config.MaxParallelRepairBuilds, + trustIndependentParallelBuilds: true, + useHybridCleanup: true); + if (focusedRepair is { } repaired + && IsBetterBoundarySlotRepairCandidate( + repaired.Score, + repaired.RetryState, + candidateScore, + candidateRetryState)) + { + candidateEdges = repaired.Edges; + candidateScore = repaired.Score; + remainingBrokenHighways = repaired.RemainingBrokenHighways; + candidateRetryState = repaired.RetryState; + } + } + + attempted = current with + { + Score = candidateScore, + RetryState = candidateRetryState, + Edges = candidateEdges, + }; + + if (!IsBetterBoundarySlotRepairCandidate( + candidateScore, + candidateRetryState, + current.Score, + current.RetryState)) + { + return false; + } + + promoted = attempted; + return true; + } + + private static void RecordHybridAttempt( + ElkLayoutRunDiagnostics? diagnostics, + ElkIterativeStrategyDiagnostics? liveStrategyDiagnostics, + int attempt, + double durationMs, + EdgeRoutingScore score, + string outcome, + ElkIterativeRouteDiagnostics routeDiagnostics, + ElkRoutedEdge[] edges) + { + if (diagnostics is null || liveStrategyDiagnostics is null) + { + return; + } + + var attemptDiagnostics = new ElkIterativeAttemptDiagnostics + { + Attempt = attempt, + TotalDurationMs = Math.Round(durationMs, 3), + Score = score, + Outcome = outcome, + RouteDiagnostics = routeDiagnostics, + Edges = edges, + }; + + lock (diagnostics.SyncRoot) + { + liveStrategyDiagnostics.Attempts = attempt; + liveStrategyDiagnostics.BestScore = score; + liveStrategyDiagnostics.AttemptDetails.Add(attemptDiagnostics); + } + + ElkLayoutDiagnostics.FlushSnapshot(diagnostics); + } + + private static string BuildHybridAttemptOutcome(RoutingRetryState retryState, bool improved) + { + if (!retryState.RequiresPrimaryRetry) + { + return improved ? "valid" : "rejected-valid"; + } + + var retry = $"retry({DescribeRetryState(retryState)})"; + return improved ? retry : $"rejected-{retry}"; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.Backtracking.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.Backtracking.cs new file mode 100644 index 000000000..008eb306f --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.Backtracking.cs @@ -0,0 +1,303 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static bool HasTargetApproachBacktracking( + IReadOnlyList path, + ElkPositionedNode targetNode) + { + if (path.Count < 3) + { + return false; + } + + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + return HasGatewayTargetApproachBacktracking(path, targetNode); + } + + var side = ElkEdgeRoutingGeometry.ResolveBoundarySide(path[^1], targetNode); + if (side is not "left" and not "right" and not "top" and not "bottom") + { + return false; + } + + const double tolerance = 0.5d; + if (HasShortOrthogonalTargetHook(path, targetNode, side, tolerance)) + { + return true; + } + + var startIndex = Math.Max( + 0, + path.Count - (side is "left" or "right" ? 4 : 3)); + var axisValues = new List(path.Count - startIndex); + for (var i = startIndex; i < path.Count; i++) + { + var value = side is "left" or "right" + ? path[i].X + : path[i].Y; + if (axisValues.Count == 0 || Math.Abs(axisValues[^1] - value) > tolerance) + { + axisValues.Add(value); + } + } + + if (axisValues.Count < 3) + { + return false; + } + + var targetAxis = side switch + { + "left" => targetNode.X, + "right" => targetNode.X + targetNode.Width, + "top" => targetNode.Y, + "bottom" => targetNode.Y + targetNode.Height, + _ => double.NaN, + }; + + var overshootsTargetSide = side switch + { + "left" or "top" => axisValues.Any(value => value > targetAxis + tolerance), + "right" or "bottom" => axisValues.Any(value => value < targetAxis - tolerance), + _ => false, + }; + if (overshootsTargetSide) + { + return true; + } + + var expectsIncreasing = side is "left" or "top"; + var sawProgress = false; + for (var i = 1; i < axisValues.Count; i++) + { + var delta = axisValues[i] - axisValues[i - 1]; + if (Math.Abs(delta) <= tolerance) + { + continue; + } + + if (expectsIncreasing) + { + if (delta > tolerance) + { + sawProgress = true; + } + else if (sawProgress) + { + return true; + } + } + else + { + if (delta < -tolerance) + { + sawProgress = true; + } + else if (sawProgress) + { + return true; + } + } + } + + return false; + } + + private static bool HasGatewayTargetApproachBacktracking( + IReadOnlyList path, + ElkPositionedNode targetNode) + { + if (path.Count < 4) + { + return false; + } + + if (HasShortGatewayTargetOrthogonalHook(path, targetNode)) + { + return true; + } + + const double tolerance = 0.5d; + var startIndex = Math.Max(0, path.Count - 4); + var nearEnd = path.Skip(startIndex).ToArray(); + if (nearEnd.Length < 3) + { + return false; + } + + var orthStart = nearEnd[^2]; + var orthPrev = nearEnd.Length >= 4 + ? nearEnd[^3] + : nearEnd[0]; + var horizontalApproach = Math.Abs(orthPrev.Y - orthStart.Y) <= tolerance + && Math.Abs(orthPrev.X - orthStart.X) > tolerance; + var verticalApproach = Math.Abs(orthPrev.X - orthStart.X) <= tolerance + && Math.Abs(orthPrev.Y - orthStart.Y) > tolerance; + if (!horizontalApproach && !verticalApproach) + { + return false; + } + + var axisValues = new List(nearEnd.Length); + foreach (var point in nearEnd) + { + var value = horizontalApproach + ? point.X + : point.Y; + if (axisValues.Count == 0 || Math.Abs(axisValues[^1] - value) > tolerance) + { + axisValues.Add(value); + } + } + + if (axisValues.Count < 3) + { + return false; + } + + var targetAxis = horizontalApproach + ? nearEnd[^1].X + : nearEnd[^1].Y; + var previousDistance = Math.Abs(axisValues[0] - targetAxis); + var sawProgress = false; + for (var i = 1; i < axisValues.Count; i++) + { + var currentDistance = Math.Abs(axisValues[i] - targetAxis); + if (currentDistance + tolerance < previousDistance) + { + sawProgress = true; + } + else if (sawProgress && currentDistance > previousDistance + tolerance) + { + return true; + } + + previousDistance = currentDistance; + } + + return false; + } + + private static bool HasShortGatewayTargetOrthogonalHook( + 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; + } + + var predecessor = path[^3]; + var predecessorDx = Math.Abs(exteriorPoint.X - predecessor.X); + var predecessorDy = Math.Abs(exteriorPoint.Y - predecessor.Y); + const double minimumApproachSpan = 24d; + return finalIsHorizontal + ? predecessorDy >= minimumApproachSpan && predecessorDy > predecessorDx * 3d + : predecessorDx >= minimumApproachSpan && predecessorDx > predecessorDy * 3d; + } + + private static bool HasShortOrthogonalTargetHook( + IReadOnlyList path, + ElkPositionedNode targetNode, + string side, + double tolerance) + { + if (path.Count < 3) + { + return false; + } + + var boundaryPoint = path[^1]; + var runStartIndex = path.Count - 2; + if (side is "left" or "right") + { + while (runStartIndex > 0 && Math.Abs(path[runStartIndex - 1].Y - boundaryPoint.Y) <= tolerance) + { + runStartIndex--; + } + } + else + { + while (runStartIndex > 0 && Math.Abs(path[runStartIndex - 1].X - boundaryPoint.X) <= tolerance) + { + runStartIndex--; + } + } + + if (runStartIndex == 0) + { + return false; + } + + var overallDeltaX = path[^1].X - path[0].X; + var overallDeltaY = path[^1].Y - path[0].Y; + var overallAbsDx = Math.Abs(overallDeltaX); + var overallAbsDy = Math.Abs(overallDeltaY); + var sameRowThreshold = Math.Max(24d, targetNode.Height / 3d); + var sameColumnThreshold = Math.Max(24d, targetNode.Width / 3d); + var looksHorizontal = overallAbsDx >= overallAbsDy * 1.15d + && overallAbsDy <= sameRowThreshold + && Math.Sign(overallDeltaX) != 0; + var looksVertical = overallAbsDy >= overallAbsDx * 1.15d + && overallAbsDx <= sameColumnThreshold + && Math.Sign(overallDeltaY) != 0; + var contradictsDominantApproach = side switch + { + "left" or "right" => looksVertical, + "top" or "bottom" => looksHorizontal, + _ => false, + }; + if (!contradictsDominantApproach) + { + return false; + } + + var runStart = path[runStartIndex]; + var boundaryDepth = side is "left" or "right" + ? Math.Abs(boundaryPoint.X - runStart.X) + : Math.Abs(boundaryPoint.Y - runStart.Y); + var requiredDepth = side is "left" or "right" + ? targetNode.Width + : targetNode.Height; + if (boundaryDepth + tolerance >= requiredDepth) + { + return false; + } + + var predecessor = path[runStartIndex - 1]; + var predecessorDx = Math.Abs(runStart.X - predecessor.X); + var predecessorDy = Math.Abs(runStart.Y - predecessor.Y); + return side switch + { + "left" or "right" => predecessorDy > predecessorDx * 3d, + "top" or "bottom" => predecessorDx > predecessorDy * 3d, + _ => false, + }; + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.CollectorRestoration.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.CollectorRestoration.cs new file mode 100644 index 000000000..6e5056951 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.CollectorRestoration.cs @@ -0,0 +1,278 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static ElkRoutedEdge[] RestoreProtectedRepeatCollectorCorridors( + ElkRoutedEdge[] candidateEdges, + ElkRoutedEdge[] referenceEdges, + ElkPositionedNode[] nodes) + { + if (candidateEdges.Length == 0 || referenceEdges.Length == 0 || nodes.Length == 0) + { + return candidateEdges; + } + + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + var minLineClearance = ResolveMinLineClearance(nodes); + var obstacles = BuildObstacles(nodes, 0d); + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var restoredIds = new List(); + var result = candidateEdges.ToArray(); + + for (var i = 0; i < result.Length && i < referenceEdges.Length; i++) + { + var reference = referenceEdges[i]; + if (!ElkEdgePostProcessor.IsRepeatCollectorLabel(reference.Label) + || !ElkEdgePostProcessor.HasCorridorBendPoints(reference, graphMinY, graphMaxY) + || ElkEdgePostProcessor.HasCorridorBendPoints(result[i], graphMinY, graphMaxY) + || EdgeCrossesNode(reference, obstacles) + || EdgeViolatesNodeClearance(reference, nodes, minLineClearance)) + { + continue; + } + + var restored = reference; + if (nodesById.TryGetValue(reference.SourceNodeId ?? string.Empty, out var sourceNode) + && ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + restored = AlignProtectedCollectorGatewaySourceExit(restored, sourceNode, graphMinY, graphMaxY); + } + + result[i] = restored; + restoredIds.Add(restored.Id); + } + + return restoredIds.Count > 0 + ? ElkRepeatCollectorCorridors.SeparateSharedLanes(result, nodes, restoredIds) + : result; + } + + private static bool EdgeViolatesNodeClearance( + ElkRoutedEdge edge, + IReadOnlyCollection nodes, + double minLineClearance) + { + if (minLineClearance <= 0d) + { + return false; + } + + foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge)) + { + var horizontal = Math.Abs(segment.Start.Y - segment.End.Y) <= 0.5d; + var vertical = Math.Abs(segment.Start.X - segment.End.X) <= 0.5d; + if (!horizontal && !vertical) + { + continue; + } + + foreach (var node in nodes) + { + if (node.Id == edge.SourceNodeId || node.Id == edge.TargetNodeId) + { + continue; + } + + if (horizontal) + { + var minX = Math.Min(segment.Start.X, segment.End.X); + var maxX = Math.Max(segment.Start.X, segment.End.X); + if (maxX <= node.X || minX >= node.X + node.Width) + { + continue; + } + + var distance = Math.Min( + Math.Abs(segment.Start.Y - node.Y), + Math.Abs(segment.Start.Y - (node.Y + node.Height))); + if (distance > 0.5d && distance < minLineClearance) + { + return true; + } + + continue; + } + + var minY = Math.Min(segment.Start.Y, segment.End.Y); + var maxY = Math.Max(segment.Start.Y, segment.End.Y); + if (maxY <= node.Y || minY >= node.Y + node.Height) + { + continue; + } + + var horizontalDistance = Math.Min( + Math.Abs(segment.Start.X - node.X), + Math.Abs(segment.Start.X - (node.X + node.Width))); + if (horizontalDistance > 0.5d && horizontalDistance < minLineClearance) + { + return true; + } + } + } + + return false; + } + + private static bool EdgeCrossesNode( + ElkRoutedEdge edge, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles) + { + foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge)) + { + if (ElkEdgePostProcessor.SegmentCrossesObstacle( + segment.Start, + segment.End, + obstacles, + edge.SourceNodeId, + edge.TargetNodeId)) + { + return true; + } + } + + return false; + } + + private static ElkRoutedEdge AlignProtectedCollectorGatewaySourceExit( + ElkRoutedEdge edge, + ElkPositionedNode sourceNode, + double graphMinY, + double graphMaxY) + { + 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) + { + return edge; + } + + var corridorIndex = 1; + for (var i = 1; i < path.Count; i++) + { + if (path[i].Y < graphMinY - 8d || path[i].Y > graphMaxY + 8d) + { + corridorIndex = i; + break; + } + } + + var corridorPoint = path[corridorIndex]; + var boundaryReferences = sourceNode.Kind == "Decision" + ? new[] + { + (BoundaryReference: path[^1], ExitReference: path[^1]), + (BoundaryReference: corridorPoint, ExitReference: corridorPoint), + (BoundaryReference: corridorPoint, ExitReference: path[^1]), + } + : new[] + { + (BoundaryReference: corridorPoint, ExitReference: corridorPoint), + }; + + List? rebuilt = null; + var bestScore = double.PositiveInfinity; + foreach (var (boundaryReference, exitReference) in boundaryReferences) + { + var boundary = ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, boundaryReference); + boundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(sourceNode, boundary, boundaryReference); + var exteriorApproach = ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, boundary, exitReference); + var desiredExitDx = exitReference.X - boundary.X; + var desiredExitDy = exitReference.Y - boundary.Y; + if (Math.Abs(desiredExitDx) >= Math.Abs(desiredExitDy) * 1.15d && Math.Sign(desiredExitDx) != 0) + { + exteriorApproach = new ElkPoint + { + X = desiredExitDx > 0d + ? sourceNode.X + sourceNode.Width + 8d + : sourceNode.X - 8d, + Y = boundary.Y, + }; + } + else if (Math.Abs(desiredExitDy) >= Math.Abs(desiredExitDx) * 1.15d && Math.Sign(desiredExitDy) != 0) + { + exteriorApproach = new ElkPoint + { + X = boundary.X, + Y = desiredExitDy > 0d + ? sourceNode.Y + sourceNode.Height + 8d + : sourceNode.Y - 8d, + }; + } + + var candidate = new List { boundary }; + if (!ElkEdgeRoutingGeometry.PointsEqual(boundary, exteriorApproach)) + { + candidate.Add(exteriorApproach); + } + + if (!ElkEdgeRoutingGeometry.PointsEqual(candidate[^1], corridorPoint)) + { + var corner = BuildOrthogonalCollectorCorner(candidate[^1], corridorPoint); + if (corner is not null && !ElkEdgeRoutingGeometry.PointsEqual(candidate[^1], corner)) + { + candidate.Add(corner); + } + + if (!ElkEdgeRoutingGeometry.PointsEqual(candidate[^1], corridorPoint)) + { + candidate.Add(corridorPoint); + } + } + + for (var i = corridorIndex + 1; i < path.Count; i++) + { + candidate.Add(path[i]); + } + + candidate = NormalizeProtectedCollectorTail(candidate, graphMinY, graphMaxY); + var score = ScoreProtectedCollectorGatewaySourceExitCandidate(candidate, sourceNode, exitReference); + if (score >= bestScore) + { + continue; + } + + bestScore = score; + rebuilt = candidate; + } + + rebuilt ??= NormalizeProtectedCollectorTail(path, graphMinY, graphMaxY); + + return 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 = rebuilt[0], + EndPoint = rebuilt[^1], + BendPoints = rebuilt.Count > 2 + ? rebuilt.Skip(1).Take(rebuilt.Count - 2).ToArray() + : [], + }, + ], + }; + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.Eligibility.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.Eligibility.cs new file mode 100644 index 000000000..e04ab71c1 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.Eligibility.cs @@ -0,0 +1,270 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static bool CanRepairEdgeLocally( + ElkRoutedEdge edge, + IReadOnlyCollection nodes, + double graphMinY, + double graphMaxY) + { + if (ShouldRouteEdge(edge, graphMinY, graphMaxY)) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(edge.SourcePortId) || !string.IsNullOrWhiteSpace(edge.TargetPortId)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(edge.Kind) + && edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label)) + { + return false; + } + + return ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY) + && HasClearOrthogonalShortcut(edge, nodes); + } + + private static bool CanRouteSelectedRepairEdge( + ElkRoutedEdge edge, + double graphMinY, + double graphMaxY, + IReadOnlySet routeRepairEdgeIds) + { + if (ShouldRouteEdge(edge, graphMinY, graphMaxY)) + { + return true; + } + + if (!routeRepairEdgeIds.Contains(edge.Id)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(edge.SourcePortId) || !string.IsNullOrWhiteSpace(edge.TargetPortId)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(edge.Kind) + && edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label) + && !ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY); + } + + private static bool HasClearOrthogonalShortcut( + ElkRoutedEdge edge, + IReadOnlyCollection nodes) + { + var firstSection = edge.Sections.FirstOrDefault(); + var lastSection = edge.Sections.LastOrDefault(); + if (firstSection is null || lastSection is null) + { + return false; + } + + var start = firstSection.StartPoint; + var end = lastSection.EndPoint; + var obstacles = nodes.Select(node => ( + Left: node.X, + Top: node.Y, + Right: node.X + node.Width, + Bottom: node.Y + node.Height, + Id: node.Id)).ToArray(); + + bool SegmentIsClear(ElkPoint from, ElkPoint to) => + !ElkEdgePostProcessor.SegmentCrossesObstacle( + from, + to, + obstacles, + edge.SourceNodeId, + edge.TargetNodeId); + + if (Math.Abs(start.X - end.X) < 2d || Math.Abs(start.Y - end.Y) < 2d) + { + return SegmentIsClear(start, end); + } + + var horizontalThenVertical = new ElkPoint { X = end.X, Y = start.Y }; + if (SegmentIsClear(start, horizontalThenVertical) && SegmentIsClear(horizontalThenVertical, end)) + { + return true; + } + + var verticalThenHorizontal = new ElkPoint { X = start.X, Y = end.Y }; + return SegmentIsClear(start, verticalThenHorizontal) + && SegmentIsClear(verticalThenHorizontal, end); + } + + private static int CountRepeatCollectorNodeClearanceViolations( + IReadOnlyCollection edges, + IReadOnlyCollection nodes, + Dictionary? severityByEdgeId, + int severityWeight = 1) + { + if (edges.Count == 0 || nodes.Count == 0) + { + return 0; + } + + var serviceNodes = nodes.Where(node => node.Kind is not "Start" and not "End").ToArray(); + var minClearance = serviceNodes.Length > 0 + ? Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d + : 50d; + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + var count = 0; + + foreach (var edge in edges) + { + if (!ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label)) + { + continue; + } + + var edgeViolations = 0; + foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge)) + { + var horizontal = Math.Abs(segment.Start.Y - segment.End.Y) < 2d; + var vertical = Math.Abs(segment.Start.X - segment.End.X) < 2d; + if (!horizontal && !vertical) + { + continue; + } + + foreach (var node in nodes) + { + if (node.Id == edge.SourceNodeId || node.Id == edge.TargetNodeId) + { + continue; + } + + if (horizontal) + { + var overlapX = Math.Max(segment.Start.X, segment.End.X) > node.X + && Math.Min(segment.Start.X, segment.End.X) < node.X + node.Width; + if (!overlapX) + { + continue; + } + + var distance = Math.Min( + Math.Abs(segment.Start.Y - node.Y), + Math.Abs(segment.Start.Y - (node.Y + node.Height))); + if (distance > 0.5d && distance < minClearance) + { + edgeViolations++; + } + } + else + { + var overlapY = Math.Max(segment.Start.Y, segment.End.Y) > node.Y + && Math.Min(segment.Start.Y, segment.End.Y) < node.Y + node.Height; + if (!overlapY) + { + continue; + } + + var distance = Math.Min( + Math.Abs(segment.Start.X - node.X), + Math.Abs(segment.Start.X - (node.X + node.Width))); + if (distance > 0.5d && distance < minClearance) + { + edgeViolations++; + } + } + } + } + + if (edgeViolations <= 0) + { + continue; + } + + count += edgeViolations; + if (severityByEdgeId is not null) + { + severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + (edgeViolations * severityWeight); + } + } + + return count; + } + + private static (ElkPoint StartPoint, ElkPoint EndPoint) ResolveRoutingEndpoints( + ElkPoint startPoint, + ElkPoint endPoint, + string? sourceNodeId, + string? targetNodeId, + IReadOnlyDictionary nodesById) + { + var adjustedStart = startPoint; + if (nodesById.TryGetValue(sourceNodeId ?? string.Empty, out var sourceNode) + && ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + adjustedStart = ResolveGatewayRoutingDeparturePoint(sourceNode, endPoint, startPoint); + } + + var adjustedEnd = endPoint; + if (nodesById.TryGetValue(targetNodeId ?? string.Empty, out var targetNode) + && ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + adjustedEnd = ResolveGatewayRoutingApproachPoint(targetNode, adjustedStart, endPoint); + } + + return (adjustedStart, adjustedEnd); + } + + private static ElkPoint ResolveGatewayRoutingBoundary( + ElkPositionedNode node, + ElkPoint referencePoint) + { + var boundary = ElkShapeBoundaries.ProjectOntoShapeBoundary(node, referencePoint); + return ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(node, boundary, referencePoint); + } + + private static ElkPoint ResolveGatewayRoutingDeparturePoint( + ElkPositionedNode node, + ElkPoint referencePoint, + ElkPoint preferredBoundary) + { + var boundary = ElkShapeBoundaries.IsGatewayBoundaryPoint(node, preferredBoundary) + ? ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(node, preferredBoundary, referencePoint) + : ResolveGatewayRoutingBoundary(node, referencePoint); + var exteriorDeparture = ElkShapeBoundaries.BuildGatewayDirectionalExteriorPoint(node, boundary, referencePoint); + return ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(node, exteriorDeparture) + ? boundary + : exteriorDeparture; + } + + private static ElkPoint ResolveGatewayRoutingApproachPoint( + ElkPositionedNode node, + ElkPoint referencePoint, + ElkPoint preferredBoundary) + { + var boundary = ElkShapeBoundaries.IsGatewayBoundaryPoint(node, preferredBoundary) + ? ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(node, preferredBoundary, referencePoint) + : ResolveGatewayRoutingBoundary(node, referencePoint); + var exteriorApproach = ElkShapeBoundaries.BuildGatewayDirectionalExteriorPoint(node, boundary, referencePoint); + return ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(node, exteriorApproach) + ? boundary + : exteriorApproach; + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.Endpoints.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.Endpoints.cs new file mode 100644 index 000000000..da7823856 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.Endpoints.cs @@ -0,0 +1,124 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static Dictionary SpreadTargetEndpoints( + ElkRoutedEdge[] edges, + Dictionary nodesById, + double graphMinY, + double graphMaxY, + double minLineClearance) + { + var result = new Dictionary(StringComparer.Ordinal); + + // Group routable edges by target + entry side + var groups = new Dictionary>(StringComparer.Ordinal); + foreach (var edge in edges) + { + if (!ShouldRouteEdge(edge, graphMinY, graphMaxY)) + { + continue; + } + + var lastSection = edge.Sections.LastOrDefault(); + if (lastSection is null || !nodesById.TryGetValue(edge.TargetNodeId ?? "", out var targetNode)) + { + continue; + } + + var ep = lastSection.EndPoint; + var adjacentPoint = lastSection.BendPoints.LastOrDefault() ?? lastSection.StartPoint; + var side = ResolveEntrySide(ep, adjacentPoint, targetNode); + var key = $"{edge.TargetNodeId}|{side}"; + + if (!groups.TryGetValue(key, out var list)) + { + list = []; + groups[key] = list; + } + + list.Add((edge.Id, ep)); + } + + // For each group with 2+ edges on the same side, spread them + foreach (var (key, group) in groups) + { + if (group.Count < 2) + { + continue; + } + + var parts = key.Split('|'); + if (!nodesById.TryGetValue(parts[0], out var node)) + { + continue; + } + + var side = parts[1]; + var sorted = side is "left" or "right" + ? group.OrderBy(g => g.OrigEnd.Y).ThenBy(g => g.EdgeId, StringComparer.Ordinal).ToList() + : group.OrderBy(g => g.OrigEnd.X).ThenBy(g => g.EdgeId, StringComparer.Ordinal).ToList(); + var assignedSlotCoordinates = ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates(node, side, sorted.Count); + + if (side is "left" or "right") + { + for (var i = 0; i < sorted.Count; i++) + { + result[sorted[i].EdgeId] = new ElkPoint + { + X = sorted[i].OrigEnd.X, + Y = assignedSlotCoordinates[i], + }; + } + } + else + { + for (var i = 0; i < sorted.Count; i++) + { + result[sorted[i].EdgeId] = new ElkPoint + { + X = assignedSlotCoordinates[i], + Y = sorted[i].OrigEnd.Y, + }; + } + } + } + + return result; + } + + private static string ResolveEntrySide(ElkPoint endpoint, ElkPoint adjacentPoint, ElkPositionedNode node) + { + return ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(endpoint, adjacentPoint, node); + } + + private static bool ShouldRouteEdge(ElkRoutedEdge edge, double graphMinY, double graphMaxY) + { + // Skip port-anchored edges (their anchors are fixed) + if (!string.IsNullOrWhiteSpace(edge.SourcePortId) || !string.IsNullOrWhiteSpace(edge.TargetPortId)) + { + return false; + } + + // Skip backward edges (routed through external corridors) + if (!string.IsNullOrWhiteSpace(edge.Kind) + && edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Skip edges with corridor bend points (already routed outside graph bounds) + if (ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY)) + { + return false; + } + + // Skip repeat collector labels + return !ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label); + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.ObstacleSkirt.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.ObstacleSkirt.cs new file mode 100644 index 000000000..470a0000c --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.ObstacleSkirt.cs @@ -0,0 +1,167 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static List? TryBuildLocalObstacleSkirtPath( + ElkPoint start, + ElkPoint end, + IReadOnlyCollection nodes, + string sourceId, + string targetId, + ElkPositionedNode? targetNode, + double obstaclePadding) + { + var obstacles = nodes.Select(node => ( + Left: node.X - obstaclePadding, + Top: node.Y - obstaclePadding, + Right: node.X + node.Width + obstaclePadding, + Bottom: node.Y + node.Height + obstaclePadding, + Id: node.Id)).ToArray(); + List? bestPath = null; + var bestScore = double.MaxValue; + + bool SegmentIsClear(ElkPoint from, ElkPoint to) => + !ElkEdgePostProcessor.SegmentCrossesObstacle(from, to, obstacles, sourceId, targetId); + + void ConsiderCandidate(IReadOnlyList rawCandidate) + { + var candidate = NormalizePolyline(rawCandidate); + if (candidate.Count < 2) + { + return; + } + + for (var i = 1; i < candidate.Count; i++) + { + if (!SegmentIsClear(candidate[i - 1], candidate[i])) + { + return; + } + } + + if (targetNode is not null + && !ElkShapeBoundaries.IsGatewayShape(targetNode) + && HasTargetApproachBacktracking(candidate, targetNode)) + { + return; + } + + var score = ComputePolylineLength(candidate) + (Math.Max(0, candidate.Count - 2) * 4d); + if (score >= bestScore - 0.5d) + { + return; + } + + bestScore = score; + bestPath = candidate; + } + + var horizontalDominant = Math.Abs(end.X - start.X) >= Math.Abs(end.Y - start.Y); + if (horizontalDominant) + { + var targetBridgeX = end.X; + if (targetNode is not null && ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + targetBridgeX = ResolveGatewayRoutingApproachPoint(targetNode, start, end).X; + } + + var minX = Math.Min(start.X, end.X) + 0.5d; + var maxX = Math.Max(start.X, end.X) - 0.5d; + var corridorTop = Math.Min(start.Y, end.Y) - obstaclePadding; + var corridorBottom = Math.Max(start.Y, end.Y) + obstaclePadding; + var bypassYCandidates = new List { start.Y, end.Y }; + foreach (var obstacle in obstacles) + { + if (string.Equals(obstacle.Id, sourceId, StringComparison.Ordinal) + || string.Equals(obstacle.Id, targetId, StringComparison.Ordinal) + || obstacle.Right <= minX + || obstacle.Left >= maxX + || obstacle.Bottom <= corridorTop + || obstacle.Top >= corridorBottom) + { + continue; + } + + AddUniqueCoordinate(bypassYCandidates, obstacle.Top); + AddUniqueCoordinate(bypassYCandidates, obstacle.Bottom); + } + + foreach (var bypassY in bypassYCandidates) + { + ConsiderCandidate( + [ + start, + new ElkPoint { X = start.X, Y = bypassY }, + new ElkPoint { X = targetBridgeX, Y = bypassY }, + end, + ]); + } + } + else + { + var targetBridgeY = end.Y; + if (targetNode is not null && ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + targetBridgeY = ResolveGatewayRoutingApproachPoint(targetNode, start, end).Y; + } + + var minY = Math.Min(start.Y, end.Y) + 0.5d; + var maxY = Math.Max(start.Y, end.Y) - 0.5d; + var corridorLeft = Math.Min(start.X, end.X) - obstaclePadding; + var corridorRight = Math.Max(start.X, end.X) + obstaclePadding; + var bypassXCandidates = new List { start.X, end.X }; + foreach (var obstacle in obstacles) + { + if (string.Equals(obstacle.Id, sourceId, StringComparison.Ordinal) + || string.Equals(obstacle.Id, targetId, StringComparison.Ordinal) + || obstacle.Bottom <= minY + || obstacle.Top >= maxY + || obstacle.Right <= corridorLeft + || obstacle.Left >= corridorRight) + { + continue; + } + + AddUniqueCoordinate(bypassXCandidates, obstacle.Left); + AddUniqueCoordinate(bypassXCandidates, obstacle.Right); + } + + foreach (var bypassX in bypassXCandidates) + { + ConsiderCandidate( + [ + start, + new ElkPoint { X = bypassX, Y = start.Y }, + new ElkPoint { X = bypassX, Y = targetBridgeY }, + end, + ]); + } + } + + return bestPath; + } + + private static double ComputePolylineLength(IReadOnlyList points) + { + var length = 0d; + for (var i = 1; i < points.Count; i++) + { + length += ElkEdgeRoutingGeometry.ComputeSegmentLength(points[i - 1], points[i]); + } + + return length; + } + + private static double ResolveMinLineClearance(IReadOnlyCollection nodes) + { + var serviceNodes = nodes.Where(node => node.Kind is not "Start" and not "End").ToArray(); + return serviceNodes.Length > 0 + ? Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d + : 50d; + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.Ordering.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.Ordering.cs new file mode 100644 index 000000000..0d38e2a9a --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.Ordering.cs @@ -0,0 +1,74 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static int[] OrderByLongestFirst( + ElkRoutedEdge[] edges, + Dictionary nodesById) + { + return Enumerable.Range(0, edges.Length) + .OrderByDescending(i => + { + if (!nodesById.TryGetValue(edges[i].SourceNodeId ?? "", out var s) + || !nodesById.TryGetValue(edges[i].TargetNodeId ?? "", out var t)) + { + return 0d; + } + + var dx = (t.X + t.Width / 2d) - (s.X + s.Width / 2d); + var dy = (t.Y + t.Height / 2d) - (s.Y + s.Height / 2d); + return Math.Sqrt(dx * dx + dy * dy); + }) + .ToArray(); + } + + private static int[] OrderByShortestFirst( + ElkRoutedEdge[] edges, + Dictionary nodesById) + { + return Enumerable.Range(0, edges.Length) + .OrderBy(i => + { + if (!nodesById.TryGetValue(edges[i].SourceNodeId ?? "", out var s) + || !nodesById.TryGetValue(edges[i].TargetNodeId ?? "", out var t)) + { + return 0d; + } + + var dx = (t.X + t.Width / 2d) - (s.X + s.Width / 2d); + var dy = (t.Y + t.Height / 2d) - (s.Y + s.Height / 2d); + return Math.Sqrt(dx * dx + dy * dy); + }) + .ToArray(); + } + + private static int[] OrderByMostConnectedFirst( + ElkRoutedEdge[] edges, + Dictionary connectionCount) + { + return Enumerable.Range(0, edges.Length) + .OrderByDescending(i => + connectionCount.GetValueOrDefault(edges[i].SourceNodeId ?? "") + + connectionCount.GetValueOrDefault(edges[i].TargetNodeId ?? "")) + .ToArray(); + } + + private static int[] OrderByReverse(ElkRoutedEdge[] edges) + { + return Enumerable.Range(0, edges.Length).Reverse().ToArray(); + } + + private static void Shuffle(int[] array, Random rng) + { + for (var i = array.Length - 1; i > 0; i--) + { + var j = rng.Next(i + 1); + (array[i], array[j]) = (array[j], array[i]); + } + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.RepairQuality.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.RepairQuality.cs new file mode 100644 index 000000000..8ed01db73 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.RepairQuality.cs @@ -0,0 +1,121 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static List? ChooseBetterLocalRepairCandidate( + ElkRoutedEdge originalEdge, + IReadOnlyCollection nodes, + List? primaryCandidate, + List? secondaryCandidate) + { + if (primaryCandidate is null || primaryCandidate.Count < 2) + { + return secondaryCandidate; + } + + if (secondaryCandidate is null || secondaryCandidate.Count < 2) + { + return primaryCandidate; + } + + var primaryEdge = BuildCandidateRepairEdge( + originalEdge.SourceNodeId, + originalEdge.TargetNodeId, + null, + null, + primaryCandidate); + var secondaryEdge = BuildCandidateRepairEdge( + originalEdge.SourceNodeId, + originalEdge.TargetNodeId, + null, + null, + secondaryCandidate); + return CompareSingleEdgeRepairQuality(secondaryEdge, primaryEdge, nodes) < 0 + ? secondaryCandidate + : primaryCandidate; + } + + private static ElkRoutedEdge BuildCandidateRepairEdge( + string? sourceNodeId, + string? targetNodeId, + ElkPositionedNode? sourceNode, + ElkPositionedNode? targetNode, + IReadOnlyList path) + { + return new ElkRoutedEdge + { + Id = "__candidate__", + SourceNodeId = sourceNodeId, + TargetNodeId = targetNodeId, + Kind = string.Empty, + Label = string.Empty, + Sections = + [ + new ElkEdgeSection + { + StartPoint = path[0], + EndPoint = path[^1], + BendPoints = path.Count > 2 + ? path.Skip(1).Take(path.Count - 2).ToArray() + : [], + }, + ], + }; + } + + private static int CompareSingleEdgeRepairQuality( + ElkRoutedEdge left, + ElkRoutedEdge right, + IReadOnlyCollection nodes) + { + var leftBlocking = CountSingleEdgeBlockingViolations(left, nodes); + var rightBlocking = CountSingleEdgeBlockingViolations(right, nodes); + if (leftBlocking != rightBlocking) + { + return leftBlocking.CompareTo(rightBlocking); + } + + var leftDetour = ElkEdgeRoutingScoring.CountExcessiveDetourViolations([left], nodes); + var rightDetour = ElkEdgeRoutingScoring.CountExcessiveDetourViolations([right], nodes); + if (leftDetour != rightDetour) + { + return leftDetour.CompareTo(rightDetour); + } + + var leftLength = ComputePolylineLength(ExtractCandidatePath(left)); + var rightLength = ComputePolylineLength(ExtractCandidatePath(right)); + return leftLength.CompareTo(rightLength); + } + + private static int CountSingleEdgeBlockingViolations( + ElkRoutedEdge edge, + IReadOnlyCollection nodes) + { + return ElkEdgeRoutingScoring.CountUnderNodeViolations([edge], nodes) + + ElkEdgeRoutingScoring.CountBelowGraphViolations([edge], nodes) + + ElkEdgeRoutingScoring.CountBadBoundaryAngles([edge], nodes) + + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations([edge], nodes); + } + + private static List ExtractCandidatePath(ElkRoutedEdge edge) + { + 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); + } + + return path; + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.RetryBudgets.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.RetryBudgets.cs new file mode 100644 index 000000000..a46b1d02e --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.RetryBudgets.cs @@ -0,0 +1,140 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static bool ShouldRetryForEdgeCrossings( + RoutingRetryState retryState, + int attempt, + int maxAdaptationsPerStrategy) + { + if (retryState.RequiresPrimaryRetry || retryState.EdgeCrossings <= 0) + { + return false; + } + + return attempt < Math.Max(0, maxAdaptationsPerStrategy - 1); + } + + private static int DetermineAdaptiveAttemptBudget( + RoutingRetryState retryState, + int maxAdaptationsPerStrategy) + { + var boundedMaximum = Math.Clamp(maxAdaptationsPerStrategy, 1, 12); + if (retryState.RequiresBlockingRetry) + { + var complexBlocking = + retryState.UnderNodeViolations > 0 + || retryState.SharedLaneViolations > 0 + || retryState.TargetApproachJoinViolations > 0 + || retryState.GatewaySourceExitViolations > 0; + return Math.Min(boundedMaximum, complexBlocking ? 6 : 5); + } + + if (retryState.RequiresLengthRetry) + { + return Math.Min(boundedMaximum, 4); + } + + if (retryState.RequiresQualityRetry || retryState.EdgeCrossings > 0) + { + return Math.Min(boundedMaximum, 3); + } + + return 1; + } + + private static bool ShouldStopForStagnation(int stagnantAttempts, int attempt, int maxAdaptationsPerStrategy) + { + if (stagnantAttempts <= 0 || attempt < 2) + { + return false; + } + + var stagnationBudget = Math.Min(Math.Max(4, maxAdaptationsPerStrategy / 12), 8); + return stagnantAttempts >= stagnationBudget; + } + + private static bool ShouldRetryForPrimaryViolations( + RoutingRetryState retryState, + int attempt, + int maxAdaptationsPerStrategy) + { + if (!retryState.RequiresPrimaryRetry) + { + return false; + } + + return attempt < Math.Max(0, maxAdaptationsPerStrategy - 1); + } + + private static int DetermineTargetValidSolutionCount( + RoutingRetryState baselineRetryState, + IterativeRoutingConfig config) + { + if (!baselineRetryState.RequiresPrimaryRetry) + { + return config.RequiredValidSolutions; + } + + if (baselineRetryState.RequiresBlockingRetry || baselineRetryState.RequiresLengthRetry) + { + return Math.Min(config.RequiredValidSolutions, 3); + } + + return Math.Min(config.RequiredValidSolutions, 2); + } + + private static int DetermineStrategySearchBudget( + RoutingRetryState baselineRetryState, + IterativeRoutingConfig config) + { + if (!baselineRetryState.RequiresPrimaryRetry) + { + return int.MaxValue; + } + + var severity = baselineRetryState.PrimaryViolationCount + baselineRetryState.EdgeCrossings; + var minimumBudget = baselineRetryState.RequiresBlockingRetry || baselineRetryState.RequiresLengthRetry + ? 8 + : 6; + var severityBudget = severity >= 20 + ? 8 + : severity >= 10 + ? 7 + : 6; + return Math.Min( + OrderingNames.Length, + Math.Max(minimumBudget, severityBudget)); + } + + private static bool ShouldKeepBaselineSolution( + IReadOnlyCollection baselineEdges, + IReadOnlyCollection nodes, + RoutingRetryState baselineRetryState) + { + if (baselineRetryState.RepeatCollectorCorridorViolations > 0) + { + return false; + } + + var hasProtectedEdgeContract = baselineEdges.Any(edge => + !string.IsNullOrWhiteSpace(edge.SourcePortId) + || !string.IsNullOrWhiteSpace(edge.TargetPortId) + || (!string.IsNullOrWhiteSpace(edge.Kind) + && edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase))); + + if (baselineEdges.Count <= 8 + || nodes.Count <= 8 + || hasProtectedEdgeContract) + { + return true; + } + + return !baselineRetryState.RequiresPrimaryRetry && baselineEdges.Count <= 12; + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.Selection.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.Selection.cs new file mode 100644 index 000000000..643788840 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.Selection.cs @@ -0,0 +1,212 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static bool IsBetterCandidate( + EdgeRoutingScore candidate, + RoutingRetryState candidateRetryState, + EdgeRoutingScore best, + RoutingRetryState bestRetryState) + { + if (HasHardRuleRegression(candidateRetryState, bestRetryState)) + { + return false; + } + + var retryComparison = CompareRetryStates(candidateRetryState, bestRetryState); + if (retryComparison != 0) + { + return retryComparison < 0; + } + + if (candidate.NodeCrossings != best.NodeCrossings) + { + return candidate.NodeCrossings < best.NodeCrossings; + } + + return candidate.Value > best.Value; + } + + private static bool HasHardRuleRegression(RoutingRetryState candidate, RoutingRetryState baseline) + { + return candidate.RemainingShortHighways > baseline.RemainingShortHighways + || candidate.RepeatCollectorCorridorViolations > baseline.RepeatCollectorCorridorViolations + || candidate.RepeatCollectorNodeClearanceViolations > baseline.RepeatCollectorNodeClearanceViolations + || candidate.BelowGraphViolations > baseline.BelowGraphViolations + || candidate.UnderNodeViolations > baseline.UnderNodeViolations + || candidate.LongDiagonalViolations > baseline.LongDiagonalViolations + || candidate.EntryAngleViolations > baseline.EntryAngleViolations + || candidate.GatewaySourceExitViolations > baseline.GatewaySourceExitViolations + || candidate.SharedLaneViolations > baseline.SharedLaneViolations + || candidate.BoundarySlotViolations > baseline.BoundarySlotViolations + || candidate.TargetApproachJoinViolations > baseline.TargetApproachJoinViolations + || candidate.TargetApproachBacktrackingViolations > baseline.TargetApproachBacktrackingViolations + || candidate.ExcessiveDetourViolations > baseline.ExcessiveDetourViolations; + } + + private static bool HasBlockingSharedLanePromotionRegression( + RoutingRetryState candidate, + RoutingRetryState baseline) + { + var allowsTemporaryBoundarySlotTrade = + candidate.SharedLaneViolations < baseline.SharedLaneViolations + && baseline.BoundarySlotViolations > 0 + && candidate.BoundarySlotViolations <= baseline.BoundarySlotViolations + 1; + + return candidate.RemainingShortHighways > baseline.RemainingShortHighways + || candidate.RepeatCollectorCorridorViolations > baseline.RepeatCollectorCorridorViolations + || candidate.RepeatCollectorNodeClearanceViolations > baseline.RepeatCollectorNodeClearanceViolations + || candidate.BelowGraphViolations > baseline.BelowGraphViolations + || candidate.UnderNodeViolations > baseline.UnderNodeViolations + || candidate.LongDiagonalViolations > baseline.LongDiagonalViolations + || candidate.EntryAngleViolations > baseline.EntryAngleViolations + || candidate.GatewaySourceExitViolations > baseline.GatewaySourceExitViolations + || candidate.SharedLaneViolations > baseline.SharedLaneViolations + || (!allowsTemporaryBoundarySlotTrade + && candidate.BoundarySlotViolations > baseline.BoundarySlotViolations) + || candidate.TargetApproachJoinViolations > baseline.TargetApproachJoinViolations + || candidate.TargetApproachBacktrackingViolations > baseline.TargetApproachBacktrackingViolations + || candidate.ExcessiveDetourViolations > baseline.ExcessiveDetourViolations; + } + + private static int CompareRetryStates(RoutingRetryState left, RoutingRetryState right) + { + if (left.RemainingShortHighways != right.RemainingShortHighways) + { + return left.RemainingShortHighways.CompareTo(right.RemainingShortHighways); + } + + if (left.RepeatCollectorCorridorViolations != right.RepeatCollectorCorridorViolations) + { + return left.RepeatCollectorCorridorViolations.CompareTo(right.RepeatCollectorCorridorViolations); + } + + if (left.RepeatCollectorNodeClearanceViolations != right.RepeatCollectorNodeClearanceViolations) + { + return left.RepeatCollectorNodeClearanceViolations.CompareTo(right.RepeatCollectorNodeClearanceViolations); + } + + if (left.BelowGraphViolations != right.BelowGraphViolations) + { + return left.BelowGraphViolations.CompareTo(right.BelowGraphViolations); + } + + if (left.UnderNodeViolations != right.UnderNodeViolations) + { + return left.UnderNodeViolations.CompareTo(right.UnderNodeViolations); + } + + if (left.LongDiagonalViolations != right.LongDiagonalViolations) + { + return left.LongDiagonalViolations.CompareTo(right.LongDiagonalViolations); + } + + if (left.EntryAngleViolations != right.EntryAngleViolations) + { + return left.EntryAngleViolations.CompareTo(right.EntryAngleViolations); + } + + if (left.GatewaySourceExitViolations != right.GatewaySourceExitViolations) + { + return left.GatewaySourceExitViolations.CompareTo(right.GatewaySourceExitViolations); + } + + if (left.SharedLaneViolations != right.SharedLaneViolations) + { + return left.SharedLaneViolations.CompareTo(right.SharedLaneViolations); + } + + if (left.BoundarySlotViolations != right.BoundarySlotViolations) + { + return left.BoundarySlotViolations.CompareTo(right.BoundarySlotViolations); + } + + if (left.TargetApproachJoinViolations != right.TargetApproachJoinViolations) + { + return left.TargetApproachJoinViolations.CompareTo(right.TargetApproachJoinViolations); + } + + if (left.TargetApproachBacktrackingViolations != right.TargetApproachBacktrackingViolations) + { + return left.TargetApproachBacktrackingViolations.CompareTo(right.TargetApproachBacktrackingViolations); + } + + if (left.ExcessiveDetourViolations != right.ExcessiveDetourViolations) + { + return left.ExcessiveDetourViolations.CompareTo(right.ExcessiveDetourViolations); + } + + if (left.ProximityViolations != right.ProximityViolations) + { + return left.ProximityViolations.CompareTo(right.ProximityViolations); + } + + if (left.LabelProximityViolations != right.LabelProximityViolations) + { + return left.LabelProximityViolations.CompareTo(right.LabelProximityViolations); + } + + return left.EdgeCrossings.CompareTo(right.EdgeCrossings); + } + + private static CandidateSolution SelectBestValidSolution( + IReadOnlyList solutions) + { + var best = solutions[0]; + for (var i = 1; i < solutions.Count; i++) + { + var candidate = solutions[i]; + if (IsBetterBoundarySlotRepairCandidate( + candidate.Score, + candidate.RetryState, + best.Score, + best.RetryState) + || candidate.Score.Value > best.Score.Value + || (Math.Abs(candidate.Score.Value - best.Score.Value) < 0.001d + && CompareRetryStates(candidate.RetryState, best.RetryState) < 0) + || (Math.Abs(candidate.Score.Value - best.Score.Value) < 0.001d + && CompareRetryStates(candidate.RetryState, best.RetryState) == 0 + && candidate.Score.EdgeCrossings < best.Score.EdgeCrossings)) + { + best = candidate; + } + } + + return best; + } + + private static CandidateSolution SelectBestFallbackSolution( + IReadOnlyList solutions) + { + var best = solutions[0]; + for (var i = 1; i < solutions.Count; i++) + { + var candidate = solutions[i]; + if (IsBetterBoundarySlotRepairCandidate( + candidate.Score, + candidate.RetryState, + best.Score, + best.RetryState) + || candidate.Score.NodeCrossings < best.Score.NodeCrossings + || (candidate.Score.NodeCrossings == best.Score.NodeCrossings + && CompareRetryStates(candidate.RetryState, best.RetryState) < 0) + || (candidate.Score.NodeCrossings == best.Score.NodeCrossings + && CompareRetryStates(candidate.RetryState, best.RetryState) == 0 + && candidate.Score.EdgeCrossings < best.Score.EdgeCrossings) + || (candidate.Score.NodeCrossings == best.Score.NodeCrossings + && CompareRetryStates(candidate.RetryState, best.RetryState) == 0 + && candidate.Score.EdgeCrossings == best.Score.EdgeCrossings + && candidate.Score.Value > best.Score.Value)) + { + best = candidate; + } + } + + return best; + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.ShortestPaths.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.ShortestPaths.cs new file mode 100644 index 000000000..77942bd1f --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.ShortestPaths.cs @@ -0,0 +1,233 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static List? TryBuildPreferredSideShortcut( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + IReadOnlyCollection nodes, + string sourceId, + string targetId) + { + var sourceCenterX = sourceNode.X + (sourceNode.Width / 2d); + var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); + var targetCenterX = targetNode.X + (targetNode.Width / 2d); + var targetCenterY = targetNode.Y + (targetNode.Height / 2d); + var deltaX = targetCenterX - sourceCenterX; + var deltaY = targetCenterY - sourceCenterY; + var absDx = Math.Abs(deltaX); + var absDy = Math.Abs(deltaY); + if (absDx < 16d && absDy < 16d) + { + return null; + } + + var horizontalDominant = absDx >= absDy; + var preferredSourceSide = horizontalDominant + ? deltaX >= 0d ? "right" : "left" + : deltaY >= 0d ? "bottom" : "top"; + var preferredTargetSide = horizontalDominant + ? deltaX >= 0d ? "left" : "right" + : deltaY >= 0d ? "top" : "bottom"; + var start = BuildPreferredBoundaryPoint(sourceNode, preferredSourceSide, targetNode); + var end = BuildPreferredBoundaryPoint(targetNode, preferredTargetSide, sourceNode); + return TryBuildShortestOrthogonalPath(start, end, nodes, sourceId, targetId, targetNode, 0d); + } + + private static ElkPoint BuildPreferredBoundaryPoint( + ElkPositionedNode node, + string side, + ElkPositionedNode otherNode) + { + var horizontalInset = Math.Min(24d, Math.Max(12d, node.Width / 4d)); + var verticalInset = Math.Min(24d, Math.Max(12d, node.Height / 4d)); + var otherCenterX = otherNode.X + (otherNode.Width / 2d); + var otherCenterY = otherNode.Y + (otherNode.Height / 2d); + + var boundary = side switch + { + "left" => new ElkPoint + { + X = node.X, + Y = Math.Clamp(otherCenterY, node.Y + verticalInset, (node.Y + node.Height) - verticalInset), + }, + "right" => new ElkPoint + { + X = node.X + node.Width, + Y = Math.Clamp(otherCenterY, node.Y + verticalInset, (node.Y + node.Height) - verticalInset), + }, + "top" => new ElkPoint + { + X = Math.Clamp(otherCenterX, node.X + horizontalInset, (node.X + node.Width) - horizontalInset), + Y = node.Y, + }, + _ => new ElkPoint + { + X = Math.Clamp(otherCenterX, node.X + horizontalInset, (node.X + node.Width) - horizontalInset), + Y = node.Y + node.Height, + }, + }; + + if (!ElkShapeBoundaries.IsGatewayShape(node)) + { + return boundary; + } + + var referencePoint = side switch + { + "left" => new ElkPoint { X = node.X - Math.Max(24d, node.Width / 3d), Y = boundary.Y }, + "right" => new ElkPoint { X = node.X + node.Width + Math.Max(24d, node.Width / 3d), Y = boundary.Y }, + "top" => new ElkPoint { X = boundary.X, Y = node.Y - Math.Max(24d, node.Height / 3d) }, + _ => new ElkPoint { X = boundary.X, Y = node.Y + node.Height + Math.Max(24d, node.Height / 3d) }, + }; + + var projected = ElkShapeBoundaries.ProjectOntoShapeBoundary(node, referencePoint); + return ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(node, projected, referencePoint); + } + + private static List? TryBuildShortestOrthogonalPath( + ElkPoint start, + ElkPoint end, + IReadOnlyCollection nodes, + string sourceId, + string targetId, + ElkPositionedNode? targetNode, + double obstaclePadding) + { + var rawObstacles = nodes.Select(node => ( + Left: node.X - obstaclePadding, + Top: node.Y - obstaclePadding, + Right: node.X + node.Width + obstaclePadding, + Bottom: node.Y + node.Height + obstaclePadding, + Id: node.Id)).ToArray(); + + bool SegmentIsClear(ElkPoint from, ElkPoint to) => + !ElkEdgePostProcessor.SegmentCrossesObstacle(from, to, rawObstacles, sourceId, targetId); + + if (Math.Abs(start.X - end.X) < 0.5d || Math.Abs(start.Y - end.Y) < 0.5d) + { + return SegmentIsClear(start, end) + ? [start, end] + : null; + } + + foreach (var pivot in EnumerateOrthogonalShortcutPivots(start, end, targetNode)) + { + if (!SegmentIsClear(start, pivot) || !SegmentIsClear(pivot, end)) + { + continue; + } + + return NormalizePolyline([start, pivot, end]); + } + + return null; + } + + private static IEnumerable EnumerateOrthogonalShortcutPivots( + ElkPoint start, + ElkPoint end, + ElkPositionedNode? targetNode) + { + var targetSide = targetNode is null + ? string.Empty + : ElkEdgeRoutingGeometry.ResolveBoundarySide(end, targetNode); + var preferred = targetSide is "left" or "right" + ? new ElkPoint { X = start.X, Y = end.Y } + : new ElkPoint { X = end.X, Y = start.Y }; + var alternate = targetSide is "left" or "right" + ? new ElkPoint { X = end.X, Y = start.Y } + : new ElkPoint { X = start.X, Y = end.Y }; + + yield return preferred; + if (!ElkEdgeRoutingGeometry.PointsEqual(preferred, alternate)) + { + yield return alternate; + } + } + + private static IEnumerable EnumerateShortestRepairEndpoints( + ElkPoint start, + ElkPoint currentEnd, + ElkPositionedNode? targetNode) + { + var endpoints = new List(); + + void AddCandidate(ElkPoint candidate) + { + if (!endpoints.Any(existing => ElkEdgeRoutingGeometry.PointsEqual(existing, candidate))) + { + endpoints.Add(candidate); + } + } + + AddCandidate(currentEnd); + if (targetNode is null) + { + return endpoints; + } + + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + if (ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, "left", start.Y, out var left)) + { + AddCandidate(left); + } + + if (ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, "right", start.Y, out var right)) + { + AddCandidate(right); + } + + if (ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, "top", start.X, out var top)) + { + AddCandidate(top); + } + + if (ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, "bottom", start.X, out var bottom)) + { + AddCandidate(bottom); + } + + return endpoints; + } + + var horizontalInset = Math.Min(24d, Math.Max(12d, targetNode.Width / 4d)); + var verticalInset = Math.Min(24d, Math.Max(12d, targetNode.Height / 4d)); + var candidateEndpoints = new[] + { + new ElkPoint + { + X = targetNode.X, + Y = Math.Clamp(start.Y, targetNode.Y + verticalInset, (targetNode.Y + targetNode.Height) - verticalInset), + }, + new ElkPoint + { + X = targetNode.X + targetNode.Width, + Y = Math.Clamp(start.Y, targetNode.Y + verticalInset, (targetNode.Y + targetNode.Height) - verticalInset), + }, + new ElkPoint + { + X = Math.Clamp(start.X, targetNode.X + horizontalInset, (targetNode.X + targetNode.Width) - horizontalInset), + Y = targetNode.Y, + }, + new ElkPoint + { + X = Math.Clamp(start.X, targetNode.X + horizontalInset, (targetNode.X + targetNode.Width) - horizontalInset), + Y = targetNode.Y + targetNode.Height, + }, + }; + + foreach (var candidate in candidateEndpoints) + { + AddCandidate(candidate); + } + + return endpoints; + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.ShortestRepair.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.ShortestRepair.cs new file mode 100644 index 000000000..697e88423 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.ShortestRepair.cs @@ -0,0 +1,220 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static List? TryRouteShortestRepair( + ElkPoint start, + ElkPoint end, + IReadOnlyCollection nodes, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + string sourceId, + string targetId, + ElkPositionedNode? sourceNode, + ElkPositionedNode? targetNode, + AStarRoutingParams routingParams, + IReadOnlyList softObstacles, + CancellationToken cancellationToken) + { + if (sourceNode is not null && targetNode is not null) + { + if (ElkEdgePostProcessor.TryBuildPreferredBoundaryShortcutPath( + sourceNode, + targetNode, + nodes, + sourceId, + targetId, + out var preferredShortcut) + && (targetNode is null || !HasTargetApproachBacktracking(preferredShortcut, targetNode))) + { + return preferredShortcut; + } + } + + var candidateEndpoints = EnumerateShortestRepairEndpoints(start, end, targetNode).ToArray(); + var minLineClearance = ResolveMinLineClearance(nodes); + var shortcutObstaclePadding = Math.Max(12d, Math.Min(routingParams.Margin, Math.Max(18d, minLineClearance - 4d))); + List? bestPath = null; + var bestLength = double.MaxValue; + + foreach (var candidateEnd in candidateEndpoints) + { + var orthogonalShortcut = TryBuildShortestOrthogonalPath( + start, + candidateEnd, + nodes, + sourceId, + targetId, + targetNode, + shortcutObstaclePadding); + if (orthogonalShortcut is null + || (targetNode is not null && HasTargetApproachBacktracking(orthogonalShortcut, targetNode))) + { + continue; + } + + var shortcutLength = ComputePolylineLength(orthogonalShortcut); + if (shortcutLength < bestLength - 0.5d) + { + bestPath = orthogonalShortcut; + bestLength = shortcutLength; + } + } + + foreach (var candidateEnd in candidateEndpoints) + { + var localSkirtShortcut = TryBuildLocalObstacleSkirtPath( + start, + candidateEnd, + nodes, + sourceId, + targetId, + targetNode, + shortcutObstaclePadding); + if (localSkirtShortcut is null + || (targetNode is not null && HasTargetApproachBacktracking(localSkirtShortcut, targetNode))) + { + continue; + } + + var shortcutLength = ComputePolylineLength(localSkirtShortcut); + if (shortcutLength < bestLength - 0.5d) + { + bestPath = localSkirtShortcut; + bestLength = shortcutLength; + } + } + + if (bestPath is not null) + { + return bestPath; + } + + var shortestParams = routingParams with + { + Margin = Math.Max(shortcutObstaclePadding, Math.Min(routingParams.Margin, minLineClearance)), + BendPenalty = Math.Min(routingParams.BendPenalty, 80d), + DiagonalPenalty = Math.Min(routingParams.DiagonalPenalty, 40d), + SoftObstacleWeight = Math.Max(0.25d, routingParams.SoftObstacleWeight * 0.35d), + SoftObstacleClearance = Math.Max(Math.Max(18d, minLineClearance * 0.6d), routingParams.SoftObstacleClearance * 0.5d), + IntermediateGridSpacing = Math.Max(12d, routingParams.IntermediateGridSpacing - 8d), + }; + var shortestObstacles = nodes + .Select(node => ( + Left: node.X - shortestParams.Margin, + Top: node.Y - shortestParams.Margin, + Right: node.X + node.Width + shortestParams.Margin, + Bottom: node.Y + node.Height + shortestParams.Margin, + Id: node.Id)) + .ToArray(); + + foreach (var candidateEnd in candidateEndpoints) + { + var diagonalPath = ElkEdgeRouterAStar8Dir.Route( + start, + candidateEnd, + shortestObstacles, + sourceId, + targetId, + shortestParams, + [], + cancellationToken); + if (diagonalPath is null + || (targetNode is not null && HasTargetApproachBacktracking(diagonalPath, targetNode))) + { + continue; + } + + var pathLength = ComputePolylineLength(diagonalPath); + if (pathLength < bestLength - 0.5d) + { + bestPath = diagonalPath; + bestLength = pathLength; + } + } + + return bestPath; + } + + private static List? TryRouteAggressiveRepair( + ElkPoint start, + ElkPoint end, + IReadOnlyCollection nodes, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + string sourceId, + string targetId, + ElkPositionedNode? sourceNode, + ElkPositionedNode? targetNode, + AStarRoutingParams routingParams, + CancellationToken cancellationToken) + { + var candidateEndpoints = EnumerateShortestRepairEndpoints(start, end, targetNode).ToArray(); + if (candidateEndpoints.Length == 0) + { + return null; + } + + var minLineClearance = ResolveMinLineClearance(nodes); + var aggressiveParams = routingParams with + { + Margin = Math.Max(10d, Math.Min(routingParams.Margin, Math.Max(14d, minLineClearance * 0.45d))), + BendPenalty = Math.Min(routingParams.BendPenalty, 60d), + DiagonalPenalty = Math.Min(routingParams.DiagonalPenalty, 35d), + SoftObstacleWeight = 0d, + SoftObstacleClearance = 0d, + IntermediateGridSpacing = Math.Max(12d, Math.Min(routingParams.IntermediateGridSpacing, Math.Max(12d, minLineClearance * 0.45d))), + }; + + var aggressiveObstacles = obstacles + .Select(obstacle => ( + Left: obstacle.Left + Math.Min(0d, aggressiveParams.Margin - routingParams.Margin), + Top: obstacle.Top + Math.Min(0d, aggressiveParams.Margin - routingParams.Margin), + Right: obstacle.Right - Math.Min(0d, aggressiveParams.Margin - routingParams.Margin), + Bottom: obstacle.Bottom - Math.Min(0d, aggressiveParams.Margin - routingParams.Margin), + obstacle.Id)) + .ToArray(); + + List? bestPath = null; + ElkRoutedEdge? bestEdge = null; + + foreach (var candidateEnd in candidateEndpoints) + { + var candidate = ElkEdgeRouterAStar8Dir.Route( + start, + candidateEnd, + aggressiveObstacles, + sourceId, + targetId, + aggressiveParams, + [], + cancellationToken); + if (candidate is null) + { + continue; + } + + if (targetNode is not null && HasTargetApproachBacktracking(candidate, targetNode)) + { + continue; + } + + var candidateEdge = BuildCandidateRepairEdge( + sourceId, + targetId, + sourceNode, + targetNode, + candidate); + if (bestPath is null || CompareSingleEdgeRepairQuality(candidateEdge, bestEdge!, nodes) < 0) + { + bestPath = candidate; + bestEdge = candidateEdge; + } + } + + return bestPath; + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.cs index 330ffd5d2..5235cddca 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.LocalRepair.cs @@ -190,1846 +190,4 @@ internal static partial class ElkEdgeRouterIterative : "none"; } - private static bool ShouldRetryForEdgeCrossings( - RoutingRetryState retryState, - int attempt, - int maxAdaptationsPerStrategy) - { - if (retryState.RequiresPrimaryRetry || retryState.EdgeCrossings <= 0) - { - return false; - } - - return attempt < Math.Max(0, maxAdaptationsPerStrategy - 1); - } - - private static int DetermineAdaptiveAttemptBudget( - RoutingRetryState retryState, - int maxAdaptationsPerStrategy) - { - var boundedMaximum = Math.Clamp(maxAdaptationsPerStrategy, 1, 12); - if (retryState.RequiresBlockingRetry) - { - var complexBlocking = - retryState.UnderNodeViolations > 0 - || retryState.SharedLaneViolations > 0 - || retryState.TargetApproachJoinViolations > 0 - || retryState.GatewaySourceExitViolations > 0; - return Math.Min(boundedMaximum, complexBlocking ? 6 : 5); - } - - if (retryState.RequiresLengthRetry) - { - return Math.Min(boundedMaximum, 4); - } - - if (retryState.RequiresQualityRetry || retryState.EdgeCrossings > 0) - { - return Math.Min(boundedMaximum, 3); - } - - return 1; - } - - private static bool ShouldStopForStagnation(int stagnantAttempts, int attempt, int maxAdaptationsPerStrategy) - { - if (stagnantAttempts <= 0 || attempt < 2) - { - return false; - } - - var stagnationBudget = Math.Min(Math.Max(4, maxAdaptationsPerStrategy / 12), 8); - return stagnantAttempts >= stagnationBudget; - } - - private static bool ShouldRetryForPrimaryViolations( - RoutingRetryState retryState, - int attempt, - int maxAdaptationsPerStrategy) - { - if (!retryState.RequiresPrimaryRetry) - { - return false; - } - - return attempt < Math.Max(0, maxAdaptationsPerStrategy - 1); - } - - private static int DetermineTargetValidSolutionCount( - RoutingRetryState baselineRetryState, - IterativeRoutingConfig config) - { - if (!baselineRetryState.RequiresPrimaryRetry) - { - return config.RequiredValidSolutions; - } - - if (baselineRetryState.RequiresBlockingRetry || baselineRetryState.RequiresLengthRetry) - { - return Math.Min(config.RequiredValidSolutions, 3); - } - - return Math.Min(config.RequiredValidSolutions, 2); - } - - private static int DetermineStrategySearchBudget( - RoutingRetryState baselineRetryState, - IterativeRoutingConfig config) - { - if (!baselineRetryState.RequiresPrimaryRetry) - { - return int.MaxValue; - } - - var severity = baselineRetryState.PrimaryViolationCount + baselineRetryState.EdgeCrossings; - var minimumBudget = baselineRetryState.RequiresBlockingRetry || baselineRetryState.RequiresLengthRetry - ? 8 - : 6; - var severityBudget = severity >= 20 - ? 8 - : severity >= 10 - ? 7 - : 6; - return Math.Min( - OrderingNames.Length, - Math.Max(minimumBudget, severityBudget)); - } - - private static bool ShouldKeepBaselineSolution( - IReadOnlyCollection baselineEdges, - IReadOnlyCollection nodes, - RoutingRetryState baselineRetryState) - { - if (baselineRetryState.RepeatCollectorCorridorViolations > 0) - { - return false; - } - - var hasProtectedEdgeContract = baselineEdges.Any(edge => - !string.IsNullOrWhiteSpace(edge.SourcePortId) - || !string.IsNullOrWhiteSpace(edge.TargetPortId) - || (!string.IsNullOrWhiteSpace(edge.Kind) - && edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase))); - - if (baselineEdges.Count <= 8 - || nodes.Count <= 8 - || hasProtectedEdgeContract) - { - return true; - } - - return !baselineRetryState.RequiresPrimaryRetry && baselineEdges.Count <= 12; - } - - private static bool IsBetterCandidate( - EdgeRoutingScore candidate, - RoutingRetryState candidateRetryState, - EdgeRoutingScore best, - RoutingRetryState bestRetryState) - { - if (HasHardRuleRegression(candidateRetryState, bestRetryState)) - { - return false; - } - - var retryComparison = CompareRetryStates(candidateRetryState, bestRetryState); - if (retryComparison != 0) - { - return retryComparison < 0; - } - - if (candidate.NodeCrossings != best.NodeCrossings) - { - return candidate.NodeCrossings < best.NodeCrossings; - } - - return candidate.Value > best.Value; - } - - private static bool HasHardRuleRegression(RoutingRetryState candidate, RoutingRetryState baseline) - { - return candidate.RemainingShortHighways > baseline.RemainingShortHighways - || candidate.RepeatCollectorCorridorViolations > baseline.RepeatCollectorCorridorViolations - || candidate.RepeatCollectorNodeClearanceViolations > baseline.RepeatCollectorNodeClearanceViolations - || candidate.BelowGraphViolations > baseline.BelowGraphViolations - || candidate.UnderNodeViolations > baseline.UnderNodeViolations - || candidate.LongDiagonalViolations > baseline.LongDiagonalViolations - || candidate.EntryAngleViolations > baseline.EntryAngleViolations - || candidate.GatewaySourceExitViolations > baseline.GatewaySourceExitViolations - || candidate.SharedLaneViolations > baseline.SharedLaneViolations - || candidate.BoundarySlotViolations > baseline.BoundarySlotViolations - || candidate.TargetApproachJoinViolations > baseline.TargetApproachJoinViolations - || candidate.TargetApproachBacktrackingViolations > baseline.TargetApproachBacktrackingViolations - || candidate.ExcessiveDetourViolations > baseline.ExcessiveDetourViolations; - } - - private static bool HasBlockingSharedLanePromotionRegression( - RoutingRetryState candidate, - RoutingRetryState baseline) - { - var allowsTemporaryBoundarySlotTrade = - candidate.SharedLaneViolations < baseline.SharedLaneViolations - && baseline.BoundarySlotViolations > 0 - && candidate.BoundarySlotViolations <= baseline.BoundarySlotViolations + 1; - - return candidate.RemainingShortHighways > baseline.RemainingShortHighways - || candidate.RepeatCollectorCorridorViolations > baseline.RepeatCollectorCorridorViolations - || candidate.RepeatCollectorNodeClearanceViolations > baseline.RepeatCollectorNodeClearanceViolations - || candidate.BelowGraphViolations > baseline.BelowGraphViolations - || candidate.UnderNodeViolations > baseline.UnderNodeViolations - || candidate.LongDiagonalViolations > baseline.LongDiagonalViolations - || candidate.EntryAngleViolations > baseline.EntryAngleViolations - || candidate.GatewaySourceExitViolations > baseline.GatewaySourceExitViolations - || candidate.SharedLaneViolations > baseline.SharedLaneViolations - || (!allowsTemporaryBoundarySlotTrade - && candidate.BoundarySlotViolations > baseline.BoundarySlotViolations) - || candidate.TargetApproachJoinViolations > baseline.TargetApproachJoinViolations - || candidate.TargetApproachBacktrackingViolations > baseline.TargetApproachBacktrackingViolations - || candidate.ExcessiveDetourViolations > baseline.ExcessiveDetourViolations; - } - - private static int CompareRetryStates(RoutingRetryState left, RoutingRetryState right) - { - if (left.RemainingShortHighways != right.RemainingShortHighways) - { - return left.RemainingShortHighways.CompareTo(right.RemainingShortHighways); - } - - if (left.RepeatCollectorCorridorViolations != right.RepeatCollectorCorridorViolations) - { - return left.RepeatCollectorCorridorViolations.CompareTo(right.RepeatCollectorCorridorViolations); - } - - if (left.RepeatCollectorNodeClearanceViolations != right.RepeatCollectorNodeClearanceViolations) - { - return left.RepeatCollectorNodeClearanceViolations.CompareTo(right.RepeatCollectorNodeClearanceViolations); - } - - if (left.BelowGraphViolations != right.BelowGraphViolations) - { - return left.BelowGraphViolations.CompareTo(right.BelowGraphViolations); - } - - if (left.UnderNodeViolations != right.UnderNodeViolations) - { - return left.UnderNodeViolations.CompareTo(right.UnderNodeViolations); - } - - if (left.LongDiagonalViolations != right.LongDiagonalViolations) - { - return left.LongDiagonalViolations.CompareTo(right.LongDiagonalViolations); - } - - if (left.EntryAngleViolations != right.EntryAngleViolations) - { - return left.EntryAngleViolations.CompareTo(right.EntryAngleViolations); - } - - if (left.GatewaySourceExitViolations != right.GatewaySourceExitViolations) - { - return left.GatewaySourceExitViolations.CompareTo(right.GatewaySourceExitViolations); - } - - if (left.SharedLaneViolations != right.SharedLaneViolations) - { - return left.SharedLaneViolations.CompareTo(right.SharedLaneViolations); - } - - if (left.BoundarySlotViolations != right.BoundarySlotViolations) - { - return left.BoundarySlotViolations.CompareTo(right.BoundarySlotViolations); - } - - if (left.TargetApproachJoinViolations != right.TargetApproachJoinViolations) - { - return left.TargetApproachJoinViolations.CompareTo(right.TargetApproachJoinViolations); - } - - if (left.TargetApproachBacktrackingViolations != right.TargetApproachBacktrackingViolations) - { - return left.TargetApproachBacktrackingViolations.CompareTo(right.TargetApproachBacktrackingViolations); - } - - if (left.ExcessiveDetourViolations != right.ExcessiveDetourViolations) - { - return left.ExcessiveDetourViolations.CompareTo(right.ExcessiveDetourViolations); - } - - if (left.ProximityViolations != right.ProximityViolations) - { - return left.ProximityViolations.CompareTo(right.ProximityViolations); - } - - if (left.LabelProximityViolations != right.LabelProximityViolations) - { - return left.LabelProximityViolations.CompareTo(right.LabelProximityViolations); - } - - return left.EdgeCrossings.CompareTo(right.EdgeCrossings); - } - - private static CandidateSolution SelectBestValidSolution( - IReadOnlyList solutions) - { - var best = solutions[0]; - for (var i = 1; i < solutions.Count; i++) - { - var candidate = solutions[i]; - if (IsBetterBoundarySlotRepairCandidate( - candidate.Score, - candidate.RetryState, - best.Score, - best.RetryState) - || candidate.Score.Value > best.Score.Value - || (Math.Abs(candidate.Score.Value - best.Score.Value) < 0.001d - && CompareRetryStates(candidate.RetryState, best.RetryState) < 0) - || (Math.Abs(candidate.Score.Value - best.Score.Value) < 0.001d - && CompareRetryStates(candidate.RetryState, best.RetryState) == 0 - && candidate.Score.EdgeCrossings < best.Score.EdgeCrossings)) - { - best = candidate; - } - } - - return best; - } - - private static CandidateSolution SelectBestFallbackSolution( - IReadOnlyList solutions) - { - var best = solutions[0]; - for (var i = 1; i < solutions.Count; i++) - { - var candidate = solutions[i]; - if (IsBetterBoundarySlotRepairCandidate( - candidate.Score, - candidate.RetryState, - best.Score, - best.RetryState) - || candidate.Score.NodeCrossings < best.Score.NodeCrossings - || (candidate.Score.NodeCrossings == best.Score.NodeCrossings - && CompareRetryStates(candidate.RetryState, best.RetryState) < 0) - || (candidate.Score.NodeCrossings == best.Score.NodeCrossings - && CompareRetryStates(candidate.RetryState, best.RetryState) == 0 - && candidate.Score.EdgeCrossings < best.Score.EdgeCrossings) - || (candidate.Score.NodeCrossings == best.Score.NodeCrossings - && CompareRetryStates(candidate.RetryState, best.RetryState) == 0 - && candidate.Score.EdgeCrossings == best.Score.EdgeCrossings - && candidate.Score.Value > best.Score.Value)) - { - best = candidate; - } - } - - return best; - } - - private static int[] OrderByLongestFirst( - ElkRoutedEdge[] edges, - Dictionary nodesById) - { - return Enumerable.Range(0, edges.Length) - .OrderByDescending(i => - { - if (!nodesById.TryGetValue(edges[i].SourceNodeId ?? "", out var s) - || !nodesById.TryGetValue(edges[i].TargetNodeId ?? "", out var t)) - { - return 0d; - } - - var dx = (t.X + t.Width / 2d) - (s.X + s.Width / 2d); - var dy = (t.Y + t.Height / 2d) - (s.Y + s.Height / 2d); - return Math.Sqrt(dx * dx + dy * dy); - }) - .ToArray(); - } - - private static int[] OrderByShortestFirst( - ElkRoutedEdge[] edges, - Dictionary nodesById) - { - return Enumerable.Range(0, edges.Length) - .OrderBy(i => - { - if (!nodesById.TryGetValue(edges[i].SourceNodeId ?? "", out var s) - || !nodesById.TryGetValue(edges[i].TargetNodeId ?? "", out var t)) - { - return 0d; - } - - var dx = (t.X + t.Width / 2d) - (s.X + s.Width / 2d); - var dy = (t.Y + t.Height / 2d) - (s.Y + s.Height / 2d); - return Math.Sqrt(dx * dx + dy * dy); - }) - .ToArray(); - } - - private static int[] OrderByMostConnectedFirst( - ElkRoutedEdge[] edges, - Dictionary connectionCount) - { - return Enumerable.Range(0, edges.Length) - .OrderByDescending(i => - connectionCount.GetValueOrDefault(edges[i].SourceNodeId ?? "") - + connectionCount.GetValueOrDefault(edges[i].TargetNodeId ?? "")) - .ToArray(); - } - - private static int[] OrderByReverse(ElkRoutedEdge[] edges) - { - return Enumerable.Range(0, edges.Length).Reverse().ToArray(); - } - - private static void Shuffle(int[] array, Random rng) - { - for (var i = array.Length - 1; i > 0; i--) - { - var j = rng.Next(i + 1); - (array[i], array[j]) = (array[j], array[i]); - } - } - - private static Dictionary SpreadTargetEndpoints( - ElkRoutedEdge[] edges, - Dictionary nodesById, - double graphMinY, - double graphMaxY, - double minLineClearance) - { - var result = new Dictionary(StringComparer.Ordinal); - - // Group routable edges by target + entry side - var groups = new Dictionary>(StringComparer.Ordinal); - foreach (var edge in edges) - { - if (!ShouldRouteEdge(edge, graphMinY, graphMaxY)) - { - continue; - } - - var lastSection = edge.Sections.LastOrDefault(); - if (lastSection is null || !nodesById.TryGetValue(edge.TargetNodeId ?? "", out var targetNode)) - { - continue; - } - - var ep = lastSection.EndPoint; - var adjacentPoint = lastSection.BendPoints.LastOrDefault() ?? lastSection.StartPoint; - var side = ResolveEntrySide(ep, adjacentPoint, targetNode); - var key = $"{edge.TargetNodeId}|{side}"; - - if (!groups.TryGetValue(key, out var list)) - { - list = []; - groups[key] = list; - } - - list.Add((edge.Id, ep)); - } - - // For each group with 2+ edges on the same side, spread them - foreach (var (key, group) in groups) - { - if (group.Count < 2) - { - continue; - } - - var parts = key.Split('|'); - if (!nodesById.TryGetValue(parts[0], out var node)) - { - continue; - } - - var side = parts[1]; - var sorted = side is "left" or "right" - ? group.OrderBy(g => g.OrigEnd.Y).ThenBy(g => g.EdgeId, StringComparer.Ordinal).ToList() - : group.OrderBy(g => g.OrigEnd.X).ThenBy(g => g.EdgeId, StringComparer.Ordinal).ToList(); - var assignedSlotCoordinates = ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates(node, side, sorted.Count); - - if (side is "left" or "right") - { - for (var i = 0; i < sorted.Count; i++) - { - result[sorted[i].EdgeId] = new ElkPoint - { - X = sorted[i].OrigEnd.X, - Y = assignedSlotCoordinates[i], - }; - } - } - else - { - for (var i = 0; i < sorted.Count; i++) - { - result[sorted[i].EdgeId] = new ElkPoint - { - X = assignedSlotCoordinates[i], - Y = sorted[i].OrigEnd.Y, - }; - } - } - } - - return result; - } - - private static string ResolveEntrySide(ElkPoint endpoint, ElkPoint adjacentPoint, ElkPositionedNode node) - { - return ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(endpoint, adjacentPoint, node); - } - - private static bool ShouldRouteEdge(ElkRoutedEdge edge, double graphMinY, double graphMaxY) - { - // Skip port-anchored edges (their anchors are fixed) - if (!string.IsNullOrWhiteSpace(edge.SourcePortId) || !string.IsNullOrWhiteSpace(edge.TargetPortId)) - { - return false; - } - - // Skip backward edges (routed through external corridors) - if (!string.IsNullOrWhiteSpace(edge.Kind) - && edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - // Skip edges with corridor bend points (already routed outside graph bounds) - if (ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY)) - { - return false; - } - - // Skip repeat collector labels - return !ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label); - } - - private static bool CanRepairEdgeLocally( - ElkRoutedEdge edge, - IReadOnlyCollection nodes, - double graphMinY, - double graphMaxY) - { - if (ShouldRouteEdge(edge, graphMinY, graphMaxY)) - { - return true; - } - - if (!string.IsNullOrWhiteSpace(edge.SourcePortId) || !string.IsNullOrWhiteSpace(edge.TargetPortId)) - { - return false; - } - - if (!string.IsNullOrWhiteSpace(edge.Kind) - && edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label)) - { - return false; - } - - return ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY) - && HasClearOrthogonalShortcut(edge, nodes); - } - - private static bool CanRouteSelectedRepairEdge( - ElkRoutedEdge edge, - double graphMinY, - double graphMaxY, - IReadOnlySet routeRepairEdgeIds) - { - if (ShouldRouteEdge(edge, graphMinY, graphMaxY)) - { - return true; - } - - if (!routeRepairEdgeIds.Contains(edge.Id)) - { - return false; - } - - if (!string.IsNullOrWhiteSpace(edge.SourcePortId) || !string.IsNullOrWhiteSpace(edge.TargetPortId)) - { - return false; - } - - if (!string.IsNullOrWhiteSpace(edge.Kind) - && edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - return ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label) - && !ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY); - } - - private static bool HasClearOrthogonalShortcut( - ElkRoutedEdge edge, - IReadOnlyCollection nodes) - { - var firstSection = edge.Sections.FirstOrDefault(); - var lastSection = edge.Sections.LastOrDefault(); - if (firstSection is null || lastSection is null) - { - return false; - } - - var start = firstSection.StartPoint; - var end = lastSection.EndPoint; - var obstacles = nodes.Select(node => ( - Left: node.X, - Top: node.Y, - Right: node.X + node.Width, - Bottom: node.Y + node.Height, - Id: node.Id)).ToArray(); - - bool SegmentIsClear(ElkPoint from, ElkPoint to) => - !ElkEdgePostProcessor.SegmentCrossesObstacle( - from, - to, - obstacles, - edge.SourceNodeId, - edge.TargetNodeId); - - if (Math.Abs(start.X - end.X) < 2d || Math.Abs(start.Y - end.Y) < 2d) - { - return SegmentIsClear(start, end); - } - - var horizontalThenVertical = new ElkPoint { X = end.X, Y = start.Y }; - if (SegmentIsClear(start, horizontalThenVertical) && SegmentIsClear(horizontalThenVertical, end)) - { - return true; - } - - var verticalThenHorizontal = new ElkPoint { X = start.X, Y = end.Y }; - return SegmentIsClear(start, verticalThenHorizontal) - && SegmentIsClear(verticalThenHorizontal, end); - } - - private static int CountRepeatCollectorNodeClearanceViolations( - IReadOnlyCollection edges, - IReadOnlyCollection nodes, - Dictionary? severityByEdgeId, - int severityWeight = 1) - { - if (edges.Count == 0 || nodes.Count == 0) - { - return 0; - } - - var serviceNodes = nodes.Where(node => node.Kind is not "Start" and not "End").ToArray(); - var minClearance = serviceNodes.Length > 0 - ? Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d - : 50d; - var graphMinY = nodes.Min(node => node.Y); - var graphMaxY = nodes.Max(node => node.Y + node.Height); - var count = 0; - - foreach (var edge in edges) - { - if (!ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label)) - { - continue; - } - - var edgeViolations = 0; - foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge)) - { - var horizontal = Math.Abs(segment.Start.Y - segment.End.Y) < 2d; - var vertical = Math.Abs(segment.Start.X - segment.End.X) < 2d; - if (!horizontal && !vertical) - { - continue; - } - - foreach (var node in nodes) - { - if (node.Id == edge.SourceNodeId || node.Id == edge.TargetNodeId) - { - continue; - } - - if (horizontal) - { - var overlapX = Math.Max(segment.Start.X, segment.End.X) > node.X - && Math.Min(segment.Start.X, segment.End.X) < node.X + node.Width; - if (!overlapX) - { - continue; - } - - var distance = Math.Min( - Math.Abs(segment.Start.Y - node.Y), - Math.Abs(segment.Start.Y - (node.Y + node.Height))); - if (distance > 0.5d && distance < minClearance) - { - edgeViolations++; - } - } - else - { - var overlapY = Math.Max(segment.Start.Y, segment.End.Y) > node.Y - && Math.Min(segment.Start.Y, segment.End.Y) < node.Y + node.Height; - if (!overlapY) - { - continue; - } - - var distance = Math.Min( - Math.Abs(segment.Start.X - node.X), - Math.Abs(segment.Start.X - (node.X + node.Width))); - if (distance > 0.5d && distance < minClearance) - { - edgeViolations++; - } - } - } - } - - if (edgeViolations <= 0) - { - continue; - } - - count += edgeViolations; - if (severityByEdgeId is not null) - { - severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + (edgeViolations * severityWeight); - } - } - - return count; - } - - private static (ElkPoint StartPoint, ElkPoint EndPoint) ResolveRoutingEndpoints( - ElkPoint startPoint, - ElkPoint endPoint, - string? sourceNodeId, - string? targetNodeId, - IReadOnlyDictionary nodesById) - { - var adjustedStart = startPoint; - if (nodesById.TryGetValue(sourceNodeId ?? string.Empty, out var sourceNode) - && ElkShapeBoundaries.IsGatewayShape(sourceNode)) - { - adjustedStart = ResolveGatewayRoutingDeparturePoint(sourceNode, endPoint, startPoint); - } - - var adjustedEnd = endPoint; - if (nodesById.TryGetValue(targetNodeId ?? string.Empty, out var targetNode) - && ElkShapeBoundaries.IsGatewayShape(targetNode)) - { - adjustedEnd = ResolveGatewayRoutingApproachPoint(targetNode, adjustedStart, endPoint); - } - - return (adjustedStart, adjustedEnd); - } - - private static ElkPoint ResolveGatewayRoutingBoundary( - ElkPositionedNode node, - ElkPoint referencePoint) - { - var boundary = ElkShapeBoundaries.ProjectOntoShapeBoundary(node, referencePoint); - return ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(node, boundary, referencePoint); - } - - private static ElkPoint ResolveGatewayRoutingDeparturePoint( - ElkPositionedNode node, - ElkPoint referencePoint, - ElkPoint preferredBoundary) - { - var boundary = ElkShapeBoundaries.IsGatewayBoundaryPoint(node, preferredBoundary) - ? ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(node, preferredBoundary, referencePoint) - : ResolveGatewayRoutingBoundary(node, referencePoint); - var exteriorDeparture = ElkShapeBoundaries.BuildGatewayDirectionalExteriorPoint(node, boundary, referencePoint); - return ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(node, exteriorDeparture) - ? boundary - : exteriorDeparture; - } - - private static ElkPoint ResolveGatewayRoutingApproachPoint( - ElkPositionedNode node, - ElkPoint referencePoint, - ElkPoint preferredBoundary) - { - var boundary = ElkShapeBoundaries.IsGatewayBoundaryPoint(node, preferredBoundary) - ? ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(node, preferredBoundary, referencePoint) - : ResolveGatewayRoutingBoundary(node, referencePoint); - var exteriorApproach = ElkShapeBoundaries.BuildGatewayDirectionalExteriorPoint(node, boundary, referencePoint); - return ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(node, exteriorApproach) - ? boundary - : exteriorApproach; - } - - private static List? TryRouteShortestRepair( - ElkPoint start, - ElkPoint end, - IReadOnlyCollection nodes, - (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, - string sourceId, - string targetId, - ElkPositionedNode? sourceNode, - ElkPositionedNode? targetNode, - AStarRoutingParams routingParams, - IReadOnlyList softObstacles, - CancellationToken cancellationToken) - { - if (sourceNode is not null && targetNode is not null) - { - if (ElkEdgePostProcessor.TryBuildPreferredBoundaryShortcutPath( - sourceNode, - targetNode, - nodes, - sourceId, - targetId, - out var preferredShortcut) - && (targetNode is null || !HasTargetApproachBacktracking(preferredShortcut, targetNode))) - { - return preferredShortcut; - } - } - - var candidateEndpoints = EnumerateShortestRepairEndpoints(start, end, targetNode).ToArray(); - var minLineClearance = ResolveMinLineClearance(nodes); - var shortcutObstaclePadding = Math.Max(12d, Math.Min(routingParams.Margin, Math.Max(18d, minLineClearance - 4d))); - List? bestPath = null; - var bestLength = double.MaxValue; - - foreach (var candidateEnd in candidateEndpoints) - { - var orthogonalShortcut = TryBuildShortestOrthogonalPath( - start, - candidateEnd, - nodes, - sourceId, - targetId, - targetNode, - shortcutObstaclePadding); - if (orthogonalShortcut is null - || (targetNode is not null && HasTargetApproachBacktracking(orthogonalShortcut, targetNode))) - { - continue; - } - - var shortcutLength = ComputePolylineLength(orthogonalShortcut); - if (shortcutLength < bestLength - 0.5d) - { - bestPath = orthogonalShortcut; - bestLength = shortcutLength; - } - } - - foreach (var candidateEnd in candidateEndpoints) - { - var localSkirtShortcut = TryBuildLocalObstacleSkirtPath( - start, - candidateEnd, - nodes, - sourceId, - targetId, - targetNode, - shortcutObstaclePadding); - if (localSkirtShortcut is null - || (targetNode is not null && HasTargetApproachBacktracking(localSkirtShortcut, targetNode))) - { - continue; - } - - var shortcutLength = ComputePolylineLength(localSkirtShortcut); - if (shortcutLength < bestLength - 0.5d) - { - bestPath = localSkirtShortcut; - bestLength = shortcutLength; - } - } - - if (bestPath is not null) - { - return bestPath; - } - - var shortestParams = routingParams with - { - Margin = Math.Max(shortcutObstaclePadding, Math.Min(routingParams.Margin, minLineClearance)), - BendPenalty = Math.Min(routingParams.BendPenalty, 80d), - DiagonalPenalty = Math.Min(routingParams.DiagonalPenalty, 40d), - SoftObstacleWeight = Math.Max(0.25d, routingParams.SoftObstacleWeight * 0.35d), - SoftObstacleClearance = Math.Max(Math.Max(18d, minLineClearance * 0.6d), routingParams.SoftObstacleClearance * 0.5d), - IntermediateGridSpacing = Math.Max(12d, routingParams.IntermediateGridSpacing - 8d), - }; - var shortestObstacles = nodes - .Select(node => ( - Left: node.X - shortestParams.Margin, - Top: node.Y - shortestParams.Margin, - Right: node.X + node.Width + shortestParams.Margin, - Bottom: node.Y + node.Height + shortestParams.Margin, - Id: node.Id)) - .ToArray(); - - foreach (var candidateEnd in candidateEndpoints) - { - var diagonalPath = ElkEdgeRouterAStar8Dir.Route( - start, - candidateEnd, - shortestObstacles, - sourceId, - targetId, - shortestParams, - [], - cancellationToken); - if (diagonalPath is null - || (targetNode is not null && HasTargetApproachBacktracking(diagonalPath, targetNode))) - { - continue; - } - - var pathLength = ComputePolylineLength(diagonalPath); - if (pathLength < bestLength - 0.5d) - { - bestPath = diagonalPath; - bestLength = pathLength; - } - } - - return bestPath; - } - - private static List? TryRouteAggressiveRepair( - ElkPoint start, - ElkPoint end, - IReadOnlyCollection nodes, - (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, - string sourceId, - string targetId, - ElkPositionedNode? sourceNode, - ElkPositionedNode? targetNode, - AStarRoutingParams routingParams, - CancellationToken cancellationToken) - { - var candidateEndpoints = EnumerateShortestRepairEndpoints(start, end, targetNode).ToArray(); - if (candidateEndpoints.Length == 0) - { - return null; - } - - var minLineClearance = ResolveMinLineClearance(nodes); - var aggressiveParams = routingParams with - { - Margin = Math.Max(10d, Math.Min(routingParams.Margin, Math.Max(14d, minLineClearance * 0.45d))), - BendPenalty = Math.Min(routingParams.BendPenalty, 60d), - DiagonalPenalty = Math.Min(routingParams.DiagonalPenalty, 35d), - SoftObstacleWeight = 0d, - SoftObstacleClearance = 0d, - IntermediateGridSpacing = Math.Max(12d, Math.Min(routingParams.IntermediateGridSpacing, Math.Max(12d, minLineClearance * 0.45d))), - }; - - var aggressiveObstacles = obstacles - .Select(obstacle => ( - Left: obstacle.Left + Math.Min(0d, aggressiveParams.Margin - routingParams.Margin), - Top: obstacle.Top + Math.Min(0d, aggressiveParams.Margin - routingParams.Margin), - Right: obstacle.Right - Math.Min(0d, aggressiveParams.Margin - routingParams.Margin), - Bottom: obstacle.Bottom - Math.Min(0d, aggressiveParams.Margin - routingParams.Margin), - obstacle.Id)) - .ToArray(); - - List? bestPath = null; - ElkRoutedEdge? bestEdge = null; - - foreach (var candidateEnd in candidateEndpoints) - { - var candidate = ElkEdgeRouterAStar8Dir.Route( - start, - candidateEnd, - aggressiveObstacles, - sourceId, - targetId, - aggressiveParams, - [], - cancellationToken); - if (candidate is null) - { - continue; - } - - if (targetNode is not null && HasTargetApproachBacktracking(candidate, targetNode)) - { - continue; - } - - var candidateEdge = BuildCandidateRepairEdge( - sourceId, - targetId, - sourceNode, - targetNode, - candidate); - if (bestPath is null || CompareSingleEdgeRepairQuality(candidateEdge, bestEdge!, nodes) < 0) - { - bestPath = candidate; - bestEdge = candidateEdge; - } - } - - return bestPath; - } - - private static List? ChooseBetterLocalRepairCandidate( - ElkRoutedEdge originalEdge, - IReadOnlyCollection nodes, - List? primaryCandidate, - List? secondaryCandidate) - { - if (primaryCandidate is null || primaryCandidate.Count < 2) - { - return secondaryCandidate; - } - - if (secondaryCandidate is null || secondaryCandidate.Count < 2) - { - return primaryCandidate; - } - - var primaryEdge = BuildCandidateRepairEdge( - originalEdge.SourceNodeId, - originalEdge.TargetNodeId, - null, - null, - primaryCandidate); - var secondaryEdge = BuildCandidateRepairEdge( - originalEdge.SourceNodeId, - originalEdge.TargetNodeId, - null, - null, - secondaryCandidate); - return CompareSingleEdgeRepairQuality(secondaryEdge, primaryEdge, nodes) < 0 - ? secondaryCandidate - : primaryCandidate; - } - - private static ElkRoutedEdge BuildCandidateRepairEdge( - string? sourceNodeId, - string? targetNodeId, - ElkPositionedNode? sourceNode, - ElkPositionedNode? targetNode, - IReadOnlyList path) - { - return new ElkRoutedEdge - { - Id = "__candidate__", - SourceNodeId = sourceNodeId, - TargetNodeId = targetNodeId, - Kind = string.Empty, - Label = string.Empty, - Sections = - [ - new ElkEdgeSection - { - StartPoint = path[0], - EndPoint = path[^1], - BendPoints = path.Count > 2 - ? path.Skip(1).Take(path.Count - 2).ToArray() - : [], - }, - ], - }; - } - - private static int CompareSingleEdgeRepairQuality( - ElkRoutedEdge left, - ElkRoutedEdge right, - IReadOnlyCollection nodes) - { - var leftBlocking = CountSingleEdgeBlockingViolations(left, nodes); - var rightBlocking = CountSingleEdgeBlockingViolations(right, nodes); - if (leftBlocking != rightBlocking) - { - return leftBlocking.CompareTo(rightBlocking); - } - - var leftDetour = ElkEdgeRoutingScoring.CountExcessiveDetourViolations([left], nodes); - var rightDetour = ElkEdgeRoutingScoring.CountExcessiveDetourViolations([right], nodes); - if (leftDetour != rightDetour) - { - return leftDetour.CompareTo(rightDetour); - } - - var leftLength = ComputePolylineLength(ExtractCandidatePath(left)); - var rightLength = ComputePolylineLength(ExtractCandidatePath(right)); - return leftLength.CompareTo(rightLength); - } - - private static int CountSingleEdgeBlockingViolations( - ElkRoutedEdge edge, - IReadOnlyCollection nodes) - { - return ElkEdgeRoutingScoring.CountUnderNodeViolations([edge], nodes) - + ElkEdgeRoutingScoring.CountBelowGraphViolations([edge], nodes) - + ElkEdgeRoutingScoring.CountBadBoundaryAngles([edge], nodes) - + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations([edge], nodes); - } - - private static List ExtractCandidatePath(ElkRoutedEdge edge) - { - 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); - } - - return path; - } - - private static List? TryBuildPreferredSideShortcut( - ElkPositionedNode sourceNode, - ElkPositionedNode targetNode, - IReadOnlyCollection nodes, - string sourceId, - string targetId) - { - var sourceCenterX = sourceNode.X + (sourceNode.Width / 2d); - var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); - var targetCenterX = targetNode.X + (targetNode.Width / 2d); - var targetCenterY = targetNode.Y + (targetNode.Height / 2d); - var deltaX = targetCenterX - sourceCenterX; - var deltaY = targetCenterY - sourceCenterY; - var absDx = Math.Abs(deltaX); - var absDy = Math.Abs(deltaY); - if (absDx < 16d && absDy < 16d) - { - return null; - } - - var horizontalDominant = absDx >= absDy; - var preferredSourceSide = horizontalDominant - ? deltaX >= 0d ? "right" : "left" - : deltaY >= 0d ? "bottom" : "top"; - var preferredTargetSide = horizontalDominant - ? deltaX >= 0d ? "left" : "right" - : deltaY >= 0d ? "top" : "bottom"; - var start = BuildPreferredBoundaryPoint(sourceNode, preferredSourceSide, targetNode); - var end = BuildPreferredBoundaryPoint(targetNode, preferredTargetSide, sourceNode); - return TryBuildShortestOrthogonalPath(start, end, nodes, sourceId, targetId, targetNode, 0d); - } - - private static ElkPoint BuildPreferredBoundaryPoint( - ElkPositionedNode node, - string side, - ElkPositionedNode otherNode) - { - var horizontalInset = Math.Min(24d, Math.Max(12d, node.Width / 4d)); - var verticalInset = Math.Min(24d, Math.Max(12d, node.Height / 4d)); - var otherCenterX = otherNode.X + (otherNode.Width / 2d); - var otherCenterY = otherNode.Y + (otherNode.Height / 2d); - - var boundary = side switch - { - "left" => new ElkPoint - { - X = node.X, - Y = Math.Clamp(otherCenterY, node.Y + verticalInset, (node.Y + node.Height) - verticalInset), - }, - "right" => new ElkPoint - { - X = node.X + node.Width, - Y = Math.Clamp(otherCenterY, node.Y + verticalInset, (node.Y + node.Height) - verticalInset), - }, - "top" => new ElkPoint - { - X = Math.Clamp(otherCenterX, node.X + horizontalInset, (node.X + node.Width) - horizontalInset), - Y = node.Y, - }, - _ => new ElkPoint - { - X = Math.Clamp(otherCenterX, node.X + horizontalInset, (node.X + node.Width) - horizontalInset), - Y = node.Y + node.Height, - }, - }; - - if (!ElkShapeBoundaries.IsGatewayShape(node)) - { - return boundary; - } - - var referencePoint = side switch - { - "left" => new ElkPoint { X = node.X - Math.Max(24d, node.Width / 3d), Y = boundary.Y }, - "right" => new ElkPoint { X = node.X + node.Width + Math.Max(24d, node.Width / 3d), Y = boundary.Y }, - "top" => new ElkPoint { X = boundary.X, Y = node.Y - Math.Max(24d, node.Height / 3d) }, - _ => new ElkPoint { X = boundary.X, Y = node.Y + node.Height + Math.Max(24d, node.Height / 3d) }, - }; - - var projected = ElkShapeBoundaries.ProjectOntoShapeBoundary(node, referencePoint); - return ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(node, projected, referencePoint); - } - - private static List? TryBuildShortestOrthogonalPath( - ElkPoint start, - ElkPoint end, - IReadOnlyCollection nodes, - string sourceId, - string targetId, - ElkPositionedNode? targetNode, - double obstaclePadding) - { - var rawObstacles = nodes.Select(node => ( - Left: node.X - obstaclePadding, - Top: node.Y - obstaclePadding, - Right: node.X + node.Width + obstaclePadding, - Bottom: node.Y + node.Height + obstaclePadding, - Id: node.Id)).ToArray(); - - bool SegmentIsClear(ElkPoint from, ElkPoint to) => - !ElkEdgePostProcessor.SegmentCrossesObstacle(from, to, rawObstacles, sourceId, targetId); - - if (Math.Abs(start.X - end.X) < 0.5d || Math.Abs(start.Y - end.Y) < 0.5d) - { - return SegmentIsClear(start, end) - ? [start, end] - : null; - } - - foreach (var pivot in EnumerateOrthogonalShortcutPivots(start, end, targetNode)) - { - if (!SegmentIsClear(start, pivot) || !SegmentIsClear(pivot, end)) - { - continue; - } - - return NormalizePolyline([start, pivot, end]); - } - - return null; - } - - private static IEnumerable EnumerateOrthogonalShortcutPivots( - ElkPoint start, - ElkPoint end, - ElkPositionedNode? targetNode) - { - var targetSide = targetNode is null - ? string.Empty - : ElkEdgeRoutingGeometry.ResolveBoundarySide(end, targetNode); - var preferred = targetSide is "left" or "right" - ? new ElkPoint { X = start.X, Y = end.Y } - : new ElkPoint { X = end.X, Y = start.Y }; - var alternate = targetSide is "left" or "right" - ? new ElkPoint { X = end.X, Y = start.Y } - : new ElkPoint { X = start.X, Y = end.Y }; - - yield return preferred; - if (!ElkEdgeRoutingGeometry.PointsEqual(preferred, alternate)) - { - yield return alternate; - } - } - - private static IEnumerable EnumerateShortestRepairEndpoints( - ElkPoint start, - ElkPoint currentEnd, - ElkPositionedNode? targetNode) - { - var endpoints = new List(); - - void AddCandidate(ElkPoint candidate) - { - if (!endpoints.Any(existing => ElkEdgeRoutingGeometry.PointsEqual(existing, candidate))) - { - endpoints.Add(candidate); - } - } - - AddCandidate(currentEnd); - if (targetNode is null) - { - return endpoints; - } - - if (ElkShapeBoundaries.IsGatewayShape(targetNode)) - { - if (ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, "left", start.Y, out var left)) - { - AddCandidate(left); - } - - if (ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, "right", start.Y, out var right)) - { - AddCandidate(right); - } - - if (ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, "top", start.X, out var top)) - { - AddCandidate(top); - } - - if (ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, "bottom", start.X, out var bottom)) - { - AddCandidate(bottom); - } - - return endpoints; - } - - var horizontalInset = Math.Min(24d, Math.Max(12d, targetNode.Width / 4d)); - var verticalInset = Math.Min(24d, Math.Max(12d, targetNode.Height / 4d)); - var candidateEndpoints = new[] - { - new ElkPoint - { - X = targetNode.X, - Y = Math.Clamp(start.Y, targetNode.Y + verticalInset, (targetNode.Y + targetNode.Height) - verticalInset), - }, - new ElkPoint - { - X = targetNode.X + targetNode.Width, - Y = Math.Clamp(start.Y, targetNode.Y + verticalInset, (targetNode.Y + targetNode.Height) - verticalInset), - }, - new ElkPoint - { - X = Math.Clamp(start.X, targetNode.X + horizontalInset, (targetNode.X + targetNode.Width) - horizontalInset), - Y = targetNode.Y, - }, - new ElkPoint - { - X = Math.Clamp(start.X, targetNode.X + horizontalInset, (targetNode.X + targetNode.Width) - horizontalInset), - Y = targetNode.Y + targetNode.Height, - }, - }; - - foreach (var candidate in candidateEndpoints) - { - AddCandidate(candidate); - } - - return endpoints; - } - - private static List? TryBuildLocalObstacleSkirtPath( - ElkPoint start, - ElkPoint end, - IReadOnlyCollection nodes, - string sourceId, - string targetId, - ElkPositionedNode? targetNode, - double obstaclePadding) - { - var obstacles = nodes.Select(node => ( - Left: node.X - obstaclePadding, - Top: node.Y - obstaclePadding, - Right: node.X + node.Width + obstaclePadding, - Bottom: node.Y + node.Height + obstaclePadding, - Id: node.Id)).ToArray(); - List? bestPath = null; - var bestScore = double.MaxValue; - - bool SegmentIsClear(ElkPoint from, ElkPoint to) => - !ElkEdgePostProcessor.SegmentCrossesObstacle(from, to, obstacles, sourceId, targetId); - - void ConsiderCandidate(IReadOnlyList rawCandidate) - { - var candidate = NormalizePolyline(rawCandidate); - if (candidate.Count < 2) - { - return; - } - - for (var i = 1; i < candidate.Count; i++) - { - if (!SegmentIsClear(candidate[i - 1], candidate[i])) - { - return; - } - } - - if (targetNode is not null - && !ElkShapeBoundaries.IsGatewayShape(targetNode) - && HasTargetApproachBacktracking(candidate, targetNode)) - { - return; - } - - var score = ComputePolylineLength(candidate) + (Math.Max(0, candidate.Count - 2) * 4d); - if (score >= bestScore - 0.5d) - { - return; - } - - bestScore = score; - bestPath = candidate; - } - - var horizontalDominant = Math.Abs(end.X - start.X) >= Math.Abs(end.Y - start.Y); - if (horizontalDominant) - { - var targetBridgeX = end.X; - if (targetNode is not null && ElkShapeBoundaries.IsGatewayShape(targetNode)) - { - targetBridgeX = ResolveGatewayRoutingApproachPoint(targetNode, start, end).X; - } - - var minX = Math.Min(start.X, end.X) + 0.5d; - var maxX = Math.Max(start.X, end.X) - 0.5d; - var corridorTop = Math.Min(start.Y, end.Y) - obstaclePadding; - var corridorBottom = Math.Max(start.Y, end.Y) + obstaclePadding; - var bypassYCandidates = new List { start.Y, end.Y }; - foreach (var obstacle in obstacles) - { - if (string.Equals(obstacle.Id, sourceId, StringComparison.Ordinal) - || string.Equals(obstacle.Id, targetId, StringComparison.Ordinal) - || obstacle.Right <= minX - || obstacle.Left >= maxX - || obstacle.Bottom <= corridorTop - || obstacle.Top >= corridorBottom) - { - continue; - } - - AddUniqueCoordinate(bypassYCandidates, obstacle.Top); - AddUniqueCoordinate(bypassYCandidates, obstacle.Bottom); - } - - foreach (var bypassY in bypassYCandidates) - { - ConsiderCandidate( - [ - start, - new ElkPoint { X = start.X, Y = bypassY }, - new ElkPoint { X = targetBridgeX, Y = bypassY }, - end, - ]); - } - } - else - { - var targetBridgeY = end.Y; - if (targetNode is not null && ElkShapeBoundaries.IsGatewayShape(targetNode)) - { - targetBridgeY = ResolveGatewayRoutingApproachPoint(targetNode, start, end).Y; - } - - var minY = Math.Min(start.Y, end.Y) + 0.5d; - var maxY = Math.Max(start.Y, end.Y) - 0.5d; - var corridorLeft = Math.Min(start.X, end.X) - obstaclePadding; - var corridorRight = Math.Max(start.X, end.X) + obstaclePadding; - var bypassXCandidates = new List { start.X, end.X }; - foreach (var obstacle in obstacles) - { - if (string.Equals(obstacle.Id, sourceId, StringComparison.Ordinal) - || string.Equals(obstacle.Id, targetId, StringComparison.Ordinal) - || obstacle.Bottom <= minY - || obstacle.Top >= maxY - || obstacle.Right <= corridorLeft - || obstacle.Left >= corridorRight) - { - continue; - } - - AddUniqueCoordinate(bypassXCandidates, obstacle.Left); - AddUniqueCoordinate(bypassXCandidates, obstacle.Right); - } - - foreach (var bypassX in bypassXCandidates) - { - ConsiderCandidate( - [ - start, - new ElkPoint { X = bypassX, Y = start.Y }, - new ElkPoint { X = bypassX, Y = targetBridgeY }, - end, - ]); - } - } - - return bestPath; - } - - private static double ComputePolylineLength(IReadOnlyList points) - { - var length = 0d; - for (var i = 1; i < points.Count; i++) - { - length += ElkEdgeRoutingGeometry.ComputeSegmentLength(points[i - 1], points[i]); - } - - return length; - } - - private static double ResolveMinLineClearance(IReadOnlyCollection nodes) - { - var serviceNodes = nodes.Where(node => node.Kind is not "Start" and not "End").ToArray(); - return serviceNodes.Length > 0 - ? Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d - : 50d; - } - - private static bool HasTargetApproachBacktracking( - IReadOnlyList path, - ElkPositionedNode targetNode) - { - if (path.Count < 3) - { - return false; - } - - var side = ElkEdgeRoutingGeometry.ResolveBoundarySide(path[^1], targetNode); - if (side is not "left" and not "right" and not "top" and not "bottom") - { - return false; - } - - const double tolerance = 0.5d; - var startIndex = Math.Max( - 0, - path.Count - (side is "left" or "right" ? 4 : 3)); - var axisValues = new List(path.Count - startIndex); - for (var i = startIndex; i < path.Count; i++) - { - var value = side is "left" or "right" - ? path[i].X - : path[i].Y; - if (axisValues.Count == 0 || Math.Abs(axisValues[^1] - value) > tolerance) - { - axisValues.Add(value); - } - } - - if (axisValues.Count < 3) - { - return false; - } - - var targetAxis = side switch - { - "left" => targetNode.X, - "right" => targetNode.X + targetNode.Width, - "top" => targetNode.Y, - "bottom" => targetNode.Y + targetNode.Height, - _ => double.NaN, - }; - - var overshootsTargetSide = side switch - { - "left" or "top" => axisValues.Any(value => value > targetAxis + tolerance), - "right" or "bottom" => axisValues.Any(value => value < targetAxis - tolerance), - _ => false, - }; - if (overshootsTargetSide) - { - return true; - } - - var expectsIncreasing = side is "left" or "top"; - var sawProgress = false; - for (var i = 1; i < axisValues.Count; i++) - { - var delta = axisValues[i] - axisValues[i - 1]; - if (Math.Abs(delta) <= tolerance) - { - continue; - } - - if (expectsIncreasing) - { - if (delta > tolerance) - { - sawProgress = true; - } - else if (sawProgress) - { - return true; - } - } - else - { - if (delta < -tolerance) - { - sawProgress = true; - } - else if (sawProgress) - { - return true; - } - } - } - - return false; - } - - private static ElkRoutedEdge[] RestoreProtectedRepeatCollectorCorridors( - ElkRoutedEdge[] candidateEdges, - ElkRoutedEdge[] referenceEdges, - ElkPositionedNode[] nodes) - { - if (candidateEdges.Length == 0 || referenceEdges.Length == 0 || nodes.Length == 0) - { - return candidateEdges; - } - - var graphMinY = nodes.Min(node => node.Y); - var graphMaxY = nodes.Max(node => node.Y + node.Height); - var minLineClearance = ResolveMinLineClearance(nodes); - var obstacles = BuildObstacles(nodes, 0d); - var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); - var restoredIds = new List(); - var result = candidateEdges.ToArray(); - - for (var i = 0; i < result.Length && i < referenceEdges.Length; i++) - { - var reference = referenceEdges[i]; - if (!ElkEdgePostProcessor.IsRepeatCollectorLabel(reference.Label) - || !ElkEdgePostProcessor.HasCorridorBendPoints(reference, graphMinY, graphMaxY) - || ElkEdgePostProcessor.HasCorridorBendPoints(result[i], graphMinY, graphMaxY) - || EdgeCrossesNode(reference, obstacles) - || EdgeViolatesNodeClearance(reference, nodes, minLineClearance)) - { - continue; - } - - var restored = reference; - if (nodesById.TryGetValue(reference.SourceNodeId ?? string.Empty, out var sourceNode) - && ElkShapeBoundaries.IsGatewayShape(sourceNode)) - { - restored = AlignProtectedCollectorGatewaySourceExit(restored, sourceNode, graphMinY, graphMaxY); - } - - result[i] = restored; - restoredIds.Add(restored.Id); - } - - return restoredIds.Count > 0 - ? ElkRepeatCollectorCorridors.SeparateSharedLanes(result, nodes, restoredIds) - : result; - } - - private static bool EdgeViolatesNodeClearance( - ElkRoutedEdge edge, - IReadOnlyCollection nodes, - double minLineClearance) - { - if (minLineClearance <= 0d) - { - return false; - } - - foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge)) - { - var horizontal = Math.Abs(segment.Start.Y - segment.End.Y) <= 0.5d; - var vertical = Math.Abs(segment.Start.X - segment.End.X) <= 0.5d; - if (!horizontal && !vertical) - { - continue; - } - - foreach (var node in nodes) - { - if (node.Id == edge.SourceNodeId || node.Id == edge.TargetNodeId) - { - continue; - } - - if (horizontal) - { - var minX = Math.Min(segment.Start.X, segment.End.X); - var maxX = Math.Max(segment.Start.X, segment.End.X); - if (maxX <= node.X || minX >= node.X + node.Width) - { - continue; - } - - var distance = Math.Min( - Math.Abs(segment.Start.Y - node.Y), - Math.Abs(segment.Start.Y - (node.Y + node.Height))); - if (distance > 0.5d && distance < minLineClearance) - { - return true; - } - - continue; - } - - var minY = Math.Min(segment.Start.Y, segment.End.Y); - var maxY = Math.Max(segment.Start.Y, segment.End.Y); - if (maxY <= node.Y || minY >= node.Y + node.Height) - { - continue; - } - - var horizontalDistance = Math.Min( - Math.Abs(segment.Start.X - node.X), - Math.Abs(segment.Start.X - (node.X + node.Width))); - if (horizontalDistance > 0.5d && horizontalDistance < minLineClearance) - { - return true; - } - } - } - - return false; - } - - private static bool EdgeCrossesNode( - ElkRoutedEdge edge, - (double Left, double Top, double Right, double Bottom, string Id)[] obstacles) - { - foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge)) - { - if (ElkEdgePostProcessor.SegmentCrossesObstacle( - segment.Start, - segment.End, - obstacles, - edge.SourceNodeId, - edge.TargetNodeId)) - { - return true; - } - } - - return false; - } - - private static ElkRoutedEdge AlignProtectedCollectorGatewaySourceExit( - ElkRoutedEdge edge, - ElkPositionedNode sourceNode, - double graphMinY, - double graphMaxY) - { - 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) - { - return edge; - } - - var corridorIndex = 1; - for (var i = 1; i < path.Count; i++) - { - if (path[i].Y < graphMinY - 8d || path[i].Y > graphMaxY + 8d) - { - corridorIndex = i; - break; - } - } - - var corridorPoint = path[corridorIndex]; - var boundaryReferences = sourceNode.Kind == "Decision" - ? new[] - { - (BoundaryReference: path[^1], ExitReference: path[^1]), - (BoundaryReference: corridorPoint, ExitReference: corridorPoint), - (BoundaryReference: corridorPoint, ExitReference: path[^1]), - } - : new[] - { - (BoundaryReference: corridorPoint, ExitReference: corridorPoint), - }; - - List? rebuilt = null; - var bestScore = double.PositiveInfinity; - foreach (var (boundaryReference, exitReference) in boundaryReferences) - { - var boundary = ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, boundaryReference); - boundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(sourceNode, boundary, boundaryReference); - var exteriorApproach = ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, boundary, exitReference); - var desiredExitDx = exitReference.X - boundary.X; - var desiredExitDy = exitReference.Y - boundary.Y; - if (Math.Abs(desiredExitDx) >= Math.Abs(desiredExitDy) * 1.15d && Math.Sign(desiredExitDx) != 0) - { - exteriorApproach = new ElkPoint - { - X = desiredExitDx > 0d - ? sourceNode.X + sourceNode.Width + 8d - : sourceNode.X - 8d, - Y = boundary.Y, - }; - } - else if (Math.Abs(desiredExitDy) >= Math.Abs(desiredExitDx) * 1.15d && Math.Sign(desiredExitDy) != 0) - { - exteriorApproach = new ElkPoint - { - X = boundary.X, - Y = desiredExitDy > 0d - ? sourceNode.Y + sourceNode.Height + 8d - : sourceNode.Y - 8d, - }; - } - - var candidate = new List { boundary }; - if (!ElkEdgeRoutingGeometry.PointsEqual(boundary, exteriorApproach)) - { - candidate.Add(exteriorApproach); - } - - if (!ElkEdgeRoutingGeometry.PointsEqual(candidate[^1], corridorPoint)) - { - var corner = BuildOrthogonalCollectorCorner(candidate[^1], corridorPoint); - if (corner is not null && !ElkEdgeRoutingGeometry.PointsEqual(candidate[^1], corner)) - { - candidate.Add(corner); - } - - if (!ElkEdgeRoutingGeometry.PointsEqual(candidate[^1], corridorPoint)) - { - candidate.Add(corridorPoint); - } - } - - for (var i = corridorIndex + 1; i < path.Count; i++) - { - candidate.Add(path[i]); - } - - candidate = NormalizeProtectedCollectorTail(candidate, graphMinY, graphMaxY); - var score = ScoreProtectedCollectorGatewaySourceExitCandidate(candidate, sourceNode, exitReference); - if (score >= bestScore) - { - continue; - } - - bestScore = score; - rebuilt = candidate; - } - - rebuilt ??= NormalizeProtectedCollectorTail(path, graphMinY, graphMaxY); - - return 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 = rebuilt[0], - EndPoint = rebuilt[^1], - BendPoints = rebuilt.Count > 2 - ? rebuilt.Skip(1).Take(rebuilt.Count - 2).ToArray() - : [], - }, - ], - }; - } - -} \ No newline at end of file +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.Evaluate.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.Evaluate.cs new file mode 100644 index 000000000..fa30f2342 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.Evaluate.cs @@ -0,0 +1,644 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static StrategyEvaluationResult EvaluateStrategy( + StrategyWorkItem workItem, + ElkRoutedEdge[] baselineEdges, + ElkPositionedNode[] nodes, + ElkLayoutOptions layoutOptions, + IterativeRoutingConfig config, + CancellationToken cancellationToken, + ElkLayoutRunDiagnostics? diagnostics) + { + using var diagnosticsScope = diagnostics is null + ? null + : ElkLayoutDiagnostics.Attach(diagnostics); + + const int maxAllowedNodeCrossings = 0; + + var strategy = workItem.Strategy; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] start: bend={strategy.RoutingParams.BendPenalty:F0} " + + $"diag={strategy.RoutingParams.DiagonalPenalty:F0} soft={strategy.RoutingParams.SoftObstacleWeight:F2} " + + $"clearance={strategy.MinLineClearance:F1}"); + + var bestAttemptScore = (EdgeRoutingScore?)null; + ElkRoutedEdge[]? bestAttemptEdges = null; + var bestAttemptRetryState = new RoutingRetryState( + RemainingShortHighways: int.MaxValue, + RepeatCollectorCorridorViolations: int.MaxValue, + RepeatCollectorNodeClearanceViolations: int.MaxValue, + TargetApproachJoinViolations: int.MaxValue, + TargetApproachBacktrackingViolations: int.MaxValue, + ExcessiveDetourViolations: int.MaxValue, + SharedLaneViolations: int.MaxValue, + BoundarySlotViolations: int.MaxValue, + BelowGraphViolations: int.MaxValue, + UnderNodeViolations: int.MaxValue, + LongDiagonalViolations: int.MaxValue, + ProximityViolations: int.MaxValue, + EntryAngleViolations: int.MaxValue, + GatewaySourceExitViolations: int.MaxValue, + LabelProximityViolations: int.MaxValue, + EdgeCrossings: int.MaxValue); + var repairSeedScore = (EdgeRoutingScore?)null; + ElkRoutedEdge[]? repairSeedEdges = null; + var repairSeedRetryState = new RoutingRetryState( + RemainingShortHighways: int.MaxValue, + RepeatCollectorCorridorViolations: int.MaxValue, + RepeatCollectorNodeClearanceViolations: int.MaxValue, + TargetApproachJoinViolations: int.MaxValue, + TargetApproachBacktrackingViolations: int.MaxValue, + ExcessiveDetourViolations: int.MaxValue, + SharedLaneViolations: int.MaxValue, + BoundarySlotViolations: int.MaxValue, + BelowGraphViolations: int.MaxValue, + UnderNodeViolations: int.MaxValue, + LongDiagonalViolations: int.MaxValue, + ProximityViolations: int.MaxValue, + EntryAngleViolations: int.MaxValue, + GatewaySourceExitViolations: int.MaxValue, + LabelProximityViolations: int.MaxValue, + EdgeCrossings: int.MaxValue); + var attemptDetails = new List(); + var fallbackSolutions = new List(); + CandidateSolution? validSolution = null; + var outcome = "no-valid"; + var attempts = 0; + var maxAttempts = config.MaxAdaptationsPerStrategy; + var stagnantAttempts = 0; + string? lastPlateauFingerprint = null; + var repeatedPlateauFingerprintCount = 0; + var recentBlockingCycleFingerprints = new List(4); + string? lastPlannedRepairFocusFingerprint = null; + var repeatedPlannedRepairFocusCount = 0; + string? lastRepairFocusFingerprint = null; + var repeatedRepairFocusCount = 0; + var hasLastAttemptState = false; + var lastAttemptRetryState = new RoutingRetryState( + RemainingShortHighways: int.MaxValue, + RepeatCollectorCorridorViolations: int.MaxValue, + RepeatCollectorNodeClearanceViolations: int.MaxValue, + TargetApproachJoinViolations: int.MaxValue, + TargetApproachBacktrackingViolations: int.MaxValue, + ExcessiveDetourViolations: int.MaxValue, + SharedLaneViolations: int.MaxValue, + BoundarySlotViolations: int.MaxValue, + BelowGraphViolations: int.MaxValue, + UnderNodeViolations: int.MaxValue, + LongDiagonalViolations: int.MaxValue, + ProximityViolations: int.MaxValue, + EntryAngleViolations: int.MaxValue, + GatewaySourceExitViolations: int.MaxValue, + LabelProximityViolations: int.MaxValue, + EdgeCrossings: int.MaxValue); + var lastAttemptNodeCrossings = int.MaxValue; + var consecutiveNonImprovingAttempts = 0; + var strategyStopwatch = Stopwatch.StartNew(); + ElkIterativeStrategyDiagnostics? liveStrategyDiagnostics = null; + + if (diagnostics is not null) + { + liveStrategyDiagnostics = new ElkIterativeStrategyDiagnostics + { + StrategyIndex = workItem.StrategyIndex, + OrderingName = workItem.StrategyName, + Attempts = 0, + TotalDurationMs = 0, + BestScore = null, + Outcome = "running", + BendPenalty = workItem.Strategy.RoutingParams.BendPenalty, + DiagonalPenalty = workItem.Strategy.RoutingParams.DiagonalPenalty, + SoftObstacleWeight = workItem.Strategy.RoutingParams.SoftObstacleWeight, + RegisteredLive = true, + }; + lock (diagnostics.SyncRoot) + { + diagnostics.IterativeStrategies.Add(liveStrategyDiagnostics); + } + + ElkLayoutDiagnostics.FlushSnapshot(diagnostics); + } + + for (var attempt = 0; attempt < maxAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + attempts++; + var attemptStopwatch = Stopwatch.StartNew(); + var phaseTimings = new List(); + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] attempt {attempt + 1} start"); + + T MeasurePhase(string phaseName, Func action) + { + var phaseStopwatch = Stopwatch.StartNew(); + var value = action(); + phaseStopwatch.Stop(); + phaseTimings.Add(new ElkIterativePhaseDiagnostics + { + Phase = phaseName, + DurationMs = Math.Round(phaseStopwatch.Elapsed.TotalMilliseconds, 3), + }); + return value; + } + + RepairPlan? repairPlan = null; + RouteAllEdgesResult? routeResult; + if (attempt == 0 || repairSeedEdges is null || repairSeedScore is null) + { + routeResult = MeasurePhase( + "route-all-edges", + () => RouteAllEdges(baselineEdges, nodes, config.ObstacleMargin, strategy, cancellationToken)); + } + else + { + repairPlan = MeasurePhase( + "select-repair-targets", + () => BuildRepairPlan(repairSeedEdges, nodes, repairSeedScore.Value, repairSeedRetryState, strategy, attempt)); + if (repairPlan is null) + { + outcome = $"no-repair-targets({DescribeRetryState(repairSeedRetryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + + var plannedRepairFocusFingerprint = BuildRepairFocusFingerprint(repairPlan); + if (!string.IsNullOrEmpty(plannedRepairFocusFingerprint)) + { + if (string.Equals(plannedRepairFocusFingerprint, lastPlannedRepairFocusFingerprint, StringComparison.Ordinal)) + { + repeatedPlannedRepairFocusCount++; + } + else + { + lastPlannedRepairFocusFingerprint = plannedRepairFocusFingerprint; + repeatedPlannedRepairFocusCount = 0; + } + + if (repeatedPlannedRepairFocusCount >= 1 && attempt >= 2) + { + outcome = $"stalled-same-repair-plan({DescribeRetryState(repairSeedRetryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + } + else + { + lastPlannedRepairFocusFingerprint = null; + repeatedPlannedRepairFocusCount = 0; + } + + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] attempt {attempt + 1} local-repair " + + $"edges=[{string.Join(", ", repairPlan.Value.EdgeIds)}] reasons=[{string.Join(", ", repairPlan.Value.Reasons)}]"); + routeResult = MeasurePhase( + "route-penalized-edges", + () => RepairPenalizedEdges( + repairSeedEdges, + nodes, + config.ObstacleMargin, + strategy, + repairPlan.Value, + cancellationToken, + config.MaxParallelRepairBuilds)); + } + if (routeResult is null) + { + outcome = "route-failed"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}@attempt{attempt + 1}"); + break; + } + + var candidateEdges = routeResult.Edges; + var scopedCleanupEdgeIds = routeResult.Diagnostics?.Mode == "local-repair" + ? routeResult.Diagnostics.RepairedEdgeIds.ToArray() + : null; + if (scopedCleanupEdgeIds is { Length: > 0 }) + { + candidateEdges = MeasurePhase( + "targeted-terminal-rule-cleanup", + () => ApplyTerminalRuleCleanupRound( + candidateEdges, + nodes, + layoutOptions.Direction, + strategy.MinLineClearance, + scopedCleanupEdgeIds)); + } + else + { + candidateEdges = MeasurePhase( + "snap-anchors", + () => ElkEdgePostProcessor.SnapAnchorsToNodeBoundary(candidateEdges, nodes)); + candidateEdges = MeasurePhase( + "eliminate-diagonals", + () => ElkEdgePostProcessor.EliminateDiagonalSegments(candidateEdges, nodes)); + candidateEdges = MeasurePhase( + "avoid-node-crossings-1", + () => ElkEdgePostProcessor.AvoidNodeCrossings(candidateEdges, nodes, layoutOptions.Direction)); + candidateEdges = MeasurePhase( + "simplify-1", + () => ElkEdgePostProcessorSimplify.SimplifyEdgePaths(candidateEdges, nodes)); + candidateEdges = MeasurePhase( + "tighten-corridors", + () => ElkEdgePostProcessorSimplify.TightenOuterCorridors(candidateEdges, nodes)); + if (HighwayProcessingEnabled) + { + candidateEdges = MeasurePhase( + "break-short-highways", + () => ElkEdgeRouterHighway.BreakShortHighways(candidateEdges, nodes)); + } + + candidateEdges = MeasurePhase( + "terminal-rule-cleanup", + () => ApplyTerminalRuleCleanupRound( + candidateEdges, + nodes, + layoutOptions.Direction, + strategy.MinLineClearance)); + } + + var score = MeasurePhase( + "compute-score", + () => ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes)); + var remainingBrokenHighways = HighwayProcessingEnabled + ? MeasurePhase( + "detect-broken-highways", + () => ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count) + : 0; + var retryState = BuildRetryState(score, remainingBrokenHighways); + + if (retryState.RequiresBlockingRetry || retryState.RequiresLengthRetry) + { + var focusedRepair = MeasurePhase( + "repair-verified-issues", + () => TryApplyVerifiedIssueRepairRound( + candidateEdges, + nodes, + config.ObstacleMargin, + strategy, + retryState, + layoutOptions.Direction, + cancellationToken, + config.MaxParallelRepairBuilds)); + if (focusedRepair is { } repaired + && IsBetterBoundarySlotRepairCandidate( + repaired.Score, + repaired.RetryState, + score, + retryState)) + { + candidateEdges = repaired.Edges; + score = repaired.Score; + remainingBrokenHighways = repaired.RemainingBrokenHighways; + retryState = repaired.RetryState; + } + } + + if (retryState.RequiresBlockingRetry || retryState.RequiresLengthRetry) + { + var stabilizedEdges = MeasurePhase( + "stabilize-terminal-rules", + () => ApplyTerminalRuleCleanupRound( + candidateEdges, + nodes, + layoutOptions.Direction, + strategy.MinLineClearance, + scopedCleanupEdgeIds)); + var stabilizedScore = MeasurePhase( + "compute-score-stabilized", + () => ElkEdgeRoutingScoring.ComputeScore(stabilizedEdges, nodes)); + var stabilizedBrokenHighways = HighwayProcessingEnabled + ? MeasurePhase( + "detect-broken-highways-stabilized", + () => ElkEdgeRouterHighway.DetectRemainingBrokenHighways(stabilizedEdges, nodes).Count) + : 0; + var stabilizedRetryState = BuildRetryState(stabilizedScore, stabilizedBrokenHighways); + if (IsBetterBoundarySlotRepairCandidate( + stabilizedScore, + stabilizedRetryState, + score, + retryState)) + { + candidateEdges = stabilizedEdges; + score = stabilizedScore; + remainingBrokenHighways = stabilizedBrokenHighways; + retryState = stabilizedRetryState; + } + } + + if (attempt == 0) + { + maxAttempts = DetermineAdaptiveAttemptBudget(retryState, config.MaxAdaptationsPerStrategy); + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] attempt-budget={maxAttempts}"); + } + + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] attempt {attempt + 1} " + + $"score={score.Value:F0} retry={DescribeRetryState(retryState)}"); + var candidate = new CandidateSolution(score, retryState, candidateEdges, workItem.StrategyIndex); + fallbackSolutions.Add(candidate); + repairSeedScore = score; + repairSeedEdges = candidateEdges; + repairSeedRetryState = retryState; + + var repairFocusFingerprint = BuildRepairFocusFingerprint(repairPlan); + if (!string.IsNullOrEmpty(repairFocusFingerprint)) + { + if (string.Equals(repairFocusFingerprint, lastRepairFocusFingerprint, StringComparison.Ordinal)) + { + repeatedRepairFocusCount++; + } + else + { + lastRepairFocusFingerprint = repairFocusFingerprint; + repeatedRepairFocusCount = 0; + } + + if (repeatedRepairFocusCount >= 1 && attempt >= 3) + { + outcome = $"stalled-same-focus({DescribeRetryState(retryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + } + else + { + lastRepairFocusFingerprint = null; + repeatedRepairFocusCount = 0; + } + + var plateauFingerprint = BuildPlateauFingerprint(retryState, repairPlan); + if (string.Equals(plateauFingerprint, lastPlateauFingerprint, StringComparison.Ordinal)) + { + repeatedPlateauFingerprintCount++; + } + else + { + lastPlateauFingerprint = plateauFingerprint; + repeatedPlateauFingerprintCount = 0; + } + + if (repeatedPlateauFingerprintCount >= 2 && attempt >= 3) + { + outcome = $"stalled-repeat({DescribeRetryState(retryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + + var blockingCycleFingerprint = BuildBlockingCycleFingerprint(retryState, repairPlan); + if (ShouldStopForBlockingCycle( + recentBlockingCycleFingerprints, + blockingCycleFingerprint, + retryState, + bestAttemptRetryState, + attempt)) + { + outcome = $"stalled-cycle({DescribeRetryState(retryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + + AppendRecentFingerprint(recentBlockingCycleFingerprints, blockingCycleFingerprint, 4); + + if (hasLastAttemptState) + { + var retryStateComparison = CompareRetryStates(retryState, lastAttemptRetryState); + var improvedBoundarySlotsSinceLast = + retryState.BoundarySlotViolations < lastAttemptRetryState.BoundarySlotViolations + && score.NodeCrossings <= lastAttemptNodeCrossings + && !HasBlockingBoundarySlotPromotionRegression(retryState, lastAttemptRetryState); + if (!improvedBoundarySlotsSinceLast + && retryStateComparison >= 0 + && score.NodeCrossings >= lastAttemptNodeCrossings) + { + consecutiveNonImprovingAttempts++; + } + else + { + consecutiveNonImprovingAttempts = 0; + } + + if (consecutiveNonImprovingAttempts >= 2 && attempt >= 3) + { + outcome = $"stalled-no-progress({DescribeRetryState(retryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + } + + hasLastAttemptState = true; + lastAttemptRetryState = retryState; + lastAttemptNodeCrossings = score.NodeCrossings; + + var improvedAttempt = bestAttemptScore is null + || IsBetterBoundarySlotRepairCandidate( + score, + retryState, + bestAttemptScore.Value, + bestAttemptRetryState); + var improvedRuleState = bestAttemptScore is null + || (retryState.BoundarySlotViolations < bestAttemptRetryState.BoundarySlotViolations + && score.NodeCrossings <= bestAttemptScore.Value.NodeCrossings + && !HasBlockingBoundarySlotPromotionRegression(retryState, bestAttemptRetryState)) + || CompareRetryStates(retryState, bestAttemptRetryState) < 0 + || score.NodeCrossings < bestAttemptScore.Value.NodeCrossings; + if (improvedAttempt) + { + bestAttemptScore = score; + bestAttemptEdges = candidateEdges; + bestAttemptRetryState = retryState; + stagnantAttempts = improvedRuleState + ? 0 + : stagnantAttempts + 1; + if (ShouldStopForStagnation(stagnantAttempts, attempt, config.MaxAdaptationsPerStrategy)) + { + outcome = $"stalled({DescribeRetryState(retryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + } + else + { + stagnantAttempts++; + if (ShouldStopForStagnation(stagnantAttempts, attempt, config.MaxAdaptationsPerStrategy)) + { + outcome = $"stalled({DescribeRetryState(retryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + } + + var attemptOutcome = score.NodeCrossings > maxAllowedNodeCrossings + ? $"hard-violation(nc={score.NodeCrossings}>{maxAllowedNodeCrossings})" + : retryState.RequiresPrimaryRetry + ? $"retry({DescribeRetryState(retryState)})" + : ShouldRetryForEdgeCrossings(retryState, attempt, config.MaxAdaptationsPerStrategy) + ? $"retry(edge-crossings={retryState.EdgeCrossings})" + : "valid"; + + if (diagnostics is not null) + { + attemptStopwatch.Stop(); + var attemptDiagnostics = new ElkIterativeAttemptDiagnostics + { + Attempt = attempt + 1, + TotalDurationMs = Math.Round(attemptStopwatch.Elapsed.TotalMilliseconds, 3), + Score = score, + Outcome = attemptOutcome, + RouteDiagnostics = routeResult.Diagnostics, + Edges = candidateEdges, + }; + attemptDiagnostics.PhaseTimings.AddRange(phaseTimings); + attemptDetails.Add(attemptDiagnostics); + + if (liveStrategyDiagnostics is not null) + { + lock (diagnostics.SyncRoot) + { + liveStrategyDiagnostics.Attempts = attempts; + liveStrategyDiagnostics.TotalDurationMs = Math.Round(strategyStopwatch.Elapsed.TotalMilliseconds, 3); + liveStrategyDiagnostics.BestScore = bestAttemptScore; + liveStrategyDiagnostics.Outcome = attemptOutcome; + liveStrategyDiagnostics.AttemptDetails.Add(attemptDiagnostics); + } + + ElkLayoutDiagnostics.FlushSnapshot(diagnostics); + } + } + + if (score.NodeCrossings > maxAllowedNodeCrossings) + { + outcome = $"{attemptOutcome}@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] adapting after node crossing violation"); + strategy.AdaptForViolations(score, attempt, retryState); + continue; + } + + if (retryState.RemainingShortHighways > 0 + || retryState.RepeatCollectorCorridorViolations > 0 + || retryState.RepeatCollectorNodeClearanceViolations > 0 + || retryState.TargetApproachJoinViolations > 0 + || retryState.TargetApproachBacktrackingViolations > 0 + || retryState.SharedLaneViolations > 0 + || retryState.BelowGraphViolations > 0 + || retryState.UnderNodeViolations > 0 + || retryState.LongDiagonalViolations > 0 + || retryState.EntryAngleViolations > 0 + || retryState.GatewaySourceExitViolations > 0) + { + if (ShouldRetryForPrimaryViolations(retryState, attempt, config.MaxAdaptationsPerStrategy)) + { + outcome = $"{attemptOutcome}@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] adapting for blocking violations"); + strategy.AdaptForViolations(score, attempt, retryState); + continue; + } + + outcome = $"invalid({DescribeRetryState(retryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + + if (retryState.RequiresLengthRetry) + { + if (ShouldRetryForPrimaryViolations(retryState, attempt, config.MaxAdaptationsPerStrategy)) + { + outcome = $"{attemptOutcome}@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] adapting for shortest-path / detour violations"); + strategy.AdaptForViolations(score, attempt, retryState); + continue; + } + + outcome = $"invalid({DescribeRetryState(retryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + + if (retryState.RequiresQualityRetry) + { + if (ShouldRetryForPrimaryViolations(retryState, attempt, config.MaxAdaptationsPerStrategy)) + { + outcome = $"{attemptOutcome}@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] adapting for quality/length violations"); + strategy.AdaptForViolations(score, attempt, retryState); + continue; + } + } + + if (ShouldRetryForEdgeCrossings(retryState, attempt, config.MaxAdaptationsPerStrategy)) + { + outcome = $"{attemptOutcome}@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] adapting for edge crossings"); + strategy.AdaptForViolations(score, attempt, retryState); + continue; + } + + var residualSoftViolations = retryState.RequiresQualityRetry || retryState.EdgeCrossings > 0; + outcome = residualSoftViolations + ? $"valid-soft({DescribeRetryState(retryState)})@attempt{attempt + 1}" + : $"valid@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + validSolution = candidate; + break; + } + + var stratDiag = new ElkIterativeStrategyDiagnostics + { + StrategyIndex = workItem.StrategyIndex, + OrderingName = workItem.StrategyName, + Attempts = attempts, + TotalDurationMs = Math.Round(strategyStopwatch.Elapsed.TotalMilliseconds, 3), + BestScore = bestAttemptScore, + Outcome = outcome, + BendPenalty = workItem.Strategy.RoutingParams.BendPenalty, + DiagonalPenalty = workItem.Strategy.RoutingParams.DiagonalPenalty, + SoftObstacleWeight = workItem.Strategy.RoutingParams.SoftObstacleWeight, + BestEdges = bestAttemptEdges, + }; + stratDiag.AttemptDetails.AddRange(attemptDetails); + + if (liveStrategyDiagnostics is not null && diagnostics is not null) + { + lock (diagnostics.SyncRoot) + { + liveStrategyDiagnostics.Attempts = attempts; + liveStrategyDiagnostics.TotalDurationMs = Math.Round(strategyStopwatch.Elapsed.TotalMilliseconds, 3); + liveStrategyDiagnostics.BestScore = bestAttemptScore; + liveStrategyDiagnostics.Outcome = outcome; + liveStrategyDiagnostics.BestEdges = bestAttemptEdges; + } + + ElkLayoutDiagnostics.FlushSnapshot(diagnostics); + } + + return new StrategyEvaluationResult( + workItem.StrategyIndex, + fallbackSolutions, + validSolution, + stratDiag); + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.Fingerprints.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.Fingerprints.cs new file mode 100644 index 000000000..b5d80dd99 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.Fingerprints.cs @@ -0,0 +1,206 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static int DetermineRepairEdgeBudget( + RoutingRetryState retryState, + bool detourPriority, + int seededEdgeCount, + int mandatoryEdgeCount) + { + if (detourPriority) + { + return Math.Clamp(Math.Max(2, seededEdgeCount), 2, 6); + } + + var budget = retryState.RequiresBlockingRetry + ? 3 + : retryState.RequiresLengthRetry + ? 2 + : 2; + if (retryState.ProximityViolations >= 6 || retryState.EdgeCrossings >= 8) + { + budget++; + } + + budget = Math.Max(budget, Math.Min(6, Math.Max(2, seededEdgeCount))); + return Math.Clamp(budget, 2, 6); + } + + private static int DetermineMandatoryFocusBudget( + int mandatoryEdgeCount, + int repairBudget, + int seededEdgeCount) + { + if (mandatoryEdgeCount <= 0) + { + return 0; + } + + if (mandatoryEdgeCount <= repairBudget) + { + return mandatoryEdgeCount; + } + + var focusBudget = Math.Max(2, Math.Max(seededEdgeCount, repairBudget - 1)); + return Math.Min(mandatoryEdgeCount, Math.Clamp(focusBudget, 2, 6)); + } + + private static IEnumerable RotateOrderedEdgeIds( + IReadOnlyList edgeIds, + int attempt) + { + if (edgeIds.Count == 0) + { + yield break; + } + + var offset = edgeIds.Count == 0 + ? 0 + : Math.Max(0, attempt - 1) % edgeIds.Count; + for (var i = 0; i < edgeIds.Count; i++) + { + yield return edgeIds[(offset + i) % edgeIds.Count]; + } + } + + private static string BuildPlateauFingerprint( + RoutingRetryState retryState, + RepairPlan? repairPlan) + { + if (repairPlan is null) + { + return $"full::{DescribeRetryState(retryState)}"; + } + + return string.Create( + CultureInfo.InvariantCulture, + $"{DescribeRetryState(retryState)}::{string.Join(",", repairPlan.Value.EdgeIds.OrderBy(id => id, StringComparer.Ordinal))}"); + } + + private static string BuildBlockingCycleFingerprint( + RoutingRetryState retryState, + RepairPlan? repairPlan) + { + var classes = new List(8); + if (retryState.RemainingShortHighways > 0) + { + classes.Add("short-highways"); + } + + if (retryState.RepeatCollectorCorridorViolations > 0) + { + classes.Add("collector-corridors"); + } + + if (retryState.RepeatCollectorNodeClearanceViolations > 0) + { + classes.Add("collector-clearance"); + } + + if (retryState.TargetApproachJoinViolations > 0) + { + classes.Add("target-joins"); + } + + if (retryState.TargetApproachBacktrackingViolations > 0) + { + classes.Add("approach-backtracking"); + } + + if (retryState.SharedLaneViolations > 0) + { + classes.Add("shared-lanes"); + } + + if (retryState.BelowGraphViolations > 0) + { + classes.Add("below-graph"); + } + + if (retryState.UnderNodeViolations > 0) + { + classes.Add("under-node"); + } + + if (retryState.LongDiagonalViolations > 0) + { + classes.Add("long-diagonal"); + } + + if (retryState.EntryAngleViolations > 0) + { + classes.Add("entry"); + } + + if (retryState.GatewaySourceExitViolations > 0) + { + classes.Add("gateway-source"); + } + + if (retryState.ExcessiveDetourViolations > 0) + { + classes.Add("detour"); + } + + var repairReasons = repairPlan is null + ? "full" + : string.Join(",", repairPlan.Value.Reasons.OrderBy(reason => reason, StringComparer.Ordinal)); + return string.Create( + CultureInfo.InvariantCulture, + $"{string.Join("|", classes)}::{repairReasons}"); + } + + private static bool ShouldStopForBlockingCycle( + IReadOnlyList recentFingerprints, + string fingerprint, + RoutingRetryState retryState, + RoutingRetryState bestRetryState, + int attempt) + { + if (attempt < 3 || recentFingerprints.Count < 3) + { + return false; + } + + var repeatCount = recentFingerprints.Count(item => string.Equals(item, fingerprint, StringComparison.Ordinal)); + return repeatCount >= 2 + && retryState.BlockingViolationCount >= bestRetryState.BlockingViolationCount + && retryState.LengthViolationCount >= bestRetryState.LengthViolationCount; + } + + private static void AppendRecentFingerprint( + List recentFingerprints, + string fingerprint, + int capacity) + { + if (capacity <= 0) + { + return; + } + + if (recentFingerprints.Count >= capacity) + { + recentFingerprints.RemoveAt(0); + } + + recentFingerprints.Add(fingerprint); + } + + private static string? BuildRepairFocusFingerprint(RepairPlan? repairPlan) + { + if (repairPlan is null) + { + return null; + } + + return string.Create( + CultureInfo.InvariantCulture, + $"{string.Join(",", repairPlan.Value.Reasons.OrderBy(reason => reason, StringComparer.Ordinal))}::{string.Join(",", repairPlan.Value.EdgeIds.OrderBy(id => id, StringComparer.Ordinal))}"); + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.ParallelBuilds.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.ParallelBuilds.cs new file mode 100644 index 000000000..ee9d8addd --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.ParallelBuilds.cs @@ -0,0 +1,226 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static int DetermineRepairBuildParallelism(int repairEdgeCount, int maxParallelRepairBuilds) + { + if (repairEdgeCount <= 1) + { + return 1; + } + + var requestedParallelism = Math.Clamp( + maxParallelRepairBuilds, + 1, + Math.Max(1, Environment.ProcessorCount)); + return Math.Min(repairEdgeCount, requestedParallelism); + } + + private static bool CanParallelizeRepairBuilds( + IReadOnlyList orderedRepairIndices, + IReadOnlyList existingEdges) + { + if (orderedRepairIndices.Count <= 1) + { + return false; + } + + var seenLockKeys = new HashSet(StringComparer.Ordinal); + foreach (var edgeIndex in orderedRepairIndices) + { + if (edgeIndex < 0 || edgeIndex >= existingEdges.Count) + { + continue; + } + + foreach (var lockKey in GetRepairBuildLockKeys(existingEdges[edgeIndex])) + { + if (!seenLockKeys.Add(lockKey)) + { + return false; + } + } + } + + return true; + } + + private static RepairEdgeBuildResult BuildRepairEdgeResult( + int edgeIndex, + ElkRoutedEdge[] existingEdges, + ElkPositionedNode[] nodes, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + IReadOnlyDictionary spreadEndpoints, + IReadOnlyDictionary nodesById, + IReadOnlyList softObstacles, + IReadOnlySet routeRepairEdgeIdSet, + IReadOnlySet preferredShortestEdgeIdSet, + IReadOnlyCollection repairReasons, + double graphMinY, + double graphMaxY, + RoutingStrategy strategy, + CancellationToken cancellationToken) + { + var edge = existingEdges[edgeIndex]; + if (!CanRouteSelectedRepairEdge(edge, graphMinY, graphMaxY, routeRepairEdgeIdSet)) + { + return new RepairEdgeBuildResult(edge, 0, 0, true); + } + + var sourceNode = nodesById.GetValueOrDefault(edge.SourceNodeId ?? string.Empty); + var targetNode = nodesById.GetValueOrDefault(edge.TargetNodeId ?? string.Empty); + var aggressiveRepairNeeded = routeRepairEdgeIdSet.Contains(edge.Id) + && repairReasons.Any(static reason => + reason is "under-node" + or "entry" + or "gateway-source-exit" + or "target-joins" + or "below-graph"); + var newSections = new List(edge.Sections.Count); + var routedSections = 0; + var fallbackSections = 0; + + foreach (var section in edge.Sections) + { + var endPoint = spreadEndpoints.TryGetValue(edge.Id, out var spread) + ? spread + : section.EndPoint; + var (startPoint, adjustedEndPoint) = ResolveRoutingEndpoints( + section.StartPoint, + endPoint, + edge.SourceNodeId, + edge.TargetNodeId, + nodesById); + List? rerouted = null; + if (preferredShortestEdgeIdSet.Contains(edge.Id)) + { + rerouted = TryRouteShortestRepair( + startPoint, + adjustedEndPoint, + nodes, + obstacles, + edge.SourceNodeId ?? string.Empty, + edge.TargetNodeId ?? string.Empty, + sourceNode, + targetNode, + strategy.RoutingParams, + softObstacles, + cancellationToken); + } + + if (aggressiveRepairNeeded) + { + var aggressiveReroute = TryRouteAggressiveRepair( + startPoint, + adjustedEndPoint, + nodes, + obstacles, + edge.SourceNodeId ?? string.Empty, + edge.TargetNodeId ?? string.Empty, + sourceNode, + targetNode, + strategy.RoutingParams, + cancellationToken); + rerouted = ChooseBetterLocalRepairCandidate( + edge, + nodes, + rerouted, + aggressiveReroute); + } + + rerouted ??= ElkEdgeRouterAStar8Dir.Route( + startPoint, + adjustedEndPoint, + obstacles, + edge.SourceNodeId ?? string.Empty, + edge.TargetNodeId ?? string.Empty, + strategy.RoutingParams, + softObstacles, + cancellationToken); + + if (rerouted is not null && rerouted.Count >= 2) + { + routedSections++; + newSections.Add(new ElkEdgeSection + { + StartPoint = rerouted[0], + EndPoint = rerouted[^1], + BendPoints = rerouted.Skip(1).Take(rerouted.Count - 2).ToArray(), + }); + } + else + { + fallbackSections++; + newSections.Add(section); + } + } + + return new RepairEdgeBuildResult( + new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + SourcePortId = edge.SourcePortId, + TargetPortId = edge.TargetPortId, + Kind = edge.Kind, + Label = edge.Label, + Sections = newSections, + }, + routedSections, + fallbackSections, + false); + } + + private static string[] GetRepairBuildLockKeys(ElkRoutedEdge edge) + { + return new[] + { + $"source:{edge.SourceNodeId ?? string.Empty}", + $"target:{edge.TargetNodeId ?? string.Empty}", + } + .Where(static key => !key.EndsWith(':')) + .Distinct(StringComparer.Ordinal) + .OrderBy(static key => key, StringComparer.Ordinal) + .ToArray(); + } + + private static void ExecuteWithRepairBuildLocks( + ConcurrentDictionary lockRegistry, + IReadOnlyList lockKeys, + Action action) + { + static void ExecuteLocked( + IReadOnlyList locks, + int index, + Action action) + { + if (index >= locks.Count) + { + action(); + return; + } + + lock (locks[index]) + { + ExecuteLocked(locks, index + 1, action); + } + } + + if (lockKeys.Count == 0) + { + action(); + return; + } + + var locks = lockKeys + .Select(key => lockRegistry.GetOrAdd(key, static _ => new object())) + .ToArray(); + ExecuteLocked(locks, 0, action); + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.RepairPenalizedEdges.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.RepairPenalizedEdges.cs new file mode 100644 index 000000000..29ff01b8d --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.RepairPenalizedEdges.cs @@ -0,0 +1,205 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static RouteAllEdgesResult RepairPenalizedEdges( + ElkRoutedEdge[] existingEdges, + ElkPositionedNode[] nodes, + double baseObstacleMargin, + RoutingStrategy strategy, + RepairPlan repairPlan, + CancellationToken cancellationToken, + int maxParallelRepairBuilds, + bool trustIndependentParallelBuilds = false) + { + var routedEdges = new ElkRoutedEdge[existingEdges.Length]; + Array.Copy(existingEdges, routedEdges, existingEdges.Length); + + var obstacleMargin = Math.Max( + baseObstacleMargin, + Math.Max(strategy.MinLineClearance + 4d, strategy.RoutingParams.Margin)); + var obstacles = BuildObstacles(nodes, obstacleMargin); + var graphMinY = nodes.Length > 0 ? nodes.Min(n => n.Y) : 0d; + var graphMaxY = nodes.Length > 0 ? nodes.Max(n => n.Y + n.Height) : 0d; + var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); + var routedEdgeCount = 0; + var skippedEdgeCount = 0; + var routedSectionCount = 0; + var fallbackSectionCount = 0; + var repairSet = repairPlan.EdgeIndices.ToHashSet(); + var routeRepairEdgeIdSet = repairPlan.RouteRepairEdgeIds.ToHashSet(StringComparer.Ordinal); + var collectorRepairSet = repairPlan.Reasons.Contains("collector-corridors", StringComparer.Ordinal) + ? repairSet + .Where(edgeIndex => edgeIndex >= 0 + && edgeIndex < existingEdges.Length + && ElkEdgePostProcessor.IsRepeatCollectorLabel(existingEdges[edgeIndex].Label)) + .Where(edgeIndex => !routeRepairEdgeIdSet.Contains(existingEdges[edgeIndex].Id)) + .ToHashSet() + : []; + var preferredShortestEdgeIdSet = repairPlan.PreferredShortestEdgeIds.ToHashSet(StringComparer.Ordinal); + if (collectorRepairSet.Count > 0) + { + var collectorEdgeIds = collectorRepairSet + .Select(edgeIndex => existingEdges[edgeIndex].Id) + .ToArray(); + routedEdges = ElkRepeatCollectorCorridors.SeparateSharedLanes(routedEdges, nodes, collectorEdgeIds); + routedEdgeCount += collectorRepairSet.Count; + } + + var aStarRepairSet = repairSet + .Where(edgeIndex => !collectorRepairSet.Contains(edgeIndex)) + .ToHashSet(); + var spreadEndpoints = SpreadTargetEndpoints(existingEdges, nodesById, graphMinY, graphMaxY, strategy.MinLineClearance); + var softObstacles = new List(); + + for (var edgeIndex = 0; edgeIndex < existingEdges.Length; edgeIndex++) + { + if (aStarRepairSet.Contains(edgeIndex)) + { + continue; + } + + foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(routedEdges[edgeIndex])) + { + softObstacles.Add(new OrthogonalSoftObstacle(segment.Start, segment.End)); + } + } + + var orderedRepairIndices = strategy.EdgeOrder + .Where(aStarRepairSet.Contains) + .Concat(aStarRepairSet.Where(edgeIndex => !strategy.EdgeOrder.Contains(edgeIndex))) + .Distinct() + .ToArray(); + var repairBuilderParallelism = (trustIndependentParallelBuilds || CanParallelizeRepairBuilds(orderedRepairIndices, existingEdges)) + ? DetermineRepairBuildParallelism(orderedRepairIndices.Length, maxParallelRepairBuilds) + : 1; + var builtRepairResults = new ConcurrentDictionary(); + var repairBuildLocks = new ConcurrentDictionary(StringComparer.Ordinal); + if (repairBuilderParallelism > 1 && orderedRepairIndices.Length > 1) + { + var immutableSoftObstacles = softObstacles.ToArray(); + var parallelOptions = new ParallelOptions + { + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = repairBuilderParallelism, + }; + + Parallel.ForEach( + orderedRepairIndices, + parallelOptions, + edgeIndex => + { + if (edgeIndex < 0 || edgeIndex >= existingEdges.Length) + { + return; + } + + var edge = existingEdges[edgeIndex]; + var lockKeys = trustIndependentParallelBuilds + ? [] + : GetRepairBuildLockKeys(edge); + ExecuteWithRepairBuildLocks( + repairBuildLocks, + lockKeys, + () => + { + builtRepairResults[edgeIndex] = BuildRepairEdgeResult( + edgeIndex, + existingEdges, + nodes, + obstacles, + spreadEndpoints, + nodesById, + immutableSoftObstacles, + routeRepairEdgeIdSet, + preferredShortestEdgeIdSet, + repairPlan.Reasons, + graphMinY, + graphMaxY, + strategy, + cancellationToken); + }); + }); + } + + foreach (var edgeIndex in orderedRepairIndices) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (edgeIndex < 0 || edgeIndex >= existingEdges.Length) + { + continue; + } + + var buildResult = builtRepairResults.TryGetValue(edgeIndex, out var parallelBuildResult) + ? parallelBuildResult + : BuildRepairEdgeResult( + edgeIndex, + existingEdges, + nodes, + obstacles, + spreadEndpoints, + nodesById, + softObstacles, + routeRepairEdgeIdSet, + preferredShortestEdgeIdSet, + repairPlan.Reasons, + graphMinY, + graphMaxY, + strategy, + cancellationToken); + if (buildResult.WasSkipped) + { + skippedEdgeCount++; + foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(buildResult.Edge)) + { + softObstacles.Add(new OrthogonalSoftObstacle(segment.Start, segment.End)); + } + + continue; + } + + routedSectionCount += buildResult.RoutedSections; + fallbackSectionCount += buildResult.FallbackSections; + routedEdgeCount++; + routedEdges[edgeIndex] = buildResult.Edge; + + foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(routedEdges[edgeIndex])) + { + softObstacles.Add(new OrthogonalSoftObstacle(segment.Start, segment.End)); + } + } + + var repeatRouteRepairIds = repairPlan.RouteRepairEdgeIds + .Where(edgeId => routedEdges.Any(edge => + string.Equals(edge.Id, edgeId, StringComparison.Ordinal) + && ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label))) + .ToArray(); + if (repeatRouteRepairIds.Length > 0) + { + routedEdges = ElkRepeatCollectorCorridors.SeparateSharedLanes(routedEdges, nodes, repeatRouteRepairIds); + } + + return new RouteAllEdgesResult( + routedEdges, + new ElkIterativeRouteDiagnostics + { + Mode = "local-repair", + TotalEdges = existingEdges.Length, + RoutedEdges = routedEdgeCount, + SkippedEdges = skippedEdgeCount, + RoutedSections = routedSectionCount, + FallbackSections = fallbackSectionCount, + SoftObstacleSegments = softObstacles.Count, + RepairedEdgeIds = repairPlan.EdgeIds, + RepairReasons = repairPlan.Reasons, + BuilderMode = repairBuilderParallelism > 1 ? "parallel-locked-local-build" : "sequential-local-build", + BuilderParallelism = repairBuilderParallelism, + }); + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.RepairPlan.Expanders.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.RepairPlan.Expanders.cs new file mode 100644 index 000000000..5f627767f --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.RepairPlan.Expanders.cs @@ -0,0 +1,215 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static string[] ExpandRepeatCollectorRepairSet( + IReadOnlyCollection selectedEdgeIds, + IReadOnlyCollection edges, + IReadOnlyCollection nodes) + { + var selected = selectedEdgeIds.ToHashSet(StringComparer.Ordinal); + foreach (var group in ElkRepeatCollectorCorridors.DetectSharedLaneGroups(edges, nodes)) + { + if (!group.EdgeIds.Any(selected.Contains)) + { + continue; + } + + foreach (var edgeId in group.EdgeIds) + { + selected.Add(edgeId); + } + } + + return selected + .OrderBy(edgeId => edgeId, StringComparer.Ordinal) + .ToArray(); + } + + private static string[] ExpandTargetApproachJoinRepairSet( + IReadOnlyCollection selectedEdgeIds, + IReadOnlyCollection edges, + IReadOnlyCollection nodes, + double minLineClearance) + { + var selected = selectedEdgeIds.ToHashSet(StringComparer.Ordinal); + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var edgeArray = edges.ToArray(); + + foreach (var group in edgeArray.GroupBy(edge => edge.TargetNodeId ?? string.Empty, StringComparer.Ordinal)) + { + if (string.IsNullOrWhiteSpace(group.Key) + || !nodesById.TryGetValue(group.Key, out var targetNode)) + { + continue; + } + + var targetEdges = group.ToArray(); + for (var i = 0; i < targetEdges.Length; i++) + { + var leftEdge = targetEdges[i]; + var leftPath = ExtractPath(leftEdge); + if (leftPath.Count < 2) + { + continue; + } + + var leftSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(leftPath[^1], leftPath[^2], targetNode); + for (var j = i + 1; j < targetEdges.Length; j++) + { + var rightEdge = targetEdges[j]; + var rightPath = ExtractPath(rightEdge); + if (rightPath.Count < 2) + { + continue; + } + + var rightSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(rightPath[^1], rightPath[^2], targetNode); + if (!string.Equals(leftSide, rightSide, StringComparison.Ordinal)) + { + continue; + } + + if (!HasTargetApproachJoinPair(leftPath, rightPath, minLineClearance)) + { + continue; + } + + selected.Add(leftEdge.Id); + selected.Add(rightEdge.Id); + } + } + } + + return selected + .OrderBy(edgeId => edgeId, StringComparer.Ordinal) + .ToArray(); + } + + private static string[] ExpandSharedLaneRepairSet( + IReadOnlyCollection selectedEdgeIds, + IReadOnlyCollection edges, + IReadOnlyCollection nodes) + { + var selected = selectedEdgeIds.ToHashSet(StringComparer.Ordinal); + foreach (var (leftEdgeId, rightEdgeId) in ElkEdgeRoutingScoring.DetectSharedLaneConflicts(edges, nodes)) + { + if (!selected.Contains(leftEdgeId) && !selected.Contains(rightEdgeId)) + { + continue; + } + + selected.Add(leftEdgeId); + selected.Add(rightEdgeId); + } + + return selected + .OrderBy(edgeId => edgeId, StringComparer.Ordinal) + .ToArray(); + } + + private static string[] ExpandUnderNodeRepairSet( + IReadOnlyCollection selectedEdgeIds, + IReadOnlyCollection edges, + IReadOnlyCollection nodes) + { + var selected = selectedEdgeIds.ToHashSet(StringComparer.Ordinal); + foreach (var edge in edges) + { + if (ElkEdgeRoutingScoring.CountUnderNodeViolations([edge], nodes) > 0) + { + selected.Add(edge.Id); + } + } + + return selected + .OrderBy(edgeId => edgeId, StringComparer.Ordinal) + .ToArray(); + } + + private static List ExtractPath(ElkRoutedEdge edge) + { + 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); + } + + return path; + } + + private static bool HasTargetApproachJoinPair( + IReadOnlyList leftPath, + IReadOnlyList rightPath, + double minLineClearance, + int maxSegmentsFromEnd = 3) + { + var leftSegments = FlattenSegmentsNearEnd(leftPath, maxSegmentsFromEnd); + var rightSegments = FlattenSegmentsNearEnd(rightPath, maxSegmentsFromEnd); + + foreach (var leftSegment in leftSegments) + { + foreach (var rightSegment in rightSegments) + { + if (!ElkEdgeRoutingGeometry.AreParallelAndClose( + leftSegment.Start, + leftSegment.End, + rightSegment.Start, + rightSegment.End, + minLineClearance)) + { + continue; + } + + var overlap = ElkEdgeRoutingGeometry.ComputeSharedSegmentLength( + leftSegment.Start, + leftSegment.End, + rightSegment.Start, + rightSegment.End); + if (overlap > 8d) + { + return true; + } + + var leftLength = ElkEdgeRoutingGeometry.ComputeSegmentLength(leftSegment.Start, leftSegment.End); + var rightLength = ElkEdgeRoutingGeometry.ComputeSegmentLength(rightSegment.Start, rightSegment.End); + if (Math.Min(leftLength, rightLength) > 8d) + { + return true; + } + } + } + + return false; + } + + private static IReadOnlyList FlattenSegmentsNearEnd( + IReadOnlyList path, + int maxSegmentsFromEnd) + { + if (path.Count < 2 || maxSegmentsFromEnd <= 0) + { + return []; + } + + var startIndex = Math.Max(0, path.Count - (maxSegmentsFromEnd + 1)); + var segments = new List(); + for (var i = startIndex; i < path.Count - 1; i++) + { + segments.Add(new RoutedEdgeSegment(string.Empty, path[i], path[i + 1])); + } + + return segments; + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.RepairPlan.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.RepairPlan.cs new file mode 100644 index 000000000..581935f33 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.RepairPlan.cs @@ -0,0 +1,373 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static RepairPlan? BuildRepairPlan( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + EdgeRoutingScore score, + RoutingRetryState retryState, + RoutingStrategy strategy, + int attempt) + { + if (edges.Length == 0) + { + return null; + } + + var severityByEdgeId = new Dictionary(StringComparer.Ordinal); + var preferredShortestEdgeIds = new HashSet(StringComparer.Ordinal); + var routeRepairEdgeIds = new HashSet(StringComparer.Ordinal); + var mandatoryEdgeIds = new HashSet(StringComparer.Ordinal); + var severityByReason = new Dictionary>(StringComparer.Ordinal); + var reasons = new List(); + var prioritizeBlockingAndLengthOnly = retryState.RequiresBlockingRetry || retryState.RequiresLengthRetry; + + void AddReason(string reason) + { + if (!reasons.Contains(reason, StringComparer.Ordinal)) + { + reasons.Add(reason); + } + } + + void AddEdgeIds( + IEnumerable edgeIds, + int severity, + string reason, + bool requiresRouteRepair = false, + bool mandatoryRepair = false) + { + AddReason(reason); + if (!severityByReason.TryGetValue(reason, out var reasonSeverity)) + { + reasonSeverity = new Dictionary(StringComparer.Ordinal); + severityByReason[reason] = reasonSeverity; + } + + foreach (var edgeId in edgeIds) + { + severityByEdgeId[edgeId] = severityByEdgeId.GetValueOrDefault(edgeId) + severity; + reasonSeverity[edgeId] = reasonSeverity.GetValueOrDefault(edgeId) + severity; + if (requiresRouteRepair) + { + routeRepairEdgeIds.Add(edgeId); + } + + if (mandatoryRepair) + { + mandatoryEdgeIds.Add(edgeId); + } + } + } + + void MergeSeverity( + Dictionary metricSeverity, + string reason, + bool preferShortestRepair = false, + bool requiresRouteRepair = false, + bool mandatoryRepair = false) + { + if (metricSeverity.Count == 0) + { + return; + } + + AddReason(reason); + if (!severityByReason.TryGetValue(reason, out var reasonSeverity)) + { + reasonSeverity = new Dictionary(StringComparer.Ordinal); + severityByReason[reason] = reasonSeverity; + } + + foreach (var (edgeId, severity) in metricSeverity) + { + severityByEdgeId[edgeId] = severityByEdgeId.GetValueOrDefault(edgeId) + severity; + reasonSeverity[edgeId] = reasonSeverity.GetValueOrDefault(edgeId) + severity; + if (preferShortestRepair || requiresRouteRepair) + { + routeRepairEdgeIds.Add(edgeId); + } + + if (mandatoryRepair) + { + mandatoryEdgeIds.Add(edgeId); + } + + if (preferShortestRepair) + { + preferredShortestEdgeIds.Add(edgeId); + } + } + } + + if (attempt == 1 && retryState.RequiresLengthRetry && !retryState.RequiresBlockingRetry) + { + if (retryState.TargetApproachBacktrackingViolations > 0) + { + var backtrackingSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(edges, nodes, backtrackingSeverity, 2_250); + MergeSeverity(backtrackingSeverity, "approach-backtracking", preferShortestRepair: true, mandatoryRepair: true); + } + + if (retryState.ExcessiveDetourViolations > 0) + { + var detourSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountExcessiveDetourViolations(edges, nodes, detourSeverity, 2_000); + MergeSeverity(detourSeverity, "detour-priority", preferShortestRepair: true, mandatoryRepair: true); + } + } + else + { + if (score.NodeCrossings > 0) + { + var nodeCrossingSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountEdgeNodeCrossings(edges, nodes, nodeCrossingSeverity, 2_500); + MergeSeverity(nodeCrossingSeverity, "node-crossings", requiresRouteRepair: true, mandatoryRepair: true); + } + + if (retryState.RemainingShortHighways > 0) + { + var brokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(edges, nodes); + AddEdgeIds( + brokenHighways.SelectMany(highway => highway.EdgeIds).Distinct(StringComparer.Ordinal), + 2_000, + "short-highways", + requiresRouteRepair: true, + mandatoryRepair: true); + } + + if (retryState.RepeatCollectorCorridorViolations > 0) + { + var collectorSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountRepeatCollectorCorridorViolations(edges, nodes, collectorSeverity, 2_000); + MergeSeverity(collectorSeverity, "collector-corridors", requiresRouteRepair: true, mandatoryRepair: true); + } + + if (retryState.RepeatCollectorNodeClearanceViolations > 0) + { + var collectorClearanceSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountRepeatCollectorNodeClearanceViolations(edges, nodes, collectorClearanceSeverity, 2_000); + MergeSeverity(collectorClearanceSeverity, "collector-clearance", requiresRouteRepair: true, mandatoryRepair: true); + } + + if (retryState.TargetApproachJoinViolations > 0) + { + var targetJoinSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(edges, nodes, targetJoinSeverity, 1_500); + MergeSeverity(targetJoinSeverity, "target-joins", requiresRouteRepair: true, mandatoryRepair: true); + } + + if (retryState.TargetApproachBacktrackingViolations > 0) + { + var backtrackingSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(edges, nodes, backtrackingSeverity, 1_600); + MergeSeverity(backtrackingSeverity, "approach-backtracking", preferShortestRepair: true, requiresRouteRepair: true, mandatoryRepair: true); + } + + if (retryState.BelowGraphViolations > 0) + { + var belowGraphSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountBelowGraphViolations(edges, nodes, belowGraphSeverity, 2_500); + MergeSeverity(belowGraphSeverity, "below-graph", preferShortestRepair: true, requiresRouteRepair: true, mandatoryRepair: true); + } + + if (retryState.UnderNodeViolations > 0) + { + var underNodeSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountUnderNodeViolations(edges, nodes, underNodeSeverity, 2_500); + MergeSeverity(underNodeSeverity, "under-node", preferShortestRepair: true, requiresRouteRepair: true, mandatoryRepair: true); + } + + if (retryState.LongDiagonalViolations > 0) + { + var longDiagonalSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountLongDiagonalViolations(edges, nodes, longDiagonalSeverity, 2_250); + MergeSeverity(longDiagonalSeverity, "long-diagonal", preferShortestRepair: true, requiresRouteRepair: true, mandatoryRepair: true); + } + + if (retryState.ExcessiveDetourViolations > 0) + { + var detourSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountExcessiveDetourViolations(edges, nodes, detourSeverity, 1_250); + MergeSeverity(detourSeverity, "detour", preferShortestRepair: true, requiresRouteRepair: true, mandatoryRepair: true); + } + + if (retryState.SharedLaneViolations > 0) + { + var sharedLaneSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountSharedLaneViolations(edges, nodes, sharedLaneSeverity, 2_000); + MergeSeverity(sharedLaneSeverity, "shared-lanes", requiresRouteRepair: true, mandatoryRepair: true); + } + + if (retryState.BoundarySlotViolations > 0) + { + var boundarySlotSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountBoundarySlotViolations(edges, nodes, boundarySlotSeverity, 2_000); + MergeSeverity(boundarySlotSeverity, "boundary-slots", requiresRouteRepair: true, mandatoryRepair: true); + } + + if (!prioritizeBlockingAndLengthOnly && retryState.ProximityViolations > 0) + { + var proximitySeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountProximityViolations(edges, nodes, proximitySeverity, 350); + MergeSeverity(proximitySeverity, "proximity", requiresRouteRepair: true); + } + + if (retryState.EntryAngleViolations > 0) + { + var entrySeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountBadBoundaryAngles(edges, nodes, entrySeverity, 450); + MergeSeverity(entrySeverity, "entry", requiresRouteRepair: true, mandatoryRepair: true); + } + + if (retryState.GatewaySourceExitViolations > 0) + { + var gatewaySourceSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(edges, nodes, gatewaySourceSeverity, 2_500); + MergeSeverity(gatewaySourceSeverity, "gateway-source-exit", preferShortestRepair: true, requiresRouteRepair: true, mandatoryRepair: true); + } + + if (!prioritizeBlockingAndLengthOnly && retryState.LabelProximityViolations > 0) + { + var labelSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountLabelProximityViolations(edges, nodes, labelSeverity, 300); + MergeSeverity(labelSeverity, "label", requiresRouteRepair: true); + } + + if (!prioritizeBlockingAndLengthOnly && retryState.EdgeCrossings > 0) + { + var edgeCrossingSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountEdgeEdgeCrossings(edges, edgeCrossingSeverity, 200); + MergeSeverity(edgeCrossingSeverity, "edge-crossings", requiresRouteRepair: true); + } + } + + if (severityByEdgeId.Count == 0) + { + return null; + } + + var orderRankByEdgeId = strategy.EdgeOrder + .Select((edgeIndex, rank) => new { edgeIndex, rank }) + .Where(item => item.edgeIndex >= 0 && item.edgeIndex < edges.Length) + .ToDictionary(item => edges[item.edgeIndex].Id, item => item.rank, StringComparer.Ordinal); + var seedSelectedEdgeIds = new List(); + foreach (var reason in reasons) + { + if (!severityByReason.TryGetValue(reason, out var reasonSeverity) + || reasonSeverity.Count == 0) + { + continue; + } + + var picksForReason = reason is "detour" or "detour-priority" or "approach-backtracking" + ? 2 + : 1; + var rankedReasonEdgeIds = reasonSeverity + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => orderRankByEdgeId.GetValueOrDefault(pair.Key, int.MaxValue)) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Key) + .ToArray(); + foreach (var edgeId in RotateOrderedEdgeIds(rankedReasonEdgeIds, attempt)) + { + if (seedSelectedEdgeIds.Contains(edgeId, StringComparer.Ordinal)) + { + continue; + } + + seedSelectedEdgeIds.Add(edgeId); + if (--picksForReason == 0) + { + break; + } + } + } + + var maxEdgeRepairs = DetermineRepairEdgeBudget( + retryState, + attempt == 1 && retryState.RequiresLengthRetry, + seedSelectedEdgeIds.Count, + mandatoryEdgeIds.Count); + var orderedEdgeIds = severityByEdgeId + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => orderRankByEdgeId.GetValueOrDefault(pair.Key, int.MaxValue)) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Key) + .ToArray(); + var prioritizedShortestEdgeIds = orderedEdgeIds + .Where(preferredShortestEdgeIds.Contains) + .Take(Math.Min(Math.Max(2, seedSelectedEdgeIds.Count), maxEdgeRepairs)) + .ToArray(); + var orderedMandatoryEdgeIds = orderedEdgeIds + .Where(mandatoryEdgeIds.Contains) + .ToArray(); + var mandatoryFocusBudget = DetermineMandatoryFocusBudget( + orderedMandatoryEdgeIds.Length, + maxEdgeRepairs, + seedSelectedEdgeIds.Count); + var focusedMandatoryEdgeIds = RotateOrderedEdgeIds(orderedMandatoryEdgeIds, attempt) + .Take(mandatoryFocusBudget) + .ToArray(); + var effectiveEdgeRepairBudget = Math.Max(maxEdgeRepairs, focusedMandatoryEdgeIds.Length); + var selectedEdgeIds = focusedMandatoryEdgeIds + .Concat(seedSelectedEdgeIds) + .Concat(prioritizedShortestEdgeIds) + .Distinct(StringComparer.Ordinal) + .Take(effectiveEdgeRepairBudget) + .ToArray(); + if (reasons.Contains("collector-corridors", StringComparer.Ordinal)) + { + selectedEdgeIds = ExpandRepeatCollectorRepairSet(selectedEdgeIds, edges, nodes); + } + + if (reasons.Contains("target-joins", StringComparer.Ordinal)) + { + selectedEdgeIds = ExpandTargetApproachJoinRepairSet(selectedEdgeIds, edges, nodes, strategy.MinLineClearance); + } + + if (reasons.Contains("under-node", StringComparer.Ordinal)) + { + selectedEdgeIds = ExpandUnderNodeRepairSet(selectedEdgeIds, edges, nodes); + } + + if (reasons.Contains("shared-lanes", StringComparer.Ordinal)) + { + selectedEdgeIds = ExpandSharedLaneRepairSet(selectedEdgeIds, edges, nodes); + } + + if (selectedEdgeIds.Length == 0) + { + return null; + } + + var preferredSelectedEdgeIds = selectedEdgeIds + .Where(preferredShortestEdgeIds.Contains) + .ToArray(); + var routeRepairSelectedEdgeIds = selectedEdgeIds + .Where(routeRepairEdgeIds.Contains) + .ToArray(); + + var edgeIndices = selectedEdgeIds + .Select(edgeId => Array.FindIndex(edges, edge => string.Equals(edge.Id, edgeId, StringComparison.Ordinal))) + .Where(edgeIndex => edgeIndex >= 0) + .ToArray(); + if (edgeIndices.Length == 0) + { + return null; + } + + return new RepairPlan( + edgeIndices, + selectedEdgeIds, + preferredSelectedEdgeIds, + routeRepairSelectedEdgeIds, + reasons.ToArray()); + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.RouteAllEdges.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.RouteAllEdges.cs new file mode 100644 index 000000000..cf4df5052 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.RouteAllEdges.cs @@ -0,0 +1,134 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static RouteAllEdgesResult? RouteAllEdges( + ElkRoutedEdge[] existingEdges, + ElkPositionedNode[] nodes, + double baseObstacleMargin, + RoutingStrategy strategy, + CancellationToken cancellationToken) + { + var routedEdges = new ElkRoutedEdge[existingEdges.Length]; + Array.Copy(existingEdges, routedEdges, existingEdges.Length); + + var obstacleMargin = Math.Max( + baseObstacleMargin, + Math.Max(strategy.MinLineClearance + 4d, strategy.RoutingParams.Margin)); + var obstacles = BuildObstacles(nodes, obstacleMargin); + var graphMinY = nodes.Length > 0 ? nodes.Min(n => n.Y) : 0d; + var graphMaxY = nodes.Length > 0 ? nodes.Max(n => n.Y + n.Height) : 0d; + var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); + var routedEdgeCount = 0; + var skippedEdgeCount = 0; + var routedSectionCount = 0; + var fallbackSectionCount = 0; + + // Spread endpoints: distribute edges arriving at the same target side + var spreadEndpoints = SpreadTargetEndpoints(existingEdges, nodesById, graphMinY, graphMaxY, strategy.MinLineClearance); + + var softObstacles = new List(); + + foreach (var edgeIndex in strategy.EdgeOrder) + { + if (edgeIndex < 0 || edgeIndex >= existingEdges.Length) + { + continue; + } + + cancellationToken.ThrowIfCancellationRequested(); + + var edge = existingEdges[edgeIndex]; + + // Skip edges that need special routing (backward, ports, corridors, collectors) + if (!CanRepairEdgeLocally(edge, nodes, graphMinY, graphMaxY)) + { + skippedEdgeCount++; + foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge)) + { + softObstacles.Add(new OrthogonalSoftObstacle(segment.Start, segment.End)); + } + + continue; + } + + var newSections = new List(edge.Sections.Count); + + foreach (var section in edge.Sections) + { + var endPoint = spreadEndpoints.TryGetValue(edge.Id, out var spread) + ? spread + : section.EndPoint; + var (startPoint, adjustedEndPoint) = ResolveRoutingEndpoints( + section.StartPoint, + endPoint, + edge.SourceNodeId, + edge.TargetNodeId, + nodesById); + + var rerouted = ElkEdgeRouterAStar8Dir.Route( + startPoint, + adjustedEndPoint, + obstacles, + edge.SourceNodeId ?? "", + edge.TargetNodeId ?? "", + strategy.RoutingParams, + softObstacles, + cancellationToken); + + if (rerouted is not null && rerouted.Count >= 2) + { + routedSectionCount++; + newSections.Add(new ElkEdgeSection + { + StartPoint = rerouted[0], + EndPoint = rerouted[^1], + BendPoints = rerouted.Skip(1).Take(rerouted.Count - 2).ToArray(), + }); + } + else + { + fallbackSectionCount++; + newSections.Add(section); + } + } + + routedEdgeCount++; + + 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 = newSections, + }; + + foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(routedEdges[edgeIndex])) + { + softObstacles.Add(new OrthogonalSoftObstacle(segment.Start, segment.End)); + } + } + + return new RouteAllEdgesResult( + routedEdges, + new ElkIterativeRouteDiagnostics + { + Mode = "full-strategy", + TotalEdges = existingEdges.Length, + RoutedEdges = routedEdgeCount, + SkippedEdges = skippedEdgeCount, + RoutedSections = routedSectionCount, + FallbackSections = fallbackSectionCount, + SoftObstacleSegments = softObstacles.Count, + }); + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.VerifiedIssues.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.VerifiedIssues.cs new file mode 100644 index 000000000..56c919796 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.VerifiedIssues.cs @@ -0,0 +1,65 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static (ElkRoutedEdge[] Edges, EdgeRoutingScore Score, RoutingRetryState RetryState, int RemainingBrokenHighways, ElkIterativeRouteDiagnostics RouteDiagnostics)? TryApplyVerifiedIssueRepairRound( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + double baseObstacleMargin, + RoutingStrategy strategy, + RoutingRetryState retryState, + ElkLayoutDirection direction, + CancellationToken cancellationToken, + int maxParallelRepairBuilds, + bool trustIndependentParallelBuilds = false, + bool useHybridCleanup = false) + { + var score = ElkEdgeRoutingScoring.ComputeScore(edges, nodes); + var focusedPlan = BuildRepairPlan( + edges, + nodes, + score, + retryState, + strategy, + int.MaxValue); + if (focusedPlan is null || focusedPlan.Value.EdgeIds.Length == 0) + { + return null; + } + + var reroutedResult = RepairPenalizedEdges( + edges, + nodes, + baseObstacleMargin, + strategy, + focusedPlan.Value, + cancellationToken, + maxParallelRepairBuilds, + trustIndependentParallelBuilds); + var rerouted = reroutedResult.Edges; + var cleaned = useHybridCleanup + ? ApplyHybridTerminalRuleCleanupRound( + rerouted, + nodes, + direction, + strategy.MinLineClearance, + focusedPlan.Value.EdgeIds) + : ApplyTerminalRuleCleanupRound( + rerouted, + nodes, + direction, + strategy.MinLineClearance, + focusedPlan.Value.EdgeIds); + var cleanedScore = ElkEdgeRoutingScoring.ComputeScore(cleaned, nodes); + var remainingBrokenHighways = HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(cleaned, nodes).Count + : 0; + var cleanedRetryState = BuildRetryState(cleanedScore, remainingBrokenHighways); + return (cleaned, cleanedScore, cleanedRetryState, remainingBrokenHighways, reroutedResult.Diagnostics); + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.cs index a3aa5d4a5..17f3c68d6 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.StrategyRepair.cs @@ -6,1978 +6,5 @@ namespace StellaOps.ElkSharp; internal static partial class ElkEdgeRouterIterative { - private static StrategyEvaluationResult EvaluateStrategy( - StrategyWorkItem workItem, - ElkRoutedEdge[] baselineEdges, - ElkPositionedNode[] nodes, - ElkLayoutOptions layoutOptions, - IterativeRoutingConfig config, - CancellationToken cancellationToken, - ElkLayoutRunDiagnostics? diagnostics) - { - using var diagnosticsScope = diagnostics is null - ? null - : ElkLayoutDiagnostics.Attach(diagnostics); - const int maxAllowedNodeCrossings = 0; - - var strategy = workItem.Strategy; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] start: bend={strategy.RoutingParams.BendPenalty:F0} " + - $"diag={strategy.RoutingParams.DiagonalPenalty:F0} soft={strategy.RoutingParams.SoftObstacleWeight:F2} " + - $"clearance={strategy.MinLineClearance:F1}"); - - var bestAttemptScore = (EdgeRoutingScore?)null; - ElkRoutedEdge[]? bestAttemptEdges = null; - var bestAttemptRetryState = new RoutingRetryState( - RemainingShortHighways: int.MaxValue, - RepeatCollectorCorridorViolations: int.MaxValue, - RepeatCollectorNodeClearanceViolations: int.MaxValue, - TargetApproachJoinViolations: int.MaxValue, - TargetApproachBacktrackingViolations: int.MaxValue, - ExcessiveDetourViolations: int.MaxValue, - SharedLaneViolations: int.MaxValue, - BoundarySlotViolations: int.MaxValue, - BelowGraphViolations: int.MaxValue, - UnderNodeViolations: int.MaxValue, - LongDiagonalViolations: int.MaxValue, - ProximityViolations: int.MaxValue, - EntryAngleViolations: int.MaxValue, - GatewaySourceExitViolations: int.MaxValue, - LabelProximityViolations: int.MaxValue, - EdgeCrossings: int.MaxValue); - var repairSeedScore = (EdgeRoutingScore?)null; - ElkRoutedEdge[]? repairSeedEdges = null; - var repairSeedRetryState = new RoutingRetryState( - RemainingShortHighways: int.MaxValue, - RepeatCollectorCorridorViolations: int.MaxValue, - RepeatCollectorNodeClearanceViolations: int.MaxValue, - TargetApproachJoinViolations: int.MaxValue, - TargetApproachBacktrackingViolations: int.MaxValue, - ExcessiveDetourViolations: int.MaxValue, - SharedLaneViolations: int.MaxValue, - BoundarySlotViolations: int.MaxValue, - BelowGraphViolations: int.MaxValue, - UnderNodeViolations: int.MaxValue, - LongDiagonalViolations: int.MaxValue, - ProximityViolations: int.MaxValue, - EntryAngleViolations: int.MaxValue, - GatewaySourceExitViolations: int.MaxValue, - LabelProximityViolations: int.MaxValue, - EdgeCrossings: int.MaxValue); - var attemptDetails = new List(); - var fallbackSolutions = new List(); - CandidateSolution? validSolution = null; - var outcome = "no-valid"; - var attempts = 0; - var maxAttempts = config.MaxAdaptationsPerStrategy; - var stagnantAttempts = 0; - string? lastPlateauFingerprint = null; - var repeatedPlateauFingerprintCount = 0; - var recentBlockingCycleFingerprints = new List(4); - string? lastPlannedRepairFocusFingerprint = null; - var repeatedPlannedRepairFocusCount = 0; - string? lastRepairFocusFingerprint = null; - var repeatedRepairFocusCount = 0; - var hasLastAttemptState = false; - var lastAttemptRetryState = new RoutingRetryState( - RemainingShortHighways: int.MaxValue, - RepeatCollectorCorridorViolations: int.MaxValue, - RepeatCollectorNodeClearanceViolations: int.MaxValue, - TargetApproachJoinViolations: int.MaxValue, - TargetApproachBacktrackingViolations: int.MaxValue, - ExcessiveDetourViolations: int.MaxValue, - SharedLaneViolations: int.MaxValue, - BoundarySlotViolations: int.MaxValue, - BelowGraphViolations: int.MaxValue, - UnderNodeViolations: int.MaxValue, - LongDiagonalViolations: int.MaxValue, - ProximityViolations: int.MaxValue, - EntryAngleViolations: int.MaxValue, - GatewaySourceExitViolations: int.MaxValue, - LabelProximityViolations: int.MaxValue, - EdgeCrossings: int.MaxValue); - var lastAttemptNodeCrossings = int.MaxValue; - var consecutiveNonImprovingAttempts = 0; - var strategyStopwatch = Stopwatch.StartNew(); - ElkIterativeStrategyDiagnostics? liveStrategyDiagnostics = null; - - if (diagnostics is not null) - { - liveStrategyDiagnostics = new ElkIterativeStrategyDiagnostics - { - StrategyIndex = workItem.StrategyIndex, - OrderingName = workItem.StrategyName, - Attempts = 0, - TotalDurationMs = 0, - BestScore = null, - Outcome = "running", - BendPenalty = workItem.Strategy.RoutingParams.BendPenalty, - DiagonalPenalty = workItem.Strategy.RoutingParams.DiagonalPenalty, - SoftObstacleWeight = workItem.Strategy.RoutingParams.SoftObstacleWeight, - RegisteredLive = true, - }; - lock (diagnostics.SyncRoot) - { - diagnostics.IterativeStrategies.Add(liveStrategyDiagnostics); - } - - ElkLayoutDiagnostics.FlushSnapshot(diagnostics); - } - - for (var attempt = 0; attempt < maxAttempts; attempt++) - { - cancellationToken.ThrowIfCancellationRequested(); - attempts++; - var attemptStopwatch = Stopwatch.StartNew(); - var phaseTimings = new List(); - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] attempt {attempt + 1} start"); - - T MeasurePhase(string phaseName, Func action) - { - var phaseStopwatch = Stopwatch.StartNew(); - var value = action(); - phaseStopwatch.Stop(); - phaseTimings.Add(new ElkIterativePhaseDiagnostics - { - Phase = phaseName, - DurationMs = Math.Round(phaseStopwatch.Elapsed.TotalMilliseconds, 3), - }); - return value; - } - - RepairPlan? repairPlan = null; - RouteAllEdgesResult? routeResult; - if (attempt == 0 || repairSeedEdges is null || repairSeedScore is null) - { - routeResult = MeasurePhase( - "route-all-edges", - () => RouteAllEdges(baselineEdges, nodes, config.ObstacleMargin, strategy, cancellationToken)); - } - else - { - repairPlan = MeasurePhase( - "select-repair-targets", - () => BuildRepairPlan(repairSeedEdges, nodes, repairSeedScore.Value, repairSeedRetryState, strategy, attempt)); - if (repairPlan is null) - { - outcome = $"no-repair-targets({DescribeRetryState(repairSeedRetryState)})@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); - break; - } - - var plannedRepairFocusFingerprint = BuildRepairFocusFingerprint(repairPlan); - if (!string.IsNullOrEmpty(plannedRepairFocusFingerprint)) - { - if (string.Equals(plannedRepairFocusFingerprint, lastPlannedRepairFocusFingerprint, StringComparison.Ordinal)) - { - repeatedPlannedRepairFocusCount++; - } - else - { - lastPlannedRepairFocusFingerprint = plannedRepairFocusFingerprint; - repeatedPlannedRepairFocusCount = 0; - } - - if (repeatedPlannedRepairFocusCount >= 1 && attempt >= 2) - { - outcome = $"stalled-same-repair-plan({DescribeRetryState(repairSeedRetryState)})@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); - break; - } - } - else - { - lastPlannedRepairFocusFingerprint = null; - repeatedPlannedRepairFocusCount = 0; - } - - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] attempt {attempt + 1} local-repair " + - $"edges=[{string.Join(", ", repairPlan.Value.EdgeIds)}] reasons=[{string.Join(", ", repairPlan.Value.Reasons)}]"); - routeResult = MeasurePhase( - "route-penalized-edges", - () => RepairPenalizedEdges( - repairSeedEdges, - nodes, - config.ObstacleMargin, - strategy, - repairPlan.Value, - cancellationToken)); - } - if (routeResult is null) - { - outcome = "route-failed"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}@attempt{attempt + 1}"); - break; - } - - var candidateEdges = routeResult.Edges; - var scopedCleanupEdgeIds = routeResult.Diagnostics?.Mode == "local-repair" - ? routeResult.Diagnostics.RepairedEdgeIds.ToArray() - : null; - if (scopedCleanupEdgeIds is { Length: > 0 }) - { - candidateEdges = MeasurePhase( - "targeted-terminal-rule-cleanup", - () => ApplyTerminalRuleCleanupRound( - candidateEdges, - nodes, - layoutOptions.Direction, - strategy.MinLineClearance, - scopedCleanupEdgeIds)); - } - else - { - candidateEdges = MeasurePhase( - "snap-anchors", - () => ElkEdgePostProcessor.SnapAnchorsToNodeBoundary(candidateEdges, nodes)); - candidateEdges = MeasurePhase( - "eliminate-diagonals", - () => ElkEdgePostProcessor.EliminateDiagonalSegments(candidateEdges, nodes)); - candidateEdges = MeasurePhase( - "avoid-node-crossings-1", - () => ElkEdgePostProcessor.AvoidNodeCrossings(candidateEdges, nodes, layoutOptions.Direction)); - candidateEdges = MeasurePhase( - "simplify-1", - () => ElkEdgePostProcessorSimplify.SimplifyEdgePaths(candidateEdges, nodes)); - candidateEdges = MeasurePhase( - "tighten-corridors", - () => ElkEdgePostProcessorSimplify.TightenOuterCorridors(candidateEdges, nodes)); - if (HighwayProcessingEnabled) - { - candidateEdges = MeasurePhase( - "break-short-highways", - () => ElkEdgeRouterHighway.BreakShortHighways(candidateEdges, nodes)); - } - - candidateEdges = MeasurePhase( - "terminal-rule-cleanup", - () => ApplyTerminalRuleCleanupRound( - candidateEdges, - nodes, - layoutOptions.Direction, - strategy.MinLineClearance)); - } - - var score = MeasurePhase( - "compute-score", - () => ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes)); - var remainingBrokenHighways = HighwayProcessingEnabled - ? MeasurePhase( - "detect-broken-highways", - () => ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count) - : 0; - var retryState = BuildRetryState(score, remainingBrokenHighways); - - if (retryState.RequiresBlockingRetry || retryState.RequiresLengthRetry) - { - var focusedRepair = MeasurePhase( - "repair-verified-issues", - () => TryApplyVerifiedIssueRepairRound( - candidateEdges, - nodes, - config.ObstacleMargin, - strategy, - retryState, - layoutOptions.Direction, - cancellationToken)); - if (focusedRepair is { } repaired - && IsBetterBoundarySlotRepairCandidate( - repaired.Score, - repaired.RetryState, - score, - retryState)) - { - candidateEdges = repaired.Edges; - score = repaired.Score; - remainingBrokenHighways = repaired.RemainingBrokenHighways; - retryState = repaired.RetryState; - } - } - - if (retryState.RequiresBlockingRetry || retryState.RequiresLengthRetry) - { - var stabilizedEdges = MeasurePhase( - "stabilize-terminal-rules", - () => ApplyTerminalRuleCleanupRound( - candidateEdges, - nodes, - layoutOptions.Direction, - strategy.MinLineClearance, - scopedCleanupEdgeIds)); - var stabilizedScore = MeasurePhase( - "compute-score-stabilized", - () => ElkEdgeRoutingScoring.ComputeScore(stabilizedEdges, nodes)); - var stabilizedBrokenHighways = HighwayProcessingEnabled - ? MeasurePhase( - "detect-broken-highways-stabilized", - () => ElkEdgeRouterHighway.DetectRemainingBrokenHighways(stabilizedEdges, nodes).Count) - : 0; - var stabilizedRetryState = BuildRetryState(stabilizedScore, stabilizedBrokenHighways); - if (IsBetterBoundarySlotRepairCandidate( - stabilizedScore, - stabilizedRetryState, - score, - retryState)) - { - candidateEdges = stabilizedEdges; - score = stabilizedScore; - remainingBrokenHighways = stabilizedBrokenHighways; - retryState = stabilizedRetryState; - } - } - - if (attempt == 0) - { - maxAttempts = DetermineAdaptiveAttemptBudget(retryState, config.MaxAdaptationsPerStrategy); - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] attempt-budget={maxAttempts}"); - } - - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] attempt {attempt + 1} " + - $"score={score.Value:F0} retry={DescribeRetryState(retryState)}"); - var candidate = new CandidateSolution(score, retryState, candidateEdges, workItem.StrategyIndex); - fallbackSolutions.Add(candidate); - repairSeedScore = score; - repairSeedEdges = candidateEdges; - repairSeedRetryState = retryState; - - var repairFocusFingerprint = BuildRepairFocusFingerprint(repairPlan); - if (!string.IsNullOrEmpty(repairFocusFingerprint)) - { - if (string.Equals(repairFocusFingerprint, lastRepairFocusFingerprint, StringComparison.Ordinal)) - { - repeatedRepairFocusCount++; - } - else - { - lastRepairFocusFingerprint = repairFocusFingerprint; - repeatedRepairFocusCount = 0; - } - - if (repeatedRepairFocusCount >= 1 && attempt >= 3) - { - outcome = $"stalled-same-focus({DescribeRetryState(retryState)})@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); - break; - } - } - else - { - lastRepairFocusFingerprint = null; - repeatedRepairFocusCount = 0; - } - - var plateauFingerprint = BuildPlateauFingerprint(retryState, repairPlan); - if (string.Equals(plateauFingerprint, lastPlateauFingerprint, StringComparison.Ordinal)) - { - repeatedPlateauFingerprintCount++; - } - else - { - lastPlateauFingerprint = plateauFingerprint; - repeatedPlateauFingerprintCount = 0; - } - - if (repeatedPlateauFingerprintCount >= 2 && attempt >= 3) - { - outcome = $"stalled-repeat({DescribeRetryState(retryState)})@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); - break; - } - - var blockingCycleFingerprint = BuildBlockingCycleFingerprint(retryState, repairPlan); - if (ShouldStopForBlockingCycle( - recentBlockingCycleFingerprints, - blockingCycleFingerprint, - retryState, - bestAttemptRetryState, - attempt)) - { - outcome = $"stalled-cycle({DescribeRetryState(retryState)})@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); - break; - } - - AppendRecentFingerprint(recentBlockingCycleFingerprints, blockingCycleFingerprint, 4); - - if (hasLastAttemptState) - { - var retryStateComparison = CompareRetryStates(retryState, lastAttemptRetryState); - var improvedBoundarySlotsSinceLast = - retryState.BoundarySlotViolations < lastAttemptRetryState.BoundarySlotViolations - && score.NodeCrossings <= lastAttemptNodeCrossings - && !HasBlockingBoundarySlotPromotionRegression(retryState, lastAttemptRetryState); - if (!improvedBoundarySlotsSinceLast - && retryStateComparison >= 0 - && score.NodeCrossings >= lastAttemptNodeCrossings) - { - consecutiveNonImprovingAttempts++; - } - else - { - consecutiveNonImprovingAttempts = 0; - } - - if (consecutiveNonImprovingAttempts >= 2 && attempt >= 3) - { - outcome = $"stalled-no-progress({DescribeRetryState(retryState)})@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); - break; - } - } - - hasLastAttemptState = true; - lastAttemptRetryState = retryState; - lastAttemptNodeCrossings = score.NodeCrossings; - - var improvedAttempt = bestAttemptScore is null - || IsBetterBoundarySlotRepairCandidate( - score, - retryState, - bestAttemptScore.Value, - bestAttemptRetryState); - var improvedRuleState = bestAttemptScore is null - || (retryState.BoundarySlotViolations < bestAttemptRetryState.BoundarySlotViolations - && score.NodeCrossings <= bestAttemptScore.Value.NodeCrossings - && !HasBlockingBoundarySlotPromotionRegression(retryState, bestAttemptRetryState)) - || CompareRetryStates(retryState, bestAttemptRetryState) < 0 - || score.NodeCrossings < bestAttemptScore.Value.NodeCrossings; - if (improvedAttempt) - { - bestAttemptScore = score; - bestAttemptEdges = candidateEdges; - bestAttemptRetryState = retryState; - stagnantAttempts = improvedRuleState - ? 0 - : stagnantAttempts + 1; - if (ShouldStopForStagnation(stagnantAttempts, attempt, config.MaxAdaptationsPerStrategy)) - { - outcome = $"stalled({DescribeRetryState(retryState)})@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); - break; - } - } - else - { - stagnantAttempts++; - if (ShouldStopForStagnation(stagnantAttempts, attempt, config.MaxAdaptationsPerStrategy)) - { - outcome = $"stalled({DescribeRetryState(retryState)})@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); - break; - } - } - - var attemptOutcome = score.NodeCrossings > maxAllowedNodeCrossings - ? $"hard-violation(nc={score.NodeCrossings}>{maxAllowedNodeCrossings})" - : retryState.RequiresPrimaryRetry - ? $"retry({DescribeRetryState(retryState)})" - : ShouldRetryForEdgeCrossings(retryState, attempt, config.MaxAdaptationsPerStrategy) - ? $"retry(edge-crossings={retryState.EdgeCrossings})" - : "valid"; - - if (diagnostics is not null) - { - attemptStopwatch.Stop(); - var attemptDiagnostics = new ElkIterativeAttemptDiagnostics - { - Attempt = attempt + 1, - TotalDurationMs = Math.Round(attemptStopwatch.Elapsed.TotalMilliseconds, 3), - Score = score, - Outcome = attemptOutcome, - RouteDiagnostics = routeResult.Diagnostics, - Edges = candidateEdges, - }; - attemptDiagnostics.PhaseTimings.AddRange(phaseTimings); - attemptDetails.Add(attemptDiagnostics); - - if (liveStrategyDiagnostics is not null) - { - lock (diagnostics.SyncRoot) - { - liveStrategyDiagnostics.Attempts = attempts; - liveStrategyDiagnostics.TotalDurationMs = Math.Round(strategyStopwatch.Elapsed.TotalMilliseconds, 3); - liveStrategyDiagnostics.BestScore = bestAttemptScore; - liveStrategyDiagnostics.Outcome = attemptOutcome; - liveStrategyDiagnostics.AttemptDetails.Add(attemptDiagnostics); - } - - ElkLayoutDiagnostics.FlushSnapshot(diagnostics); - } - } - - if (score.NodeCrossings > maxAllowedNodeCrossings) - { - outcome = $"{attemptOutcome}@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] adapting after node crossing violation"); - strategy.AdaptForViolations(score, attempt, retryState); - continue; - } - - if (retryState.RemainingShortHighways > 0 - || retryState.RepeatCollectorCorridorViolations > 0 - || retryState.RepeatCollectorNodeClearanceViolations > 0 - || retryState.TargetApproachJoinViolations > 0 - || retryState.TargetApproachBacktrackingViolations > 0 - || retryState.SharedLaneViolations > 0 - || retryState.BelowGraphViolations > 0 - || retryState.UnderNodeViolations > 0 - || retryState.LongDiagonalViolations > 0 - || retryState.EntryAngleViolations > 0 - || retryState.GatewaySourceExitViolations > 0) - { - if (ShouldRetryForPrimaryViolations(retryState, attempt, config.MaxAdaptationsPerStrategy)) - { - outcome = $"{attemptOutcome}@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] adapting for blocking violations"); - strategy.AdaptForViolations(score, attempt, retryState); - continue; - } - - outcome = $"invalid({DescribeRetryState(retryState)})@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); - break; - } - - if (retryState.RequiresLengthRetry) - { - if (ShouldRetryForPrimaryViolations(retryState, attempt, config.MaxAdaptationsPerStrategy)) - { - outcome = $"{attemptOutcome}@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] adapting for shortest-path / detour violations"); - strategy.AdaptForViolations(score, attempt, retryState); - continue; - } - - outcome = $"invalid({DescribeRetryState(retryState)})@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); - break; - } - - if (retryState.RequiresQualityRetry) - { - if (ShouldRetryForPrimaryViolations(retryState, attempt, config.MaxAdaptationsPerStrategy)) - { - outcome = $"{attemptOutcome}@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] adapting for quality/length violations"); - strategy.AdaptForViolations(score, attempt, retryState); - continue; - } - } - - if (ShouldRetryForEdgeCrossings(retryState, attempt, config.MaxAdaptationsPerStrategy)) - { - outcome = $"{attemptOutcome}@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] adapting for edge crossings"); - strategy.AdaptForViolations(score, attempt, retryState); - continue; - } - - var residualSoftViolations = retryState.RequiresQualityRetry || retryState.EdgeCrossings > 0; - outcome = residualSoftViolations - ? $"valid-soft({DescribeRetryState(retryState)})@attempt{attempt + 1}" - : $"valid@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); - validSolution = candidate; - break; - } - - var stratDiag = new ElkIterativeStrategyDiagnostics - { - StrategyIndex = workItem.StrategyIndex, - OrderingName = workItem.StrategyName, - Attempts = attempts, - TotalDurationMs = Math.Round(strategyStopwatch.Elapsed.TotalMilliseconds, 3), - BestScore = bestAttemptScore, - Outcome = outcome, - BendPenalty = workItem.Strategy.RoutingParams.BendPenalty, - DiagonalPenalty = workItem.Strategy.RoutingParams.DiagonalPenalty, - SoftObstacleWeight = workItem.Strategy.RoutingParams.SoftObstacleWeight, - BestEdges = bestAttemptEdges, - }; - stratDiag.AttemptDetails.AddRange(attemptDetails); - - if (liveStrategyDiagnostics is not null && diagnostics is not null) - { - lock (diagnostics.SyncRoot) - { - liveStrategyDiagnostics.Attempts = attempts; - liveStrategyDiagnostics.TotalDurationMs = Math.Round(strategyStopwatch.Elapsed.TotalMilliseconds, 3); - liveStrategyDiagnostics.BestScore = bestAttemptScore; - liveStrategyDiagnostics.Outcome = outcome; - liveStrategyDiagnostics.BestEdges = bestAttemptEdges; - } - - ElkLayoutDiagnostics.FlushSnapshot(diagnostics); - } - - return new StrategyEvaluationResult( - workItem.StrategyIndex, - fallbackSolutions, - validSolution, - stratDiag); - } - - private static RouteAllEdgesResult? RouteAllEdges( - ElkRoutedEdge[] existingEdges, - ElkPositionedNode[] nodes, - double baseObstacleMargin, - RoutingStrategy strategy, - CancellationToken cancellationToken) - { - var routedEdges = new ElkRoutedEdge[existingEdges.Length]; - Array.Copy(existingEdges, routedEdges, existingEdges.Length); - - var obstacleMargin = Math.Max( - baseObstacleMargin, - Math.Max(strategy.MinLineClearance + 4d, strategy.RoutingParams.Margin)); - var obstacles = BuildObstacles(nodes, obstacleMargin); - var graphMinY = nodes.Length > 0 ? nodes.Min(n => n.Y) : 0d; - var graphMaxY = nodes.Length > 0 ? nodes.Max(n => n.Y + n.Height) : 0d; - var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); - var routedEdgeCount = 0; - var skippedEdgeCount = 0; - var routedSectionCount = 0; - var fallbackSectionCount = 0; - - // Spread endpoints: distribute edges arriving at the same target side - var spreadEndpoints = SpreadTargetEndpoints(existingEdges, nodesById, graphMinY, graphMaxY, strategy.MinLineClearance); - - var softObstacles = new List(); - - foreach (var edgeIndex in strategy.EdgeOrder) - { - if (edgeIndex < 0 || edgeIndex >= existingEdges.Length) - { - continue; - } - - cancellationToken.ThrowIfCancellationRequested(); - - var edge = existingEdges[edgeIndex]; - - // Skip edges that need special routing (backward, ports, corridors, collectors) - if (!CanRepairEdgeLocally(edge, nodes, graphMinY, graphMaxY)) - { - skippedEdgeCount++; - foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge)) - { - softObstacles.Add(new OrthogonalSoftObstacle(segment.Start, segment.End)); - } - - continue; - } - - var newSections = new List(edge.Sections.Count); - - foreach (var section in edge.Sections) - { - var endPoint = spreadEndpoints.TryGetValue(edge.Id, out var spread) - ? spread - : section.EndPoint; - var (startPoint, adjustedEndPoint) = ResolveRoutingEndpoints( - section.StartPoint, - endPoint, - edge.SourceNodeId, - edge.TargetNodeId, - nodesById); - - var rerouted = ElkEdgeRouterAStar8Dir.Route( - startPoint, - adjustedEndPoint, - obstacles, - edge.SourceNodeId ?? "", - edge.TargetNodeId ?? "", - strategy.RoutingParams, - softObstacles, - cancellationToken); - - if (rerouted is not null && rerouted.Count >= 2) - { - routedSectionCount++; - newSections.Add(new ElkEdgeSection - { - StartPoint = rerouted[0], - EndPoint = rerouted[^1], - BendPoints = rerouted.Skip(1).Take(rerouted.Count - 2).ToArray(), - }); - } - else - { - fallbackSectionCount++; - newSections.Add(section); - } - } - - routedEdgeCount++; - - 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 = newSections, - }; - - foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(routedEdges[edgeIndex])) - { - softObstacles.Add(new OrthogonalSoftObstacle(segment.Start, segment.End)); - } - } - - return new RouteAllEdgesResult( - routedEdges, - new ElkIterativeRouteDiagnostics - { - Mode = "full-strategy", - TotalEdges = existingEdges.Length, - RoutedEdges = routedEdgeCount, - SkippedEdges = skippedEdgeCount, - RoutedSections = routedSectionCount, - FallbackSections = fallbackSectionCount, - SoftObstacleSegments = softObstacles.Count, - }); - } - - private static RouteAllEdgesResult RepairPenalizedEdges( - ElkRoutedEdge[] existingEdges, - ElkPositionedNode[] nodes, - double baseObstacleMargin, - RoutingStrategy strategy, - RepairPlan repairPlan, - CancellationToken cancellationToken) - { - var routedEdges = new ElkRoutedEdge[existingEdges.Length]; - Array.Copy(existingEdges, routedEdges, existingEdges.Length); - - var obstacleMargin = Math.Max( - baseObstacleMargin, - Math.Max(strategy.MinLineClearance + 4d, strategy.RoutingParams.Margin)); - var obstacles = BuildObstacles(nodes, obstacleMargin); - var graphMinY = nodes.Length > 0 ? nodes.Min(n => n.Y) : 0d; - var graphMaxY = nodes.Length > 0 ? nodes.Max(n => n.Y + n.Height) : 0d; - var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); - var routedEdgeCount = 0; - var skippedEdgeCount = 0; - var routedSectionCount = 0; - var fallbackSectionCount = 0; - var repairSet = repairPlan.EdgeIndices.ToHashSet(); - var routeRepairEdgeIdSet = repairPlan.RouteRepairEdgeIds.ToHashSet(StringComparer.Ordinal); - var collectorRepairSet = repairPlan.Reasons.Contains("collector-corridors", StringComparer.Ordinal) - ? repairSet - .Where(edgeIndex => edgeIndex >= 0 - && edgeIndex < existingEdges.Length - && ElkEdgePostProcessor.IsRepeatCollectorLabel(existingEdges[edgeIndex].Label)) - .Where(edgeIndex => !routeRepairEdgeIdSet.Contains(existingEdges[edgeIndex].Id)) - .ToHashSet() - : []; - var preferredShortestEdgeIdSet = repairPlan.PreferredShortestEdgeIds.ToHashSet(StringComparer.Ordinal); - if (collectorRepairSet.Count > 0) - { - var collectorEdgeIds = collectorRepairSet - .Select(edgeIndex => existingEdges[edgeIndex].Id) - .ToArray(); - routedEdges = ElkRepeatCollectorCorridors.SeparateSharedLanes(routedEdges, nodes, collectorEdgeIds); - routedEdgeCount += collectorRepairSet.Count; - } - - var aStarRepairSet = repairSet - .Where(edgeIndex => !collectorRepairSet.Contains(edgeIndex)) - .ToHashSet(); - var spreadEndpoints = SpreadTargetEndpoints(existingEdges, nodesById, graphMinY, graphMaxY, strategy.MinLineClearance); - var softObstacles = new List(); - - for (var edgeIndex = 0; edgeIndex < existingEdges.Length; edgeIndex++) - { - if (aStarRepairSet.Contains(edgeIndex)) - { - continue; - } - - foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(routedEdges[edgeIndex])) - { - softObstacles.Add(new OrthogonalSoftObstacle(segment.Start, segment.End)); - } - } - - var orderedRepairIndices = strategy.EdgeOrder - .Where(aStarRepairSet.Contains) - .Concat(aStarRepairSet.Where(edgeIndex => !strategy.EdgeOrder.Contains(edgeIndex))) - .Distinct() - .ToArray(); - var repairBuilderParallelism = CanParallelizeRepairBuilds(orderedRepairIndices, existingEdges) - ? DetermineRepairBuildParallelism(orderedRepairIndices.Length) - : 1; - var builtRepairResults = new ConcurrentDictionary(); - var repairBuildLocks = new ConcurrentDictionary(StringComparer.Ordinal); - if (repairBuilderParallelism > 1 && orderedRepairIndices.Length > 1) - { - var immutableSoftObstacles = softObstacles.ToArray(); - var parallelOptions = new ParallelOptions - { - CancellationToken = cancellationToken, - MaxDegreeOfParallelism = repairBuilderParallelism, - }; - - Parallel.ForEach( - orderedRepairIndices, - parallelOptions, - edgeIndex => - { - if (edgeIndex < 0 || edgeIndex >= existingEdges.Length) - { - return; - } - - var edge = existingEdges[edgeIndex]; - var lockKeys = GetRepairBuildLockKeys(edge); - ExecuteWithRepairBuildLocks( - repairBuildLocks, - lockKeys, - () => - { - builtRepairResults[edgeIndex] = BuildRepairEdgeResult( - edgeIndex, - existingEdges, - nodes, - obstacles, - spreadEndpoints, - nodesById, - immutableSoftObstacles, - routeRepairEdgeIdSet, - preferredShortestEdgeIdSet, - repairPlan.Reasons, - graphMinY, - graphMaxY, - strategy, - cancellationToken); - }); - }); - } - - foreach (var edgeIndex in orderedRepairIndices) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (edgeIndex < 0 || edgeIndex >= existingEdges.Length) - { - continue; - } - - var buildResult = builtRepairResults.TryGetValue(edgeIndex, out var parallelBuildResult) - ? parallelBuildResult - : BuildRepairEdgeResult( - edgeIndex, - existingEdges, - nodes, - obstacles, - spreadEndpoints, - nodesById, - softObstacles, - routeRepairEdgeIdSet, - preferredShortestEdgeIdSet, - repairPlan.Reasons, - graphMinY, - graphMaxY, - strategy, - cancellationToken); - if (buildResult.WasSkipped) - { - skippedEdgeCount++; - foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(buildResult.Edge)) - { - softObstacles.Add(new OrthogonalSoftObstacle(segment.Start, segment.End)); - } - - continue; - } - - routedSectionCount += buildResult.RoutedSections; - fallbackSectionCount += buildResult.FallbackSections; - routedEdgeCount++; - routedEdges[edgeIndex] = buildResult.Edge; - - foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(routedEdges[edgeIndex])) - { - softObstacles.Add(new OrthogonalSoftObstacle(segment.Start, segment.End)); - } - } - - var repeatRouteRepairIds = repairPlan.RouteRepairEdgeIds - .Where(edgeId => routedEdges.Any(edge => - string.Equals(edge.Id, edgeId, StringComparison.Ordinal) - && ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label))) - .ToArray(); - if (repeatRouteRepairIds.Length > 0) - { - routedEdges = ElkRepeatCollectorCorridors.SeparateSharedLanes(routedEdges, nodes, repeatRouteRepairIds); - } - - return new RouteAllEdgesResult( - routedEdges, - new ElkIterativeRouteDiagnostics - { - Mode = "local-repair", - TotalEdges = existingEdges.Length, - RoutedEdges = routedEdgeCount, - SkippedEdges = skippedEdgeCount, - RoutedSections = routedSectionCount, - FallbackSections = fallbackSectionCount, - SoftObstacleSegments = softObstacles.Count, - RepairedEdgeIds = repairPlan.EdgeIds, - RepairReasons = repairPlan.Reasons, - BuilderMode = repairBuilderParallelism > 1 ? "parallel-locked-local-build" : "sequential-local-build", - BuilderParallelism = repairBuilderParallelism, - }); - } - - private static (ElkRoutedEdge[] Edges, EdgeRoutingScore Score, RoutingRetryState RetryState, int RemainingBrokenHighways)? TryApplyVerifiedIssueRepairRound( - ElkRoutedEdge[] edges, - ElkPositionedNode[] nodes, - double baseObstacleMargin, - RoutingStrategy strategy, - RoutingRetryState retryState, - ElkLayoutDirection direction, - CancellationToken cancellationToken) - { - var score = ElkEdgeRoutingScoring.ComputeScore(edges, nodes); - var focusedPlan = BuildRepairPlan( - edges, - nodes, - score, - retryState, - strategy, - int.MaxValue); - if (focusedPlan is null || focusedPlan.Value.EdgeIds.Length == 0) - { - return null; - } - - var rerouted = RepairPenalizedEdges( - edges, - nodes, - baseObstacleMargin, - strategy, - focusedPlan.Value, - cancellationToken).Edges; - var cleaned = ApplyTerminalRuleCleanupRound( - rerouted, - nodes, - direction, - strategy.MinLineClearance, - focusedPlan.Value.EdgeIds); - var cleanedScore = ElkEdgeRoutingScoring.ComputeScore(cleaned, nodes); - var remainingBrokenHighways = HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(cleaned, nodes).Count - : 0; - var cleanedRetryState = BuildRetryState(cleanedScore, remainingBrokenHighways); - return (cleaned, cleanedScore, cleanedRetryState, remainingBrokenHighways); - } - - private static RepairPlan? BuildRepairPlan( - ElkRoutedEdge[] edges, - ElkPositionedNode[] nodes, - EdgeRoutingScore score, - RoutingRetryState retryState, - RoutingStrategy strategy, - int attempt) - { - if (edges.Length == 0) - { - return null; - } - - var severityByEdgeId = new Dictionary(StringComparer.Ordinal); - var preferredShortestEdgeIds = new HashSet(StringComparer.Ordinal); - var routeRepairEdgeIds = new HashSet(StringComparer.Ordinal); - var mandatoryEdgeIds = new HashSet(StringComparer.Ordinal); - var severityByReason = new Dictionary>(StringComparer.Ordinal); - var reasons = new List(); - var prioritizeBlockingAndLengthOnly = retryState.RequiresBlockingRetry || retryState.RequiresLengthRetry; - - void AddReason(string reason) - { - if (!reasons.Contains(reason, StringComparer.Ordinal)) - { - reasons.Add(reason); - } - } - - void AddEdgeIds( - IEnumerable edgeIds, - int severity, - string reason, - bool requiresRouteRepair = false, - bool mandatoryRepair = false) - { - AddReason(reason); - if (!severityByReason.TryGetValue(reason, out var reasonSeverity)) - { - reasonSeverity = new Dictionary(StringComparer.Ordinal); - severityByReason[reason] = reasonSeverity; - } - - foreach (var edgeId in edgeIds) - { - severityByEdgeId[edgeId] = severityByEdgeId.GetValueOrDefault(edgeId) + severity; - reasonSeverity[edgeId] = reasonSeverity.GetValueOrDefault(edgeId) + severity; - if (requiresRouteRepair) - { - routeRepairEdgeIds.Add(edgeId); - } - - if (mandatoryRepair) - { - mandatoryEdgeIds.Add(edgeId); - } - } - } - - void MergeSeverity( - Dictionary metricSeverity, - string reason, - bool preferShortestRepair = false, - bool requiresRouteRepair = false, - bool mandatoryRepair = false) - { - if (metricSeverity.Count == 0) - { - return; - } - - AddReason(reason); - if (!severityByReason.TryGetValue(reason, out var reasonSeverity)) - { - reasonSeverity = new Dictionary(StringComparer.Ordinal); - severityByReason[reason] = reasonSeverity; - } - - foreach (var (edgeId, severity) in metricSeverity) - { - severityByEdgeId[edgeId] = severityByEdgeId.GetValueOrDefault(edgeId) + severity; - reasonSeverity[edgeId] = reasonSeverity.GetValueOrDefault(edgeId) + severity; - if (preferShortestRepair || requiresRouteRepair) - { - routeRepairEdgeIds.Add(edgeId); - } - - if (mandatoryRepair) - { - mandatoryEdgeIds.Add(edgeId); - } - - if (preferShortestRepair) - { - preferredShortestEdgeIds.Add(edgeId); - } - } - } - - if (attempt == 1 && retryState.RequiresLengthRetry && !retryState.RequiresBlockingRetry) - { - if (retryState.TargetApproachBacktrackingViolations > 0) - { - var backtrackingSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(edges, nodes, backtrackingSeverity, 2_250); - MergeSeverity(backtrackingSeverity, "approach-backtracking", preferShortestRepair: true, mandatoryRepair: true); - } - - if (retryState.ExcessiveDetourViolations > 0) - { - var detourSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountExcessiveDetourViolations(edges, nodes, detourSeverity, 2_000); - MergeSeverity(detourSeverity, "detour-priority", preferShortestRepair: true, mandatoryRepair: true); - } - } - else - { - if (score.NodeCrossings > 0) - { - var nodeCrossingSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountEdgeNodeCrossings(edges, nodes, nodeCrossingSeverity, 2_500); - MergeSeverity(nodeCrossingSeverity, "node-crossings", requiresRouteRepair: true, mandatoryRepair: true); - } - - if (retryState.RemainingShortHighways > 0) - { - var brokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(edges, nodes); - AddEdgeIds( - brokenHighways.SelectMany(highway => highway.EdgeIds).Distinct(StringComparer.Ordinal), - 2_000, - "short-highways", - requiresRouteRepair: true, - mandatoryRepair: true); - } - - if (retryState.RepeatCollectorCorridorViolations > 0) - { - var collectorSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountRepeatCollectorCorridorViolations(edges, nodes, collectorSeverity, 2_000); - MergeSeverity(collectorSeverity, "collector-corridors", requiresRouteRepair: true, mandatoryRepair: true); - } - - if (retryState.RepeatCollectorNodeClearanceViolations > 0) - { - var collectorClearanceSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountRepeatCollectorNodeClearanceViolations(edges, nodes, collectorClearanceSeverity, 2_000); - MergeSeverity(collectorClearanceSeverity, "collector-clearance", requiresRouteRepair: true, mandatoryRepair: true); - } - - if (retryState.TargetApproachJoinViolations > 0) - { - var targetJoinSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(edges, nodes, targetJoinSeverity, 1_500); - MergeSeverity(targetJoinSeverity, "target-joins", requiresRouteRepair: true, mandatoryRepair: true); - } - - if (retryState.TargetApproachBacktrackingViolations > 0) - { - var backtrackingSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(edges, nodes, backtrackingSeverity, 1_600); - MergeSeverity(backtrackingSeverity, "approach-backtracking", preferShortestRepair: true, requiresRouteRepair: true, mandatoryRepair: true); - } - - if (retryState.BelowGraphViolations > 0) - { - var belowGraphSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountBelowGraphViolations(edges, nodes, belowGraphSeverity, 2_500); - MergeSeverity(belowGraphSeverity, "below-graph", preferShortestRepair: true, requiresRouteRepair: true, mandatoryRepair: true); - } - - if (retryState.UnderNodeViolations > 0) - { - var underNodeSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountUnderNodeViolations(edges, nodes, underNodeSeverity, 2_500); - MergeSeverity(underNodeSeverity, "under-node", preferShortestRepair: true, requiresRouteRepair: true, mandatoryRepair: true); - } - - if (retryState.LongDiagonalViolations > 0) - { - var longDiagonalSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountLongDiagonalViolations(edges, nodes, longDiagonalSeverity, 2_250); - MergeSeverity(longDiagonalSeverity, "long-diagonal", preferShortestRepair: true, requiresRouteRepair: true, mandatoryRepair: true); - } - - if (retryState.ExcessiveDetourViolations > 0) - { - var detourSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountExcessiveDetourViolations(edges, nodes, detourSeverity, 1_250); - MergeSeverity(detourSeverity, "detour", preferShortestRepair: true, requiresRouteRepair: true, mandatoryRepair: true); - } - - if (retryState.SharedLaneViolations > 0) - { - var sharedLaneSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountSharedLaneViolations(edges, nodes, sharedLaneSeverity, 2_000); - MergeSeverity(sharedLaneSeverity, "shared-lanes", requiresRouteRepair: true, mandatoryRepair: true); - } - - if (retryState.BoundarySlotViolations > 0) - { - var boundarySlotSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountBoundarySlotViolations(edges, nodes, boundarySlotSeverity, 2_000); - MergeSeverity(boundarySlotSeverity, "boundary-slots", requiresRouteRepair: true, mandatoryRepair: true); - } - - if (!prioritizeBlockingAndLengthOnly && retryState.ProximityViolations > 0) - { - var proximitySeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountProximityViolations(edges, nodes, proximitySeverity, 350); - MergeSeverity(proximitySeverity, "proximity", requiresRouteRepair: true); - } - - if (retryState.EntryAngleViolations > 0) - { - var entrySeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountBadBoundaryAngles(edges, nodes, entrySeverity, 450); - MergeSeverity(entrySeverity, "entry", requiresRouteRepair: true, mandatoryRepair: true); - } - - if (retryState.GatewaySourceExitViolations > 0) - { - var gatewaySourceSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(edges, nodes, gatewaySourceSeverity, 2_500); - MergeSeverity(gatewaySourceSeverity, "gateway-source-exit", preferShortestRepair: true, requiresRouteRepair: true, mandatoryRepair: true); - } - - if (!prioritizeBlockingAndLengthOnly && retryState.LabelProximityViolations > 0) - { - var labelSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountLabelProximityViolations(edges, nodes, labelSeverity, 300); - MergeSeverity(labelSeverity, "label", requiresRouteRepair: true); - } - - if (!prioritizeBlockingAndLengthOnly && retryState.EdgeCrossings > 0) - { - var edgeCrossingSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountEdgeEdgeCrossings(edges, edgeCrossingSeverity, 200); - MergeSeverity(edgeCrossingSeverity, "edge-crossings", requiresRouteRepair: true); - } - } - - if (severityByEdgeId.Count == 0) - { - return null; - } - - var orderRankByEdgeId = strategy.EdgeOrder - .Select((edgeIndex, rank) => new { edgeIndex, rank }) - .Where(item => item.edgeIndex >= 0 && item.edgeIndex < edges.Length) - .ToDictionary(item => edges[item.edgeIndex].Id, item => item.rank, StringComparer.Ordinal); - var seedSelectedEdgeIds = new List(); - foreach (var reason in reasons) - { - if (!severityByReason.TryGetValue(reason, out var reasonSeverity) - || reasonSeverity.Count == 0) - { - continue; - } - - var picksForReason = reason is "detour" or "detour-priority" or "approach-backtracking" - ? 2 - : 1; - var rankedReasonEdgeIds = reasonSeverity - .OrderByDescending(pair => pair.Value) - .ThenBy(pair => orderRankByEdgeId.GetValueOrDefault(pair.Key, int.MaxValue)) - .ThenBy(pair => pair.Key, StringComparer.Ordinal) - .Select(pair => pair.Key) - .ToArray(); - foreach (var edgeId in RotateOrderedEdgeIds(rankedReasonEdgeIds, attempt)) - { - if (seedSelectedEdgeIds.Contains(edgeId, StringComparer.Ordinal)) - { - continue; - } - - seedSelectedEdgeIds.Add(edgeId); - if (--picksForReason == 0) - { - break; - } - } - } - - var maxEdgeRepairs = DetermineRepairEdgeBudget( - retryState, - attempt == 1 && retryState.RequiresLengthRetry, - seedSelectedEdgeIds.Count, - mandatoryEdgeIds.Count); - var orderedEdgeIds = severityByEdgeId - .OrderByDescending(pair => pair.Value) - .ThenBy(pair => orderRankByEdgeId.GetValueOrDefault(pair.Key, int.MaxValue)) - .ThenBy(pair => pair.Key, StringComparer.Ordinal) - .Select(pair => pair.Key) - .ToArray(); - var prioritizedShortestEdgeIds = orderedEdgeIds - .Where(preferredShortestEdgeIds.Contains) - .Take(Math.Min(Math.Max(2, seedSelectedEdgeIds.Count), maxEdgeRepairs)) - .ToArray(); - var orderedMandatoryEdgeIds = orderedEdgeIds - .Where(mandatoryEdgeIds.Contains) - .ToArray(); - var mandatoryFocusBudget = DetermineMandatoryFocusBudget( - orderedMandatoryEdgeIds.Length, - maxEdgeRepairs, - seedSelectedEdgeIds.Count); - var focusedMandatoryEdgeIds = RotateOrderedEdgeIds(orderedMandatoryEdgeIds, attempt) - .Take(mandatoryFocusBudget) - .ToArray(); - var effectiveEdgeRepairBudget = Math.Max(maxEdgeRepairs, focusedMandatoryEdgeIds.Length); - var selectedEdgeIds = focusedMandatoryEdgeIds - .Concat(seedSelectedEdgeIds) - .Concat(prioritizedShortestEdgeIds) - .Distinct(StringComparer.Ordinal) - .Take(effectiveEdgeRepairBudget) - .ToArray(); - if (reasons.Contains("collector-corridors", StringComparer.Ordinal)) - { - selectedEdgeIds = ExpandRepeatCollectorRepairSet(selectedEdgeIds, edges, nodes); - } - - if (reasons.Contains("target-joins", StringComparer.Ordinal)) - { - selectedEdgeIds = ExpandTargetApproachJoinRepairSet(selectedEdgeIds, edges, nodes, strategy.MinLineClearance); - } - - if (reasons.Contains("under-node", StringComparer.Ordinal)) - { - selectedEdgeIds = ExpandUnderNodeRepairSet(selectedEdgeIds, edges, nodes); - } - - if (reasons.Contains("shared-lanes", StringComparer.Ordinal)) - { - selectedEdgeIds = ExpandSharedLaneRepairSet(selectedEdgeIds, edges, nodes); - } - - if (selectedEdgeIds.Length == 0) - { - return null; - } - - var preferredSelectedEdgeIds = selectedEdgeIds - .Where(preferredShortestEdgeIds.Contains) - .ToArray(); - var routeRepairSelectedEdgeIds = selectedEdgeIds - .Where(routeRepairEdgeIds.Contains) - .ToArray(); - - var edgeIndices = selectedEdgeIds - .Select(edgeId => Array.FindIndex(edges, edge => string.Equals(edge.Id, edgeId, StringComparison.Ordinal))) - .Where(edgeIndex => edgeIndex >= 0) - .ToArray(); - if (edgeIndices.Length == 0) - { - return null; - } - - return new RepairPlan( - edgeIndices, - selectedEdgeIds, - preferredSelectedEdgeIds, - routeRepairSelectedEdgeIds, - reasons.ToArray()); - } - - private static string[] ExpandRepeatCollectorRepairSet( - IReadOnlyCollection selectedEdgeIds, - IReadOnlyCollection edges, - IReadOnlyCollection nodes) - { - var selected = selectedEdgeIds.ToHashSet(StringComparer.Ordinal); - foreach (var group in ElkRepeatCollectorCorridors.DetectSharedLaneGroups(edges, nodes)) - { - if (!group.EdgeIds.Any(selected.Contains)) - { - continue; - } - - foreach (var edgeId in group.EdgeIds) - { - selected.Add(edgeId); - } - } - - return selected - .OrderBy(edgeId => edgeId, StringComparer.Ordinal) - .ToArray(); - } - - private static string[] ExpandTargetApproachJoinRepairSet( - IReadOnlyCollection selectedEdgeIds, - IReadOnlyCollection edges, - IReadOnlyCollection nodes, - double minLineClearance) - { - var selected = selectedEdgeIds.ToHashSet(StringComparer.Ordinal); - var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); - var edgeArray = edges.ToArray(); - - foreach (var group in edgeArray.GroupBy(edge => edge.TargetNodeId ?? string.Empty, StringComparer.Ordinal)) - { - if (string.IsNullOrWhiteSpace(group.Key) - || !nodesById.TryGetValue(group.Key, out var targetNode)) - { - continue; - } - - var targetEdges = group.ToArray(); - for (var i = 0; i < targetEdges.Length; i++) - { - var leftEdge = targetEdges[i]; - var leftPath = ExtractPath(leftEdge); - if (leftPath.Count < 2) - { - continue; - } - - var leftSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(leftPath[^1], leftPath[^2], targetNode); - for (var j = i + 1; j < targetEdges.Length; j++) - { - var rightEdge = targetEdges[j]; - var rightPath = ExtractPath(rightEdge); - if (rightPath.Count < 2) - { - continue; - } - - var rightSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(rightPath[^1], rightPath[^2], targetNode); - if (!string.Equals(leftSide, rightSide, StringComparison.Ordinal)) - { - continue; - } - - if (!HasTargetApproachJoinPair(leftPath, rightPath, minLineClearance)) - { - continue; - } - - selected.Add(leftEdge.Id); - selected.Add(rightEdge.Id); - } - } - } - - return selected - .OrderBy(edgeId => edgeId, StringComparer.Ordinal) - .ToArray(); - } - - private static string[] ExpandSharedLaneRepairSet( - IReadOnlyCollection selectedEdgeIds, - IReadOnlyCollection edges, - IReadOnlyCollection nodes) - { - var selected = selectedEdgeIds.ToHashSet(StringComparer.Ordinal); - foreach (var (leftEdgeId, rightEdgeId) in ElkEdgeRoutingScoring.DetectSharedLaneConflicts(edges, nodes)) - { - if (!selected.Contains(leftEdgeId) && !selected.Contains(rightEdgeId)) - { - continue; - } - - selected.Add(leftEdgeId); - selected.Add(rightEdgeId); - } - - return selected - .OrderBy(edgeId => edgeId, StringComparer.Ordinal) - .ToArray(); - } - - private static string[] ExpandUnderNodeRepairSet( - IReadOnlyCollection selectedEdgeIds, - IReadOnlyCollection edges, - IReadOnlyCollection nodes) - { - var selected = selectedEdgeIds.ToHashSet(StringComparer.Ordinal); - foreach (var edge in edges) - { - if (ElkEdgeRoutingScoring.CountUnderNodeViolations([edge], nodes) > 0) - { - selected.Add(edge.Id); - } - } - - return selected - .OrderBy(edgeId => edgeId, StringComparer.Ordinal) - .ToArray(); - } - - private static List ExtractPath(ElkRoutedEdge edge) - { - 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); - } - - return path; - } - - private static bool HasTargetApproachJoinPair( - IReadOnlyList leftPath, - IReadOnlyList rightPath, - double minLineClearance, - int maxSegmentsFromEnd = 3) - { - var leftSegments = FlattenSegmentsNearEnd(leftPath, maxSegmentsFromEnd); - var rightSegments = FlattenSegmentsNearEnd(rightPath, maxSegmentsFromEnd); - - foreach (var leftSegment in leftSegments) - { - foreach (var rightSegment in rightSegments) - { - if (!ElkEdgeRoutingGeometry.AreParallelAndClose( - leftSegment.Start, - leftSegment.End, - rightSegment.Start, - rightSegment.End, - minLineClearance)) - { - continue; - } - - var overlap = ElkEdgeRoutingGeometry.ComputeSharedSegmentLength( - leftSegment.Start, - leftSegment.End, - rightSegment.Start, - rightSegment.End); - if (overlap > 8d) - { - return true; - } - - var leftLength = ElkEdgeRoutingGeometry.ComputeSegmentLength(leftSegment.Start, leftSegment.End); - var rightLength = ElkEdgeRoutingGeometry.ComputeSegmentLength(rightSegment.Start, rightSegment.End); - if (Math.Min(leftLength, rightLength) > 8d) - { - return true; - } - } - } - - return false; - } - - private static IReadOnlyList FlattenSegmentsNearEnd( - IReadOnlyList path, - int maxSegmentsFromEnd) - { - if (path.Count < 2 || maxSegmentsFromEnd <= 0) - { - return []; - } - - var startIndex = Math.Max(0, path.Count - (maxSegmentsFromEnd + 1)); - var segments = new List(); - for (var i = startIndex; i < path.Count - 1; i++) - { - segments.Add(new RoutedEdgeSegment(string.Empty, path[i], path[i + 1])); - } - - return segments; - } - - private static int DetermineRepairBuildParallelism(int repairEdgeCount) - { - if (repairEdgeCount <= 1) - { - return 1; - } - - var cpuBudget = Math.Clamp(Environment.ProcessorCount / 4, 2, 8); - return Math.Min(repairEdgeCount, cpuBudget); - } - - private static bool CanParallelizeRepairBuilds( - IReadOnlyList orderedRepairIndices, - IReadOnlyList existingEdges) - { - if (orderedRepairIndices.Count <= 1) - { - return false; - } - - var seenLockKeys = new HashSet(StringComparer.Ordinal); - foreach (var edgeIndex in orderedRepairIndices) - { - if (edgeIndex < 0 || edgeIndex >= existingEdges.Count) - { - continue; - } - - foreach (var lockKey in GetRepairBuildLockKeys(existingEdges[edgeIndex])) - { - if (!seenLockKeys.Add(lockKey)) - { - return false; - } - } - } - - return true; - } - - private static RepairEdgeBuildResult BuildRepairEdgeResult( - int edgeIndex, - ElkRoutedEdge[] existingEdges, - ElkPositionedNode[] nodes, - (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, - IReadOnlyDictionary spreadEndpoints, - IReadOnlyDictionary nodesById, - IReadOnlyList softObstacles, - IReadOnlySet routeRepairEdgeIdSet, - IReadOnlySet preferredShortestEdgeIdSet, - IReadOnlyCollection repairReasons, - double graphMinY, - double graphMaxY, - RoutingStrategy strategy, - CancellationToken cancellationToken) - { - var edge = existingEdges[edgeIndex]; - if (!CanRouteSelectedRepairEdge(edge, graphMinY, graphMaxY, routeRepairEdgeIdSet)) - { - return new RepairEdgeBuildResult(edge, 0, 0, true); - } - - var sourceNode = nodesById.GetValueOrDefault(edge.SourceNodeId ?? string.Empty); - var targetNode = nodesById.GetValueOrDefault(edge.TargetNodeId ?? string.Empty); - var aggressiveRepairNeeded = routeRepairEdgeIdSet.Contains(edge.Id) - && repairReasons.Any(static reason => - reason is "under-node" - or "entry" - or "gateway-source-exit" - or "target-joins" - or "below-graph"); - var newSections = new List(edge.Sections.Count); - var routedSections = 0; - var fallbackSections = 0; - - foreach (var section in edge.Sections) - { - var endPoint = spreadEndpoints.TryGetValue(edge.Id, out var spread) - ? spread - : section.EndPoint; - var (startPoint, adjustedEndPoint) = ResolveRoutingEndpoints( - section.StartPoint, - endPoint, - edge.SourceNodeId, - edge.TargetNodeId, - nodesById); - List? rerouted = null; - if (preferredShortestEdgeIdSet.Contains(edge.Id)) - { - rerouted = TryRouteShortestRepair( - startPoint, - adjustedEndPoint, - nodes, - obstacles, - edge.SourceNodeId ?? string.Empty, - edge.TargetNodeId ?? string.Empty, - sourceNode, - targetNode, - strategy.RoutingParams, - softObstacles, - cancellationToken); - } - - if (aggressiveRepairNeeded) - { - var aggressiveReroute = TryRouteAggressiveRepair( - startPoint, - adjustedEndPoint, - nodes, - obstacles, - edge.SourceNodeId ?? string.Empty, - edge.TargetNodeId ?? string.Empty, - sourceNode, - targetNode, - strategy.RoutingParams, - cancellationToken); - rerouted = ChooseBetterLocalRepairCandidate( - edge, - nodes, - rerouted, - aggressiveReroute); - } - - rerouted ??= ElkEdgeRouterAStar8Dir.Route( - startPoint, - adjustedEndPoint, - obstacles, - edge.SourceNodeId ?? string.Empty, - edge.TargetNodeId ?? string.Empty, - strategy.RoutingParams, - softObstacles, - cancellationToken); - - if (rerouted is not null && rerouted.Count >= 2) - { - routedSections++; - newSections.Add(new ElkEdgeSection - { - StartPoint = rerouted[0], - EndPoint = rerouted[^1], - BendPoints = rerouted.Skip(1).Take(rerouted.Count - 2).ToArray(), - }); - } - else - { - fallbackSections++; - newSections.Add(section); - } - } - - return new RepairEdgeBuildResult( - new ElkRoutedEdge - { - Id = edge.Id, - SourceNodeId = edge.SourceNodeId, - TargetNodeId = edge.TargetNodeId, - SourcePortId = edge.SourcePortId, - TargetPortId = edge.TargetPortId, - Kind = edge.Kind, - Label = edge.Label, - Sections = newSections, - }, - routedSections, - fallbackSections, - false); - } - - private static string[] GetRepairBuildLockKeys(ElkRoutedEdge edge) - { - return new[] - { - $"source:{edge.SourceNodeId ?? string.Empty}", - $"target:{edge.TargetNodeId ?? string.Empty}", - } - .Where(static key => !key.EndsWith(':')) - .Distinct(StringComparer.Ordinal) - .OrderBy(static key => key, StringComparer.Ordinal) - .ToArray(); - } - - private static void ExecuteWithRepairBuildLocks( - ConcurrentDictionary lockRegistry, - IReadOnlyList lockKeys, - Action action) - { - static void ExecuteLocked( - IReadOnlyList locks, - int index, - Action action) - { - if (index >= locks.Count) - { - action(); - return; - } - - lock (locks[index]) - { - ExecuteLocked(locks, index + 1, action); - } - } - - if (lockKeys.Count == 0) - { - action(); - return; - } - - var locks = lockKeys - .Select(key => lockRegistry.GetOrAdd(key, static _ => new object())) - .ToArray(); - ExecuteLocked(locks, 0, action); - } - - private static int DetermineRepairEdgeBudget( - RoutingRetryState retryState, - bool detourPriority, - int seededEdgeCount, - int mandatoryEdgeCount) - { - if (detourPriority) - { - return Math.Clamp(Math.Max(2, seededEdgeCount), 2, 6); - } - - var budget = retryState.RequiresBlockingRetry - ? 3 - : retryState.RequiresLengthRetry - ? 2 - : 2; - if (retryState.ProximityViolations >= 6 || retryState.EdgeCrossings >= 8) - { - budget++; - } - - budget = Math.Max(budget, Math.Min(6, Math.Max(2, seededEdgeCount))); - return Math.Clamp(budget, 2, 6); - } - - private static int DetermineMandatoryFocusBudget( - int mandatoryEdgeCount, - int repairBudget, - int seededEdgeCount) - { - if (mandatoryEdgeCount <= 0) - { - return 0; - } - - if (mandatoryEdgeCount <= repairBudget) - { - return mandatoryEdgeCount; - } - - var focusBudget = Math.Max(2, Math.Max(seededEdgeCount, repairBudget - 1)); - return Math.Min(mandatoryEdgeCount, Math.Clamp(focusBudget, 2, 6)); - } - - private static IEnumerable RotateOrderedEdgeIds( - IReadOnlyList edgeIds, - int attempt) - { - if (edgeIds.Count == 0) - { - yield break; - } - - var offset = edgeIds.Count == 0 - ? 0 - : Math.Max(0, attempt - 1) % edgeIds.Count; - for (var i = 0; i < edgeIds.Count; i++) - { - yield return edgeIds[(offset + i) % edgeIds.Count]; - } - } - - private static string BuildPlateauFingerprint( - RoutingRetryState retryState, - RepairPlan? repairPlan) - { - if (repairPlan is null) - { - return $"full::{DescribeRetryState(retryState)}"; - } - - return string.Create( - CultureInfo.InvariantCulture, - $"{DescribeRetryState(retryState)}::{string.Join(",", repairPlan.Value.EdgeIds.OrderBy(id => id, StringComparer.Ordinal))}"); - } - - private static string BuildBlockingCycleFingerprint( - RoutingRetryState retryState, - RepairPlan? repairPlan) - { - var classes = new List(8); - if (retryState.RemainingShortHighways > 0) - { - classes.Add("short-highways"); - } - - if (retryState.RepeatCollectorCorridorViolations > 0) - { - classes.Add("collector-corridors"); - } - - if (retryState.RepeatCollectorNodeClearanceViolations > 0) - { - classes.Add("collector-clearance"); - } - - if (retryState.TargetApproachJoinViolations > 0) - { - classes.Add("target-joins"); - } - - if (retryState.TargetApproachBacktrackingViolations > 0) - { - classes.Add("approach-backtracking"); - } - - if (retryState.SharedLaneViolations > 0) - { - classes.Add("shared-lanes"); - } - - if (retryState.BelowGraphViolations > 0) - { - classes.Add("below-graph"); - } - - if (retryState.UnderNodeViolations > 0) - { - classes.Add("under-node"); - } - - if (retryState.LongDiagonalViolations > 0) - { - classes.Add("long-diagonal"); - } - - if (retryState.EntryAngleViolations > 0) - { - classes.Add("entry"); - } - - if (retryState.GatewaySourceExitViolations > 0) - { - classes.Add("gateway-source"); - } - - if (retryState.ExcessiveDetourViolations > 0) - { - classes.Add("detour"); - } - - var repairReasons = repairPlan is null - ? "full" - : string.Join(",", repairPlan.Value.Reasons.OrderBy(reason => reason, StringComparer.Ordinal)); - return string.Create( - CultureInfo.InvariantCulture, - $"{string.Join("|", classes)}::{repairReasons}"); - } - - private static bool ShouldStopForBlockingCycle( - IReadOnlyList recentFingerprints, - string fingerprint, - RoutingRetryState retryState, - RoutingRetryState bestRetryState, - int attempt) - { - if (attempt < 3 || recentFingerprints.Count < 3) - { - return false; - } - - var repeatCount = recentFingerprints.Count(item => string.Equals(item, fingerprint, StringComparison.Ordinal)); - return repeatCount >= 2 - && retryState.BlockingViolationCount >= bestRetryState.BlockingViolationCount - && retryState.LengthViolationCount >= bestRetryState.LengthViolationCount; - } - - private static void AppendRecentFingerprint( - List recentFingerprints, - string fingerprint, - int capacity) - { - if (capacity <= 0) - { - return; - } - - if (recentFingerprints.Count >= capacity) - { - recentFingerprints.RemoveAt(0); - } - - recentFingerprints.Add(fingerprint); - } - - private static string? BuildRepairFocusFingerprint(RepairPlan? repairPlan) - { - if (repairPlan is null) - { - return null; - } - - return string.Create( - CultureInfo.InvariantCulture, - $"{string.Join(",", repairPlan.Value.Reasons.OrderBy(reason => reason, StringComparer.Ordinal))}::{string.Join(",", repairPlan.Value.EdgeIds.OrderBy(id => id, StringComparer.Ordinal))}"); - } - -} \ No newline at end of file +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.BoundarySlots.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.BoundarySlots.cs new file mode 100644 index 000000000..cee741df6 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.BoundarySlots.cs @@ -0,0 +1,262 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static CandidateSolution ApplyFinalBoundarySlotPolish( + CandidateSolution solution, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance, + int maxRounds = 3) + { + var current = solution; + + for (var round = 0; round < maxRounds; round++) + { + var boundarySlotSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountBoundarySlotViolations(current.Edges, nodes, boundarySlotSeverity, 10); + if (boundarySlotSeverity.Count == 0) + { + break; + } + + var batchedRootEdgeIds = boundarySlotSeverity + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Take(MaxWinnerPolishBatchedRootEdges) + .Select(pair => pair.Key) + .ToArray(); + var batchedFocusEdgeIds = ExpandWinningSolutionFocus(current.Edges, batchedRootEdgeIds).ToArray(); + if (batchedFocusEdgeIds.Length > 0) + { + var batchedCandidateEdges = BuildFinalBoundarySlotCandidate( + current.Edges, + nodes, + direction, + minLineClearance, + batchedFocusEdgeIds, + allowLateRestabilizedClosure: false); + if (TryPromoteFinalBoundarySlotCandidate(current, batchedCandidateEdges, nodes, out var batchedPromoted)) + { + current = batchedPromoted; + continue; + } + } + + var improved = false; + foreach (var edgeId in boundarySlotSeverity + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Key)) + { + var focusEdgeIds = ExpandWinningSolutionFocus(current.Edges, [edgeId]).ToArray(); + if (focusEdgeIds.Length == 0) + { + continue; + } + + var candidateEdges = BuildFinalBoundarySlotCandidate( + current.Edges, + nodes, + direction, + minLineClearance, + focusEdgeIds, + allowLateRestabilizedClosure: false); + + if (!TryPromoteFinalBoundarySlotCandidate(current, candidateEdges, nodes, out var promoted)) + { + continue; + } + + current = promoted; + improved = true; + break; + } + + if (!improved) + { + break; + } + } + + return current; + } + + private static CandidateSolution ApplyFinalPostSlotHardRulePolish( + CandidateSolution solution, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance, + int maxRounds = 3) + { + var current = solution; + + for (var round = 0; round < maxRounds; round++) + { + var severityByEdgeId = new Dictionary(StringComparer.Ordinal); + var pressure = + ElkEdgeRoutingScoring.CountBadBoundaryAngles(current.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(current.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(current.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountSharedLaneViolations(current.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountBoundarySlotViolations(current.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(current.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountExcessiveDetourViolations(current.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountBelowGraphViolations(current.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountUnderNodeViolations(current.Edges, nodes, severityByEdgeId, 10); + ElkLayoutDiagnostics.LogProgress( + $"Winner post-slot hard-rule round {round + 1} start: pressure={pressure} retry={DescribeRetryState(current.RetryState)} focus={severityByEdgeId.Count}"); + if (pressure == 0) + { + break; + } + + var preferFastTerminalOnly = ShouldPreferFastTerminalOnlyHardRuleClosure(current.RetryState); + + var batchedRootEdgeIds = severityByEdgeId + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Take(MaxWinnerPolishBatchedRootEdges) + .Select(pair => pair.Key) + .ToArray(); + var batchedFocusEdgeIds = preferFastTerminalOnly + ? batchedRootEdgeIds + : ExpandWinningSolutionFocus(current.Edges, batchedRootEdgeIds).ToArray(); + if (batchedFocusEdgeIds.Length > 0) + { + if (preferFastTerminalOnly) + { + ElkLayoutDiagnostics.LogProgress( + $"Winner post-slot hard-rule round {round + 1} fast-terminal focus=[{string.Join(", ", batchedFocusEdgeIds)}]"); + var quickBatchedCandidateEdges = BuildFastTerminalOnlyHardRuleCandidate( + current.Edges, + nodes, + direction, + minLineClearance, + batchedFocusEdgeIds); + if (TryPromoteFinalHardRuleCandidate(current, quickBatchedCandidateEdges, nodes, out var quickBatchedPromoted)) + { + current = quickBatchedPromoted; + continue; + } + + var focusedTerminalClosureEdges = CloseRemainingTerminalViolations( + current.Edges, + nodes, + direction, + minLineClearance, + batchedFocusEdgeIds); + if (TryPromoteFinalHardRuleCandidate(current, focusedTerminalClosureEdges, nodes, out var focusedTerminalPromoted)) + { + current = focusedTerminalPromoted; + continue; + } + + var exactRestabilizedEdges = BuildFinalRestabilizedCandidate( + current.Edges, + nodes, + direction, + minLineClearance, + batchedFocusEdgeIds); + if (TryPromoteFinalHardRuleCandidate(current, exactRestabilizedEdges, nodes, out var exactRestabilizedPromoted)) + { + current = exactRestabilizedPromoted; + continue; + } + + var quickBatchedScore = ElkEdgeRoutingScoring.ComputeScore(quickBatchedCandidateEdges, nodes); + var quickBatchedRetryState = BuildRetryState( + quickBatchedScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(quickBatchedCandidateEdges, nodes).Count + : 0); + var changedFocusEdgeIds = batchedFocusEdgeIds + .Where(edgeId => HasEdgeGeometryChanged(current.Edges, quickBatchedCandidateEdges, edgeId)) + .ToArray(); + ElkLayoutDiagnostics.LogProgress( + $"Winner post-slot hard-rule round {round + 1} fast-terminal made no promotion: candidate={DescribeRetryState(quickBatchedRetryState)} changed=[{string.Join(", ", changedFocusEdgeIds)}]"); + } + else + { + ElkLayoutDiagnostics.LogProgress( + $"Winner post-slot hard-rule round {round + 1} full-restabilize focus=[{string.Join(", ", batchedFocusEdgeIds)}]"); + var batchedCandidateEdges = BuildFinalRestabilizedCandidate( + current.Edges, + nodes, + direction, + minLineClearance, + batchedFocusEdgeIds); + if (TryPromoteFinalHardRuleCandidate(current, batchedCandidateEdges, nodes, out var batchedPromoted)) + { + current = batchedPromoted; + continue; + } + } + } + + var orderedSeverityEdgeIds = severityByEdgeId + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Key) + .ToArray(); + var improved = false; + if (preferFastTerminalOnly) + { + var candidateEdgeIds = orderedSeverityEdgeIds + .Take(MaxWinnerPolishFastTerminalSingleEdgeCandidates) + .ToArray(); + ElkLayoutDiagnostics.LogProgress( + $"Winner post-slot hard-rule round {round + 1} evaluating {candidateEdgeIds.Length}/{orderedSeverityEdgeIds.Length} fast-terminal single-edge candidates in parallel"); + if (TryPromoteFastTerminalCandidates( + current, + nodes, + direction, + minLineClearance, + candidateEdgeIds, + out var parallelPromoted)) + { + current = parallelPromoted; + improved = true; + } + } + else + { + foreach (var edgeId in orderedSeverityEdgeIds) + { + var focusEdgeIds = ExpandWinningSolutionFocus(current.Edges, [edgeId]).ToArray(); + if (focusEdgeIds.Length == 0) + { + continue; + } + + var candidateEdges = BuildFinalRestabilizedCandidate( + current.Edges, + nodes, + direction, + minLineClearance, + focusEdgeIds); + if (!TryPromoteFinalHardRuleCandidate(current, candidateEdges, nodes, out var promoted)) + { + continue; + } + + current = promoted; + improved = true; + break; + } + } + + if (!improved) + { + break; + } + } + + return current; + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Detours.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Detours.cs new file mode 100644 index 000000000..7261299b1 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Detours.cs @@ -0,0 +1,75 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static CandidateSolution ApplyWinnerDetourPolish( + CandidateSolution solution, + ElkPositionedNode[] nodes, + double minLineClearance) + { + var focusSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountExcessiveDetourViolations(solution.Edges, nodes, focusSeverity, 10); + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(solution.Edges, nodes, focusSeverity, 10); + + if (focusSeverity.Count > 0) + { + var batchedRootEdgeIds = focusSeverity + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Take(Math.Min(focusSeverity.Count, MaxWinnerPolishBatchedRootEdges + 2)) + .Select(pair => pair.Key) + .ToArray(); + var batchedFocusEdgeIds = ExpandWinningSolutionFocus(solution.Edges, batchedRootEdgeIds).ToArray(); + if (batchedFocusEdgeIds.Length > 0) + { + var batchedCandidateEdges = ComposeTransactionalFinalDetourCandidate( + solution.Edges, + nodes, + minLineClearance, + batchedFocusEdgeIds); + batchedCandidateEdges = ChoosePreferredHardRuleLayout(solution.Edges, batchedCandidateEdges, nodes); + if (TryPromoteFinalDetourCandidate( + solution.Edges, + batchedCandidateEdges, + nodes, + solution.Score, + solution.RetryState, + out var batchedPromotedEdges)) + { + var batchedPromotedScore = ElkEdgeRoutingScoring.ComputeScore(batchedPromotedEdges, nodes); + var batchedPromotedRetryState = BuildRetryState( + batchedPromotedScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(batchedPromotedEdges, nodes).Count + : 0); + + solution = new CandidateSolution( + batchedPromotedScore, + batchedPromotedRetryState, + batchedPromotedEdges, + solution.StrategyIndex); + } + } + } + + var candidateEdges = ApplyFinalDetourPolish(solution.Edges, nodes, minLineClearance, restrictedEdgeIds: null); + if (ReferenceEquals(candidateEdges, solution.Edges)) + { + return solution; + } + + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); + var candidateRetryState = BuildRetryState( + candidateScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count + : 0); + + return new CandidateSolution(candidateScore, candidateRetryState, candidateEdges, solution.StrategyIndex); + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.FastTerminalCandidates.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.FastTerminalCandidates.cs new file mode 100644 index 000000000..80535d571 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.FastTerminalCandidates.cs @@ -0,0 +1,58 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static bool TryPromoteFastTerminalCandidates( + CandidateSolution current, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance, + IReadOnlyList orderedEdgeIds, + out CandidateSolution promoted) + { + promoted = current; + if (orderedEdgeIds.Count == 0) + { + return false; + } + + var candidatePromotions = new CandidateSolution?[orderedEdgeIds.Count]; + var parallelOptions = new ParallelOptions + { + MaxDegreeOfParallelism = Math.Min( + orderedEdgeIds.Count, + Math.Max(1, Environment.ProcessorCount)), + }; + + Parallel.For( + 0, + orderedEdgeIds.Count, + parallelOptions, + index => + { + var candidateEdges = BuildFastTerminalOnlyHardRuleCandidate( + current.Edges, + nodes, + direction, + minLineClearance, + [orderedEdgeIds[index]]); + if (TryPromoteFinalHardRuleCandidate(current, candidateEdges, nodes, out var candidatePromoted)) + { + candidatePromotions[index] = candidatePromoted; + } + }); + + for (var index = 0; index < candidatePromotions.Length; index++) + { + if (candidatePromotions[index] is not { } candidatePromoted) + { + continue; + } + + promoted = candidatePromoted; + return true; + } + + return false; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.FinalBoundarySlot.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.FinalBoundarySlot.cs new file mode 100644 index 000000000..ff4b4d10f --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.FinalBoundarySlot.cs @@ -0,0 +1,228 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + internal static ElkRoutedEdge[] BuildFinalBoundarySlotCandidate( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance, + IReadOnlyCollection? restrictedEdgeIds = null, + bool allowLateRestabilizedClosure = true) + { + var focusEdgeIds = restrictedEdgeIds?.Count > 0 + ? restrictedEdgeIds + : edges + .Select(edge => edge.Id) + .OrderBy(edgeId => edgeId, StringComparer.Ordinal) + .ToArray(); + var useLeanRestrictedBoundarySlotPass = + restrictedEdgeIds?.Count > 0 + && (!allowLateRestabilizedClosure + || restrictedEdgeIds.Count <= 2); + var useUltraLeanRestrictedBoundarySlotPass = + restrictedEdgeIds?.Count > 0 + && restrictedEdgeIds.Count <= MaxWinnerPolishBatchedRootEdges + 1; + ElkLayoutDiagnostics.LogProgress( + $"Boundary-slot candidate start: focus={focusEdgeIds.Count} allowLateRestabilizedClosure={allowLateRestabilizedClosure}"); + + var best = edges; + var candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( + edges, + nodes, + minLineClearance, + restrictedEdgeIds, + enforceAllNodeEndpoints: true); + best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); + ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after initial snap"); + var terminalClosureCandidate = useLeanRestrictedBoundarySlotPass + ? ApplyHybridTerminalRuleCleanupRound( + candidate, + nodes, + direction, + minLineClearance, + restrictedEdgeIds) + : CloseRemainingTerminalViolations( + candidate, + nodes, + direction, + minLineClearance, + restrictedEdgeIds); + candidate = ChoosePreferredBoundarySlotRepairLayout(candidate, terminalClosureCandidate, nodes); + best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); + ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after terminal closure"); + if (focusEdgeIds.Count > 0) + { + candidate = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts( + candidate, + nodes, + minLineClearance, + focusEdgeIds); + candidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts( + candidate, + nodes, + minLineClearance, + focusEdgeIds); + best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); + ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after face/shared-lane separation"); + } + + candidate = ClampBelowGraphEdges(candidate, nodes, restrictedEdgeIds); + candidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes); + candidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes); + candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( + candidate, + nodes, + minLineClearance, + restrictedEdgeIds, + enforceAllNodeEndpoints: true); + best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); + ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after normalization snap"); + if (useUltraLeanRestrictedBoundarySlotPass) + { + ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate complete (ultra-lean restricted path)"); + return ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); + } + candidate = ApplyPostSlotDetourClosure(candidate, nodes, minLineClearance, restrictedEdgeIds); + best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); + ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after detour closure"); + candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( + candidate, + nodes, + minLineClearance, + restrictedEdgeIds, + enforceAllNodeEndpoints: true); + best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); + ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after detour snap"); + if (useLeanRestrictedBoundarySlotPass) + { + ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate complete (lean restricted path)"); + return ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); + } + + if (restrictedEdgeIds?.Count > 0) + { + candidate = ChoosePreferredBoundarySlotRepairLayout( + candidate, + CloseRemainingTerminalViolations(candidate, nodes, direction, minLineClearance, restrictedEdgeIds), + nodes); + best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); + candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( + candidate, + nodes, + minLineClearance, + restrictedEdgeIds, + enforceAllNodeEndpoints: true); + best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); + ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after restricted terminal recheck"); + } + + candidate = ApplyLateBoundarySlotRestabilization( + candidate, + nodes, + minLineClearance, + focusEdgeIds); + best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); + ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after late restabilization"); + candidate = ChoosePreferredBoundarySlotRepairLayout( + candidate, + ElkEdgePostProcessor.SnapBoundarySlotAssignments( + candidate, + nodes, + minLineClearance, + restrictedEdgeIds, + enforceAllNodeEndpoints: true), + nodes); + best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); + ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after late restabilization snap"); + if (focusEdgeIds.Count > 0) + { + var lateHardRuleCandidate = ApplyAggressiveSharedLaneClosure( + candidate, + nodes, + direction, + minLineClearance, + focusEdgeIds); + lateHardRuleCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( + lateHardRuleCandidate, + nodes, + minLineClearance, + restrictedEdgeIds, + enforceAllNodeEndpoints: true); + candidate = ChoosePreferredHardRuleLayout(candidate, lateHardRuleCandidate, nodes); + candidate = ChoosePreferredBoundarySlotRepairLayout( + candidate, + ElkEdgePostProcessor.SnapBoundarySlotAssignments( + candidate, + nodes, + minLineClearance, + restrictedEdgeIds, + enforceAllNodeEndpoints: true), + nodes); + best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); + ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after late hard-rule closure"); + } + if (focusEdgeIds.Count > 0 + && (ElkEdgeRoutingScoring.CountBoundarySlotViolations(candidate, nodes) > 0 + || ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(candidate, nodes) > 0)) + { + var lateClosureCandidate = ApplyLateFocusedBoundarySlotClosure( + candidate, + nodes, + direction, + minLineClearance, + focusEdgeIds, + restrictedEdgeIds); + candidate = ChoosePreferredBoundarySlotRepairLayout(candidate, lateClosureCandidate, nodes); + candidate = ChoosePreferredBoundarySlotRepairLayout( + candidate, + ElkEdgePostProcessor.SnapBoundarySlotAssignments( + candidate, + nodes, + minLineClearance, + restrictedEdgeIds, + enforceAllNodeEndpoints: true), + nodes); + best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); + ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after focused late closure"); + } + + if (allowLateRestabilizedClosure + && focusEdgeIds.Count > 0 + && focusEdgeIds.Count <= MaxLateRestabilizedClosureFocusEdges) + { + var stagedLateRestabilizedCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( + edges, + nodes, + minLineClearance, + restrictedEdgeIds, + enforceAllNodeEndpoints: true); + stagedLateRestabilizedCandidate = BuildFinalRestabilizedCandidate( + stagedLateRestabilizedCandidate, + nodes, + direction, + minLineClearance, + restrictedEdgeIds); + stagedLateRestabilizedCandidate = ChoosePreferredBoundarySlotRepairLayout( + stagedLateRestabilizedCandidate, + ElkEdgePostProcessor.SnapBoundarySlotAssignments( + stagedLateRestabilizedCandidate, + nodes, + minLineClearance, + restrictedEdgeIds, + enforceAllNodeEndpoints: true), + nodes); + candidate = ChoosePreferredBoundarySlotRepairLayout(candidate, stagedLateRestabilizedCandidate, nodes); + best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); + ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after staged late restabilized closure"); + } + + ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate complete"); + return ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.FinalRestabilized.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.FinalRestabilized.cs new file mode 100644 index 000000000..62c9875c9 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.FinalRestabilized.cs @@ -0,0 +1,213 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + internal static ElkRoutedEdge[] BuildFinalRestabilizedCandidate( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance, + IReadOnlyCollection? restrictedEdgeIds = null) + { + var focusEdgeIds = restrictedEdgeIds?.Count > 0 + ? restrictedEdgeIds + : edges + .Select(edge => edge.Id) + .OrderBy(edgeId => edgeId, StringComparer.Ordinal) + .ToArray(); + var focusEdgeSet = focusEdgeIds.ToHashSet(StringComparer.Ordinal); + + var candidate = ChoosePreferredHardRuleLayout( + edges, + CloseRemainingTerminalViolations(edges, nodes, direction, minLineClearance, restrictedEdgeIds), + nodes); + if (focusEdgeIds.Count > 0) + { + candidate = ChoosePreferredHardRuleLayout( + candidate, + ApplyAggressiveSharedLaneClosure( + candidate, + nodes, + direction, + minLineClearance, + focusEdgeIds), + nodes); + } + + candidate = BuildFinalBoundarySlotCandidate( + candidate, + nodes, + direction, + minLineClearance, + restrictedEdgeIds, + allowLateRestabilizedClosure: false); + if (focusEdgeIds.Count > 0) + { + candidate = ChoosePreferredHardRuleLayout( + candidate, + ApplyAggressiveSharedLaneClosure( + candidate, + nodes, + direction, + minLineClearance, + focusEdgeIds), + nodes); + } + + candidate = ChoosePreferredBoundarySlotRepairLayout( + candidate, + ElkEdgePostProcessor.SnapBoundarySlotAssignments( + candidate, + nodes, + minLineClearance, + restrictedEdgeIds, + enforceAllNodeEndpoints: true), + nodes); + candidate = ChoosePreferredBoundarySlotRepairLayout( + candidate, + CloseRemainingTerminalViolations(candidate, nodes, direction, minLineClearance, restrictedEdgeIds), + nodes); + if (focusEdgeIds.Count > 0) + { + var lateHardRuleCandidate = ApplyAggressiveSharedLaneClosure( + candidate, + nodes, + direction, + minLineClearance, + focusEdgeIds); + lateHardRuleCandidate = CloseRemainingTerminalViolations( + lateHardRuleCandidate, + nodes, + direction, + minLineClearance, + focusEdgeIds); + candidate = ChoosePreferredHardRuleLayout(candidate, lateHardRuleCandidate, nodes); + + var remainingBadAngles = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountBadBoundaryAngles(candidate, nodes, remainingBadAngles, 10); + var remainingTerminalFocus = ExpandWinningSolutionFocus( + candidate, + remainingBadAngles.Keys.Where(focusEdgeSet.Contains)) + .Where(focusEdgeSet.Contains) + .OrderBy(edgeId => edgeId, StringComparer.Ordinal) + .ToArray(); + if (remainingTerminalFocus.Length > 0) + { + var terminalFocus = (IReadOnlyCollection)remainingTerminalFocus; + var lateTerminalCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches( + candidate, + nodes, + minLineClearance, + terminalFocus); + lateTerminalCandidate = BuildFinalBoundarySlotCandidate( + lateTerminalCandidate, + nodes, + direction, + minLineClearance, + terminalFocus, + allowLateRestabilizedClosure: false); + lateTerminalCandidate = CloseRemainingTerminalViolations( + lateTerminalCandidate, + nodes, + direction, + minLineClearance, + terminalFocus); + candidate = ChoosePreferredBoundarySlotRepairLayout(candidate, lateTerminalCandidate, nodes); + } + } + + candidate = ChoosePreferredBoundarySlotRepairLayout( + candidate, + ElkEdgePostProcessor.SnapBoundarySlotAssignments( + candidate, + nodes, + minLineClearance, + restrictedEdgeIds, + enforceAllNodeEndpoints: true), + nodes); + candidate = ChoosePreferredBoundarySlotRepairLayout( + candidate, + ApplyPostSlotDetourClosure(candidate, nodes, minLineClearance, restrictedEdgeIds), + nodes); + candidate = ChoosePreferredBoundarySlotRepairLayout( + candidate, + ElkEdgePostProcessor.SnapBoundarySlotAssignments( + candidate, + nodes, + minLineClearance, + restrictedEdgeIds, + enforceAllNodeEndpoints: true), + nodes); + candidate = ApplyLateBoundarySlotRestabilization( + candidate, + nodes, + minLineClearance, + focusEdgeIds); + candidate = ChoosePreferredBoundarySlotRepairLayout( + candidate, + ElkEdgePostProcessor.SnapBoundarySlotAssignments( + candidate, + nodes, + minLineClearance, + restrictedEdgeIds, + enforceAllNodeEndpoints: true), + nodes); + + if (focusEdgeIds.Count > 0) + { + var finalSharedLaneCandidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts( + candidate, + nodes, + minLineClearance, + focusEdgeIds); + candidate = ChoosePreferredSharedLanePolishLayout(candidate, finalSharedLaneCandidate, nodes); + + var finalSourceJoinCandidate = ElkEdgePostProcessor.SpreadSourceDepartureJoins( + finalSharedLaneCandidate, + nodes, + minLineClearance, + focusEdgeIds); + candidate = ChoosePreferredSharedLanePolishLayout(candidate, finalSourceJoinCandidate, nodes); + + var finalTargetJoinCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins( + finalSourceJoinCandidate, + nodes, + minLineClearance, + focusEdgeIds, + forceOutwardAxisSpacing: true); + candidate = ChoosePreferredSharedLanePolishLayout(candidate, finalTargetJoinCandidate, nodes); + + var finalAggressiveCandidate = ApplyAggressiveSharedLaneClosure( + candidate, + nodes, + direction, + minLineClearance, + focusEdgeIds); + candidate = ChoosePreferredSharedLanePolishLayout(candidate, finalAggressiveCandidate, nodes); + + var forcedFinalSharedLaneCandidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts( + candidate, + nodes, + minLineClearance, + focusEdgeIds); + var baselineSharedLaneViolations = ElkEdgeRoutingScoring.CountSharedLaneViolations(candidate, nodes); + var forcedSharedLaneViolations = ElkEdgeRoutingScoring.CountSharedLaneViolations(forcedFinalSharedLaneCandidate, nodes); + if (forcedSharedLaneViolations < baselineSharedLaneViolations) + { + var baselineScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes); + var forcedScore = ElkEdgeRoutingScoring.ComputeScore(forcedFinalSharedLaneCandidate, nodes); + if (forcedScore.NodeCrossings <= baselineScore.NodeCrossings) + { + candidate = forcedFinalSharedLaneCandidate; + } + } + } + + return candidate; + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Focus.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Focus.cs new file mode 100644 index 000000000..362f1065a --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Focus.cs @@ -0,0 +1,127 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static IEnumerable ExpandWinningSolutionFocus( + IReadOnlyCollection edges, + IEnumerable focusEdgeIds) + { + var edgesById = edges.ToDictionary(edge => edge.Id, StringComparer.Ordinal); + var expanded = new HashSet(StringComparer.Ordinal); + + foreach (var edgeId in focusEdgeIds) + { + if (!expanded.Add(edgeId) || !edgesById.TryGetValue(edgeId, out var edge)) + { + continue; + } + + foreach (var peer in edges) + { + if (string.Equals(peer.Id, edge.Id, StringComparison.Ordinal)) + { + continue; + } + + if (string.Equals(peer.SourceNodeId, edge.SourceNodeId, StringComparison.Ordinal) + || string.Equals(peer.TargetNodeId, edge.TargetNodeId, StringComparison.Ordinal) + || string.Equals(peer.SourceNodeId, edge.TargetNodeId, StringComparison.Ordinal) + || string.Equals(peer.TargetNodeId, edge.SourceNodeId, StringComparison.Ordinal)) + { + expanded.Add(peer.Id); + } + } + } + + return expanded.OrderBy(edgeId => edgeId, StringComparer.Ordinal); + } + + private static IEnumerable ExpandSharedLanePolishFocus( + IReadOnlyCollection edges, + IReadOnlyCollection nodes, + string focusEdgeId) + { + var edgesById = edges.ToDictionary(edge => edge.Id, StringComparer.Ordinal); + if (!edgesById.TryGetValue(focusEdgeId, out var focusEdge)) + { + return []; + } + + var focusedEdgeIds = new HashSet(StringComparer.Ordinal) + { + focusEdgeId, + }; + var sharedNodeIds = new HashSet(StringComparer.Ordinal); + + foreach (var (leftEdgeId, rightEdgeId) in ElkEdgeRoutingScoring.DetectSharedLaneConflicts(edges, nodes)) + { + string partnerEdgeId; + if (string.Equals(leftEdgeId, focusEdgeId, StringComparison.Ordinal)) + { + partnerEdgeId = rightEdgeId; + } + else if (string.Equals(rightEdgeId, focusEdgeId, StringComparison.Ordinal)) + { + partnerEdgeId = leftEdgeId; + } + else + { + continue; + } + + if (!edgesById.TryGetValue(partnerEdgeId, out var partnerEdge)) + { + continue; + } + + focusedEdgeIds.Add(partnerEdgeId); + CollectSharedConflictNodeIds(focusEdge, partnerEdge, sharedNodeIds); + } + + if (sharedNodeIds.Count == 0) + { + return ExpandWinningSolutionFocus(edges, [focusEdgeId]); + } + + foreach (var edge in edges) + { + if (focusedEdgeIds.Contains(edge.Id)) + { + continue; + } + + if ((edge.SourceNodeId is not null && sharedNodeIds.Contains(edge.SourceNodeId)) + || (edge.TargetNodeId is not null && sharedNodeIds.Contains(edge.TargetNodeId))) + { + focusedEdgeIds.Add(edge.Id); + } + } + + return focusedEdgeIds.OrderBy(edgeId => edgeId, StringComparer.Ordinal); + } + + private static void CollectSharedConflictNodeIds( + ElkRoutedEdge edge, + ElkRoutedEdge partner, + ISet sharedNodeIds) + { + if (edge.SourceNodeId is not null + && (string.Equals(edge.SourceNodeId, partner.SourceNodeId, StringComparison.Ordinal) + || string.Equals(edge.SourceNodeId, partner.TargetNodeId, StringComparison.Ordinal))) + { + sharedNodeIds.Add(edge.SourceNodeId); + } + + if (edge.TargetNodeId is not null + && (string.Equals(edge.TargetNodeId, partner.SourceNodeId, StringComparison.Ordinal) + || string.Equals(edge.TargetNodeId, partner.TargetNodeId, StringComparison.Ordinal))) + { + sharedNodeIds.Add(edge.TargetNodeId); + } + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs new file mode 100644 index 000000000..15afb964c --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs @@ -0,0 +1,86 @@ +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, 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)}"); + } + + return current; + } + + 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; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.HybridLowWave.Residual.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.HybridLowWave.Residual.cs new file mode 100644 index 000000000..5ee3306e8 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.HybridLowWave.Residual.cs @@ -0,0 +1,664 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static ElkRoutedEdge[] ApplyHybridFocusedResidualTerminalRecovery( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance) + { + var boundarySlotSeverity = new Dictionary(StringComparer.Ordinal); + var backtrackingSeverity = new Dictionary(StringComparer.Ordinal); + var entrySeverity = new Dictionary(StringComparer.Ordinal); + var boundaryAngleSeverity = new Dictionary(StringComparer.Ordinal); + var gatewaySourceSeverity = new Dictionary(StringComparer.Ordinal); + var targetJoinSeverity = new Dictionary(StringComparer.Ordinal); + var severityByEdgeId = new Dictionary(StringComparer.Ordinal); + var pressure = + ElkEdgeRoutingScoring.CountBoundarySlotViolations(edges, nodes, severityByEdgeId, 40) + + ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(edges, nodes, severityByEdgeId, 40) + + ElkEdgeRoutingScoring.CountBadEntryAngles(edges, nodes, severityByEdgeId, 20) + + ElkEdgeRoutingScoring.CountBadBoundaryAngles(edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(edges, nodes, severityByEdgeId, 10); + ElkEdgeRoutingScoring.CountBoundarySlotViolations(edges, nodes, boundarySlotSeverity, 40); + ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(edges, nodes, backtrackingSeverity, 40); + ElkEdgeRoutingScoring.CountBadEntryAngles(edges, nodes, entrySeverity, 20); + ElkEdgeRoutingScoring.CountBadBoundaryAngles(edges, nodes, boundaryAngleSeverity, 10); + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(edges, nodes, gatewaySourceSeverity, 10); + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(edges, nodes, targetJoinSeverity, 10); + if (pressure == 0 || severityByEdgeId.Count == 0) + { + return edges; + } + + var boundaryRootEdgeIds = BuildHybridResidualCategoryRootEdgeIds(boundarySlotSeverity, gatewaySourceSeverity); + var approachRootEdgeIds = BuildHybridResidualApproachRootEdgeIds( + backtrackingSeverity, + entrySeverity, + boundaryAngleSeverity, + targetJoinSeverity); + var boundaryFocusEdgeIds = ExpandHybridResidualBoundaryFocus(edges, boundaryRootEdgeIds); + var rootEdgeIds = boundaryRootEdgeIds + .Concat(approachRootEdgeIds) + .Distinct(StringComparer.Ordinal) + .OrderBy(edgeId => edgeId, StringComparer.Ordinal) + .ToArray(); + if (boundaryFocusEdgeIds.Length == 0 && approachRootEdgeIds.Length == 0) + { + return edges; + } + + ElkLayoutDiagnostics.LogProgress( + $"Hybrid low-wave residual recovery start: roots=[{string.Join(", ", rootEdgeIds)}] boundary-focus={boundaryFocusEdgeIds.Length} approach-roots={approachRootEdgeIds.Length}"); + + var candidate = edges; + if (boundaryFocusEdgeIds.Length > 0) + { + candidate = ChoosePreferredBoundarySlotRepairLayout( + candidate, + BuildFinalBoundarySlotCandidate( + candidate, + nodes, + direction, + minLineClearance, + boundaryFocusEdgeIds, + allowLateRestabilizedClosure: false), + nodes); + + if (boundaryRootEdgeIds.Length > 0 + && (ElkEdgeRoutingScoring.CountBoundarySlotViolations(candidate, nodes) > 0 + || ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(candidate, nodes) > 0)) + { + foreach (var boundaryRootEdgeId in boundaryRootEdgeIds) + { + var exactBoundaryFocusEdgeIds = ExpandHybridResidualBoundaryFocus(candidate, [boundaryRootEdgeId]); + if (exactBoundaryFocusEdgeIds.Length == 0) + { + continue; + } + + ElkLayoutDiagnostics.LogProgress( + $"Hybrid residual boundary root {boundaryRootEdgeId}: focus=[{string.Join(", ", exactBoundaryFocusEdgeIds)}]"); + candidate = ChoosePreferredBoundarySlotRepairLayout( + candidate, + BuildFinalBoundarySlotCandidate( + BuildHybridResidualSingleEdgeRepairCandidate( + candidate, + nodes, + minLineClearance, + boundaryRootEdgeId, + ["boundary-slots", "gateway-source-exit"]), + nodes, + direction, + minLineClearance, + exactBoundaryFocusEdgeIds, + allowLateRestabilizedClosure: false), + nodes); + + if (ElkEdgeRoutingScoring.CountBoundarySlotViolations(candidate, nodes) == 0 + && ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(candidate, nodes) == 0) + { + break; + } + } + } + } + + foreach (var approachRootEdgeId in approachRootEdgeIds) + { + if (boundaryRootEdgeIds.Contains(approachRootEdgeId, StringComparer.Ordinal)) + { + continue; + } + + ElkLayoutDiagnostics.LogProgress( + $"Hybrid residual approach root {approachRootEdgeId}: focus=[{approachRootEdgeId}]"); + var singleEdgeCandidate = BuildHybridResidualSingleEdgeRepairCandidate( + candidate, + nodes, + minLineClearance, + approachRootEdgeId, + ["approach-backtracking", "entry"]); + candidate = ChoosePreferredFocusedApproachRepairLayout( + candidate, + singleEdgeCandidate, + nodes, + approachRootEdgeId); + + if (!HasFocusedApproachResidualViolations(candidate, nodes, approachRootEdgeId)) + { + continue; + } + + if (IsGatewayTargetEdge(candidate, nodes, approachRootEdgeId)) + { + continue; + } + + candidate = ChoosePreferredFocusedApproachRepairLayout( + candidate, + BuildHybridResidualApproachCandidate( + candidate, + nodes, + minLineClearance, + [approachRootEdgeId]), + nodes, + approachRootEdgeId); + + if (ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(candidate, nodes) == 0 + && ElkEdgeRoutingScoring.CountBadEntryAngles(candidate, nodes) == 0 + && ElkEdgeRoutingScoring.CountBadBoundaryAngles(candidate, nodes) == 0 + && ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(candidate, nodes) == 0) + { + break; + } + } + + if (boundaryFocusEdgeIds.Length > 0 + && ElkEdgeRoutingScoring.CountBoundarySlotViolations(candidate, nodes) > 0) + { + candidate = ChoosePreferredBoundarySlotRepairLayout( + candidate, + ElkEdgePostProcessor.SnapBoundarySlotAssignments( + candidate, + nodes, + minLineClearance, + boundaryFocusEdgeIds, + enforceAllNodeEndpoints: true), + nodes); + } + + return ChoosePreferredBoundarySlotRepairLayout(edges, candidate, nodes); + } + + private static ElkRoutedEdge[] BuildHybridResidualApproachCandidate( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + double minLineClearance, + string[] focusEdgeIds) + { + var candidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(edges, nodes, focusEdgeIds); + candidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(candidate, nodes, minLineClearance, focusEdgeIds); + candidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(candidate, nodes, minLineClearance, focusEdgeIds); + candidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(candidate, nodes, minLineClearance, focusEdgeIds); + candidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(candidate, nodes, focusEdgeIds); + candidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(candidate, nodes, focusEdgeIds); + candidate = ApplyPostSlotDetourClosure(candidate, nodes, minLineClearance, focusEdgeIds); + candidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes); + candidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes); + return candidate; + } + + private static ElkRoutedEdge[] BuildHybridResidualSingleEdgeRepairCandidate( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + double minLineClearance, + string edgeId, + IReadOnlyCollection repairReasons) + { + var edgeIndex = Array.FindIndex(edges, edge => string.Equals(edge.Id, edgeId, StringComparison.Ordinal)); + if (edgeIndex < 0) + { + return edges; + } + + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var graphMinY = nodes.Length > 0 ? nodes.Min(node => node.Y) : 0d; + var graphMaxY = nodes.Length > 0 ? nodes.Max(node => node.Y + node.Height) : 0d; + var strategy = new RoutingStrategy + { + EdgeOrder = [edgeIndex], + BaseLineClearance = minLineClearance, + MinLineClearance = minLineClearance, + RoutingParams = new AStarRoutingParams(18d, 200d, 500d, 2.0d, minLineClearance, 40d, true), + }; + var obstacleMargin = Math.Max( + strategy.MinLineClearance + 4d, + strategy.RoutingParams.Margin); + var obstacles = BuildObstacles(nodes, obstacleMargin); + var spreadEndpoints = SpreadTargetEndpoints(edges, nodesById, graphMinY, graphMaxY, strategy.MinLineClearance); + var softObstacles = edges + .Where(edge => !string.Equals(edge.Id, edgeId, StringComparison.Ordinal)) + .SelectMany(ElkEdgeRoutingGeometry.FlattenSegments) + .Select(segment => new OrthogonalSoftObstacle(segment.Start, segment.End)) + .ToArray(); + var buildResult = BuildRepairEdgeResult( + edgeIndex, + edges, + nodes, + obstacles, + spreadEndpoints, + nodesById, + softObstacles, + new HashSet([edgeId], StringComparer.Ordinal), + new HashSet([edgeId], StringComparer.Ordinal), + repairReasons, + graphMinY, + graphMaxY, + strategy, + CancellationToken.None); + if (buildResult.WasSkipped || buildResult.RoutedSections == 0) + { + return edges; + } + + var candidate = edges.ToArray(); + candidate[edgeIndex] = buildResult.Edge; + return candidate; + } + + private static ElkRoutedEdge[] ApplyHybridFinalGatewayTargetEntryCleanup( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes) + { + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var severityByEdgeId = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(edges, nodes, severityByEdgeId, 40); + ElkEdgeRoutingScoring.CountBadEntryAngles(edges, nodes, severityByEdgeId, 20); + var focusEdgeIds = severityByEdgeId + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Key) + .Where(edgeId => + { + var edge = edges.FirstOrDefault(candidate => string.Equals(candidate.Id, edgeId, StringComparison.Ordinal)); + return edge is not null + && nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode) + && ElkShapeBoundaries.IsGatewayShape(targetNode); + }) + .Take(2) + .ToArray(); + if (focusEdgeIds.Length == 0) + { + return edges; + } + + var candidate = edges; + foreach (var focusEdgeId in focusEdgeIds) + { + var repaired = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(candidate, nodes, [focusEdgeId]); + repaired = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(repaired, nodes, [focusEdgeId]); + repaired = ElkEdgePostProcessor.NormalizeBoundaryAngles(repaired, nodes); + repaired = ElkEdgePostProcessor.NormalizeSourceExitAngles(repaired, nodes); + candidate = ChoosePreferredFocusedApproachRepairLayout(candidate, repaired, nodes, focusEdgeId); + } + + return candidate; + } + + private static ElkRoutedEdge[] ChoosePreferredFocusedApproachRepairLayout( + ElkRoutedEdge[] baseline, + ElkRoutedEdge[] candidate, + ElkPositionedNode[] nodes, + string focusEdgeId) + { + if (ReferenceEquals(candidate, baseline)) + { + return baseline; + } + + var baselinePenalty = CountFocusedApproachResidualPenalty(baseline, nodes, focusEdgeId); + var candidatePenalty = CountFocusedApproachResidualPenalty(candidate, nodes, focusEdgeId); + if (candidatePenalty < baselinePenalty) + { + var baselineScore = ElkEdgeRoutingScoring.ComputeScore(baseline, nodes); + var baselineRetryState = BuildRetryState( + baselineScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(baseline, nodes).Count + : 0); + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes); + var candidateRetryState = BuildRetryState( + candidateScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count + : 0); + if (!HasHardRuleRegression(candidateRetryState, baselineRetryState) + && candidateScore.NodeCrossings <= baselineScore.NodeCrossings) + { + return candidate; + } + } + + return ChoosePreferredBoundarySlotRepairLayout(baseline, candidate, nodes); + } + + private static bool HasFocusedApproachResidualViolations( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + string focusEdgeId) + { + return CountFocusedApproachResidualPenalty(edges, nodes, focusEdgeId) > 0; + } + + private static bool IsGatewayTargetEdge( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + string focusEdgeId) + { + var edge = edges.FirstOrDefault(candidate => string.Equals(candidate.Id, focusEdgeId, StringComparison.Ordinal)); + if (edge is null) + { + return false; + } + + var targetNode = nodes.FirstOrDefault(node => string.Equals(node.Id, edge.TargetNodeId, StringComparison.Ordinal)); + return targetNode is not null && ElkShapeBoundaries.IsGatewayShape(targetNode); + } + + private static int CountFocusedApproachResidualPenalty( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + string focusEdgeId) + { + var edge = edges.FirstOrDefault(candidate => string.Equals(candidate.Id, focusEdgeId, StringComparison.Ordinal)); + if (edge is null) + { + return int.MaxValue / 4; + } + + return (ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations([edge], nodes) * 100) + + (ElkEdgeRoutingScoring.CountBadEntryAngles([edge], nodes) * 40) + + (ElkEdgeRoutingScoring.CountBadBoundaryAngles([edge], nodes) * 20) + + (ElkEdgeRoutingScoring.CountTargetApproachJoinViolations([edge], nodes) * 10); + } + + private static string[] ExpandHybridResidualBoundaryFocus( + IReadOnlyCollection edges, + IReadOnlyList rootEdgeIds) + { + if (rootEdgeIds.Count == 0) + { + return []; + } + + var edgesById = edges.ToDictionary(edge => edge.Id, StringComparer.Ordinal); + var focused = new HashSet(StringComparer.Ordinal); + var ordered = new List(MaxLateRestabilizedClosureFocusEdges); + + foreach (var rootEdgeId in rootEdgeIds) + { + if (!focused.Add(rootEdgeId)) + { + continue; + } + + ordered.Add(rootEdgeId); + } + + foreach (var rootEdgeId in rootEdgeIds) + { + if (!edgesById.TryGetValue(rootEdgeId, out var rootEdge)) + { + continue; + } + + AddSharedEndpointPeers( + edges, + rootEdge, + focused, + ordered, + includeSharedSource: true, + includeSharedTarget: true); + AddDefaultDownstreamChain(edgesById, edges, rootEdge.TargetNodeId, focused, ordered); + } + + return ordered.ToArray(); + } + + private static string[] ExpandHybridResidualApproachFocus( + IReadOnlyCollection edges, + IReadOnlyList rootEdgeIds) + { + if (rootEdgeIds.Count == 0) + { + return []; + } + + var edgesById = edges.ToDictionary(edge => edge.Id, StringComparer.Ordinal); + var focused = new HashSet(StringComparer.Ordinal); + var ordered = new List(MaxLateRestabilizedClosureFocusEdges); + + foreach (var rootEdgeId in rootEdgeIds) + { + if (!focused.Add(rootEdgeId)) + { + continue; + } + + ordered.Add(rootEdgeId); + } + + foreach (var rootEdgeId in rootEdgeIds) + { + if (!edgesById.TryGetValue(rootEdgeId, out var rootEdge)) + { + continue; + } + + AddSharedEndpointPeers( + edges, + rootEdge, + focused, + ordered, + includeSharedSource: true, + includeSharedTarget: true); + AddOutgoingTargetEdges(edges, rootEdge.TargetNodeId, focused, ordered); + } + + return ordered.ToArray(); + } + + private static string[] BuildHybridResidualCategoryRootEdgeIds( + params IReadOnlyDictionary[] severities) + { + var ordered = new List(Math.Min(2, MaxWinnerPolishBatchedRootEdges)); + var seen = new HashSet(StringComparer.Ordinal); + + foreach (var severity in severities) + { + if (ordered.Count >= Math.Min(2, MaxWinnerPolishBatchedRootEdges)) + { + break; + } + + AddTopSeverityEdge(severity, seen, ordered); + } + + return ordered.ToArray(); + } + + private static string[] BuildHybridResidualApproachRootEdgeIds( + IReadOnlyDictionary backtrackingSeverity, + IReadOnlyDictionary entrySeverity, + IReadOnlyDictionary boundaryAngleSeverity, + IReadOnlyDictionary targetJoinSeverity) + { + const int maxResidualApproachRoots = 2; + var weightedSeverity = new Dictionary(StringComparer.Ordinal); + + AddWeightedSeverity(weightedSeverity, backtrackingSeverity, 100); + AddWeightedSeverity(weightedSeverity, entrySeverity, 40); + AddWeightedSeverity(weightedSeverity, boundaryAngleSeverity, 20); + AddWeightedSeverity(weightedSeverity, targetJoinSeverity, 10); + + return weightedSeverity + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Take(maxResidualApproachRoots) + .Select(pair => pair.Key) + .ToArray(); + } + + private static void AddTopSeverityEdge( + IReadOnlyDictionary severity, + ISet seen, + IList ordered) + { + foreach (var edgeId in severity + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Key)) + { + if (!seen.Add(edgeId)) + { + continue; + } + + ordered.Add(edgeId); + return; + } + } + + private static void AddTopSeverityEdges( + IReadOnlyDictionary severity, + ISet seen, + IList ordered, + int take, + int maxTotal = int.MaxValue) + { + foreach (var edgeId in severity + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Key)) + { + if (ordered.Count >= maxTotal) + { + return; + } + + if (!seen.Add(edgeId)) + { + continue; + } + + ordered.Add(edgeId); + if (--take <= 0) + { + return; + } + } + } + + private static void AddWeightedSeverity( + IDictionary weightedSeverity, + IReadOnlyDictionary severity, + int weight) + { + foreach (var pair in severity) + { + weightedSeverity[pair.Key] = weightedSeverity.TryGetValue(pair.Key, out var current) + ? current + (pair.Value * weight) + : pair.Value * weight; + } + } + + private static void AddSharedEndpointPeers( + IReadOnlyCollection edges, + ElkRoutedEdge rootEdge, + ISet focused, + IList ordered, + bool includeSharedSource, + bool includeSharedTarget) + { + foreach (var peer in edges.OrderBy(edge => edge.Id, StringComparer.Ordinal)) + { + if (ordered.Count >= MaxLateRestabilizedClosureFocusEdges + || string.Equals(peer.Id, rootEdge.Id, StringComparison.Ordinal)) + { + continue; + } + + if (includeSharedSource + && string.Equals(peer.SourceNodeId, rootEdge.SourceNodeId, StringComparison.Ordinal) + && focused.Add(peer.Id)) + { + ordered.Add(peer.Id); + continue; + } + + if (includeSharedTarget + && string.Equals(peer.TargetNodeId, rootEdge.TargetNodeId, StringComparison.Ordinal) + && focused.Add(peer.Id)) + { + ordered.Add(peer.Id); + } + } + } + + private static void AddOutgoingTargetEdges( + IReadOnlyCollection edges, + string? nodeId, + ISet focused, + IList ordered) + { + if (string.IsNullOrWhiteSpace(nodeId)) + { + return; + } + + foreach (var peer in edges + .Where(edge => string.Equals(edge.SourceNodeId, nodeId, StringComparison.Ordinal)) + .OrderBy(edge => edge.Id, StringComparer.Ordinal)) + { + if (!focused.Add(peer.Id)) + { + continue; + } + + ordered.Add(peer.Id); + if (ordered.Count >= MaxLateRestabilizedClosureFocusEdges) + { + return; + } + } + } + + private static void AddDefaultDownstreamChain( + IReadOnlyDictionary edgesById, + IReadOnlyCollection edges, + string? nodeId, + ISet focused, + IList ordered) + { + for (var depth = 0; depth < 2 && !string.IsNullOrWhiteSpace(nodeId); depth++) + { + var nextEdge = edges + .Where(edge => string.Equals(edge.SourceNodeId, nodeId, StringComparison.Ordinal)) + .OrderBy(edge => IsDefaultLikeEdge(edge) ? 0 : 1) + .ThenBy(edge => edge.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (nextEdge is null) + { + return; + } + + if (focused.Add(nextEdge.Id)) + { + ordered.Add(nextEdge.Id); + if (ordered.Count >= MaxLateRestabilizedClosureFocusEdges) + { + return; + } + } + + nodeId = nextEdge.TargetNodeId; + if (nodeId is not null && edgesById.ContainsKey(nextEdge.Id) && !IsDefaultLikeEdge(nextEdge)) + { + return; + } + } + } + + private static bool IsDefaultLikeEdge(ElkRoutedEdge edge) + { + if (string.IsNullOrWhiteSpace(edge.Label)) + { + return true; + } + + return string.Equals(edge.Label.Trim(), "default", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.HybridLowWave.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.HybridLowWave.cs new file mode 100644 index 000000000..b4b04f2bf --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.HybridLowWave.cs @@ -0,0 +1,155 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static CandidateSolution ApplyHybridLeanPostSlotHardRulePolish( + CandidateSolution solution, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance) + { + var severityByEdgeId = new Dictionary(StringComparer.Ordinal); + var pressure = + ElkEdgeRoutingScoring.CountBadBoundaryAngles(solution.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(solution.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(solution.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountSharedLaneViolations(solution.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountBoundarySlotViolations(solution.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(solution.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountExcessiveDetourViolations(solution.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountBelowGraphViolations(solution.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountUnderNodeViolations(solution.Edges, nodes, severityByEdgeId, 10); + ElkLayoutDiagnostics.LogProgress( + $"Hybrid lean post-slot hard-rule start: pressure={pressure} retry={DescribeRetryState(solution.RetryState)} focus={severityByEdgeId.Count}"); + if (pressure == 0 || severityByEdgeId.Count == 0) + { + return solution; + } + + var rootEdgeIds = severityByEdgeId + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Take(MaxWinnerPolishBatchedRootEdges) + .Select(pair => pair.Key) + .ToArray(); + var focusEdgeIds = ExpandHybridLeanPostSlotFocus(solution.Edges, rootEdgeIds); + if (focusEdgeIds.Length == 0) + { + return solution; + } + + var candidateEdges = BuildFinalBoundarySlotCandidate( + solution.Edges, + nodes, + direction, + minLineClearance, + focusEdgeIds, + allowLateRestabilizedClosure: false); + candidateEdges = ChoosePreferredHardRuleLayout( + solution.Edges, + candidateEdges, + nodes); + + if (solution.RetryState.SharedLaneViolations > 0 + || solution.RetryState.TargetApproachJoinViolations > 0 + || solution.RetryState.UnderNodeViolations > 0 + || solution.RetryState.BoundarySlotViolations > 0 + || solution.RetryState.GatewaySourceExitViolations > 0) + { + candidateEdges = ChoosePreferredHardRuleLayout( + candidateEdges, + ApplyAggressiveSharedLaneClosure( + candidateEdges, + nodes, + direction, + minLineClearance, + focusEdgeIds), + nodes); + } + + var shouldRunFocusedTerminalCleanup = + focusEdgeIds.Length <= MaxWinnerPolishBatchedRootEdges + && (ElkEdgeRoutingScoring.CountSharedLaneViolations(candidateEdges, nodes) > 0 + || ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(candidateEdges, nodes) > 0 + || ElkEdgeRoutingScoring.CountUnderNodeViolations(candidateEdges, nodes) > 0 + || ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(candidateEdges, nodes) > 0); + if (shouldRunFocusedTerminalCleanup) + { + candidateEdges = ChoosePreferredBoundarySlotRepairLayout( + candidateEdges, + ApplyHybridTerminalRuleCleanupRound( + candidateEdges, + nodes, + direction, + minLineClearance, + focusEdgeIds), + nodes); + } + if (ElkEdgeRoutingScoring.CountBoundarySlotViolations(candidateEdges, nodes) > 0 + || ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(candidateEdges, nodes) > 0 + || ElkEdgeRoutingScoring.CountBadEntryAngles(candidateEdges, nodes) > 0) + { + candidateEdges = ApplyHybridFocusedResidualTerminalRecovery( + candidateEdges, + nodes, + direction, + minLineClearance); + } + + if (ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(candidateEdges, nodes) > 0 + || ElkEdgeRoutingScoring.CountBadEntryAngles(candidateEdges, nodes) > 0) + { + candidateEdges = ApplyHybridFinalGatewayTargetEntryCleanup(candidateEdges, nodes); + } + + return TryPromoteFinalHardRuleCandidate(solution, candidateEdges, nodes, out var promoted) + ? promoted + : solution; + } + + private static string[] ExpandHybridLeanPostSlotFocus( + IReadOnlyCollection edges, + IReadOnlyList rootEdgeIds) + { + const int maxFocusedEdges = 5; + if (rootEdgeIds.Count == 0) + { + return []; + } + + var edgesById = edges.ToDictionary(edge => edge.Id, StringComparer.Ordinal); + var focused = new HashSet(StringComparer.Ordinal); + var ordered = new List(Math.Max(rootEdgeIds.Count, maxFocusedEdges)); + + foreach (var rootEdgeId in rootEdgeIds) + { + if (!focused.Add(rootEdgeId)) + { + continue; + } + + ordered.Add(rootEdgeId); + } + + foreach (var rootEdgeId in rootEdgeIds) + { + if (ordered.Count >= maxFocusedEdges + || !edgesById.TryGetValue(rootEdgeId, out var rootEdge)) + { + continue; + } + + AddSharedEndpointPeers( + edges, + rootEdge, + focused, + ordered, + includeSharedSource: true, + includeSharedTarget: true); + } + + return ordered + .Take(maxFocusedEdges) + .ToArray(); + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.LateRestabilization.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.LateRestabilization.cs new file mode 100644 index 000000000..7deff3e0f --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.LateRestabilization.cs @@ -0,0 +1,170 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static ElkRoutedEdge[] ApplyLateBoundarySlotRestabilization( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + double minLineClearance, + IReadOnlyCollection focusEdgeIds) + { + if (focusEdgeIds.Count == 0) + { + return edges; + } + + var candidate = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts( + edges, + nodes, + minLineClearance, + focusEdgeIds); + ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after mixed-face separation"); + candidate = ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts( + candidate, + nodes, + minLineClearance, + focusEdgeIds); + ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after collector separation"); + candidate = ElkEdgePostProcessor.SpreadSourceDepartureJoins( + candidate, + nodes, + minLineClearance, + focusEdgeIds); + ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after source-join spread"); + candidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts( + candidate, + nodes, + minLineClearance, + focusEdgeIds); + ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after shared-lane separation"); + candidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches( + candidate, + nodes, + minLineClearance, + focusEdgeIds); + ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after boundary/target repair"); + candidate = ElkEdgePostProcessor.SpreadTargetApproachJoins( + candidate, + nodes, + minLineClearance, + focusEdgeIds); + ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after target-join spread"); + candidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands( + candidate, + nodes, + minLineClearance, + focusEdgeIds); + ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after feeder-band spread"); + candidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(candidate, nodes, focusEdgeIds); + ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after gateway finalize"); + candidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes); + candidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes); + candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( + candidate, + nodes, + minLineClearance, + focusEdgeIds, + enforceAllNodeEndpoints: true); + ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after first normalization snap"); + candidate = ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts( + candidate, + nodes, + minLineClearance, + focusEdgeIds); + ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after second collector separation"); + candidate = ElkEdgePostProcessor.SpreadSourceDepartureJoins( + candidate, + nodes, + minLineClearance, + focusEdgeIds); + ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after second source-join spread"); + candidate = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts( + candidate, + nodes, + minLineClearance, + focusEdgeIds); + ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after second mixed-face separation"); + candidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts( + candidate, + nodes, + minLineClearance, + focusEdgeIds); + ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after second shared-lane separation"); + candidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches( + candidate, + nodes, + minLineClearance, + focusEdgeIds); + ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after second boundary/target repair"); + candidate = ElkEdgePostProcessor.SpreadTargetApproachJoins( + candidate, + nodes, + minLineClearance, + focusEdgeIds); + ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after second target-join spread"); + candidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands( + candidate, + nodes, + minLineClearance, + focusEdgeIds); + ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after second feeder-band spread"); + candidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(candidate, nodes, focusEdgeIds); + ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after second gateway finalize"); + candidate = ApplyPostSlotDetourClosure(candidate, nodes, minLineClearance, focusEdgeIds); + ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after detour closure"); + candidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes); + candidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes); + candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( + candidate, + nodes, + minLineClearance, + focusEdgeIds, + enforceAllNodeEndpoints: true); + ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization complete"); + + return ChoosePreferredBoundarySlotRepairLayout(edges, candidate, nodes); + } + + private static ElkRoutedEdge[] ApplyLateFocusedBoundarySlotClosure( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance, + IReadOnlyCollection focusEdgeIds, + IReadOnlyCollection? restrictedEdgeIds) + { + var candidate = ChoosePreferredHardRuleLayout( + edges, + ApplyAggressiveSharedLaneClosure(edges, nodes, direction, minLineClearance, focusEdgeIds), + nodes); + candidate = ChoosePreferredBoundarySlotRepairLayout( + candidate, + ElkEdgePostProcessor.SnapBoundarySlotAssignments( + candidate, + nodes, + minLineClearance, + restrictedEdgeIds, + enforceAllNodeEndpoints: true), + nodes); + candidate = ChoosePreferredBoundarySlotRepairLayout( + candidate, + CloseRemainingTerminalViolations(candidate, nodes, direction, minLineClearance, restrictedEdgeIds), + nodes); + candidate = ApplyLateBoundarySlotRestabilization(candidate, nodes, minLineClearance, focusEdgeIds); + candidate = ChoosePreferredBoundarySlotRepairLayout( + candidate, + ElkEdgePostProcessor.SnapBoundarySlotAssignments( + candidate, + nodes, + minLineClearance, + restrictedEdgeIds, + enforceAllNodeEndpoints: true), + nodes); + return candidate; + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Promotion.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Promotion.cs new file mode 100644 index 000000000..aa1f216aa --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Promotion.cs @@ -0,0 +1,165 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static bool TryPromoteFinalBoundarySlotCandidate( + CandidateSolution current, + ElkRoutedEdge[] candidateEdges, + ElkPositionedNode[] nodes, + out CandidateSolution promoted) + { + promoted = current; + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); + var candidateRetryState = BuildRetryState( + candidateScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count + : 0); + if (!IsBetterBoundarySlotRepairCandidate( + candidateScore, + candidateRetryState, + current.Score, + current.RetryState)) + { + return false; + } + + promoted = current with + { + Score = candidateScore, + RetryState = candidateRetryState, + Edges = candidateEdges, + }; + return true; + } + + private static bool HasBlockingBoundarySlotPromotionRegression( + RoutingRetryState candidate, + RoutingRetryState baseline) + { + return candidate.RemainingShortHighways > baseline.RemainingShortHighways + || candidate.RepeatCollectorCorridorViolations > baseline.RepeatCollectorCorridorViolations + || candidate.RepeatCollectorNodeClearanceViolations > baseline.RepeatCollectorNodeClearanceViolations + || candidate.BelowGraphViolations > baseline.BelowGraphViolations + || candidate.UnderNodeViolations > baseline.UnderNodeViolations; + } + + private static bool HasEdgeGeometryChanged( + IReadOnlyList baselineEdges, + IReadOnlyList candidateEdges, + string edgeId) + { + var baseline = baselineEdges.FirstOrDefault(edge => string.Equals(edge.Id, edgeId, StringComparison.Ordinal)); + var candidate = candidateEdges.FirstOrDefault(edge => string.Equals(edge.Id, edgeId, StringComparison.Ordinal)); + if (baseline is null || candidate is null) + { + return false; + } + + var baselinePath = ExtractEdgePath(baseline); + var candidatePath = ExtractEdgePath(candidate); + if (baselinePath.Count != candidatePath.Count) + { + return true; + } + + for (var i = 0; i < baselinePath.Count; i++) + { + if (!ElkEdgeRoutingGeometry.PointsEqual(baselinePath[i], candidatePath[i])) + { + return true; + } + } + + return false; + } + + private static List ExtractEdgePath(ElkRoutedEdge edge) + { + var path = new List(); + foreach (var section in edge.Sections) + { + if (path.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(path[^1], section.StartPoint)) + { + path.Add(section.StartPoint); + } + + foreach (var bendPoint in section.BendPoints) + { + if (path.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(path[^1], bendPoint)) + { + path.Add(bendPoint); + } + } + + if (path.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(path[^1], section.EndPoint)) + { + path.Add(section.EndPoint); + } + } + + return path; + } + + private static bool TryPromoteFinalHardRuleCandidate( + CandidateSolution current, + ElkRoutedEdge[] candidateEdges, + ElkPositionedNode[] nodes, + out CandidateSolution promoted) + { + promoted = current; + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); + var candidateRetryState = BuildRetryState( + candidateScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count + : 0); + if (!IsBetterBoundarySlotRepairCandidate( + candidateScore, + candidateRetryState, + current.Score, + current.RetryState)) + { + return false; + } + + promoted = current with + { + Score = candidateScore, + RetryState = candidateRetryState, + Edges = candidateEdges, + }; + return true; + } + + private static ElkRoutedEdge[] ApplyAggressiveSharedLaneClosure( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance, + IReadOnlyCollection focusEdgeIds) + { + var result = edges; + result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, focusEdgeIds); + result = ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(result, nodes, minLineClearance, focusEdgeIds); + result = ElkRepeatCollectorCorridors.SeparateSharedLanes(result, nodes, focusEdgeIds); + result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, focusEdgeIds); + result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, focusEdgeIds); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, focusEdgeIds, forceOutwardAxisSpacing: true); + result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, focusEdgeIds); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, focusEdgeIds); + result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes, focusEdgeIds); + result = ClampBelowGraphEdges(result, nodes, focusEdgeIds); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, focusEdgeIds); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, focusEdgeIds); + result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, focusEdgeIds); + return result; + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.SharedLane.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.SharedLane.cs new file mode 100644 index 000000000..afc76cf2f --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.SharedLane.cs @@ -0,0 +1,146 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static CandidateSolution ApplyFinalSharedLanePolish( + CandidateSolution solution, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance, + bool preferLeanTerminalCleanup = false) + { + var current = solution; + if (current.RetryState.SharedLaneViolations <= 0) + { + return current; + } + + var maxRounds = preferLeanTerminalCleanup ? 1 : 3; + for (var round = 0; round < maxRounds; round++) + { + var sharedLaneSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountSharedLaneViolations(current.Edges, nodes, sharedLaneSeverity, 10); + if (sharedLaneSeverity.Count == 0) + { + break; + } + + var improved = false; + foreach (var edgeId in sharedLaneSeverity + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Key)) + { + var focusEdgeIds = ExpandSharedLanePolishFocus(current.Edges, nodes, edgeId).ToArray(); + if (focusEdgeIds.Length == 0) + { + continue; + } + + var directCandidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts( + current.Edges, + nodes, + minLineClearance, + focusEdgeIds); + var directClosureCandidate = preferLeanTerminalCleanup + ? ApplyHybridTerminalRuleCleanupRound( + directCandidate, + nodes, + direction, + minLineClearance, + focusEdgeIds) + : CloseRemainingTerminalViolations( + directCandidate, + nodes, + direction, + minLineClearance, + focusEdgeIds); + var closureCandidate = preferLeanTerminalCleanup + ? ApplyHybridTerminalRuleCleanupRound( + current.Edges, + nodes, + direction, + minLineClearance, + focusEdgeIds) + : CloseRemainingTerminalViolations( + current.Edges, + nodes, + direction, + minLineClearance, + focusEdgeIds); + var aggressiveCandidate = ApplyAggressiveSharedLaneClosure( + current.Edges, + nodes, + direction, + minLineClearance, + focusEdgeIds); + var directRetryState = BuildRetryState( + ElkEdgeRoutingScoring.ComputeScore(directCandidate, nodes), + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(directCandidate, nodes).Count + : 0); + var directClosureRetryState = BuildRetryState( + ElkEdgeRoutingScoring.ComputeScore(directClosureCandidate, nodes), + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(directClosureCandidate, nodes).Count + : 0); + var closureRetryState = BuildRetryState( + ElkEdgeRoutingScoring.ComputeScore(closureCandidate, nodes), + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(closureCandidate, nodes).Count + : 0); + var aggressiveRetryState = BuildRetryState( + ElkEdgeRoutingScoring.ComputeScore(aggressiveCandidate, nodes), + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(aggressiveCandidate, nodes).Count + : 0); + ElkLayoutDiagnostics.LogProgress( + $"Winner shared-lane focus edge={edgeId} focus=[{string.Join(", ", focusEdgeIds)}] " + + $"direct={DescribeRetryState(directRetryState)} " + + $"direct-closure={DescribeRetryState(directClosureRetryState)} " + + $"closure={DescribeRetryState(closureRetryState)} " + + $"aggressive={DescribeRetryState(aggressiveRetryState)}"); + var candidateEdges = ChoosePreferredSharedLanePolishLayout(directCandidate, directClosureCandidate, nodes); + candidateEdges = ChoosePreferredSharedLanePolishLayout(candidateEdges, closureCandidate, nodes); + candidateEdges = ChoosePreferredSharedLanePolishLayout(candidateEdges, aggressiveCandidate, nodes); + candidateEdges = ChoosePreferredSharedLanePolishLayout(current.Edges, candidateEdges, nodes); + + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); + var candidateRetryState = BuildRetryState( + candidateScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count + : 0); + + var improvedSharedLanes = candidateRetryState.SharedLaneViolations < current.RetryState.SharedLaneViolations; + if (HasBlockingSharedLanePromotionRegression(candidateRetryState, current.RetryState) + || (!improvedSharedLanes + && !IsBetterCandidate(candidateScore, candidateRetryState, current.Score, current.RetryState))) + { + continue; + } + + current = current with + { + Score = candidateScore, + RetryState = candidateRetryState, + Edges = candidateEdges, + }; + improved = true; + break; + } + + if (!improved) + { + break; + } + } + + return current; + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.TerminalClosures.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.TerminalClosures.cs new file mode 100644 index 000000000..a18dd12d8 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.TerminalClosures.cs @@ -0,0 +1,149 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static bool ShouldPreferFastTerminalOnlyHardRuleClosure(RoutingRetryState retryState) + { + return retryState.RemainingShortHighways == 0 + && retryState.RepeatCollectorCorridorViolations == 0 + && retryState.RepeatCollectorNodeClearanceViolations == 0 + && retryState.TargetApproachBacktrackingViolations == 0 + && retryState.ExcessiveDetourViolations == 0 + && retryState.BoundarySlotViolations == 0 + && retryState.BelowGraphViolations == 0 + && retryState.UnderNodeViolations == 0 + && retryState.LongDiagonalViolations == 0 + && retryState.SharedLaneViolations <= 2 + && (retryState.TargetApproachJoinViolations > 0 + || retryState.EntryAngleViolations > 0 + || retryState.GatewaySourceExitViolations > 0 + || retryState.SharedLaneViolations > 0); + } + + private static ElkRoutedEdge[] BuildFastTerminalOnlyHardRuleCandidate( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance, + IReadOnlyCollection restrictedEdgeIds) + { + var candidate = edges; + candidate = ChoosePreferredHardRuleLayout( + candidate, + ElkEdgePostProcessor.SeparateSharedLaneConflicts( + candidate, + nodes, + minLineClearance, + restrictedEdgeIds), + nodes); + candidate = ChoosePreferredHardRuleLayout( + candidate, + ElkEdgePostProcessor.SpreadSourceDepartureJoins( + candidate, + nodes, + minLineClearance, + restrictedEdgeIds), + nodes); + candidate = ChoosePreferredHardRuleLayout( + candidate, + ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches( + candidate, + nodes, + minLineClearance, + restrictedEdgeIds), + nodes); + candidate = ChoosePreferredHardRuleLayout( + candidate, + ElkEdgePostProcessor.SpreadTargetApproachJoins( + candidate, + nodes, + minLineClearance, + restrictedEdgeIds, + forceOutwardAxisSpacing: true), + nodes); + candidate = ChoosePreferredHardRuleLayout( + candidate, + ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes), + nodes); + candidate = ChoosePreferredHardRuleLayout( + candidate, + ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes), + nodes); + candidate = ChoosePreferredHardRuleLayout( + candidate, + ElkEdgePostProcessor.SeparateSharedLaneConflicts( + candidate, + nodes, + minLineClearance, + restrictedEdgeIds), + nodes); + return candidate; + } + + private static bool ShouldPreferCompactFocusedTerminalClosure( + RoutingRetryState retryState, + int focusEdgeCount) + { + return focusEdgeCount <= 4 + && retryState.BoundarySlotViolations == 0 + && retryState.BelowGraphViolations == 0 + && retryState.UnderNodeViolations == 0 + && retryState.GatewaySourceExitViolations == 0 + && retryState.EntryAngleViolations == 0 + && retryState.TargetApproachBacktrackingViolations == 0 + && retryState.ExcessiveDetourViolations == 0 + && retryState.TargetApproachJoinViolations <= 1 + && retryState.SharedLaneViolations <= 1 + && (retryState.TargetApproachJoinViolations > 0 + || retryState.SharedLaneViolations > 0); + } + + private static ElkRoutedEdge[] ApplyCompactFocusedTerminalClosure( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance, + IReadOnlyCollection focusedEdgeIds) + { + var candidate = edges; + candidate = ApplyGuardedFocusedHardRulePass( + candidate, + nodes, + current => ElkEdgePostProcessor.SeparateSharedLaneConflicts(current, nodes, minLineClearance, focusedEdgeIds)); + candidate = ApplyGuardedFocusedHardRulePass( + candidate, + nodes, + current => ElkEdgePostProcessor.SpreadSourceDepartureJoins(current, nodes, minLineClearance, focusedEdgeIds)); + candidate = ApplyGuardedFocusedHardRulePass( + candidate, + nodes, + current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focusedEdgeIds)); + candidate = ApplyGuardedFocusedHardRulePass( + candidate, + nodes, + current => ElkEdgePostProcessor.SpreadTargetApproachJoins( + current, + nodes, + minLineClearance, + focusedEdgeIds, + forceOutwardAxisSpacing: true)); + candidate = ApplyGuardedFocusedHardRulePass( + candidate, + nodes, + current => ElkEdgePostProcessor.SeparateSharedLaneConflicts(current, nodes, minLineClearance, focusedEdgeIds)); + candidate = ApplyGuardedFocusedHardRulePass( + candidate, + nodes, + current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes)); + candidate = ApplyGuardedFocusedHardRulePass( + candidate, + nodes, + current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes)); + return candidate; + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.UnderNode.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.UnderNode.cs new file mode 100644 index 000000000..cb94932a6 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.UnderNode.cs @@ -0,0 +1,154 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRouterIterative +{ + private static CandidateSolution ApplyFinalDirectUnderNodePolish( + CandidateSolution solution, + ElkPositionedNode[] nodes, + double minLineClearance) + { + var current = solution; + var underNodeSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountUnderNodeViolations(current.Edges, nodes, underNodeSeverity, 10); + if (underNodeSeverity.Count == 0) + { + return current; + } + + foreach (var edgeId in underNodeSeverity + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Key)) + { + var candidateEdges = ElkEdgePostProcessor.ElevateUnderNodeViolations( + current.Edges, + nodes, + minLineClearance, + [edgeId]); + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); + var candidateRetryState = BuildRetryState( + candidateScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count + : 0); + if (!IsBetterCandidate(candidateScore, candidateRetryState, current.Score, current.RetryState)) + { + continue; + } + + current = current with + { + Score = candidateScore, + RetryState = candidateRetryState, + Edges = candidateEdges, + }; + } + + return current; + } + + private static CandidateSolution ApplyFinalProtectedLocalBundlePolish( + CandidateSolution solution, + ElkPositionedNode[] nodes, + double minLineClearance) + { + var current = solution; + var underNodeSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountUnderNodeViolations(current.Edges, nodes, underNodeSeverity, 10); + + var focusSeverity = new Dictionary(underNodeSeverity, StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(current.Edges, nodes, focusSeverity, 10); + if (focusSeverity.Count == 0) + { + return current; + } + + foreach (var edgeId in focusSeverity + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Key)) + { + var focusEdgeIds = ExpandWinningSolutionFocus(current.Edges, [edgeId]).ToArray(); + if (focusEdgeIds.Length == 0) + { + continue; + } + + var focusedRepairSeeds = focusEdgeIds + .Where(underNodeSeverity.ContainsKey) + .OrderBy(id => id, StringComparer.Ordinal) + .ToArray(); + if (focusedRepairSeeds.Length == 0) + { + focusedRepairSeeds = [edgeId]; + } + + var candidateEdges = ElkEdgePostProcessor.ElevateUnderNodeViolations( + current.Edges, + nodes, + minLineClearance, + focusedRepairSeeds); + candidateEdges = ElkEdgePostProcessor.SpreadTargetApproachJoins( + candidateEdges, + nodes, + minLineClearance, + focusEdgeIds, + forceOutwardAxisSpacing: true); + candidateEdges = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands( + candidateEdges, + nodes, + minLineClearance, + focusEdgeIds); + candidateEdges = ElkEdgePostProcessor.SeparateSharedLaneConflicts( + candidateEdges, + nodes, + minLineClearance, + focusEdgeIds); + candidateEdges = ElkEdgePostProcessor.ElevateUnderNodeViolations( + candidateEdges, + nodes, + minLineClearance, + focusEdgeIds); + candidateEdges = ElkEdgePostProcessor.SpreadTargetApproachJoins( + candidateEdges, + nodes, + minLineClearance, + focusEdgeIds, + forceOutwardAxisSpacing: true); + candidateEdges = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands( + candidateEdges, + nodes, + minLineClearance, + focusEdgeIds); + candidateEdges = ChoosePreferredHardRuleLayout(current.Edges, candidateEdges, nodes); + + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); + var candidateRetryState = BuildRetryState( + candidateScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count + : 0); + if (!IsBetterCandidate(candidateScore, candidateRetryState, current.Score, current.RetryState)) + { + continue; + } + + current = current with + { + Score = candidateScore, + RetryState = candidateRetryState, + Edges = candidateEdges, + }; + + underNodeSeverity.Clear(); + ElkEdgeRoutingScoring.CountUnderNodeViolations(current.Edges, nodes, underNodeSeverity, 10); + } + + return current; + } + +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.cs index 832671282..05988a5a3 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.cs @@ -116,1550 +116,4 @@ internal static partial class ElkEdgeRouterIterative return current; } - private static IEnumerable ExpandWinningSolutionFocus( - IReadOnlyCollection edges, - IEnumerable focusEdgeIds) - { - var edgesById = edges.ToDictionary(edge => edge.Id, StringComparer.Ordinal); - var expanded = new HashSet(StringComparer.Ordinal); - - foreach (var edgeId in focusEdgeIds) - { - if (!expanded.Add(edgeId) || !edgesById.TryGetValue(edgeId, out var edge)) - { - continue; - } - - foreach (var peer in edges) - { - if (string.Equals(peer.Id, edge.Id, StringComparison.Ordinal)) - { - continue; - } - - if (string.Equals(peer.SourceNodeId, edge.SourceNodeId, StringComparison.Ordinal) - || string.Equals(peer.TargetNodeId, edge.TargetNodeId, StringComparison.Ordinal) - || string.Equals(peer.SourceNodeId, edge.TargetNodeId, StringComparison.Ordinal) - || string.Equals(peer.TargetNodeId, edge.SourceNodeId, StringComparison.Ordinal)) - { - expanded.Add(peer.Id); - } - } - } - - return expanded.OrderBy(edgeId => edgeId, StringComparer.Ordinal); - } - - private static IEnumerable ExpandSharedLanePolishFocus( - IReadOnlyCollection edges, - IReadOnlyCollection nodes, - string focusEdgeId) - { - var edgesById = edges.ToDictionary(edge => edge.Id, StringComparer.Ordinal); - if (!edgesById.TryGetValue(focusEdgeId, out var focusEdge)) - { - return []; - } - - var focusedEdgeIds = new HashSet(StringComparer.Ordinal) - { - focusEdgeId, - }; - var sharedNodeIds = new HashSet(StringComparer.Ordinal); - - foreach (var (leftEdgeId, rightEdgeId) in ElkEdgeRoutingScoring.DetectSharedLaneConflicts(edges, nodes)) - { - string partnerEdgeId; - if (string.Equals(leftEdgeId, focusEdgeId, StringComparison.Ordinal)) - { - partnerEdgeId = rightEdgeId; - } - else if (string.Equals(rightEdgeId, focusEdgeId, StringComparison.Ordinal)) - { - partnerEdgeId = leftEdgeId; - } - else - { - continue; - } - - if (!edgesById.TryGetValue(partnerEdgeId, out var partnerEdge)) - { - continue; - } - - focusedEdgeIds.Add(partnerEdgeId); - CollectSharedConflictNodeIds(focusEdge, partnerEdge, sharedNodeIds); - } - - if (sharedNodeIds.Count == 0) - { - return ExpandWinningSolutionFocus(edges, [focusEdgeId]); - } - - foreach (var edge in edges) - { - if (focusedEdgeIds.Contains(edge.Id)) - { - continue; - } - - if ((edge.SourceNodeId is not null && sharedNodeIds.Contains(edge.SourceNodeId)) - || (edge.TargetNodeId is not null && sharedNodeIds.Contains(edge.TargetNodeId))) - { - focusedEdgeIds.Add(edge.Id); - } - } - - return focusedEdgeIds.OrderBy(edgeId => edgeId, StringComparer.Ordinal); - } - - private static void CollectSharedConflictNodeIds( - ElkRoutedEdge edge, - ElkRoutedEdge partner, - ISet sharedNodeIds) - { - if (edge.SourceNodeId is not null - && (string.Equals(edge.SourceNodeId, partner.SourceNodeId, StringComparison.Ordinal) - || string.Equals(edge.SourceNodeId, partner.TargetNodeId, StringComparison.Ordinal))) - { - sharedNodeIds.Add(edge.SourceNodeId); - } - - if (edge.TargetNodeId is not null - && (string.Equals(edge.TargetNodeId, partner.SourceNodeId, StringComparison.Ordinal) - || string.Equals(edge.TargetNodeId, partner.TargetNodeId, StringComparison.Ordinal))) - { - sharedNodeIds.Add(edge.TargetNodeId); - } - } - - private static CandidateSolution ApplyFinalDirectUnderNodePolish( - CandidateSolution solution, - ElkPositionedNode[] nodes, - double minLineClearance) - { - var current = solution; - var underNodeSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountUnderNodeViolations(current.Edges, nodes, underNodeSeverity, 10); - if (underNodeSeverity.Count == 0) - { - return current; - } - - foreach (var edgeId in underNodeSeverity - .OrderByDescending(pair => pair.Value) - .ThenBy(pair => pair.Key, StringComparer.Ordinal) - .Select(pair => pair.Key)) - { - var candidateEdges = ElkEdgePostProcessor.ElevateUnderNodeViolations( - current.Edges, - nodes, - minLineClearance, - [edgeId]); - var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); - var candidateRetryState = BuildRetryState( - candidateScore, - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count - : 0); - if (!IsBetterCandidate(candidateScore, candidateRetryState, current.Score, current.RetryState)) - { - continue; - } - - current = current with - { - Score = candidateScore, - RetryState = candidateRetryState, - Edges = candidateEdges, - }; - } - - return current; - } - - private static CandidateSolution ApplyFinalProtectedLocalBundlePolish( - CandidateSolution solution, - ElkPositionedNode[] nodes, - double minLineClearance) - { - var current = solution; - var underNodeSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountUnderNodeViolations(current.Edges, nodes, underNodeSeverity, 10); - - var focusSeverity = new Dictionary(underNodeSeverity, StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(current.Edges, nodes, focusSeverity, 10); - if (focusSeverity.Count == 0) - { - return current; - } - - foreach (var edgeId in focusSeverity - .OrderByDescending(pair => pair.Value) - .ThenBy(pair => pair.Key, StringComparer.Ordinal) - .Select(pair => pair.Key)) - { - var focusEdgeIds = ExpandWinningSolutionFocus(current.Edges, [edgeId]).ToArray(); - if (focusEdgeIds.Length == 0) - { - continue; - } - - var focusedRepairSeeds = focusEdgeIds - .Where(underNodeSeverity.ContainsKey) - .OrderBy(id => id, StringComparer.Ordinal) - .ToArray(); - if (focusedRepairSeeds.Length == 0) - { - focusedRepairSeeds = [edgeId]; - } - - var candidateEdges = ElkEdgePostProcessor.ElevateUnderNodeViolations( - current.Edges, - nodes, - minLineClearance, - focusedRepairSeeds); - candidateEdges = ElkEdgePostProcessor.SpreadTargetApproachJoins( - candidateEdges, - nodes, - minLineClearance, - focusEdgeIds, - forceOutwardAxisSpacing: true); - candidateEdges = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands( - candidateEdges, - nodes, - minLineClearance, - focusEdgeIds); - candidateEdges = ElkEdgePostProcessor.SeparateSharedLaneConflicts( - candidateEdges, - nodes, - minLineClearance, - focusEdgeIds); - candidateEdges = ElkEdgePostProcessor.ElevateUnderNodeViolations( - candidateEdges, - nodes, - minLineClearance, - focusEdgeIds); - candidateEdges = ElkEdgePostProcessor.SpreadTargetApproachJoins( - candidateEdges, - nodes, - minLineClearance, - focusEdgeIds, - forceOutwardAxisSpacing: true); - candidateEdges = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands( - candidateEdges, - nodes, - minLineClearance, - focusEdgeIds); - candidateEdges = ChoosePreferredHardRuleLayout(current.Edges, candidateEdges, nodes); - - var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); - var candidateRetryState = BuildRetryState( - candidateScore, - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count - : 0); - if (!IsBetterCandidate(candidateScore, candidateRetryState, current.Score, current.RetryState)) - { - continue; - } - - current = current with - { - Score = candidateScore, - RetryState = candidateRetryState, - Edges = candidateEdges, - }; - - underNodeSeverity.Clear(); - ElkEdgeRoutingScoring.CountUnderNodeViolations(current.Edges, nodes, underNodeSeverity, 10); - } - - return current; - } - - private static CandidateSolution ApplyFinalSharedLanePolish( - CandidateSolution solution, - ElkPositionedNode[] nodes, - ElkLayoutDirection direction, - double minLineClearance) - { - var current = solution; - if (current.RetryState.SharedLaneViolations <= 0) - { - return current; - } - - for (var round = 0; round < 3; round++) - { - var sharedLaneSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountSharedLaneViolations(current.Edges, nodes, sharedLaneSeverity, 10); - if (sharedLaneSeverity.Count == 0) - { - break; - } - - var improved = false; - foreach (var edgeId in sharedLaneSeverity - .OrderByDescending(pair => pair.Value) - .ThenBy(pair => pair.Key, StringComparer.Ordinal) - .Select(pair => pair.Key)) - { - var focusEdgeIds = ExpandSharedLanePolishFocus(current.Edges, nodes, edgeId).ToArray(); - if (focusEdgeIds.Length == 0) - { - continue; - } - - var directCandidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts( - current.Edges, - nodes, - minLineClearance, - focusEdgeIds); - var directClosureCandidate = CloseRemainingTerminalViolations( - directCandidate, - nodes, - direction, - minLineClearance, - focusEdgeIds); - var closureCandidate = CloseRemainingTerminalViolations( - current.Edges, - nodes, - direction, - minLineClearance, - focusEdgeIds); - var aggressiveCandidate = ApplyAggressiveSharedLaneClosure( - current.Edges, - nodes, - direction, - minLineClearance, - focusEdgeIds); - var directRetryState = BuildRetryState( - ElkEdgeRoutingScoring.ComputeScore(directCandidate, nodes), - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(directCandidate, nodes).Count - : 0); - var directClosureRetryState = BuildRetryState( - ElkEdgeRoutingScoring.ComputeScore(directClosureCandidate, nodes), - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(directClosureCandidate, nodes).Count - : 0); - var closureRetryState = BuildRetryState( - ElkEdgeRoutingScoring.ComputeScore(closureCandidate, nodes), - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(closureCandidate, nodes).Count - : 0); - var aggressiveRetryState = BuildRetryState( - ElkEdgeRoutingScoring.ComputeScore(aggressiveCandidate, nodes), - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(aggressiveCandidate, nodes).Count - : 0); - ElkLayoutDiagnostics.LogProgress( - $"Winner shared-lane focus edge={edgeId} focus=[{string.Join(", ", focusEdgeIds)}] " + - $"direct={DescribeRetryState(directRetryState)} " + - $"direct-closure={DescribeRetryState(directClosureRetryState)} " + - $"closure={DescribeRetryState(closureRetryState)} " + - $"aggressive={DescribeRetryState(aggressiveRetryState)}"); - var candidateEdges = ChoosePreferredSharedLanePolishLayout(directCandidate, directClosureCandidate, nodes); - candidateEdges = ChoosePreferredSharedLanePolishLayout(candidateEdges, closureCandidate, nodes); - candidateEdges = ChoosePreferredSharedLanePolishLayout(candidateEdges, aggressiveCandidate, nodes); - candidateEdges = ChoosePreferredSharedLanePolishLayout(current.Edges, candidateEdges, nodes); - - var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); - var candidateRetryState = BuildRetryState( - candidateScore, - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count - : 0); - - var improvedSharedLanes = candidateRetryState.SharedLaneViolations < current.RetryState.SharedLaneViolations; - if (HasBlockingSharedLanePromotionRegression(candidateRetryState, current.RetryState) - || (!improvedSharedLanes - && !IsBetterCandidate(candidateScore, candidateRetryState, current.Score, current.RetryState))) - { - continue; - } - - current = current with - { - Score = candidateScore, - RetryState = candidateRetryState, - Edges = candidateEdges, - }; - improved = true; - break; - } - - if (!improved) - { - break; - } - } - - return current; - } - - private static CandidateSolution ApplyFinalBoundarySlotPolish( - CandidateSolution solution, - ElkPositionedNode[] nodes, - ElkLayoutDirection direction, - double minLineClearance) - { - var current = solution; - - for (var round = 0; round < 3; round++) - { - var boundarySlotSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountBoundarySlotViolations(current.Edges, nodes, boundarySlotSeverity, 10); - if (boundarySlotSeverity.Count == 0) - { - break; - } - - var batchedRootEdgeIds = boundarySlotSeverity - .OrderByDescending(pair => pair.Value) - .ThenBy(pair => pair.Key, StringComparer.Ordinal) - .Take(MaxWinnerPolishBatchedRootEdges) - .Select(pair => pair.Key) - .ToArray(); - var batchedFocusEdgeIds = ExpandWinningSolutionFocus(current.Edges, batchedRootEdgeIds).ToArray(); - if (batchedFocusEdgeIds.Length > 0) - { - var batchedCandidateEdges = BuildFinalBoundarySlotCandidate( - current.Edges, - nodes, - direction, - minLineClearance, - batchedFocusEdgeIds, - allowLateRestabilizedClosure: false); - if (TryPromoteFinalBoundarySlotCandidate(current, batchedCandidateEdges, nodes, out var batchedPromoted)) - { - current = batchedPromoted; - continue; - } - } - - var improved = false; - foreach (var edgeId in boundarySlotSeverity - .OrderByDescending(pair => pair.Value) - .ThenBy(pair => pair.Key, StringComparer.Ordinal) - .Select(pair => pair.Key)) - { - var focusEdgeIds = ExpandWinningSolutionFocus(current.Edges, [edgeId]).ToArray(); - if (focusEdgeIds.Length == 0) - { - continue; - } - - var candidateEdges = BuildFinalBoundarySlotCandidate( - current.Edges, - nodes, - direction, - minLineClearance, - focusEdgeIds, - allowLateRestabilizedClosure: false); - - if (!TryPromoteFinalBoundarySlotCandidate(current, candidateEdges, nodes, out var promoted)) - { - continue; - } - - current = promoted; - improved = true; - break; - } - - if (!improved) - { - break; - } - } - - return current; - } - - private static CandidateSolution ApplyFinalPostSlotHardRulePolish( - CandidateSolution solution, - ElkPositionedNode[] nodes, - ElkLayoutDirection direction, - double minLineClearance) - { - var current = solution; - - for (var round = 0; round < 3; round++) - { - var severityByEdgeId = new Dictionary(StringComparer.Ordinal); - var pressure = - ElkEdgeRoutingScoring.CountBadBoundaryAngles(current.Edges, nodes, severityByEdgeId, 10) - + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(current.Edges, nodes, severityByEdgeId, 10) - + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(current.Edges, nodes, severityByEdgeId, 10) - + ElkEdgeRoutingScoring.CountSharedLaneViolations(current.Edges, nodes, severityByEdgeId, 10) - + ElkEdgeRoutingScoring.CountBoundarySlotViolations(current.Edges, nodes, severityByEdgeId, 10) - + ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(current.Edges, nodes, severityByEdgeId, 10) - + ElkEdgeRoutingScoring.CountExcessiveDetourViolations(current.Edges, nodes, severityByEdgeId, 10) - + ElkEdgeRoutingScoring.CountBelowGraphViolations(current.Edges, nodes, severityByEdgeId, 10) - + ElkEdgeRoutingScoring.CountUnderNodeViolations(current.Edges, nodes, severityByEdgeId, 10); - ElkLayoutDiagnostics.LogProgress( - $"Winner post-slot hard-rule round {round + 1} start: pressure={pressure} retry={DescribeRetryState(current.RetryState)} focus={severityByEdgeId.Count}"); - if (pressure == 0) - { - break; - } - - var preferFastTerminalOnly = ShouldPreferFastTerminalOnlyHardRuleClosure(current.RetryState); - - var batchedRootEdgeIds = severityByEdgeId - .OrderByDescending(pair => pair.Value) - .ThenBy(pair => pair.Key, StringComparer.Ordinal) - .Take(MaxWinnerPolishBatchedRootEdges) - .Select(pair => pair.Key) - .ToArray(); - var batchedFocusEdgeIds = preferFastTerminalOnly - ? batchedRootEdgeIds - : ExpandWinningSolutionFocus(current.Edges, batchedRootEdgeIds).ToArray(); - if (batchedFocusEdgeIds.Length > 0) - { - if (preferFastTerminalOnly) - { - ElkLayoutDiagnostics.LogProgress( - $"Winner post-slot hard-rule round {round + 1} fast-terminal focus=[{string.Join(", ", batchedFocusEdgeIds)}]"); - var quickBatchedCandidateEdges = BuildFastTerminalOnlyHardRuleCandidate( - current.Edges, - nodes, - direction, - minLineClearance, - batchedFocusEdgeIds); - if (TryPromoteFinalHardRuleCandidate(current, quickBatchedCandidateEdges, nodes, out var quickBatchedPromoted)) - { - current = quickBatchedPromoted; - continue; - } - - var focusedTerminalClosureEdges = CloseRemainingTerminalViolations( - current.Edges, - nodes, - direction, - minLineClearance, - batchedFocusEdgeIds); - if (TryPromoteFinalHardRuleCandidate(current, focusedTerminalClosureEdges, nodes, out var focusedTerminalPromoted)) - { - current = focusedTerminalPromoted; - continue; - } - - var exactRestabilizedEdges = BuildFinalRestabilizedCandidate( - current.Edges, - nodes, - direction, - minLineClearance, - batchedFocusEdgeIds); - if (TryPromoteFinalHardRuleCandidate(current, exactRestabilizedEdges, nodes, out var exactRestabilizedPromoted)) - { - current = exactRestabilizedPromoted; - continue; - } - - var quickBatchedScore = ElkEdgeRoutingScoring.ComputeScore(quickBatchedCandidateEdges, nodes); - var quickBatchedRetryState = BuildRetryState( - quickBatchedScore, - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(quickBatchedCandidateEdges, nodes).Count - : 0); - var changedFocusEdgeIds = batchedFocusEdgeIds - .Where(edgeId => HasEdgeGeometryChanged(current.Edges, quickBatchedCandidateEdges, edgeId)) - .ToArray(); - ElkLayoutDiagnostics.LogProgress( - $"Winner post-slot hard-rule round {round + 1} fast-terminal made no promotion: candidate={DescribeRetryState(quickBatchedRetryState)} changed=[{string.Join(", ", changedFocusEdgeIds)}]"); - } - else - { - ElkLayoutDiagnostics.LogProgress( - $"Winner post-slot hard-rule round {round + 1} full-restabilize focus=[{string.Join(", ", batchedFocusEdgeIds)}]"); - var batchedCandidateEdges = BuildFinalRestabilizedCandidate( - current.Edges, - nodes, - direction, - minLineClearance, - batchedFocusEdgeIds); - if (TryPromoteFinalHardRuleCandidate(current, batchedCandidateEdges, nodes, out var batchedPromoted)) - { - current = batchedPromoted; - continue; - } - } - } - - var improved = false; - foreach (var edgeId in severityByEdgeId - .OrderByDescending(pair => pair.Value) - .ThenBy(pair => pair.Key, StringComparer.Ordinal) - .Select(pair => pair.Key)) - { - var focusEdgeIds = preferFastTerminalOnly - ? [edgeId] - : ExpandWinningSolutionFocus(current.Edges, [edgeId]).ToArray(); - if (focusEdgeIds.Length == 0) - { - continue; - } - - if (preferFastTerminalOnly) - { - var quickCandidateEdges = BuildFastTerminalOnlyHardRuleCandidate( - current.Edges, - nodes, - direction, - minLineClearance, - focusEdgeIds); - if (TryPromoteFinalHardRuleCandidate(current, quickCandidateEdges, nodes, out var quickPromoted)) - { - current = quickPromoted; - improved = true; - break; - } - - continue; - } - - var candidateEdges = BuildFinalRestabilizedCandidate( - current.Edges, - nodes, - direction, - minLineClearance, - focusEdgeIds); - if (!TryPromoteFinalHardRuleCandidate(current, candidateEdges, nodes, out var promoted)) - { - continue; - } - - current = promoted; - improved = true; - break; - } - - if (!improved) - { - break; - } - } - - return current; - } - - private static bool ShouldPreferFastTerminalOnlyHardRuleClosure(RoutingRetryState retryState) - { - return retryState.RemainingShortHighways == 0 - && retryState.RepeatCollectorCorridorViolations == 0 - && retryState.RepeatCollectorNodeClearanceViolations == 0 - && retryState.TargetApproachBacktrackingViolations == 0 - && retryState.ExcessiveDetourViolations == 0 - && retryState.BoundarySlotViolations == 0 - && retryState.BelowGraphViolations == 0 - && retryState.UnderNodeViolations == 0 - && retryState.LongDiagonalViolations == 0 - && retryState.SharedLaneViolations <= 2 - && (retryState.TargetApproachJoinViolations > 0 - || retryState.EntryAngleViolations > 0 - || retryState.GatewaySourceExitViolations > 0 - || retryState.SharedLaneViolations > 0); - } - - private static ElkRoutedEdge[] BuildFastTerminalOnlyHardRuleCandidate( - ElkRoutedEdge[] edges, - ElkPositionedNode[] nodes, - ElkLayoutDirection direction, - double minLineClearance, - IReadOnlyCollection restrictedEdgeIds) - { - var candidate = edges; - candidate = ChoosePreferredHardRuleLayout( - candidate, - ElkEdgePostProcessor.SeparateSharedLaneConflicts( - candidate, - nodes, - minLineClearance, - restrictedEdgeIds), - nodes); - candidate = ChoosePreferredHardRuleLayout( - candidate, - ElkEdgePostProcessor.SpreadSourceDepartureJoins( - candidate, - nodes, - minLineClearance, - restrictedEdgeIds), - nodes); - candidate = ChoosePreferredHardRuleLayout( - candidate, - ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches( - candidate, - nodes, - minLineClearance, - restrictedEdgeIds), - nodes); - candidate = ChoosePreferredHardRuleLayout( - candidate, - ElkEdgePostProcessor.SpreadTargetApproachJoins( - candidate, - nodes, - minLineClearance, - restrictedEdgeIds, - forceOutwardAxisSpacing: true), - nodes); - candidate = ChoosePreferredHardRuleLayout( - candidate, - ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes), - nodes); - candidate = ChoosePreferredHardRuleLayout( - candidate, - ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes), - nodes); - candidate = ChoosePreferredHardRuleLayout( - candidate, - ElkEdgePostProcessor.SeparateSharedLaneConflicts( - candidate, - nodes, - minLineClearance, - restrictedEdgeIds), - nodes); - return candidate; - } - - private static bool ShouldPreferCompactFocusedTerminalClosure( - RoutingRetryState retryState, - int focusEdgeCount) - { - return focusEdgeCount <= 4 - && retryState.BoundarySlotViolations == 0 - && retryState.BelowGraphViolations == 0 - && retryState.UnderNodeViolations == 0 - && retryState.GatewaySourceExitViolations == 0 - && retryState.EntryAngleViolations == 0 - && retryState.TargetApproachBacktrackingViolations == 0 - && retryState.ExcessiveDetourViolations == 0 - && retryState.TargetApproachJoinViolations <= 1 - && retryState.SharedLaneViolations <= 1 - && (retryState.TargetApproachJoinViolations > 0 - || retryState.SharedLaneViolations > 0); - } - - private static ElkRoutedEdge[] ApplyCompactFocusedTerminalClosure( - ElkRoutedEdge[] edges, - ElkPositionedNode[] nodes, - ElkLayoutDirection direction, - double minLineClearance, - IReadOnlyCollection focusedEdgeIds) - { - var candidate = edges; - candidate = ApplyGuardedFocusedHardRulePass( - candidate, - nodes, - current => ElkEdgePostProcessor.SeparateSharedLaneConflicts(current, nodes, minLineClearance, focusedEdgeIds)); - candidate = ApplyGuardedFocusedHardRulePass( - candidate, - nodes, - current => ElkEdgePostProcessor.SpreadSourceDepartureJoins(current, nodes, minLineClearance, focusedEdgeIds)); - candidate = ApplyGuardedFocusedHardRulePass( - candidate, - nodes, - current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focusedEdgeIds)); - candidate = ApplyGuardedFocusedHardRulePass( - candidate, - nodes, - current => ElkEdgePostProcessor.SpreadTargetApproachJoins( - current, - nodes, - minLineClearance, - focusedEdgeIds, - forceOutwardAxisSpacing: true)); - candidate = ApplyGuardedFocusedHardRulePass( - candidate, - nodes, - current => ElkEdgePostProcessor.SeparateSharedLaneConflicts(current, nodes, minLineClearance, focusedEdgeIds)); - candidate = ApplyGuardedFocusedHardRulePass( - candidate, - nodes, - current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes)); - candidate = ApplyGuardedFocusedHardRulePass( - candidate, - nodes, - current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes)); - return candidate; - } - - private static CandidateSolution ApplyWinnerDetourPolish( - CandidateSolution solution, - ElkPositionedNode[] nodes, - double minLineClearance) - { - var focusSeverity = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountExcessiveDetourViolations(solution.Edges, nodes, focusSeverity, 10); - ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(solution.Edges, nodes, focusSeverity, 10); - - if (focusSeverity.Count > 0) - { - var batchedRootEdgeIds = focusSeverity - .OrderByDescending(pair => pair.Value) - .ThenBy(pair => pair.Key, StringComparer.Ordinal) - .Take(Math.Min(focusSeverity.Count, MaxWinnerPolishBatchedRootEdges + 2)) - .Select(pair => pair.Key) - .ToArray(); - var batchedFocusEdgeIds = ExpandWinningSolutionFocus(solution.Edges, batchedRootEdgeIds).ToArray(); - if (batchedFocusEdgeIds.Length > 0) - { - var batchedCandidateEdges = ComposeTransactionalFinalDetourCandidate( - solution.Edges, - nodes, - minLineClearance, - batchedFocusEdgeIds); - batchedCandidateEdges = ChoosePreferredHardRuleLayout(solution.Edges, batchedCandidateEdges, nodes); - if (TryPromoteFinalDetourCandidate( - solution.Edges, - batchedCandidateEdges, - nodes, - solution.Score, - solution.RetryState, - out var batchedPromotedEdges)) - { - var batchedPromotedScore = ElkEdgeRoutingScoring.ComputeScore(batchedPromotedEdges, nodes); - var batchedPromotedRetryState = BuildRetryState( - batchedPromotedScore, - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(batchedPromotedEdges, nodes).Count - : 0); - - solution = new CandidateSolution( - batchedPromotedScore, - batchedPromotedRetryState, - batchedPromotedEdges, - solution.StrategyIndex); - } - } - } - - var candidateEdges = ApplyFinalDetourPolish(solution.Edges, nodes, minLineClearance, restrictedEdgeIds: null); - if (ReferenceEquals(candidateEdges, solution.Edges)) - { - return solution; - } - - var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); - var candidateRetryState = BuildRetryState( - candidateScore, - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count - : 0); - - return new CandidateSolution(candidateScore, candidateRetryState, candidateEdges, solution.StrategyIndex); - } - - internal static ElkRoutedEdge[] BuildFinalBoundarySlotCandidate( - ElkRoutedEdge[] edges, - ElkPositionedNode[] nodes, - ElkLayoutDirection direction, - double minLineClearance, - IReadOnlyCollection? restrictedEdgeIds = null, - bool allowLateRestabilizedClosure = true) - { - var focusEdgeIds = restrictedEdgeIds?.Count > 0 - ? restrictedEdgeIds - : edges - .Select(edge => edge.Id) - .OrderBy(edgeId => edgeId, StringComparer.Ordinal) - .ToArray(); - ElkLayoutDiagnostics.LogProgress( - $"Boundary-slot candidate start: focus={focusEdgeIds.Count} allowLateRestabilizedClosure={allowLateRestabilizedClosure}"); - - var best = edges; - var candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( - edges, - nodes, - minLineClearance, - restrictedEdgeIds, - enforceAllNodeEndpoints: true); - best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); - ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after initial snap"); - candidate = CloseRemainingTerminalViolations(candidate, nodes, direction, minLineClearance, restrictedEdgeIds); - best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); - ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after terminal closure"); - if (focusEdgeIds.Count > 0) - { - candidate = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts( - candidate, - nodes, - minLineClearance, - focusEdgeIds); - candidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts( - candidate, - nodes, - minLineClearance, - focusEdgeIds); - best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); - ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after face/shared-lane separation"); - } - - candidate = ClampBelowGraphEdges(candidate, nodes, restrictedEdgeIds); - candidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes); - candidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes); - candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( - candidate, - nodes, - minLineClearance, - restrictedEdgeIds, - enforceAllNodeEndpoints: true); - best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); - ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after normalization snap"); - candidate = ApplyPostSlotDetourClosure(candidate, nodes, minLineClearance, restrictedEdgeIds); - best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); - ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after detour closure"); - candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( - candidate, - nodes, - minLineClearance, - restrictedEdgeIds, - enforceAllNodeEndpoints: true); - best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); - ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after detour snap"); - if (restrictedEdgeIds?.Count > 0) - { - candidate = ChoosePreferredBoundarySlotRepairLayout( - candidate, - CloseRemainingTerminalViolations(candidate, nodes, direction, minLineClearance, restrictedEdgeIds), - nodes); - best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); - candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( - candidate, - nodes, - minLineClearance, - restrictedEdgeIds, - enforceAllNodeEndpoints: true); - best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); - ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after restricted terminal recheck"); - } - - candidate = ApplyLateBoundarySlotRestabilization( - candidate, - nodes, - minLineClearance, - focusEdgeIds); - best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); - ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after late restabilization"); - candidate = ChoosePreferredBoundarySlotRepairLayout( - candidate, - ElkEdgePostProcessor.SnapBoundarySlotAssignments( - candidate, - nodes, - minLineClearance, - restrictedEdgeIds, - enforceAllNodeEndpoints: true), - nodes); - best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); - ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after late restabilization snap"); - if (focusEdgeIds.Count > 0) - { - var lateHardRuleCandidate = ApplyAggressiveSharedLaneClosure( - candidate, - nodes, - direction, - minLineClearance, - focusEdgeIds); - lateHardRuleCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( - lateHardRuleCandidate, - nodes, - minLineClearance, - restrictedEdgeIds, - enforceAllNodeEndpoints: true); - candidate = ChoosePreferredHardRuleLayout(candidate, lateHardRuleCandidate, nodes); - candidate = ChoosePreferredBoundarySlotRepairLayout( - candidate, - ElkEdgePostProcessor.SnapBoundarySlotAssignments( - candidate, - nodes, - minLineClearance, - restrictedEdgeIds, - enforceAllNodeEndpoints: true), - nodes); - best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); - ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after late hard-rule closure"); - } - if (focusEdgeIds.Count > 0 - && (ElkEdgeRoutingScoring.CountBoundarySlotViolations(candidate, nodes) > 0 - || ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(candidate, nodes) > 0)) - { - var lateClosureCandidate = ApplyLateFocusedBoundarySlotClosure( - candidate, - nodes, - direction, - minLineClearance, - focusEdgeIds, - restrictedEdgeIds); - candidate = ChoosePreferredBoundarySlotRepairLayout(candidate, lateClosureCandidate, nodes); - candidate = ChoosePreferredBoundarySlotRepairLayout( - candidate, - ElkEdgePostProcessor.SnapBoundarySlotAssignments( - candidate, - nodes, - minLineClearance, - restrictedEdgeIds, - enforceAllNodeEndpoints: true), - nodes); - best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); - ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after focused late closure"); - } - - if (allowLateRestabilizedClosure - && focusEdgeIds.Count > 0 - && focusEdgeIds.Count <= MaxLateRestabilizedClosureFocusEdges) - { - var stagedLateRestabilizedCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( - edges, - nodes, - minLineClearance, - restrictedEdgeIds, - enforceAllNodeEndpoints: true); - stagedLateRestabilizedCandidate = BuildFinalRestabilizedCandidate( - stagedLateRestabilizedCandidate, - nodes, - direction, - minLineClearance, - restrictedEdgeIds); - stagedLateRestabilizedCandidate = ChoosePreferredBoundarySlotRepairLayout( - stagedLateRestabilizedCandidate, - ElkEdgePostProcessor.SnapBoundarySlotAssignments( - stagedLateRestabilizedCandidate, - nodes, - minLineClearance, - restrictedEdgeIds, - enforceAllNodeEndpoints: true), - nodes); - candidate = ChoosePreferredBoundarySlotRepairLayout(candidate, stagedLateRestabilizedCandidate, nodes); - best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); - ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after staged late restabilized closure"); - } - - ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate complete"); - return ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes); - } - - internal static ElkRoutedEdge[] BuildFinalRestabilizedCandidate( - ElkRoutedEdge[] edges, - ElkPositionedNode[] nodes, - ElkLayoutDirection direction, - double minLineClearance, - IReadOnlyCollection? restrictedEdgeIds = null) - { - var focusEdgeIds = restrictedEdgeIds?.Count > 0 - ? restrictedEdgeIds - : edges - .Select(edge => edge.Id) - .OrderBy(edgeId => edgeId, StringComparer.Ordinal) - .ToArray(); - var focusEdgeSet = focusEdgeIds.ToHashSet(StringComparer.Ordinal); - - var candidate = ChoosePreferredHardRuleLayout( - edges, - CloseRemainingTerminalViolations(edges, nodes, direction, minLineClearance, restrictedEdgeIds), - nodes); - if (focusEdgeIds.Count > 0) - { - candidate = ChoosePreferredHardRuleLayout( - candidate, - ApplyAggressiveSharedLaneClosure( - candidate, - nodes, - direction, - minLineClearance, - focusEdgeIds), - nodes); - } - - candidate = BuildFinalBoundarySlotCandidate( - candidate, - nodes, - direction, - minLineClearance, - restrictedEdgeIds, - allowLateRestabilizedClosure: false); - if (focusEdgeIds.Count > 0) - { - candidate = ChoosePreferredHardRuleLayout( - candidate, - ApplyAggressiveSharedLaneClosure( - candidate, - nodes, - direction, - minLineClearance, - focusEdgeIds), - nodes); - } - - candidate = ChoosePreferredBoundarySlotRepairLayout( - candidate, - ElkEdgePostProcessor.SnapBoundarySlotAssignments( - candidate, - nodes, - minLineClearance, - restrictedEdgeIds, - enforceAllNodeEndpoints: true), - nodes); - candidate = ChoosePreferredBoundarySlotRepairLayout( - candidate, - CloseRemainingTerminalViolations(candidate, nodes, direction, minLineClearance, restrictedEdgeIds), - nodes); - if (focusEdgeIds.Count > 0) - { - var lateHardRuleCandidate = ApplyAggressiveSharedLaneClosure( - candidate, - nodes, - direction, - minLineClearance, - focusEdgeIds); - lateHardRuleCandidate = CloseRemainingTerminalViolations( - lateHardRuleCandidate, - nodes, - direction, - minLineClearance, - focusEdgeIds); - candidate = ChoosePreferredHardRuleLayout(candidate, lateHardRuleCandidate, nodes); - - var remainingBadAngles = new Dictionary(StringComparer.Ordinal); - ElkEdgeRoutingScoring.CountBadBoundaryAngles(candidate, nodes, remainingBadAngles, 10); - var remainingTerminalFocus = ExpandWinningSolutionFocus( - candidate, - remainingBadAngles.Keys.Where(focusEdgeSet.Contains)) - .Where(focusEdgeSet.Contains) - .OrderBy(edgeId => edgeId, StringComparer.Ordinal) - .ToArray(); - if (remainingTerminalFocus.Length > 0) - { - var terminalFocus = (IReadOnlyCollection)remainingTerminalFocus; - var lateTerminalCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches( - candidate, - nodes, - minLineClearance, - terminalFocus); - lateTerminalCandidate = BuildFinalBoundarySlotCandidate( - lateTerminalCandidate, - nodes, - direction, - minLineClearance, - terminalFocus, - allowLateRestabilizedClosure: false); - lateTerminalCandidate = CloseRemainingTerminalViolations( - lateTerminalCandidate, - nodes, - direction, - minLineClearance, - terminalFocus); - candidate = ChoosePreferredBoundarySlotRepairLayout(candidate, lateTerminalCandidate, nodes); - } - } - - candidate = ChoosePreferredBoundarySlotRepairLayout( - candidate, - ElkEdgePostProcessor.SnapBoundarySlotAssignments( - candidate, - nodes, - minLineClearance, - restrictedEdgeIds, - enforceAllNodeEndpoints: true), - nodes); - candidate = ChoosePreferredBoundarySlotRepairLayout( - candidate, - ApplyPostSlotDetourClosure(candidate, nodes, minLineClearance, restrictedEdgeIds), - nodes); - candidate = ChoosePreferredBoundarySlotRepairLayout( - candidate, - ElkEdgePostProcessor.SnapBoundarySlotAssignments( - candidate, - nodes, - minLineClearance, - restrictedEdgeIds, - enforceAllNodeEndpoints: true), - nodes); - candidate = ApplyLateBoundarySlotRestabilization( - candidate, - nodes, - minLineClearance, - focusEdgeIds); - candidate = ChoosePreferredBoundarySlotRepairLayout( - candidate, - ElkEdgePostProcessor.SnapBoundarySlotAssignments( - candidate, - nodes, - minLineClearance, - restrictedEdgeIds, - enforceAllNodeEndpoints: true), - nodes); - - if (focusEdgeIds.Count > 0) - { - var finalSharedLaneCandidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts( - candidate, - nodes, - minLineClearance, - focusEdgeIds); - candidate = ChoosePreferredSharedLanePolishLayout(candidate, finalSharedLaneCandidate, nodes); - - var finalSourceJoinCandidate = ElkEdgePostProcessor.SpreadSourceDepartureJoins( - finalSharedLaneCandidate, - nodes, - minLineClearance, - focusEdgeIds); - candidate = ChoosePreferredSharedLanePolishLayout(candidate, finalSourceJoinCandidate, nodes); - - var finalTargetJoinCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins( - finalSourceJoinCandidate, - nodes, - minLineClearance, - focusEdgeIds, - forceOutwardAxisSpacing: true); - candidate = ChoosePreferredSharedLanePolishLayout(candidate, finalTargetJoinCandidate, nodes); - - var finalAggressiveCandidate = ApplyAggressiveSharedLaneClosure( - candidate, - nodes, - direction, - minLineClearance, - focusEdgeIds); - candidate = ChoosePreferredSharedLanePolishLayout(candidate, finalAggressiveCandidate, nodes); - - var forcedFinalSharedLaneCandidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts( - candidate, - nodes, - minLineClearance, - focusEdgeIds); - var baselineSharedLaneViolations = ElkEdgeRoutingScoring.CountSharedLaneViolations(candidate, nodes); - var forcedSharedLaneViolations = ElkEdgeRoutingScoring.CountSharedLaneViolations(forcedFinalSharedLaneCandidate, nodes); - if (forcedSharedLaneViolations < baselineSharedLaneViolations) - { - var baselineScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes); - var forcedScore = ElkEdgeRoutingScoring.ComputeScore(forcedFinalSharedLaneCandidate, nodes); - if (forcedScore.NodeCrossings <= baselineScore.NodeCrossings) - { - candidate = forcedFinalSharedLaneCandidate; - } - } - } - - return candidate; - } - - private static ElkRoutedEdge[] ApplyLateBoundarySlotRestabilization( - ElkRoutedEdge[] edges, - ElkPositionedNode[] nodes, - double minLineClearance, - IReadOnlyCollection focusEdgeIds) - { - if (focusEdgeIds.Count == 0) - { - return edges; - } - - var candidate = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts( - edges, - nodes, - minLineClearance, - focusEdgeIds); - ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after mixed-face separation"); - candidate = ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts( - candidate, - nodes, - minLineClearance, - focusEdgeIds); - ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after collector separation"); - candidate = ElkEdgePostProcessor.SpreadSourceDepartureJoins( - candidate, - nodes, - minLineClearance, - focusEdgeIds); - ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after source-join spread"); - candidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts( - candidate, - nodes, - minLineClearance, - focusEdgeIds); - ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after shared-lane separation"); - candidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches( - candidate, - nodes, - minLineClearance, - focusEdgeIds); - ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after boundary/target repair"); - candidate = ElkEdgePostProcessor.SpreadTargetApproachJoins( - candidate, - nodes, - minLineClearance, - focusEdgeIds); - ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after target-join spread"); - candidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands( - candidate, - nodes, - minLineClearance, - focusEdgeIds); - ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after feeder-band spread"); - candidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(candidate, nodes, focusEdgeIds); - ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after gateway finalize"); - candidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes); - candidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes); - candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( - candidate, - nodes, - minLineClearance, - focusEdgeIds, - enforceAllNodeEndpoints: true); - ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after first normalization snap"); - candidate = ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts( - candidate, - nodes, - minLineClearance, - focusEdgeIds); - ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after second collector separation"); - candidate = ElkEdgePostProcessor.SpreadSourceDepartureJoins( - candidate, - nodes, - minLineClearance, - focusEdgeIds); - ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after second source-join spread"); - candidate = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts( - candidate, - nodes, - minLineClearance, - focusEdgeIds); - ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after second mixed-face separation"); - candidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts( - candidate, - nodes, - minLineClearance, - focusEdgeIds); - ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after second shared-lane separation"); - candidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches( - candidate, - nodes, - minLineClearance, - focusEdgeIds); - ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after second boundary/target repair"); - candidate = ElkEdgePostProcessor.SpreadTargetApproachJoins( - candidate, - nodes, - minLineClearance, - focusEdgeIds); - ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after second target-join spread"); - candidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands( - candidate, - nodes, - minLineClearance, - focusEdgeIds); - ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after second feeder-band spread"); - candidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(candidate, nodes, focusEdgeIds); - ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after second gateway finalize"); - candidate = ApplyPostSlotDetourClosure(candidate, nodes, minLineClearance, focusEdgeIds); - ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after detour closure"); - candidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes); - candidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes); - candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments( - candidate, - nodes, - minLineClearance, - focusEdgeIds, - enforceAllNodeEndpoints: true); - ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization complete"); - - return ChoosePreferredBoundarySlotRepairLayout(edges, candidate, nodes); - } - - private static ElkRoutedEdge[] ApplyLateFocusedBoundarySlotClosure( - ElkRoutedEdge[] edges, - ElkPositionedNode[] nodes, - ElkLayoutDirection direction, - double minLineClearance, - IReadOnlyCollection focusEdgeIds, - IReadOnlyCollection? restrictedEdgeIds) - { - var candidate = ChoosePreferredHardRuleLayout( - edges, - ApplyAggressiveSharedLaneClosure(edges, nodes, direction, minLineClearance, focusEdgeIds), - nodes); - candidate = ChoosePreferredBoundarySlotRepairLayout( - candidate, - ElkEdgePostProcessor.SnapBoundarySlotAssignments( - candidate, - nodes, - minLineClearance, - restrictedEdgeIds, - enforceAllNodeEndpoints: true), - nodes); - candidate = ChoosePreferredBoundarySlotRepairLayout( - candidate, - CloseRemainingTerminalViolations(candidate, nodes, direction, minLineClearance, restrictedEdgeIds), - nodes); - candidate = ApplyLateBoundarySlotRestabilization(candidate, nodes, minLineClearance, focusEdgeIds); - candidate = ChoosePreferredBoundarySlotRepairLayout( - candidate, - ElkEdgePostProcessor.SnapBoundarySlotAssignments( - candidate, - nodes, - minLineClearance, - restrictedEdgeIds, - enforceAllNodeEndpoints: true), - nodes); - return candidate; - } - - private static bool TryPromoteFinalBoundarySlotCandidate( - CandidateSolution current, - ElkRoutedEdge[] candidateEdges, - ElkPositionedNode[] nodes, - out CandidateSolution promoted) - { - promoted = current; - var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); - var candidateRetryState = BuildRetryState( - candidateScore, - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count - : 0); - if (!IsBetterBoundarySlotRepairCandidate( - candidateScore, - candidateRetryState, - current.Score, - current.RetryState)) - { - return false; - } - - promoted = current with - { - Score = candidateScore, - RetryState = candidateRetryState, - Edges = candidateEdges, - }; - return true; - } - - private static bool HasBlockingBoundarySlotPromotionRegression( - RoutingRetryState candidate, - RoutingRetryState baseline) - { - return candidate.RemainingShortHighways > baseline.RemainingShortHighways - || candidate.RepeatCollectorCorridorViolations > baseline.RepeatCollectorCorridorViolations - || candidate.RepeatCollectorNodeClearanceViolations > baseline.RepeatCollectorNodeClearanceViolations - || candidate.BelowGraphViolations > baseline.BelowGraphViolations - || candidate.UnderNodeViolations > baseline.UnderNodeViolations; - } - - private static bool HasEdgeGeometryChanged( - IReadOnlyList baselineEdges, - IReadOnlyList candidateEdges, - string edgeId) - { - var baseline = baselineEdges.FirstOrDefault(edge => string.Equals(edge.Id, edgeId, StringComparison.Ordinal)); - var candidate = candidateEdges.FirstOrDefault(edge => string.Equals(edge.Id, edgeId, StringComparison.Ordinal)); - if (baseline is null || candidate is null) - { - return false; - } - - var baselinePath = ExtractEdgePath(baseline); - var candidatePath = ExtractEdgePath(candidate); - if (baselinePath.Count != candidatePath.Count) - { - return true; - } - - for (var i = 0; i < baselinePath.Count; i++) - { - if (!ElkEdgeRoutingGeometry.PointsEqual(baselinePath[i], candidatePath[i])) - { - return true; - } - } - - return false; - } - - private static List ExtractEdgePath(ElkRoutedEdge edge) - { - var path = new List(); - foreach (var section in edge.Sections) - { - if (path.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(path[^1], section.StartPoint)) - { - path.Add(section.StartPoint); - } - - foreach (var bendPoint in section.BendPoints) - { - if (path.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(path[^1], bendPoint)) - { - path.Add(bendPoint); - } - } - - if (path.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(path[^1], section.EndPoint)) - { - path.Add(section.EndPoint); - } - } - - return path; - } - - private static bool TryPromoteFinalHardRuleCandidate( - CandidateSolution current, - ElkRoutedEdge[] candidateEdges, - ElkPositionedNode[] nodes, - out CandidateSolution promoted) - { - promoted = current; - var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); - var candidateRetryState = BuildRetryState( - candidateScore, - HighwayProcessingEnabled - ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count - : 0); - if (!IsBetterBoundarySlotRepairCandidate( - candidateScore, - candidateRetryState, - current.Score, - current.RetryState)) - { - return false; - } - - promoted = current with - { - Score = candidateScore, - RetryState = candidateRetryState, - Edges = candidateEdges, - }; - return true; - } - - private static ElkRoutedEdge[] ApplyAggressiveSharedLaneClosure( - ElkRoutedEdge[] edges, - ElkPositionedNode[] nodes, - ElkLayoutDirection direction, - double minLineClearance, - IReadOnlyCollection focusEdgeIds) - { - var result = edges; - result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, focusEdgeIds); - result = ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(result, nodes, minLineClearance, focusEdgeIds); - result = ElkRepeatCollectorCorridors.SeparateSharedLanes(result, nodes, focusEdgeIds); - result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, focusEdgeIds); - result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, focusEdgeIds); - result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, focusEdgeIds, forceOutwardAxisSpacing: true); - result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, focusEdgeIds); - result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, focusEdgeIds); - result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes, focusEdgeIds); - result = ClampBelowGraphEdges(result, nodes, focusEdgeIds); - result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, focusEdgeIds); - result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); - result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); - result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, focusEdgeIds); - result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, focusEdgeIds); - return result; - } - -} \ No newline at end of file +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.cs index bf33bf8d5..975927433 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.cs @@ -8,6 +8,7 @@ internal static partial class ElkEdgeRouterIterative { private static readonly bool HighwayProcessingEnabled = true; private const int MaxWinnerPolishBatchedRootEdges = 4; + private const int MaxWinnerPolishFastTerminalSingleEdgeCandidates = 8; private const int MaxLateRestabilizedClosureFocusEdges = 8; private static readonly string[] OrderingNames = @@ -31,7 +32,6 @@ internal static partial class ElkEdgeRouterIterative return ApplyPostProcessing(baselineEdges, nodes, layoutOptions); } - // Rule: minimum line clearance = average service-task dimension var serviceNodes = nodes.Where(n => n.Kind is not "Start" and not "End").ToArray(); var minLineClearance = serviceNodes.Length > 0 ? Math.Min(serviceNodes.Average(n => n.Width), serviceNodes.Average(n => n.Height)) / 2d @@ -41,8 +41,12 @@ internal static partial class ElkEdgeRouterIterative var validSolutions = new List(); var fallbackSolutions = new List(); - // Strategy 0: baseline (existing routing with post-processing) - var baselineProcessed = ApplyPostProcessing(baselineEdges, nodes, layoutOptions); + var baselineProcessed = ApplyPostProcessing( + baselineEdges, + nodes, + layoutOptions, + preferHybridTerminalCleanup: config.Mode == IterativeRoutingMode.HybridDeterministic + && config.MaxRepairWaves <= 2); var baselineProcessedScore = ElkEdgeRoutingScoring.ComputeScore(baselineProcessed, nodes); var baselineBrokenHighways = HighwayProcessingEnabled ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(baselineProcessed, nodes) @@ -91,6 +95,32 @@ internal static partial class ElkEdgeRouterIterative return ElkEdgePostProcessor.ClearInternalRoutingMarkers(baselineProcessed); } + if (config.Mode == IterativeRoutingMode.HybridDeterministic) + { + var hybridBest = OptimizeHybrid( + baselineProcessed, + baselineProcessedScore, + baselineRetryState, + nodes, + layoutOptions, + config, + minLineClearance, + cancellationToken); + if (diagnostics is not null) + { + diagnostics.SelectedStrategyIndex = 1; + diagnostics.FinalScore = hybridBest.Score; + diagnostics.FinalBrokenShortHighwayCount = HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(hybridBest.Edges, nodes).Count + : 0; + ElkLayoutDiagnostics.FlushSnapshot(diagnostics); + } + + var cleanedHybridEdges = ElkEdgePostProcessor.ClearInternalRoutingMarkers(hybridBest.Edges); + ElkLayoutDiagnostics.LogProgress("Hybrid refinement markers cleared"); + return cleanedHybridEdges; + } + var strategyInputs = GenerateStrategies(baselineEdges, nodes, config, minLineClearance) .Take(maxStrategiesToAttempt) .Select((strategy, zeroBasedIndex) => new StrategyWorkItem( @@ -154,426 +184,6 @@ internal static partial class ElkEdgeRouterIterative return cleanedEdges; } - private static double ScoreProtectedCollectorGatewaySourceExitCandidate( - IReadOnlyList path, - ElkPositionedNode sourceNode, - ElkPoint exitReference) - { - var score = 0d; - if (path.Count < 2) - { - return double.PositiveInfinity; - } - - var boundary = path[0]; - var adjacent = path[1]; - var centerX = sourceNode.X + (sourceNode.Width / 2d); - var centerY = sourceNode.Y + (sourceNode.Height / 2d); - var desiredDx = exitReference.X - centerX; - var desiredDy = exitReference.Y - centerY; - var boundaryDx = boundary.X - centerX; - var boundaryDy = boundary.Y - centerY; - var firstDx = adjacent.X - boundary.X; - var firstDy = adjacent.Y - boundary.Y; - const double tolerance = 0.5d; - - var dominantHorizontal = Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.15d && Math.Sign(desiredDx) != 0; - var dominantVertical = Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.15d && Math.Sign(desiredDy) != 0; - if (dominantHorizontal) - { - if (Math.Sign(firstDx) != Math.Sign(desiredDx) || Math.Abs(firstDx) <= tolerance) - { - score += 100_000d; - } - - if (Math.Abs(boundaryDy) > sourceNode.Height * 0.28d) - { - score += 25_000d; - } - - score += Math.Abs(boundaryDy) * 6d; - } - else if (dominantVertical) - { - if (Math.Sign(firstDy) != Math.Sign(desiredDy) || Math.Abs(firstDy) <= tolerance) - { - score += 100_000d; - } - - if (Math.Abs(boundaryDx) > sourceNode.Width * 0.28d) - { - score += 25_000d; - } - - score += Math.Abs(boundaryDx) * 6d; - } - - if (ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, boundary, 8d)) - { - score += 4_000d; - } - - var length = 0d; - for (var i = 1; i < path.Count; i++) - { - length += ElkEdgeRoutingGeometry.ComputeSegmentLength(path[i - 1], path[i]); - } - - return score - + length - + (Math.Max(0, path.Count - 2) * 6d); - } - - private static List NormalizeProtectedCollectorTail( - IReadOnlyList path, - double graphMinY, - double graphMaxY) - { - if (path.Count < 5) - { - return path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); - } - - const double corridorTolerance = 8d; - const double coordinateTolerance = 0.5d; - var firstCorridorIndex = -1; - var lastCorridorIndex = -1; - for (var i = 0; i < path.Count; i++) - { - if (path[i].Y < graphMinY - corridorTolerance || path[i].Y > graphMaxY + corridorTolerance) - { - if (firstCorridorIndex < 0) - { - firstCorridorIndex = i; - } - - lastCorridorIndex = i; - } - } - - if (firstCorridorIndex < 0 - || lastCorridorIndex <= firstCorridorIndex - || lastCorridorIndex >= path.Count - 1 - || path[firstCorridorIndex].Y > graphMinY - corridorTolerance) - { - return path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); - } - - var desiredDx = path[^1].X - path[0].X; - if (Math.Abs(desiredDx) <= coordinateTolerance) - { - return path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); - } - - var preCorridorHorizontalIndex = -1; - for (var i = 1; i < lastCorridorIndex; i++) - { - if (Math.Abs(path[i].Y - path[i + 1].Y) > coordinateTolerance - || Math.Abs(path[i].X - path[i + 1].X) <= coordinateTolerance) - { - continue; - } - - var horizontalDelta = path[i + 1].X - path[i].X; - if (Math.Sign(horizontalDelta) == 0 || Math.Sign(horizontalDelta) == Math.Sign(desiredDx)) - { - continue; - } - - preCorridorHorizontalIndex = i; - break; - } - - if (preCorridorHorizontalIndex >= 0) - { - var rebuiltPrefix = path - .Take(preCorridorHorizontalIndex + 1) - .Select(point => new ElkPoint { X = point.X, Y = point.Y }) - .ToList(); - var rewrittenCorridorY = path[lastCorridorIndex].Y; - var rewrittenReentryPoint = path[lastCorridorIndex + 1]; - - if (Math.Abs(rebuiltPrefix[^1].Y - rewrittenCorridorY) > coordinateTolerance) - { - rebuiltPrefix.Add(new ElkPoint - { - X = rebuiltPrefix[^1].X, - Y = rewrittenCorridorY, - }); - } - - if (Math.Abs(rebuiltPrefix[^1].X - rewrittenReentryPoint.X) > coordinateTolerance) - { - rebuiltPrefix.Add(new ElkPoint - { - X = rewrittenReentryPoint.X, - Y = rewrittenCorridorY, - }); - } - - for (var i = lastCorridorIndex + 1; i < path.Count; i++) - { - rebuiltPrefix.Add(new ElkPoint { X = path[i].X, Y = path[i].Y }); - } - - return NormalizeCollectorPoints(rebuiltPrefix); - } - - var firstHorizontalIndex = -1; - for (var i = firstCorridorIndex; i < lastCorridorIndex; i++) - { - if (Math.Abs(path[i].Y - path[i + 1].Y) <= coordinateTolerance - && Math.Abs(path[i].X - path[i + 1].X) > coordinateTolerance) - { - firstHorizontalIndex = i; - break; - } - } - - if (firstHorizontalIndex < 0) - { - return path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); - } - - var firstHorizontalDelta = path[firstHorizontalIndex + 1].X - path[firstHorizontalIndex].X; - if (Math.Sign(firstHorizontalDelta) == 0 || Math.Sign(firstHorizontalDelta) == Math.Sign(desiredDx)) - { - return path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); - } - - var rebuilt = path - .Take(firstCorridorIndex + 1) - .Select(point => new ElkPoint { X = point.X, Y = point.Y }) - .ToList(); - var targetCorridorY = path[lastCorridorIndex].Y; - var reentryPoint = path[lastCorridorIndex + 1]; - - if (Math.Abs(rebuilt[^1].Y - targetCorridorY) > coordinateTolerance) - { - rebuilt.Add(new ElkPoint - { - X = rebuilt[^1].X, - Y = targetCorridorY, - }); - } - - if (Math.Abs(rebuilt[^1].X - reentryPoint.X) > coordinateTolerance) - { - rebuilt.Add(new ElkPoint - { - X = reentryPoint.X, - Y = targetCorridorY, - }); - } - - for (var i = lastCorridorIndex + 1; i < path.Count; i++) - { - rebuilt.Add(new ElkPoint { X = path[i].X, Y = path[i].Y }); - } - - return NormalizeCollectorPoints(rebuilt); - } - - private static ElkPoint? BuildOrthogonalCollectorCorner(ElkPoint from, ElkPoint to) - { - const double tolerance = 0.5d; - if (Math.Abs(from.X - to.X) <= tolerance || Math.Abs(from.Y - to.Y) <= tolerance) - { - return null; - } - - return Math.Abs(to.Y - from.Y) >= Math.Abs(to.X - from.X) - ? new ElkPoint { X = from.X, Y = to.Y } - : new ElkPoint { X = to.X, Y = from.Y }; - } - - private static List NormalizeCollectorPoints(IReadOnlyList points) - { - const double coordinateTolerance = 0.5d; - var deduped = new List(); - foreach (var point in points) - { - if (deduped.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(deduped[^1], point)) - { - deduped.Add(point); - } - } - - if (deduped.Count <= 2) - { - return deduped; - } - - var simplified = new List { deduped[0] }; - for (var i = 1; i < deduped.Count - 1; i++) - { - var previous = simplified[^1]; - var current = deduped[i]; - var next = deduped[i + 1]; - var sameX = Math.Abs(previous.X - current.X) <= coordinateTolerance - && Math.Abs(current.X - next.X) <= coordinateTolerance; - var sameY = Math.Abs(previous.Y - current.Y) <= coordinateTolerance - && Math.Abs(current.Y - next.Y) <= coordinateTolerance; - if (!sameX && !sameY) - { - simplified.Add(current); - } - } - - simplified.Add(deduped[^1]); - return simplified; - } - - private static List NormalizePolyline(IReadOnlyList points) - { - var result = new List(points.Count); - foreach (var point in points) - { - if (result.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(result[^1], point)) - { - result.Add(point); - } - } - - if (result.Count < 3) - { - return result; - } - - var collapsed = new List { result[0] }; - for (var i = 1; i < result.Count - 1; i++) - { - var previous = collapsed[^1]; - var current = result[i]; - var next = result[i + 1]; - var sameX = Math.Abs(previous.X - current.X) < 0.5d && Math.Abs(current.X - next.X) < 0.5d; - var sameY = Math.Abs(previous.Y - current.Y) < 0.5d && Math.Abs(current.Y - next.Y) < 0.5d; - if (!sameX && !sameY) - { - collapsed.Add(current); - } - } - - collapsed.Add(result[^1]); - return collapsed; - } - - private static void AddUniqueCoordinate(ICollection coordinates, double candidate, double tolerance = 0.5d) - { - foreach (var coordinate in coordinates) - { - if (Math.Abs(coordinate - candidate) <= tolerance) - { - return; - } - } - - coordinates.Add(candidate); - } - - private static (double Left, double Top, double Right, double Bottom, string Id)[] BuildObstacles( - ElkPositionedNode[] nodes, - double margin) - { - return nodes.Select(n => ( - Left: n.X - margin, - Top: n.Y - margin, - Right: n.X + n.Width + margin, - Bottom: n.Y + n.Height + margin, - Id: n.Id - )).ToArray(); - } - - private static ElkRoutedEdge[] ClampBelowGraphEdges( - ElkRoutedEdge[] edges, - ElkPositionedNode[] nodes, - IReadOnlyCollection? restrictedEdgeIds = null) - { - if (edges.Length == 0 || nodes.Length == 0) - { - return edges; - } - - var graphMinY = nodes.Min(node => node.Y); - var graphMaxY = nodes.Max(node => node.Y + node.Height); - var limitY = graphMaxY + 4d; - var obstacles = BuildObstacles(nodes, 0d); - var restrictedSet = restrictedEdgeIds is null - ? null - : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); - var result = edges.ToArray(); - - for (var i = 0; i < result.Length; i++) - { - var edge = result[i]; - if (restrictedSet is not null && !restrictedSet.Contains(edge.Id)) - { - continue; - } - - var path = ExtractPath(edge) - .Select(point => new ElkPoint { X = point.X, Y = point.Y }) - .ToList(); - if (path.Count < 3) - { - continue; - } - - var changed = false; - for (var pointIndex = 1; pointIndex < path.Count - 1; pointIndex++) - { - if (path[pointIndex].Y <= limitY) - { - continue; - } - - path[pointIndex] = new ElkPoint - { - X = path[pointIndex].X, - Y = limitY, - }; - changed = true; - } - - if (!changed) - { - continue; - } - - var normalized = NormalizePolyline(path); - var candidate = 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() - : [], - }, - ], - }; - - if (EdgeCrossesNode(candidate, obstacles)) - { - continue; - } - - result[i] = candidate; - } - - return result; - } - private static IterativeRoutingConfig ResolveConfig(ElkLayoutOptions layoutOptions) { var requested = layoutOptions.IterativeRouting ?? new IterativeRoutingOptions(); @@ -581,9 +191,14 @@ internal static partial class ElkEdgeRouterIterative return new IterativeRoutingConfig( Enabled: enabled, + Mode: requested.Mode, MaxAdaptationsPerStrategy: Math.Clamp(requested.MaxAdaptationsPerStrategy, 1, 100), RequiredValidSolutions: Math.Max(1, requested.RequiredValidSolutions), + MaxRepairWaves: Math.Clamp(requested.MaxRepairWaves, 1, 12), + MaxParallelRepairBuilds: Math.Clamp( + requested.MaxParallelRepairBuilds, + 1, + Math.Max(1, Environment.ProcessorCount)), ObstacleMargin: 18d); } - -} \ No newline at end of file +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingGeometry.Boundary.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingGeometry.Boundary.cs new file mode 100644 index 000000000..141af0516 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingGeometry.Boundary.cs @@ -0,0 +1,186 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRoutingGeometry +{ + internal static bool AreParallelAndClose( + ElkPoint a1, + ElkPoint a2, + ElkPoint b1, + ElkPoint b2, + double clearance) + { + if (IsHorizontal(a1, a2) && IsHorizontal(b1, b2)) + { + return Math.Abs(a1.Y - b1.Y) <= clearance + && OverlapLength(Math.Min(a1.X, a2.X), Math.Max(a1.X, a2.X), Math.Min(b1.X, b2.X), Math.Max(b1.X, b2.X)) > 1d; + } + + if (IsVertical(a1, a2) && IsVertical(b1, b2)) + { + return Math.Abs(a1.X - b1.X) <= clearance + && OverlapLength(Math.Min(a1.Y, a2.Y), Math.Max(a1.Y, a2.Y), Math.Min(b1.Y, b2.Y), Math.Max(b1.Y, b2.Y)) > 1d; + } + + return false; + } + + internal static double ComputeSharedSegmentLength( + ElkPoint a1, + ElkPoint a2, + ElkPoint b1, + ElkPoint b2) + { + if (IsHorizontal(a1, a2) && IsHorizontal(b1, b2) && Math.Abs(a1.Y - b1.Y) <= CoordinateTolerance) + { + return Math.Max(0d, OverlapLength( + Math.Min(a1.X, a2.X), + Math.Max(a1.X, a2.X), + Math.Min(b1.X, b2.X), + Math.Max(b1.X, b2.X))); + } + + if (IsVertical(a1, a2) && IsVertical(b1, b2) && Math.Abs(a1.X - b1.X) <= CoordinateTolerance) + { + return Math.Max(0d, OverlapLength( + Math.Min(a1.Y, a2.Y), + Math.Max(a1.Y, a2.Y), + Math.Min(b1.Y, b2.Y), + Math.Max(b1.Y, b2.Y))); + } + + return 0d; + } + + internal static string ResolveBoundarySide(ElkPoint point, ElkPositionedNode node) + { + var distLeft = Math.Abs(point.X - node.X); + var distRight = Math.Abs(point.X - (node.X + node.Width)); + var distTop = Math.Abs(point.Y - node.Y); + var distBottom = Math.Abs(point.Y - (node.Y + node.Height)); + var min = Math.Min(Math.Min(distLeft, distRight), Math.Min(distTop, distBottom)); + + if (Math.Abs(min - distLeft) <= CoordinateTolerance) + { + return "left"; + } + + if (Math.Abs(min - distRight) <= CoordinateTolerance) + { + return "right"; + } + + if (Math.Abs(min - distTop) <= CoordinateTolerance) + { + return "top"; + } + + return "bottom"; + } + + internal static string ResolveBoundaryApproachSide( + ElkPoint boundaryPoint, + ElkPoint adjacentPoint, + ElkPositionedNode node) + { + if (!ElkShapeBoundaries.IsGatewayShape(node)) + { + return ResolveBoundarySide(boundaryPoint, node); + } + + var deltaX = boundaryPoint.X - adjacentPoint.X; + var deltaY = boundaryPoint.Y - adjacentPoint.Y; + var absDx = Math.Abs(deltaX); + var absDy = Math.Abs(deltaY); + if (absDx <= CoordinateTolerance && absDy > CoordinateTolerance) + { + return deltaY >= 0d ? "top" : "bottom"; + } + + if (absDy <= CoordinateTolerance && absDx > CoordinateTolerance) + { + return deltaX >= 0d ? "left" : "right"; + } + + if (absDx > absDy * 1.25d) + { + return deltaX >= 0d ? "left" : "right"; + } + + if (absDy > absDx * 1.25d) + { + return deltaY >= 0d ? "top" : "bottom"; + } + + return ResolveBoundarySide(boundaryPoint, node); + } + + internal static double ComputeParallelOverlapLength( + ElkPoint a1, + ElkPoint a2, + ElkPoint b1, + ElkPoint b2) + { + if (IsHorizontal(a1, a2) && IsHorizontal(b1, b2)) + { + return OverlapLength( + Math.Min(a1.X, a2.X), + Math.Max(a1.X, a2.X), + Math.Min(b1.X, b2.X), + Math.Max(b1.X, b2.X)); + } + + if (IsVertical(a1, a2) && IsVertical(b1, b2)) + { + return OverlapLength( + Math.Min(a1.Y, a2.Y), + Math.Max(a1.Y, a2.Y), + Math.Min(b1.Y, b2.Y), + Math.Max(b1.Y, b2.Y)); + } + + return 0d; + } + + internal static bool AreCollinearAndOverlapping(ElkPoint a1, ElkPoint a2, ElkPoint b1, ElkPoint b2) + { + if (IsHorizontal(a1, a2) && IsHorizontal(b1, b2) && Math.Abs(a1.Y - b1.Y) <= CoordinateTolerance) + { + return OverlapLength(Math.Min(a1.X, a2.X), Math.Max(a1.X, a2.X), Math.Min(b1.X, b2.X), Math.Max(b1.X, b2.X)) > 1d; + } + + if (IsVertical(a1, a2) && IsVertical(b1, b2) && Math.Abs(a1.X - b1.X) <= CoordinateTolerance) + { + return OverlapLength(Math.Min(a1.Y, a2.Y), Math.Max(a1.Y, a2.Y), Math.Min(b1.Y, b2.Y), Math.Max(b1.Y, b2.Y)) > 1d; + } + + return false; + } + + private static bool IsHorizontal(ElkPoint start, ElkPoint end) => Math.Abs(start.Y - end.Y) <= CoordinateTolerance; + + private static bool IsVertical(ElkPoint start, ElkPoint end) => Math.Abs(start.X - end.X) <= CoordinateTolerance; + + private static IReadOnlyList FlattenSegmentsNearEnd( + IReadOnlyList path, + int maxSegmentsFromEnd) + { + if (path.Count < 2 || maxSegmentsFromEnd <= 0) + { + return []; + } + + var startIndex = Math.Max(0, path.Count - (maxSegmentsFromEnd + 1)); + var segments = new List(); + for (var i = startIndex; i < path.Count - 1; i++) + { + segments.Add(new RoutedEdgeSegment(string.Empty, path[i], path[i + 1])); + } + + return segments; + } + + private static double OverlapLength(double firstMin, double firstMax, double secondMin, double secondMax) + { + return Math.Min(firstMax, secondMax) - Math.Max(firstMin, secondMin); + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingGeometry.Intersections.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingGeometry.Intersections.cs new file mode 100644 index 000000000..2f9b35b8c --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingGeometry.Intersections.cs @@ -0,0 +1,87 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgeRoutingGeometry +{ + internal static bool SegmentsIntersect(ElkPoint a1, ElkPoint a2, ElkPoint b1, ElkPoint b2) + { + if (ShareEndpoint(a1, a2, b1, b2)) + { + return false; + } + + if (AreCollinearAndOverlapping(a1, a2, b1, b2)) + { + return false; + } + + if (IsHorizontal(a1, a2) && IsVertical(b1, b2)) + { + return IntersectsOrthogonal(a1, a2, b1, b2); + } + + if (IsVertical(a1, a2) && IsHorizontal(b1, b2)) + { + return IntersectsOrthogonal(b1, b2, a1, a2); + } + + return SegmentsIntersectGeneral(a1, a2, b1, b2); + } + + private static bool IntersectsOrthogonal(ElkPoint horizontalStart, ElkPoint horizontalEnd, ElkPoint verticalStart, ElkPoint verticalEnd) + { + var minHorizontalX = Math.Min(horizontalStart.X, horizontalEnd.X); + var maxHorizontalX = Math.Max(horizontalStart.X, horizontalEnd.X); + var minVerticalY = Math.Min(verticalStart.Y, verticalEnd.Y); + var maxVerticalY = Math.Max(verticalStart.Y, verticalEnd.Y); + + return verticalStart.X > minHorizontalX + CoordinateTolerance + && verticalStart.X < maxHorizontalX - CoordinateTolerance + && horizontalStart.Y > minVerticalY + CoordinateTolerance + && horizontalStart.Y < maxVerticalY - CoordinateTolerance; + } + + private static bool ShareEndpoint(ElkPoint a1, ElkPoint a2, ElkPoint b1, ElkPoint b2) + { + return PointsEqual(a1, b1) + || PointsEqual(a1, b2) + || PointsEqual(a2, b1) + || PointsEqual(a2, b2); + } + + private static bool SegmentsIntersectGeneral(ElkPoint a1, ElkPoint a2, ElkPoint b1, ElkPoint b2) + { + var o1 = Orientation(a1, a2, b1); + var o2 = Orientation(a1, a2, b2); + var o3 = Orientation(b1, b2, a1); + var o4 = Orientation(b1, b2, a2); + + if (o1 != o2 && o3 != o4) + { + return true; + } + + return o1 == 0 && OnSegment(a1, b1, a2) + || o2 == 0 && OnSegment(a1, b2, a2) + || o3 == 0 && OnSegment(b1, a1, b2) + || o4 == 0 && OnSegment(b1, a2, b2); + } + + private static int Orientation(ElkPoint start, ElkPoint middle, ElkPoint end) + { + var value = ((middle.Y - start.Y) * (end.X - middle.X)) - ((middle.X - start.X) * (end.Y - middle.Y)); + if (Math.Abs(value) <= CoordinateTolerance) + { + return 0; + } + + return value > 0 ? 1 : 2; + } + + private static bool OnSegment(ElkPoint start, ElkPoint point, ElkPoint end) + { + return point.X <= Math.Max(start.X, end.X) + CoordinateTolerance + && point.X >= Math.Min(start.X, end.X) - CoordinateTolerance + && point.Y <= Math.Max(start.Y, end.Y) + CoordinateTolerance + && point.Y >= Math.Min(start.Y, end.Y) - CoordinateTolerance; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingGeometry.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingGeometry.cs index 782039798..9fb4e6d79 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingGeometry.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingGeometry.cs @@ -1,6 +1,6 @@ namespace StellaOps.ElkSharp; -internal static class ElkEdgeRoutingGeometry +internal static partial class ElkEdgeRoutingGeometry { private const double CoordinateTolerance = 0.5d; @@ -45,80 +45,6 @@ internal static class ElkEdgeRoutingGeometry return Math.Sqrt((dx * dx) + (dy * dy)); } - internal static bool SegmentsIntersect(ElkPoint a1, ElkPoint a2, ElkPoint b1, ElkPoint b2) - { - if (ShareEndpoint(a1, a2, b1, b2)) - { - return false; - } - - if (AreCollinearAndOverlapping(a1, a2, b1, b2)) - { - return false; - } - - if (IsHorizontal(a1, a2) && IsVertical(b1, b2)) - { - return IntersectsOrthogonal(a1, a2, b1, b2); - } - - if (IsVertical(a1, a2) && IsHorizontal(b1, b2)) - { - return IntersectsOrthogonal(b1, b2, a1, a2); - } - - return SegmentsIntersectGeneral(a1, a2, b1, b2); - } - - internal static bool AreParallelAndClose( - ElkPoint a1, - ElkPoint a2, - ElkPoint b1, - ElkPoint b2, - double clearance) - { - if (IsHorizontal(a1, a2) && IsHorizontal(b1, b2)) - { - return Math.Abs(a1.Y - b1.Y) <= clearance - && OverlapLength(Math.Min(a1.X, a2.X), Math.Max(a1.X, a2.X), Math.Min(b1.X, b2.X), Math.Max(b1.X, b2.X)) > 1d; - } - - if (IsVertical(a1, a2) && IsVertical(b1, b2)) - { - return Math.Abs(a1.X - b1.X) <= clearance - && OverlapLength(Math.Min(a1.Y, a2.Y), Math.Max(a1.Y, a2.Y), Math.Min(b1.Y, b2.Y), Math.Max(b1.Y, b2.Y)) > 1d; - } - - return false; - } - - internal static double ComputeSharedSegmentLength( - ElkPoint a1, - ElkPoint a2, - ElkPoint b1, - ElkPoint b2) - { - if (IsHorizontal(a1, a2) && IsHorizontal(b1, b2) && Math.Abs(a1.Y - b1.Y) <= CoordinateTolerance) - { - return Math.Max(0d, OverlapLength( - Math.Min(a1.X, a2.X), - Math.Max(a1.X, a2.X), - Math.Min(b1.X, b2.X), - Math.Max(b1.X, b2.X))); - } - - if (IsVertical(a1, a2) && IsVertical(b1, b2) && Math.Abs(a1.X - b1.X) <= CoordinateTolerance) - { - return Math.Max(0d, OverlapLength( - Math.Min(a1.Y, a2.Y), - Math.Max(a1.Y, a2.Y), - Math.Min(b1.Y, b2.Y), - Math.Max(b1.Y, b2.Y))); - } - - return 0d; - } - internal static double ComputeLongestSharedSegmentLength(ElkRoutedEdge left, ElkRoutedEdge right) { var leftSegments = FlattenSegments(left); @@ -164,111 +90,6 @@ internal static class ElkEdgeRoutingGeometry return longest; } - internal static string ResolveBoundarySide(ElkPoint point, ElkPositionedNode node) - { - var distLeft = Math.Abs(point.X - node.X); - var distRight = Math.Abs(point.X - (node.X + node.Width)); - var distTop = Math.Abs(point.Y - node.Y); - var distBottom = Math.Abs(point.Y - (node.Y + node.Height)); - var min = Math.Min(Math.Min(distLeft, distRight), Math.Min(distTop, distBottom)); - - if (Math.Abs(min - distLeft) <= CoordinateTolerance) - { - return "left"; - } - - if (Math.Abs(min - distRight) <= CoordinateTolerance) - { - return "right"; - } - - if (Math.Abs(min - distTop) <= CoordinateTolerance) - { - return "top"; - } - - return "bottom"; - } - - internal static string ResolveBoundaryApproachSide( - ElkPoint boundaryPoint, - ElkPoint adjacentPoint, - ElkPositionedNode node) - { - if (!ElkShapeBoundaries.IsGatewayShape(node)) - { - return ResolveBoundarySide(boundaryPoint, node); - } - - var deltaX = boundaryPoint.X - adjacentPoint.X; - var deltaY = boundaryPoint.Y - adjacentPoint.Y; - var absDx = Math.Abs(deltaX); - var absDy = Math.Abs(deltaY); - if (absDx <= CoordinateTolerance && absDy > CoordinateTolerance) - { - return deltaY >= 0d ? "top" : "bottom"; - } - - if (absDy <= CoordinateTolerance && absDx > CoordinateTolerance) - { - return deltaX >= 0d ? "left" : "right"; - } - - if (absDx > absDy * 1.25d) - { - return deltaX >= 0d ? "left" : "right"; - } - - if (absDy > absDx * 1.25d) - { - return deltaY >= 0d ? "top" : "bottom"; - } - - return ResolveBoundarySide(boundaryPoint, node); - } - - internal static double ComputeParallelOverlapLength( - ElkPoint a1, - ElkPoint a2, - ElkPoint b1, - ElkPoint b2) - { - if (IsHorizontal(a1, a2) && IsHorizontal(b1, b2)) - { - return OverlapLength( - Math.Min(a1.X, a2.X), - Math.Max(a1.X, a2.X), - Math.Min(b1.X, b2.X), - Math.Max(b1.X, b2.X)); - } - - if (IsVertical(a1, a2) && IsVertical(b1, b2)) - { - return OverlapLength( - Math.Min(a1.Y, a2.Y), - Math.Max(a1.Y, a2.Y), - Math.Min(b1.Y, b2.Y), - Math.Max(b1.Y, b2.Y)); - } - - return 0d; - } - - internal static bool AreCollinearAndOverlapping(ElkPoint a1, ElkPoint a2, ElkPoint b1, ElkPoint b2) - { - if (IsHorizontal(a1, a2) && IsHorizontal(b1, b2) && Math.Abs(a1.Y - b1.Y) <= CoordinateTolerance) - { - return OverlapLength(Math.Min(a1.X, a2.X), Math.Max(a1.X, a2.X), Math.Min(b1.X, b2.X), Math.Max(b1.X, b2.X)) > 1d; - } - - if (IsVertical(a1, a2) && IsVertical(b1, b2) && Math.Abs(a1.X - b1.X) <= CoordinateTolerance) - { - return OverlapLength(Math.Min(a1.Y, a2.Y), Math.Max(a1.Y, a2.Y), Math.Min(b1.Y, b2.Y), Math.Max(b1.Y, b2.Y)) > 1d; - } - - return false; - } - internal static ElkPoint ResolveApproachPoint(ElkRoutedEdge edge) { var lastSection = edge.Sections.Last(); @@ -285,90 +106,4 @@ internal static class ElkEdgeRoutingGeometry return Math.Abs(left.X - right.X) <= CoordinateTolerance && Math.Abs(left.Y - right.Y) <= CoordinateTolerance; } - - private static bool IsHorizontal(ElkPoint start, ElkPoint end) => Math.Abs(start.Y - end.Y) <= CoordinateTolerance; - - private static bool IsVertical(ElkPoint start, ElkPoint end) => Math.Abs(start.X - end.X) <= CoordinateTolerance; - - private static IReadOnlyList FlattenSegmentsNearEnd( - IReadOnlyList path, - int maxSegmentsFromEnd) - { - if (path.Count < 2 || maxSegmentsFromEnd <= 0) - { - return []; - } - - var startIndex = Math.Max(0, path.Count - (maxSegmentsFromEnd + 1)); - var segments = new List(); - for (var i = startIndex; i < path.Count - 1; i++) - { - segments.Add(new RoutedEdgeSegment(string.Empty, path[i], path[i + 1])); - } - - return segments; - } - - private static bool IntersectsOrthogonal(ElkPoint horizontalStart, ElkPoint horizontalEnd, ElkPoint verticalStart, ElkPoint verticalEnd) - { - var minHorizontalX = Math.Min(horizontalStart.X, horizontalEnd.X); - var maxHorizontalX = Math.Max(horizontalStart.X, horizontalEnd.X); - var minVerticalY = Math.Min(verticalStart.Y, verticalEnd.Y); - var maxVerticalY = Math.Max(verticalStart.Y, verticalEnd.Y); - - return verticalStart.X > minHorizontalX + CoordinateTolerance - && verticalStart.X < maxHorizontalX - CoordinateTolerance - && horizontalStart.Y > minVerticalY + CoordinateTolerance - && horizontalStart.Y < maxVerticalY - CoordinateTolerance; - } - - private static bool ShareEndpoint(ElkPoint a1, ElkPoint a2, ElkPoint b1, ElkPoint b2) - { - return PointsEqual(a1, b1) - || PointsEqual(a1, b2) - || PointsEqual(a2, b1) - || PointsEqual(a2, b2); - } - - private static bool SegmentsIntersectGeneral(ElkPoint a1, ElkPoint a2, ElkPoint b1, ElkPoint b2) - { - var o1 = Orientation(a1, a2, b1); - var o2 = Orientation(a1, a2, b2); - var o3 = Orientation(b1, b2, a1); - var o4 = Orientation(b1, b2, a2); - - if (o1 != o2 && o3 != o4) - { - return true; - } - - return o1 == 0 && OnSegment(a1, b1, a2) - || o2 == 0 && OnSegment(a1, b2, a2) - || o3 == 0 && OnSegment(b1, a1, b2) - || o4 == 0 && OnSegment(b1, a2, b2); - } - - private static int Orientation(ElkPoint start, ElkPoint middle, ElkPoint end) - { - var value = ((middle.Y - start.Y) * (end.X - middle.X)) - ((middle.X - start.X) * (end.Y - middle.Y)); - if (Math.Abs(value) <= CoordinateTolerance) - { - return 0; - } - - return value > 0 ? 1 : 2; - } - - private static bool OnSegment(ElkPoint start, ElkPoint point, ElkPoint end) - { - return point.X <= Math.Max(start.X, end.X) + CoordinateTolerance - && point.X >= Math.Min(start.X, end.X) - CoordinateTolerance - && point.Y <= Math.Max(start.Y, end.Y) + CoordinateTolerance - && point.Y >= Math.Min(start.Y, end.Y) - CoordinateTolerance; - } - - private static double OverlapLength(double firstMin, double firstMax, double secondMin, double secondMax) - { - return Math.Min(firstMax, secondMax) - Math.Max(firstMin, secondMin); - } } diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.cs index d5d3aa41a..42c8656cd 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.cs @@ -1960,7 +1960,7 @@ internal static class ElkEdgeRoutingScoring if (ElkShapeBoundaries.IsGatewayShape(targetNode)) { - return HasGatewayTargetApproachBacktracking(path); + return HasGatewayTargetApproachBacktracking(path, targetNode); } var side = ElkEdgeRoutingGeometry.ResolveBoundarySide(path[^1], targetNode); @@ -1970,6 +1970,11 @@ internal static class ElkEdgeRoutingScoring } const double tolerance = 0.5d; + if (HasShortOrthogonalTargetHook(path, targetNode, side, tolerance)) + { + return true; + } + var startIndex = Math.Max( 0, path.Count - (side is "left" or "right" ? 4 : 3)); @@ -2047,6 +2052,85 @@ internal static class ElkEdgeRoutingScoring return false; } + private static bool HasShortOrthogonalTargetHook( + IReadOnlyList path, + ElkPositionedNode targetNode, + string side, + double tolerance) + { + if (path.Count < 3) + { + return false; + } + + var boundaryPoint = path[^1]; + var runStartIndex = path.Count - 2; + if (side is "left" or "right") + { + while (runStartIndex > 0 && Math.Abs(path[runStartIndex - 1].Y - boundaryPoint.Y) <= tolerance) + { + runStartIndex--; + } + } + else + { + while (runStartIndex > 0 && Math.Abs(path[runStartIndex - 1].X - boundaryPoint.X) <= tolerance) + { + runStartIndex--; + } + } + + if (runStartIndex == 0) + { + return false; + } + + var overallDeltaX = path[^1].X - path[0].X; + var overallDeltaY = path[^1].Y - path[0].Y; + var overallAbsDx = Math.Abs(overallDeltaX); + var overallAbsDy = Math.Abs(overallDeltaY); + var sameRowThreshold = Math.Max(24d, targetNode.Height / 3d); + var sameColumnThreshold = Math.Max(24d, targetNode.Width / 3d); + var looksHorizontal = overallAbsDx >= overallAbsDy * 1.15d + && overallAbsDy <= sameRowThreshold + && Math.Sign(overallDeltaX) != 0; + var looksVertical = overallAbsDy >= overallAbsDx * 1.15d + && overallAbsDx <= sameColumnThreshold + && Math.Sign(overallDeltaY) != 0; + var contradictsDominantApproach = side switch + { + "left" or "right" => looksVertical, + "top" or "bottom" => looksHorizontal, + _ => false, + }; + if (!contradictsDominantApproach) + { + return false; + } + + var runStart = path[runStartIndex]; + var boundaryDepth = side is "left" or "right" + ? Math.Abs(boundaryPoint.X - runStart.X) + : Math.Abs(boundaryPoint.Y - runStart.Y); + var requiredDepth = side is "left" or "right" + ? targetNode.Width + : targetNode.Height; + if (boundaryDepth + tolerance >= requiredDepth) + { + return false; + } + + var predecessor = path[runStartIndex - 1]; + var predecessorDx = Math.Abs(runStart.X - predecessor.X); + var predecessorDy = Math.Abs(runStart.Y - predecessor.Y); + return side switch + { + "left" or "right" => predecessorDy > predecessorDx * 3d, + "top" or "bottom" => predecessorDx > predecessorDy * 3d, + _ => false, + }; + } + private static bool HasGatewaySourceExitIssue( IReadOnlyList path, ElkPositionedNode sourceNode, @@ -2234,13 +2318,20 @@ internal static class ElkEdgeRoutingScoring return false; } - private static bool HasGatewayTargetApproachBacktracking(IReadOnlyList path) + private static bool HasGatewayTargetApproachBacktracking( + IReadOnlyList path, + ElkPositionedNode targetNode) { if (path.Count < 4) { return false; } + if (HasShortGatewayTargetOrthogonalHook(path, targetNode)) + { + return true; + } + const double tolerance = 0.5d; var startIndex = Math.Max(0, path.Count - 4); var nearEnd = path.Skip(startIndex).ToArray(); @@ -2304,6 +2395,43 @@ internal static class ElkEdgeRoutingScoring return false; } + private static bool HasShortGatewayTargetOrthogonalHook( + 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; + } + + var predecessor = path[^3]; + var predecessorDx = Math.Abs(exteriorPoint.X - predecessor.X); + var predecessorDy = Math.Abs(exteriorPoint.Y - predecessor.Y); + const double minimumApproachSpan = 24d; + return finalIsHorizontal + ? predecessorDy >= minimumApproachSpan && predecessorDy > predecessorDx * 3d + : predecessorDx >= minimumApproachSpan && predecessorDx > predecessorDy * 3d; + } + private static IReadOnlyList FlattenSegmentsNearEnd( IReadOnlyList path, int maxSegmentsFromEnd) diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkLayoutTypes.Retry.cs b/src/__Libraries/StellaOps.ElkSharp/ElkLayoutTypes.Retry.cs new file mode 100644 index 000000000..3c2ced79d --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkLayoutTypes.Retry.cs @@ -0,0 +1,80 @@ +namespace StellaOps.ElkSharp; + +internal readonly record struct RoutingRetryState( + int RemainingShortHighways, + int RepeatCollectorCorridorViolations, + int RepeatCollectorNodeClearanceViolations, + int TargetApproachJoinViolations, + int TargetApproachBacktrackingViolations, + int ExcessiveDetourViolations, + int SharedLaneViolations, + int BoundarySlotViolations, + int BelowGraphViolations, + int UnderNodeViolations, + int LongDiagonalViolations, + int ProximityViolations, + int EntryAngleViolations, + int GatewaySourceExitViolations, + int LabelProximityViolations, + int EdgeCrossings) +{ + internal int QualityViolationCount => + ProximityViolations + LabelProximityViolations; + + internal bool RequiresQualityRetry => QualityViolationCount > 0; + + internal int BlockingViolationCount => + RemainingShortHighways + + RepeatCollectorCorridorViolations + + RepeatCollectorNodeClearanceViolations + + TargetApproachJoinViolations + + TargetApproachBacktrackingViolations + + SharedLaneViolations + + BoundarySlotViolations + + BelowGraphViolations + + UnderNodeViolations + + LongDiagonalViolations + + EntryAngleViolations + + GatewaySourceExitViolations + + LabelProximityViolations; + + internal bool RequiresBlockingRetry => BlockingViolationCount > 0; + + internal int LengthViolationCount => + ExcessiveDetourViolations; + + internal bool RequiresLengthRetry => LengthViolationCount > 0; + + internal int PrimaryViolationCount => + BlockingViolationCount + LengthViolationCount + QualityViolationCount; + + internal bool RequiresPrimaryRetry => PrimaryViolationCount > 0; +} + +internal readonly record struct EdgeRoutingIssue( + string EdgeId, + int Severity); + +internal readonly record struct RoutedEdgeSegment( + string EdgeId, + ElkPoint Start, + ElkPoint End); + +internal readonly record struct OrthogonalAStarOptions( + double Margin, + double BendPenalty, + double SoftObstacleWeight, + double SoftObstacleClearance); + +internal readonly record struct OrthogonalSoftObstacle( + ElkPoint Start, + ElkPoint End); + +internal readonly record struct AStarRoutingParams( + double Margin, + double BendPenalty, + double DiagonalPenalty, + double SoftObstacleWeight, + double SoftObstacleClearance, + double IntermediateGridSpacing, + bool EnforceEntryAngle); diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkLayoutTypes.Strategy.cs b/src/__Libraries/StellaOps.ElkSharp/ElkLayoutTypes.Strategy.cs new file mode 100644 index 000000000..a0a0969da --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkLayoutTypes.Strategy.cs @@ -0,0 +1,159 @@ +namespace StellaOps.ElkSharp; + +internal sealed class RoutingStrategy +{ + internal int[] EdgeOrder { get; init; } = []; + internal double BaseLineClearance { get; init; } + internal double MinLineClearance { get; set; } + internal AStarRoutingParams RoutingParams { get; set; } + + internal void AdaptForViolations(EdgeRoutingScore score, int attempt, RoutingRetryState retryState) + { + var highwayPressure = Math.Min(retryState.RemainingShortHighways, 4); + var collectorCorridorPressure = Math.Min(retryState.RepeatCollectorCorridorViolations, 4); + var collectorClearancePressure = Math.Min(retryState.RepeatCollectorNodeClearanceViolations, 6); + var targetJoinPressure = Math.Min(retryState.TargetApproachJoinViolations, 4); + var backtrackingPressure = Math.Min(retryState.TargetApproachBacktrackingViolations, 4); + var detourPressure = Math.Min(retryState.ExcessiveDetourViolations, 4); + var sharedLanePressure = Math.Min(retryState.SharedLaneViolations, 6); + var boundarySlotPressure = Math.Min(retryState.BoundarySlotViolations, 6); + var underNodePressure = Math.Min(retryState.UnderNodeViolations, 6); + var proximityPressure = Math.Min(retryState.ProximityViolations, 6); + var entryPressure = Math.Min(retryState.EntryAngleViolations, 4); + var gatewaySourcePressure = Math.Min(retryState.GatewaySourceExitViolations, 4); + var labelPressure = Math.Min(retryState.LabelProximityViolations, 4); + var crossingPressure = Math.Min(retryState.EdgeCrossings, 6); + var clearanceStep = 4d + + (highwayPressure > 0 ? 8d : 0d) + + (collectorCorridorPressure > 0 ? 10d : 0d) + + (collectorClearancePressure > 0 ? 10d : 0d) + + (targetJoinPressure > 0 ? 12d : 0d) + + (backtrackingPressure > 0 ? 6d : 0d) + + (sharedLanePressure > 0 ? 12d : 0d) + + (boundarySlotPressure > 0 ? 12d : 0d) + + (underNodePressure > 0 ? 12d : 0d) + + (proximityPressure > 0 ? 10d : 0d) + + (gatewaySourcePressure > 0 ? 8d : 0d) + + (labelPressure > 0 ? 4d : 0d) + + (crossingPressure > 0 ? 3d : 0d); + MinLineClearance = Math.Min( + Math.Max(MinLineClearance, BaseLineClearance) + clearanceStep, + BaseLineClearance * 2d); + + var bendPenalty = RoutingParams.BendPenalty; + if (entryPressure > 0 || gatewaySourcePressure > 0 || labelPressure > 0 || highwayPressure > 0 || collectorCorridorPressure > 0 || collectorClearancePressure > 0 || targetJoinPressure > 0 || sharedLanePressure > 0 || boundarySlotPressure > 0 || underNodePressure > 0) + { + bendPenalty = Math.Min(bendPenalty + 40d, 800d); + } + else if (backtrackingPressure > 0 || detourPressure > 0 || sharedLanePressure > 0 || boundarySlotPressure > 0 || underNodePressure > 0 || proximityPressure > 0 || crossingPressure > 0) + { + bendPenalty = Math.Max( + 80d, + bendPenalty - (backtrackingPressure > 0 ? 80d : detourPressure > 0 ? 50d : sharedLanePressure > 0 || boundarySlotPressure > 0 || underNodePressure > 0 ? 40d : 30d)); + } + + var margin = RoutingParams.Margin; + if (backtrackingPressure > 0 || detourPressure > 0) + { + margin = Math.Max(12d, margin - (backtrackingPressure > 0 ? 6d : 4d)); + } + else + { + margin = Math.Min( + margin + + (highwayPressure > 0 ? 8d : 4d) + + (collectorCorridorPressure > 0 ? 8d : 0d) + + (collectorClearancePressure > 0 ? 8d : 0d) + + (targetJoinPressure > 0 ? 10d : 0d) + + (sharedLanePressure > 0 ? 10d : 0d) + + (boundarySlotPressure > 0 ? 10d : 0d) + + (underNodePressure > 0 ? 10d : 0d) + + (proximityPressure > 0 ? 6d : 0d) + + (entryPressure > 0 ? 3d : 0d) + + (gatewaySourcePressure > 0 ? 4d : 0d), + BaseLineClearance * 2d); + } + + var softObstacleWeight = RoutingParams.SoftObstacleWeight; + if (backtrackingPressure > 0 || detourPressure > 0) + { + softObstacleWeight = Math.Max( + 0.5d, + softObstacleWeight - (backtrackingPressure > 0 ? 1.0d : 0.75d)); + } + else + { + softObstacleWeight = Math.Min( + softObstacleWeight + + (highwayPressure > 0 ? 0.75d : 0.25d) + + (collectorCorridorPressure > 0 ? 0.75d : 0d) + + (collectorClearancePressure > 0 ? 0.75d : 0d) + + (targetJoinPressure > 0 ? 1.0d : 0d) + + (sharedLanePressure > 0 ? 1.0d : 0d) + + (proximityPressure > 0 ? 0.75d : 0d) + + (crossingPressure > 0 ? 0.5d : 0d), + 8d); + } + + var softObstacleClearance = RoutingParams.SoftObstacleClearance; + if (backtrackingPressure > 0 || detourPressure > 0) + { + softObstacleClearance = Math.Max( + BaseLineClearance * 0.6d, + softObstacleClearance - (backtrackingPressure > 0 ? 14d : 10d)); + } + else + { + softObstacleClearance = Math.Min( + softObstacleClearance + + (highwayPressure > 0 ? 8d : 4d) + + (collectorCorridorPressure > 0 ? 10d : 0d) + + (collectorClearancePressure > 0 ? 10d : 0d) + + (targetJoinPressure > 0 ? 16d : 0d) + + (sharedLanePressure > 0 ? 16d : 0d) + + (proximityPressure > 0 ? 10d : 0d) + + (labelPressure > 0 ? 4d : 0d) + + (crossingPressure > 0 ? 4d : 0d), + BaseLineClearance * 2d); + } + + var intermediateGridSpacing = RoutingParams.IntermediateGridSpacing; + if (backtrackingPressure > 0 || detourPressure > 0) + { + intermediateGridSpacing = Math.Max( + 12d, + intermediateGridSpacing - (backtrackingPressure > 0 ? 10d : 6d)); + } + else + { + intermediateGridSpacing = Math.Max( + 12d, + intermediateGridSpacing + - (highwayPressure > 0 ? 6d : 2d) + - (collectorCorridorPressure > 0 ? 6d : 0d) + - (targetJoinPressure > 0 ? 8d : 0d) + - (sharedLanePressure > 0 ? 8d : 0d) + - (proximityPressure > 0 ? 6d : 0d) + - (entryPressure > 0 ? 4d : 0d) + - (labelPressure > 0 ? 2d : 0d)); + } + + RoutingParams = RoutingParams with + { + Margin = margin, + BendPenalty = bendPenalty, + SoftObstacleWeight = softObstacleWeight, + SoftObstacleClearance = softObstacleClearance, + IntermediateGridSpacing = intermediateGridSpacing, + }; + } +} + +internal readonly record struct IterativeRoutingConfig( + bool Enabled, + IterativeRoutingMode Mode, + int MaxAdaptationsPerStrategy, + int RequiredValidSolutions, + int MaxRepairWaves, + int MaxParallelRepairBuilds, + double ObstacleMargin); diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkLayoutTypes.cs b/src/__Libraries/StellaOps.ElkSharp/ElkLayoutTypes.cs index 4eda3c52b..ba97467da 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkLayoutTypes.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkLayoutTypes.cs @@ -70,236 +70,3 @@ internal readonly record struct EdgeRoutingScore( int ProximityViolations, double TotalPathLength, double Value); - -internal readonly record struct RoutingRetryState( - int RemainingShortHighways, - int RepeatCollectorCorridorViolations, - int RepeatCollectorNodeClearanceViolations, - int TargetApproachJoinViolations, - int TargetApproachBacktrackingViolations, - int ExcessiveDetourViolations, - int SharedLaneViolations, - int BoundarySlotViolations, - int BelowGraphViolations, - int UnderNodeViolations, - int LongDiagonalViolations, - int ProximityViolations, - int EntryAngleViolations, - int GatewaySourceExitViolations, - int LabelProximityViolations, - int EdgeCrossings) -{ - internal int QualityViolationCount => - ProximityViolations + LabelProximityViolations; - - internal bool RequiresQualityRetry => QualityViolationCount > 0; - - internal int BlockingViolationCount => - RemainingShortHighways - + RepeatCollectorCorridorViolations - + RepeatCollectorNodeClearanceViolations - + TargetApproachJoinViolations - + TargetApproachBacktrackingViolations - + SharedLaneViolations - + BoundarySlotViolations - + BelowGraphViolations - + UnderNodeViolations - + LongDiagonalViolations - + EntryAngleViolations - + GatewaySourceExitViolations; - - internal bool RequiresBlockingRetry => BlockingViolationCount > 0; - - internal int LengthViolationCount => - ExcessiveDetourViolations; - - internal bool RequiresLengthRetry => LengthViolationCount > 0; - - internal int PrimaryViolationCount => - BlockingViolationCount + LengthViolationCount + QualityViolationCount; - - internal bool RequiresPrimaryRetry => PrimaryViolationCount > 0; -} - -internal readonly record struct EdgeRoutingIssue( - string EdgeId, - int Severity); - -internal readonly record struct RoutedEdgeSegment( - string EdgeId, - ElkPoint Start, - ElkPoint End); - -internal readonly record struct OrthogonalAStarOptions( - double Margin, - double BendPenalty, - double SoftObstacleWeight, - double SoftObstacleClearance); - -internal readonly record struct OrthogonalSoftObstacle( - ElkPoint Start, - ElkPoint End); - -internal readonly record struct AStarRoutingParams( - double Margin, - double BendPenalty, - double DiagonalPenalty, - double SoftObstacleWeight, - double SoftObstacleClearance, - double IntermediateGridSpacing, - bool EnforceEntryAngle); - -internal sealed class RoutingStrategy -{ - internal int[] EdgeOrder { get; init; } = []; - internal double BaseLineClearance { get; init; } - internal double MinLineClearance { get; set; } - internal AStarRoutingParams RoutingParams { get; set; } - - internal void AdaptForViolations(EdgeRoutingScore score, int attempt, RoutingRetryState retryState) - { - var highwayPressure = Math.Min(retryState.RemainingShortHighways, 4); - var collectorCorridorPressure = Math.Min(retryState.RepeatCollectorCorridorViolations, 4); - var collectorClearancePressure = Math.Min(retryState.RepeatCollectorNodeClearanceViolations, 6); - var targetJoinPressure = Math.Min(retryState.TargetApproachJoinViolations, 4); - var backtrackingPressure = Math.Min(retryState.TargetApproachBacktrackingViolations, 4); - var detourPressure = Math.Min(retryState.ExcessiveDetourViolations, 4); - var sharedLanePressure = Math.Min(retryState.SharedLaneViolations, 6); - var boundarySlotPressure = Math.Min(retryState.BoundarySlotViolations, 6); - var underNodePressure = Math.Min(retryState.UnderNodeViolations, 6); - var proximityPressure = Math.Min(retryState.ProximityViolations, 6); - var entryPressure = Math.Min(retryState.EntryAngleViolations, 4); - var gatewaySourcePressure = Math.Min(retryState.GatewaySourceExitViolations, 4); - var labelPressure = Math.Min(retryState.LabelProximityViolations, 4); - var crossingPressure = Math.Min(retryState.EdgeCrossings, 6); - var clearanceStep = 4d - + (highwayPressure > 0 ? 8d : 0d) - + (collectorCorridorPressure > 0 ? 10d : 0d) - + (collectorClearancePressure > 0 ? 10d : 0d) - + (targetJoinPressure > 0 ? 12d : 0d) - + (backtrackingPressure > 0 ? 6d : 0d) - + (sharedLanePressure > 0 ? 12d : 0d) - + (boundarySlotPressure > 0 ? 12d : 0d) - + (underNodePressure > 0 ? 12d : 0d) - + (proximityPressure > 0 ? 10d : 0d) - + (gatewaySourcePressure > 0 ? 8d : 0d) - + (labelPressure > 0 ? 4d : 0d) - + (crossingPressure > 0 ? 3d : 0d); - MinLineClearance = Math.Min( - Math.Max(MinLineClearance, BaseLineClearance) + clearanceStep, - BaseLineClearance * 2d); - - var bendPenalty = RoutingParams.BendPenalty; - if (entryPressure > 0 || gatewaySourcePressure > 0 || labelPressure > 0 || highwayPressure > 0 || collectorCorridorPressure > 0 || collectorClearancePressure > 0 || targetJoinPressure > 0 || sharedLanePressure > 0 || boundarySlotPressure > 0 || underNodePressure > 0) - { - bendPenalty = Math.Min(bendPenalty + 40d, 800d); - } - else if (backtrackingPressure > 0 || detourPressure > 0 || sharedLanePressure > 0 || boundarySlotPressure > 0 || underNodePressure > 0 || proximityPressure > 0 || crossingPressure > 0) - { - bendPenalty = Math.Max( - 80d, - bendPenalty - (backtrackingPressure > 0 ? 80d : detourPressure > 0 ? 50d : sharedLanePressure > 0 || boundarySlotPressure > 0 || underNodePressure > 0 ? 40d : 30d)); - } - - var margin = RoutingParams.Margin; - if (backtrackingPressure > 0 || detourPressure > 0) - { - margin = Math.Max(12d, margin - (backtrackingPressure > 0 ? 6d : 4d)); - } - else - { - margin = Math.Min( - margin - + (highwayPressure > 0 ? 8d : 4d) - + (collectorCorridorPressure > 0 ? 8d : 0d) - + (collectorClearancePressure > 0 ? 8d : 0d) - + (targetJoinPressure > 0 ? 10d : 0d) - + (sharedLanePressure > 0 ? 10d : 0d) - + (boundarySlotPressure > 0 ? 10d : 0d) - + (underNodePressure > 0 ? 10d : 0d) - + (proximityPressure > 0 ? 6d : 0d) - + (entryPressure > 0 ? 3d : 0d) - + (gatewaySourcePressure > 0 ? 4d : 0d), - BaseLineClearance * 2d); - } - - var softObstacleWeight = RoutingParams.SoftObstacleWeight; - if (backtrackingPressure > 0 || detourPressure > 0) - { - softObstacleWeight = Math.Max( - 0.5d, - softObstacleWeight - (backtrackingPressure > 0 ? 1.0d : 0.75d)); - } - else - { - softObstacleWeight = Math.Min( - softObstacleWeight - + (highwayPressure > 0 ? 0.75d : 0.25d) - + (collectorCorridorPressure > 0 ? 0.75d : 0d) - + (collectorClearancePressure > 0 ? 0.75d : 0d) - + (targetJoinPressure > 0 ? 1.0d : 0d) - + (sharedLanePressure > 0 ? 1.0d : 0d) - + (proximityPressure > 0 ? 0.75d : 0d) - + (crossingPressure > 0 ? 0.5d : 0d), - 8d); - } - - var softObstacleClearance = RoutingParams.SoftObstacleClearance; - if (backtrackingPressure > 0 || detourPressure > 0) - { - softObstacleClearance = Math.Max( - BaseLineClearance * 0.6d, - softObstacleClearance - (backtrackingPressure > 0 ? 14d : 10d)); - } - else - { - softObstacleClearance = Math.Min( - softObstacleClearance - + (highwayPressure > 0 ? 8d : 4d) - + (collectorCorridorPressure > 0 ? 10d : 0d) - + (collectorClearancePressure > 0 ? 10d : 0d) - + (targetJoinPressure > 0 ? 16d : 0d) - + (sharedLanePressure > 0 ? 16d : 0d) - + (proximityPressure > 0 ? 10d : 0d) - + (labelPressure > 0 ? 4d : 0d) - + (crossingPressure > 0 ? 4d : 0d), - BaseLineClearance * 2d); - } - - var intermediateGridSpacing = RoutingParams.IntermediateGridSpacing; - if (backtrackingPressure > 0 || detourPressure > 0) - { - intermediateGridSpacing = Math.Max( - 12d, - intermediateGridSpacing - (backtrackingPressure > 0 ? 10d : 6d)); - } - else - { - intermediateGridSpacing = Math.Max( - 12d, - intermediateGridSpacing - - (highwayPressure > 0 ? 6d : 2d) - - (collectorCorridorPressure > 0 ? 6d : 0d) - - (targetJoinPressure > 0 ? 8d : 0d) - - (sharedLanePressure > 0 ? 8d : 0d) - - (proximityPressure > 0 ? 6d : 0d) - - (entryPressure > 0 ? 4d : 0d) - - (labelPressure > 0 ? 2d : 0d)); - } - - RoutingParams = RoutingParams with - { - Margin = margin, - BendPenalty = bendPenalty, - SoftObstacleWeight = softObstacleWeight, - SoftObstacleClearance = softObstacleClearance, - IntermediateGridSpacing = intermediateGridSpacing, - }; - } -} - -internal readonly record struct IterativeRoutingConfig( - bool Enabled, - int MaxAdaptationsPerStrategy, - int RequiredValidSolutions, - double ObstacleMargin); diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkModels.cs b/src/__Libraries/StellaOps.ElkSharp/ElkModels.cs index 0b1b8a393..10b4e65ae 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkModels.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkModels.cs @@ -87,8 +87,17 @@ public sealed record EdgeRefinementOptions public sealed record IterativeRoutingOptions { public bool? Enabled { get; init; } + public IterativeRoutingMode Mode { get; init; } = IterativeRoutingMode.LegacyMultiStrategy; public int MaxAdaptationsPerStrategy { get; init; } = 100; public int RequiredValidSolutions { get; init; } = 10; + public int MaxRepairWaves { get; init; } = 4; + public int MaxParallelRepairBuilds { get; init; } = 4; +} + +public enum IterativeRoutingMode +{ + LegacyMultiStrategy = 0, + HybridDeterministic = 1, } public sealed record ElkPoint diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkNodePlacement.Grid.cs b/src/__Libraries/StellaOps.ElkSharp/ElkNodePlacement.Grid.cs new file mode 100644 index 000000000..95ba8dbfc --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkNodePlacement.Grid.cs @@ -0,0 +1,188 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkNodePlacement +{ + internal static void AlignToPlacementGrid( + Dictionary positionedNodes, + IReadOnlyList layers, + IReadOnlySet dummyNodeIds, + double nodeSpacing, + NodePlacementGrid placementGrid, + ElkLayoutDirection direction) + { + if (layers.Count == 0) + { + return; + } + + if (direction == ElkLayoutDirection.LeftToRight) + { + foreach (var layer in layers) + { + var actualNodes = layer.Where(node => !dummyNodeIds.Contains(node.Id)).ToArray(); + if (actualNodes.Length == 0) + { + continue; + } + + var currentX = positionedNodes[actualNodes[0].Id].X; + var snappedX = SnapToPlacementGrid(currentX, placementGrid.XStep); + var deltaX = snappedX - currentX; + if (Math.Abs(deltaX) > 0.01d) + { + foreach (var node in layer) + { + var pos = positionedNodes[node.Id]; + positionedNodes[node.Id] = ElkLayoutHelpers.CreatePositionedNode( + node, + pos.X + deltaX, + pos.Y, + direction); + } + } + + var desiredY = actualNodes + .Select(node => SnapToPlacementGrid(positionedNodes[node.Id].Y, placementGrid.YStep)) + .ToArray(); + EnforceLinearSpacing(actualNodes, desiredY, nodeSpacing, placementGrid.YStep, horizontal: true); + for (var i = 0; i < actualNodes.Length; i++) + { + var current = positionedNodes[actualNodes[i].Id]; + positionedNodes[actualNodes[i].Id] = ElkLayoutHelpers.CreatePositionedNode( + actualNodes[i], + current.X, + desiredY[i], + direction); + } + } + + var minY = positionedNodes.Values.Min(node => node.Y); + if (minY < -0.01d) + { + var shift = SnapForwardToPlacementGrid(-minY, placementGrid.YStep); + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + var pos = positionedNodes[nodeId]; + positionedNodes[nodeId] = pos with { Y = pos.Y + shift }; + } + } + + return; + } + + foreach (var layer in layers) + { + var actualNodes = layer.Where(node => !dummyNodeIds.Contains(node.Id)).ToArray(); + if (actualNodes.Length == 0) + { + continue; + } + + var currentY = positionedNodes[actualNodes[0].Id].Y; + var snappedY = SnapToPlacementGrid(currentY, placementGrid.YStep); + var deltaY = snappedY - currentY; + if (Math.Abs(deltaY) > 0.01d) + { + foreach (var node in layer) + { + var pos = positionedNodes[node.Id]; + positionedNodes[node.Id] = ElkLayoutHelpers.CreatePositionedNode( + node, + pos.X, + pos.Y + deltaY, + direction); + } + } + + var desiredX = actualNodes + .Select(node => SnapToPlacementGrid(positionedNodes[node.Id].X, placementGrid.XStep)) + .ToArray(); + EnforceLinearSpacing(actualNodes, desiredX, nodeSpacing, placementGrid.XStep, horizontal: false); + for (var i = 0; i < actualNodes.Length; i++) + { + var current = positionedNodes[actualNodes[i].Id]; + positionedNodes[actualNodes[i].Id] = ElkLayoutHelpers.CreatePositionedNode( + actualNodes[i], + desiredX[i], + current.Y, + direction); + } + } + + var minX = positionedNodes.Values.Min(node => node.X); + if (minX < -0.01d) + { + var shift = SnapForwardToPlacementGrid(-minX, placementGrid.XStep); + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + var pos = positionedNodes[nodeId]; + positionedNodes[nodeId] = pos with { X = pos.X + shift }; + } + } + } + + internal static void EnforceLinearSpacing( + IReadOnlyList layer, + double[] desiredCoordinates, + double spacing, + double gridStep, + bool horizontal) + { + for (var index = 1; index < layer.Count; index++) + { + var extent = horizontal ? layer[index - 1].Height : layer[index - 1].Width; + desiredCoordinates[index] = Math.Max( + desiredCoordinates[index], + desiredCoordinates[index - 1] + extent + spacing); + desiredCoordinates[index] = SnapForwardToPlacementGrid(desiredCoordinates[index], gridStep); + } + + for (var index = layer.Count - 2; index >= 0; index--) + { + var extent = horizontal ? layer[index].Height : layer[index].Width; + desiredCoordinates[index] = Math.Min( + desiredCoordinates[index], + desiredCoordinates[index + 1] - extent - spacing); + desiredCoordinates[index] = SnapBackwardToPlacementGrid(desiredCoordinates[index], gridStep); + } + + for (var index = 1; index < layer.Count; index++) + { + var extent = horizontal ? layer[index - 1].Height : layer[index - 1].Width; + desiredCoordinates[index] = Math.Max( + desiredCoordinates[index], + desiredCoordinates[index - 1] + extent + spacing); + desiredCoordinates[index] = SnapForwardToPlacementGrid(desiredCoordinates[index], gridStep); + } + } + + internal static double SnapToPlacementGrid(double value, double gridStep) + { + if (gridStep <= 1d) + { + return value; + } + + return Math.Round(value / gridStep) * gridStep; + } + + internal static double SnapForwardToPlacementGrid(double value, double gridStep) + { + if (gridStep <= 1d) + { + return value; + } + + return Math.Ceiling(value / gridStep) * gridStep; + } + + internal static double SnapBackwardToPlacementGrid(double value, double gridStep) + { + if (gridStep <= 1d) + { + return value; + } + + return Math.Floor(value / gridStep) * gridStep; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkNodePlacement.Refinement.cs b/src/__Libraries/StellaOps.ElkSharp/ElkNodePlacement.Refinement.cs new file mode 100644 index 000000000..617599df4 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkNodePlacement.Refinement.cs @@ -0,0 +1,196 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkNodePlacement +{ + internal static void RefineHorizontalPlacement( + Dictionary positionedNodes, + IReadOnlyList layers, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary nodesById, + double nodeSpacing, + int iterationCount, + ElkLayoutDirection direction) + { + if (iterationCount <= 0) + { + return; + } + + for (var iteration = 0; iteration < iterationCount; iteration++) + { + var layerIndices = iteration % 2 == 0 + ? Enumerable.Range(0, layers.Count) + : Enumerable.Range(0, layers.Count).Reverse(); + + foreach (var layerIndex in layerIndices) + { + var layer = layers[layerIndex]; + if (layer.Length == 0) + { + continue; + } + + var desiredY = new double[layer.Length]; + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var node = layer[nodeIndex]; + var preferredCenter = ElkNodePlacementPreferredCenter.ResolvePreferredCenter( + node.Id, + incomingNodeIds, + outgoingNodeIds, + positionedNodes, + horizontal: true); + desiredY[nodeIndex] = preferredCenter.HasValue + ? preferredCenter.Value - (node.Height / 2d) + : positionedNodes[node.Id].Y; + } + + for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++) + { + var minY = desiredY[nodeIndex - 1] + layer[nodeIndex - 1].Height + nodeSpacing; + if (desiredY[nodeIndex] < minY) + { + desiredY[nodeIndex] = minY; + } + } + + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var current = positionedNodes[layer[nodeIndex].Id]; + positionedNodes[layer[nodeIndex].Id] = ElkLayoutHelpers.CreatePositionedNode( + nodesById[layer[nodeIndex].Id], + current.X, + desiredY[nodeIndex], + direction); + } + } + } + } + + internal static void RefineVerticalPlacement( + Dictionary positionedNodes, + IReadOnlyList layers, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary nodesById, + double nodeSpacing, + int iterationCount, + ElkLayoutDirection direction) + { + if (iterationCount <= 0) + { + return; + } + + for (var iteration = 0; iteration < iterationCount; iteration++) + { + var layerIndices = iteration % 2 == 0 + ? Enumerable.Range(0, layers.Count) + : Enumerable.Range(0, layers.Count).Reverse(); + + foreach (var layerIndex in layerIndices) + { + var layer = layers[layerIndex]; + if (layer.Length == 0) + { + continue; + } + + var desiredX = new double[layer.Length]; + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var node = layer[nodeIndex]; + var preferredCenter = ElkNodePlacementPreferredCenter.ResolvePreferredCenter( + node.Id, + incomingNodeIds, + outgoingNodeIds, + positionedNodes, + horizontal: false); + desiredX[nodeIndex] = preferredCenter.HasValue + ? preferredCenter.Value - (node.Width / 2d) + : positionedNodes[node.Id].X; + } + + for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++) + { + var minX = desiredX[nodeIndex - 1] + layer[nodeIndex - 1].Width + nodeSpacing; + if (desiredX[nodeIndex] < minX) + { + desiredX[nodeIndex] = minX; + } + } + + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var current = positionedNodes[layer[nodeIndex].Id]; + positionedNodes[layer[nodeIndex].Id] = ElkLayoutHelpers.CreatePositionedNode( + nodesById[layer[nodeIndex].Id], + desiredX[nodeIndex], + current.Y, + direction); + } + } + } + } + + internal static void SnapOriginalPrimaryAxes( + Dictionary positionedNodes, + IReadOnlyList layers, + IReadOnlySet dummyNodeIds, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary originalNodesById, + double nodeSpacing, + ElkLayoutDirection direction) + { + for (var iteration = 0; iteration < 3; iteration++) + { + foreach (var layer in layers) + { + var actualNodes = layer + .Where(node => !dummyNodeIds.Contains(node.Id) && originalNodesById.ContainsKey(node.Id)) + .Select(node => originalNodesById[node.Id]) + .ToArray(); + if (actualNodes.Length == 0) + { + continue; + } + + var nodeDesiredPairs = new (ElkNode Node, double Desired)[actualNodes.Length]; + for (var nodeIndex = 0; nodeIndex < actualNodes.Length; nodeIndex++) + { + var positioned = positionedNodes[actualNodes[nodeIndex].Id]; + var preferredCenter = ElkNodePlacementPreferredCenter.ResolveOriginalPreferredCenter( + actualNodes[nodeIndex].Id, + incomingNodeIds, + outgoingNodeIds, + positionedNodes, + horizontal: direction == ElkLayoutDirection.LeftToRight); + nodeDesiredPairs[nodeIndex] = (actualNodes[nodeIndex], preferredCenter.HasValue + ? preferredCenter.Value - ((direction == ElkLayoutDirection.LeftToRight ? positioned.Height : positioned.Width) / 2d) + : (direction == ElkLayoutDirection.LeftToRight ? positioned.Y : positioned.X)); + } + + Array.Sort(nodeDesiredPairs, (a, b) => a.Desired.CompareTo(b.Desired)); + var sortedNodes = nodeDesiredPairs.Select(p => p.Node).ToArray(); + var desiredCoordinates = nodeDesiredPairs.Select(p => p.Desired).ToArray(); + + EnforceLinearSpacing( + sortedNodes, + desiredCoordinates, + nodeSpacing, + 0d, + horizontal: direction == ElkLayoutDirection.LeftToRight); + + for (var nodeIndex = 0; nodeIndex < sortedNodes.Length; nodeIndex++) + { + var current = positionedNodes[sortedNodes[nodeIndex].Id]; + positionedNodes[sortedNodes[nodeIndex].Id] = direction == ElkLayoutDirection.LeftToRight + ? ElkLayoutHelpers.CreatePositionedNode(sortedNodes[nodeIndex], current.X, desiredCoordinates[nodeIndex], direction) + : ElkLayoutHelpers.CreatePositionedNode(sortedNodes[nodeIndex], desiredCoordinates[nodeIndex], current.Y, direction); + } + } + } + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkNodePlacement.cs b/src/__Libraries/StellaOps.ElkSharp/ElkNodePlacement.cs index 8bb5e8246..17f4740eb 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkNodePlacement.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkNodePlacement.cs @@ -1,6 +1,6 @@ namespace StellaOps.ElkSharp; -internal static class ElkNodePlacement +internal static partial class ElkNodePlacement { internal static NodePlacementGrid ResolvePlacementGrid(IReadOnlyCollection nodes) { @@ -57,379 +57,4 @@ internal static class ElkNodePlacement }; } - internal static void RefineHorizontalPlacement( - Dictionary positionedNodes, - IReadOnlyList layers, - IReadOnlyDictionary> incomingNodeIds, - IReadOnlyDictionary> outgoingNodeIds, - IReadOnlyDictionary nodesById, - double nodeSpacing, - int iterationCount, - ElkLayoutDirection direction) - { - if (iterationCount <= 0) - { - return; - } - - for (var iteration = 0; iteration < iterationCount; iteration++) - { - var layerIndices = iteration % 2 == 0 - ? Enumerable.Range(0, layers.Count) - : Enumerable.Range(0, layers.Count).Reverse(); - - foreach (var layerIndex in layerIndices) - { - var layer = layers[layerIndex]; - if (layer.Length == 0) - { - continue; - } - - var desiredY = new double[layer.Length]; - for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) - { - var node = layer[nodeIndex]; - var preferredCenter = ElkNodePlacementPreferredCenter.ResolvePreferredCenter( - node.Id, - incomingNodeIds, - outgoingNodeIds, - positionedNodes, - horizontal: true); - desiredY[nodeIndex] = preferredCenter.HasValue - ? preferredCenter.Value - (node.Height / 2d) - : positionedNodes[node.Id].Y; - } - - for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++) - { - var minY = desiredY[nodeIndex - 1] + layer[nodeIndex - 1].Height + nodeSpacing; - if (desiredY[nodeIndex] < minY) - { - desiredY[nodeIndex] = minY; - } - } - - for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) - { - var current = positionedNodes[layer[nodeIndex].Id]; - positionedNodes[layer[nodeIndex].Id] = ElkLayoutHelpers.CreatePositionedNode( - nodesById[layer[nodeIndex].Id], - current.X, - desiredY[nodeIndex], - direction); - } - } - } - } - - internal static void RefineVerticalPlacement( - Dictionary positionedNodes, - IReadOnlyList layers, - IReadOnlyDictionary> incomingNodeIds, - IReadOnlyDictionary> outgoingNodeIds, - IReadOnlyDictionary nodesById, - double nodeSpacing, - int iterationCount, - ElkLayoutDirection direction) - { - if (iterationCount <= 0) - { - return; - } - - for (var iteration = 0; iteration < iterationCount; iteration++) - { - var layerIndices = iteration % 2 == 0 - ? Enumerable.Range(0, layers.Count) - : Enumerable.Range(0, layers.Count).Reverse(); - - foreach (var layerIndex in layerIndices) - { - var layer = layers[layerIndex]; - if (layer.Length == 0) - { - continue; - } - - var desiredX = new double[layer.Length]; - for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) - { - var node = layer[nodeIndex]; - var preferredCenter = ElkNodePlacementPreferredCenter.ResolvePreferredCenter( - node.Id, - incomingNodeIds, - outgoingNodeIds, - positionedNodes, - horizontal: false); - desiredX[nodeIndex] = preferredCenter.HasValue - ? preferredCenter.Value - (node.Width / 2d) - : positionedNodes[node.Id].X; - } - - for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++) - { - var minX = desiredX[nodeIndex - 1] + layer[nodeIndex - 1].Width + nodeSpacing; - if (desiredX[nodeIndex] < minX) - { - desiredX[nodeIndex] = minX; - } - } - - for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) - { - var current = positionedNodes[layer[nodeIndex].Id]; - positionedNodes[layer[nodeIndex].Id] = ElkLayoutHelpers.CreatePositionedNode( - nodesById[layer[nodeIndex].Id], - desiredX[nodeIndex], - current.Y, - direction); - } - } - } - } - - internal static void SnapOriginalPrimaryAxes( - Dictionary positionedNodes, - IReadOnlyList layers, - IReadOnlySet dummyNodeIds, - IReadOnlyDictionary> incomingNodeIds, - IReadOnlyDictionary> outgoingNodeIds, - IReadOnlyDictionary originalNodesById, - double nodeSpacing, - ElkLayoutDirection direction) - { - for (var iteration = 0; iteration < 3; iteration++) - { - foreach (var layer in layers) - { - var actualNodes = layer - .Where(node => !dummyNodeIds.Contains(node.Id) && originalNodesById.ContainsKey(node.Id)) - .Select(node => originalNodesById[node.Id]) - .ToArray(); - if (actualNodes.Length == 0) - { - continue; - } - - var nodeDesiredPairs = new (ElkNode Node, double Desired)[actualNodes.Length]; - for (var nodeIndex = 0; nodeIndex < actualNodes.Length; nodeIndex++) - { - var positioned = positionedNodes[actualNodes[nodeIndex].Id]; - var preferredCenter = ElkNodePlacementPreferredCenter.ResolveOriginalPreferredCenter( - actualNodes[nodeIndex].Id, - incomingNodeIds, - outgoingNodeIds, - positionedNodes, - horizontal: direction == ElkLayoutDirection.LeftToRight); - nodeDesiredPairs[nodeIndex] = (actualNodes[nodeIndex], preferredCenter.HasValue - ? preferredCenter.Value - ((direction == ElkLayoutDirection.LeftToRight ? positioned.Height : positioned.Width) / 2d) - : (direction == ElkLayoutDirection.LeftToRight ? positioned.Y : positioned.X)); - } - - Array.Sort(nodeDesiredPairs, (a, b) => a.Desired.CompareTo(b.Desired)); - var sortedNodes = nodeDesiredPairs.Select(p => p.Node).ToArray(); - var desiredCoordinates = nodeDesiredPairs.Select(p => p.Desired).ToArray(); - - EnforceLinearSpacing( - sortedNodes, - desiredCoordinates, - nodeSpacing, - 0d, - horizontal: direction == ElkLayoutDirection.LeftToRight); - - for (var nodeIndex = 0; nodeIndex < sortedNodes.Length; nodeIndex++) - { - var current = positionedNodes[sortedNodes[nodeIndex].Id]; - positionedNodes[sortedNodes[nodeIndex].Id] = direction == ElkLayoutDirection.LeftToRight - ? ElkLayoutHelpers.CreatePositionedNode(sortedNodes[nodeIndex], current.X, desiredCoordinates[nodeIndex], direction) - : ElkLayoutHelpers.CreatePositionedNode(sortedNodes[nodeIndex], desiredCoordinates[nodeIndex], current.Y, direction); - } - } - } - } - - internal static void AlignToPlacementGrid( - Dictionary positionedNodes, - IReadOnlyList layers, - IReadOnlySet dummyNodeIds, - double nodeSpacing, - NodePlacementGrid placementGrid, - ElkLayoutDirection direction) - { - if (layers.Count == 0) - { - return; - } - - if (direction == ElkLayoutDirection.LeftToRight) - { - foreach (var layer in layers) - { - var actualNodes = layer.Where(node => !dummyNodeIds.Contains(node.Id)).ToArray(); - if (actualNodes.Length == 0) - { - continue; - } - - var currentX = positionedNodes[actualNodes[0].Id].X; - var snappedX = SnapToPlacementGrid(currentX, placementGrid.XStep); - var deltaX = snappedX - currentX; - if (Math.Abs(deltaX) > 0.01d) - { - foreach (var node in layer) - { - var pos = positionedNodes[node.Id]; - positionedNodes[node.Id] = ElkLayoutHelpers.CreatePositionedNode( - node, - pos.X + deltaX, - pos.Y, - direction); - } - } - - var desiredY = actualNodes - .Select(node => SnapToPlacementGrid(positionedNodes[node.Id].Y, placementGrid.YStep)) - .ToArray(); - EnforceLinearSpacing(actualNodes, desiredY, nodeSpacing, placementGrid.YStep, horizontal: true); - for (var i = 0; i < actualNodes.Length; i++) - { - var current = positionedNodes[actualNodes[i].Id]; - positionedNodes[actualNodes[i].Id] = ElkLayoutHelpers.CreatePositionedNode( - actualNodes[i], - current.X, - desiredY[i], - direction); - } - } - - var minY = positionedNodes.Values.Min(node => node.Y); - if (minY < -0.01d) - { - var shift = SnapForwardToPlacementGrid(-minY, placementGrid.YStep); - foreach (var nodeId in positionedNodes.Keys.ToArray()) - { - var pos = positionedNodes[nodeId]; - positionedNodes[nodeId] = pos with { Y = pos.Y + shift }; - } - } - - return; - } - - foreach (var layer in layers) - { - var actualNodes = layer.Where(node => !dummyNodeIds.Contains(node.Id)).ToArray(); - if (actualNodes.Length == 0) - { - continue; - } - - var currentY = positionedNodes[actualNodes[0].Id].Y; - var snappedY = SnapToPlacementGrid(currentY, placementGrid.YStep); - var deltaY = snappedY - currentY; - if (Math.Abs(deltaY) > 0.01d) - { - foreach (var node in layer) - { - var pos = positionedNodes[node.Id]; - positionedNodes[node.Id] = ElkLayoutHelpers.CreatePositionedNode( - node, - pos.X, - pos.Y + deltaY, - direction); - } - } - - var desiredX = actualNodes - .Select(node => SnapToPlacementGrid(positionedNodes[node.Id].X, placementGrid.XStep)) - .ToArray(); - EnforceLinearSpacing(actualNodes, desiredX, nodeSpacing, placementGrid.XStep, horizontal: false); - for (var i = 0; i < actualNodes.Length; i++) - { - var current = positionedNodes[actualNodes[i].Id]; - positionedNodes[actualNodes[i].Id] = ElkLayoutHelpers.CreatePositionedNode( - actualNodes[i], - desiredX[i], - current.Y, - direction); - } - } - - var minX = positionedNodes.Values.Min(node => node.X); - if (minX < -0.01d) - { - var shift = SnapForwardToPlacementGrid(-minX, placementGrid.XStep); - foreach (var nodeId in positionedNodes.Keys.ToArray()) - { - var pos = positionedNodes[nodeId]; - positionedNodes[nodeId] = pos with { X = pos.X + shift }; - } - } - } - - internal static void EnforceLinearSpacing( - IReadOnlyList layer, - double[] desiredCoordinates, - double spacing, - double gridStep, - bool horizontal) - { - for (var index = 1; index < layer.Count; index++) - { - var extent = horizontal ? layer[index - 1].Height : layer[index - 1].Width; - desiredCoordinates[index] = Math.Max( - desiredCoordinates[index], - desiredCoordinates[index - 1] + extent + spacing); - desiredCoordinates[index] = SnapForwardToPlacementGrid(desiredCoordinates[index], gridStep); - } - - for (var index = layer.Count - 2; index >= 0; index--) - { - var extent = horizontal ? layer[index].Height : layer[index].Width; - desiredCoordinates[index] = Math.Min( - desiredCoordinates[index], - desiredCoordinates[index + 1] - extent - spacing); - desiredCoordinates[index] = SnapBackwardToPlacementGrid(desiredCoordinates[index], gridStep); - } - - for (var index = 1; index < layer.Count; index++) - { - var extent = horizontal ? layer[index - 1].Height : layer[index - 1].Width; - desiredCoordinates[index] = Math.Max( - desiredCoordinates[index], - desiredCoordinates[index - 1] + extent + spacing); - desiredCoordinates[index] = SnapForwardToPlacementGrid(desiredCoordinates[index], gridStep); - } - } - - internal static double SnapToPlacementGrid(double value, double gridStep) - { - if (gridStep <= 1d) - { - return value; - } - - return Math.Round(value / gridStep) * gridStep; - } - - internal static double SnapForwardToPlacementGrid(double value, double gridStep) - { - if (gridStep <= 1d) - { - return value; - } - - return Math.Ceiling(value / gridStep) * gridStep; - } - - internal static double SnapBackwardToPlacementGrid(double value, double gridStep) - { - if (gridStep <= 1d) - { - return value; - } - - return Math.Floor(value / gridStep) * gridStep; - } } diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkRepeatCollectorCorridors.Candidates.cs b/src/__Libraries/StellaOps.ElkSharp/ElkRepeatCollectorCorridors.Candidates.cs new file mode 100644 index 000000000..dfcc33208 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkRepeatCollectorCorridors.Candidates.cs @@ -0,0 +1,171 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkRepeatCollectorCorridors +{ + private static bool ConflictsOnOuterLane( + CollectorCandidate left, + CollectorCandidate right, + double minLineClearance) + { + if (!string.Equals(left.TargetNodeId, right.TargetNodeId, StringComparison.Ordinal) + || left.IsAbove != right.IsAbove) + { + return false; + } + + if (Math.Min(left.MaxX, right.MaxX) - Math.Max(left.MinX, right.MinX) <= 1d) + { + return false; + } + + return Math.Abs(left.CorridorY - right.CorridorY) < minLineClearance - CoordinateTolerance; + } + + private static CollectorCandidate? CreateCandidate( + ElkRoutedEdge edge, + double graphMinY, + double graphMaxY) + { + if (!ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label) + || !ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY) + || string.IsNullOrWhiteSpace(edge.TargetNodeId)) + { + return null; + } + + var path = ExtractPath(edge); + if (path.Count < 2) + { + return null; + } + + CollectorCandidate? best = null; + for (var i = 0; i < path.Count - 1; i++) + { + var start = path[i]; + var end = path[i + 1]; + if (Math.Abs(start.Y - end.Y) > CoordinateTolerance) + { + continue; + } + + var y = (start.Y + end.Y) / 2d; + var isAbove = y < graphMinY - 8d; + var isBelow = y > graphMaxY + 8d; + if (!isAbove && !isBelow) + { + continue; + } + + var length = Math.Abs(end.X - start.X); + if (length <= 1d) + { + continue; + } + + var candidate = new CollectorCandidate( + edge.Id, + edge.TargetNodeId!, + isAbove, + y, + Math.Min(start.X, end.X), + Math.Max(start.X, end.X), + path[0].X, + length); + if (best is null || candidate.Length > best.Value.Length) + { + best = candidate; + } + } + + return best; + } + + private static (double Top, double Bottom)[] BuildForbiddenCorridorBands( + IReadOnlyCollection nodes, + double spanMinX, + double spanMaxX, + double minLineClearance) + { + return nodes + .Where(node => node.Kind is not "Start" and not "End") + .Where(node => node.X + node.Width > spanMinX + CoordinateTolerance + && node.X < spanMaxX - CoordinateTolerance) + .Select(node => ( + Top: node.Y - minLineClearance, + Bottom: node.Y + node.Height + minLineClearance)) + .OrderBy(band => band.Top) + .ToArray(); + } + + private static double ResolveSafeCorridorY( + double candidateY, + bool isAbove, + double laneGap, + IReadOnlyList<(double Top, double Bottom)> forbiddenBands) + { + if (forbiddenBands.Count == 0) + { + return candidateY; + } + + while (true) + { + var shifted = false; + foreach (var band in forbiddenBands) + { + if (candidateY < band.Top - CoordinateTolerance + || candidateY > band.Bottom + CoordinateTolerance) + { + continue; + } + + candidateY = isAbove + ? band.Top - laneGap + : band.Bottom + laneGap; + shifted = true; + break; + } + + if (!shifted) + { + return candidateY; + } + } + } + + private static List ExtractPath(ElkRoutedEdge edge) + { + 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); + } + + return path; + } + + private readonly record struct CollectorCandidate( + string EdgeId, + string TargetNodeId, + bool IsAbove, + double CorridorY, + double MinX, + double MaxX, + double StartX, + double Length); + + private readonly record struct RepairMember( + int Index, + string EdgeId, + double CorridorY, + double StartX, + double MinX, + double MaxX); +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkRepeatCollectorCorridors.Rewrite.cs b/src/__Libraries/StellaOps.ElkSharp/ElkRepeatCollectorCorridors.Rewrite.cs new file mode 100644 index 000000000..1e732c9b8 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkRepeatCollectorCorridors.Rewrite.cs @@ -0,0 +1,47 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkRepeatCollectorCorridors +{ + private static ElkRoutedEdge RewriteOuterLane( + ElkRoutedEdge edge, + double currentY, + double assignedY, + double graphMinY, + double graphMaxY, + bool isAbove) + { + if (Math.Abs(currentY - assignedY) <= CoordinateTolerance) + { + return edge; + } + + bool ShouldShift(ElkPoint point) => + Math.Abs(point.Y - currentY) <= CoordinateTolerance + && (isAbove + ? point.Y < graphMinY - 8d + : point.Y > graphMaxY + 8d); + + ElkPoint Shift(ElkPoint point) => ShouldShift(point) + ? new ElkPoint { X = point.X, Y = assignedY } + : point; + + return new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + SourcePortId = edge.SourcePortId, + TargetPortId = edge.TargetPortId, + Kind = edge.Kind, + Label = edge.Label, + Sections = edge.Sections + .Select(section => new ElkEdgeSection + { + StartPoint = Shift(section.StartPoint), + EndPoint = Shift(section.EndPoint), + BendPoints = section.BendPoints.Select(Shift).ToArray(), + }) + .ToArray(), + }; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkRepeatCollectorCorridors.cs b/src/__Libraries/StellaOps.ElkSharp/ElkRepeatCollectorCorridors.cs index b2af5ae44..9f1aaa38e 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkRepeatCollectorCorridors.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkRepeatCollectorCorridors.cs @@ -11,7 +11,7 @@ internal sealed class ElkRepeatCollectorCorridorGroup public required double MaxX { get; init; } } -internal static class ElkRepeatCollectorCorridors +internal static partial class ElkRepeatCollectorCorridors { private const double CoordinateTolerance = 0.5d; @@ -203,214 +203,4 @@ internal static class ElkRepeatCollectorCorridors return result; } - - private static ElkRoutedEdge RewriteOuterLane( - ElkRoutedEdge edge, - double currentY, - double assignedY, - double graphMinY, - double graphMaxY, - bool isAbove) - { - if (Math.Abs(currentY - assignedY) <= CoordinateTolerance) - { - return edge; - } - - bool ShouldShift(ElkPoint point) => - Math.Abs(point.Y - currentY) <= CoordinateTolerance - && (isAbove - ? point.Y < graphMinY - 8d - : point.Y > graphMaxY + 8d); - - ElkPoint Shift(ElkPoint point) => ShouldShift(point) - ? new ElkPoint { X = point.X, Y = assignedY } - : point; - - return new ElkRoutedEdge - { - Id = edge.Id, - SourceNodeId = edge.SourceNodeId, - TargetNodeId = edge.TargetNodeId, - SourcePortId = edge.SourcePortId, - TargetPortId = edge.TargetPortId, - Kind = edge.Kind, - Label = edge.Label, - Sections = edge.Sections - .Select(section => new ElkEdgeSection - { - StartPoint = Shift(section.StartPoint), - EndPoint = Shift(section.EndPoint), - BendPoints = section.BendPoints.Select(Shift).ToArray(), - }) - .ToArray(), - }; - } - - private static bool ConflictsOnOuterLane( - CollectorCandidate left, - CollectorCandidate right, - double minLineClearance) - { - if (!string.Equals(left.TargetNodeId, right.TargetNodeId, StringComparison.Ordinal) - || left.IsAbove != right.IsAbove) - { - return false; - } - - if (Math.Min(left.MaxX, right.MaxX) - Math.Max(left.MinX, right.MinX) <= 1d) - { - return false; - } - - return Math.Abs(left.CorridorY - right.CorridorY) < minLineClearance - CoordinateTolerance; - } - - private static CollectorCandidate? CreateCandidate( - ElkRoutedEdge edge, - double graphMinY, - double graphMaxY) - { - if (!ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label) - || !ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY) - || string.IsNullOrWhiteSpace(edge.TargetNodeId)) - { - return null; - } - - var path = ExtractPath(edge); - if (path.Count < 2) - { - return null; - } - - CollectorCandidate? best = null; - for (var i = 0; i < path.Count - 1; i++) - { - var start = path[i]; - var end = path[i + 1]; - if (Math.Abs(start.Y - end.Y) > CoordinateTolerance) - { - continue; - } - - var y = (start.Y + end.Y) / 2d; - var isAbove = y < graphMinY - 8d; - var isBelow = y > graphMaxY + 8d; - if (!isAbove && !isBelow) - { - continue; - } - - var length = Math.Abs(end.X - start.X); - if (length <= 1d) - { - continue; - } - - var candidate = new CollectorCandidate( - edge.Id, - edge.TargetNodeId!, - isAbove, - y, - Math.Min(start.X, end.X), - Math.Max(start.X, end.X), - path[0].X, - length); - if (best is null || candidate.Length > best.Value.Length) - { - best = candidate; - } - } - - return best; - } - - private static (double Top, double Bottom)[] BuildForbiddenCorridorBands( - IReadOnlyCollection nodes, - double spanMinX, - double spanMaxX, - double minLineClearance) - { - return nodes - .Where(node => node.Kind is not "Start" and not "End") - .Where(node => node.X + node.Width > spanMinX + CoordinateTolerance - && node.X < spanMaxX - CoordinateTolerance) - .Select(node => ( - Top: node.Y - minLineClearance, - Bottom: node.Y + node.Height + minLineClearance)) - .OrderBy(band => band.Top) - .ToArray(); - } - - private static double ResolveSafeCorridorY( - double candidateY, - bool isAbove, - double laneGap, - IReadOnlyList<(double Top, double Bottom)> forbiddenBands) - { - if (forbiddenBands.Count == 0) - { - return candidateY; - } - - while (true) - { - var shifted = false; - foreach (var band in forbiddenBands) - { - if (candidateY < band.Top - CoordinateTolerance - || candidateY > band.Bottom + CoordinateTolerance) - { - continue; - } - - candidateY = isAbove - ? band.Top - laneGap - : band.Bottom + laneGap; - shifted = true; - break; - } - - if (!shifted) - { - return candidateY; - } - } - } - - private static List ExtractPath(ElkRoutedEdge edge) - { - 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); - } - - return path; - } - - private readonly record struct CollectorCandidate( - string EdgeId, - string TargetNodeId, - bool IsAbove, - double CorridorY, - double MinX, - double MaxX, - double StartX, - double Length); - - private readonly record struct RepairMember( - int Index, - string EdgeId, - double CorridorY, - double StartX, - double MinX, - double MaxX); } diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.BoundarySlots.cs b/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.BoundarySlots.cs new file mode 100644 index 000000000..8914be651 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.BoundarySlots.cs @@ -0,0 +1,182 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkShapeBoundaries +{ + internal static bool TryProjectGatewayBoundarySlot( + ElkPositionedNode node, + string side, + double slotCoordinate, + out ElkPoint boundaryPoint) + { + boundaryPoint = default!; + if (!IsGatewayShape(node)) + { + return false; + } + + var candidates = new List(); + var polygon = BuildGatewayBoundaryPoints(node); + switch (side) + { + case "left": + case "right": + { + var y = Math.Max(node.Y + 4d, Math.Min(node.Y + node.Height - 4d, slotCoordinate)); + for (var index = 0; index < polygon.Count; index++) + { + var start = polygon[index]; + var end = polygon[(index + 1) % polygon.Count]; + AddGatewaySlotIntersections(candidates, TryIntersectHorizontalSlot(start, end, y)); + } + + if (candidates.Count == 0) + { + return false; + } + + boundaryPoint = side == "left" + ? candidates.OrderBy(point => point.X).ThenBy(point => point.Y).First() + : candidates.OrderByDescending(point => point.X).ThenBy(point => point.Y).First(); + boundaryPoint = PreferGatewayEdgeInteriorBoundary( + node, + boundaryPoint, + new ElkPoint + { + X = side == "left" ? node.X - 32d : node.X + node.Width + 32d, + Y = y, + }); + return true; + } + case "top": + case "bottom": + { + var x = Math.Max(node.X + 4d, Math.Min(node.X + node.Width - 4d, slotCoordinate)); + for (var index = 0; index < polygon.Count; index++) + { + var start = polygon[index]; + var end = polygon[(index + 1) % polygon.Count]; + AddGatewaySlotIntersections(candidates, TryIntersectVerticalSlot(start, end, x)); + } + + if (candidates.Count == 0) + { + return false; + } + + boundaryPoint = side == "top" + ? candidates.OrderBy(point => point.Y).ThenBy(point => point.X).First() + : candidates.OrderByDescending(point => point.Y).ThenBy(point => point.X).First(); + boundaryPoint = PreferGatewayEdgeInteriorBoundary( + node, + boundaryPoint, + new ElkPoint + { + X = x, + Y = side == "top" ? node.Y - 32d : node.Y + node.Height + 32d, + }); + return true; + } + default: + return false; + } + } + + private static void AddGatewaySlotIntersections( + ICollection candidates, + IEnumerable intersections) + { + foreach (var candidate in intersections) + { + if (candidates.Any(existing => + Math.Abs(existing.X - candidate.X) <= CoordinateTolerance + && Math.Abs(existing.Y - candidate.Y) <= CoordinateTolerance)) + { + continue; + } + + candidates.Add(candidate); + } + } + + private static IEnumerable TryIntersectHorizontalSlot( + ElkPoint start, + ElkPoint end, + double y) + { + if (Math.Abs(start.Y - end.Y) <= CoordinateTolerance) + { + if (Math.Abs(y - start.Y) > CoordinateTolerance) + { + yield break; + } + + yield return new ElkPoint { X = start.X, Y = y }; + if (Math.Abs(end.X - start.X) > CoordinateTolerance) + { + yield return new ElkPoint { X = end.X, Y = y }; + } + + yield break; + } + + var minY = Math.Min(start.Y, end.Y) - CoordinateTolerance; + var maxY = Math.Max(start.Y, end.Y) + CoordinateTolerance; + if (y < minY || y > maxY) + { + yield break; + } + + var t = (y - start.Y) / (end.Y - start.Y); + if (t < -CoordinateTolerance || t > 1d + CoordinateTolerance) + { + yield break; + } + + yield return new ElkPoint + { + X = start.X + ((end.X - start.X) * t), + Y = y, + }; + } + + private static IEnumerable TryIntersectVerticalSlot( + ElkPoint start, + ElkPoint end, + double x) + { + if (Math.Abs(start.X - end.X) <= CoordinateTolerance) + { + if (Math.Abs(x - start.X) > CoordinateTolerance) + { + yield break; + } + + yield return new ElkPoint { X = x, Y = start.Y }; + if (Math.Abs(end.Y - start.Y) > CoordinateTolerance) + { + yield return new ElkPoint { X = x, Y = end.Y }; + } + + yield break; + } + + var minX = Math.Min(start.X, end.X) - CoordinateTolerance; + var maxX = Math.Max(start.X, end.X) + CoordinateTolerance; + if (x < minX || x > maxX) + { + yield break; + } + + var t = (x - start.X) / (end.X - start.X); + if (t < -CoordinateTolerance || t > 1d + CoordinateTolerance) + { + yield break; + } + + yield return new ElkPoint + { + X = x, + Y = start.Y + ((end.Y - start.Y) * t), + }; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.Exterior.Helpers.cs b/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.Exterior.Helpers.cs new file mode 100644 index 000000000..96ce42b91 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.Exterior.Helpers.cs @@ -0,0 +1,131 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkShapeBoundaries +{ + private static double DistanceToSegment(ElkPoint point, ElkPoint start, ElkPoint end) + { + var deltaX = end.X - start.X; + var deltaY = end.Y - start.Y; + var lengthSquared = (deltaX * deltaX) + (deltaY * deltaY); + if (lengthSquared <= 0.001d) + { + return Math.Sqrt(((point.X - start.X) * (point.X - start.X)) + ((point.Y - start.Y) * (point.Y - start.Y))); + } + + var t = (((point.X - start.X) * deltaX) + ((point.Y - start.Y) * deltaY)) / lengthSquared; + t = Math.Max(0d, Math.Min(1d, t)); + var projectionX = start.X + (t * deltaX); + var projectionY = start.Y + (t * deltaY); + var distanceX = point.X - projectionX; + var distanceY = point.Y - projectionY; + return Math.Sqrt((distanceX * distanceX) + (distanceY * distanceY)); + } + + private static bool TryGetGatewayBoundaryFace( + ElkPositionedNode node, + ElkPoint boundaryPoint, + out ElkPoint faceStart, + out ElkPoint faceEnd) + { + faceStart = default!; + faceEnd = default!; + + var polygon = BuildGatewayBoundaryPoints(node); + var bestDistance = double.PositiveInfinity; + var bestIndex = -1; + for (var index = 0; index < polygon.Count; index++) + { + var start = polygon[index]; + var end = polygon[(index + 1) % polygon.Count]; + var distance = DistanceToSegment(boundaryPoint, start, end); + if (distance > 2d || distance >= bestDistance) + { + continue; + } + + bestDistance = distance; + bestIndex = index; + } + + if (bestIndex < 0) + { + return false; + } + + faceStart = polygon[bestIndex]; + faceEnd = polygon[(bestIndex + 1) % polygon.Count]; + return true; + } + + private static bool IsDisallowedGatewayVertex( + ElkPositionedNode node, + ElkPoint boundaryPoint) + { + return IsNearGatewayVertex(node, boundaryPoint, GatewayVertexTolerance) + && !IsAllowedGatewayTipVertex(node, boundaryPoint, GatewayVertexTolerance); + } + + private static (double X, double Y) BuildGatewayFaceNormal( + ElkPositionedNode node, + ElkPoint faceStart, + ElkPoint faceEnd, + ElkPoint boundaryPoint) + { + var deltaX = faceEnd.X - faceStart.X; + var deltaY = faceEnd.Y - faceStart.Y; + var length = Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY)); + if (length <= 0.001d) + { + return (0d, -1d); + } + + var normalAX = deltaY / length; + var normalAY = -deltaX / length; + var normalBX = -normalAX; + var normalBY = -normalAY; + var centerX = node.X + (node.Width / 2d); + var centerY = node.Y + (node.Height / 2d); + var centerToBoundaryX = boundaryPoint.X - centerX; + var centerToBoundaryY = boundaryPoint.Y - centerY; + var dotA = (normalAX * centerToBoundaryX) + (normalAY * centerToBoundaryY); + var dotB = (normalBX * centerToBoundaryX) + (normalBY * centerToBoundaryY); + return dotA >= dotB + ? (normalAX, normalAY) + : (normalBX, normalBY); + } + + private static double ComputeRayExitDistanceFromBoundingBox( + ElkPositionedNode node, + ElkPoint origin, + double directionX, + double directionY) + { + const double epsilon = 0.0001d; + var bestDistance = double.PositiveInfinity; + + if (directionX > epsilon) + { + bestDistance = Math.Min(bestDistance, (node.X + node.Width - origin.X) / directionX); + } + else if (directionX < -epsilon) + { + bestDistance = Math.Min(bestDistance, (node.X - origin.X) / directionX); + } + + if (directionY > epsilon) + { + bestDistance = Math.Min(bestDistance, (node.Y + node.Height - origin.Y) / directionY); + } + else if (directionY < -epsilon) + { + bestDistance = Math.Min(bestDistance, (node.Y - origin.Y) / directionY); + } + + if (double.IsInfinity(bestDistance) || bestDistance < 0d) + { + return 0d; + } + + return bestDistance; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.Exterior.cs b/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.Exterior.cs new file mode 100644 index 000000000..030b75868 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.Exterior.cs @@ -0,0 +1,185 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkShapeBoundaries +{ + internal static ElkPoint BuildGatewayExteriorApproachPoint( + ElkPositionedNode node, + ElkPoint boundaryPoint, + double padding = 8d) + { + if (!IsGatewayShape(node) + || !TryGetGatewayBoundaryFace(node, boundaryPoint, out var faceStart, out var faceEnd)) + { + return boundaryPoint; + } + + var (normalX, normalY) = BuildGatewayFaceNormal(node, faceStart, faceEnd, boundaryPoint); + var exitDistance = ComputeRayExitDistanceFromBoundingBox(node, boundaryPoint, normalX, normalY); + var offset = Math.Max(0.5d, exitDistance + padding); + return new ElkPoint + { + X = boundaryPoint.X + (normalX * offset), + Y = boundaryPoint.Y + (normalY * offset), + }; + } + + internal static ElkPoint BuildGatewayDirectionalExteriorPoint( + ElkPositionedNode node, + ElkPoint boundaryPoint, + ElkPoint referencePoint, + double padding = 8d) + { + if (!IsGatewayShape(node)) + { + return boundaryPoint; + } + + var candidates = new List + { + BuildGatewayExteriorApproachPoint(node, boundaryPoint, padding), + }; + + var horizontalDirection = Math.Sign(referencePoint.X - boundaryPoint.X); + if (horizontalDirection != 0d) + { + candidates.Add(new ElkPoint + { + X = horizontalDirection > 0d + ? node.X + node.Width + padding + : node.X - padding, + Y = boundaryPoint.Y, + }); + } + + var verticalDirection = Math.Sign(referencePoint.Y - boundaryPoint.Y); + if (verticalDirection != 0d) + { + candidates.Add(new ElkPoint + { + X = boundaryPoint.X, + Y = verticalDirection > 0d + ? node.Y + node.Height + padding + : node.Y - padding, + }); + } + + ElkPoint? bestCandidate = null; + var bestScore = double.PositiveInfinity; + foreach (var candidate in candidates) + { + if (IsInsideNodeBoundingBoxInterior(node, candidate) + || !HasValidGatewayBoundaryAngle(node, boundaryPoint, candidate)) + { + continue; + } + + var deltaX = candidate.X - boundaryPoint.X; + var deltaY = candidate.Y - boundaryPoint.Y; + var moveLength = Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY)); + var referenceDistance = Math.Abs(referencePoint.X - candidate.X) + Math.Abs(referencePoint.Y - candidate.Y); + var score = moveLength + (referenceDistance * 0.1d); + + if (Math.Abs(referencePoint.X - boundaryPoint.X) >= Math.Abs(referencePoint.Y - boundaryPoint.Y) * 1.2d) + { + if (Math.Sign(deltaX) != Math.Sign(referencePoint.X - boundaryPoint.X)) + { + score += 10_000d; + } + + score += Math.Abs(deltaY) * 0.35d; + } + else if (Math.Abs(referencePoint.Y - boundaryPoint.Y) >= Math.Abs(referencePoint.X - boundaryPoint.X) * 1.2d) + { + if (Math.Sign(deltaY) != Math.Sign(referencePoint.Y - boundaryPoint.Y)) + { + score += 10_000d; + } + + score += Math.Abs(deltaX) * 0.35d; + } + + if (score >= bestScore) + { + continue; + } + + bestScore = score; + bestCandidate = candidate; + } + + return bestCandidate ?? BuildGatewayExteriorApproachPoint(node, boundaryPoint, padding); + } + + internal static ElkPoint BuildPreferredGatewaySourceExteriorPoint( + ElkPositionedNode node, + ElkPoint boundaryPoint, + ElkPoint referencePoint, + double padding = 8d) + { + if (!IsGatewayShape(node)) + { + return boundaryPoint; + } + + var deltaX = referencePoint.X - boundaryPoint.X; + var deltaY = referencePoint.Y - boundaryPoint.Y; + if (node.Kind == "Decision" + && !IsNearGatewayVertex(node, boundaryPoint, 8d) + && TryGetGatewayBoundaryFace(node, boundaryPoint, out var faceStart, out var faceEnd)) + { + var faceDx = Math.Abs(faceEnd.X - faceStart.X); + var faceDy = Math.Abs(faceEnd.Y - faceStart.Y); + var hasMaterialHorizontal = Math.Abs(deltaX) >= 12d; + var hasMaterialVertical = Math.Abs(deltaY) >= 12d; + var prefersDiagonalStub = hasMaterialHorizontal + && hasMaterialVertical + && Math.Abs(Math.Abs(deltaX) - Math.Abs(deltaY)) <= Math.Max(18d, Math.Min(Math.Abs(deltaX), Math.Abs(deltaY)) * 0.75d); + if (faceDx >= 3d && faceDy >= 3d && prefersDiagonalStub) + { + var faceNormalCandidate = BuildGatewayExteriorApproachPoint(node, boundaryPoint, padding); + if (!IsInsideNodeBoundingBoxInterior(node, faceNormalCandidate) + && HasValidGatewayBoundaryAngle(node, boundaryPoint, faceNormalCandidate)) + { + return faceNormalCandidate; + } + } + } + + var dominantHorizontal = Math.Abs(deltaX) >= Math.Abs(deltaY) * 1.15d; + var dominantVertical = Math.Abs(deltaY) >= Math.Abs(deltaX) * 1.15d; + + if (dominantHorizontal && Math.Sign(deltaX) != 0) + { + var horizontalCandidate = new ElkPoint + { + X = deltaX > 0d + ? node.X + node.Width + padding + : node.X - padding, + Y = boundaryPoint.Y, + }; + if (!IsInsideNodeBoundingBoxInterior(node, horizontalCandidate) + && HasValidGatewayBoundaryAngle(node, boundaryPoint, horizontalCandidate)) + { + return horizontalCandidate; + } + } + + if (dominantVertical && Math.Sign(deltaY) != 0) + { + var verticalCandidate = new ElkPoint + { + X = boundaryPoint.X, + Y = deltaY > 0d + ? node.Y + node.Height + padding + : node.Y - padding, + }; + if (!IsInsideNodeBoundingBoxInterior(node, verticalCandidate) + && HasValidGatewayBoundaryAngle(node, boundaryPoint, verticalCandidate)) + { + return verticalCandidate; + } + } + + return BuildGatewayDirectionalExteriorPoint(node, boundaryPoint, referencePoint, padding); + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.GatewayInterior.cs b/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.GatewayInterior.cs new file mode 100644 index 000000000..7b87406f8 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.GatewayInterior.cs @@ -0,0 +1,300 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkShapeBoundaries +{ + internal static bool HasValidGatewayBoundaryAngle( + ElkPositionedNode node, + ElkPoint boundaryPoint, + ElkPoint adjacentPoint) + { + if (!IsGatewayShape(node)) + { + return false; + } + + var segDx = Math.Abs(boundaryPoint.X - adjacentPoint.X); + var segDy = Math.Abs(boundaryPoint.Y - adjacentPoint.Y); + if (segDx < 3d && segDy < 3d) + { + return true; + } + + if (!IsPointOnGatewayBoundary(node, boundaryPoint, 2d)) + { + return false; + } + + if (IsInsideNodeShapeInterior(node, adjacentPoint)) + { + return false; + } + + if (IsDisallowedGatewayVertex(node, boundaryPoint)) + { + return false; + } + + if (IsAllowedGatewayTipVertex(node, boundaryPoint)) + { + return segDx > segDy * 3d; + } + + if (!TryGetGatewayBoundaryFace(node, boundaryPoint, out var faceStart, out var faceEnd)) + { + return false; + } + + var outwardVectorX = adjacentPoint.X - boundaryPoint.X; + var outwardVectorY = adjacentPoint.Y - boundaryPoint.Y; + var outwardLength = Math.Sqrt((outwardVectorX * outwardVectorX) + (outwardVectorY * outwardVectorY)); + if (outwardLength <= 0.001d) + { + return true; + } + + var (normalX, normalY) = BuildGatewayFaceNormal(node, faceStart, faceEnd, boundaryPoint); + var outwardDot = ((outwardVectorX / outwardLength) * normalX) + ((outwardVectorY / outwardLength) * normalY); + var faceDx = Math.Abs(faceEnd.X - faceStart.X); + var faceDy = Math.Abs(faceEnd.Y - faceStart.Y); + var faceIsDiagonal = faceDx >= 3d && faceDy >= 3d; + + if (faceIsDiagonal) + { + // Diamond-like faces can leave/arrive with a short 45-degree or orthogonal + // stub as long as that stub moves outward from the face and does not land on + // a corner vertex. + return outwardDot >= 0.55d; + } + + return (segDx < 3d || segDy < 3d) && outwardDot >= 0.85d; + } + + private static ElkPoint InterpolateAwayFromVertex( + ElkPoint vertexPoint, + ElkPoint adjacentVertex, + double? forcedOffset = null) + { + var deltaX = adjacentVertex.X - vertexPoint.X; + var deltaY = adjacentVertex.Y - vertexPoint.Y; + var length = Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY)); + if (length <= 0.001d) + { + return vertexPoint; + } + + var offset = forcedOffset ?? Math.Min(18d, Math.Max(10d, length * 0.2d)); + offset = Math.Min(Math.Max(length - 0.5d, 0.5d), offset); + var scale = offset / length; + return new ElkPoint + { + X = vertexPoint.X + (deltaX * scale), + Y = vertexPoint.Y + (deltaY * scale), + }; + } + + private static bool IsPointOnGatewayBoundary(ElkPositionedNode node, ElkPoint point, double tolerance) + { + var polygon = BuildGatewayBoundaryPoints(node); + for (var index = 0; index < polygon.Count; index++) + { + var start = polygon[index]; + var end = polygon[(index + 1) % polygon.Count]; + if (DistanceToSegment(point, start, end) <= tolerance) + { + return true; + } + } + + return false; + } + + internal static bool IsNearGatewayVertex(ElkPositionedNode node, ElkPoint boundaryPoint, double tolerance = GatewayVertexTolerance) + { + foreach (var vertex in BuildGatewayBoundaryPoints(node)) + { + if (Math.Abs(vertex.X - boundaryPoint.X) <= tolerance + && Math.Abs(vertex.Y - boundaryPoint.Y) <= tolerance) + { + return true; + } + } + + return false; + } + + internal static bool IsAllowedGatewayTipVertex( + ElkPositionedNode node, + ElkPoint boundaryPoint, + double tolerance = GatewayVertexTolerance) + { + // Gateway tips read as visually detached "pin" exits/entries in the renderer. + // Keep all gateway joins on a face interior instead of permitting any tip vertex. + return false; + } + + internal static bool IsInsideNodeBoundingBoxInterior( + ElkPositionedNode node, + ElkPoint point, + double tolerance = CoordinateTolerance) + { + return point.X > node.X + tolerance + && point.X < node.X + node.Width - tolerance + && point.Y > node.Y + tolerance + && point.Y < node.Y + node.Height - tolerance; + } + + internal static bool IsInsideNodeShapeInterior( + ElkPositionedNode node, + ElkPoint point, + double tolerance = CoordinateTolerance) + { + if (!IsGatewayShape(node)) + { + return IsInsideNodeBoundingBoxInterior(node, point, tolerance); + } + + if (!IsInsideNodeBoundingBoxInterior(node, point, tolerance)) + { + return false; + } + + if (IsPointOnGatewayBoundary(node, point, Math.Max(2d, tolerance * 2d))) + { + return false; + } + + var polygon = BuildGatewayBoundaryPoints(node); + bool? hasPositiveSign = null; + for (var index = 0; index < polygon.Count; index++) + { + var start = polygon[index]; + var end = polygon[(index + 1) % polygon.Count]; + var cross = Cross(end.X - start.X, end.Y - start.Y, point.X - start.X, point.Y - start.Y); + if (Math.Abs(cross) <= tolerance) + { + continue; + } + + var isPositive = cross > 0d; + if (!hasPositiveSign.HasValue) + { + hasPositiveSign = isPositive; + continue; + } + + if (hasPositiveSign.Value != isPositive) + { + return false; + } + } + + return hasPositiveSign.HasValue; + } + + internal static ElkPoint PreferGatewayEdgeInteriorBoundary( + ElkPositionedNode node, + ElkPoint boundaryPoint, + ElkPoint anchor) + { + if (!IsGatewayShape(node) || !IsNearGatewayVertex(node, boundaryPoint)) + { + return boundaryPoint; + } + + if (IsAllowedGatewayTipVertex(node, boundaryPoint)) + { + return boundaryPoint; + } + + var polygon = BuildGatewayBoundaryPoints(node); + var nearestVertexIndex = -1; + var nearestVertexDistance = double.PositiveInfinity; + for (var index = 0; index < polygon.Count; index++) + { + var vertex = polygon[index]; + var deltaX = boundaryPoint.X - vertex.X; + var deltaY = boundaryPoint.Y - vertex.Y; + var distance = Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY)); + if (distance >= nearestVertexDistance) + { + continue; + } + + nearestVertexDistance = distance; + nearestVertexIndex = index; + } + + if (nearestVertexIndex < 0) + { + return boundaryPoint; + } + + var vertexPoint = polygon[nearestVertexIndex]; + var previousVertex = polygon[(nearestVertexIndex - 1 + polygon.Count) % polygon.Count]; + var nextVertex = polygon[(nearestVertexIndex + 1) % polygon.Count]; + var projectedAnchor = ProjectOntoShapeBoundary(node, anchor); + var candidates = new[] + { + InterpolateAwayFromVertex(vertexPoint, previousVertex), + InterpolateAwayFromVertex(vertexPoint, nextVertex), + }; + + var bestCandidate = boundaryPoint; + var bestScore = double.PositiveInfinity; + foreach (var candidate in candidates) + { + if (!IsPointOnGatewayBoundary(node, candidate, 2d)) + { + continue; + } + + var score = ScoreGatewayBoundaryCandidate(node, anchor, projectedAnchor, candidate); + if (score >= bestScore) + { + continue; + } + + bestScore = score; + bestCandidate = candidate; + } + + if (IsNearGatewayVertex(node, bestCandidate)) + { + var forcedOffset = node.Kind == "Decision" + ? 18d + : 14d; + var forcedCandidates = new[] + { + InterpolateAwayFromVertex(vertexPoint, previousVertex, forcedOffset), + InterpolateAwayFromVertex(vertexPoint, nextVertex, forcedOffset), + }; + + foreach (var candidate in forcedCandidates) + { + if (!IsPointOnGatewayBoundary(node, candidate, 2.5d)) + { + continue; + } + + var score = ScoreGatewayBoundaryCandidate(node, anchor, projectedAnchor, candidate); + if (score >= bestScore) + { + continue; + } + + bestScore = score; + bestCandidate = candidate; + } + } + + return bestCandidate; + } + + internal static bool IsGatewayBoundaryPoint( + ElkPositionedNode node, + ElkPoint point, + double tolerance = 2d) + { + return IsGatewayShape(node) && IsPointOnGatewayBoundary(node, point, tolerance); + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.Intersections.cs b/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.Intersections.cs new file mode 100644 index 000000000..124d1f315 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.Intersections.cs @@ -0,0 +1,243 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkShapeBoundaries +{ + internal static bool TryProjectGatewayDiagonalBoundary( + ElkPositionedNode node, + ElkPoint anchor, + ElkPoint fallbackBoundary, + out ElkPoint boundaryPoint) + { + boundaryPoint = default!; + if (!IsGatewayShape(node)) + { + return false; + } + + var candidates = new List(); + var projectedAnchor = ProjectOntoShapeBoundary(node, anchor); + AddGatewayCandidate(node, candidates, projectedAnchor); + AddGatewayCandidate(node, candidates, fallbackBoundary); + AddGatewayCandidate(node, candidates, ProjectOntoShapeBoundary(node, fallbackBoundary)); + + foreach (var vertex in BuildGatewayBoundaryPoints(node)) + { + AddGatewayCandidate(node, candidates, vertex); + } + + var centerX = node.X + (node.Width / 2d); + var centerY = node.Y + (node.Height / 2d); + var directionX = Math.Sign(centerX - anchor.X); + var directionY = Math.Sign(centerY - anchor.Y); + var diagonalDirections = new HashSet<(int X, int Y)>(); + if (directionX != 0 && directionY != 0) + { + diagonalDirections.Add((directionX, directionY)); + } + + var fallbackDirectionX = Math.Sign(fallbackBoundary.X - anchor.X); + var fallbackDirectionY = Math.Sign(fallbackBoundary.Y - anchor.Y); + if (fallbackDirectionX != 0 && fallbackDirectionY != 0) + { + diagonalDirections.Add((fallbackDirectionX, fallbackDirectionY)); + } + + foreach (var diagonalDirection in diagonalDirections) + { + if (TryIntersectGatewayRay( + node, + anchor.X, + anchor.Y, + diagonalDirection.X, + diagonalDirection.Y, + out var rayBoundary)) + { + AddGatewayCandidate(node, candidates, rayBoundary); + } + } + + var bestCandidate = default(ElkPoint?); + var bestScore = double.PositiveInfinity; + foreach (var candidate in candidates) + { + var score = ScoreGatewayBoundaryCandidate(node, anchor, projectedAnchor, candidate); + if (score >= bestScore) + { + continue; + } + + bestScore = score; + bestCandidate = candidate; + } + + if (bestCandidate is null) + { + return false; + } + + boundaryPoint = PreferGatewayEdgeInteriorBoundary(node, bestCandidate, anchor); + return true; + } + + internal static ElkPoint IntersectPolygonBoundary( + double originX, + double originY, + double deltaX, + double deltaY, + IReadOnlyList polygon) + { + var bestScale = double.PositiveInfinity; + ElkPoint? bestPoint = null; + for (var index = 0; index < polygon.Count; index++) + { + var start = polygon[index]; + var end = polygon[(index + 1) % polygon.Count]; + if (!TryIntersectRayWithSegment(originX, originY, deltaX, deltaY, start, end, out var scale, out var point)) + { + continue; + } + + if (scale < bestScale) + { + bestScale = scale; + bestPoint = point; + } + } + + return bestPoint ?? new ElkPoint + { + X = originX + deltaX, + Y = originY + deltaY, + }; + } + + internal static bool TryIntersectRayWithSegment( + double originX, + double originY, + double deltaX, + double deltaY, + ElkPoint segmentStart, + ElkPoint segmentEnd, + out double scale, + out ElkPoint point) + { + scale = double.PositiveInfinity; + point = default!; + + var segmentDeltaX = segmentEnd.X - segmentStart.X; + var segmentDeltaY = segmentEnd.Y - segmentStart.Y; + var denominator = Cross(deltaX, deltaY, segmentDeltaX, segmentDeltaY); + if (Math.Abs(denominator) <= 0.001d) + { + return false; + } + + var relativeX = segmentStart.X - originX; + var relativeY = segmentStart.Y - originY; + var rayScale = Cross(relativeX, relativeY, segmentDeltaX, segmentDeltaY) / denominator; + var segmentScale = Cross(relativeX, relativeY, deltaX, deltaY) / denominator; + if (rayScale < 0d || segmentScale < 0d || segmentScale > 1d) + { + return false; + } + + scale = rayScale; + point = new ElkPoint + { + X = originX + (deltaX * rayScale), + Y = originY + (deltaY * rayScale), + }; + return true; + } + + internal static double Cross(double ax, double ay, double bx, double by) + { + return (ax * by) - (ay * bx); + } + + private static bool TryIntersectGatewayRay( + ElkPositionedNode node, + double originX, + double originY, + double deltaX, + double deltaY, + out ElkPoint boundaryPoint) + { + var polygon = BuildGatewayBoundaryPoints(node); + var bestScale = double.PositiveInfinity; + ElkPoint? bestPoint = null; + for (var index = 0; index < polygon.Count; index++) + { + var start = polygon[index]; + var end = polygon[(index + 1) % polygon.Count]; + if (!TryIntersectRayWithSegment(originX, originY, deltaX, deltaY, start, end, out var scale, out var point)) + { + continue; + } + + if (scale < bestScale) + { + bestScale = scale; + bestPoint = point; + } + } + + boundaryPoint = bestPoint ?? default!; + return bestPoint is not null; + } + + private static void AddGatewayCandidate( + ElkPositionedNode node, + ICollection candidates, + ElkPoint candidate) + { + if (!IsPointOnGatewayBoundary(node, candidate, 2d)) + { + return; + } + + if (candidates.Any(existing => + Math.Abs(existing.X - candidate.X) <= CoordinateTolerance + && Math.Abs(existing.Y - candidate.Y) <= CoordinateTolerance)) + { + return; + } + + candidates.Add(candidate); + } + + private static double ScoreGatewayBoundaryCandidate( + ElkPositionedNode node, + ElkPoint anchor, + ElkPoint projectedAnchor, + ElkPoint candidate) + { + var towardCenterX = (node.X + (node.Width / 2d)) - anchor.X; + var towardCenterY = (node.Y + (node.Height / 2d)) - anchor.Y; + var candidateDeltaX = candidate.X - anchor.X; + var candidateDeltaY = candidate.Y - anchor.Y; + var towardDot = (candidateDeltaX * towardCenterX) + (candidateDeltaY * towardCenterY); + if (towardDot <= 0d) + { + return double.PositiveInfinity; + } + + var absDx = Math.Abs(candidateDeltaX); + var absDy = Math.Abs(candidateDeltaY); + var isDiagonal = absDx >= 3d && absDy >= 3d; + var diagonalPenalty = isDiagonal + ? Math.Abs(absDx - absDy) + : 10_000d; + var projectedDistance = Math.Abs(candidate.X - projectedAnchor.X) + Math.Abs(candidate.Y - projectedAnchor.Y); + var segmentLength = Math.Sqrt((candidateDeltaX * candidateDeltaX) + (candidateDeltaY * candidateDeltaY)); + var candidateNearVertex = IsNearGatewayVertex(node, candidate, GatewayVertexTolerance); + var projectedNearVertex = IsNearGatewayVertex(node, projectedAnchor, GatewayVertexTolerance); + var vertexPenalty = candidateNearVertex + ? projectedNearVertex + ? 4d + : 24d + : 0d; + + return diagonalPenalty + (segmentLength * 0.05d) + (projectedDistance * 0.1d) + vertexPenalty; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.cs b/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.cs index d43716608..aed1f2d12 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.cs @@ -1,6 +1,6 @@ namespace StellaOps.ElkSharp; -internal static class ElkShapeBoundaries +internal static partial class ElkShapeBoundaries { private const double CoordinateTolerance = 0.5d; private const double GatewayVertexTolerance = 3d; @@ -24,150 +24,6 @@ internal static class ElkShapeBoundaries return ProjectOntoRectBoundary(node, toward); } - internal static bool TryProjectGatewayDiagonalBoundary( - ElkPositionedNode node, - ElkPoint anchor, - ElkPoint fallbackBoundary, - out ElkPoint boundaryPoint) - { - boundaryPoint = default!; - if (!IsGatewayShape(node)) - { - return false; - } - - var candidates = new List(); - var projectedAnchor = ProjectOntoShapeBoundary(node, anchor); - AddGatewayCandidate(node, candidates, projectedAnchor); - AddGatewayCandidate(node, candidates, fallbackBoundary); - AddGatewayCandidate(node, candidates, ProjectOntoShapeBoundary(node, fallbackBoundary)); - - foreach (var vertex in BuildGatewayBoundaryPoints(node)) - { - AddGatewayCandidate(node, candidates, vertex); - } - - var centerX = node.X + (node.Width / 2d); - var centerY = node.Y + (node.Height / 2d); - var directionX = Math.Sign(centerX - anchor.X); - var directionY = Math.Sign(centerY - anchor.Y); - var diagonalDirections = new HashSet<(int X, int Y)>(); - if (directionX != 0 && directionY != 0) - { - diagonalDirections.Add((directionX, directionY)); - } - - var fallbackDirectionX = Math.Sign(fallbackBoundary.X - anchor.X); - var fallbackDirectionY = Math.Sign(fallbackBoundary.Y - anchor.Y); - if (fallbackDirectionX != 0 && fallbackDirectionY != 0) - { - diagonalDirections.Add((fallbackDirectionX, fallbackDirectionY)); - } - - foreach (var diagonalDirection in diagonalDirections) - { - if (TryIntersectGatewayRay( - node, - anchor.X, - anchor.Y, - diagonalDirection.X, - diagonalDirection.Y, - out var rayBoundary)) - { - AddGatewayCandidate(node, candidates, rayBoundary); - } - } - - var bestCandidate = default(ElkPoint?); - var bestScore = double.PositiveInfinity; - foreach (var candidate in candidates) - { - var score = ScoreGatewayBoundaryCandidate(node, anchor, projectedAnchor, candidate); - if (score >= bestScore) - { - continue; - } - - bestScore = score; - bestCandidate = candidate; - } - - if (bestCandidate is null) - { - return false; - } - - boundaryPoint = PreferGatewayEdgeInteriorBoundary(node, bestCandidate, anchor); - return true; - } - - internal static bool HasValidGatewayBoundaryAngle( - ElkPositionedNode node, - ElkPoint boundaryPoint, - ElkPoint adjacentPoint) - { - if (!IsGatewayShape(node)) - { - return false; - } - - var segDx = Math.Abs(boundaryPoint.X - adjacentPoint.X); - var segDy = Math.Abs(boundaryPoint.Y - adjacentPoint.Y); - if (segDx < 3d && segDy < 3d) - { - return true; - } - - if (!IsPointOnGatewayBoundary(node, boundaryPoint, 2d)) - { - return false; - } - - if (IsInsideNodeShapeInterior(node, adjacentPoint)) - { - return false; - } - - if (IsDisallowedGatewayVertex(node, boundaryPoint)) - { - return false; - } - - if (IsAllowedGatewayTipVertex(node, boundaryPoint)) - { - return segDx > segDy * 3d; - } - - if (!TryGetGatewayBoundaryFace(node, boundaryPoint, out var faceStart, out var faceEnd)) - { - return false; - } - - var outwardVectorX = adjacentPoint.X - boundaryPoint.X; - var outwardVectorY = adjacentPoint.Y - boundaryPoint.Y; - var outwardLength = Math.Sqrt((outwardVectorX * outwardVectorX) + (outwardVectorY * outwardVectorY)); - if (outwardLength <= 0.001d) - { - return true; - } - - var (normalX, normalY) = BuildGatewayFaceNormal(node, faceStart, faceEnd, boundaryPoint); - var outwardDot = ((outwardVectorX / outwardLength) * normalX) + ((outwardVectorY / outwardLength) * normalY); - var faceDx = Math.Abs(faceEnd.X - faceStart.X); - var faceDy = Math.Abs(faceEnd.Y - faceStart.Y); - var faceIsDiagonal = faceDx >= 3d && faceDy >= 3d; - - if (faceIsDiagonal) - { - // Diamond-like faces can leave/arrive with a short 45-degree or orthogonal - // stub as long as that stub moves outward from the face and does not land on - // a corner vertex. - return outwardDot >= 0.55d; - } - - return (segDx < 3d || segDy < 3d) && outwardDot >= 0.85d; - } - internal static ElkPoint ProjectOntoRectBoundary(ElkPositionedNode node, ElkPoint toward) { var cx = node.X + node.Width / 2d; @@ -280,881 +136,4 @@ internal static class ElkShapeBoundaries new ElkPoint { X = node.X, Y = node.Y + (node.Height / 2d) }, ]; } - - internal static ElkPoint IntersectPolygonBoundary( - double originX, - double originY, - double deltaX, - double deltaY, - IReadOnlyList polygon) - { - var bestScale = double.PositiveInfinity; - ElkPoint? bestPoint = null; - for (var index = 0; index < polygon.Count; index++) - { - var start = polygon[index]; - var end = polygon[(index + 1) % polygon.Count]; - if (!TryIntersectRayWithSegment(originX, originY, deltaX, deltaY, start, end, out var scale, out var point)) - { - continue; - } - - if (scale < bestScale) - { - bestScale = scale; - bestPoint = point; - } - } - - return bestPoint ?? new ElkPoint - { - X = originX + deltaX, - Y = originY + deltaY, - }; - } - - internal static bool TryIntersectRayWithSegment( - double originX, - double originY, - double deltaX, - double deltaY, - ElkPoint segmentStart, - ElkPoint segmentEnd, - out double scale, - out ElkPoint point) - { - scale = double.PositiveInfinity; - point = default!; - - var segmentDeltaX = segmentEnd.X - segmentStart.X; - var segmentDeltaY = segmentEnd.Y - segmentStart.Y; - var denominator = Cross(deltaX, deltaY, segmentDeltaX, segmentDeltaY); - if (Math.Abs(denominator) <= 0.001d) - { - return false; - } - - var relativeX = segmentStart.X - originX; - var relativeY = segmentStart.Y - originY; - var rayScale = Cross(relativeX, relativeY, segmentDeltaX, segmentDeltaY) / denominator; - var segmentScale = Cross(relativeX, relativeY, deltaX, deltaY) / denominator; - if (rayScale < 0d || segmentScale < 0d || segmentScale > 1d) - { - return false; - } - - scale = rayScale; - point = new ElkPoint - { - X = originX + (deltaX * rayScale), - Y = originY + (deltaY * rayScale), - }; - return true; - } - - internal static double Cross(double ax, double ay, double bx, double by) - { - return (ax * by) - (ay * bx); - } - - private static bool TryIntersectGatewayRay( - ElkPositionedNode node, - double originX, - double originY, - double deltaX, - double deltaY, - out ElkPoint boundaryPoint) - { - var polygon = BuildGatewayBoundaryPoints(node); - var bestScale = double.PositiveInfinity; - ElkPoint? bestPoint = null; - for (var index = 0; index < polygon.Count; index++) - { - var start = polygon[index]; - var end = polygon[(index + 1) % polygon.Count]; - if (!TryIntersectRayWithSegment(originX, originY, deltaX, deltaY, start, end, out var scale, out var point)) - { - continue; - } - - if (scale < bestScale) - { - bestScale = scale; - bestPoint = point; - } - } - - boundaryPoint = bestPoint ?? default!; - return bestPoint is not null; - } - - private static void AddGatewayCandidate( - ElkPositionedNode node, - ICollection candidates, - ElkPoint candidate) - { - if (!IsPointOnGatewayBoundary(node, candidate, 2d)) - { - return; - } - - if (candidates.Any(existing => - Math.Abs(existing.X - candidate.X) <= CoordinateTolerance - && Math.Abs(existing.Y - candidate.Y) <= CoordinateTolerance)) - { - return; - } - - candidates.Add(candidate); - } - - private static double ScoreGatewayBoundaryCandidate( - ElkPositionedNode node, - ElkPoint anchor, - ElkPoint projectedAnchor, - ElkPoint candidate) - { - var towardCenterX = (node.X + (node.Width / 2d)) - anchor.X; - var towardCenterY = (node.Y + (node.Height / 2d)) - anchor.Y; - var candidateDeltaX = candidate.X - anchor.X; - var candidateDeltaY = candidate.Y - anchor.Y; - var towardDot = (candidateDeltaX * towardCenterX) + (candidateDeltaY * towardCenterY); - if (towardDot <= 0d) - { - return double.PositiveInfinity; - } - - var absDx = Math.Abs(candidateDeltaX); - var absDy = Math.Abs(candidateDeltaY); - var isDiagonal = absDx >= 3d && absDy >= 3d; - var diagonalPenalty = isDiagonal - ? Math.Abs(absDx - absDy) - : 10_000d; - var projectedDistance = Math.Abs(candidate.X - projectedAnchor.X) + Math.Abs(candidate.Y - projectedAnchor.Y); - var segmentLength = Math.Sqrt((candidateDeltaX * candidateDeltaX) + (candidateDeltaY * candidateDeltaY)); - var candidateNearVertex = IsNearGatewayVertex(node, candidate, GatewayVertexTolerance); - var projectedNearVertex = IsNearGatewayVertex(node, projectedAnchor, GatewayVertexTolerance); - var vertexPenalty = candidateNearVertex - ? projectedNearVertex - ? 4d - : 24d - : 0d; - - return diagonalPenalty + (segmentLength * 0.05d) + (projectedDistance * 0.1d) + vertexPenalty; - } - - private static ElkPoint InterpolateAwayFromVertex( - ElkPoint vertexPoint, - ElkPoint adjacentVertex, - double? forcedOffset = null) - { - var deltaX = adjacentVertex.X - vertexPoint.X; - var deltaY = adjacentVertex.Y - vertexPoint.Y; - var length = Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY)); - if (length <= 0.001d) - { - return vertexPoint; - } - - var offset = forcedOffset ?? Math.Min(18d, Math.Max(10d, length * 0.2d)); - offset = Math.Min(Math.Max(length - 0.5d, 0.5d), offset); - var scale = offset / length; - return new ElkPoint - { - X = vertexPoint.X + (deltaX * scale), - Y = vertexPoint.Y + (deltaY * scale), - }; - } - - private static bool IsPointOnGatewayBoundary(ElkPositionedNode node, ElkPoint point, double tolerance) - { - var polygon = BuildGatewayBoundaryPoints(node); - for (var index = 0; index < polygon.Count; index++) - { - var start = polygon[index]; - var end = polygon[(index + 1) % polygon.Count]; - if (DistanceToSegment(point, start, end) <= tolerance) - { - return true; - } - } - - return false; - } - - internal static bool IsNearGatewayVertex(ElkPositionedNode node, ElkPoint boundaryPoint, double tolerance = GatewayVertexTolerance) - { - foreach (var vertex in BuildGatewayBoundaryPoints(node)) - { - if (Math.Abs(vertex.X - boundaryPoint.X) <= tolerance - && Math.Abs(vertex.Y - boundaryPoint.Y) <= tolerance) - { - return true; - } - } - - return false; - } - - internal static bool IsAllowedGatewayTipVertex( - ElkPositionedNode node, - ElkPoint boundaryPoint, - double tolerance = GatewayVertexTolerance) - { - // Gateway tips read as visually detached "pin" exits/entries in the renderer. - // Keep all gateway joins on a face interior instead of permitting any tip vertex. - return false; - } - - internal static bool IsInsideNodeBoundingBoxInterior( - ElkPositionedNode node, - ElkPoint point, - double tolerance = CoordinateTolerance) - { - return point.X > node.X + tolerance - && point.X < node.X + node.Width - tolerance - && point.Y > node.Y + tolerance - && point.Y < node.Y + node.Height - tolerance; - } - - internal static bool IsInsideNodeShapeInterior( - ElkPositionedNode node, - ElkPoint point, - double tolerance = CoordinateTolerance) - { - if (!IsGatewayShape(node)) - { - return IsInsideNodeBoundingBoxInterior(node, point, tolerance); - } - - if (!IsInsideNodeBoundingBoxInterior(node, point, tolerance)) - { - return false; - } - - if (IsPointOnGatewayBoundary(node, point, Math.Max(2d, tolerance * 2d))) - { - return false; - } - - var polygon = BuildGatewayBoundaryPoints(node); - bool? hasPositiveSign = null; - for (var index = 0; index < polygon.Count; index++) - { - var start = polygon[index]; - var end = polygon[(index + 1) % polygon.Count]; - var cross = Cross(end.X - start.X, end.Y - start.Y, point.X - start.X, point.Y - start.Y); - if (Math.Abs(cross) <= tolerance) - { - continue; - } - - var isPositive = cross > 0d; - if (!hasPositiveSign.HasValue) - { - hasPositiveSign = isPositive; - continue; - } - - if (hasPositiveSign.Value != isPositive) - { - return false; - } - } - - return hasPositiveSign.HasValue; - } - - internal static ElkPoint PreferGatewayEdgeInteriorBoundary( - ElkPositionedNode node, - ElkPoint boundaryPoint, - ElkPoint anchor) - { - if (!IsGatewayShape(node) || !IsNearGatewayVertex(node, boundaryPoint)) - { - return boundaryPoint; - } - - if (IsAllowedGatewayTipVertex(node, boundaryPoint)) - { - return boundaryPoint; - } - - var polygon = BuildGatewayBoundaryPoints(node); - var nearestVertexIndex = -1; - var nearestVertexDistance = double.PositiveInfinity; - for (var index = 0; index < polygon.Count; index++) - { - var vertex = polygon[index]; - var deltaX = boundaryPoint.X - vertex.X; - var deltaY = boundaryPoint.Y - vertex.Y; - var distance = Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY)); - if (distance >= nearestVertexDistance) - { - continue; - } - - nearestVertexDistance = distance; - nearestVertexIndex = index; - } - - if (nearestVertexIndex < 0) - { - return boundaryPoint; - } - - var vertexPoint = polygon[nearestVertexIndex]; - var previousVertex = polygon[(nearestVertexIndex - 1 + polygon.Count) % polygon.Count]; - var nextVertex = polygon[(nearestVertexIndex + 1) % polygon.Count]; - var projectedAnchor = ProjectOntoShapeBoundary(node, anchor); - var candidates = new[] - { - InterpolateAwayFromVertex(vertexPoint, previousVertex), - InterpolateAwayFromVertex(vertexPoint, nextVertex), - }; - - var bestCandidate = boundaryPoint; - var bestScore = double.PositiveInfinity; - foreach (var candidate in candidates) - { - if (!IsPointOnGatewayBoundary(node, candidate, 2d)) - { - continue; - } - - var score = ScoreGatewayBoundaryCandidate(node, anchor, projectedAnchor, candidate); - if (score >= bestScore) - { - continue; - } - - bestScore = score; - bestCandidate = candidate; - } - - if (IsNearGatewayVertex(node, bestCandidate)) - { - var forcedOffset = node.Kind == "Decision" - ? 18d - : 14d; - var forcedCandidates = new[] - { - InterpolateAwayFromVertex(vertexPoint, previousVertex, forcedOffset), - InterpolateAwayFromVertex(vertexPoint, nextVertex, forcedOffset), - }; - - foreach (var candidate in forcedCandidates) - { - if (!IsPointOnGatewayBoundary(node, candidate, 2.5d)) - { - continue; - } - - var score = ScoreGatewayBoundaryCandidate(node, anchor, projectedAnchor, candidate); - if (score >= bestScore) - { - continue; - } - - bestScore = score; - bestCandidate = candidate; - } - } - - return bestCandidate; - } - - internal static bool IsGatewayBoundaryPoint( - ElkPositionedNode node, - ElkPoint point, - double tolerance = 2d) - { - return IsGatewayShape(node) && IsPointOnGatewayBoundary(node, point, tolerance); - } - - internal static bool TryProjectGatewayBoundarySlot( - ElkPositionedNode node, - string side, - double slotCoordinate, - out ElkPoint boundaryPoint) - { - boundaryPoint = default!; - if (!IsGatewayShape(node)) - { - return false; - } - - var candidates = new List(); - var polygon = BuildGatewayBoundaryPoints(node); - switch (side) - { - case "left": - case "right": - { - var y = Math.Max(node.Y + 4d, Math.Min(node.Y + node.Height - 4d, slotCoordinate)); - for (var index = 0; index < polygon.Count; index++) - { - var start = polygon[index]; - var end = polygon[(index + 1) % polygon.Count]; - AddGatewaySlotIntersections(candidates, TryIntersectHorizontalSlot(start, end, y)); - } - - if (candidates.Count == 0) - { - return false; - } - - boundaryPoint = side == "left" - ? candidates.OrderBy(point => point.X).ThenBy(point => point.Y).First() - : candidates.OrderByDescending(point => point.X).ThenBy(point => point.Y).First(); - boundaryPoint = PreferGatewayEdgeInteriorBoundary( - node, - boundaryPoint, - new ElkPoint - { - X = side == "left" ? node.X - 32d : node.X + node.Width + 32d, - Y = y, - }); - return true; - } - case "top": - case "bottom": - { - var x = Math.Max(node.X + 4d, Math.Min(node.X + node.Width - 4d, slotCoordinate)); - for (var index = 0; index < polygon.Count; index++) - { - var start = polygon[index]; - var end = polygon[(index + 1) % polygon.Count]; - AddGatewaySlotIntersections(candidates, TryIntersectVerticalSlot(start, end, x)); - } - - if (candidates.Count == 0) - { - return false; - } - - boundaryPoint = side == "top" - ? candidates.OrderBy(point => point.Y).ThenBy(point => point.X).First() - : candidates.OrderByDescending(point => point.Y).ThenBy(point => point.X).First(); - boundaryPoint = PreferGatewayEdgeInteriorBoundary( - node, - boundaryPoint, - new ElkPoint - { - X = x, - Y = side == "top" ? node.Y - 32d : node.Y + node.Height + 32d, - }); - return true; - } - default: - return false; - } - } - - internal static ElkPoint BuildGatewayExteriorApproachPoint( - ElkPositionedNode node, - ElkPoint boundaryPoint, - double padding = 8d) - { - if (!IsGatewayShape(node) - || !TryGetGatewayBoundaryFace(node, boundaryPoint, out var faceStart, out var faceEnd)) - { - return boundaryPoint; - } - - var (normalX, normalY) = BuildGatewayFaceNormal(node, faceStart, faceEnd, boundaryPoint); - var exitDistance = ComputeRayExitDistanceFromBoundingBox(node, boundaryPoint, normalX, normalY); - var offset = Math.Max(0.5d, exitDistance + padding); - return new ElkPoint - { - X = boundaryPoint.X + (normalX * offset), - Y = boundaryPoint.Y + (normalY * offset), - }; - } - - internal static ElkPoint BuildGatewayDirectionalExteriorPoint( - ElkPositionedNode node, - ElkPoint boundaryPoint, - ElkPoint referencePoint, - double padding = 8d) - { - if (!IsGatewayShape(node)) - { - return boundaryPoint; - } - - var candidates = new List - { - BuildGatewayExteriorApproachPoint(node, boundaryPoint, padding), - }; - - var horizontalDirection = Math.Sign(referencePoint.X - boundaryPoint.X); - if (horizontalDirection != 0d) - { - candidates.Add(new ElkPoint - { - X = horizontalDirection > 0d - ? node.X + node.Width + padding - : node.X - padding, - Y = boundaryPoint.Y, - }); - } - - var verticalDirection = Math.Sign(referencePoint.Y - boundaryPoint.Y); - if (verticalDirection != 0d) - { - candidates.Add(new ElkPoint - { - X = boundaryPoint.X, - Y = verticalDirection > 0d - ? node.Y + node.Height + padding - : node.Y - padding, - }); - } - - ElkPoint? bestCandidate = null; - var bestScore = double.PositiveInfinity; - foreach (var candidate in candidates) - { - if (IsInsideNodeBoundingBoxInterior(node, candidate) - || !HasValidGatewayBoundaryAngle(node, boundaryPoint, candidate)) - { - continue; - } - - var deltaX = candidate.X - boundaryPoint.X; - var deltaY = candidate.Y - boundaryPoint.Y; - var moveLength = Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY)); - var referenceDistance = Math.Abs(referencePoint.X - candidate.X) + Math.Abs(referencePoint.Y - candidate.Y); - var score = moveLength + (referenceDistance * 0.1d); - - if (Math.Abs(referencePoint.X - boundaryPoint.X) >= Math.Abs(referencePoint.Y - boundaryPoint.Y) * 1.2d) - { - if (Math.Sign(deltaX) != Math.Sign(referencePoint.X - boundaryPoint.X)) - { - score += 10_000d; - } - - score += Math.Abs(deltaY) * 0.35d; - } - else if (Math.Abs(referencePoint.Y - boundaryPoint.Y) >= Math.Abs(referencePoint.X - boundaryPoint.X) * 1.2d) - { - if (Math.Sign(deltaY) != Math.Sign(referencePoint.Y - boundaryPoint.Y)) - { - score += 10_000d; - } - - score += Math.Abs(deltaX) * 0.35d; - } - - if (score >= bestScore) - { - continue; - } - - bestScore = score; - bestCandidate = candidate; - } - - return bestCandidate ?? BuildGatewayExteriorApproachPoint(node, boundaryPoint, padding); - } - - internal static ElkPoint BuildPreferredGatewaySourceExteriorPoint( - ElkPositionedNode node, - ElkPoint boundaryPoint, - ElkPoint referencePoint, - double padding = 8d) - { - if (!IsGatewayShape(node)) - { - return boundaryPoint; - } - - var deltaX = referencePoint.X - boundaryPoint.X; - var deltaY = referencePoint.Y - boundaryPoint.Y; - if (node.Kind == "Decision" - && !IsNearGatewayVertex(node, boundaryPoint, 8d) - && TryGetGatewayBoundaryFace(node, boundaryPoint, out var faceStart, out var faceEnd)) - { - var faceDx = Math.Abs(faceEnd.X - faceStart.X); - var faceDy = Math.Abs(faceEnd.Y - faceStart.Y); - var hasMaterialHorizontal = Math.Abs(deltaX) >= 12d; - var hasMaterialVertical = Math.Abs(deltaY) >= 12d; - var prefersDiagonalStub = hasMaterialHorizontal - && hasMaterialVertical - && Math.Abs(Math.Abs(deltaX) - Math.Abs(deltaY)) <= Math.Max(18d, Math.Min(Math.Abs(deltaX), Math.Abs(deltaY)) * 0.75d); - if (faceDx >= 3d && faceDy >= 3d && prefersDiagonalStub) - { - var faceNormalCandidate = BuildGatewayExteriorApproachPoint(node, boundaryPoint, padding); - if (!IsInsideNodeBoundingBoxInterior(node, faceNormalCandidate) - && HasValidGatewayBoundaryAngle(node, boundaryPoint, faceNormalCandidate)) - { - return faceNormalCandidate; - } - } - } - - var dominantHorizontal = Math.Abs(deltaX) >= Math.Abs(deltaY) * 1.15d; - var dominantVertical = Math.Abs(deltaY) >= Math.Abs(deltaX) * 1.15d; - - if (dominantHorizontal && Math.Sign(deltaX) != 0) - { - var horizontalCandidate = new ElkPoint - { - X = deltaX > 0d - ? node.X + node.Width + padding - : node.X - padding, - Y = boundaryPoint.Y, - }; - if (!IsInsideNodeBoundingBoxInterior(node, horizontalCandidate) - && HasValidGatewayBoundaryAngle(node, boundaryPoint, horizontalCandidate)) - { - return horizontalCandidate; - } - } - - if (dominantVertical && Math.Sign(deltaY) != 0) - { - var verticalCandidate = new ElkPoint - { - X = boundaryPoint.X, - Y = deltaY > 0d - ? node.Y + node.Height + padding - : node.Y - padding, - }; - if (!IsInsideNodeBoundingBoxInterior(node, verticalCandidate) - && HasValidGatewayBoundaryAngle(node, boundaryPoint, verticalCandidate)) - { - return verticalCandidate; - } - } - - return BuildGatewayDirectionalExteriorPoint(node, boundaryPoint, referencePoint, padding); - } - - private static void AddGatewaySlotIntersections( - ICollection candidates, - IEnumerable intersections) - { - foreach (var candidate in intersections) - { - if (candidates.Any(existing => - Math.Abs(existing.X - candidate.X) <= CoordinateTolerance - && Math.Abs(existing.Y - candidate.Y) <= CoordinateTolerance)) - { - continue; - } - - candidates.Add(candidate); - } - } - - private static IEnumerable TryIntersectHorizontalSlot( - ElkPoint start, - ElkPoint end, - double y) - { - if (Math.Abs(start.Y - end.Y) <= CoordinateTolerance) - { - if (Math.Abs(y - start.Y) > CoordinateTolerance) - { - yield break; - } - - yield return new ElkPoint { X = start.X, Y = y }; - if (Math.Abs(end.X - start.X) > CoordinateTolerance) - { - yield return new ElkPoint { X = end.X, Y = y }; - } - - yield break; - } - - var minY = Math.Min(start.Y, end.Y) - CoordinateTolerance; - var maxY = Math.Max(start.Y, end.Y) + CoordinateTolerance; - if (y < minY || y > maxY) - { - yield break; - } - - var t = (y - start.Y) / (end.Y - start.Y); - if (t < -CoordinateTolerance || t > 1d + CoordinateTolerance) - { - yield break; - } - - yield return new ElkPoint - { - X = start.X + ((end.X - start.X) * t), - Y = y, - }; - } - - private static IEnumerable TryIntersectVerticalSlot( - ElkPoint start, - ElkPoint end, - double x) - { - if (Math.Abs(start.X - end.X) <= CoordinateTolerance) - { - if (Math.Abs(x - start.X) > CoordinateTolerance) - { - yield break; - } - - yield return new ElkPoint { X = x, Y = start.Y }; - if (Math.Abs(end.Y - start.Y) > CoordinateTolerance) - { - yield return new ElkPoint { X = x, Y = end.Y }; - } - - yield break; - } - - var minX = Math.Min(start.X, end.X) - CoordinateTolerance; - var maxX = Math.Max(start.X, end.X) + CoordinateTolerance; - if (x < minX || x > maxX) - { - yield break; - } - - var t = (x - start.X) / (end.X - start.X); - if (t < -CoordinateTolerance || t > 1d + CoordinateTolerance) - { - yield break; - } - - yield return new ElkPoint - { - X = x, - Y = start.Y + ((end.Y - start.Y) * t), - }; - } - - private static double DistanceToSegment(ElkPoint point, ElkPoint start, ElkPoint end) - { - var deltaX = end.X - start.X; - var deltaY = end.Y - start.Y; - var lengthSquared = (deltaX * deltaX) + (deltaY * deltaY); - if (lengthSquared <= 0.001d) - { - return Math.Sqrt(((point.X - start.X) * (point.X - start.X)) + ((point.Y - start.Y) * (point.Y - start.Y))); - } - - var t = (((point.X - start.X) * deltaX) + ((point.Y - start.Y) * deltaY)) / lengthSquared; - t = Math.Max(0d, Math.Min(1d, t)); - var projectionX = start.X + (t * deltaX); - var projectionY = start.Y + (t * deltaY); - var distanceX = point.X - projectionX; - var distanceY = point.Y - projectionY; - return Math.Sqrt((distanceX * distanceX) + (distanceY * distanceY)); - } - - private static bool TryGetGatewayBoundaryFace( - ElkPositionedNode node, - ElkPoint boundaryPoint, - out ElkPoint faceStart, - out ElkPoint faceEnd) - { - faceStart = default!; - faceEnd = default!; - - var polygon = BuildGatewayBoundaryPoints(node); - var bestDistance = double.PositiveInfinity; - var bestIndex = -1; - for (var index = 0; index < polygon.Count; index++) - { - var start = polygon[index]; - var end = polygon[(index + 1) % polygon.Count]; - var distance = DistanceToSegment(boundaryPoint, start, end); - if (distance > 2d || distance >= bestDistance) - { - continue; - } - - bestDistance = distance; - bestIndex = index; - } - - if (bestIndex < 0) - { - return false; - } - - faceStart = polygon[bestIndex]; - faceEnd = polygon[(bestIndex + 1) % polygon.Count]; - return true; - } - - private static bool IsDisallowedGatewayVertex( - ElkPositionedNode node, - ElkPoint boundaryPoint) - { - return IsNearGatewayVertex(node, boundaryPoint, GatewayVertexTolerance) - && !IsAllowedGatewayTipVertex(node, boundaryPoint, GatewayVertexTolerance); - } - - private static (double X, double Y) BuildGatewayFaceNormal( - ElkPositionedNode node, - ElkPoint faceStart, - ElkPoint faceEnd, - ElkPoint boundaryPoint) - { - var deltaX = faceEnd.X - faceStart.X; - var deltaY = faceEnd.Y - faceStart.Y; - var length = Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY)); - if (length <= 0.001d) - { - return (0d, -1d); - } - - var normalAX = deltaY / length; - var normalAY = -deltaX / length; - var normalBX = -normalAX; - var normalBY = -normalAY; - var centerX = node.X + (node.Width / 2d); - var centerY = node.Y + (node.Height / 2d); - var centerToBoundaryX = boundaryPoint.X - centerX; - var centerToBoundaryY = boundaryPoint.Y - centerY; - var dotA = (normalAX * centerToBoundaryX) + (normalAY * centerToBoundaryY); - var dotB = (normalBX * centerToBoundaryX) + (normalBY * centerToBoundaryY); - return dotA >= dotB - ? (normalAX, normalAY) - : (normalBX, normalBY); - } - - private static double ComputeRayExitDistanceFromBoundingBox( - ElkPositionedNode node, - ElkPoint origin, - double directionX, - double directionY) - { - const double epsilon = 0.0001d; - var bestDistance = double.PositiveInfinity; - - if (directionX > epsilon) - { - bestDistance = Math.Min(bestDistance, (node.X + node.Width - origin.X) / directionX); - } - else if (directionX < -epsilon) - { - bestDistance = Math.Min(bestDistance, (node.X - origin.X) / directionX); - } - - if (directionY > epsilon) - { - bestDistance = Math.Min(bestDistance, (node.Y + node.Height - origin.Y) / directionY); - } - else if (directionY < -epsilon) - { - bestDistance = Math.Min(bestDistance, (node.Y - origin.Y) / directionY); - } - - if (double.IsInfinity(bestDistance) || bestDistance < 0d) - { - return 0d; - } - - return bestDistance; - } }