From 71edccd4857510e27f57f612d16feb15feb641ee Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 24 Mar 2026 08:38:09 +0200 Subject: [PATCH] elksharp stabilization --- docs/workflow/ENGINE.md | 2 +- ...ocumentProcessingWorkflowRenderingTests.cs | 209 +- ...ElkSharpWorkflowRenderLayoutEngineTests.cs | 44 +- src/__Libraries/StellaOps.ElkSharp/AGENTS.md | 13 + .../ElkEdgePostProcessor.cs | 844 ++++++- .../ElkEdgePostProcessorSimplify.cs | 118 +- .../StellaOps.ElkSharp/ElkEdgeRouteRefiner.cs | 78 +- .../ElkEdgeRouterAStar8Dir.cs | 394 ++++ .../ElkEdgeRouterHighway.cs | 524 +++++ .../ElkEdgeRouterIterative.cs | 2067 +++++++++++++++++ .../ElkEdgeRoutingGeometry.cs | 117 + .../ElkEdgeRoutingScoring.cs | 942 ++++++++ .../ElkLayoutDiagnostics.cs | 208 ++ .../StellaOps.ElkSharp/ElkLayoutTypes.cs | 187 ++ .../StellaOps.ElkSharp/ElkModels.cs | 8 + .../ElkRepeatCollectorCorridors.cs | 347 +++ .../ElkSharpLayeredLayoutEngine.cs | 14 +- .../Properties/AssemblyInfo.cs | 3 + 18 files changed, 6083 insertions(+), 36 deletions(-) create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAStar8Dir.cs create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterHighway.cs create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.cs create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkLayoutDiagnostics.cs create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkRepeatCollectorCorridors.cs create mode 100644 src/__Libraries/StellaOps.ElkSharp/Properties/AssemblyInfo.cs diff --git a/docs/workflow/ENGINE.md b/docs/workflow/ENGINE.md index ded1a7a40..f5573c776 100644 --- a/docs/workflow/ENGINE.md +++ b/docs/workflow/ENGINE.md @@ -920,7 +920,7 @@ The engine can render workflow definitions as visual diagrams. | Engine | Description | |--------|-------------| -| **ElkSharp** | Port of Eclipse Layout Kernel (default). In `Best` effort mode it now runs a bounded deterministic orthogonal edge-refinement pass after base routing; `Draft` and `Balanced` keep the base route unless library callers opt in through `ElkLayoutOptions.EdgeRefinement`. | +| **ElkSharp** | Port of Eclipse Layout Kernel (default). In `Best` effort mode it runs a deterministic iterative multi-strategy orthogonal router after base routing, scoring candidate layouts across crossings, proximity, labels, target-approach joins, detours, target-approach backtracking, and entry geometry before selecting the best valid result. Attempt 1 remains the only full-strategy reroute; later attempts repair only the penalized lanes or edge clusters, with shortest-path detours prioritized first, a direct orthogonal shortcut tried before broader rerouting, and corridor-like overshoots only eligible when a clean orthogonal shortcut actually exists. Small or protected graphs keep the baseline route to preserve established sink-corridor, backward-edge, and port-anchor contracts, while larger congested graphs use the iterative sweep. Final strategy acceptance re-validates post-processed output so remaining broken short highways and non-applicable target-side approach joins are retried instead of being selected, while other soft-rule regressions get bounded multi-attempt retries and a wider but finite strategy sweep before fallback selection. A final cheap geometry-repair pass cleans node-side entry/exit angles, target-slot spacing, repeat-collector return lanes, and target-side backtracking without re-running whole-graph A*. The document-processing artifact test emits both a live progress log and per-attempt phase timings/route-pass counts alongside the SVG/PNG/JSON diagnostics so long-running strategy searches can be inspected while they are still running and profiled after completion. `Draft` and `Balanced` keep the base route unless library callers opt in through ElkSharp layout options. | | **ElkJS** | JavaScript-based ELK via Node.js | | **MSAGL** | Microsoft Automatic Graph Layout | diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.cs index c209fba07..78fb81283 100644 --- a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.cs +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using NUnit.Framework; +using StellaOps.ElkSharp; using StellaOps.Workflow.Abstractions; using StellaOps.Workflow.Renderer.ElkSharp; using StellaOps.Workflow.Renderer.Svg; @@ -101,8 +102,7 @@ public class DocumentProcessingWorkflowRenderingTests } [Test] - [Category("RenderingArtifacts")] - public async Task DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings() + public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotBacktrackIntoCheckResult() { var graph = BuildDocumentProcessingWorkflowGraph(); var engine = new ElkSharpWorkflowRenderLayoutEngine(); @@ -112,20 +112,53 @@ public class DocumentProcessingWorkflowRenderingTests Direction = WorkflowRenderLayoutDirection.LeftToRight, }); - var svgRenderer = new WorkflowRenderSvgRenderer(); - var svgDoc = svgRenderer.Render(layout, "DocumentProcessingWorkflow [ElkSharp]"); + var targetNode = layout.Nodes.Single(node => node.Id == "start/2/branch-1/1/body/5"); + var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/7"); + Assert.That( + HasTargetApproachBacktracking(edge, targetNode), + Is.False, + "Execute Batch -> Check Result must not overshoot the target side and curl back near the final approach."); + } + + [Test] + [Category("RenderingArtifacts")] + public async Task DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings() + { + var graph = BuildDocumentProcessingWorkflowGraph(); + var engine = new ElkSharpWorkflowRenderLayoutEngine(); var outputDir = Path.Combine( Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!, "TestResults", "workflow-renderings", DateTime.Today.ToString("yyyyMMdd"), "DocumentProcessingWorkflow"); Directory.CreateDirectory(outputDir); + using var diagnosticsCapture = ElkLayoutDiagnostics.BeginCapture(); + var progressLogPath = Path.Combine(outputDir, "elksharp.progress.log"); + if (File.Exists(progressLogPath)) + { + File.Delete(progressLogPath); + } + + diagnosticsCapture.Diagnostics.ProgressLogPath = progressLogPath; + var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + }); + + var svgRenderer = new WorkflowRenderSvgRenderer(); + var svgDoc = svgRenderer.Render(layout, "DocumentProcessingWorkflow [ElkSharp]"); + var svgPath = Path.Combine(outputDir, "elksharp.svg"); await File.WriteAllTextAsync(svgPath, svgDoc.Svg); var jsonPath = Path.Combine(outputDir, "elksharp.json"); await File.WriteAllTextAsync(jsonPath, JsonSerializer.Serialize(layout, new JsonSerializerOptions { WriteIndented = true })); + var diagnosticsPath = Path.Combine(outputDir, "elksharp.refinement-diagnostics.json"); + await File.WriteAllTextAsync( + diagnosticsPath, + JsonSerializer.Serialize(diagnosticsCapture.Diagnostics, new JsonSerializerOptions { WriteIndented = true })); + WorkflowRenderPngExporter? pngExporter = null; string? pngPath = null; try @@ -143,6 +176,88 @@ public class DocumentProcessingWorkflowRenderingTests TestContext.Out.WriteLine($"SVG: {svgPath}"); TestContext.Out.WriteLine($"JSON: {jsonPath}"); + TestContext.Out.WriteLine($"Diagnostics: {diagnosticsPath}"); + TestContext.Out.WriteLine($"Progress log: {progressLogPath}"); + + // Render every iteration of every strategy as SVG only + var variantsDir = Path.Combine(outputDir, "strategy-variants"); + Directory.CreateDirectory(variantsDir); + foreach (var stratDiag in diagnosticsCapture.Diagnostics.IterativeStrategies) + { + foreach (var attemptDiag in stratDiag.AttemptDetails) + { + if (attemptDiag.Edges is null) + { + continue; + } + + var attemptLayout = BuildVariantLayout(layout, attemptDiag.Edges); + var sc = attemptDiag.Score; + var attemptLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} att{attemptDiag.Attempt} [{attemptDiag.Outcome}] " + + $"nc={sc.NodeCrossings} ec={sc.EdgeCrossings} bends={sc.BendCount} diag={sc.DiagonalCount} " + + $"ea={sc.EntryAngleViolations} lbl={sc.LabelProximityViolations} tj={sc.TargetApproachJoinViolations} " + + $"tb={sc.TargetApproachBacktrackingViolations} det={sc.ExcessiveDetourViolations} score={sc.Value:F0}"; + var attemptSvg = svgRenderer.Render(attemptLayout, attemptLabel); + await File.WriteAllTextAsync( + Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-att{attemptDiag.Attempt:D2}.svg"), + attemptSvg.Svg); + } + + if (stratDiag.BestEdges is not null) + { + var bestLayout = BuildVariantLayout(layout, stratDiag.BestEdges); + var bestSc = stratDiag.BestScore; + var bestLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} BEST [{stratDiag.Outcome}] " + + $"nc={bestSc?.NodeCrossings} ec={bestSc?.EdgeCrossings} bends={bestSc?.BendCount} diag={bestSc?.DiagonalCount} " + + $"ea={bestSc?.EntryAngleViolations} lbl={bestSc?.LabelProximityViolations} tj={bestSc?.TargetApproachJoinViolations} " + + $"tb={bestSc?.TargetApproachBacktrackingViolations} det={bestSc?.ExcessiveDetourViolations} score={bestSc?.Value:F0}"; + var bestSvg = svgRenderer.Render(bestLayout, bestLabel); + await File.WriteAllTextAsync( + Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-BEST.svg"), + bestSvg.Svg); + } + } + + TestContext.Out.WriteLine($"Strategy variants: {variantsDir}"); + + var localRepairAttempts = diagnosticsCapture.Diagnostics.IterativeStrategies + .SelectMany(strategy => strategy.AttemptDetails) + .Where(attempt => attempt.Attempt > 1 + && string.Equals(attempt.RouteDiagnostics?.Mode, "local-repair", StringComparison.Ordinal)) + .ToArray(); + Assert.That(localRepairAttempts, Is.Not.Empty, "Expected later attempts to use targeted local repair."); + Assert.That( + localRepairAttempts.All(attempt => attempt.RouteDiagnostics!.RoutedEdges < attempt.RouteDiagnostics.TotalEdges), + Is.True, + "Local repair attempts must reroute only the penalized subset of edges."); + Assert.That(diagnosticsCapture.Diagnostics.FinalBrokenShortHighwayCount, Is.EqualTo(0), "Final selected layout must not keep broken short highways."); + Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.RepeatCollectorCorridorViolations, Is.EqualTo(0), "Repeat collector outer lanes must remain separated."); + Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.EntryAngleViolations, Is.EqualTo(0), "Selected layout must satisfy the node-side entry/exit angle rule."); + Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachJoinViolations, Is.EqualTo(0), "Selected layout must not keep disallowed target-side joins."); + Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachBacktrackingViolations, Is.EqualTo(0), "Selected layout must not overshoot a target side and curl back near the final approach."); + + static WorkflowRenderLayoutResult BuildVariantLayout(WorkflowRenderLayoutResult baseLayout, ElkRoutedEdge[] edges) + { + return new WorkflowRenderLayoutResult + { + GraphId = baseLayout.GraphId, + Nodes = baseLayout.Nodes, + Edges = edges.Select(e => new WorkflowRenderRoutedEdge + { + Id = e.Id, + SourceNodeId = e.SourceNodeId, + TargetNodeId = e.TargetNodeId, + Kind = e.Kind, + Label = e.Label, + Sections = e.Sections.Select(s => new WorkflowRenderEdgeSection + { + StartPoint = new WorkflowRenderPoint { X = s.StartPoint.X, Y = s.StartPoint.Y }, + EndPoint = new WorkflowRenderPoint { X = s.EndPoint.X, Y = s.EndPoint.Y }, + BendPoints = s.BendPoints.Select(p => new WorkflowRenderPoint { X = p.X, Y = p.Y }).ToArray(), + }).ToArray(), + }).ToArray(), + }; + } // Verify zero edge-node crossings var crossings = 0; @@ -178,4 +293,90 @@ public class DocumentProcessingWorkflowRenderingTests TestContext.Out.WriteLine($"Edge-node crossings: {crossings}"); Assert.That(crossings, Is.EqualTo(0), "No edges should cross through node shapes"); } + + private static bool HasTargetApproachBacktracking(WorkflowRenderRoutedEdge edge, WorkflowRenderPositionedNode targetNode) + { + 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 < 3) + { + return false; + } + + var side = 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 - 5); + 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, + }; + + return side switch + { + "left" or "top" => axisValues.Any(value => value > targetAxis + tolerance), + "right" or "bottom" => axisValues.Any(value => value < targetAxis - tolerance), + _ => false, + }; + } + + private static string ResolveBoundarySide(WorkflowRenderPoint point, WorkflowRenderPositionedNode node) + { + var left = Math.Abs(point.X - node.X); + var right = Math.Abs(point.X - (node.X + node.Width)); + var top = Math.Abs(point.Y - node.Y); + var bottom = Math.Abs(point.Y - (node.Y + node.Height)); + var min = Math.Min(Math.Min(left, right), Math.Min(top, bottom)); + if (Math.Abs(min - left) < 0.5d) + { + return "left"; + } + + if (Math.Abs(min - right) < 0.5d) + { + return "right"; + } + + if (Math.Abs(min - top) < 0.5d) + { + return "top"; + } + + return "bottom"; + } } diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpWorkflowRenderLayoutEngineTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpWorkflowRenderLayoutEngineTests.cs index f647f637c..7c79cbb7b 100644 --- a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpWorkflowRenderLayoutEngineTests.cs +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpWorkflowRenderLayoutEngineTests.cs @@ -714,12 +714,14 @@ public class ElkSharpWorkflowRenderLayoutEngineTests var successTwo = result.Edges.Single(edge => edge.Id == "success-2").Sections.Single(); var failure = result.Edges.Single(edge => edge.Id == "failure-1").Sections.Single(); + // All three edges should reach the end node with distinct approach Y values var successBundleYOne = ResolvePreTargetBundleY(successOne); var successBundleYTwo = ResolvePreTargetBundleY(successTwo); var failureBundleY = ResolvePreTargetBundleY(failure); - successBundleYOne.Should().BeApproximately(successBundleYTwo, 0.01d); - failureBundleY.Should().NotBeApproximately(successBundleYOne, 0.01d); + var allApproachYs = new[] { successBundleYOne, successBundleYTwo, failureBundleY }; + allApproachYs.Should().OnlyHaveUniqueItems( + "each edge should approach the target at a distinct Y coordinate"); } [Test] @@ -841,7 +843,7 @@ public class ElkSharpWorkflowRenderLayoutEngineTests } [Test] - public async Task LayoutAsync_WhenBackwardFamilySharesTarget_ShouldUseSharedSourceCollectorColumn() + public async Task LayoutAsync_WhenBackwardFamilySharesTarget_ShouldStackOuterCollectorLanes() { var engine = new ElkSharpWorkflowRenderLayoutEngine(); var graph = new WorkflowRenderGraph @@ -937,22 +939,42 @@ public class ElkSharpWorkflowRenderLayoutEngineTests .Select(edge => edge.Sections.Single()) .ToArray(); - loopEdges.Should().OnlyContain(section => section.BendPoints.Count >= 3); - var sharedCollectorX = loopEdges[0].BendPoints.ElementAt(0).X; - loopEdges.Should().OnlyContain(section => Math.Abs(section.BendPoints.ElementAt(0).X - sharedCollectorX) <= 0.01d); - loopEdges.Should().OnlyContain(section => Math.Abs(section.BendPoints.ElementAt(1).X - sharedCollectorX) <= 0.01d); - var sharedCorridorY = loopEdges[0].BendPoints.ElementAt(1).Y; - loopEdges.Should().OnlyContain(section => Math.Abs(section.BendPoints.ElementAt(1).Y - sharedCorridorY) <= 0.01d); + var outerLoopEdges = loopEdges + .Where(section => section.BendPoints.Min(point => point.Y) < section.EndPoint.Y - 1d) + .ToArray(); + outerLoopEdges.Should().HaveCountGreaterOrEqualTo(2); + outerLoopEdges.Should().OnlyContain(section => section.BendPoints.Count >= 3); + + var directLoopEdges = loopEdges.Except(outerLoopEdges).ToArray(); + directLoopEdges.Should().OnlyContain(section => section.BendPoints.Count >= 2); + + var collectorX = outerLoopEdges + .Select(section => section.BendPoints.Max(point => point.X)) + .DistinctBy(x => Math.Round(x, 2)) + .ToArray(); + collectorX.Should().HaveCount(1); + + var outerLaneYs = outerLoopEdges + .Select(section => section.BendPoints.Min(point => point.Y)) + .OrderBy(y => y) + .ToArray(); + outerLaneYs.Should().OnlyHaveUniqueItems(); + outerLaneYs.Should().BeInAscendingOrder(); } private static double ResolvePreTargetBundleY(WorkflowRenderEdgeSection section) { + if (section.BendPoints.Count == 0) + { + return section.StartPoint.Y; + } + var preTargetX = section.BendPoints.Max(point => point.X); var bundlePoint = section.BendPoints .Where(point => Math.Abs(point.X - preTargetX) <= 0.01d && Math.Abs(point.Y - section.EndPoint.Y) > 0.01d) .OrderBy(point => point.Y) - .First(); + .FirstOrDefault(); - return bundlePoint.Y; + return bundlePoint?.Y ?? section.BendPoints.Last().Y; } } diff --git a/src/__Libraries/StellaOps.ElkSharp/AGENTS.md b/src/__Libraries/StellaOps.ElkSharp/AGENTS.md index d890abccc..5e94a99d7 100644 --- a/src/__Libraries/StellaOps.ElkSharp/AGENTS.md +++ b/src/__Libraries/StellaOps.ElkSharp/AGENTS.md @@ -15,7 +15,20 @@ - Preserve deterministic output for the same graph and options. Do not introduce random tie-breaking. - Keep orthogonal routing as the default contract unless a sprint explicitly broadens it. - Treat channel assignment, dummy-edge reconstruction, and anchor selection as authoritative upstream inputs. +- The current `Best`-effort path uses deterministic multi-strategy iterative routing after the baseline channel route. Keep strategy ordering stable and keep the seeded-random strategy family reproducible for the same graph. +- A strategy attempt is only valid after final post-processing if it leaves no remaining broken short highways; detection alone is not enough. +- Treat near-end target-side approach joins that collapse multiple edges into the same arrival rail as blocking violations. Valid highways may still remain when they satisfy the shared-length rule; non-applicable joins must not be selected silently. +- When proximity, entry-angle, label, detour, or crossing quality is poor, use bounded multi-attempt retries and a broader but still finite strategy sweep when baseline artifacts remain; keep the final node-crossing cleanup at the end of post-processing. +- Keep iterative diagnostics detailed enough to prove progress. The document-processing artifact test should emit a live progress log that shows baseline state, strategy starts, per-attempt scores, and adaptation decisions. +- Iterative optimization work should focus on penalized edge or edge-cluster fixes, not whole-graph reroutes. Use whole-graph retries only as a fallback once diagnostics show the local repair path is unavailable. +- Keep attempt 1 as the only full-strategy reroute. Attempt 2+ must target only the failed lanes or failed edge clusters, with shortest-path detours prioritized before broader quality cleanup. +- The selected layout must not backtrack inside the final target-approach window. Attempt 2+ shortest-path repair should try a direct orthogonal shortcut first and only fall back to a low-penalty 45-degree A* candidate when the orthogonal repair is blocked by other rules. +- Keep small or protected graphs on the baseline route when the iterative sweep would risk established geometry contracts; reserve the multi-strategy path for larger congested graphs where it materially improves routing quality. +- Keep per-attempt diagnostics granular enough to expose routing versus post-processing cost. Phase timings and route-pass counts are required evidence before widening the retry budget again. +- Use cheap local geometry repair after routing to clean boundary-angle, target-side arrival-slot, and repeat-collector return-lane defects before escalating to more A* work. The selected layout must satisfy the node-side 90° entry/exit rule and must not leave repeat-collector lanes collapsed onto the same outer return lane. - Do not replace corridor and backward-route behavior with generic rerouting unless the sprint explicitly changes that contract. +- When touching proximity/highway logic, keep long applicable shared corridors distinct from short shared segments that must be spread apart. +- Future A* performance work must precompute occupied grid cells or blocked segment masks and avoid expanding through cells already owned by non-terminal nodes or previously committed lanes. Derive intermediate grid spacing from approximately one third of the average service-task size instead of keeping a fixed dense lattice. - Keep `TopToBottom` behavior stable unless the sprint explicitly includes it. ## Testing diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs index d505761b1..0b368e756 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs @@ -170,6 +170,7 @@ internal static class ElkEdgePostProcessor { 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 obstacles = nodes.Select(n => (L: n.X - 4d, T: n.Y - 4d, R: n.X + n.Width + 4d, B: n.Y + n.Height + 4d, Id: n.Id)).ToArray(); var result = new ElkRoutedEdge[edges.Length]; for (var i = 0; i < edges.Length; i++) @@ -177,9 +178,12 @@ internal static class ElkEdgePostProcessor var edge = edges[i]; var anyFixed = false; var newSections = new List(); + var sectionList = edge.Sections.ToList(); - foreach (var section in edge.Sections) + for (var sIdx = 0; sIdx < sectionList.Count; sIdx++) { + var section = sectionList[sIdx]; + var isLastSection = sIdx == sectionList.Count - 1; var pts = new List { section.StartPoint }; pts.AddRange(section.BendPoints); pts.Add(section.EndPoint); @@ -205,6 +209,26 @@ internal static class ElkEdgePostProcessor { // Preserve diagonal for backward collector edges } + else if (isLastSection && j == pts.Count - 1 && !isBackwardSection) + { + // Target approach: L-corner must be perpendicular to the entry side. + // Vertical side (left/right) → last segment horizontal (default). + // Horizontal side (top/bottom) → last segment vertical (flipped). + var targetNode = nodesById.GetValueOrDefault(edge.TargetNodeId ?? ""); + var onHorizontalSide = targetNode is not null + && (Math.Abs(curr.Y - targetNode.Y) < 2d + || Math.Abs(curr.Y - (targetNode.Y + targetNode.Height)) < 2d); + if (onHorizontalSide) + { + fixedPts.Add(new ElkPoint { X = curr.X, Y = prev.Y }); + } + else + { + fixedPts.Add(new ElkPoint { X = prev.X, Y = curr.Y }); + } + + anyFixed = true; + } else { fixedPts.Add(new ElkPoint { X = prev.X, Y = curr.Y }); @@ -229,6 +253,275 @@ internal static class ElkEdgePostProcessor return result; } + internal static ElkRoutedEdge[] NormalizeBoundaryAngles( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes) + { + if (edges.Length == 0 || nodes.Length == 0) + { + return edges; + } + + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + var result = new ElkRoutedEdge[edges.Length]; + for (var i = 0; i < edges.Length; i++) + { + var edge = edges[i]; + 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) + { + result[i] = edge; + continue; + } + + var normalized = path; + var preserveSourceExit = ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label) + || ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY); + if (!preserveSourceExit + && nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)) + { + var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[0], sourceNode); + var sourceNormalized = NormalizeExitPath(normalized, sourceNode, sourceSide); + if (HasClearSourceExitSegment(sourceNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId)) + { + normalized = sourceNormalized; + } + } + + if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)) + { + var targetSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[^1], targetNode); + normalized = NormalizeEntryPath(normalized, targetNode, targetSide); + } + + if (normalized.Count == path.Count + && normalized.Zip(path, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal)) + { + result[i] = edge; + continue; + } + + result[i] = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + SourcePortId = edge.SourcePortId, + TargetPortId = edge.TargetPortId, + Kind = edge.Kind, + Label = edge.Label, + Sections = + [ + new ElkEdgeSection + { + StartPoint = normalized[0], + EndPoint = normalized[^1], + BendPoints = normalized.Count > 2 + ? normalized.Skip(1).Take(normalized.Count - 2).ToArray() + : [], + }, + ], + }; + } + + return result; + } + + internal static ElkRoutedEdge[] NormalizeTargetEntryAngles( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes) + { + return NormalizeBoundaryAngles(edges, nodes); + } + + internal static ElkRoutedEdge[] NormalizeSourceExitAngles( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes) + { + if (edges.Length == 0 || nodes.Length == 0) + { + return edges; + } + + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + var result = new ElkRoutedEdge[edges.Length]; + for (var i = 0; i < edges.Length; i++) + { + var edge = edges[i]; + var preserveSourceExit = ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label) + || ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY); + if (preserveSourceExit + || !nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)) + { + result[i] = edge; + continue; + } + + var path = new List(); + foreach (var section in edge.Sections) + { + if (path.Count == 0) + { + path.Add(section.StartPoint); + } + + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + } + + if (path.Count < 2) + { + result[i] = edge; + continue; + } + + var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(path[0], sourceNode); + var normalized = NormalizeExitPath(path, sourceNode, sourceSide); + if (!HasClearSourceExitSegment(normalized, nodes, edge.SourceNodeId, edge.TargetNodeId)) + { + result[i] = edge; + continue; + } + + if (normalized.Count == path.Count + && normalized.Zip(path, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal)) + { + result[i] = edge; + continue; + } + + result[i] = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + SourcePortId = edge.SourcePortId, + TargetPortId = edge.TargetPortId, + Kind = edge.Kind, + Label = edge.Label, + Sections = + [ + new ElkEdgeSection + { + StartPoint = normalized[0], + EndPoint = normalized[^1], + BendPoints = normalized.Count > 2 + ? normalized.Skip(1).Take(normalized.Count - 2).ToArray() + : [], + }, + ], + }; + } + + return result; + } + + internal static ElkRoutedEdge[] RepairBoundaryAnglesAndTargetApproaches( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + double minLineClearance, + IReadOnlyCollection? restrictedEdgeIds = null) + { + if (edges.Length == 0 || nodes.Length == 0) + { + return edges; + } + + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + var targetSlots = ResolveTargetApproachSlots(edges, nodesById, graphMinY, graphMaxY, minLineClearance, restrictedSet); + var result = new ElkRoutedEdge[edges.Length]; + + for (var i = 0; i < edges.Length; i++) + { + var edge = edges[i]; + if (restrictedSet is not null && !restrictedSet.Contains(edge.Id)) + { + result[i] = edge; + continue; + } + + var path = ExtractFullPath(edge); + if (path.Count < 2) + { + result[i] = edge; + continue; + } + + var normalized = path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + + if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode) + && !HasValidBoundaryAngle(normalized[0], normalized[1], sourceNode)) + { + var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[0], sourceNode); + var sourceNormalized = NormalizeExitPath(normalized, sourceNode, sourceSide); + if (HasClearBoundarySegments(sourceNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId, true, 3)) + { + normalized = sourceNormalized; + } + } + + if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)) + { + var assignedEndpoint = targetSlots.TryGetValue(edge.Id, out var slot) + ? slot + : normalized[^1]; + var targetSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(assignedEndpoint, targetNode); + if (!ElkEdgeRoutingGeometry.PointsEqual(assignedEndpoint, normalized[^1]) + || !HasValidBoundaryAngle(normalized[^1], normalized[^2], targetNode)) + { + var targetNormalized = NormalizeEntryPath(normalized, targetNode, targetSide, assignedEndpoint); + if (HasClearBoundarySegments(targetNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId, false, 3)) + { + normalized = targetNormalized; + } + } + + var shortenedApproach = TrimTargetApproachBacktracking(normalized, targetNode, targetSide, assignedEndpoint); + if (shortenedApproach.Count != normalized.Count + || !shortenedApproach.Zip(normalized, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal)) + { + if (HasClearBoundarySegments(shortenedApproach, nodes, edge.SourceNodeId, edge.TargetNodeId, false, 3)) + { + normalized = shortenedApproach; + } + } + } + + if (normalized.Count == path.Count + && normalized.Zip(path, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal)) + { + result[i] = edge; + continue; + } + + result[i] = BuildSingleSectionEdge(edge, normalized); + } + + return result; + } + internal static bool IsRepeatCollectorLabel(string? label) { if (string.IsNullOrWhiteSpace(label)) @@ -268,10 +561,8 @@ internal static class ElkEdgePostProcessor (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, string sourceId, string targetId) { - var segLen = Math.Sqrt(Math.Pow(p1.X - p2.X, 2) + Math.Pow(p1.Y - p2.Y, 2)); var isH = Math.Abs(p1.Y - p2.Y) < 2d; var isV = Math.Abs(p1.X - p2.X) < 2d; - if (!isH && !isV) return segLen > 15d; foreach (var ob in obstacles) { @@ -288,8 +579,555 @@ internal static class ElkEdgePostProcessor var maxY = Math.Max(p1.Y, p2.Y); if (maxY > ob.Top && minY < ob.Bottom) return true; } + else if (!isH && !isV) + { + // Diagonal segment: check actual intersection with obstacle rectangle + if (ElkEdgeRoutingGeometry.SegmentsIntersect(p1, p2, + new ElkPoint { X = ob.Left, Y = ob.Top }, new ElkPoint { X = ob.Right, Y = ob.Top }) + || ElkEdgeRoutingGeometry.SegmentsIntersect(p1, p2, + new ElkPoint { X = ob.Right, Y = ob.Top }, new ElkPoint { X = ob.Right, Y = ob.Bottom }) + || ElkEdgeRoutingGeometry.SegmentsIntersect(p1, p2, + new ElkPoint { X = ob.Right, Y = ob.Bottom }, new ElkPoint { X = ob.Left, Y = ob.Bottom }) + || ElkEdgeRoutingGeometry.SegmentsIntersect(p1, p2, + new ElkPoint { X = ob.Left, Y = ob.Bottom }, new ElkPoint { X = ob.Left, Y = ob.Top })) + { + return true; + } + } } return false; } + + private static bool HasClearSourceExitSegment( + IReadOnlyList path, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId) + { + return HasClearBoundarySegments(path, nodes, sourceNodeId, targetNodeId, true, 2); + } + + private static List NormalizeExitPath( + IReadOnlyList sourcePath, + ElkPositionedNode sourceNode, + string side) + { + const double coordinateTolerance = 0.5d; + var path = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (path.Count < 2) + { + return path; + } + + if (side is "left" or "right") + { + var sourceX = side == "left" + ? sourceNode.X + : sourceNode.X + sourceNode.Width; + while (path.Count >= 3 && Math.Abs(path[1].X - sourceX) <= coordinateTolerance) + { + path.RemoveAt(1); + } + + var rebuilt = new List + { + new() { X = sourceX, Y = path[0].Y }, + }; + var anchor = path[1]; + var stubX = side == "left" + ? sourceX - 24d + : sourceX + 24d; + if (Math.Abs(stubX - sourceX) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint + { + X = stubX, + Y = path[0].Y, + }); + } + + if (Math.Abs(anchor.Y - path[0].Y) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = stubX, Y = anchor.Y }); + } + + if (Math.Abs(anchor.X - stubX) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = anchor.X, Y = anchor.Y }); + } + + rebuilt.AddRange(path.Skip(2)); + return NormalizePathPoints(rebuilt); + } + + var sourceY = side == "top" + ? sourceNode.Y + : sourceNode.Y + sourceNode.Height; + while (path.Count >= 3 && Math.Abs(path[1].Y - sourceY) <= coordinateTolerance) + { + path.RemoveAt(1); + } + + var verticalRebuilt = new List + { + new() { X = path[0].X, Y = sourceY }, + }; + var verticalAnchor = path[1]; + var stubY = side == "top" + ? sourceY - 24d + : sourceY + 24d; + if (Math.Abs(stubY - sourceY) > coordinateTolerance) + { + verticalRebuilt.Add(new ElkPoint + { + X = path[0].X, + Y = stubY, + }); + } + + if (Math.Abs(verticalAnchor.X - path[0].X) > coordinateTolerance) + { + verticalRebuilt.Add(new ElkPoint { X = verticalAnchor.X, Y = stubY }); + } + + if (Math.Abs(verticalAnchor.Y - stubY) > coordinateTolerance) + { + verticalRebuilt.Add(new ElkPoint { X = verticalAnchor.X, Y = verticalAnchor.Y }); + } + + verticalRebuilt.AddRange(path.Skip(2)); + return NormalizePathPoints(verticalRebuilt); + } + + private static List NormalizeEntryPath( + IReadOnlyList sourcePath, + ElkPositionedNode targetNode, + string side) + { + return NormalizeEntryPath(sourcePath, targetNode, side, null); + } + + private static List NormalizeEntryPath( + IReadOnlyList sourcePath, + ElkPositionedNode targetNode, + string side, + ElkPoint? explicitEndpoint) + { + const double coordinateTolerance = 0.5d; + var path = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (path.Count < 2) + { + return path; + } + + if (side is "left" or "right") + { + var targetX = side == "left" + ? targetNode.X + : targetNode.X + targetNode.Width; + var endpoint = explicitEndpoint ?? new ElkPoint { X = targetX, Y = path[^1].Y }; + while (path.Count >= 3 && Math.Abs(path[^2].X - targetX) <= coordinateTolerance) + { + path.RemoveAt(path.Count - 2); + } + + var anchor = path[^2]; + var rebuilt = path.Take(path.Count - 2).ToList(); + if (rebuilt.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], anchor)) + { + rebuilt.Add(anchor); + } + + var stubX = side == "left" + ? targetX - 24d + : targetX + 24d; + if (Math.Abs(anchor.X - stubX) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = stubX, Y = anchor.Y }); + } + + if (Math.Abs(anchor.Y - endpoint.Y) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = stubX, Y = endpoint.Y }); + } + + rebuilt.Add(endpoint); + return NormalizePathPoints(rebuilt); + } + + var targetY = side == "top" + ? targetNode.Y + : targetNode.Y + targetNode.Height; + var verticalEndpoint = explicitEndpoint ?? new ElkPoint { X = path[^1].X, Y = targetY }; + while (path.Count >= 3 && Math.Abs(path[^2].Y - targetY) <= coordinateTolerance) + { + path.RemoveAt(path.Count - 2); + } + + var verticalAnchor = path[^2]; + var verticalRebuilt = path.Take(path.Count - 2).ToList(); + if (verticalRebuilt.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(verticalRebuilt[^1], verticalAnchor)) + { + verticalRebuilt.Add(verticalAnchor); + } + + var stubY = side == "top" + ? targetY - 24d + : targetY + 24d; + if (Math.Abs(verticalAnchor.X - verticalEndpoint.X) > coordinateTolerance) + { + verticalRebuilt.Add(new ElkPoint { X = verticalEndpoint.X, Y = verticalAnchor.Y }); + } + + if (Math.Abs(verticalAnchor.Y - stubY) > coordinateTolerance) + { + verticalRebuilt.Add(new ElkPoint { X = verticalEndpoint.X, Y = stubY }); + } + + verticalRebuilt.Add(verticalEndpoint); + return NormalizePathPoints(verticalRebuilt); + } + + private static List TrimTargetApproachBacktracking( + IReadOnlyList sourcePath, + ElkPositionedNode targetNode, + string side, + ElkPoint explicitEndpoint) + { + if (sourcePath.Count < 4) + { + return sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + } + + const double tolerance = 0.5d; + var startIndex = Math.Max(0, sourcePath.Count - 5); + var firstOffendingIndex = -1; + for (var i = startIndex; i < sourcePath.Count - 1; i++) + { + if (IsOnWrongSideOfTarget(sourcePath[i], targetNode, side, tolerance)) + { + firstOffendingIndex = i; + break; + } + } + + if (firstOffendingIndex < 0) + { + return sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + } + + var trimmed = sourcePath + .Take(Math.Max(1, firstOffendingIndex)) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (trimmed.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(trimmed[^1], explicitEndpoint)) + { + trimmed.Add(explicitEndpoint); + } + + return NormalizeEntryPath(trimmed, targetNode, side, explicitEndpoint); + } + + private static bool IsOnWrongSideOfTarget( + ElkPoint point, + ElkPositionedNode targetNode, + string side, + double tolerance) + { + return side switch + { + "left" => point.X > targetNode.X + tolerance, + "right" => point.X < (targetNode.X + targetNode.Width) - tolerance, + "top" => point.Y > targetNode.Y + tolerance, + "bottom" => point.Y < (targetNode.Y + targetNode.Height) - tolerance, + _ => false, + }; + } + + private static Dictionary ResolveTargetApproachSlots( + IReadOnlyCollection edges, + IReadOnlyDictionary nodesById, + double graphMinY, + double graphMaxY, + double minLineClearance, + IReadOnlySet? restrictedEdgeIds) + { + var result = new Dictionary(StringComparer.Ordinal); + var groups = new Dictionary>(StringComparer.Ordinal); + + foreach (var edge in edges) + { + if (restrictedEdgeIds is not null && !restrictedEdgeIds.Contains(edge.Id)) + { + continue; + } + + if (!ShouldSpreadTargetApproach(edge, graphMinY, graphMaxY) + || !nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)) + { + continue; + } + + var path = ExtractFullPath(edge); + if (path.Count < 2) + { + continue; + } + + var endpoint = path[^1]; + var side = ElkEdgeRoutingGeometry.ResolveBoundarySide(endpoint, targetNode); + var key = $"{targetNode.Id}|{side}"; + if (!groups.TryGetValue(key, out var group)) + { + group = []; + groups[key] = group; + } + + group.Add((edge.Id, endpoint)); + } + + foreach (var (key, group) in groups) + { + if (group.Count < 2) + { + continue; + } + + var separator = key.IndexOf('|', StringComparison.Ordinal); + var targetId = key[..separator]; + var side = key[(separator + 1)..]; + if (!nodesById.TryGetValue(targetId, out var targetNode)) + { + continue; + } + + var sideLength = side is "left" or "right" + ? Math.Max(8d, targetNode.Height - 8d) + : Math.Max(8d, targetNode.Width - 8d); + var slotSpacing = group.Count > 1 + ? Math.Max(12d, Math.Min(minLineClearance, sideLength / (group.Count - 1))) + : 0d; + var totalSpan = (group.Count - 1) * slotSpacing; + + if (side is "left" or "right") + { + var centerY = targetNode.Y + (targetNode.Height / 2d); + var startY = Math.Max(targetNode.Y + 4d, centerY - (totalSpan / 2d)); + var sorted = group.OrderBy(item => item.Endpoint.Y).ToArray(); + for (var i = 0; i < sorted.Length; i++) + { + var slotY = Math.Min(targetNode.Y + targetNode.Height - 4d, startY + (i * slotSpacing)); + result[sorted[i].EdgeId] = new ElkPoint + { + X = side == "left" ? targetNode.X : targetNode.X + targetNode.Width, + Y = slotY, + }; + } + } + else + { + var centerX = targetNode.X + (targetNode.Width / 2d); + var startX = Math.Max(targetNode.X + 4d, centerX - (totalSpan / 2d)); + var sorted = group.OrderBy(item => item.Endpoint.X).ToArray(); + for (var i = 0; i < sorted.Length; i++) + { + var slotX = Math.Min(targetNode.X + targetNode.Width - 4d, startX + (i * slotSpacing)); + result[sorted[i].EdgeId] = new ElkPoint + { + X = slotX, + Y = side == "top" ? targetNode.Y : targetNode.Y + targetNode.Height, + }; + } + } + } + + return result; + } + + private static bool ShouldSpreadTargetApproach( + ElkRoutedEdge edge, + double graphMinY, + double graphMaxY) + { + 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 (IsRepeatCollectorLabel(edge.Label)) + { + return true; + } + + if (HasCorridorBendPoints(edge, graphMinY, graphMaxY)) + { + return false; + } + + return true; + } + + private static bool HasClearBoundarySegments( + IReadOnlyList path, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + bool fromStart, + int segmentCount) + { + if (path.Count < 2) + { + return true; + } + + 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(); + if (fromStart) + { + var maxIndex = Math.Min(path.Count - 1, segmentCount); + for (var i = 0; i < maxIndex; i++) + { + if (SegmentCrossesObstacle(path[i], path[i + 1], obstacles, sourceNodeId ?? string.Empty, targetNodeId ?? string.Empty)) + { + return false; + } + } + + return true; + } + + var startIndex = Math.Max(0, path.Count - 1 - segmentCount); + for (var i = startIndex; i < path.Count - 1; i++) + { + if (SegmentCrossesObstacle(path[i], path[i + 1], obstacles, sourceNodeId ?? string.Empty, targetNodeId ?? string.Empty)) + { + return false; + } + } + + return true; + } + + private static bool HasValidBoundaryAngle( + ElkPoint boundaryPoint, + ElkPoint adjacentPoint, + ElkPositionedNode node) + { + var segDx = Math.Abs(boundaryPoint.X - adjacentPoint.X); + var segDy = Math.Abs(boundaryPoint.Y - adjacentPoint.Y); + if (segDx < 3d && segDy < 3d) + { + return true; + } + + var side = ElkEdgeRoutingGeometry.ResolveBoundarySide(boundaryPoint, node); + var validForVerticalSide = segDx > segDy * 3d; + var validForHorizontalSide = segDy > segDx * 3d; + return side switch + { + "left" or "right" => validForVerticalSide, + "top" or "bottom" => validForHorizontalSide, + _ => true, + }; + } + + 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 static ElkRoutedEdge BuildSingleSectionEdge( + ElkRoutedEdge edge, + IReadOnlyList path) + { + 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 = path[0], + EndPoint = path[^1], + BendPoints = path.Count > 2 + ? path.Skip(1).Take(path.Count - 2).ToArray() + : [], + }, + ], + }; + } + + private static List NormalizePathPoints(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/ElkEdgePostProcessorSimplify.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorSimplify.cs index 5a7d7b59e..81d7cb5e7 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorSimplify.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorSimplify.cs @@ -69,6 +69,12 @@ internal static class ElkEdgePostProcessorSimplify } } } + + if (!changed && TryApplyOrthogonalShortcut(cleaned, obstacles, excludeIds)) + { + changed = true; + anyChanged = true; + } } // Remove trailing duplicates (bend point == endpoint) @@ -110,8 +116,12 @@ internal static class ElkEdgePostProcessorSimplify var graphMinY = nodes.Min(n => n.Y); var graphMaxY = nodes.Max(n => n.Y + n.Height); - const double minMargin = 12d; - const double laneGap = 8d; + 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++) @@ -250,6 +260,110 @@ internal static class ElkEdgePostProcessorSimplify 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, diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouteRefiner.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouteRefiner.cs index d4d793a3c..92d7351e6 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouteRefiner.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouteRefiner.cs @@ -25,9 +25,15 @@ internal static class ElkEdgeRouteRefiner return edges; } + var diagnostics = ElkLayoutDiagnostics.Current; var bestEdges = edges; var bestScore = ElkEdgeRoutingScoring.ComputeScore(bestEdges, nodes); var bestNodeCrossings = bestScore.NodeCrossings; + if (diagnostics is not null) + { + diagnostics.InitialScore = bestScore; + diagnostics.FinalScore = bestScore; + } for (var passIndex = 0; passIndex < options.MaxGlobalPasses; passIndex++) { @@ -46,7 +52,7 @@ internal static class ElkEdgeRouteRefiner { cancellationToken.ThrowIfCancellationRequested(); - if (!TryImproveEdge(bestEdges, nodes, issue.EdgeId, bestScore, options, cancellationToken, out var improvedEdges, out var improvedScore)) + if (!TryImproveEdge(bestEdges, nodes, passIndex + 1, issue, bestScore, options, cancellationToken, out var improvedEdges, out var improvedScore)) { continue; } @@ -73,13 +79,23 @@ internal static class ElkEdgeRouteRefiner } } + if (diagnostics is not null) + { + diagnostics.CompletedPasses = diagnostics.Attempts + .Select(attempt => attempt.PassIndex) + .DefaultIfEmpty(0) + .Max(); + diagnostics.FinalScore = bestScore; + } + return bestEdges; } private static bool TryImproveEdge( ElkRoutedEdge[] edges, ElkPositionedNode[] nodes, - string edgeId, + int passIndex, + EdgeRoutingIssue issue, EdgeRoutingScore baselineScore, EdgeRefinementOptions options, CancellationToken cancellationToken, @@ -89,7 +105,7 @@ internal static class ElkEdgeRouteRefiner improvedEdges = edges; improvedScore = baselineScore; - var edgeIndex = Array.FindIndex(edges, edge => string.Equals(edge.Id, edgeId, StringComparison.Ordinal)); + var edgeIndex = Array.FindIndex(edges, edge => string.Equals(edge.Id, issue.EdgeId, StringComparison.Ordinal)); if (edgeIndex < 0) { return false; @@ -107,15 +123,28 @@ internal static class ElkEdgeRouteRefiner Right: node.X + node.Width, Bottom: node.Y + node.Height, Id: node.Id)).ToArray(); - var softObstacles = BuildSoftObstacles(edges, edgeId); + var softObstacles = BuildSoftObstacles(edges, issue.EdgeId); + + var attemptDiagnostics = ElkLayoutDiagnostics.Current is null + ? null + : new ElkEdgeRefinementAttemptDiagnostics + { + PassIndex = passIndex, + EdgeId = issue.EdgeId, + Severity = issue.Severity, + BaselineScore = baselineScore, + }; var bestLocalEdges = edges; var bestLocalScore = baselineScore; var trials = BuildTrials(options).Take(options.MaxTrialsPerProblemEdge); + var bestTrialIndex = -1; + var trialCounter = 0; foreach (var trial in trials) { cancellationToken.ThrowIfCancellationRequested(); + trialCounter++; var reroutedSections = new List(edge.Sections.Count); var rerouteFailed = false; @@ -146,6 +175,14 @@ internal static class ElkEdgeRouteRefiner if (rerouteFailed) { + attemptDiagnostics?.Trials.Add(new ElkEdgeRefinementTrialDiagnostics + { + TrialIndex = trialCounter, + Margin = trial.Margin, + BendPenalty = trial.BendPenalty, + SoftObstacleWeight = trial.SoftObstacleWeight, + Outcome = "no-path", + }); continue; } @@ -163,6 +200,21 @@ internal static class ElkEdgeRouteRefiner }; var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); + attemptDiagnostics?.Trials.Add(new ElkEdgeRefinementTrialDiagnostics + { + TrialIndex = trialCounter, + Margin = trial.Margin, + BendPenalty = trial.BendPenalty, + SoftObstacleWeight = trial.SoftObstacleWeight, + Outcome = "scored", + CandidateScore = candidateScore, + Sections = reroutedSections.Select(section => new ElkDiagnosticSectionPath + { + StartPoint = section.StartPoint, + BendPoints = section.BendPoints.ToArray(), + EndPoint = section.EndPoint, + }).ToArray(), + }); if (!IsBetterCandidate(candidateScore, bestLocalScore)) { continue; @@ -170,6 +222,24 @@ internal static class ElkEdgeRouteRefiner bestLocalEdges = candidateEdges; bestLocalScore = candidateScore; + bestTrialIndex = trialCounter; + } + + if (attemptDiagnostics is not null) + { + attemptDiagnostics.AttemptCount = trialCounter; + attemptDiagnostics.AcceptedTrialIndex = bestTrialIndex > 0 ? bestTrialIndex : null; + attemptDiagnostics.AcceptedScore = bestTrialIndex > 0 ? bestLocalScore : null; + if (bestTrialIndex > 0) + { + var acceptedTrial = attemptDiagnostics.Trials.FirstOrDefault(trial => trial.TrialIndex == bestTrialIndex); + if (acceptedTrial is not null) + { + acceptedTrial.Accepted = true; + } + } + + ElkLayoutDiagnostics.AddRefinementAttempt(attemptDiagnostics); } if (ReferenceEquals(bestLocalEdges, edges)) diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAStar8Dir.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAStar8Dir.cs new file mode 100644 index 000000000..310c17607 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAStar8Dir.cs @@ -0,0 +1,394 @@ +namespace StellaOps.ElkSharp; + +internal static 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) + private static readonly int[] DirCodes = [1, 1, 2, 2, 3, 3, 4, 4]; + + internal static List? Route( + ElkPoint start, + ElkPoint end, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + string sourceId, + string targetId, + AStarRoutingParams routingParams, + IReadOnlyList softObstacles, + CancellationToken cancellationToken) + { + var xs = new SortedSet { start.X, end.X }; + var ys = new SortedSet { start.Y, end.Y }; + foreach (var ob in obstacles) + { + if (ob.Id == sourceId || ob.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); + } + + if (routingParams.IntermediateGridSpacing > 0d) + { + AddIntermediateLines(xs, routingParams.IntermediateGridSpacing); + AddIntermediateLines(ys, routingParams.IntermediateGridSpacing); + } + + var xArr = xs.ToArray(); + var yArr = ys.ToArray(); + var xCount = xArr.Length; + var yCount = yArr.Length; + if (xCount < 2 || yCount < 2) + { + return null; + } + + var startIx = Array.BinarySearch(xArr, start.X); + var startIy = Array.BinarySearch(yArr, start.Y); + var endIx = Array.BinarySearch(xArr, end.X); + var endIy = Array.BinarySearch(yArr, end.Y); + if (startIx < 0 || startIy < 0 || endIx < 0 || endIy < 0) + { + return null; + } + + bool IsBlockedOrthogonal(int ix1, int iy1, int ix2, int iy2) + { + var x1 = xArr[ix1]; + var y1 = yArr[iy1]; + var x2 = xArr[ix2]; + var y2 = yArr[iy2]; + foreach (var ob in obstacles) + { + if (ob.Id == sourceId || ob.Id == targetId) + { + continue; + } + + if (ix1 == ix2) + { + if (x1 > ob.Left && x1 < ob.Right) + { + var minY = Math.Min(y1, y2); + var maxY = Math.Max(y1, y2); + if (maxY > ob.Top && minY < ob.Bottom) + { + return true; + } + } + } + else if (iy1 == iy2) + { + if (y1 > ob.Top && y1 < ob.Bottom) + { + var minX = Math.Min(x1, x2); + var maxX = Math.Max(x1, x2); + if (maxX > ob.Left && minX < ob.Right) + { + return true; + } + } + } + } + + return false; + } + + const int dirCount = 5; + var stateCount = xCount * yCount * dirCount; + var gScore = new double[stateCount]; + Array.Fill(gScore, double.MaxValue); + 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) + { + if (ob.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; + if (Math.Abs(end.X - nodeLeft) < 2d || Math.Abs(end.X - nodeRight) < 2d) + { + blockedEntryDir = 2; // vertical side → block vertical + } + else if (Math.Abs(end.Y - nodeTop) < 2d || Math.Abs(end.Y - nodeBottom) < 2d) + { + blockedEntryDir = 1; // horizontal side → block horizontal + } + + break; + } + } + + int StateId(int ix, int iy, int dir) => (ix * yCount + iy) * dirCount + dir; + + double Heuristic(int ix, int iy) + { + var hdx = xArr[ix] - xArr[endIx]; + var hdy = yArr[iy] - yArr[endIy]; + return Math.Sqrt(hdx * hdx + hdy * hdy); + } + + var startState = StateId(startIx, startIy, 0); + gScore[startState] = 0d; + var openSet = new PriorityQueue(); + openSet.Enqueue(startState, Heuristic(startIx, startIy)); + + var maxIterations = xCount * yCount * 12; + var iterations = 0; + var closed = new HashSet(); + + while (openSet.Count > 0 && iterations++ < maxIterations) + { + cancellationToken.ThrowIfCancellationRequested(); + + var current = openSet.Dequeue(); + if (!closed.Add(current)) + { + continue; + } + + var curDir = current % dirCount; + var curIy = (current / dirCount) % yCount; + var curIx = (current / dirCount) / yCount; + + if (curIx == endIx && curIy == endIy) + { + return ReconstructPath(current, cameFrom, xArr, yArr, yCount, dirCount); + } + + for (var d = 0; d < 8; d++) + { + var nx = curIx + Dx[d]; + var ny = curIy + Dy[d]; + if (nx < 0 || nx >= xCount || ny < 0 || ny >= yCount) + { + continue; + } + + var isDiagonal = Dx[d] != 0 && Dy[d] != 0; + if (isDiagonal) + { + if (IsBlockedOrthogonal(curIx, curIy, nx, curIy) + || IsBlockedOrthogonal(curIx, curIy, curIx, ny)) + { + 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) + { + continue; + } + + var bend = ComputeBendPenalty(curDir, newDir, routingParams.BendPenalty); + + double dist; + if (isDiagonal) + { + var ddx = xArr[nx] - xArr[curIx]; + var ddy = yArr[ny] - yArr[curIy]; + dist = Math.Sqrt(ddx * ddx + ddy * ddy) + routingParams.DiagonalPenalty; + } + else + { + dist = Math.Abs(xArr[nx] - xArr[curIx]) + Math.Abs(yArr[ny] - yArr[curIy]); + } + + var softCost = ComputeSoftObstacleCost( + xArr[curIx], yArr[curIy], xArr[nx], yArr[ny], + softObstacles, routingParams); + + var tentativeG = gScore[current] + dist + bend + softCost; + var neighborState = StateId(nx, ny, newDir); + + if (tentativeG < gScore[neighborState]) + { + gScore[neighborState] = tentativeG; + cameFrom[neighborState] = current; + openSet.Enqueue(neighborState, tentativeG + Heuristic(nx, ny)); + } + } + } + + return null; + } + + 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, + IReadOnlyList softObstacles, + AStarRoutingParams routingParams) + { + if (routingParams.SoftObstacleWeight <= 0d || softObstacles.Count == 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 cost = 0d; + + foreach (var obstacle in softObstacles) + { + 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.Start, obstacle.End, + 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, + ElkPoint obStart, ElkPoint obEnd, + double clearance) + { + var obIsH = Math.Abs(obStart.Y - obEnd.Y) < 2d; + var obIsV = Math.Abs(obStart.X - obEnd.X) < 2d; + + if (candidateIsH && obIsH) + { + var dist = Math.Abs(y1 - obStart.Y); + if (dist >= clearance) + { + return -1d; + } + + var overlapMin = Math.Max(Math.Min(x1, x2), Math.Min(obStart.X, obEnd.X)); + var overlapMax = Math.Min(Math.Max(x1, x2), Math.Max(obStart.X, obEnd.X)); + return overlapMax > overlapMin + 1d ? dist : -1d; + } + + if (candidateIsV && obIsV) + { + var dist = Math.Abs(x1 - obStart.X); + if (dist >= clearance) + { + return -1d; + } + + var overlapMin = Math.Max(Math.Min(y1, y2), Math.Min(obStart.Y, obEnd.Y)); + var overlapMax = Math.Min(Math.Max(y1, y2), Math.Max(obStart.Y, obEnd.Y)); + return overlapMax > overlapMin + 1d ? dist : -1d; + } + + return -1d; + } + + private static List ReconstructPath( + int endState, int[] cameFrom, + double[] xArr, double[] yArr, + 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] }); + 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 next = path[i + 1]; + var dx1 = Math.Sign(path[i].X - prev.X); + var dy1 = Math.Sign(path[i].Y - prev.Y); + var dx2 = Math.Sign(next.X - path[i].X); + var dy2 = Math.Sign(next.Y - path[i].Y); + if (dx1 != dx2 || dy1 != dy2) + { + simplified.Add(path[i]); + } + } + + 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); + } + } + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterHighway.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterHighway.cs new file mode 100644 index 000000000..dbc18da72 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterHighway.cs @@ -0,0 +1,524 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkEdgeRouterHighway +{ + private const double MinHighwayRatio = 2d / 5d; + private const double BoundaryInset = 4d; + private const double MinimumSpreadSpacing = 12d; + private const double CoordinateTolerance = 0.5d; + + internal static ElkRoutedEdge[] BreakShortHighways( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes) + { + if (edges.Length < 2 || nodes.Length == 0) + { + return edges; + } + + 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 nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); + var graphMinY = nodes.Min(n => n.Y); + var graphMaxY = nodes.Max(n => n.Y + n.Height); + var result = edges.ToArray(); + + foreach (var (key, edgeIndices) in BuildTargetSideGroups(result, nodesById, graphMinY, graphMaxY) + .OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + if (edgeIndices.Count < 2) + { + continue; + } + + var separator = key.IndexOf('|', StringComparison.Ordinal); + var targetId = key[..separator]; + var side = key[(separator + 1)..]; + if (!nodesById.TryGetValue(targetId, out var targetNode)) + { + continue; + } + + ProcessTargetSideGroup(result, edgeIndices, targetNode, side, minLineClearance); + } + + return result; + } + + internal static IReadOnlyList DetectRemainingBrokenHighways( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes) + { + if (edges.Length < 2 || nodes.Length == 0) + { + return []; + } + + 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 nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); + var graphMinY = nodes.Min(n => n.Y); + var graphMaxY = nodes.Max(n => n.Y + n.Height); + var detections = new List(); + + foreach (var (key, edgeIndices) in BuildTargetSideGroups(edges, nodesById, graphMinY, graphMaxY) + .OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + if (edgeIndices.Count < 2) + { + continue; + } + + var separator = key.IndexOf('|', StringComparison.Ordinal); + var targetId = key[..separator]; + var side = key[(separator + 1)..]; + if (!nodesById.TryGetValue(targetId, out var targetNode)) + { + continue; + } + + var detection = EvaluateTargetSideGroup(edges, edgeIndices, targetNode, side, minLineClearance); + if (detection is not null && detection.Value.Diagnostic.WasBroken) + { + detections.Add(detection.Value.Diagnostic); + } + } + + return detections; + } + + private static bool ShouldProcessEdge(ElkRoutedEdge edge, double graphMinY, double graphMaxY) + { + if (!string.IsNullOrWhiteSpace(edge.Kind) + && edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY)) + { + return false; + } + + if (ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label)) + { + return false; + } + + return true; + } + + private static void ProcessTargetSideGroup( + ElkRoutedEdge[] result, + List edgeIndices, + ElkPositionedNode targetNode, + string side, + double minLineClearance) + { + var evaluation = EvaluateTargetSideGroup(result, edgeIndices, targetNode, side, minLineClearance); + if (evaluation is null) + { + return; + } + + ElkLayoutDiagnostics.AddDetectedHighway(evaluation.Value.Diagnostic); + + if (!evaluation.Value.Diagnostic.WasBroken) + { + return; + } + + var ordered = evaluation.Value.Members + .OrderBy(member => member.EndpointCoord) + .ThenBy(member => member.EdgeId, StringComparer.Ordinal) + .ToList(); + var slotCoords = BuildSlotCoordinates(targetNode, side, ordered.Count, minLineClearance); + for (var i = 0; i < ordered.Count; i++) + { + var adjustedPath = AdjustPathToTargetSlot(ordered[i].Path, targetNode, side, slotCoords[i], minLineClearance); + 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; + } + + 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 requiresSpread = (actualGap + CoordinateTolerance) < minLineClearance + && !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 < clearance {minLineClearance:F0}px" + : pairMetrics.AllPairsApplicable + ? $"shared ratio {pairMetrics.ShortestSharedRatio:F2} >= {MinHighwayRatio:F2}" + : $"gap {actualGap:F0}px >= clearance {minLineClearance: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) + { + if (count <= 1) + { + return + [ + side is "left" or "right" + ? targetNode.Y + (targetNode.Height / 2d) + : targetNode.X + (targetNode.Width / 2d), + ]; + } + + var axisMin = side is "left" or "right" + ? targetNode.Y + BoundaryInset + : targetNode.X + BoundaryInset; + var axisMax = side is "left" or "right" + ? targetNode.Y + targetNode.Height - BoundaryInset + : targetNode.X + targetNode.Width - BoundaryInset; + var axisLength = Math.Max(8d, axisMax - axisMin); + var spacing = Math.Max( + MinimumSpreadSpacing, + Math.Min(minLineClearance, axisLength / (count - 1))); + var totalSpan = (count - 1) * spacing; + var center = (axisMin + axisMax) / 2d; + var start = Math.Max(axisMin, Math.Min(center - (totalSpan / 2d), axisMax - totalSpan)); + + return Enumerable.Range(0, count) + .Select(index => Math.Min(axisMax, start + (index * spacing))) + .ToArray(); + } + + 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.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.cs new file mode 100644 index 000000000..4eca7f9c5 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.cs @@ -0,0 +1,2067 @@ +using System.Diagnostics; + +namespace StellaOps.ElkSharp; + +internal static class ElkEdgeRouterIterative +{ + private static readonly bool HighwayProcessingEnabled = true; + + private static readonly string[] OrderingNames = + [ + "longest-first", "shortest-first", "most-connected-first", "reverse", + "longest-first-high", "shortest-first-high", "most-connected-first-high", "reverse-high", + "random-9", "random-10", "random-11", "random-12", + "random-13", "random-14", "random-15", "random-16", + ]; + + internal static ElkRoutedEdge[] Optimize( + ElkRoutedEdge[] baselineEdges, + ElkPositionedNode[] nodes, + ElkLayoutOptions layoutOptions, + CancellationToken cancellationToken) + { + var config = ResolveConfig(layoutOptions); + if (!config.Enabled || nodes.Length == 0 || baselineEdges.Length == 0 + || layoutOptions.Direction != ElkLayoutDirection.LeftToRight) + { + 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 + : 50d; + var diagnostics = ElkLayoutDiagnostics.Current; + + var validSolutions = new List(); + var fallbackSolutions = new List(); + + // Strategy 0: baseline (existing routing with post-processing) + var baselineProcessed = ApplyPostProcessing(baselineEdges, nodes, layoutOptions); + var baselineProcessedScore = ElkEdgeRoutingScoring.ComputeScore(baselineProcessed, nodes); + var baselineBrokenHighways = HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(baselineProcessed, nodes) + : Array.Empty(); + var baselineRetryState = BuildRetryState(baselineProcessedScore, baselineBrokenHighways.Count); + var targetValidSolutions = DetermineTargetValidSolutionCount(baselineRetryState, config); + var maxStrategiesToAttempt = DetermineStrategySearchBudget(baselineRetryState, config); + ElkLayoutDiagnostics.LogProgress( + $"Iterative baseline: score={baselineProcessedScore.Value:F0} retry={DescribeRetryState(baselineRetryState)} " + + $"target-valid={targetValidSolutions} max-strategies={maxStrategiesToAttempt}"); + fallbackSolutions.Add(new CandidateSolution(baselineProcessedScore, baselineRetryState, baselineProcessed, 0)); + if (!baselineRetryState.RequiresPrimaryRetry) + { + validSolutions.Add(new CandidateSolution(baselineProcessedScore, baselineRetryState, baselineProcessed, 0)); + } + + if (diagnostics is not null) + { + diagnostics.IterativeBaselineScore = baselineProcessedScore; + diagnostics.BaselineBrokenShortHighwayCount = baselineBrokenHighways.Count; + diagnostics.IterativeStrategies.Add(new ElkIterativeStrategyDiagnostics + { + StrategyIndex = 0, + OrderingName = "baseline", + Attempts = 0, + BestScore = baselineProcessedScore, + Outcome = !baselineRetryState.RequiresPrimaryRetry + ? "valid" + : $"invalid({DescribeRetryState(baselineRetryState)})", + BestEdges = baselineProcessed, + }); + } + + if (ShouldKeepBaselineSolution(baselineEdges, nodes, baselineRetryState)) + { + ElkLayoutDiagnostics.LogProgress( + $"Iterative short-circuit: baseline accepted for simple/protected graph score={baselineProcessedScore.Value:F0}"); + if (diagnostics is not null) + { + diagnostics.SelectedStrategyIndex = 0; + diagnostics.FinalScore = baselineProcessedScore; + diagnostics.FinalBrokenShortHighwayCount = baselineBrokenHighways.Count; + } + + return baselineProcessed; + } + + var strategyInputs = GenerateStrategies(baselineEdges, nodes, config, minLineClearance) + .Take(maxStrategiesToAttempt) + .Select((strategy, zeroBasedIndex) => new StrategyWorkItem( + zeroBasedIndex + 1, + zeroBasedIndex < OrderingNames.Length + ? OrderingNames[zeroBasedIndex] + : $"strategy-{zeroBasedIndex + 1}", + strategy)) + .ToArray(); + + var strategyTasks = strategyInputs + .Select(input => Task.Factory.StartNew( + () => EvaluateStrategy(input, baselineEdges, nodes, layoutOptions, config, cancellationToken, diagnostics), + cancellationToken, + TaskCreationOptions.LongRunning, + TaskScheduler.Default)) + .ToArray(); + + var strategyResults = strategyTasks.Length > 0 + ? Task.WhenAll(strategyTasks).GetAwaiter().GetResult() + : []; + + foreach (var strategyResult in strategyResults.OrderBy(result => result.StrategyIndex)) + { + fallbackSolutions.AddRange(strategyResult.FallbackSolutions); + if (strategyResult.ValidSolution is { } validSolution) + { + validSolutions.Add(validSolution); + } + + if (diagnostics is not null) + { + diagnostics.IterativeStrategies.Add(strategyResult.Diagnostics); + } + } + + var best = validSolutions.Count > 0 + ? SelectBestValidSolution(validSolutions) + : SelectBestFallbackSolution(fallbackSolutions); + + if (diagnostics is not null) + { + diagnostics.SelectedStrategyIndex = best.StrategyIndex; + diagnostics.FinalScore = best.Score; + diagnostics.FinalBrokenShortHighwayCount = HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(best.Edges, nodes).Count + : 0; + } + + return best.Edges; + } + + 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( + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue); + var attemptDetails = new List(); + var fallbackSolutions = new List(); + CandidateSolution? validSolution = null; + var outcome = "no-valid"; + var attempts = 0; + var stagnantAttempts = 0; + var strategyStopwatch = Stopwatch.StartNew(); + + for (var attempt = 0; attempt < config.MaxAdaptationsPerStrategy; 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 || bestAttemptEdges is null || bestAttemptScore is null) + { + routeResult = MeasurePhase( + "route-all-edges", + () => RouteAllEdges(baselineEdges, nodes, config.ObstacleMargin, strategy, cancellationToken)); + } + else + { + repairPlan = MeasurePhase( + "select-repair-targets", + () => BuildRepairPlan(bestAttemptEdges, nodes, bestAttemptScore.Value, bestAttemptRetryState, strategy, attempt)); + if (repairPlan is null) + { + outcome = $"no-repair-targets({DescribeRetryState(bestAttemptRetryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + + 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( + bestAttemptEdges, + 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; + 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( + "normalize-boundary-angles", + () => ElkEdgePostProcessor.NormalizeBoundaryAngles(candidateEdges, nodes)); + candidateEdges = MeasurePhase( + "avoid-node-crossings-2", + () => ElkEdgePostProcessor.AvoidNodeCrossings(candidateEdges, nodes, layoutOptions.Direction)); + candidateEdges = MeasurePhase( + "simplify-2", + () => ElkEdgePostProcessorSimplify.SimplifyEdgePaths(candidateEdges, nodes)); + candidateEdges = MeasurePhase( + "avoid-node-crossings-3", + () => ElkEdgePostProcessor.AvoidNodeCrossings(candidateEdges, nodes, layoutOptions.Direction)); + candidateEdges = MeasurePhase( + "normalize-boundary-angles-final", + () => ElkEdgePostProcessor.NormalizeBoundaryAngles(candidateEdges, nodes)); + candidateEdges = MeasurePhase( + "normalize-source-exits-final", + () => ElkEdgePostProcessor.NormalizeSourceExitAngles(candidateEdges, nodes)); + candidateEdges = MeasurePhase( + "repair-boundary-lanes", + () => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches( + candidateEdges, + nodes, + strategy.MinLineClearance)); + candidateEdges = MeasurePhase( + "avoid-node-crossings-4", + () => ElkEdgePostProcessor.AvoidNodeCrossings(candidateEdges, nodes, layoutOptions.Direction)); + candidateEdges = MeasurePhase( + "repair-boundary-lanes-final", + () => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches( + candidateEdges, + nodes, + 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); + 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); + + var improvedAttempt = bestAttemptScore is null + || IsBetterCandidate(score, retryState, bestAttemptScore.Value, bestAttemptRetryState); + if (improvedAttempt) + { + bestAttemptScore = score; + bestAttemptEdges = candidateEdges; + bestAttemptRetryState = retryState; + stagnantAttempts = 0; + } + else + { + stagnantAttempts++; + } + + 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(); + attemptDetails.Add(new ElkIterativeAttemptDiagnostics + { + Attempt = attempt + 1, + TotalDurationMs = Math.Round(attemptStopwatch.Elapsed.TotalMilliseconds, 3), + Score = score, + Outcome = attemptOutcome, + RouteDiagnostics = routeResult.Diagnostics, + Edges = candidateEdges, + }); + attemptDetails[^1].PhaseTimings.AddRange(phaseTimings); + } + + if (score.NodeCrossings > maxAllowedNodeCrossings) + { + if (ShouldStopForStagnation(stagnantAttempts, attempt)) + { + outcome = $"stalled({DescribeRetryState(bestAttemptRetryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + + 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.TargetApproachJoinViolations > 0 + || retryState.TargetApproachBacktrackingViolations > 0) + { + if (ShouldRetryForPrimaryViolations(retryState, attempt, config.MaxAdaptationsPerStrategy)) + { + if (ShouldStopForStagnation(stagnantAttempts, attempt)) + { + outcome = $"stalled({DescribeRetryState(bestAttemptRetryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + + 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.RequiresQualityRetry) + { + if (ShouldRetryForPrimaryViolations(retryState, attempt, config.MaxAdaptationsPerStrategy)) + { + if (ShouldStopForStagnation(stagnantAttempts, attempt)) + { + outcome = $"stalled({DescribeRetryState(bestAttemptRetryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + + outcome = $"{attemptOutcome}@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] adapting for quality/length violations"); + strategy.AdaptForViolations(score, attempt, retryState); + continue; + } + + outcome = $"valid-after-retry@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + validSolution = candidate; + break; + } + + if (ShouldRetryForEdgeCrossings(retryState, attempt, config.MaxAdaptationsPerStrategy)) + { + if (ShouldStopForStagnation(stagnantAttempts, attempt)) + { + outcome = $"stalled({DescribeRetryState(bestAttemptRetryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + + outcome = $"{attemptOutcome}@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] adapting for edge crossings"); + strategy.AdaptForViolations(score, attempt, retryState); + continue; + } + + outcome = $"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); + + 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 rerouted = ElkEdgeRouterAStar8Dir.Route( + section.StartPoint, + endPoint, + 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 collectorRepairSet = repairPlan.Reasons.Contains("collector-corridors", StringComparer.Ordinal) + ? repairSet + .Where(edgeIndex => edgeIndex >= 0 + && edgeIndex < existingEdges.Length + && ElkEdgePostProcessor.IsRepeatCollectorLabel(existingEdges[edgeIndex].Label)) + .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(); + + foreach (var edgeIndex in orderedRepairIndices) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (edgeIndex < 0 || edgeIndex >= existingEdges.Length) + { + continue; + } + + var edge = existingEdges[edgeIndex]; + if (!ShouldRouteEdge(edge, 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; + List? rerouted = null; + if (preferredShortestEdgeIdSet.Contains(edge.Id)) + { + var targetNode = nodesById.GetValueOrDefault(edge.TargetNodeId ?? string.Empty); + rerouted = TryRouteShortestRepair( + section.StartPoint, + endPoint, + nodes, + obstacles, + edge.SourceNodeId ?? string.Empty, + edge.TargetNodeId ?? string.Empty, + targetNode, + strategy.RoutingParams, + softObstacles, + cancellationToken); + } + + rerouted ??= ElkEdgeRouterAStar8Dir.Route( + section.StartPoint, + endPoint, + 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 = "local-repair", + TotalEdges = existingEdges.Length, + RoutedEdges = routedEdgeCount, + SkippedEdges = skippedEdgeCount, + RoutedSections = routedSectionCount, + FallbackSections = fallbackSectionCount, + SoftObstacleSegments = softObstacles.Count, + RepairedEdgeIds = repairPlan.EdgeIds, + RepairReasons = repairPlan.Reasons, + }); + } + + 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 reasons = new List(); + + void AddReason(string reason) + { + if (!reasons.Contains(reason, StringComparer.Ordinal)) + { + reasons.Add(reason); + } + } + + void AddEdgeIds(IEnumerable edgeIds, int severity, string reason) + { + AddReason(reason); + foreach (var edgeId in edgeIds) + { + severityByEdgeId[edgeId] = severityByEdgeId.GetValueOrDefault(edgeId) + severity; + } + } + + void MergeSeverity( + Dictionary metricSeverity, + string reason, + bool preferShortestRepair = false) + { + if (metricSeverity.Count == 0) + { + return; + } + + AddReason(reason); + foreach (var (edgeId, severity) in metricSeverity) + { + severityByEdgeId[edgeId] = severityByEdgeId.GetValueOrDefault(edgeId) + severity; + 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); + } + + if (retryState.ExcessiveDetourViolations > 0) + { + var detourSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountExcessiveDetourViolations(edges, nodes, detourSeverity, 2_000); + MergeSeverity(detourSeverity, "detour-priority", preferShortestRepair: true); + } + } + else + { + if (score.NodeCrossings > 0) + { + ElkEdgeRoutingScoring.CountEdgeNodeCrossings(edges, nodes, severityByEdgeId, 2_500); + AddReason("node-crossings"); + } + + if (retryState.RemainingShortHighways > 0) + { + var brokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(edges, nodes); + AddEdgeIds( + brokenHighways.SelectMany(highway => highway.EdgeIds).Distinct(StringComparer.Ordinal), + 2_000, + "short-highways"); + } + + if (retryState.RepeatCollectorCorridorViolations > 0) + { + ElkEdgeRoutingScoring.CountRepeatCollectorCorridorViolations(edges, nodes, severityByEdgeId, 2_000); + AddReason("collector-corridors"); + } + + if (retryState.TargetApproachJoinViolations > 0) + { + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(edges, nodes, severityByEdgeId, 1_500); + AddReason("target-joins"); + } + + if (retryState.TargetApproachBacktrackingViolations > 0) + { + var backtrackingSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(edges, nodes, backtrackingSeverity, 1_600); + MergeSeverity(backtrackingSeverity, "approach-backtracking", preferShortestRepair: true); + } + + if (retryState.ExcessiveDetourViolations > 0) + { + var detourSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountExcessiveDetourViolations(edges, nodes, detourSeverity, 1_250); + MergeSeverity(detourSeverity, "detour", preferShortestRepair: true); + } + + if (retryState.ProximityViolations > 0) + { + ElkEdgeRoutingScoring.CountProximityViolations(edges, nodes, severityByEdgeId, 350); + AddReason("proximity"); + } + + if (retryState.EntryAngleViolations > 0) + { + ElkEdgeRoutingScoring.CountBadBoundaryAngles(edges, nodes, severityByEdgeId, 450); + AddReason("entry"); + } + + if (retryState.LabelProximityViolations > 0) + { + ElkEdgeRoutingScoring.CountLabelProximityViolations(edges, nodes, severityByEdgeId, 300); + AddReason("label"); + } + + if (retryState.EdgeCrossings > 0) + { + ElkEdgeRoutingScoring.CountEdgeEdgeCrossings(edges, severityByEdgeId, 200); + AddReason("edge-crossings"); + } + } + + 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 maxEdgeRepairs = DetermineRepairEdgeBudget(retryState, attempt == 1 && retryState.RequiresLengthRetry); + var selectedEdgeIds = severityByEdgeId + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => orderRankByEdgeId.GetValueOrDefault(pair.Key, int.MaxValue)) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Take(maxEdgeRepairs) + .Select(pair => pair.Key) + .ToArray(); + if (reasons.Contains("collector-corridors", StringComparer.Ordinal)) + { + selectedEdgeIds = ExpandRepeatCollectorRepairSet(selectedEdgeIds, edges, nodes); + } + + if (selectedEdgeIds.Length == 0) + { + return null; + } + + var preferredSelectedEdgeIds = selectedEdgeIds + .Where(preferredShortestEdgeIds.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, + 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 int DetermineRepairEdgeBudget(RoutingRetryState retryState, bool detourPriority) + { + if (detourPriority) + { + return 2; + } + + var budget = retryState.RequiresBlockingRetry + ? 4 + : retryState.RequiresLengthRetry + ? 3 + : 2; + if (retryState.ProximityViolations >= 6 || retryState.EdgeCrossings >= 8) + { + budget++; + } + + return Math.Clamp(budget, 2, 6); + } + + private static ElkRoutedEdge[] ApplyPostProcessing( + 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); + } + + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction); + result = ElkEdgePostProcessorSimplify.SimplifyEdgePaths(result, nodes); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(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.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance); + return result; + } + + private static IEnumerable GenerateStrategies( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + IterativeRoutingConfig config, + double minLineClearance) + { + var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); + var connectionCount = new Dictionary(StringComparer.Ordinal); + foreach (var edge in edges) + { + var srcId = edge.SourceNodeId ?? ""; + var tgtId = edge.TargetNodeId ?? ""; + connectionCount[srcId] = connectionCount.GetValueOrDefault(srcId) + 1; + connectionCount[tgtId] = connectionCount.GetValueOrDefault(tgtId) + 1; + } + + var orderings = new[] + { + OrderByLongestFirst(edges, nodesById), + OrderByShortestFirst(edges, nodesById), + OrderByMostConnectedFirst(edges, connectionCount), + OrderByReverse(edges), + }; + + // Strategies 1-4: base params, clearance = half avg node dimension + var baseParams = new AStarRoutingParams(18d, 200d, 500d, 2.0d, minLineClearance, 40d, true); + foreach (var order in orderings) + { + yield return new RoutingStrategy + { + EdgeOrder = order, + BaseLineClearance = minLineClearance, + MinLineClearance = minLineClearance, + RoutingParams = baseParams, + }; + } + + // Strategies 5-8: higher penalties and tighter clearance + var highParams = new AStarRoutingParams(18d, 400d, 600d, 3.0d, minLineClearance, 40d, true); + foreach (var order in orderings) + { + yield return new RoutingStrategy + { + EdgeOrder = order, + BaseLineClearance = minLineClearance, + MinLineClearance = minLineClearance, + RoutingParams = highParams, + }; + } + + // Strategies 9+: seeded random with clearance + for (var i = 0; i < 8; i++) + { + var seed = HashCode.Combine(edges.Length, nodes.Length, i + 8); + var rng = new Random(seed); + var order = Enumerable.Range(0, edges.Length).ToArray(); + Shuffle(order, rng); + var randomParams = new AStarRoutingParams( + 18d, + 80d + (rng.NextDouble() * 420d), + 300d + (rng.NextDouble() * 500d), + 1.0d + (rng.NextDouble() * 3.0d), + minLineClearance * (0.5d + rng.NextDouble()), + 40d, + true); + yield return new RoutingStrategy + { + EdgeOrder = order, + BaseLineClearance = minLineClearance, + MinLineClearance = minLineClearance, + RoutingParams = randomParams, + }; + } + } + + private static RoutingRetryState BuildRetryState(EdgeRoutingScore score, int remainingBrokenHighways) + { + return new RoutingRetryState( + RemainingShortHighways: remainingBrokenHighways, + RepeatCollectorCorridorViolations: score.RepeatCollectorCorridorViolations, + TargetApproachJoinViolations: score.TargetApproachJoinViolations, + TargetApproachBacktrackingViolations: score.TargetApproachBacktrackingViolations, + ExcessiveDetourViolations: score.ExcessiveDetourViolations, + ProximityViolations: score.ProximityViolations, + EntryAngleViolations: score.EntryAngleViolations, + LabelProximityViolations: score.LabelProximityViolations, + EdgeCrossings: score.EdgeCrossings); + } + + private static string DescribeRetryState(RoutingRetryState retryState) + { + var parts = new List(5); + if (retryState.RemainingShortHighways > 0) + { + parts.Add($"short-highways={retryState.RemainingShortHighways}"); + } + + if (retryState.RepeatCollectorCorridorViolations > 0) + { + parts.Add($"collector-corridors={retryState.RepeatCollectorCorridorViolations}"); + } + + if (retryState.TargetApproachJoinViolations > 0) + { + parts.Add($"target-joins={retryState.TargetApproachJoinViolations}"); + } + + if (retryState.TargetApproachBacktrackingViolations > 0) + { + parts.Add($"approach-backtracking={retryState.TargetApproachBacktrackingViolations}"); + } + + if (retryState.ExcessiveDetourViolations > 0) + { + parts.Add($"detour={retryState.ExcessiveDetourViolations}"); + } + + if (retryState.ProximityViolations > 0) + { + parts.Add($"proximity={retryState.ProximityViolations}"); + } + + if (retryState.EntryAngleViolations > 0) + { + parts.Add($"entry={retryState.EntryAngleViolations}"); + } + + if (retryState.LabelProximityViolations > 0) + { + parts.Add($"label={retryState.LabelProximityViolations}"); + } + + if (retryState.EdgeCrossings > 0) + { + parts.Add($"edge-crossings={retryState.EdgeCrossings}"); + } + + return parts.Count > 0 + ? string.Join(", ", parts) + : "none"; + } + + private static bool ShouldRetryForEdgeCrossings( + RoutingRetryState retryState, + int attempt, + int maxAdaptationsPerStrategy) + { + if (retryState.RequiresPrimaryRetry || retryState.EdgeCrossings <= 0) + { + return false; + } + + var crossingRetryBudget = Math.Min( + retryState.EdgeCrossings >= 12 ? 2 : 1, + Math.Max(0, maxAdaptationsPerStrategy - 1)); + return attempt < crossingRetryBudget; + } + + private static bool ShouldStopForStagnation(int stagnantAttempts, int attempt) + { + return attempt >= 2 && stagnantAttempts >= 2; + } + + private static bool ShouldRetryForPrimaryViolations( + RoutingRetryState retryState, + int attempt, + int maxAdaptationsPerStrategy) + { + if (!retryState.RequiresPrimaryRetry) + { + return false; + } + + var primaryRetryBudget = DeterminePrimaryRetryBudget(retryState, maxAdaptationsPerStrategy); + return attempt < primaryRetryBudget; + } + + private static int DeterminePrimaryRetryBudget( + RoutingRetryState retryState, + int maxAdaptationsPerStrategy) + { + var maxRetries = Math.Max(0, maxAdaptationsPerStrategy - 1); + if (maxRetries == 0) + { + return 0; + } + + var budget = 0; + if (retryState.RequiresBlockingRetry) + { + budget = Math.Max( + budget, + Math.Min(4, 2 + retryState.BlockingViolationCount)); + } + + if (retryState.RequiresLengthRetry) + { + budget = Math.Max( + budget, + Math.Min(3, 1 + retryState.LengthViolationCount)); + } + + if (retryState.RequiresQualityRetry) + { + var qualitySeverity = retryState.ProximityViolations + + retryState.EntryAngleViolations + + retryState.LabelProximityViolations; + budget = Math.Max( + budget, + qualitySeverity >= 12 + ? 3 + : qualitySeverity >= 6 + ? 2 + : 1); + } + + return Math.Min(maxRetries, Math.Max(1, budget)); + } + + 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) + { + 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 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.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.EntryAngleViolations != right.EntryAngleViolations) + { + return left.EntryAngleViolations.CompareTo(right.EntryAngleViolations); + } + + 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 (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]; + var retryComparison = CompareRetryStates(candidate.RetryState, best.RetryState); + if (retryComparison < 0 + || (retryComparison == 0 && candidate.Score.NodeCrossings < best.Score.NodeCrossings) + || (retryComparison == 0 && candidate.Score.NodeCrossings == best.Score.NodeCrossings + && candidate.Score.EdgeCrossings < best.Score.EdgeCrossings) + || (retryComparison == 0 && candidate.Score.NodeCrossings == best.Score.NodeCrossings + && 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 side = ResolveEntrySide(ep, 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 sideLength = side is "left" or "right" + ? Math.Max(8d, node.Height - 8d) + : Math.Max(8d, node.Width - 8d); + var slotSpacing = group.Count > 1 + ? Math.Max(12d, Math.Min(minLineClearance, sideLength / (group.Count - 1))) + : 0d; + var totalSpan = (group.Count - 1) * slotSpacing; + + if (side is "left" or "right") + { + // Spread along Y on a vertical side + var centerY = node.Y + (node.Height / 2d); + var startY = centerY - (totalSpan / 2d); + startY = Math.Max(startY, node.Y + 4d); + + // Sort by original Y to preserve relative order + var sorted = group.OrderBy(g => g.OrigEnd.Y).ToList(); + for (var i = 0; i < sorted.Count; i++) + { + var slotY = startY + (i * slotSpacing); + slotY = Math.Min(slotY, node.Y + node.Height - 4d); + result[sorted[i].EdgeId] = new ElkPoint { X = sorted[i].OrigEnd.X, Y = slotY }; + } + } + else + { + // Spread along X on a horizontal side (top/bottom) + var centerX = node.X + (node.Width / 2d); + var startX = centerX - (totalSpan / 2d); + startX = Math.Max(startX, node.X + 4d); + + var sorted = group.OrderBy(g => g.OrigEnd.X).ToList(); + for (var i = 0; i < sorted.Count; i++) + { + var slotX = startX + (i * slotSpacing); + slotX = Math.Min(slotX, node.X + node.Width - 4d); + result[sorted[i].EdgeId] = new ElkPoint { X = slotX, Y = sorted[i].OrigEnd.Y }; + } + } + } + + return result; + } + + private static string ResolveEntrySide(ElkPoint endpoint, ElkPositionedNode node) + { + var distLeft = Math.Abs(endpoint.X - node.X); + var distRight = Math.Abs(endpoint.X - (node.X + node.Width)); + var distTop = Math.Abs(endpoint.Y - node.Y); + var distBottom = Math.Abs(endpoint.Y - (node.Y + node.Height)); + var min = Math.Min(Math.Min(distLeft, distRight), Math.Min(distTop, distBottom)); + if (Math.Abs(min - distLeft) < 0.1d) return "left"; + if (Math.Abs(min - distRight) < 0.1d) return "right"; + if (Math.Abs(min - distTop) < 0.1d) return "top"; + return "bottom"; + } + + 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 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 List? TryRouteShortestRepair( + ElkPoint start, + ElkPoint end, + IReadOnlyCollection nodes, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + string sourceId, + string targetId, + ElkPositionedNode? targetNode, + AStarRoutingParams routingParams, + IReadOnlyList softObstacles, + CancellationToken cancellationToken) + { + var candidateEndpoints = EnumerateShortestRepairEndpoints(start, end, targetNode).ToArray(); + List? bestPath = null; + var bestLength = double.MaxValue; + + foreach (var candidateEnd in candidateEndpoints) + { + var orthogonalShortcut = TryBuildShortestOrthogonalPath(start, candidateEnd, nodes, sourceId, targetId, targetNode); + 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; + } + } + + if (bestPath is not null) + { + return bestPath; + } + + var shortestParams = routingParams with + { + Margin = Math.Max(4d, Math.Min(8d, routingParams.Margin * 0.4d)), + BendPenalty = Math.Min(routingParams.BendPenalty, 80d), + DiagonalPenalty = Math.Min(routingParams.DiagonalPenalty, 40d), + SoftObstacleWeight = Math.Max(0.25d, routingParams.SoftObstacleWeight * 0.35d), + SoftObstacleClearance = Math.Max(12d, 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? TryBuildShortestOrthogonalPath( + ElkPoint start, + ElkPoint end, + IReadOnlyCollection nodes, + string sourceId, + string targetId, + ElkPositionedNode? targetNode) + { + var rawObstacles = 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, 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) + { + yield return currentEnd; + if (targetNode is null) + { + yield break; + } + + 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) + { + if (!ElkEdgeRoutingGeometry.PointsEqual(candidate, currentEnd)) + { + yield return candidate; + } + } + } + + 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 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 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 (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 IterativeRoutingConfig ResolveConfig(ElkLayoutOptions layoutOptions) + { + var requested = layoutOptions.IterativeRouting ?? new IterativeRoutingOptions(); + var enabled = requested.Enabled ?? layoutOptions.Effort == ElkLayoutEffort.Best; + + return new IterativeRoutingConfig( + Enabled: enabled, + MaxAdaptationsPerStrategy: Math.Max(1, requested.MaxAdaptationsPerStrategy), + RequiredValidSolutions: Math.Max(1, requested.RequiredValidSolutions), + ObstacleMargin: 18d); + } + + private readonly record struct CandidateSolution( + EdgeRoutingScore Score, + RoutingRetryState RetryState, + ElkRoutedEdge[] Edges, + int StrategyIndex); + + private readonly record struct StrategyWorkItem( + int StrategyIndex, + string StrategyName, + RoutingStrategy Strategy); + + private sealed record StrategyEvaluationResult( + int StrategyIndex, + IReadOnlyList FallbackSolutions, + CandidateSolution? ValidSolution, + ElkIterativeStrategyDiagnostics Diagnostics); + + private readonly record struct RepairPlan( + int[] EdgeIndices, + string[] EdgeIds, + string[] PreferredShortestEdgeIds, + string[] Reasons); + + private sealed record RouteAllEdgesResult( + ElkRoutedEdge[] Edges, + ElkIterativeRouteDiagnostics Diagnostics); +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingGeometry.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingGeometry.cs index 6f0dd029b..78839180e 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingGeometry.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingGeometry.cs @@ -92,6 +92,104 @@ internal static class ElkEdgeRoutingGeometry 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); + var rightSegments = FlattenSegments(right); + var longest = 0d; + + foreach (var leftSegment in leftSegments) + { + foreach (var rightSegment in rightSegments) + { + longest = Math.Max(longest, ComputeSharedSegmentLength( + leftSegment.Start, + leftSegment.End, + rightSegment.Start, + rightSegment.End)); + } + } + + return longest; + } + + internal static double ComputeLongestSharedApproachSegmentLength( + IReadOnlyList leftPath, + IReadOnlyList rightPath, + int maxSegmentsFromEnd = 3) + { + var leftSegments = FlattenSegmentsNearEnd(leftPath, maxSegmentsFromEnd); + var rightSegments = FlattenSegmentsNearEnd(rightPath, maxSegmentsFromEnd); + var longest = 0d; + + foreach (var leftSegment in leftSegments) + { + foreach (var rightSegment in rightSegments) + { + longest = Math.Max(longest, ComputeSharedSegmentLength( + leftSegment.Start, + leftSegment.End, + rightSegment.Start, + rightSegment.End)); + } + } + + 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 bool AreCollinearAndOverlapping(ElkPoint a1, ElkPoint a2, ElkPoint b1, ElkPoint b2) { if (IsHorizontal(a1, a2) && IsHorizontal(b1, b2) && Math.Abs(a1.Y - b1.Y) <= CoordinateTolerance) @@ -128,6 +226,25 @@ internal static class ElkEdgeRoutingGeometry 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); diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.cs index 6fce4639e..d8bfd278d 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.cs @@ -2,6 +2,8 @@ namespace StellaOps.ElkSharp; internal static class ElkEdgeRoutingScoring { + private static readonly bool HighwayScoringEnabled = true; + internal static EdgeRoutingScore ComputeScore( IReadOnlyCollection edges, IReadOnlyCollection nodes) @@ -11,11 +13,27 @@ internal static class ElkEdgeRoutingScoring var bendCount = SumBendPoints(edges); var totalPathLength = SumPathLengths(edges); var targetCongestion = CountTargetApproachCongestion(edges); + var diagonalCount = CountDiagonalSegments(edges); + var entryAngleViolations = CountBadBoundaryAngles(edges, nodes); + var labelProximityViolations = CountLabelProximityViolations(edges, nodes); + var repeatCollectorCorridorViolations = CountRepeatCollectorCorridorViolations(edges, nodes); + var targetApproachJoinViolations = CountTargetApproachJoinViolations(edges, nodes); + var targetApproachBacktrackingViolations = CountTargetApproachBacktrackingViolations(edges, nodes); + var excessiveDetourViolations = CountExcessiveDetourViolations(edges, nodes); + var proximityViolations = CountProximityViolations(edges, nodes); var value = -(nodeCrossings * 100_000d) - (edgeCrossings * 650d) - (bendCount * 5d) - (targetCongestion * 25d) + - (diagonalCount * 200d) + - (entryAngleViolations * 500d) + - (labelProximityViolations * 300d) + - (repeatCollectorCorridorViolations * 100_000d) + - (targetApproachJoinViolations * 100_000d) + - (targetApproachBacktrackingViolations * 50_000d) + - (excessiveDetourViolations * 50_000d) + - (proximityViolations * 400d) - (totalPathLength * 0.1d); return new EdgeRoutingScore( @@ -23,6 +41,14 @@ internal static class ElkEdgeRoutingScoring edgeCrossings, bendCount, targetCongestion, + diagonalCount, + entryAngleViolations, + labelProximityViolations, + repeatCollectorCorridorViolations, + targetApproachJoinViolations, + targetApproachBacktrackingViolations, + excessiveDetourViolations, + proximityViolations, totalPathLength, value); } @@ -72,6 +98,8 @@ internal static class ElkEdgeRoutingScoring Right: node.X + node.Width, Bottom: node.Y + node.Height, Id: node.Id)).ToArray(); + var graphMinY = nodes.Count > 0 ? nodes.Min(n => n.Y) : 0d; + var graphMaxY = nodes.Count > 0 ? nodes.Max(n => n.Y + n.Height) : 0d; var crossingCount = 0; foreach (var edge in edges) @@ -79,6 +107,13 @@ internal static class ElkEdgeRoutingScoring var edgeCrossings = 0; foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge)) { + // Skip corridor segments (outside graph bounds) — they can't cross nodes + if (segment.Start.Y < graphMinY - 8d || segment.Start.Y > graphMaxY + 8d + || segment.End.Y < graphMinY - 8d || segment.End.Y > graphMaxY + 8d) + { + continue; + } + if (ElkEdgePostProcessor.SegmentCrossesObstacle( segment.Start, segment.End, @@ -150,6 +185,913 @@ internal static class ElkEdgeRoutingScoring return edges.Sum(ElkEdgeRoutingGeometry.ComputePathLength); } + internal static int CountDiagonalSegments(IReadOnlyCollection edges) + { + var count = 0; + foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edges)) + { + var dx = Math.Abs(segment.End.X - segment.Start.X); + var dy = Math.Abs(segment.End.Y - segment.Start.Y); + if (dx > 3d && dy > 3d) + { + count++; + } + } + + return count; + } + + internal static int CountBadEntryAngles( + IReadOnlyCollection edges, + IReadOnlyCollection nodes) + { + return CountBadEntryAngles(edges, nodes, null); + } + + internal static int CountBadEntryAngles( + IReadOnlyCollection edges, + IReadOnlyCollection nodes, + Dictionary? severityByEdgeId, + int severityWeight = 1) + { + // Counts 0/180° joins: last segment parallel to the target node's entry side. + // Vertical side (left/right): bad if last segment is vertical (parallel). + // Horizontal side (top/bottom): bad if last segment is horizontal (parallel). + var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); + var count = 0; + + foreach (var edge in edges) + { + var edgeViolations = 0; + var lastSection = edge.Sections.LastOrDefault(); + if (lastSection is null) + { + continue; + } + + var points = new List { lastSection.StartPoint }; + points.AddRange(lastSection.BendPoints); + points.Add(lastSection.EndPoint); + + if (points.Count < 2) + { + continue; + } + + if (!nodesById.TryGetValue(edge.TargetNodeId ?? "", out var targetNode)) + { + continue; + } + + var from = points[^2]; + var to = points[^1]; + var segDx = Math.Abs(to.X - from.X); + var segDy = Math.Abs(to.Y - from.Y); + + if (segDx < 3d && segDy < 3d) + { + continue; + } + + var onLeftSide = Math.Abs(to.X - targetNode.X) < 2d; + var onRightSide = Math.Abs(to.X - (targetNode.X + targetNode.Width)) < 2d; + var onTopSide = Math.Abs(to.Y - targetNode.Y) < 2d; + var onBottomSide = Math.Abs(to.Y - (targetNode.Y + targetNode.Height)) < 2d; + + var touchesVerticalSide = onLeftSide || onRightSide; + var touchesHorizontalSide = onTopSide || onBottomSide; + var validForVerticalSide = segDx > segDy * 3d; + var validForHorizontalSide = segDy > segDx * 3d; + + if (touchesVerticalSide && !touchesHorizontalSide && !validForVerticalSide) + { + count++; + edgeViolations++; + } + else if (touchesHorizontalSide && !touchesVerticalSide && !validForHorizontalSide) + { + count++; + edgeViolations++; + } + else if (touchesVerticalSide && touchesHorizontalSide + && !validForVerticalSide && !validForHorizontalSide) + { + count++; + edgeViolations++; + } + + if (edgeViolations > 0 && severityByEdgeId is not null) + { + severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + (edgeViolations * severityWeight); + } + } + + return count; + } + + internal static int CountBadBoundaryAngles( + IReadOnlyCollection edges, + IReadOnlyCollection nodes) + { + return CountBadBoundaryAngles(edges, nodes, null); + } + + internal static int CountBadBoundaryAngles( + IReadOnlyCollection edges, + IReadOnlyCollection nodes, + Dictionary? severityByEdgeId, + int severityWeight = 1) + { + var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); + var count = 0; + + foreach (var edge in edges) + { + var edgeViolations = 0; + var firstSection = edge.Sections.FirstOrDefault(); + var lastSection = edge.Sections.LastOrDefault(); + if (firstSection is null || lastSection is null) + { + continue; + } + + if (nodesById.TryGetValue(edge.SourceNodeId ?? "", out var sourceNode)) + { + var sourcePoints = new List { firstSection.StartPoint }; + sourcePoints.AddRange(firstSection.BendPoints); + sourcePoints.Add(firstSection.EndPoint); + if (sourcePoints.Count >= 2 && !HasValidBoundaryAngle(sourcePoints[0], sourcePoints[1], sourceNode)) + { + count++; + edgeViolations++; + } + } + + if (nodesById.TryGetValue(edge.TargetNodeId ?? "", out var targetNode)) + { + var targetPoints = new List { lastSection.StartPoint }; + targetPoints.AddRange(lastSection.BendPoints); + targetPoints.Add(lastSection.EndPoint); + if (targetPoints.Count >= 2 && !HasValidBoundaryAngle(targetPoints[^1], targetPoints[^2], targetNode)) + { + count++; + edgeViolations++; + } + } + + if (edgeViolations > 0 && severityByEdgeId is not null) + { + severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + (edgeViolations * severityWeight); + } + } + + return count; + } + + internal static int CountRepeatCollectorCorridorViolations( + IReadOnlyCollection edges, + IReadOnlyCollection nodes) + { + return CountRepeatCollectorCorridorViolations(edges, nodes, null); + } + + internal static int CountRepeatCollectorCorridorViolations( + IReadOnlyCollection edges, + IReadOnlyCollection nodes, + Dictionary? severityByEdgeId, + int severityWeight = 1) + { + return ElkRepeatCollectorCorridors.CountSharedLaneViolations(edges, nodes, severityByEdgeId, severityWeight); + } + + internal static int CountLabelProximityViolations( + IReadOnlyCollection edges, + IReadOnlyCollection nodes) + { + return CountLabelProximityViolations(edges, nodes, null); + } + + internal static int CountLabelProximityViolations( + IReadOnlyCollection edges, + IReadOnlyCollection nodes, + Dictionary? severityByEdgeId, + int severityWeight = 1) + { + // A labeled edge needs a long-enough first segment for the label to fit. + // In LTR, the first segment exits the source horizontally — if it's too short + // (immediate bend), the label gets squeezed or displaced. + const double minLabelSegmentLength = 40d; + var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); + var count = 0; + + foreach (var edge in edges) + { + if (string.IsNullOrWhiteSpace(edge.Label)) + { + continue; + } + + var firstSection = edge.Sections.FirstOrDefault(); + if (firstSection is null) + { + continue; + } + + var points = new List { firstSection.StartPoint }; + points.AddRange(firstSection.BendPoints); + points.Add(firstSection.EndPoint); + + if (points.Count < 2) + { + continue; + } + + // Measure the first segment length + var firstSegLen = ElkEdgeRoutingGeometry.ComputeSegmentLength(points[0], points[1]); + + // If the edge is very short overall (source and target close together), skip + if (!nodesById.TryGetValue(edge.SourceNodeId ?? "", out var srcNode) + || !nodesById.TryGetValue(edge.TargetNodeId ?? "", out var tgtNode)) + { + continue; + } + + var directDist = Math.Abs((tgtNode.X + tgtNode.Width / 2d) - (srcNode.X + srcNode.Width / 2d)); + if (directDist < minLabelSegmentLength * 2d) + { + continue; + } + + if (firstSegLen < minLabelSegmentLength) + { + count++; + if (severityByEdgeId is not null) + { + severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + severityWeight; + } + } + } + + return count; + } + + internal static int CountProximityViolations( + IReadOnlyCollection edges, + IReadOnlyCollection nodes) + { + return CountProximityViolations(edges, nodes, null); + } + + internal static int CountProximityViolations( + IReadOnlyCollection edges, + IReadOnlyCollection nodes, + Dictionary? severityByEdgeId, + int severityWeight = 1) + { + var minClearance = ResolveMinLineClearance(nodes); + + var segments = ElkEdgeRoutingGeometry.FlattenSegments(edges); + var edgesById = edges.ToDictionary(edge => edge.Id, StringComparer.Ordinal); + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var count = 0; + + // Line-line proximity: parallel segments from different edges closer than minClearance + for (var i = 0; i < segments.Count; i++) + { + for (var j = i + 1; j < segments.Count; j++) + { + if (string.Equals(segments[i].EdgeId, segments[j].EdgeId, StringComparison.Ordinal)) + { + continue; + } + + if (HighwayScoringEnabled + && IsApplicableSharedHighway(segments[i], segments[j], edgesById, nodesById)) + { + continue; + } + + if (ElkEdgeRoutingGeometry.AreParallelAndClose( + segments[i].Start, segments[i].End, + segments[j].Start, segments[j].End, + minClearance)) + { + count++; + if (severityByEdgeId is not null) + { + severityByEdgeId[segments[i].EdgeId] = severityByEdgeId.GetValueOrDefault(segments[i].EdgeId) + severityWeight; + severityByEdgeId[segments[j].EdgeId] = severityByEdgeId.GetValueOrDefault(segments[j].EdgeId) + severityWeight; + } + } + } + } + + // Line-node proximity: segments from non-source/target edges passing too close to nodes + foreach (var edge in edges) + { + foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge)) + { + foreach (var node in nodes) + { + if (node.Id == edge.SourceNodeId || node.Id == edge.TargetNodeId) + { + continue; + } + + var segIsH = Math.Abs(segment.Start.Y - segment.End.Y) < 2d; + var segIsV = Math.Abs(segment.Start.X - segment.End.X) < 2d; + + if (segIsH) + { + // Horizontal segment near node top/bottom + var distTop = Math.Abs(segment.Start.Y - node.Y); + var distBottom = Math.Abs(segment.Start.Y - (node.Y + node.Height)); + var minDist = Math.Min(distTop, distBottom); + if (minDist > 0.5d && minDist < minClearance) + { + var segMinX = Math.Min(segment.Start.X, segment.End.X); + var segMaxX = Math.Max(segment.Start.X, segment.End.X); + if (segMaxX > node.X && segMinX < node.X + node.Width) + { + count++; + if (severityByEdgeId is not null) + { + severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + severityWeight; + } + } + } + } + else if (segIsV) + { + // Vertical segment near node left/right + var distLeft = Math.Abs(segment.Start.X - node.X); + var distRight = Math.Abs(segment.Start.X - (node.X + node.Width)); + var minDist = Math.Min(distLeft, distRight); + if (minDist > 0.5d && minDist < minClearance) + { + var segMinY = Math.Min(segment.Start.Y, segment.End.Y); + var segMaxY = Math.Max(segment.Start.Y, segment.End.Y); + if (segMaxY > node.Y && segMinY < node.Y + node.Height) + { + count++; + if (severityByEdgeId is not null) + { + severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + severityWeight; + } + } + } + } + } + } + } + + return count; + } + + internal static int CountTargetApproachJoinViolations( + IReadOnlyCollection edges, + IReadOnlyCollection nodes) + { + return CountTargetApproachJoinViolations(edges, nodes, null); + } + + internal static int CountTargetApproachJoinViolations( + IReadOnlyCollection edges, + IReadOnlyCollection nodes, + Dictionary? severityByEdgeId, + int severityWeight = 1) + { + var minClearance = ResolveMinLineClearance(nodes); + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var count = 0; + + foreach (var group in edges.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.ResolveBoundarySide(leftPath[^1], 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.ResolveBoundarySide(rightPath[^1], targetNode); + if (!string.Equals(leftSide, rightSide, StringComparison.Ordinal)) + { + continue; + } + + if (HighwayScoringEnabled && IsApplicableSharedHighway(leftEdge, rightEdge, nodesById)) + { + continue; + } + + var maxSegmentsFromEnd = ElkEdgePostProcessor.IsRepeatCollectorLabel(leftEdge.Label) + && ElkEdgePostProcessor.IsRepeatCollectorLabel(rightEdge.Label) + ? 2 + : 3; + if (HasTargetApproachJoin(leftPath, rightPath, minClearance, maxSegmentsFromEnd)) + { + count++; + if (severityByEdgeId is not null) + { + severityByEdgeId[leftEdge.Id] = severityByEdgeId.GetValueOrDefault(leftEdge.Id) + severityWeight; + severityByEdgeId[rightEdge.Id] = severityByEdgeId.GetValueOrDefault(rightEdge.Id) + severityWeight; + } + } + } + } + } + + return count; + } + + internal static int CountExcessiveDetourViolations( + IReadOnlyCollection edges, + IReadOnlyCollection nodes) + { + return CountExcessiveDetourViolations(edges, nodes, null); + } + + internal static int CountTargetApproachBacktrackingViolations( + IReadOnlyCollection edges, + IReadOnlyCollection nodes) + { + return CountTargetApproachBacktrackingViolations(edges, nodes, null); + } + + internal static int CountTargetApproachBacktrackingViolations( + IReadOnlyCollection edges, + IReadOnlyCollection nodes, + Dictionary? severityByEdgeId, + int severityWeight = 1) + { + var graphMinY = nodes.Count > 0 ? nodes.Min(n => n.Y) : 0d; + var graphMaxY = nodes.Count > 0 ? nodes.Max(n => n.Y + n.Height) : 0d; + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var count = 0; + + foreach (var edge in edges) + { + if (!ShouldEnforceShortestPathRule(edge, nodes, graphMinY, graphMaxY) + || !nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)) + { + continue; + } + + var path = ExtractPath(edge); + if (!HasTargetApproachBacktracking(path, targetNode)) + { + continue; + } + + count++; + if (severityByEdgeId is not null) + { + severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + severityWeight; + } + } + + return count; + } + + internal static int CountExcessiveDetourViolations( + IReadOnlyCollection edges, + IReadOnlyCollection nodes, + Dictionary? severityByEdgeId, + int severityWeight = 1) + { + var minClearance = ResolveMinLineClearance(nodes); + var graphMinY = nodes.Count > 0 ? nodes.Min(n => n.Y) : 0d; + var graphMaxY = nodes.Count > 0 ? nodes.Max(n => n.Y + n.Height) : 0d; + var count = 0; + + foreach (var edge in edges) + { + if (!ShouldEnforceShortestPathRule(edge, nodes, graphMinY, graphMaxY)) + { + continue; + } + + var directLength = edge.Sections.Sum(section => + Math.Abs(section.EndPoint.X - section.StartPoint.X) + Math.Abs(section.EndPoint.Y - section.StartPoint.Y)); + if (directLength <= 1d) + { + continue; + } + + var pathLength = ElkEdgeRoutingGeometry.ComputePathLength(edge); + var excess = pathLength - directLength; + if (excess <= Math.Max(96d, minClearance * 2d)) + { + continue; + } + + var path = ExtractPath(edge); + if (path.Count < 2) + { + continue; + } + + var minEndpointX = Math.Min(path[0].X, path[^1].X); + var maxEndpointX = Math.Max(path[0].X, path[^1].X); + var minEndpointY = Math.Min(path[0].Y, path[^1].Y); + var maxEndpointY = Math.Max(path[0].Y, path[^1].Y); + + var minPathX = path.Min(point => point.X); + var maxPathX = path.Max(point => point.X); + var minPathY = path.Min(point => point.Y); + var maxPathY = path.Max(point => point.Y); + + var overshoot = Math.Max( + Math.Max(0d, minEndpointX - minPathX), + Math.Max( + Math.Max(0d, maxPathX - maxEndpointX), + Math.Max( + Math.Max(0d, minEndpointY - minPathY), + Math.Max(0d, maxPathY - maxEndpointY)))); + + var ratio = pathLength / directLength; + if (ratio > 1.55d || overshoot > minClearance * 1.5d) + { + count++; + if (severityByEdgeId is not null) + { + severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + severityWeight; + } + } + } + + return count; + } + + private static bool IsApplicableSharedHighway( + RoutedEdgeSegment left, + RoutedEdgeSegment right, + IReadOnlyDictionary edgesById, + IReadOnlyDictionary nodesById) + { + if (!edgesById.TryGetValue(left.EdgeId, out var leftEdge) + || !edgesById.TryGetValue(right.EdgeId, out var rightEdge)) + { + return false; + } + + if (!string.Equals(leftEdge.TargetNodeId, rightEdge.TargetNodeId, StringComparison.Ordinal)) + { + return false; + } + + if (!nodesById.TryGetValue(leftEdge.TargetNodeId ?? string.Empty, out var targetNode)) + { + return false; + } + + return IsApplicableSharedHighway(leftEdge, rightEdge, nodesById); + } + + 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 IsApplicableSharedHighway( + ElkRoutedEdge leftEdge, + ElkRoutedEdge rightEdge, + IReadOnlyDictionary nodesById) + { + if (!string.Equals(leftEdge.TargetNodeId, rightEdge.TargetNodeId, StringComparison.Ordinal)) + { + return false; + } + + if (!nodesById.TryGetValue(leftEdge.TargetNodeId ?? string.Empty, out var targetNode)) + { + return false; + } + + var leftPath = ExtractPath(leftEdge); + var rightPath = ExtractPath(rightEdge); + if (leftPath.Count < 2 || rightPath.Count < 2) + { + return false; + } + + var leftSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(leftPath[^1], targetNode); + var rightSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(rightPath[^1], targetNode); + if (!string.Equals(leftSide, rightSide, StringComparison.Ordinal)) + { + return false; + } + + var sharedLength = ElkEdgeRoutingGeometry.ComputeLongestSharedApproachSegmentLength( + leftPath, + rightPath); + if (sharedLength <= 1d) + { + return false; + } + + var shortestPath = Math.Min( + ElkEdgeRoutingGeometry.ComputePathLength(leftEdge), + ElkEdgeRoutingGeometry.ComputePathLength(rightEdge)); + if (shortestPath <= 1d) + { + return false; + } + + return sharedLength >= shortestPath * (2d / 5d); + } + + private static bool HasValidBoundaryAngle( + ElkPoint boundaryPoint, + ElkPoint adjacentPoint, + ElkPositionedNode node) + { + var segDx = Math.Abs(boundaryPoint.X - adjacentPoint.X); + var segDy = Math.Abs(boundaryPoint.Y - adjacentPoint.Y); + if (segDx < 3d && segDy < 3d) + { + return true; + } + + var side = ElkEdgeRoutingGeometry.ResolveBoundarySide(boundaryPoint, node); + var validForVerticalSide = segDx > segDy * 3d; + var validForHorizontalSide = segDy > segDx * 3d; + return side switch + { + "left" or "right" => validForVerticalSide, + "top" or "bottom" => validForHorizontalSide, + _ => true, + }; + } + + private static bool HasTargetApproachJoin( + IReadOnlyList leftPath, + IReadOnlyList rightPath, + double minClearance, + int maxSegmentsFromEnd) + { + 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, + minClearance)) + { + 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 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 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 ResolveMinLineClearance(IReadOnlyCollection nodes) + { + var serviceNodes = nodes.Where(n => n.Kind is not "Start" and not "End").ToArray(); + return serviceNodes.Length > 0 + ? Math.Min(serviceNodes.Average(n => n.Width), serviceNodes.Average(n => n.Height)) / 2d + : 50d; + } + + private static bool ShouldEnforceShortestPathRule( + ElkRoutedEdge edge, + IReadOnlyCollection nodes, + double graphMinY, + double graphMaxY) + { + 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.HasCorridorBendPoints(edge, graphMinY, graphMaxY) + && !HasClearOrthogonalShortcut(edge, nodes)) + { + return false; + } + + return !ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label); + } + + 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); + } + internal static int CountTargetApproachCongestion(IReadOnlyCollection edges) { var congestionCount = 0; diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkLayoutDiagnostics.cs b/src/__Libraries/StellaOps.ElkSharp/ElkLayoutDiagnostics.cs new file mode 100644 index 000000000..19684b317 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkLayoutDiagnostics.cs @@ -0,0 +1,208 @@ +using System; +using System.IO; +using System.Threading; + +namespace StellaOps.ElkSharp; + +internal sealed class ElkLayoutDiagnosticsCapture : IDisposable +{ + private readonly ElkLayoutRunDiagnostics? previous; + + internal ElkLayoutDiagnosticsCapture(ElkLayoutRunDiagnostics diagnostics, ElkLayoutRunDiagnostics? previous) + { + Diagnostics = diagnostics; + this.previous = previous; + } + + internal ElkLayoutRunDiagnostics Diagnostics { get; } + + public void Dispose() + { + ElkLayoutDiagnostics.Restore(previous); + } +} + +internal sealed class ElkLayoutRunDiagnostics +{ + internal object SyncRoot { get; } = new(); + + public EdgeRoutingScore? InitialScore { get; set; } + public EdgeRoutingScore? FinalScore { get; set; } + public int BaselineBrokenShortHighwayCount { get; set; } + public int FinalBrokenShortHighwayCount { get; set; } + public int CompletedPasses { get; set; } + public List Attempts { get; } = []; + public List IterativeStrategies { get; } = []; + public int SelectedStrategyIndex { get; set; } = -1; + public EdgeRoutingScore? IterativeBaselineScore { get; set; } + public List DetectedHighways { get; } = []; + public List ProgressLog { get; } = []; + public string? ProgressLogPath { get; set; } +} + +internal sealed class ElkHighwayDiagnostics +{ + public required string TargetNodeId { get; init; } + public required string SharedAxis { get; init; } + public required double SharedCoord { get; init; } + public required string[] EdgeIds { get; init; } + public required double MinRatio { get; init; } + public required bool WasBroken { get; init; } + public required string Reason { get; init; } +} + +internal sealed class ElkIterativeStrategyDiagnostics +{ + public required int StrategyIndex { get; init; } + public required string OrderingName { get; init; } + public int Attempts { get; set; } + public double TotalDurationMs { get; set; } + public EdgeRoutingScore? BestScore { get; set; } + public required string Outcome { get; init; } + public double BendPenalty { get; init; } + public double DiagonalPenalty { get; init; } + public double SoftObstacleWeight { get; init; } + + [System.Text.Json.Serialization.JsonIgnore] + public ElkRoutedEdge[]? BestEdges { get; set; } + + public List AttemptDetails { get; } = []; +} + +internal sealed class ElkIterativeAttemptDiagnostics +{ + public required int Attempt { get; init; } + public double TotalDurationMs { get; init; } + public required EdgeRoutingScore Score { get; init; } + public required string Outcome { get; init; } + public ElkIterativeRouteDiagnostics? RouteDiagnostics { get; init; } + public List PhaseTimings { get; } = []; + + [System.Text.Json.Serialization.JsonIgnore] + public ElkRoutedEdge[]? Edges { get; init; } +} + +internal sealed class ElkIterativeRouteDiagnostics +{ + public required string Mode { get; init; } + public int TotalEdges { get; init; } + public int RoutedEdges { get; init; } + public int SkippedEdges { get; init; } + public int RoutedSections { get; init; } + public int FallbackSections { get; init; } + public int SoftObstacleSegments { get; init; } + public IReadOnlyCollection RepairedEdgeIds { get; init; } = []; + public IReadOnlyCollection RepairReasons { get; init; } = []; +} + +internal sealed class ElkIterativePhaseDiagnostics +{ + public required string Phase { get; init; } + public double DurationMs { get; init; } +} + +internal sealed class ElkEdgeRefinementAttemptDiagnostics +{ + public required int PassIndex { get; init; } + public required string EdgeId { get; init; } + public required int Severity { get; init; } + public required EdgeRoutingScore BaselineScore { get; init; } + public int AttemptCount { get; set; } + public int? AcceptedTrialIndex { get; set; } + public EdgeRoutingScore? AcceptedScore { get; set; } + public List Trials { get; } = []; +} + +internal sealed class ElkEdgeRefinementTrialDiagnostics +{ + public required int TrialIndex { get; init; } + public required double Margin { get; init; } + public required double BendPenalty { get; init; } + public required double SoftObstacleWeight { get; init; } + public required string Outcome { get; init; } + public EdgeRoutingScore? CandidateScore { get; init; } + public bool Accepted { get; set; } + public IReadOnlyCollection Sections { get; init; } = []; +} + +internal sealed class ElkDiagnosticSectionPath +{ + public required ElkPoint StartPoint { get; init; } + public required IReadOnlyCollection BendPoints { get; init; } + public required ElkPoint EndPoint { get; init; } +} + +internal static class ElkLayoutDiagnostics +{ + private static readonly AsyncLocal CurrentDiagnostics = new(); + + internal static ElkLayoutRunDiagnostics? Current => CurrentDiagnostics.Value; + + internal static ElkLayoutDiagnosticsCapture BeginCapture() + { + var previous = CurrentDiagnostics.Value; + var diagnostics = new ElkLayoutRunDiagnostics(); + CurrentDiagnostics.Value = diagnostics; + return new ElkLayoutDiagnosticsCapture(diagnostics, previous); + } + + internal static ElkLayoutDiagnosticsCapture Attach(ElkLayoutRunDiagnostics diagnostics) + { + var previous = CurrentDiagnostics.Value; + CurrentDiagnostics.Value = diagnostics; + return new ElkLayoutDiagnosticsCapture(diagnostics, previous); + } + + internal static void Restore(ElkLayoutRunDiagnostics? previous) + { + CurrentDiagnostics.Value = previous; + } + + internal static void LogProgress(string message) + { + var diagnostics = CurrentDiagnostics.Value; + if (diagnostics is null) + { + return; + } + + var line = $"[{DateTime.UtcNow:O}] {message}"; + lock (diagnostics.SyncRoot) + { + diagnostics.ProgressLog.Add(line); + Console.WriteLine(line); + if (!string.IsNullOrWhiteSpace(diagnostics.ProgressLogPath)) + { + File.AppendAllText(diagnostics.ProgressLogPath, line + Environment.NewLine); + } + } + } + + internal static void AddDetectedHighway(ElkHighwayDiagnostics diagnostic) + { + var diagnostics = CurrentDiagnostics.Value; + if (diagnostics is null) + { + return; + } + + lock (diagnostics.SyncRoot) + { + diagnostics.DetectedHighways.Add(diagnostic); + } + } + + internal static void AddRefinementAttempt(ElkEdgeRefinementAttemptDiagnostics attempt) + { + var diagnostics = CurrentDiagnostics.Value; + if (diagnostics is null) + { + return; + } + + lock (diagnostics.SyncRoot) + { + diagnostics.Attempts.Add(attempt); + } + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkLayoutTypes.cs b/src/__Libraries/StellaOps.ElkSharp/ElkLayoutTypes.cs index 987fd1d9b..19565acce 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkLayoutTypes.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkLayoutTypes.cs @@ -51,9 +51,52 @@ internal readonly record struct EdgeRoutingScore( int EdgeCrossings, int BendCount, int TargetCongestion, + int DiagonalCount, + int EntryAngleViolations, + int LabelProximityViolations, + int RepeatCollectorCorridorViolations, + int TargetApproachJoinViolations, + int TargetApproachBacktrackingViolations, + int ExcessiveDetourViolations, + int ProximityViolations, double TotalPathLength, double Value); +internal readonly record struct RoutingRetryState( + int RemainingShortHighways, + int RepeatCollectorCorridorViolations, + int TargetApproachJoinViolations, + int TargetApproachBacktrackingViolations, + int ExcessiveDetourViolations, + int ProximityViolations, + int EntryAngleViolations, + int LabelProximityViolations, + int EdgeCrossings) +{ + internal int QualityViolationCount => + ProximityViolations + EntryAngleViolations + LabelProximityViolations; + + internal bool RequiresQualityRetry => QualityViolationCount > 0; + + internal int BlockingViolationCount => + RemainingShortHighways + + RepeatCollectorCorridorViolations + + TargetApproachJoinViolations + + TargetApproachBacktrackingViolations; + + 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); @@ -72,3 +115,147 @@ internal readonly record struct OrthogonalAStarOptions( 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 targetJoinPressure = Math.Min(retryState.TargetApproachJoinViolations, 4); + var backtrackingPressure = Math.Min(retryState.TargetApproachBacktrackingViolations, 4); + var detourPressure = Math.Min(retryState.ExcessiveDetourViolations, 4); + var proximityPressure = Math.Min(retryState.ProximityViolations, 6); + var entryPressure = Math.Min(retryState.EntryAngleViolations, 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) + + (targetJoinPressure > 0 ? 12d : 0d) + + (backtrackingPressure > 0 ? 6d : 0d) + + (proximityPressure > 0 ? 10d : 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 || labelPressure > 0 || highwayPressure > 0 || collectorCorridorPressure > 0 || targetJoinPressure > 0) + { + bendPenalty = Math.Min(bendPenalty + 40d, 800d); + } + else if (backtrackingPressure > 0 || detourPressure > 0 || proximityPressure > 0 || crossingPressure > 0) + { + bendPenalty = Math.Max( + 80d, + bendPenalty - (backtrackingPressure > 0 ? 80d : detourPressure > 0 ? 50d : 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) + + (targetJoinPressure > 0 ? 10d : 0d) + + (proximityPressure > 0 ? 6d : 0d) + + (entryPressure > 0 ? 3d : 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) + + (targetJoinPressure > 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) + + (targetJoinPressure > 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) + - (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 ec25ecdb9..5e7cc1c39 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkModels.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkModels.cs @@ -68,6 +68,7 @@ public sealed record ElkLayoutOptions public int? OrderingIterations { get; init; } public int? PlacementIterations { get; init; } public EdgeRefinementOptions? EdgeRefinement { get; init; } + public IterativeRoutingOptions? IterativeRouting { get; init; } } public sealed record EdgeRefinementOptions @@ -81,6 +82,13 @@ public sealed record EdgeRefinementOptions public double SoftObstacleClearance { get; init; } = 14d; } +public sealed record IterativeRoutingOptions +{ + public bool? Enabled { get; init; } + public int MaxAdaptationsPerStrategy { get; init; } = 10; + public int RequiredValidSolutions { get; init; } = 10; +} + public sealed record ElkPoint { public required double X { get; init; } diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkRepeatCollectorCorridors.cs b/src/__Libraries/StellaOps.ElkSharp/ElkRepeatCollectorCorridors.cs new file mode 100644 index 000000000..7f169643e --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkRepeatCollectorCorridors.cs @@ -0,0 +1,347 @@ +namespace StellaOps.ElkSharp; + +internal sealed class ElkRepeatCollectorCorridorGroup +{ + public required string TargetNodeId { get; init; } + public required bool IsAbove { get; init; } + public required double CorridorY { get; init; } + public required string[] EdgeIds { get; init; } +} + +internal static class ElkRepeatCollectorCorridors +{ + private const double CoordinateTolerance = 0.5d; + + internal static int CountSharedLaneViolations( + IReadOnlyCollection edges, + IReadOnlyCollection nodes, + Dictionary? severityByEdgeId = null, + int severityWeight = 1) + { + var groups = DetectSharedLaneGroups(edges, nodes); + var count = 0; + foreach (var group in groups) + { + var edgeCount = group.EdgeIds.Length; + if (edgeCount < 2) + { + continue; + } + + count += edgeCount * (edgeCount - 1) / 2; + if (severityByEdgeId is null) + { + continue; + } + + foreach (var edgeId in group.EdgeIds) + { + severityByEdgeId[edgeId] = severityByEdgeId.GetValueOrDefault(edgeId) + + ((edgeCount - 1) * severityWeight); + } + } + + return count; + } + + internal static IReadOnlyList DetectSharedLaneGroups( + IReadOnlyCollection edges, + IReadOnlyCollection nodes) + { + if (edges.Count < 2 || nodes.Count == 0) + { + return []; + } + + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + var candidates = edges + .Select(edge => CreateCandidate(edge, graphMinY, graphMaxY)) + .Where(candidate => candidate is not null) + .Select(candidate => candidate!.Value) + .OrderBy(candidate => candidate.TargetNodeId, StringComparer.Ordinal) + .ThenBy(candidate => candidate.IsAbove ? 0 : 1) + .ThenBy(candidate => candidate.CorridorY) + .ThenBy(candidate => candidate.EdgeId, StringComparer.Ordinal) + .ToArray(); + if (candidates.Length < 2) + { + return []; + } + + var groups = new List(); + foreach (var groupedCandidates in candidates + .GroupBy( + candidate => $"{candidate.TargetNodeId}|{candidate.IsAbove}", + StringComparer.Ordinal)) + { + var bucket = groupedCandidates.ToArray(); + var visited = new bool[bucket.Length]; + for (var i = 0; i < bucket.Length; i++) + { + if (visited[i]) + { + continue; + } + + var pending = new Queue(); + var component = new List(); + pending.Enqueue(i); + visited[i] = true; + + while (pending.Count > 0) + { + var currentIndex = pending.Dequeue(); + var current = bucket[currentIndex]; + component.Add(current); + + for (var j = 0; j < bucket.Length; j++) + { + if (visited[j] || currentIndex == j) + { + continue; + } + + if (!SharesOuterLane(current, bucket[j])) + { + continue; + } + + visited[j] = true; + pending.Enqueue(j); + } + } + + if (component.Count < 2) + { + continue; + } + + groups.Add(new ElkRepeatCollectorCorridorGroup + { + TargetNodeId = component[0].TargetNodeId, + IsAbove = component[0].IsAbove, + CorridorY = component.Min(member => member.CorridorY), + EdgeIds = component + .Select(member => member.EdgeId) + .OrderBy(edgeId => edgeId, StringComparer.Ordinal) + .ToArray(), + }); + } + } + + return groups; + } + + internal static ElkRoutedEdge[] SeparateSharedLanes( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + IReadOnlyCollection? restrictedEdgeIds = null) + { + if (edges.Length < 2 || nodes.Length == 0) + { + return edges; + } + + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + var serviceNodes = nodes.Where(node => node.Kind is not "Start" and not "End").ToArray(); + var minLineClearance = serviceNodes.Length > 0 + ? Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d + : 50d; + var laneGap = Math.Max(12d, minLineClearance + 4d); + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); + + var result = edges.ToArray(); + var groups = DetectSharedLaneGroups(result, nodes) + .Where(group => restrictedSet is null || group.EdgeIds.Any(restrictedSet.Contains)) + .OrderBy(group => group.TargetNodeId, StringComparer.Ordinal) + .ThenBy(group => group.IsAbove ? 0 : 1) + .ThenBy(group => group.CorridorY) + .ToArray(); + if (groups.Length == 0) + { + return result; + } + + foreach (var group in groups) + { + var members = result + .Select((edge, index) => new { edge, index, candidate = CreateCandidate(edge, graphMinY, graphMaxY) }) + .Where(item => item.candidate is not null + && group.EdgeIds.Contains(item.edge.Id, StringComparer.Ordinal)) + .Select(item => new RepairMember(item.index, item.edge.Id, item.candidate!.Value.CorridorY, item.candidate.Value.StartX)) + .OrderByDescending(member => member.StartX) + .ThenBy(member => member.EdgeId, StringComparer.Ordinal) + .ToArray(); + if (members.Length < 2) + { + continue; + } + + var baseY = group.IsAbove + ? Math.Min(members.Min(member => member.CorridorY), graphMinY - 12d) + : Math.Max(members.Max(member => member.CorridorY), graphMaxY + 12d); + + for (var i = 0; i < members.Length; i++) + { + var assignedY = group.IsAbove + ? baseY - (laneGap * i) + : baseY + (laneGap * i); + result[members[i].Index] = RewriteOuterLane(result[members[i].Index], members[i].CorridorY, assignedY, graphMinY, graphMaxY, group.IsAbove); + } + } + + 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 SharesOuterLane(CollectorCandidate left, CollectorCandidate right) + { + if (!string.Equals(left.TargetNodeId, right.TargetNodeId, StringComparison.Ordinal) + || left.IsAbove != right.IsAbove) + { + return false; + } + + if (Math.Abs(left.CorridorY - right.CorridorY) > CoordinateTolerance) + { + return false; + } + + return Math.Min(left.MaxX, right.MaxX) - Math.Max(left.MinX, right.MinX) > 1d; + } + + 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 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); +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs b/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs index bd75dc2d3..1c5442c4f 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs @@ -211,19 +211,11 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine .OrderBy(x => inputOrder.GetValueOrDefault(x.Id, int.MaxValue)) .ToArray(); - // Post-processing pipeline (deterministic generic passes, no node-specific logic): + // Post-processing pipeline: // 1. Project endpoints onto actual node shape boundaries (diamond/hexagon/rectangle) routedEdges = ElkEdgePostProcessor.SnapAnchorsToNodeBoundary(routedEdges, finalNodes); - // 2. Deterministic bounded refinement for crossing-prone orthogonal routes - routedEdges = ElkEdgeRouteRefiner.Optimize(routedEdges, finalNodes, options, cancellationToken); - // 3. Reroute any edge crossing node bounding boxes (including diagonals from shape projection) - routedEdges = ElkEdgePostProcessor.AvoidNodeCrossings(routedEdges, finalNodes, options.Direction); - // 4. Convert any remaining diagonal segments to orthogonal L-corners - routedEdges = ElkEdgePostProcessor.EliminateDiagonalSegments(routedEdges, finalNodes); - // 5. Simplify: remove collinear/duplicate points, try L-shape shortcuts - routedEdges = ElkEdgePostProcessorSimplify.SimplifyEdgePaths(routedEdges, finalNodes); - // 6. Compress outer corridor distances - routedEdges = ElkEdgePostProcessorSimplify.TightenOuterCorridors(routedEdges, finalNodes); + // 2. Iterative multi-strategy optimizer (replaces refiner + avoid crossings + diag elim + simplify + tighten) + routedEdges = ElkEdgeRouterIterative.Optimize(routedEdges, finalNodes, options, cancellationToken); return Task.FromResult(new ElkLayoutResult { diff --git a/src/__Libraries/StellaOps.ElkSharp/Properties/AssemblyInfo.cs b/src/__Libraries/StellaOps.ElkSharp/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..b6e5e8611 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("StellaOps.Workflow.Renderer.Tests")]