From 1151c30e3a22839ee01a1233dd0f9a632cd34873 Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 5 Apr 2026 15:02:12 +0300 Subject: [PATCH] elksharp: stabilize document-processing terminal routing --- ...Sharp_document_processing_routing_fixes.md | 90 ++ .../WorkflowRenderSvgRenderer.cs | 252 +++- ...rkflowRenderingTests.ArtifactInspection.cs | 61 +- ...cessingWorkflowRenderingTests.Artifacts.cs | 5 +- ...cessingWorkflowRenderingTests.Scenarios.cs | 452 +++++++ ...tTests.Restabilization.AdvancedFamilies.cs | 309 +++++ .../WorkflowRenderSvgRendererTests.cs | 151 ++- ...lkEdgePostProcessor.EndTerminalFamilies.cs | 1094 +++++++++++++++++ ...ative.WinnerRefinement.GatewayArtifacts.cs | 18 +- .../ElkEdgeRoutingScoring.GatewaySource.cs | 157 ++- .../ElkTopCorridorOwnership.cs | 428 +++++++ 11 files changed, 2946 insertions(+), 71 deletions(-) create mode 100644 docs/implplan/SPRINT_20260403_002_ElkSharp_document_processing_routing_fixes.md create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.EndTerminalFamilies.cs create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkTopCorridorOwnership.cs diff --git a/docs/implplan/SPRINT_20260403_002_ElkSharp_document_processing_routing_fixes.md b/docs/implplan/SPRINT_20260403_002_ElkSharp_document_processing_routing_fixes.md new file mode 100644 index 000000000..314753243 --- /dev/null +++ b/docs/implplan/SPRINT_20260403_002_ElkSharp_document_processing_routing_fixes.md @@ -0,0 +1,90 @@ +# Sprint 20260403-002 - ElkSharp Document Processing Routing Fixes + +## Topic & Scope +- Fix the document-processing ELKSharp routing faults confirmed during artifact review: fork-to-repeat branch docking, crowded top-corridor ownership, and the email-dispatch terminal bundle into `End`. +- Keep the fixes generic to the routing pipeline rather than hard-coding document-processing edge IDs. +- Working directory: `src/__Libraries/StellaOps.ElkSharp/`. +- Expected evidence: targeted workflow renderer tests, regenerated document-processing PNG/JSON artifacts, and focused regression assertions. + +## Dependencies & Concurrency +- Depends on the current ElkSharp hybrid/finalization pipeline and the discrete boundary-slot contract. +- Safe cross-module edits limited to: + - `src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/` + - `src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/` +- Avoid unrelated workflow library changes because the worktree already contains user edits there. + +## Documentation Prerequisites +- `docs/code-of-conduct/CODE_OF_CONDUCT.md` +- `docs/code-of-conduct/TESTING_PRACTICES.md` +- `docs/workflow/ENGINE.md` +- `src/__Libraries/StellaOps.ElkSharp/AGENTS.md` + +## Delivery Tracker + +### TASK-001 - Repair document-processing routing readability defects +Status: DONE +Dependency: none +Owners: Implementer +Task description: +- Apply three renderer fixes driven by the reviewed document-processing artifact: dock fork-to-repeat branch entries on a repeat/header target instead of the left-face midpoint, spread above-graph corridor lanes by ownership instead of color-only crowding, and give terminal `End` arrivals a coherent bundle so `Email Dispatch` does not collapse into the mixed face approach. +- Add regression assertions in the focused workflow renderer test project to lock the repaired geometry. + +Completion criteria: +- [x] `edge/3` enters `Process Batch` from the header/top band rather than the left-face midpoint +- [x] the top corridor keeps distinct lanes for the repeat-return and long terminal sweeps +- [x] `edge/30`, `edge/32`, and `edge/33` no longer collapse into the same ambiguous `End` bundle +- [x] targeted renderer tests pass on the individual `.csproj` + +### TASK-002 - Repair semantic route-family ownership for the remaining default lanes +Status: DONE +Dependency: TASK-001 +Owners: Implementer +Task description: +- Fix the deeper pipeline faults still visible in the document-processing artifact after TASK-001: the direct `Parallel Execution -> Join` bypass still owns the visual fork mainline, the `Retry Decision` default and `Cooldown Timer` continuation still do not resolve as one readable setter family, and the long `End` arrivals still split between corridor and side-face terminal strategies. +- Treat the route-family fix as a cross-layer change. The ElkSharp batching and side-resolution rules must use the same semantic family model, and the SVG renderer must stop collapsing a short horizontal-plus-shallow-diagonal continuation into a tiny vertical stub. +- Add failing regression tests first for the fork primary axis, the retry/timer local setter band, the full `End` terminal family, and the SVG-path preservation rule. + +Completion criteria: +- [x] `edge/4` no longer owns the fork primary axis over the work branch into `Process Batch` +- [x] `edge/9` and `edge/10` remain in one readable local setter family without a lower detour band +- [x] all document-processing arrivals into `End` use one coherent left-face terminal family +- [x] the SVG renderer preserves short readable continuations instead of collapsing them into degenerate stubs +- [x] targeted renderer tests pass on the individual `.csproj` + +### TASK-003 - Polish render annotations and remove the remaining fork-bypass false positive +Status: DONE +Dependency: TASK-002 +Owners: Implementer +Task description: +- Compact the SVG legend footprint, wrap long condition labels into readable badges, and remove the remaining clean fork-bypass gateway-source false positive from both scoring and artifact inspection. +- Re-attempt the document-processing top-corridor and `End` family cleanup only after the current ElkSharp winner-refinement terminal-closure loop can absorb those rewrites without reopening heavy boundary-slot and target-join pressure in the dirty worktree. + +Completion criteria: +- [x] the SVG legend height is content-driven instead of fixed to the previous oversized frame +- [x] long condition labels render as wrapped badges instead of one thin over-wide strip +- [x] clean orthogonal fork-bypass paths like document-processing `edge/4` are not counted by targeted gateway-source scoring +- [x] the document-processing rerender converges with zero gateway-source diagnostics in the latest-artifact inspection path +- [x] top-corridor and `End`-family cleanup can be reintroduced without reopening boundary-slot / target-join / under-node pressure + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-04-03 | Sprint created for the confirmed document-processing routing defects after artifact review of the ELKSharp output. | Implementer | +| 2026-04-03 | Added semantic target-side docking for fork-to-repeat and non-corridored `End` arrivals, plus above-graph corridor ownership spreading to keep terminal sweeps visually distinct. | Implementer | +| 2026-04-03 | Verified targeted renderer evidence on the individual test project: artifact PNG test passed, and focused regression tests passed for branch header docking, top-corridor ownership, and end-bundle separation. | Implementer | +| 2026-04-03 | Follow-up review found the remaining root-cause defects: fork bypass mainline ownership, retry/timer setter-family fragmentation, split `End` terminal families, and SVG short-jog collapse for the cooldown continuation. | Implementer | +| 2026-04-04 | Completed the remaining route-family work: gateway-source scoring heuristics now suppress protected corridor and clean orthogonal branch exits, gateway vertex exits only count when the departure is actually problematic, and the document-processing artifact plus focused geometry/SVG guards all pass on the renderer test project. | Implementer | +| 2026-04-05 | Added content-driven SVG legend sizing, wrapped long edge-condition badges, and a direct `edge/4` fork-bypass gateway-source regression test. Targeted renderer and scorer tests pass on the individual renderer test project. | Implementer | +| 2026-04-05 | Attempted to activate the new top-corridor and `End` terminal-family hooks in the live hybrid refinement loop, but captured document-processing rerenders reopened heavy terminal-closure pressure (`boundary-slots`, `target-joins`, `under-node`) and did not converge cleanly. The hook entry points were returned to pass-through and the remaining cleanup moved to TASK-003. | Implementer | +| 2026-04-05 | Reintroduced the winner-refinement top-corridor ownership pass with score-gated cluster metrics, reactivated the `End` terminal-family cleanup, and verified the stable document-processing rerender plus latest-artifact inspection. Direct regressions now pass for overlapping repeat/end roof-lane ownership and top-family `End` sharing. | Implementer | + +## Decisions & Risks +- Cross-module edits are limited to the document-processing renderer tests and the SVG renderer so the routing contract and the emitted artifact can be pinned together. +- The work must preserve deterministic routing and existing repeat-return clearance guarantees while improving readability. +- The remaining cooldown-continuation defect is partly in the SVG path cleanup, so the sprint now includes a tightly scoped renderer fix in addition to ElkSharp routing changes. +- Final verification for TASK-002 used the individual renderer test project: the full document-processing artifact regression, the retry/local-setter and terminal-family scenario guards, the latest-artifact inspection probe, and the SVG short-continuation unit test. +- TASK-003 initially exposed a real stability risk in the dirty worktree: turning the corridor and `End`-family helpers back on without extra score-gating could reopen terminal-closure pressure and stop the document-processing artifact from converging. +- That risk is now resolved by the score-gated top-corridor ownership pass plus the stabilized `End`-family rewrite: the stable 2026-04-05 rerender and latest-artifact inspection both complete with zero gateway-source, boundary-slot, under-node, shared-lane, target-join, and target-backtracking defects. + +## Next Checkpoints +- Archive the sprint after the current ElkSharp worktree is ready for commit sequencing and no additional document-processing routing follow-ups are opened from the remaining soft readability review. diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderSvgRenderer.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderSvgRenderer.cs index aad325e71..eecda575a 100644 --- a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderSvgRenderer.cs +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderSvgRenderer.cs @@ -11,8 +11,11 @@ public sealed class WorkflowRenderSvgRenderer private const double Margin = 32; private const double HeaderHeight = 220; private const double LegendTop = 34; - private const double LegendHeight = 160; - private const double LabelMinTop = LegendTop + LegendHeight + 18; + private const double LegendLeft = 24; + private const double LegendInnerLeft = 48; + private const double LegendInnerRight = 24; + private const double LegendBottomPadding = 16; + private const double LegendSectionGap = 18; private const double LabelInsetX = 56; private const double LabelInsetRight = 16; @@ -25,9 +28,11 @@ public sealed class WorkflowRenderSvgRenderer var bounds = CalculateBounds(layout); var width = Math.Max(1328, bounds.Width + (Margin * 2)); - var height = Math.Max(320, bounds.Height + HeaderHeight + Margin + 96); + var legendLayout = BuildLegendLayout(width, layout); + var headerHeight = Math.Max(HeaderHeight, legendLayout.Bottom + 28d); + var height = Math.Max(320, bounds.Height + headerHeight + Margin + 96); var offsetX = Margin - bounds.MinX; - var offsetY = HeaderHeight - bounds.MinY; + var offsetY = headerHeight - bounds.MinY; var builder = new StringBuilder(); var edgeLabels = new List(); @@ -80,7 +85,7 @@ public sealed class WorkflowRenderSvgRenderer {Encode(title)} """); - RenderLegend(builder, width, layout); + RenderLegend(builder, legendLayout); var highways = DetectHighwayGroups(layout); var highwayByEdgeId = highways.Values @@ -228,6 +233,7 @@ public sealed class WorkflowRenderSvgRenderer offsetY, width, height, + legendLayout.Bottom + 18d, labelObstacles, edgeLabels)); } @@ -510,29 +516,38 @@ public sealed class WorkflowRenderSvgRenderer stroke-dasharray="2.2 4.8" /> - 1 ? 10.8d : 11.5d; + var lineHeight = placement.Lines.Count > 1 ? 12d : 0d; + var firstBaseline = placement.Lines.Count > 1 + ? placement.Top + ((placement.Height - ((placement.Lines.Count - 1) * lineHeight)) / 2d) + 4d + : placement.Top + 14d; + for (var index = 0; index < placement.Lines.Count; index++) + { + builder.AppendLine($""" + {Encode(placement.Label)} + fill="{placement.Style.LabelText}">{Encode(placement.Lines[index])} """); + } } - private static void RenderLegend(StringBuilder builder, double canvasWidth, WorkflowRenderLayoutResult layout) + private static LegendLayout BuildLegendLayout(double canvasWidth, WorkflowRenderLayoutResult layout) { var nodeKinds = new HashSet(layout.Nodes.Select(n => n.Kind), StringComparer.OrdinalIgnoreCase); var edgeLabels = new HashSet( layout.Edges.Where(e => !string.IsNullOrWhiteSpace(e.Label)).Select(e => e.Label!), StringComparer.OrdinalIgnoreCase); - var legendWidth = Math.Min(canvasWidth - 48d, 1260d); - builder.AppendLine($""" - - - Legend - Node Shapes: - + var legendWidth = Math.Min(canvasWidth - 48d, 1040d); + var maxRight = LegendLeft + legendWidth - LegendInnerRight; + var contentBuilder = new StringBuilder(); + contentBuilder.AppendLine(""" + Legend """); var nodeChips = new List<(string Kind, string Label)>(); @@ -547,11 +562,18 @@ public sealed class WorkflowRenderSvgRenderer if (nodeKinds.Contains("Repeat")) nodeChips.Add(("Repeat", "Repeat / Loop")); if (nodeKinds.Contains("Signal")) nodeChips.Add(("Signal", "Signal")); - var nodeChipX = 118d; - foreach (var (kind, label) in nodeChips) + var cursorY = 78d; + var maxBottom = 56d; + + if (nodeChips.Count > 0) { - RenderLegendNodeChip(builder, nodeChipX, 62, kind, label); - nodeChipX += (label.Length * 7.5d) + 56d; + contentBuilder.AppendLine($""" + Node Shapes: + """); + maxBottom = Math.Max(maxBottom, cursorY); + var nodeBottom = RenderWrappedLegendNodeChips(contentBuilder, nodeChips, cursorY + 10d, maxRight); + maxBottom = Math.Max(maxBottom, nodeBottom); + cursorY = nodeBottom + LegendSectionGap; } var badgeChips = new List<(string Kind, string Label)>(); @@ -565,15 +587,13 @@ public sealed class WorkflowRenderSvgRenderer if (badgeChips.Count > 0) { - builder.AppendLine(""" - Badges: + contentBuilder.AppendLine($""" + Badges: """); - var badgeChipX = 102d; - foreach (var (kind, label) in badgeChips) - { - RenderLegendBadgeChip(builder, badgeChipX, 128, kind, label); - badgeChipX += (label.Length * 7d) + 50d; - } + maxBottom = Math.Max(maxBottom, cursorY); + var badgeBottom = RenderWrappedLegendBadgeChips(contentBuilder, badgeChips, cursorY + 8d, maxRight); + maxBottom = Math.Max(maxBottom, badgeBottom); + cursorY = badgeBottom + LegendSectionGap; } var hasWhenCondition = edgeLabels.Any(l => l.StartsWith("when ", StringComparison.OrdinalIgnoreCase)); @@ -595,16 +615,116 @@ public sealed class WorkflowRenderSvgRenderer if (branchChips.Count > 0) { - builder.AppendLine(""" - Branch Callouts: + contentBuilder.AppendLine($""" + Branch Callouts: """); - var branchChipX = 152d; - foreach (var (color, label) in branchChips) - { - RenderLegendBranchChip(builder, branchChipX, 160, color, label); - branchChipX += (label.Length * 7d) + 46d; - } + maxBottom = Math.Max(maxBottom, cursorY); + var branchBottom = RenderWrappedLegendBranchChips(contentBuilder, branchChips, cursorY + 8d, maxRight); + maxBottom = Math.Max(maxBottom, branchBottom); } + + var legendHeight = Math.Max(74d, (maxBottom - LegendTop) + LegendBottomPadding); + var legendBuilder = new StringBuilder(); + legendBuilder.AppendLine($""" + + + """); + legendBuilder.Append(contentBuilder.ToString()); + legendBuilder.AppendLine(" "); + + return new LegendLayout(legendWidth, legendHeight, legendBuilder.ToString()); + } + + private static void RenderLegend(StringBuilder builder, LegendLayout legendLayout) + { + builder.Append(legendLayout.Svg); + } + + private static double RenderWrappedLegendNodeChips( + StringBuilder builder, + IReadOnlyList<(string Kind, string Label)> chips, + double startY, + double maxRight) + { + var x = LegendInnerLeft; + var y = startY; + foreach (var (kind, label) in chips) + { + var width = MeasureLegendNodeChipWidth(label); + if (x + width > maxRight && x > LegendInnerLeft) + { + x = LegendInnerLeft; + y += 32d; + } + + RenderLegendNodeChip(builder, x, y, kind, label); + x += width + 16d; + } + + return y + 24d; + } + + private static double RenderWrappedLegendBadgeChips( + StringBuilder builder, + IReadOnlyList<(string Kind, string Label)> chips, + double startY, + double maxRight) + { + var x = LegendInnerLeft; + var y = startY; + foreach (var (kind, label) in chips) + { + var width = MeasureLegendBadgeChipWidth(label); + if (x + width > maxRight && x > LegendInnerLeft) + { + x = LegendInnerLeft; + y += 28d; + } + + RenderLegendBadgeChip(builder, x + 10.5d, y + 10.5d, kind, label); + x += width + 16d; + } + + return y + 22d; + } + + private static double RenderWrappedLegendBranchChips( + StringBuilder builder, + IReadOnlyList<(string Color, string Label)> chips, + double startY, + double maxRight) + { + var x = LegendInnerLeft; + var y = startY; + foreach (var (color, label) in chips) + { + var width = MeasureLegendBranchChipWidth(label); + if (x + width > maxRight && x > LegendInnerLeft) + { + x = LegendInnerLeft; + y += 24d; + } + + RenderLegendBranchChip(builder, x, y + 10d, color, label); + x += width + 14d; + } + + return y + 18d; + } + + private static double MeasureLegendNodeChipWidth(string label) + { + return 56d + (label.Length * 7.2d); + } + + private static double MeasureLegendBadgeChipWidth(string label) + { + return 34d + (label.Length * 6.8d); + } + + private static double MeasureLegendBranchChipWidth(string label) + { + return 54d + (label.Length * 6.8d); } private static (double MinX, double MinY, double Width, double Height) CalculateBounds(WorkflowRenderLayoutResult layout) @@ -860,6 +980,24 @@ public sealed class WorkflowRenderSvgRenderer return $"{value[..(maxCharsPerLine - 3)]}..."; } + private static string[] WrapEdgeLabelLines(string label) + { + var normalized = label.Replace('\r', ' ').Replace('\n', ' '); + var wrapped = WrapSingleLine(normalized, 28) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .ToList(); + if (wrapped.Count <= 2) + { + return wrapped.ToArray(); + } + + return + [ + wrapped[0], + TruncateSingleLine(string.Join(" ", wrapped.Skip(1)), 28), + ]; + } + private static string Encode(string value) { return WebUtility.HtmlEncode(value); @@ -878,12 +1016,14 @@ public sealed class WorkflowRenderSvgRenderer double offsetY, double canvasWidth, double canvasHeight, + double labelMinTop, IReadOnlyCollection nodeObstacles, IReadOnlyCollection placedLabels) { - var renderedLabel = TruncateSingleLine(label, 50); - var width = Math.Min(368d, Math.Max(92d, (renderedLabel.Length * 6.35d) + 10d)); - var height = 22d; + var renderedLines = WrapEdgeLabelLines(label); + var longestLineLength = renderedLines.Max(static line => line.Length); + var width = Math.Min(252d, Math.Max(108d, (longestLineLength * 6.35d) + 18d)); + var height = renderedLines.Length == 1 ? 22d : 36d; var isErrorLabel = label.Contains("failure", StringComparison.OrdinalIgnoreCase) || label.Contains("timeout", StringComparison.OrdinalIgnoreCase); var segment = ResolveLabelAnchorSegment(points); @@ -912,10 +1052,10 @@ public sealed class WorkflowRenderSvgRenderer var bestOverlapArea = double.MaxValue; var bestDistance = double.MaxValue; - foreach (var candidate in EnumerateEdgeLabelCandidates(anchorX, anchorY, width, height, horizontal, canvasWidth, canvasHeight)) + foreach (var candidate in EnumerateEdgeLabelCandidates(anchorX, anchorY, width, height, horizontal, canvasWidth, canvasHeight, labelMinTop)) { var placement = new WorkflowRenderEdgeLabelPlacement( - renderedLabel, + renderedLines, edgeStyle, anchorX, anchorY, @@ -946,12 +1086,12 @@ public sealed class WorkflowRenderSvgRenderer return bestPlacement ?? new WorkflowRenderEdgeLabelPlacement( - renderedLabel, + renderedLines, edgeStyle, anchorX, anchorY, Clamp(anchorX - (width / 2d), 24d, Math.Max(24d, canvasWidth - 24d - width)), - Clamp(anchorY - height - 54d, LabelMinTop, Math.Max(LabelMinTop, canvasHeight - 24d - height)), + Clamp(anchorY - height - 54d, labelMinTop, Math.Max(labelMinTop, canvasHeight - 24d - height)), width, height); } @@ -963,20 +1103,21 @@ public sealed class WorkflowRenderSvgRenderer double height, bool horizontal, double canvasWidth, - double canvasHeight) + double canvasHeight, + double labelMinTop) { - static double ResolveTopBound(double canvasHeightLocal, double heightLocal) + static double ResolveTopBound(double canvasHeightLocal, double heightLocal, double labelMinTopLocal) { - return Math.Max(LabelMinTop, canvasHeightLocal - 24d - heightLocal); + return Math.Max(labelMinTopLocal, canvasHeightLocal - 24d - heightLocal); } (double Left, double Top) Normalize(double left, double top) { var maxLeft = Math.Max(24d, canvasWidth - 24d - width); - var maxTop = ResolveTopBound(canvasHeight, height); + var maxTop = ResolveTopBound(canvasHeight, height, labelMinTop); return ( Clamp(left, 24d, maxLeft), - Clamp(top, LabelMinTop, maxTop)); + Clamp(top, labelMinTop, maxTop)); } for (var level = 0; level < 5; level++) @@ -2239,7 +2380,10 @@ public sealed class WorkflowRenderSvgRenderer if (segLen < 30d && i < mutablePoints.Count - 1) { var next = mutablePoints[i + 1]; - if (dxIn < dyIn) + var dxOut = Math.Abs(next.X - curr.X); + var dyOut = Math.Abs(next.Y - curr.Y); + var preserveHorizontalContinuation = dxOut >= dyOut; + if (preserveHorizontalContinuation) { mutablePoints[i + 1] = new WorkflowRenderPoint { X = next.X, Y = prev.Y }; } @@ -2321,7 +2465,7 @@ public sealed class WorkflowRenderSvgRenderer string LabelText); private sealed record WorkflowRenderEdgeLabelPlacement( - string Label, + IReadOnlyList Lines, WorkflowRenderEdgeStyle Style, double AnchorX, double AnchorY, @@ -2334,6 +2478,14 @@ public sealed class WorkflowRenderSvgRenderer public double CenterY => Top + (Height / 2d); } + private sealed record LegendLayout( + double Width, + double Height, + string Svg) + { + public double Bottom => LegendTop + Height; + } + private sealed record WorkflowRenderRect( double Left, double Top, diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.ArtifactInspection.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.ArtifactInspection.cs index 6c516d3fa..48dc70d5e 100644 --- a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.ArtifactInspection.cs +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.ArtifactInspection.cs @@ -5,6 +5,8 @@ using NUnit.Framework; using StellaOps.ElkSharp; using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Renderer.ElkSharp; +using StellaOps.Workflow.Renderer.Svg; namespace StellaOps.Workflow.Renderer.Tests; @@ -13,14 +15,7 @@ public partial class DocumentProcessingWorkflowRenderingTests [Test] public void DocumentProcessingWorkflow_WhenInspectingLatestElkSharpArtifact_ShouldReportBoundarySlotOffenders() { - var workflowRenderingsDirectory = Path.Combine( - Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!, - "TestResults", - "workflow-renderings"); - var outputDir = Directory.GetDirectories(workflowRenderingsDirectory) - .OrderByDescending(path => Path.GetFileName(path), StringComparer.Ordinal) - .Select(path => Path.Combine(path, "DocumentProcessingWorkflow")) - .First(Directory.Exists); + var outputDir = RenderLatestElkSharpArtifactForInspection(); var jsonPath = Path.Combine(outputDir, "elksharp.json"); Assert.That(File.Exists(jsonPath), Is.True); @@ -259,6 +254,7 @@ public partial class DocumentProcessingWorkflowRenderingTests TestContext.Out.WriteLine( $"{offender.Key} gateway-source={offender.Value}: {string.Join(" -> ", ExtractElkPath(edge).Select(point => $"({point.X:F3},{point.Y:F3})"))}"); } + Assert.That(gatewaySourceCount, Is.EqualTo(0), "Latest artifact should not report residual gateway-source violations."); var sharedLaneConflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes) .Distinct() @@ -514,4 +510,51 @@ public partial class DocumentProcessingWorkflowRenderingTests } } } -} \ No newline at end of file + + private static string RenderLatestElkSharpArtifactForInspection() + { + 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); + } + + var diagnosticsPath = Path.Combine(outputDir, "elksharp.refinement-diagnostics.json"); + if (File.Exists(diagnosticsPath)) + { + File.Delete(diagnosticsPath); + } + + diagnosticsCapture.Diagnostics.ProgressLogPath = progressLogPath; + diagnosticsCapture.Diagnostics.SnapshotPath = diagnosticsPath; + + var layout = engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + NodeSpacing = 50, + }).GetAwaiter().GetResult(); + + var svgRenderer = new WorkflowRenderSvgRenderer(); + var svgDoc = svgRenderer.Render(layout, "DocumentProcessingWorkflow [ElkSharp]"); + File.WriteAllText(Path.Combine(outputDir, "elksharp.svg"), svgDoc.Svg); + File.WriteAllText( + Path.Combine(outputDir, "elksharp.json"), + JsonSerializer.Serialize(layout, new JsonSerializerOptions { WriteIndented = true })); + File.WriteAllText( + diagnosticsPath, + JsonSerializer.Serialize(diagnosticsCapture.Diagnostics, new JsonSerializerOptions { WriteIndented = true })); + + return outputDir; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.Artifacts.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.Artifacts.cs index c1e66b367..27ee753b7 100644 --- a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.Artifacts.cs +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.Artifacts.cs @@ -198,13 +198,14 @@ public partial class DocumentProcessingWorkflowRenderingTests Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.EntryAngleViolations, Is.EqualTo(0), "Selected layout must satisfy the node-side entry/exit angle rule."); Assert.That(targetJoinOffenders, Is.Empty, "Selected layout must not leave visually collapsed target-side approach joins."); Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachJoinViolations, Is.EqualTo(0), "Selected layout must not keep disallowed target-side joins."); - Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.SharedLaneViolations, Is.EqualTo(0), "Selected layout must not keep same-lane occupancy outside explicit corridor/highway exceptions."); + Assert.That(sharedLaneOffenders, Is.Empty, "Selected layout must not keep same-lane occupancy outside explicit corridor/highway exceptions."); Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.BoundarySlotViolations, Is.EqualTo(0), "Selected layout must not concentrate more than one edge onto the same discrete side slot or leave side endpoints off the evenly spread slot lattice."); Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.BelowGraphViolations, Is.EqualTo(0), "Selected layout must not route any lane below the node field."); Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.UnderNodeViolations, Is.EqualTo(0), "Selected layout must not keep horizontal lanes tucked underneath other nodes."); Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.LongDiagonalViolations, Is.EqualTo(0), "Selected layout must not keep overlong 45-degree segments."); Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachBacktrackingViolations, Is.EqualTo(0), "Selected layout must not overshoot a target side and curl back near the final approach."); Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.ExcessiveDetourViolations, Is.EqualTo(0), "Selected layout must not keep shortest-path violations after the retry budget is exhausted."); + Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.GatewaySourceExitViolations, Is.EqualTo(0), "Selected layout must not leave soft gateway-source false positives in the final diagnostic score."); var gatewayCornerDiagonalCount = layout.Edges.Count(edge => HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true) || HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false)); @@ -309,4 +310,4 @@ public partial class DocumentProcessingWorkflowRenderingTests TestContext.Out.WriteLine($"Edge-node crossings: {crossings}"); Assert.That(crossings, Is.EqualTo(0), "No edges should cross through node shapes"); } -} \ No newline at end of file +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.Scenarios.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.Scenarios.cs index f6fec3481..5ceb58224 100644 --- a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.Scenarios.cs +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.Scenarios.cs @@ -247,6 +247,31 @@ public partial class DocumentProcessingWorkflowRenderingTests path.Max(point => point.Y), Is.LessThanOrEqualTo(maxAllowedY), "Local repeat-return lanes must not drop into a lower detour band when an upper return is available."); + + var elkNodes = layout.Nodes.Select(ToElkNode).ToArray(); + var elkEdges = layout.Edges.Select(routedEdge => new ElkRoutedEdge + { + Id = routedEdge.Id, + SourceNodeId = routedEdge.SourceNodeId, + TargetNodeId = routedEdge.TargetNodeId, + Kind = routedEdge.Kind, + Label = routedEdge.Label, + Sections = routedEdge.Sections.Select(section => new ElkEdgeSection + { + StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y }, + EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y }, + BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(), + }).ToArray(), + }).ToArray(); + var repeatReturnEdges = elkEdges + .Where(routedEdge => routedEdge.TargetNodeId == "start/2/branch-1/1" + && (routedEdge.Id == "edge/14" || routedEdge.Id == "edge/15" || routedEdge.Id == "edge/35")) + .ToArray(); + + Assert.That( + ElkEdgeRoutingScoring.CountUnderNodeViolations(repeatReturnEdges, elkNodes), + Is.EqualTo(0), + "Repeat returns into Process Batch should stay above the Parallel Execution join field instead of routing under it."); } [Test] @@ -283,4 +308,431 @@ public partial class DocumentProcessingWorkflowRenderingTests Is.EqualTo(0), $"Selected layout must keep decision source exits on the discrete boundary-slot lattice after winner refinement. Offenders: {string.Join(", ", severityByEdgeId.OrderBy(entry => entry.Key, StringComparer.Ordinal).Select(entry => entry.Key))}"); } + + [Test] + public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldDockForkBranchIntoProcessBatchHeaderBand() + { + var graph = BuildDocumentProcessingWorkflowGraph(); + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + + var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + NodeSpacing = 50, + }); + + var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/3"); + var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId); + var path = FlattenPath(edge); + + Assert.That(path.Count, Is.GreaterThanOrEqualTo(3)); + Assert.That( + ResolveBoundarySide(path[^1], targetNode), + Is.EqualTo("top"), + "Fork branch lanes into Process Batch should dock into the repeat header band instead of the left-face midpoint."); + Assert.That( + path[^1].Y, + Is.EqualTo(targetNode.Y).Within(0.5d), + "The Process Batch branch endpoint should land on the top boundary."); + Assert.That( + path[^2].X, + Is.EqualTo(path[^1].X).Within(0.5d), + "The final Process Batch branch segment should be a direct vertical drop into the header band."); + } + + [Test] + public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldShareOneAboveGraphTerminalHighwayIntoEnd() + { + var graph = BuildDocumentProcessingWorkflowGraph(); + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + + var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + NodeSpacing = 50, + }); + var graphMinY = layout.Nodes.Min(node => node.Y); + + static double FindAboveGraphLaneY(WorkflowRenderRoutedEdge edge, double graphMinY) + { + var path = FlattenPath(edge); + var bestLength = double.NegativeInfinity; + var bestY = double.NaN; + for (var i = 0; i < path.Count - 1; i++) + { + if (Math.Abs(path[i].Y - path[i + 1].Y) > 0.5d + || path[i].Y >= graphMinY - 8d) + { + continue; + } + + var length = Math.Abs(path[i + 1].X - path[i].X); + if (length <= bestLength) + { + continue; + } + + bestLength = length; + bestY = path[i].Y; + } + + Assert.That(double.IsNaN(bestY), Is.False, $"Expected an above-graph corridor lane for {edge.Id}."); + return bestY; + } + + var failureLaneY = FindAboveGraphLaneY(layout.Edges.Single(edge => edge.Id == "edge/20"), graphMinY); + var defaultLaneY = FindAboveGraphLaneY(layout.Edges.Single(edge => edge.Id == "edge/23"), graphMinY); + + Assert.That( + Math.Abs(failureLaneY - defaultLaneY), + Is.LessThanOrEqualTo(1d), + "Long terminal sweeps into End should converge onto one shared terminal highway instead of splitting into color-only roof lanes."); + } + + [Test] + public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepEmailDispatchTerminalBundleDistinctAtEnd() + { + var graph = BuildDocumentProcessingWorkflowGraph(); + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + + var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + NodeSpacing = 50, + }); + + var endNode = layout.Nodes.Single(node => node.Id == "end"); + var terminalEdges = new[] + { + layout.Edges.Single(edge => edge.Id == "edge/30"), + layout.Edges.Single(edge => edge.Id == "edge/32"), + layout.Edges.Single(edge => edge.Id == "edge/33"), + }; + var terminalEndpointYs = terminalEdges + .Select(edge => + { + var path = FlattenPath(edge); + Assert.That( + ResolveBoundarySide(path[^1], endNode), + Is.EqualTo("left"), + $"Terminal end arrivals should converge on a coherent left-face bundle for {edge.Id}."); + return path[^1].Y; + }) + .OrderBy(value => value) + .ToArray(); + var terminalGap = terminalEndpointYs + .Zip(terminalEndpointYs.Skip(1), (upper, lower) => lower - upper) + .DefaultIfEmpty(double.MaxValue) + .Min(); + + Assert.That( + terminalGap, + Is.GreaterThanOrEqualTo(24d), + "The email-dispatch terminal bundle should keep distinct end-face slots."); + + var elkNodes = layout.Nodes.Select(ToElkNode).ToArray(); + var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Kind = edge.Kind, + Label = edge.Label, + Sections = edge.Sections.Select(section => new ElkEdgeSection + { + StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y }, + EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y }, + BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(), + }).ToArray(), + }).ToArray(); + var terminalIds = new HashSet(terminalEdges.Select(edge => edge.Id), StringComparer.Ordinal); + var sharedTerminalConflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes) + .Where(conflict => terminalIds.Contains(conflict.LeftEdgeId) && terminalIds.Contains(conflict.RightEdgeId)) + .ToArray(); + + Assert.That( + sharedTerminalConflicts, + Is.Empty, + "Email dispatch terminal edges must not collapse into a shared lane near End."); + } + + [Test] + public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldPreferTheWorkBranchOnTheForkPrimaryAxis() + { + var graph = BuildDocumentProcessingWorkflowGraph(); + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + + var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + NodeSpacing = 50, + }); + + var splitNode = layout.Nodes.Single(node => node.Id == "start/2/split"); + var workBranch = FlattenPath(layout.Edges.Single(edge => edge.Id == "edge/3")); + var bypassBranch = FlattenPath(layout.Edges.Single(edge => edge.Id == "edge/4")); + var splitCenterY = splitNode.Y + (splitNode.Height / 2d); + var workOffset = Math.Abs(workBranch[0].Y - splitCenterY); + var bypassOffset = Math.Abs(bypassBranch[0].Y - splitCenterY); + + Assert.That( + workOffset, + Is.LessThan(bypassOffset), + "The direct Parallel Execution -> Join bypass must not own the fork primary axis over the work branch into Process Batch."); + } + + [Test] + public void DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotCountCleanForkBypassAsGatewaySourceViolation() + { + var elkNodes = new[] + { + new ElkPositionedNode + { + Id = "start/2/split", + Label = "Parallel Execution", + Kind = "Fork", + X = 652d, + Y = 127.1552734375d, + Width = 176d, + Height = 124d, + }, + new ElkPositionedNode + { + Id = "start/2/branch-1/1", + Label = "Process Batch", + Kind = "Repeat", + X = 992d, + Y = 268.310546875d, + Width = 208d, + Height = 88d, + }, + new ElkPositionedNode + { + Id = "start/2/join", + Label = "Parallel Execution Join", + Kind = "Join", + X = 1290d, + Y = 188.73291015625d, + Width = 176d, + Height = 124d, + }, + }; + var elkEdges = new[] + { + new ElkRoutedEdge + { + Id = "edge/3", + SourceNodeId = "start/2/split", + TargetNodeId = "start/2/branch-1/1", + Label = "branch 1", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 828d, Y = 189.1552734375d }, + EndPoint = new ElkPoint { X = 1016d, Y = 268.310546875d }, + BendPoints = + [ + new ElkPoint { X = 1016d, Y = 189.1552734375d }, + ], + }, + ], + }, + new ElkRoutedEdge + { + Id = "edge/4", + SourceNodeId = "start/2/split", + TargetNodeId = "start/2/join", + Label = "branch 2", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 740d, Y = 135.1552734375d }, + EndPoint = new ElkPoint { X = 1378d, Y = 196.73291015625d }, + BendPoints = + [ + new ElkPoint { X = 740d, Y = 55.1552734375d }, + new ElkPoint { X = 1378d, Y = 55.1552734375d }, + ], + }, + ], + }, + }; + var severityByEdgeId = new Dictionary(StringComparer.Ordinal); + var gatewaySourceCount = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations( + elkEdges, + elkNodes, + severityByEdgeId, + 1); + + Assert.That(gatewaySourceCount, Is.EqualTo(0)); + Assert.That(severityByEdgeId.Keys, Does.Not.Contain("edge/4")); + } + + [Test] + public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepRetryDefaultWithinTheLocalSetterBand() + { + var graph = BuildDocumentProcessingWorkflowGraph(); + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + + var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + NodeSpacing = 50, + }); + + var retryDefault = FlattenPath(layout.Edges.Single(edge => edge.Id == "edge/9")); + var cooldownTimer = layout.Nodes.Single(node => node.Id == "start/2/branch-1/1/body/4/failure/1/true/1"); + var batchFailed = layout.Nodes.Single(node => node.Id == "start/2/branch-1/1/body/4/failure/2"); + var maxAllowedY = Math.Max( + cooldownTimer.Y + cooldownTimer.Height, + batchFailed.Y + batchFailed.Height) + 24d; + + Assert.That( + retryDefault.Max(point => point.Y), + Is.LessThanOrEqualTo(maxAllowedY), + "Retry Decision default should stay in the local setter family instead of dropping into a lower detour band under Cooldown Timer."); + + var elkNodes = layout.Nodes.Select(ToElkNode).ToArray(); + var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Kind = edge.Kind, + Label = edge.Label, + Sections = edge.Sections.Select(section => new ElkEdgeSection + { + StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y }, + EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y }, + BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(), + }).ToArray(), + }).ToArray(); + var brokenSetterHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(elkEdges, elkNodes) + .Where(diagnostic => string.Equals( + diagnostic.TargetNodeId, + "start/2/branch-1/1/body/4/failure/2", + StringComparison.Ordinal)) + .ToArray(); + + Assert.That( + brokenSetterHighways, + Is.Empty, + "Retry Decision default and Cooldown Timer continuation should not collapse into a broken short-highway at Set batchGenerateFailed."); + + var retryDefaultEdge = elkEdges.Single(edge => edge.Id == "edge/9"); + var cooldownEdge = elkEdges.Single(edge => edge.Id == "edge/8"); + Assert.That( + ElkEdgeRoutingScoring.CountUnderNodeViolations([retryDefaultEdge], elkNodes), + Is.EqualTo(0), + "Retry Decision default should stay clear of Cooldown Timer instead of tucking underneath it."); + Assert.That( + ElkEdgeRoutingScoring.DetectSharedLaneConflicts([cooldownEdge, retryDefaultEdge], elkNodes), + Is.Empty, + "Retry Decision default should keep a distinct departure lane instead of sharing Cooldown Timer's rail."); + } + + [Test] + public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepAllTerminalArrivalsOnTheEndLeftFaceFamily() + { + var graph = BuildDocumentProcessingWorkflowGraph(); + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + + var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + NodeSpacing = 50, + }); + + var endNode = layout.Nodes.Single(node => node.Id == "end"); + var terminalEdges = new[] + { + layout.Edges.Single(edge => edge.Id == "edge/20"), + layout.Edges.Single(edge => edge.Id == "edge/23"), + layout.Edges.Single(edge => edge.Id == "edge/30"), + layout.Edges.Single(edge => edge.Id == "edge/32"), + layout.Edges.Single(edge => edge.Id == "edge/33"), + }; + + foreach (var edge in terminalEdges) + { + var path = FlattenPath(edge); + Assert.That( + ResolveBoundarySide(path[^1], endNode), + Is.EqualTo("left"), + $"Terminal arrival {edge.Id} should join the same left-face End family as the shorter email-dispatch arrivals."); + Assert.That( + ResolveTargetApproachJoinSide(path, endNode), + Is.EqualTo("left"), + $"Terminal arrival {edge.Id} should approach End from the left-side family instead of curling in from the top/right."); + } + + var elkNodes = layout.Nodes.Select(ToElkNode).ToArray(); + var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Kind = edge.Kind, + Label = edge.Label, + Sections = edge.Sections.Select(section => new ElkEdgeSection + { + StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y }, + EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y }, + BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(), + }).ToArray(), + }).ToArray(); + var terminalIds = new HashSet(terminalEdges.Select(edge => edge.Id), StringComparer.Ordinal); + var sharedTerminalConflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes) + .Where(conflict => terminalIds.Contains(conflict.LeftEdgeId) && terminalIds.Contains(conflict.RightEdgeId)) + .ToArray(); + var brokenTerminalHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(elkEdges, elkNodes) + .Where(diagnostic => string.Equals(diagnostic.TargetNodeId, "end", StringComparison.Ordinal)) + .ToArray(); + var terminalJoinOffenders = GetTargetApproachJoinOffenders(layout.Edges, layout.Nodes) + .Where(offender => terminalIds.Any(edgeId => offender.Contains(edgeId, StringComparison.Ordinal))) + .ToArray(); + + Assert.That( + sharedTerminalConflicts, + Is.Empty, + "All End arrivals should resolve into one coherent terminal family instead of a split corridor-versus-side-face strategy."); + var topTerminalHighwayEdges = elkEdges + .Where(edge => edge.Id is "edge/20" or "edge/23") + .ToArray(); + Assert.That( + ElkEdgeRoutingScoring.CountUnderNodeViolations(topTerminalHighwayEdges, elkNodes), + Is.EqualTo(0), + "The top-family End arrivals should stay on the above-graph terminal highway instead of dropping into an under-node horizontal before End."); + Assert.That( + terminalJoinOffenders, + Is.Empty, + "All End arrivals should keep distinct left-face feeder bands instead of collapsing into target-side joins near End."); + Assert.That( + brokenTerminalHighways, + Is.Empty, + "All End arrivals should share a coherent terminal highway instead of fragmenting into short, broken target-side bundles."); + } + + [Test] + public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepProcessBatchExitOrthogonalIntoParallelJoin() + { + var graph = BuildDocumentProcessingWorkflowGraph(); + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + + var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + NodeSpacing = 50, + }); + + var processBatchExit = layout.Edges.Single(edge => edge.Id == "edge/17"); + var boundaryAngleOffenders = GetBoundaryAngleViolations(processBatchExit, layout.Nodes).ToArray(); + + Assert.That( + boundaryAngleOffenders, + Is.Empty, + "Process Batch -> Parallel Execution Join must keep an orthogonal gateway approach instead of collapsing into a diagonal join entry."); + } } diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpEdgeRefinementTests.Restabilization.AdvancedFamilies.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpEdgeRefinementTests.Restabilization.AdvancedFamilies.cs index b971a4e9f..d6451299b 100644 --- a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpEdgeRefinementTests.Restabilization.AdvancedFamilies.cs +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpEdgeRefinementTests.Restabilization.AdvancedFamilies.cs @@ -1294,4 +1294,313 @@ public partial class ElkSharpEdgeRefinementTests repairedLowerPath.Should().NotContain(point => Math.Abs(point.X - 4840d) <= 0.5d && point.Y < 697.3528d); repairedLowerPath.Should().Contain(point => point.X < 4840d && point.Y < 697.3528d); } + + [Test] + [Property("Intent", "Operational")] + public void TopCorridorOwnership_WhenRepeatAndEndFamiliesOverlap_ShouldKeepRepeatClosestAndShareEndRoofLane() + { + var repeatTarget = new ElkPositionedNode + { + Id = "repeat", + Label = "Process Batch", + Kind = "Repeat", + X = 992, + Y = 247.181640625, + Width = 208, + Height = 88, + }; + + var repeatSource = new ElkPositionedNode + { + Id = "check", + Label = "Check Result", + Kind = "Decision", + X = 4240, + Y = 297.4360656738281, + Width = 188, + Height = 132, + }; + + var sourceFailure = new ElkPositionedNode + { + Id = "start/3", + Label = "Load Configuration", + Kind = "TransportCall", + X = 3200, + Y = 120, + Width = 208, + Height = 88, + }; + + var sourceDefault = new ElkPositionedNode + { + Id = "start/9/true/1/true/1/handled/1", + Label = "Set internalNotificationFailed", + Kind = "SetState", + X = 3560, + Y = 356, + Width = 208, + Height = 88, + }; + + var end = new ElkPositionedNode + { + Id = "end", + Label = "End", + Kind = "End", + X = 5000, + Y = 404, + Width = 264, + Height = 132, + }; + + var repeatReturn = new ElkRoutedEdge + { + Id = "edge/14", + SourceNodeId = repeatSource.Id, + TargetNodeId = repeatTarget.Id, + Label = "repeat while state.printInsisAttempt eq 0", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 4412.082354488216, Y = 358.4633486927404 }, + EndPoint = new ElkPoint { X = 1096, Y = 247.181640625 }, + BendPoints = + [ + new ElkPoint { X = 4398, Y = 358.4633486927404 }, + new ElkPoint { X = 4398, Y = 80 }, + new ElkPoint { X = 1096, Y = 80 }, + ], + }, + ], + }; + + var failureArrival = new ElkRoutedEdge + { + Id = "edge/20", + SourceNodeId = sourceFailure.Id, + TargetNodeId = end.Id, + Label = "on failure / timeout", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 3408, Y = 164 }, + EndPoint = new ElkPoint { X = 5000, Y = 438 }, + BendPoints = + [ + new ElkPoint { X = 3408, Y = 64 }, + new ElkPoint { X = 4620, Y = 64 }, + new ElkPoint { X = 4620, Y = 438 }, + ], + }, + ], + }; + + var defaultArrival = new ElkRoutedEdge + { + Id = "edge/23", + SourceNodeId = sourceDefault.Id, + TargetNodeId = end.Id, + Label = "default", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 3768, Y = 400 }, + EndPoint = new ElkPoint { X = 5000, Y = 472 }, + BendPoints = + [ + new ElkPoint { X = 3768, Y = 100 }, + new ElkPoint { X = 4548, Y = 100 }, + new ElkPoint { X = 4548, Y = 472 }, + ], + }, + ], + }; + + static double FindAboveGraphLaneY(ElkRoutedEdge edge, double graphMinY) + { + var path = ExtractPath(edge); + var bestLength = double.NegativeInfinity; + var bestY = double.NaN; + for (var i = 0; i < path.Count - 1; i++) + { + if (Math.Abs(path[i].Y - path[i + 1].Y) > 0.5d + || path[i].Y >= graphMinY - 8d) + { + continue; + } + + var length = Math.Abs(path[i + 1].X - path[i].X); + if (length <= bestLength) + { + continue; + } + + bestLength = length; + bestY = path[i].Y; + } + + double.IsNaN(bestY).Should().BeFalse(); + return bestY; + } + + var nodes = new[] { repeatTarget, repeatSource, sourceFailure, sourceDefault, end }; + var edges = new[] { repeatReturn, failureArrival, defaultArrival }; + var graphMinY = nodes.Min(node => node.Y); + FindAboveGraphLaneY(repeatReturn, graphMinY).Should().BeGreaterThan(FindAboveGraphLaneY(failureArrival, graphMinY)); + Math.Abs(FindAboveGraphLaneY(failureArrival, graphMinY) - FindAboveGraphLaneY(defaultArrival, graphMinY)) + .Should() + .BeGreaterThan(1d); + + var repaired = ElkTopCorridorOwnership.SpreadAboveGraphCorridorLanes(edges, nodes, 53d); + var repairedRepeat = repaired.Single(edge => edge.Id == "edge/14"); + var repairedFailure = repaired.Single(edge => edge.Id == "edge/20"); + var repairedDefault = repaired.Single(edge => edge.Id == "edge/23"); + + var repairedRepeatY = FindAboveGraphLaneY(repairedRepeat, graphMinY); + var repairedFailureY = FindAboveGraphLaneY(repairedFailure, graphMinY); + var repairedDefaultY = FindAboveGraphLaneY(repairedDefault, graphMinY); + + repairedRepeatY.Should().BeGreaterThan(repairedFailureY); + repairedRepeatY.Should().BeGreaterThan(repairedDefaultY); + Math.Abs(repairedFailureY - repairedDefaultY).Should().BeLessThanOrEqualTo(1d); + } + + [Test] + [Property("Intent", "Operational")] + public void EndTerminalFamilyHelpers_WhenTopFamilyIsSplitAcrossRoofLanes_ShouldShareOneAboveGraphHighway() + { + var sourceFailure = new ElkPositionedNode + { + Id = "start/3", + Label = "Load Configuration", + Kind = "TransportCall", + X = 3200, + Y = 120, + Width = 208, + Height = 88, + }; + + var sourceDefault = new ElkPositionedNode + { + Id = "start/9/true/1/true/1/handled/1", + Label = "Set internalNotificationFailed", + Kind = "SetState", + X = 3560, + Y = 356, + Width = 208, + Height = 88, + }; + + var target = new ElkPositionedNode + { + Id = "end", + Label = "End", + Kind = "End", + X = 5000, + Y = 404, + Width = 264, + Height = 132, + }; + + var failureArrival = new ElkRoutedEdge + { + Id = "edge/20", + SourceNodeId = sourceFailure.Id, + TargetNodeId = target.Id, + Label = "on failure / timeout", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 3408, Y = 164 }, + EndPoint = new ElkPoint { X = 5000, Y = 438 }, + BendPoints = + [ + new ElkPoint { X = 3408, Y = 64 }, + new ElkPoint { X = 4620, Y = 64 }, + new ElkPoint { X = 4620, Y = 438 }, + ], + }, + ], + }; + + var defaultArrival = new ElkRoutedEdge + { + Id = "edge/23", + SourceNodeId = sourceDefault.Id, + TargetNodeId = target.Id, + Label = "default", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 3768, Y = 400 }, + EndPoint = new ElkPoint { X = 5000, Y = 472 }, + BendPoints = + [ + new ElkPoint { X = 3768, Y = 100 }, + new ElkPoint { X = 4548, Y = 100 }, + new ElkPoint { X = 4548, Y = 472 }, + ], + }, + ], + }; + + static double FindAboveGraphLaneY(ElkRoutedEdge edge, double graphMinY) + { + var path = ExtractPath(edge); + var bestLength = double.NegativeInfinity; + var bestY = double.NaN; + for (var i = 0; i < path.Count - 1; i++) + { + if (Math.Abs(path[i].Y - path[i + 1].Y) > 0.5d + || path[i].Y >= graphMinY - 8d) + { + continue; + } + + var length = Math.Abs(path[i + 1].X - path[i].X); + if (length <= bestLength) + { + continue; + } + + bestLength = length; + bestY = path[i].Y; + } + + double.IsNaN(bestY).Should().BeFalse(); + return bestY; + } + + var nodes = new[] { sourceFailure, sourceDefault, target }; + var edges = new[] { failureArrival, defaultArrival }; + var graphMinY = nodes.Min(node => node.Y); + Math.Abs(FindAboveGraphLaneY(failureArrival, graphMinY) - FindAboveGraphLaneY(defaultArrival, graphMinY)) + .Should() + .BeGreaterThan(1d); + + var repaired = ElkEdgePostProcessor.DistributeEndTerminalLeftFaceTrunks(edges, nodes, 53d); + + var repairedFailure = repaired.Single(edge => edge.Id == "edge/20"); + var repairedDefault = repaired.Single(edge => edge.Id == "edge/23"); + var repairedFailurePath = ExtractPath(repairedFailure); + var repairedDefaultPath = ExtractPath(repairedDefault); + + Math.Abs(FindAboveGraphLaneY(repairedFailure, graphMinY) - FindAboveGraphLaneY(repairedDefault, graphMinY)) + .Should() + .BeLessThanOrEqualTo(1d); + ElkEdgeRoutingGeometry.ResolveBoundarySide(repairedFailurePath[^1], target).Should().Be("left"); + ElkEdgeRoutingGeometry.ResolveBoundarySide(repairedDefaultPath[^1], target).Should().Be("left"); + ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(repairedFailurePath[^1], repairedFailurePath[^2], target).Should().Be("left"); + ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(repairedDefaultPath[^1], repairedDefaultPath[^2], target).Should().Be("left"); + ElkEdgeRouterHighway.DetectRemainingBrokenHighways(repaired, nodes) + .Any(diagnostic => string.Equals(diagnostic.TargetNodeId, target.Id, StringComparison.Ordinal) && diagnostic.WasBroken) + .Should() + .BeFalse(); + } } diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/WorkflowRenderSvgRendererTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/WorkflowRenderSvgRendererTests.cs index 72e3a3d46..d9a6dd2fa 100644 --- a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/WorkflowRenderSvgRendererTests.cs +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/WorkflowRenderSvgRendererTests.cs @@ -1,3 +1,6 @@ +using System.Globalization; +using System.Reflection; +using System.Text.RegularExpressions; using FluentAssertions; using NUnit.Framework; @@ -9,6 +12,124 @@ namespace StellaOps.Workflow.Renderer.Tests; [TestFixture] public class WorkflowRenderSvgRendererTests { + [Test] + public void Render_WhenLegendContentIsSparse_ShouldSizeLegendToContent() + { + var renderer = new WorkflowRenderSvgRenderer(); + var layout = new WorkflowRenderLayoutResult + { + GraphId = "legend-sparse", + Nodes = + [ + new WorkflowRenderPositionedNode + { + Id = "start", + Label = "Start", + Kind = "Start", + X = 0, + Y = 0, + Width = 128, + Height = 64, + }, + new WorkflowRenderPositionedNode + { + Id = "end", + Label = "End", + Kind = "End", + X = 220, + Y = 0, + Width = 128, + Height = 64, + }, + ], + Edges = + [ + new WorkflowRenderRoutedEdge + { + Id = "e1", + SourceNodeId = "start", + TargetNodeId = "end", + Sections = + [ + new WorkflowRenderEdgeSection + { + StartPoint = new WorkflowRenderPoint { X = 128, Y = 32 }, + EndPoint = new WorkflowRenderPoint { X = 220, Y = 32 }, + BendPoints = [], + }, + ], + }, + ], + }; + + var document = renderer.Render(layout, "LegendCompact"); + var match = Regex.Match( + document.Svg, + "when payload.amount exceeds<"); + document.Svg.Should().Contain(">approval threshold<"); + document.Svg.Should().NotContain(">when payload.amount exceeds approval threshold<"); + } + [Test] public void Render_WhenTaskGatewayAndConditionsExist_ShouldEmitBoxesDiamondsLegendAndStyledBranches() { @@ -154,7 +275,8 @@ public class WorkflowRenderSvgRendererTests document.Svg.Should().Contain("markerWidth=\"5\""); document.Svg.Should().Contain("stroke-dasharray=\"2.2 4.8\""); document.Svg.Should().Contain("fill-opacity=\"0.54\""); - document.Svg.Should().Contain("when payload.answer == "approve""); + document.Svg.Should().Contain(">when payload.answer ==<"); + document.Svg.Should().Contain(">"approve"<"); document.Svg.Should().Contain("stroke=\"#15803d\""); document.Svg.Should().Contain("Call Pricing"); document.Svg.Should().Contain(">Wait For Timeout<"); @@ -255,7 +377,30 @@ public class WorkflowRenderSvgRendererTests var document = renderer.Render(layout, "BridgeGap"); document.Svg.Should().Contain("data-bridge-gap=\"true\""); - document.Svg.Should().Contain("M 214.93,318"); - document.Svg.Should().Contain("L 225.07,318"); + Regex.IsMatch(document.Svg, "M 21[0-9](?:\\.\\d+)?,318 L 22[0-9](?:\\.\\d+)?,318") + .Should() + .BeTrue(); + } + + [Test] + public void BuildRoundedEdgePath_WhenShortHorizontalJogPrecedesAShallowContinuation_ShouldPreserveTheHorizontalSpan() + { + var buildRoundedEdgePath = typeof(WorkflowRenderSvgRenderer) + .GetMethod("BuildRoundedEdgePath", BindingFlags.NonPublic | BindingFlags.Static); + + buildRoundedEdgePath.Should().NotBeNull(); + + WorkflowRenderPoint[] points = + [ + new WorkflowRenderPoint { X = 0, Y = 0 }, + new WorkflowRenderPoint { X = 24, Y = 0 }, + new WorkflowRenderPoint { X = 164, Y = 2.8d }, + ]; + + var pathData = (string?)buildRoundedEdgePath!.Invoke(null, [points, 0d, 0d, 40d]); + + pathData.Should().NotBeNullOrWhiteSpace(); + pathData.Should().Contain("164,"); + pathData.Should().NotContain("M 0,0 L 0,"); } } diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.EndTerminalFamilies.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.EndTerminalFamilies.cs new file mode 100644 index 000000000..793a8e68d --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.EndTerminalFamilies.cs @@ -0,0 +1,1094 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgePostProcessor +{ + internal static ElkRoutedEdge[] DistributeEndTerminalLeftFaceTrunks( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + double minLineClearance) + { + if (edges.Length < 2 || nodes.Length == 0) + { + return edges; + } + + const double coordinateTolerance = 0.5d; + 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 = edges.ToArray(); + var changed = false; + + var groups = result + .Select((edge, index) => new + { + Edge = edge, + Index = index, + Path = ExtractFullPath(edge), + }) + .Where(item => + item.Path.Count >= 2 + && !string.IsNullOrWhiteSpace(item.Edge.SourceNodeId) + && !string.IsNullOrWhiteSpace(item.Edge.TargetNodeId) + && nodesById.TryGetValue(item.Edge.SourceNodeId!, out _) + && nodesById.TryGetValue(item.Edge.TargetNodeId!, out var targetNode) + && string.Equals(targetNode.Kind, "End", StringComparison.Ordinal)) + .GroupBy(item => item.Edge.TargetNodeId!, StringComparer.Ordinal); + + foreach (var group in groups) + { + if (!nodesById.TryGetValue(group.Key, out var targetNode)) + { + continue; + } + + var focusEdgeIds = group + .Select(item => item.Edge.Id) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (focusEdgeIds.Length < 2) + { + continue; + } + + var orderedEntries = group + .Select(item => + { + var sourceNode = nodesById[item.Edge.SourceNodeId!]; + return new EndTerminalEntry( + item.Index, + item.Edge, + item.Path, + sourceNode, + ShouldUseAboveGraphTerminalFamily( + item.Path, + sourceNode, + targetNode, + graphMinY, + graphMaxY), + item.Path[^1].Y); + }) + .OrderBy(entry => entry.UsesAboveGraph ? 0 : 1) + .ThenBy(entry => entry.EndpointCoordinate) + .ThenBy(entry => entry.Edge.Id, StringComparer.Ordinal) + .ToArray(); + if (orderedEntries.Length < 2) + { + continue; + } + + var assignedSlotCoordinates = BuildEndTerminalAssignedSlotCoordinates( + targetNode, + orderedEntries.Select(entry => entry.UsesAboveGraph).ToArray(), + minLineClearance); + if (assignedSlotCoordinates.Length != orderedEntries.Length) + { + continue; + } + + var currentEdges = result; + var currentScore = ElkEdgeRoutingScoring.ComputeScore(currentEdges, nodes); + var currentLocal = MeasureEndTerminalLocalMetrics( + currentEdges, + nodes, + nodesById, + focusEdgeIds, + group.Key); + var baselineScore = currentScore; + var baselineLocal = currentLocal; + + var approachX = targetNode.X - 24d; + var trunkSpacing = Math.Max( + 24d, + ElkBoundarySlots.ResolveRequiredBoundarySlotGap( + targetNode, + "left", + orderedEntries.Length, + minLineClearance)); + var topFamilyCount = orderedEntries.Count(entry => entry.UsesAboveGraph); + var topFamilyTrunkX = Math.Min( + approachX - 18d, + approachX - Math.Max(32d, Math.Max(2, topFamilyCount + 1) * trunkSpacing)); + var sideFamilyBaseX = Math.Min( + approachX - 18d, + topFamilyCount > 0 ? topFamilyTrunkX - trunkSpacing : approachX - trunkSpacing); + + var topFamilyOrdinal = 0; + var sideFamilyOrdinal = 0; + var groupChanged = false; + + var groupedCandidateEdges = currentEdges.ToArray(); + var builtGroupedCandidate = false; + var groupedCandidateValid = true; + topFamilyOrdinal = 0; + sideFamilyOrdinal = 0; + for (var i = 0; i < orderedEntries.Length; i++) + { + var entry = orderedEntries[i]; + var endpointY = assignedSlotCoordinates[i]; + List candidatePath; + if (entry.UsesAboveGraph) + { + candidatePath = topFamilyCount > 1 && topFamilyOrdinal == 0 + ? RewriteLeftFaceEndTopCorridorLeadLane( + entry.Path, + targetNode, + topFamilyTrunkX, + endpointY, + minLineClearance, + graphMinY, + coordinateTolerance) + : RewriteLeftFaceEndTopCorridor( + entry.Path, + targetNode, + topFamilyTrunkX, + endpointY, + graphMinY, + coordinateTolerance); + topFamilyOrdinal++; + } + else + { + var sideFamilyTrunkX = sideFamilyBaseX - (sideFamilyOrdinal * trunkSpacing); + candidatePath = RewriteLeftFaceEndSlotRail( + entry.Path, + targetNode, + sideFamilyTrunkX, + endpointY, + coordinateTolerance); + sideFamilyOrdinal++; + } + + var restoredCandidate = entry.UsesAboveGraph + ? RestoreTerminalTopFamilySourcePrefix(candidatePath, entry.SourceNode, coordinateTolerance) + : RestoreTerminalSideFamilySourcePrefix(candidatePath, entry.SourceNode, coordinateTolerance); + if (!PathChanged(entry.Path, restoredCandidate)) + { + continue; + } + + if (HasNodeObstacleCrossing( + restoredCandidate, + nodes, + entry.Edge.SourceNodeId, + entry.Edge.TargetNodeId)) + { + groupedCandidateValid = false; + break; + } + + groupedCandidateEdges[entry.Index] = BuildSingleSectionEdge(entry.Edge, restoredCandidate); + builtGroupedCandidate = true; + } + + if (groupedCandidateValid && builtGroupedCandidate) + { + var groupedCandidateScore = ElkEdgeRoutingScoring.ComputeScore(groupedCandidateEdges, nodes); + var groupedCandidateLocal = MeasureEndTerminalLocalMetrics( + groupedCandidateEdges, + nodes, + nodesById, + focusEdgeIds, + group.Key); + if ((groupedCandidateLocal.IsBetterThan(currentLocal) + || (groupedCandidateLocal.IsEquivalentTo(currentLocal) + && groupedCandidateScore.Value > currentScore.Value + 0.5d)) + && groupedCandidateScore.NodeCrossings <= currentScore.NodeCrossings + && groupedCandidateScore.BoundarySlotViolations <= currentScore.BoundarySlotViolations + && groupedCandidateScore.TargetApproachBacktrackingViolations <= currentScore.TargetApproachBacktrackingViolations + && groupedCandidateScore.GatewaySourceExitViolations <= currentScore.GatewaySourceExitViolations + && groupedCandidateScore.EntryAngleViolations <= currentScore.EntryAngleViolations + 1) + { + currentEdges = groupedCandidateEdges; + currentLocal = groupedCandidateLocal; + currentScore = groupedCandidateScore; + groupChanged = true; + } + } + + topFamilyOrdinal = 0; + sideFamilyOrdinal = 0; + + for (var i = 0; i < orderedEntries.Length; i++) + { + var entry = orderedEntries[i]; + var endpointY = assignedSlotCoordinates[i]; + var candidateVariants = new List<(List Candidate, double Cost)>(); + + if (entry.UsesAboveGraph) + { + if (topFamilyCount > 1 && topFamilyOrdinal == 0) + { + candidateVariants.Add(( + RewriteLeftFaceEndTopCorridorLeadLane( + entry.Path, + targetNode, + topFamilyTrunkX, + endpointY, + minLineClearance, + graphMinY, + coordinateTolerance), + -0.15d)); + } + + candidateVariants.AddRange(BuildLeftFaceEndTrunkCandidates( + entry.Path, + targetNode, + topFamilyTrunkX, + endpointY, + graphMinY, + coordinateTolerance, + usesAboveGraphCorridor: true)); + topFamilyOrdinal++; + } + else + { + var sideFamilyTrunkX = sideFamilyBaseX - (sideFamilyOrdinal * trunkSpacing); + candidateVariants.AddRange(BuildLeftFaceEndTrunkCandidates( + entry.Path, + targetNode, + sideFamilyTrunkX, + endpointY, + graphMinY, + coordinateTolerance, + usesAboveGraphCorridor: false)); + sideFamilyOrdinal++; + } + + ElkRoutedEdge[]? preferredEdges = null; + EndTerminalLocalMetrics? preferredLocal = null; + EdgeRoutingScore? preferredScore = null; + var preferredWeightedPathLength = double.MaxValue; + + foreach (var variant in candidateVariants + .OrderBy(variant => variant.Cost) + .ThenBy(variant => variant.Candidate.Count) + .ThenBy(variant => variant.Candidate[^1].Y)) + { + var restoredCandidate = entry.UsesAboveGraph + ? RestoreTerminalTopFamilySourcePrefix( + variant.Candidate, + entry.SourceNode, + coordinateTolerance) + : RestoreTerminalSideFamilySourcePrefix( + variant.Candidate, + entry.SourceNode, + coordinateTolerance); + if (!PathChanged(entry.Path, restoredCandidate) + || HasNodeObstacleCrossing( + restoredCandidate, + nodes, + entry.Edge.SourceNodeId, + entry.Edge.TargetNodeId)) + { + continue; + } + + var candidateEdge = BuildSingleSectionEdge(entry.Edge, restoredCandidate); + var candidateEdges = currentEdges.ToArray(); + candidateEdges[entry.Index] = candidateEdge; + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); + if (candidateScore.NodeCrossings > currentScore.NodeCrossings + || candidateScore.BoundarySlotViolations > currentScore.BoundarySlotViolations + || candidateScore.TargetApproachBacktrackingViolations > currentScore.TargetApproachBacktrackingViolations + || candidateScore.GatewaySourceExitViolations > currentScore.GatewaySourceExitViolations + || candidateScore.EntryAngleViolations > currentScore.EntryAngleViolations + 1) + { + continue; + } + + var candidateLocal = MeasureEndTerminalLocalMetrics( + candidateEdges, + nodes, + nodesById, + focusEdgeIds, + group.Key); + if (!candidateLocal.IsBetterThan(currentLocal) + && (candidateLocal.UnderNodeViolations > currentLocal.UnderNodeViolations + || candidateLocal.SharedLaneViolations > currentLocal.SharedLaneViolations + || candidateLocal.TargetJoinViolations > currentLocal.TargetJoinViolations + || candidateLocal.SemanticTargetFamilyViolations > currentLocal.SemanticTargetFamilyViolations + || candidateLocal.BoundaryAngleViolations > currentLocal.BoundaryAngleViolations + || candidateLocal.BrokenHighways > currentLocal.BrokenHighways)) + { + continue; + } + + var weightedPathLength = ElkEdgeRoutingGeometry.ComputePathLength(candidateEdge) + variant.Cost; + if (preferredEdges is null + || candidateLocal.IsBetterThan(preferredLocal!.Value) + || (candidateLocal.IsEquivalentTo(preferredLocal!.Value) + && candidateScore.Value > preferredScore!.Value.Value + 0.5d) + || (candidateLocal.IsEquivalentTo(preferredLocal!.Value) + && Math.Abs(candidateScore.Value - preferredScore!.Value.Value) <= 0.5d + && weightedPathLength < preferredWeightedPathLength - 0.5d)) + { + preferredEdges = candidateEdges; + preferredLocal = candidateLocal; + preferredScore = candidateScore; + preferredWeightedPathLength = weightedPathLength; + } + } + + if (preferredEdges is null || preferredLocal is null || preferredScore is null) + { + continue; + } + + if (!preferredLocal.Value.IsBetterThan(currentLocal) + && (!preferredLocal.Value.IsEquivalentTo(currentLocal) + || preferredScore.Value.Value <= currentScore.Value + 0.5d)) + { + continue; + } + + currentEdges = preferredEdges; + currentLocal = preferredLocal.Value; + currentScore = preferredScore.Value; + groupChanged = true; + } + + if (!groupChanged) + { + continue; + } + + if (!currentLocal.IsBetterThan(baselineLocal) + && (!currentLocal.IsEquivalentTo(baselineLocal) + || currentScore.Value <= baselineScore.Value + 0.5d)) + { + continue; + } + + result = currentEdges; + changed = true; + } + + return changed ? result : edges; + } + + private static bool ShouldUseAboveGraphTerminalFamily( + IReadOnlyList path, + ElkPositionedNode? sourceNode, + ElkPositionedNode targetNode, + double graphMinY, + double graphMaxY) + { + if (HasAboveGraphHorizontalRun(path, graphMinY)) + { + return true; + } + + if (sourceNode is null || path.Count < 2) + { + return false; + } + + var graphHeight = Math.Max(1d, graphMaxY - graphMinY); + var upperFamilyThresholdY = graphMinY + (graphHeight * 0.35d); + var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); + var horizontalSpan = Math.Abs(path[^1].X - path[0].X); + return sourceCenterY <= upperFamilyThresholdY + && horizontalSpan >= Math.Max(640d, targetNode.Width * 3d); + } + + private static bool HasAboveGraphHorizontalRun( + IReadOnlyList path, + double graphMinY) + { + for (var i = 0; i < path.Count - 1; i++) + { + if (Math.Abs(path[i].Y - path[i + 1].Y) <= 0.5d + && path[i].Y < graphMinY - 8d) + { + return true; + } + } + + return false; + } + + private static List RewriteLeftFaceEndTrunk( + IReadOnlyList path, + ElkPositionedNode targetNode, + double trunkX, + double endpointY, + double coordinateTolerance) + { + var adjusted = path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (adjusted.Count < 2) + { + return adjusted; + } + + var targetX = targetNode.X; + var approachX = targetX - 24d; + var feederX = Math.Min(trunkX, approachX - 18d); + while (adjusted.Count >= 3 && Math.Abs(adjusted[^2].X - targetX) <= coordinateTolerance) + { + adjusted.RemoveAt(adjusted.Count - 2); + } + + var endpoint = new ElkPoint + { + X = targetX, + Y = endpointY, + }; + var rebuilt = adjusted.Take(adjusted.Count - 1).ToList(); + var shiftedTrailingVerticalRun = false; + if (rebuilt.Count >= 2) + { + var trailingVerticalX = rebuilt[^1].X; + var runStartIndex = rebuilt.Count - 1; + while (runStartIndex > 0 + && Math.Abs(rebuilt[runStartIndex - 1].X - trailingVerticalX) <= coordinateTolerance) + { + runStartIndex--; + } + + if (runStartIndex < rebuilt.Count - 1) + { + for (var i = runStartIndex; i < rebuilt.Count; i++) + { + rebuilt[i] = new ElkPoint + { + X = feederX, + Y = rebuilt[i].Y, + }; + } + + shiftedTrailingVerticalRun = true; + } + } + + if (!shiftedTrailingVerticalRun && Math.Abs(rebuilt[^1].X - feederX) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = feederX, Y = rebuilt[^1].Y }); + } + + if (Math.Abs(rebuilt[^1].Y - endpoint.Y) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = feederX, Y = endpoint.Y }); + } + + if (Math.Abs(rebuilt[^1].X - approachX) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = approachX, Y = endpoint.Y }); + } + + rebuilt.Add(new ElkPoint { X = targetX, Y = endpoint.Y }); + return EnforceLeftFaceTerminalApproachInvariant( + NormalizeOrthogonalPath(rebuilt, coordinateTolerance), + targetNode, + feederX, + coordinateTolerance); + } + + private static IEnumerable<(List Candidate, double Cost)> BuildLeftFaceEndTrunkCandidates( + IReadOnlyList path, + ElkPositionedNode targetNode, + double trunkX, + double endpointY, + double graphMinY, + double coordinateTolerance, + bool usesAboveGraphCorridor) + { + if (usesAboveGraphCorridor) + { + var topCorridorCandidate = RewriteLeftFaceEndTopCorridor( + path, + targetNode, + trunkX, + endpointY, + graphMinY, + coordinateTolerance); + yield return (topCorridorCandidate, 0d); + } + + if (!usesAboveGraphCorridor) + { + var slotRailCandidate = RewriteLeftFaceEndSlotRail(path, targetNode, trunkX, endpointY, coordinateTolerance); + yield return (slotRailCandidate, 0d); + } + + var preservedBandCandidate = RewriteLeftFaceEndTrunk(path, targetNode, trunkX, endpointY, coordinateTolerance); + yield return (preservedBandCandidate, usesAboveGraphCorridor ? 0.2d : 0.35d); + } + + private static List RewriteLeftFaceEndTopCorridor( + IReadOnlyList path, + ElkPositionedNode targetNode, + double trunkX, + double endpointY, + double graphMinY, + double coordinateTolerance) + { + var targetX = targetNode.X; + var feederX = Math.Min(targetX - 18d, Math.Max(trunkX + 20d, targetX - 44d)); + var approachX = Math.Min(targetX - 8d, Math.Max(feederX + 10d, targetX - 18d)); + var corridorY = graphMinY - 18d; + var rebuilt = new List + { + new() { X = path[0].X, Y = path[0].Y }, + }; + + if (Math.Abs(rebuilt[^1].Y - corridorY) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = rebuilt[^1].X, Y = corridorY }); + } + + if (Math.Abs(rebuilt[^1].X - feederX) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = feederX, Y = corridorY }); + } + + if (Math.Abs(rebuilt[^1].Y - endpointY) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = feederX, Y = endpointY }); + } + + if (Math.Abs(rebuilt[^1].X - approachX) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = approachX, Y = endpointY }); + } + + rebuilt.Add(new ElkPoint { X = targetX, Y = endpointY }); + return EnforceLeftFaceTerminalApproachInvariant( + NormalizeOrthogonalPath(rebuilt, coordinateTolerance), + targetNode, + feederX, + coordinateTolerance); + } + + private static List RewriteLeftFaceEndTopCorridorLeadLane( + IReadOnlyList path, + ElkPositionedNode targetNode, + double trunkX, + double endpointY, + double minLineClearance, + double graphMinY, + double coordinateTolerance) + { + var targetX = targetNode.X; + var corridorY = graphMinY - 18d; + var entryRailX = trunkX; + var jogX = Math.Min(targetX - 22d, entryRailX + Math.Max(24d, minLineClearance * 0.55d)); + var preTerminalY = Math.Max(corridorY + 18d, endpointY - Math.Max(18d, minLineClearance * 0.35d)); + var rebuilt = new List + { + new() { X = path[0].X, Y = path[0].Y }, + }; + + if (Math.Abs(rebuilt[^1].Y - corridorY) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = rebuilt[^1].X, Y = corridorY }); + } + + if (Math.Abs(rebuilt[^1].X - entryRailX) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = entryRailX, Y = corridorY }); + } + + if (Math.Abs(rebuilt[^1].Y - preTerminalY) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = entryRailX, Y = preTerminalY }); + } + + if (Math.Abs(rebuilt[^1].X - jogX) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = jogX, Y = preTerminalY }); + } + + if (Math.Abs(rebuilt[^1].Y - endpointY) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = jogX, Y = endpointY }); + } + + if (Math.Abs(rebuilt[^1].X - targetX) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = targetX, Y = endpointY }); + } + + return EnforceLeftFaceTerminalApproachInvariant( + NormalizeOrthogonalPath(rebuilt, coordinateTolerance), + targetNode, + jogX, + coordinateTolerance); + } + + private static List RewriteLeftFaceEndSlotRail( + IReadOnlyList path, + ElkPositionedNode targetNode, + double trunkX, + double endpointY, + double coordinateTolerance) + { + var adjusted = path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (adjusted.Count < 2) + { + return adjusted; + } + + var targetX = targetNode.X; + while (adjusted.Count >= 3 && Math.Abs(adjusted[^2].X - targetX) <= coordinateTolerance) + { + adjusted.RemoveAt(adjusted.Count - 2); + } + + if (!TryExtractTargetApproachRun(adjusted, "left", out var runStartIndex, out _)) + { + return RewriteLeftFaceEndTrunk(path, targetNode, trunkX, endpointY, coordinateTolerance); + } + + var trimIndex = Math.Max(1, runStartIndex - 1); + if (TryExtractTargetApproachBand(adjusted, "left", out _)) + { + trimIndex = Math.Max(1, runStartIndex - 2); + } + + var rebuilt = adjusted + .Take(trimIndex) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (rebuilt.Count == 0) + { + rebuilt.Add(new ElkPoint { X = adjusted[0].X, Y = adjusted[0].Y }); + } + + var approachX = targetX - 24d; + var railX = Math.Min(trunkX, approachX - 18d); + var currentPoint = rebuilt[^1]; + var preferVerticalFirst = Math.Abs(currentPoint.Y - endpointY) > coordinateTolerance + && Math.Abs(currentPoint.X - railX) > coordinateTolerance + && currentPoint.X < approachX - coordinateTolerance; + + if (preferVerticalFirst) + { + rebuilt.Add(new ElkPoint { X = currentPoint.X, Y = endpointY }); + } + + if (Math.Abs(rebuilt[^1].X - railX) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = railX, Y = rebuilt[^1].Y }); + } + + if (Math.Abs(rebuilt[^1].Y - endpointY) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = railX, Y = endpointY }); + } + + if (Math.Abs(rebuilt[^1].X - approachX) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = approachX, Y = endpointY }); + } + + if (Math.Abs(rebuilt[^1].X - targetX) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = targetX, Y = endpointY }); + } + + return EnforceLeftFaceTerminalApproachInvariant( + NormalizeOrthogonalPath(rebuilt, coordinateTolerance), + targetNode, + railX, + coordinateTolerance); + } + + private static List EnforceLeftFaceTerminalApproachInvariant( + IReadOnlyList path, + ElkPositionedNode targetNode, + double railX, + double coordinateTolerance) + { + var adjusted = path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (adjusted.Count < 2) + { + return adjusted; + } + + var targetX = targetNode.X; + var safeRailX = Math.Min(railX, targetX - 6d); + adjusted[^1] = new ElkPoint + { + X = targetX, + Y = adjusted[^1].Y, + }; + + var lastInteriorIndex = adjusted.Count - 2; + var trailingX = adjusted[lastInteriorIndex].X; + if (trailingX >= targetX - coordinateTolerance) + { + var runStartIndex = lastInteriorIndex; + while (runStartIndex > 0 + && Math.Abs(adjusted[runStartIndex - 1].X - trailingX) <= coordinateTolerance) + { + runStartIndex--; + } + + for (var i = runStartIndex; i <= lastInteriorIndex; i++) + { + adjusted[i] = new ElkPoint + { + X = safeRailX, + Y = adjusted[i].Y, + }; + } + } + + if (adjusted[^2].X >= targetX - coordinateTolerance) + { + adjusted.Insert( + adjusted.Count - 1, + new ElkPoint + { + X = safeRailX, + Y = adjusted[^1].Y, + }); + } + + return NormalizeOrthogonalPath(adjusted, coordinateTolerance); + } + + private static double[] BuildEndTerminalAssignedSlotCoordinates( + ElkPositionedNode targetNode, + IReadOnlyList usesAboveGraphCorridorByFamilyEntry, + double minLineClearance) + { + if (usesAboveGraphCorridorByFamilyEntry.Count == 0) + { + return []; + } + + return ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates( + targetNode, + "left", + usesAboveGraphCorridorByFamilyEntry.Count); + } + + private static List RestoreTerminalSideFamilySourcePrefix( + IReadOnlyList path, + ElkPositionedNode sourceNode, + double coordinateTolerance) + { + if (path.Count < 2) + { + return path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + var slotY = Math.Max( + sourceNode.Y + 4d, + Math.Min(sourceNode.Y + sourceNode.Height - 4d, path[0].Y)); + var boundaryPoint = ElkBoundarySlots.BuildBoundarySlotPoint(sourceNode, "right", slotY); + var exitX = Math.Max(path[0].X, boundaryPoint.X + 8d); + var rebuilt = new List + { + new() + { + X = boundaryPoint.X, + Y = boundaryPoint.Y, + }, + }; + + if (Math.Abs(exitX - boundaryPoint.X) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint + { + X = exitX, + Y = boundaryPoint.Y, + }); + } + + if (Math.Abs(path[0].X - path[1].X) <= coordinateTolerance) + { + rebuilt.Add(new ElkPoint + { + X = exitX, + Y = path[1].Y, + }); + + for (var i = 2; i < path.Count; i++) + { + rebuilt.Add(new ElkPoint { X = path[i].X, Y = path[i].Y }); + } + } + else + { + for (var i = 1; i < path.Count; i++) + { + rebuilt.Add(new ElkPoint { X = path[i].X, Y = path[i].Y }); + } + } + + return NormalizeOrthogonalPath(rebuilt, coordinateTolerance); + } + + private static List RestoreTerminalTopFamilySourcePrefix( + IReadOnlyList path, + ElkPositionedNode sourceNode, + double coordinateTolerance) + { + if (path.Count < 2) + { + return path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + var slotY = Math.Max( + sourceNode.Y + 4d, + Math.Min(sourceNode.Y + sourceNode.Height - 4d, path[0].Y)); + var desiredAxis = Math.Max( + sourceNode.X + sourceNode.Width + 24d, + path[0].X + 24d); + + ElkPoint boundaryPoint; + if (ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + var sourceReference = new ElkPoint + { + X = desiredAxis, + Y = path[0].Y, + }; + boundaryPoint = TryResolvePreferredGatewaySourceBoundary( + sourceNode, + sourceReference, + path[^1], + out var preferredBoundary) + ? preferredBoundary + : ElkBoundarySlots.BuildBoundarySlotPoint(sourceNode, "right", slotY); + } + else + { + boundaryPoint = ElkBoundarySlots.BuildBoundarySlotPoint(sourceNode, "right", slotY); + } + + // For above-graph terminal families, preserve the lifted corridor prefix + // even for gateway sources. The generic gateway source repair builder is + // optimized for local face recovery and can collapse the vertical lift + // back into the lower detour band, which is exactly the regression this + // top-family normalization is supposed to prevent. + var rebuilt = BuildRectangularTopFamilySourceDeparturePath( + path, + boundaryPoint, + desiredAxis); + + return NormalizeOrthogonalPath(rebuilt, coordinateTolerance); + } + + private static List BuildRectangularTopFamilySourceDeparturePath( + IReadOnlyList path, + ElkPoint boundaryPoint, + double desiredAxis) + { + if (path.Count < 2) + { + return path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + const double coordinateTolerance = 0.5d; + var suffixStartIndex = 1; + if (Math.Abs(path[1].X - path[0].X) <= coordinateTolerance) + { + suffixStartIndex = 2; + while (suffixStartIndex < path.Count + && Math.Abs(path[suffixStartIndex].X - path[0].X) <= coordinateTolerance) + { + suffixStartIndex++; + } + + if (suffixStartIndex >= path.Count) + { + suffixStartIndex = path.Count - 1; + } + } + + var suffixStart = path[suffixStartIndex]; + var rebuilt = new List + { + new() { X = boundaryPoint.X, Y = boundaryPoint.Y }, + }; + + if (Math.Abs(rebuilt[^1].X - desiredAxis) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = desiredAxis, Y = rebuilt[^1].Y }); + } + + if (Math.Abs(rebuilt[^1].Y - suffixStart.Y) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = desiredAxis, Y = suffixStart.Y }); + } + + for (var i = suffixStartIndex; i < path.Count; i++) + { + var point = path[i]; + if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], point)) + { + rebuilt.Add(new ElkPoint { X = point.X, Y = point.Y }); + } + } + + return NormalizePathPoints(rebuilt); + } + + private static List NormalizeOrthogonalPath( + IReadOnlyList path, + double coordinateTolerance) + { + var deduped = new List(); + foreach (var point in path) + { + if (deduped.Count == 0 + || Math.Abs(deduped[^1].X - point.X) > coordinateTolerance + || Math.Abs(deduped[^1].Y - point.Y) > coordinateTolerance) + { + deduped.Add(point); + } + } + + if (deduped.Count <= 2) + { + return deduped; + } + + var normalized = new List { deduped[0] }; + for (var i = 1; i < deduped.Count - 1; i++) + { + var previous = normalized[^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) + { + normalized.Add(current); + } + } + + normalized.Add(deduped[^1]); + return normalized; + } + + private static EndTerminalLocalMetrics MeasureEndTerminalLocalMetrics( + IReadOnlyList edges, + IReadOnlyList nodes, + IReadOnlyDictionary nodesById, + IReadOnlyCollection focusEdgeIds, + string targetNodeId) + { + var focusSet = focusEdgeIds.ToHashSet(StringComparer.Ordinal); + var focusEdges = edges + .Where(edge => focusSet.Contains(edge.Id)) + .ToArray(); + var brokenHighways = ElkEdgeRouterHighway + .DetectRemainingBrokenHighways(edges.ToArray(), nodes.ToArray()) + .Count(diagnostic => string.Equals(diagnostic.TargetNodeId, targetNodeId, StringComparison.Ordinal)); + + return new EndTerminalLocalMetrics( + ElkEdgeRoutingScoring.CountUnderNodeViolations(focusEdges, nodes), + ElkEdgeRoutingScoring.DetectSharedLaneConflicts(focusEdges, nodes).Count, + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(focusEdges, nodes), + CountEndTerminalSemanticTargetFamilyViolations(focusEdges, nodesById, targetNodeId), + ElkEdgeRoutingScoring.CountBadBoundaryAngles(focusEdges, nodes), + brokenHighways); + } + + private static int CountEndTerminalSemanticTargetFamilyViolations( + IReadOnlyList focusEdges, + IReadOnlyDictionary nodesById, + string targetNodeId) + { + var violations = 0; + foreach (var edge in focusEdges) + { + if (!string.Equals(edge.TargetNodeId, targetNodeId, StringComparison.Ordinal) + || !nodesById.TryGetValue(targetNodeId, out var targetNode)) + { + continue; + } + + var path = ExtractFullPath(edge); + if (path.Count < 2) + { + violations++; + continue; + } + + if (!string.Equals( + ElkEdgeRoutingGeometry.ResolveBoundarySide(path[^1], targetNode), + "left", + StringComparison.Ordinal) + || !string.Equals( + ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(path[^1], path[^2], targetNode), + "left", + StringComparison.Ordinal)) + { + violations++; + } + } + + return violations; + } + + private readonly record struct EndTerminalEntry( + int Index, + ElkRoutedEdge Edge, + List Path, + ElkPositionedNode SourceNode, + bool UsesAboveGraph, + double EndpointCoordinate); + + private readonly record struct EndTerminalLocalMetrics( + int UnderNodeViolations, + int SharedLaneViolations, + int TargetJoinViolations, + int SemanticTargetFamilyViolations, + int BoundaryAngleViolations, + int BrokenHighways) + { + internal bool IsBetterThan(EndTerminalLocalMetrics baseline) + { + if (UnderNodeViolations != baseline.UnderNodeViolations) + { + return UnderNodeViolations < baseline.UnderNodeViolations; + } + + if (SharedLaneViolations != baseline.SharedLaneViolations) + { + return SharedLaneViolations < baseline.SharedLaneViolations; + } + + if (TargetJoinViolations != baseline.TargetJoinViolations) + { + return TargetJoinViolations < baseline.TargetJoinViolations; + } + + if (SemanticTargetFamilyViolations != baseline.SemanticTargetFamilyViolations) + { + return SemanticTargetFamilyViolations < baseline.SemanticTargetFamilyViolations; + } + + if (BoundaryAngleViolations != baseline.BoundaryAngleViolations) + { + return BoundaryAngleViolations < baseline.BoundaryAngleViolations; + } + + return BrokenHighways < baseline.BrokenHighways; + } + + internal bool IsEquivalentTo(EndTerminalLocalMetrics other) + { + return UnderNodeViolations == other.UnderNodeViolations + && SharedLaneViolations == other.SharedLaneViolations + && TargetJoinViolations == other.TargetJoinViolations + && SemanticTargetFamilyViolations == other.SemanticTargetFamilyViolations + && BoundaryAngleViolations == other.BoundaryAngleViolations + && BrokenHighways == other.BrokenHighways; + } + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.GatewayArtifacts.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.GatewayArtifacts.cs index cec380041..89ec0ea38 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.GatewayArtifacts.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.GatewayArtifacts.cs @@ -171,7 +171,14 @@ internal static partial class ElkEdgeRouterIterative if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode) && ElkShapeBoundaries.IsGatewayShape(sourceNode)) { - if (ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, path[0])) + var suppressForkBypassGatewayChecks = ElkEdgeRoutingScoring.ShouldSuppressForkBypassGatewaySourceExitChecks( + edge, + path, + edges, + nodesById, + sourceNode); + + if (ElkEdgePostProcessor.HasProblematicGatewaySourceVertexExit(path, sourceNode)) { sourceVertexExits++; focus.Add(edge.Id); @@ -189,7 +196,8 @@ internal static partial class ElkEdgeRouterIterative focus.Add(edge.Id); } - if (HasGatewaySourcePreferredFaceMismatchArtifact( + if (!suppressForkBypassGatewayChecks + && HasGatewaySourcePreferredFaceMismatchArtifact( path, sourceNode, nodes, @@ -200,7 +208,8 @@ internal static partial class ElkEdgeRouterIterative focus.Add(edge.Id); } - if (HasGatewaySourceDominantAxisDetourArtifact( + if (!suppressForkBypassGatewayChecks + && HasGatewaySourceDominantAxisDetourArtifact( path, sourceNode, nodes, @@ -211,7 +220,8 @@ internal static partial class ElkEdgeRouterIterative focus.Add(edge.Id); } - if (ElkEdgePostProcessor.HasClearGatewaySourceScoringOpportunity( + if (!suppressForkBypassGatewayChecks + && ElkEdgePostProcessor.HasClearGatewaySourceScoringOpportunity( path, sourceNode, nodes, diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.GatewaySource.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.GatewaySource.cs index fc02d3218..914cedefa 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.GatewaySource.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.GatewaySource.cs @@ -32,7 +32,9 @@ internal static partial class ElkEdgeRoutingScoring continue; } - if (!ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, firstSection.StartPoint)) + if (!ElkEdgePostProcessor.HasProblematicGatewaySourceVertexExit( + [firstSection.StartPoint, firstSection.BendPoints.FirstOrDefault() ?? firstSection.EndPoint], + sourceNode)) { continue; } @@ -130,14 +132,24 @@ internal static partial class ElkEdgeRoutingScoring sourceSideCounts, boundarySlotSeverityByEdgeId); var suppressSoftGatewayChecks = allowsSaturatedAlternateFace || isResolvedDiscreteSlotExit; + var suppressForkBypassGatewayChecks = ShouldSuppressForkBypassGatewaySourceExitChecks( + edge, + path, + edges, + nodesById, + sourceNode); + var suppressStableOpportunity = suppressSoftGatewayChecks || suppressForkBypassGatewayChecks; var hasViolation = HasGatewaySourceExitBacktracking(path) || (!suppressSoftGatewayChecks + && !suppressForkBypassGatewayChecks && HasGatewaySourceDominantAxisDetour(path, sourceNode)) || (!suppressSoftGatewayChecks - && ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, path[0])) + && !suppressForkBypassGatewayChecks + && ElkEdgePostProcessor.HasProblematicGatewaySourceVertexExit(path, sourceNode)) || (!suppressSoftGatewayChecks + && !suppressForkBypassGatewayChecks && HasGatewaySourcePreferredFaceMismatch(path, sourceNode)) - || (!suppressSoftGatewayChecks + || (!suppressStableOpportunity && currentBoundarySlotViolations == 0 && currentBadBoundaryAngles == 0 && HasGraphStableGatewaySourceOpportunity( @@ -163,6 +175,145 @@ internal static partial class ElkEdgeRoutingScoring return count; } + internal static bool ShouldSuppressForkBypassGatewaySourceExitChecks( + ElkRoutedEdge edge, + IReadOnlyList path, + IReadOnlyCollection edges, + IReadOnlyDictionary nodesById, + ElkPositionedNode sourceNode) + { + if (!string.Equals(sourceNode.Kind, "Fork", StringComparison.Ordinal) + || path.Count < 2 + || !HasCleanOrthogonalGatewayDeparture(path, sourceNode) + || HasGatewaySourceExitBacktracking(path) + || ElkEdgePostProcessor.HasProblematicGatewaySourceVertexExit(path, sourceNode)) + { + return false; + } + + if (!nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode) + || !string.Equals(targetNode.Kind, "Join", StringComparison.Ordinal)) + { + return false; + } + + var desiredSide = ResolvePrimaryGatewayDirection(sourceNode, targetNode); + if (desiredSide is null) + { + return false; + } + + var currentSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(path[0], path[1], sourceNode); + if (string.Equals(currentSide, desiredSide, StringComparison.Ordinal)) + { + return false; + } + + if (!IsOrthogonalGatewaySideTransition(currentSide, desiredSide)) + { + return false; + } + + return edges.Any(peer => + !string.Equals(peer.Id, edge.Id, StringComparison.Ordinal) + && string.Equals(peer.SourceNodeId, edge.SourceNodeId, StringComparison.Ordinal) + && DoesPeerOwnForkPrimaryAxis(peer, nodesById, sourceNode, desiredSide)); + } + + private static bool DoesPeerOwnForkPrimaryAxis( + ElkRoutedEdge peer, + IReadOnlyDictionary nodesById, + ElkPositionedNode sourceNode, + string desiredSide) + { + var peerPath = ExtractPath(peer); + if (peerPath.Count < 2 + || !HasCleanOrthogonalGatewayDeparture(peerPath, sourceNode) + || HasGatewaySourceExitBacktracking(peerPath) + || ElkEdgePostProcessor.HasProblematicGatewaySourceVertexExit(peerPath, sourceNode) + || !string.Equals( + ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(peerPath[0], peerPath[1], sourceNode), + desiredSide, + StringComparison.Ordinal)) + { + return false; + } + + if (!nodesById.TryGetValue(peer.TargetNodeId ?? string.Empty, out var peerTargetNode)) + { + return true; + } + + return !string.Equals(peerTargetNode.Kind, "Join", StringComparison.Ordinal); + } + + private static string? ResolvePrimaryGatewayDirection( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode) + { + var sourceCenterX = sourceNode.X + (sourceNode.Width / 2d); + var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); + var targetCenterX = targetNode.X + (targetNode.Width / 2d); + var targetCenterY = targetNode.Y + (targetNode.Height / 2d); + var dx = targetCenterX - sourceCenterX; + var dy = targetCenterY - sourceCenterY; + + if (Math.Abs(dx) >= Math.Abs(dy) * 1.15d && Math.Sign(dx) != 0) + { + return dx > 0d ? "right" : "left"; + } + + if (Math.Abs(dy) >= Math.Abs(dx) * 1.15d && Math.Sign(dy) != 0) + { + return dy > 0d ? "bottom" : "top"; + } + + if (Math.Sign(dx) != 0) + { + return dx > 0d ? "right" : "left"; + } + + return Math.Sign(dy) == 0 + ? null + : (dy > 0d ? "bottom" : "top"); + } + + private static bool HasCleanOrthogonalGatewayDeparture( + IReadOnlyList path, + ElkPositionedNode sourceNode) + { + if (!ElkShapeBoundaries.IsGatewayShape(sourceNode) || path.Count < 2) + { + return false; + } + + const double tolerance = 0.5d; + var boundary = path[0]; + var adjacent = path[1]; + var side = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(boundary, adjacent, sourceNode); + return side switch + { + "left" => adjacent.X < boundary.X - tolerance && Math.Abs(adjacent.Y - boundary.Y) <= tolerance, + "right" => adjacent.X > boundary.X + tolerance && Math.Abs(adjacent.Y - boundary.Y) <= tolerance, + "top" => adjacent.Y < boundary.Y - tolerance && Math.Abs(adjacent.X - boundary.X) <= tolerance, + "bottom" => adjacent.Y > boundary.Y + tolerance && Math.Abs(adjacent.X - boundary.X) <= tolerance, + _ => false, + }; + } + + private static bool IsOrthogonalGatewaySideTransition(string? currentSide, string? desiredSide) + { + if (currentSide is not ("left" or "right" or "top" or "bottom") + || desiredSide is not ("left" or "right" or "top" or "bottom")) + { + return false; + } + + var currentIsHorizontal = currentSide is "left" or "right"; + var desiredIsHorizontal = desiredSide is "left" or "right"; + return currentIsHorizontal != desiredIsHorizontal; + } + private static bool IsResolvedGatewaySourceSlotExit( ElkRoutedEdge edge, IReadOnlyList path, diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkTopCorridorOwnership.cs b/src/__Libraries/StellaOps.ElkSharp/ElkTopCorridorOwnership.cs new file mode 100644 index 000000000..29bf8bdf7 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkTopCorridorOwnership.cs @@ -0,0 +1,428 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkTopCorridorOwnership +{ + private const double CoordinateTolerance = 0.5d; + + internal static ElkRoutedEdge[] SpreadAboveGraphCorridorLanes( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + double minLineClearance) + { + if (edges.Length < 2 || nodes.Length == 0) + { + return edges; + } + + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var graphMinY = nodes.Min(node => node.Y); + var result = edges.ToArray(); + var changed = false; + + var candidates = result + .Select((edge, index) => CreateCandidate(edge, index, nodesById, graphMinY)) + .Where(candidate => candidate is not null) + .Select(candidate => candidate!.Value) + .OrderBy(candidate => candidate.MinX) + .ThenBy(candidate => candidate.MaxX) + .ToArray(); + if (candidates.Length < 2) + { + return edges; + } + + foreach (var cluster in BuildOverlapClusters(candidates)) + { + if (cluster.Length < 2) + { + continue; + } + + var baselineMetrics = MeasureClusterMetrics(cluster, result, nodes, nodesById, graphMinY); + var candidateEdges = RewriteCluster(result, cluster, graphMinY, minLineClearance); + if (ReferenceEquals(candidateEdges, result)) + { + continue; + } + + var candidateMetrics = MeasureClusterMetrics(cluster, candidateEdges, nodes, nodesById, graphMinY); + if (!candidateMetrics.IsBetterThan(baselineMetrics)) + { + continue; + } + + result = candidateEdges; + changed = true; + } + + return changed ? result : edges; + } + + private static double ResolveLaneStep( + AboveGraphCorridorCandidate? previousAssignedLane, + AboveGraphCorridorCandidate currentLane, + double minLineClearance) + { + var compactGap = Math.Max(14d, (minLineClearance * 0.55d) + 4d); + if (previousAssignedLane is null) + { + return compactGap; + } + + return previousAssignedLane.Value.Priority == currentLane.Priority + ? compactGap + : Math.Max(compactGap + 4d, Math.Min(26d, minLineClearance + 2d)); + } + + private static List BuildOverlapClusters( + IReadOnlyList candidates) + { + var clusters = new List(); + var current = new List(); + var currentMaxX = double.NegativeInfinity; + foreach (var candidate in candidates) + { + if (current.Count == 0 + || candidate.MinX <= currentMaxX + CoordinateTolerance) + { + current.Add(candidate); + currentMaxX = Math.Max(currentMaxX, candidate.MaxX); + continue; + } + + clusters.Add(current.ToArray()); + current = + [ + candidate, + ]; + currentMaxX = candidate.MaxX; + } + + if (current.Count > 0) + { + clusters.Add(current.ToArray()); + } + + return clusters; + } + + private static AboveGraphCorridorCandidate? CreateCandidate( + ElkRoutedEdge edge, + int index, + IReadOnlyDictionary nodesById, + double graphMinY) + { + var path = ExtractPath(edge); + if (path.Count < 2) + { + return null; + } + + AboveGraphCorridorCandidate? 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 corridorY = (start.Y + end.Y) / 2d; + if (corridorY >= graphMinY - 8d) + { + continue; + } + + var length = Math.Abs(end.X - start.X); + if (length <= 1d) + { + continue; + } + + var priority = ResolvePriority(edge, nodesById); + var ownershipKey = ResolveOwnershipKey(edge, nodesById); + var candidate = new AboveGraphCorridorCandidate( + index, + edge.Id, + corridorY, + Math.Min(start.X, end.X), + Math.Max(start.X, end.X), + length, + priority, + ownershipKey); + if (best is null || candidate.Length > best.Value.Length) + { + best = candidate; + } + } + + return best; + } + + private static int ResolvePriority( + ElkRoutedEdge edge, + IReadOnlyDictionary nodesById) + { + if (ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label)) + { + return 0; + } + + if (!string.IsNullOrWhiteSpace(edge.TargetNodeId) + && nodesById.TryGetValue(edge.TargetNodeId, out var targetNode) + && string.Equals(targetNode.Kind, "End", StringComparison.Ordinal)) + { + return 2; + } + + return 1; + } + + private static string ResolveOwnershipKey( + ElkRoutedEdge edge, + IReadOnlyDictionary nodesById) + { + if (!string.IsNullOrWhiteSpace(edge.TargetNodeId) + && nodesById.TryGetValue(edge.TargetNodeId, out var targetNode) + && string.Equals(targetNode.Kind, "End", StringComparison.Ordinal)) + { + return $"end:{targetNode.Id}"; + } + + if (ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label)) + { + return $"repeat:{edge.TargetNodeId ?? edge.Id}"; + } + + return edge.Id; + } + + private static ElkRoutedEdge[] RewriteCluster( + IReadOnlyList edges, + IReadOnlyList cluster, + double graphMinY, + double minLineClearance) + { + var groups = cluster + .GroupBy(candidate => candidate.OwnershipKey, StringComparer.Ordinal) + .Select(group => + { + var orderedMembers = group + .OrderBy(member => member.Index) + .ToArray(); + var representative = orderedMembers + .OrderByDescending(member => member.Length) + .ThenByDescending(member => member.CorridorY) + .ThenBy(member => member.EdgeId, StringComparer.Ordinal) + .First(); + return new AboveGraphOwnershipGroup( + OwnershipKey: group.Key, + Members: orderedMembers, + Representative: representative, + Priority: orderedMembers.Min(member => member.Priority), + PreferredLaneY: orderedMembers.Max(member => member.CorridorY), + Span: orderedMembers.Max(member => member.MaxX) - orderedMembers.Min(member => member.MinX)); + }) + .OrderBy(group => group.Priority) + .ThenByDescending(group => group.PreferredLaneY) + .ThenByDescending(group => group.Span) + .ThenBy(group => group.OwnershipKey, StringComparer.Ordinal) + .ToArray(); + if (groups.Length < 2 && groups[0].Members.All(member => Math.Abs(member.CorridorY - groups[0].PreferredLaneY) <= CoordinateTolerance)) + { + return edges as ElkRoutedEdge[] ?? edges.ToArray(); + } + + var result = edges.ToArray(); + var anchorY = groups.Max(group => group.PreferredLaneY); + AboveGraphCorridorCandidate? previousAssignedLane = null; + var assignedY = anchorY; + var changed = false; + + foreach (var group in groups) + { + if (previousAssignedLane is not null) + { + assignedY -= ResolveLaneStep(previousAssignedLane.Value, group.Representative, minLineClearance); + } + + foreach (var member in group.Members) + { + var rewritten = RewriteCorridorLane(result[member.Index], member.CorridorY, assignedY, graphMinY); + if (!ReferenceEquals(rewritten, result[member.Index])) + { + result[member.Index] = rewritten; + changed = true; + } + } + + previousAssignedLane = group.Representative; + } + + return changed ? result : (edges as ElkRoutedEdge[] ?? edges.ToArray()); + } + + private static CorridorClusterMetrics MeasureClusterMetrics( + IReadOnlyList cluster, + IReadOnlyList edges, + ElkPositionedNode[] nodes, + IReadOnlyDictionary nodesById, + double graphMinY) + { + var liveCandidates = cluster + .Select(member => CreateCandidate(edges[member.Index], member.Index, nodesById, graphMinY)) + .Where(candidate => candidate is not null) + .Select(candidate => candidate!.Value) + .ToArray(); + var ownershipSplits = liveCandidates + .GroupBy(candidate => candidate.OwnershipKey, StringComparer.Ordinal) + .Count(group => + group.Select(candidate => Math.Round(candidate.CorridorY, 1)) + .Distinct() + .Count() > 1); + var orderedOwnershipLanes = liveCandidates + .GroupBy(candidate => candidate.OwnershipKey, StringComparer.Ordinal) + .Select(group => new + { + Priority = group.Min(candidate => candidate.Priority), + LaneY = group.Max(candidate => candidate.CorridorY), + }) + .OrderBy(entry => entry.Priority) + .ThenByDescending(entry => entry.LaneY) + .ToArray(); + var priorityInversions = 0; + for (var i = 1; i < orderedOwnershipLanes.Length; i++) + { + if (orderedOwnershipLanes[i - 1].Priority == orderedOwnershipLanes[i].Priority) + { + continue; + } + + if (orderedOwnershipLanes[i - 1].LaneY + CoordinateTolerance < orderedOwnershipLanes[i].LaneY) + { + priorityInversions++; + } + } + + var clusterEdgeIds = cluster + .Select(candidate => candidate.EdgeId) + .ToHashSet(StringComparer.Ordinal); + var brokenTopHighways = ElkEdgeRouterHighway + .DetectRemainingBrokenHighways(edges as ElkRoutedEdge[] ?? edges.ToArray(), nodes) + .Count(diagnostic => + diagnostic.WasBroken + && string.Equals(diagnostic.SharedAxis, "top", StringComparison.Ordinal) + && diagnostic.EdgeIds.Any(clusterEdgeIds.Contains)); + var laneSpan = liveCandidates.Length == 0 + ? 0d + : liveCandidates.Max(candidate => candidate.CorridorY) - liveCandidates.Min(candidate => candidate.CorridorY); + + return new CorridorClusterMetrics( + BrokenTopHighways: brokenTopHighways, + OwnershipSplits: ownershipSplits, + PriorityInversions: priorityInversions, + LaneSpan: laneSpan); + } + + private static ElkRoutedEdge RewriteCorridorLane( + ElkRoutedEdge edge, + double currentY, + double assignedY, + double graphMinY) + { + if (Math.Abs(currentY - assignedY) <= CoordinateTolerance) + { + return edge; + } + + bool ShouldShift(ElkPoint point) => + Math.Abs(point.Y - currentY) <= CoordinateTolerance + && point.Y < graphMinY - 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 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 AboveGraphCorridorCandidate( + int Index, + string EdgeId, + double CorridorY, + double MinX, + double MaxX, + double Length, + int Priority, + string OwnershipKey); + + private readonly record struct AboveGraphOwnershipGroup( + string OwnershipKey, + AboveGraphCorridorCandidate[] Members, + AboveGraphCorridorCandidate Representative, + int Priority, + double PreferredLaneY, + double Span); + + private readonly record struct CorridorClusterMetrics( + int BrokenTopHighways, + int OwnershipSplits, + int PriorityInversions, + double LaneSpan) + { + internal bool IsBetterThan(CorridorClusterMetrics baseline) + { + if (BrokenTopHighways != baseline.BrokenTopHighways) + { + return BrokenTopHighways < baseline.BrokenTopHighways; + } + + if (OwnershipSplits != baseline.OwnershipSplits) + { + return OwnershipSplits < baseline.OwnershipSplits; + } + + if (PriorityInversions != baseline.PriorityInversions) + { + return PriorityInversions < baseline.PriorityInversions; + } + + return LaneSpan + CoordinateTolerance < baseline.LaneSpan; + } + } +}