From c21011522479ab9ade1eec8d6d1dc6cb12346ae3 Mon Sep 17 00:00:00 2001 From: master <> Date: Thu, 26 Mar 2026 13:57:47 +0200 Subject: [PATCH] Fix ElkSharp gateway target peer conflict polish --- ...23_002_ElkSharp_bounded_edge_refinement.md | 76 + .../ElkSharpEdgeRefinementTests.cs | 3454 +++++ .../ElkEdgePostProcessor.cs | 12646 +++++++++++++++- .../ElkEdgeRouterIterative.cs | 3922 ++++- 4 files changed, 19758 insertions(+), 340 deletions(-) diff --git a/docs/implplan/SPRINT_20260323_002_ElkSharp_bounded_edge_refinement.md b/docs/implplan/SPRINT_20260323_002_ElkSharp_bounded_edge_refinement.md index 6f96ff42c..6ea456274 100644 --- a/docs/implplan/SPRINT_20260323_002_ElkSharp_bounded_edge_refinement.md +++ b/docs/implplan/SPRINT_20260323_002_ElkSharp_bounded_edge_refinement.md @@ -95,6 +95,58 @@ Completion criteria: - [x] The document-processing render shows the `Set emailDispatchFailed -> End` edge repaired to a direct L-shape instead of the previous deep detour - [x] Targeted renderer tests and the full workflow renderer test project pass +### TASK-007 - Add gateway-specific polygon boundary landing +Status: DONE +Dependency: TASK-006 +Owners: Implementer +Task description: +Replace the remaining rectangle-style landing assumptions for `Decision`, `Fork`, and `Join` nodes with gateway-specific polygon-boundary handling. Off-axis gateway entry and exit should land on the actual boundary with short diagonal stubs, gateway-specific backtracking/entry validation should stop misclassifying those routes, and rectangle-side highway detection should no longer treat gateway targets as ordinary target-side highway groups. + +Completion criteria: +- [x] Gateway helper logic can project an off-axis lane to a diagonal boundary landing instead of falling back to rectangular snapping +- [x] Workflow renderer tests cover diagonal gateway exit and diagonal gateway entry against `Decision` nodes +- [x] Document-processing artifact render passes again with zero selected broken short highways and zero selected entry-angle violations +- [x] Workflow docs and module-local guidance mention the gateway-specific landing behavior + +### TASK-008 - Refine gateway diagonals to avoid corner-vertex landings +Status: DONE +Dependency: TASK-007 +Owners: Implementer +Task description: +Tighten the gateway-boundary helper so `Decision`, `Fork`, and `Join` nodes still allow short diagonal stubs on their side faces, but no longer land those diagonals directly on a gateway corner vertex. Any remaining corner-diagonal cases must surface as entry-angle defects so the local repair path and the document-processing artifact assertion can catch them. + +Completion criteria: +- [x] Side-face gateway diagonals remain valid for off-axis entry and exit +- [x] Corner-vertex gateway diagonals are rejected and shifted onto the adjacent edge interior +- [x] The document-processing artifact test asserts zero selected gateway corner diagonals +- [x] Workflow docs and module-local guidance mention the corner-diagonal exclusion + +### TASK-009 - Fix gateway target slots and repeat-corridor node safety +Status: DONE +Dependency: TASK-008 +Owners: Implementer +Task description: +Finish the gateway join fix by making gateway target slots polygon-aware instead of rectangular, while keeping local repair scoped to penalized lanes. Also remove the repeat-collector loophole that let a preserved outer corridor skip node-crossing repair when the pre-corridor prefix still crossed a node. + +Completion criteria: +- [x] Gateway target slot assignment uses polygon-face intersections so repaired gateway arrivals do not collapse back onto the same face rail +- [x] Restricted local repair computes target-slot spacing against the full peer set on the same target side +- [x] Repeat-collector pre-corridor prefixes can reroute into a preserved corridor when they cross a node +- [x] The document-processing artifact render passes with zero selected node crossings and zero selected target-approach joins +- [x] The full workflow renderer test project passes + +### TASK-010 - Finish source-departure lane separation and placement-grid follow-up +Status: DONE +Dependency: TASK-009 +Owners: Implementer +Task description: +Add a dedicated source-side same-lane rule so two edges leaving the same source face cannot silently share the same departure lane unless an explicit corridor/highway exception applies. Separate node placement spacing from the routing lattice by deriving a placement grid from the average non-terminal node size, then revalidate the document-processing artifact so the new source-side rule does not leave late boundary-angle or target-join regressions behind. + +Completion criteria: +- [x] Source-departure same-lane conflicts surface as blocking shared-lane issues instead of being treated only as target-side joins or generic proximity +- [x] Left-to-right placement spacing derives from an average-node-size placement grid rather than the edge-routing lattice alone +- [x] The document-processing artifact render revalidates with zero selected shared-lane violations and no new boundary-angle or target-join regressions + ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | @@ -112,8 +164,20 @@ Completion criteria: | 2026-03-23 | Expanded the iterative-router pressure path from the accidental 2-attempt/4-strategy clamp to bounded multi-attempt retries with a wider finite strategy sweep, added stagnation cutoffs to avoid blind repetition, and wired the document-processing artifact test to emit `elksharp.progress.log` plus in-memory progress diagnostics so long-running strategy searches can be inspected while they are still running. A live run confirmed the new path executed `Strategy 1 attempt 1`, `attempt 2`, `attempt 3`, then advanced to `Strategy 2` instead of stopping after two attempts. | Implementer | | 2026-03-24 | Added per-attempt phase timings and route-pass counters to the iterative diagnostics JSON, regenerated the document-processing artifact with `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` (1/1, 50s), and confirmed the runtime hotspot is overwhelmingly `route-all-edges`: for the selected `reverse` strategy the three attempts spent about `45.3s` in `route-all-edges` versus about `15.9ms` in all post-processing/scoring phases combined. The same run still reported `ExcessiveDetourViolations=1` for `edge/33`, so the shortest-path issue remains unresolved and requires a local detour-repair path rather than more full-graph retries. | Implementer | | 2026-03-24 | Reworked iterative retry attempts to repair only penalized edges after the first full strategy pass, made attempt 2 prioritize shortest-path detours, narrowed the protected-corridor exemption so ordinary forward overshoots still qualify for detour repair, and revalidated with `dotnet build src/__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.sln`, `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` (1/1, 22s), and `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore` (20/20). The new artifact diagnostics show attempt 2+ `Mode=local-repair` with rerouted-edge counts below the full graph, and the `Set emailDispatchFailed -> End` path is now the direct L-shape instead of the previous deep outer detour. | Implementer | +| 2026-03-24 | Added late local geometry repair for node-side entry/exit angles, repeat-collector return-lane stacking, and target-side slot spacing; narrowed repeat-collector target-join scoring so the shared outer collector column is not miscounted as a target-side join; updated the backward-family regression expectations; and revalidated with `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~ElkSharpWorkflowRenderLayoutEngineTests" -v minimal` (11/11) plus `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` (1/1, 32s). The regenerated document-processing artifact now reaches `NodeCrossings=0`, `BrokenShortHighways=0`, `RepeatCollectorCorridorViolations=0`, `EntryAngleViolations=0`, `TargetApproachJoinViolations=0`, and `ExcessiveDetourViolations=0`, with strategy `reverse` becoming a valid selected result. | Implementer | +| 2026-03-24 | Added a blocking target-approach-backtracking metric plus local shortest-path repair so `Execute Batch -> Check Result` no longer curls past the target side before returning, kept attempt 2+ focused on penalized lanes only, and updated the backward-family collector regression to allow the nearest loop to take the new shorter direct return while the remaining outer-loop family still stacks on shared top collector lanes. Revalidated with `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotBacktrackIntoCheckResult" -v minimal` (1/1) and `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~LayoutAsync_WhenBackwardFamilySharesTarget_ShouldStackOuterCollectorLanes" -v minimal` (1/1). | Implementer | +| 2026-03-24 | Optimized the A* hot path by precomputing node-obstacle blocked step masks per route, replacing the closed-set `HashSet` with indexed state flags, and adding cheap soft-obstacle bounding-box rejection before exact intersection/proximity math. Measured document-processing render time dropped from `41s` test duration / `62.34s` wall clock to `3s` / `8.94s`, and the full renderer test project dropped from `1m35s` to `6s` test duration (`17.25s` wall clock). Revalidated with `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` and `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore -v minimal` (21/21). | Implementer | +| 2026-03-24 | Added gateway-specific polygon-boundary landing for `Decision`/`Fork`/`Join`, including diagonal-stub projection helpers, gateway-aware boundary-angle/backtracking scoring, and exclusion of gateway targets from rectangle-style short-highway grouping. Added focused gateway regression tests, regenerated the document-processing artifact render, and revalidated with `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~GatewayBoundaryHelpers_WhenDecisionAnchorIsOffAxis_ShouldProjectDiagonalStub|FullyQualifiedName~LayoutAsync_WhenDecisionSourceExitsTowardLowerBranch_ShouldUseDiagonalGatewayExit|FullyQualifiedName~LayoutAsync_WhenDecisionTargetIsReachedOffAxis_ShouldUseDiagonalGatewayEntry" -v minimal`, `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal`, and `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore -v minimal` (24/24). | Implementer | +| 2026-03-24 | Tightened gateway polygon landing again so 45-degree stubs are kept on gateway side faces but not on gateway corner vertices, added helper and artifact regressions for corner-diagonal rejection, regenerated the document-processing render, visually checked `elksharp.png`, and revalidated with `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~GatewayBoundaryHelpers|FullyQualifiedName~DecisionSourceExitsTowardLowerBranch|FullyQualifiedName~DecisionTargetIsReachedOffAxis|FullyQualifiedName~DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotBacktrackIntoCheckResult" -v minimal`, `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal`, and `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore -v minimal` (26/26). | Implementer | +| 2026-03-24 | Finished the gateway-face-slot and repeat-corridor safety follow-up: gateway target slots now come from polygon-face intersections instead of rectangular side slots, restricted local repair computes slot spacing against the full peer set, and above-corridor repeat collectors reroute only the pre-corridor prefix when that prefix crosses a node. Revalidated with `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~GatewayBoundaryHelpers|FullyQualifiedName~DecisionSourceExitsTowardLowerBranch|FullyQualifiedName~DecisionTargetIsReachedOffAxis" -v minimal` (7/7), `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` (1/1), and `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore -v minimal` (28/28). | Implementer | +| 2026-03-24 | Fixed the latest gateway-source and repeat-return regressions without widening retries back to whole-graph reroutes: gateway-source dominant-axis detours are now only asserted when a clean direct repair opportunity actually exists, the focused blocker regression keeps a right-facing gateway exit that climbs above the blocker before continuing, and the selected document-processing artifact again keeps repeat-return lanes outside the `Load Configuration` clearance band. Revalidated with `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~GatewayBoundaryHelpers_WhenDecisionSourceHeadsMostlyRight_ShouldUseDirectFaceExit|FullyQualifiedName~GatewayBoundaryHelpers_WhenDecisionSourceHasBlockingNode_ShouldRepairOnlyTheLocalExitPrefix|FullyQualifiedName~GatewayBoundaryHelpers_WhenDecisionSourceAlreadyTurnsDownIntoBlocker_ShouldRecoverRightFacingExitFirst|FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` (4/4) and `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore -v minimal` (38/38). | Implementer | +| 2026-03-25 | Reopened ElkSharp follow-up work for the user-reported source-side same-lane conflict between `Internal Notification -> Has Recipients` and `Internal Notification -> Set internalNotificationFailed`. Added source-departure join spreading plus blocking `SharedLaneViolations`, derived placement spacing from an average-node-size placement grid, and verified the focused helper regression with `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~SourceDepartureHelpers_WhenOutgoingEdgesShareTheSameDepartureLane_ShouldSpreadOnlyTheConflictingPeer" -v minimal` (1/1). The current document-processing artifact now reports `SharedLaneViolations=0` for the selected route, but still has unresolved late boundary-angle / target-join regressions (`EntryAngleViolations=6`, `TargetApproachJoinViolations=1`), so TASK-010 remains open. | Implementer | +| 2026-03-25 | Tightened the iterative local-repair planner so attempt 2+ now selects only currently failing edges plus exact conflict peers instead of padding the repair set with generic ranked edges, and added a lock-aware parallel local builder that computes candidates concurrently but serializes overlapping source/target neighborhoods before merging deterministically. Revalidated with `dotnet build src/__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.sln -v minimal` and `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` (still failing in ~20s on `SharedLaneViolations=1`, `UnderNodeViolations=2`; selected offender cluster remains `edge/15+edge/17`, `edge/9`, `edge/15`). | Implementer | +| 2026-03-26 | Cleared the last document-processing handoff by letting gateway target peer-conflict candidates start from slotted feeder paths and reusing focused target-peer conflict polish during transactional final-detour repair. Revalidated with `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~Debug_DumpDocumentProcessingFinalDetourOffenders" -v normal --logger "console;verbosity=normal"` (1/1) and `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` (1/1, 2m42s). | Implementer | ## Decisions & Risks +- 2026-03-26: The remaining document-processing defect was not another retry-budget issue. Gateway target peer-conflict candidate building needed a slotted feeder so focused peer-conflict polish could separate same-face arrivals without restoring the final excessive detour. +- 2026-03-25 follow-up: the selected document-processing artifact now enforces zero below-graph lanes and zero overlong 45-degree segments, and gateway source exits are no longer allowed to leave from fork/join tip vertices. Gateway target join detection/spreading now groups arrivals by their landed boundary band instead of letting gateway arrivals slip through as highway-like exemptions. Targeted evidence: `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` (1/1 pass, refreshed 20260325 artifact). That checkpoint still left TASK-010 open; the 2026-03-26 peer-conflict fix closes it. - There was no module-local `AGENTS.md` under `src/__Libraries/StellaOps.ElkSharp/`; this sprint adds one before code changes so the module is no longer undocumented. - Cross-module edits are limited to workflow renderer tests and workflow engine docs because the implementation changes a shared library used by those surfaces. - The iterative router must remain deterministic. Seeded-random strategy variants are allowed only when the seed is graph-stable so the same graph yields the same candidate set and final output. @@ -132,6 +196,18 @@ Completion criteria: - Iterative retries now repair only the penalized subset of edges after the first full-strategy pass. Diagnostics record the route mode and repaired edge ids so the document-processing artifact can prove that attempt 2+ no longer reroute the whole graph. - The previous shortest-path exemption for any edge with corridor-like bend points was too broad and hid ordinary forward overshoot artifacts such as `edge/33`. Only protected reverse/corridor routes now keep that exemption; forward overshoots are eligible for local detour repair. - Small or protected graphs now short-circuit to the baseline route before the iterative sweep. That preserves existing sink-corridor, backward-edge, and port-anchor contracts while still allowing the larger document-processing workflow to use iterative local repair. +- Final local geometry repair now runs after iterative post-processing to enforce node-side entry/exit angles, repeat-return lane separation, and target-slot spacing without sending more edges back through A*. The document-processing artifact test now asserts zero selected broken highways, zero repeat-collector lane collapses, zero node-side angle violations, and zero disallowed target-side joins. +- The shortest-path fix now treats target-side backtracking as a blocking defect. Late local repair trims or reroutes only the offending lane, and attempt 2+ tries a direct orthogonal shortcut before falling back to a low-penalty diagonal A* candidate when another rule blocks the straight repair. +- The A* router now precomputes blocked node-step masks, which removed the repeated obstacle scan from neighbor expansion and cut document-processing route-all-edges time from the previous tens-of-seconds range to sub-second strategy attempts. Future A* optimization should extend the same idea to previously committed edge lanes: build lane-occupancy masks or blocked-segment maps for soft obstacles, and derive intermediate grid spacing from roughly one third of the average service-task width/height instead of the current fixed dense intermediate spacing. +- Gateway nodes (`Decision`, `Fork`, `Join`) now use polygon-boundary landing instead of rectangle-side snapping. Off-axis lanes are allowed to finish with short diagonal stubs on the real gateway boundary, gateway-target backtracking detection now checks only the final near-end gateway approach instead of applying rectangle-side overshoot heuristics, and rectangle-style short-highway grouping is skipped for gateway targets because those cases are governed by gateway-boundary spacing rather than shared rectangular arrival rails. +- Gateway diagonals are now restricted to gateway side faces. If a candidate lands on a gateway corner vertex, the helper shifts it onto the adjacent edge interior and the boundary-angle validator rejects any remaining corner diagonal so local repair and artifact assertions can catch it. +- Gateway target repairs now use polygon-face slot projection instead of rectangular side slots. When only a penalized subset of edges is being repaired, target-slot spacing still considers the unchanged peer edges on that same target side so the repaired edge cannot collapse back into the existing arrival rail. +- Repeat-collector edges with preserved outer corridors are no longer exempt from node-crossing repair. If the prefix that leads into the corridor crosses a node, that prefix is rerouted into the preserved corridor while the outer corridor segment remains intact. +- Gateway-source dominant-axis scoring is now opportunity-gated: a gateway source is only treated as leaving on the wrong axis when a clean downstream-facing repair opportunity actually exists. Obstacle-blocked local exits can still take a short dogleg while the document-processing artifact assertions keep them clear of blockers and out of unrelated node clearance bands. +- The user-reported `Internal Notification` overlap was not a target-side highway issue. The previous rule set modeled target-side joins and repeat-corridor sharing, but not two edges leaving the same source face on the same departure lane. TASK-010 adds source-departure join spreading and blocking `SharedLaneViolations` for that case. +- Node placement spacing now uses a separate placement grid derived from the average non-terminal node width/height (`ResolvePlacementGrid`) instead of depending only on the routing lattice. The focused helper/layout checks are green, but the end-to-end document-processing artifact still needs a clean rerun after the late boundary-angle / target-join regressions are resolved. +- Iterative local repair now stays constrained to currently failing lanes and exact conflict peers. The planner no longer fills the repair budget with unrelated high-severity edges once the current failing rule set has been seeded. +- Per-iteration local repair candidate building can now run in parallel, but builds that share a source or target neighborhood acquire the same lock and wait instead of racing through the same local conflict zone. Current measured document-processing renders still finish in about 20 seconds, so the remaining work is repair quality for the `edge/9` / `edge/15` cluster rather than retry churn. - Optimization plan for the next pass: 1. Build a reusable immutable per-strategy routing context so grid lines, blocked segment masks, and target-slot metadata are computed once per strategy instead of once per edge route. 2. Replace global whole-graph retries for soft penalties with issue-focused repair passes: detour edge repair, target-side join repair, and proximity cluster repair. diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpEdgeRefinementTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpEdgeRefinementTests.cs index c29cc6759..367d5e405 100644 --- a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpEdgeRefinementTests.cs +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpEdgeRefinementTests.cs @@ -1,9 +1,11 @@ +using System.Reflection; using System.Text.Json; using FluentAssertions; using NUnit.Framework; using StellaOps.ElkSharp; +using StellaOps.Workflow.Abstractions; namespace StellaOps.Workflow.Renderer.Tests; @@ -66,6 +68,3368 @@ public class ElkSharpEdgeRefinementTests JsonSerializer.Serialize(refined).Should().Be(JsonSerializer.Serialize(baseline)); } + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenDecisionAnchorIsOffAxis_ShouldProjectDiagonalStub() + { + var decision = new ElkPositionedNode + { + Id = "gate", + Label = "Gate", + Kind = "Decision", + X = 100, + Y = 80, + Width = 188, + Height = 132, + }; + + var anchor = new ElkPoint { X = 68, Y = 118 }; + var fallbackBoundary = new ElkPoint + { + X = decision.X, + Y = decision.Y + (decision.Height / 2d), + }; + + var projected = ElkShapeBoundaries.TryProjectGatewayDiagonalBoundary(decision, anchor, fallbackBoundary, out var boundary); + + projected.Should().BeTrue(); + var deltaX = Math.Abs(boundary.X - anchor.X); + var deltaY = Math.Abs(boundary.Y - anchor.Y); + deltaX.Should().BeGreaterThan(3d); + deltaY.Should().BeGreaterThan(3d); + (deltaX / deltaY).Should().BeInRange(0.8d, 1.25d); + var exteriorApproach = ElkShapeBoundaries.BuildGatewayExteriorApproachPoint(decision, boundary); + ElkShapeBoundaries.HasValidGatewayBoundaryAngle(decision, boundary, exteriorApproach).Should().BeTrue(); + ElkEdgeRoutingGeometry.PointsEqual(boundary, ElkShapeBoundaries.ProjectOntoShapeBoundary(decision, anchor)).Should().BeFalse(); + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenDiagonalTouchesDecisionVertex_ShouldBeRejected() + { + var decision = new ElkPositionedNode + { + Id = "gate", + Label = "Gate", + Kind = "Decision", + X = 100, + Y = 80, + Width = 188, + Height = 132, + }; + + var boundary = new ElkPoint + { + X = decision.X, + Y = decision.Y + (decision.Height / 2d), + }; + var adjacent = new ElkPoint + { + X = boundary.X - 24d, + Y = boundary.Y - 24d, + }; + + ElkShapeBoundaries.IsNearGatewayVertex(decision, boundary).Should().BeTrue(); + ElkShapeBoundaries.HasValidGatewayBoundaryAngle(decision, boundary, adjacent).Should().BeFalse(); + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenJoinApproachComesFromUpperLeft_ShouldPreferEdgeInteriorOverCorner() + { + var join = new ElkPositionedNode + { + Id = "join", + Label = "Parallel Execution Join", + Kind = "Join", + X = 1227, + Y = 116.5908203125, + Width = 176, + Height = 124, + }; + + var anchor = new ElkPoint { X = 1182, Y = 127.81 }; + var fallbackBoundary = new ElkPoint + { + X = join.X, + Y = join.Y + (join.Height / 2d), + }; + + var projected = ElkShapeBoundaries.TryProjectGatewayDiagonalBoundary(join, anchor, fallbackBoundary, out var boundary); + + projected.Should().BeTrue(); + ElkShapeBoundaries.IsNearGatewayVertex(join, boundary).Should().BeFalse(); + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenJoinProjectsToTipForRightwardExit_ShouldPushOntoFaceInterior() + { + var join = new ElkPositionedNode + { + Id = "join", + Label = "Parallel Execution Join", + Kind = "Join", + X = 1227, + Y = 116.5908203125, + Width = 176, + Height = 124, + }; + + var rightTip = new ElkPoint + { + X = join.X + join.Width, + Y = join.Y + (join.Height / 2d), + }; + var anchor = new ElkPoint + { + X = rightTip.X + 96d, + Y = rightTip.Y, + }; + + var shifted = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(join, rightTip, anchor); + + ElkShapeBoundaries.IsNearGatewayVertex(join, shifted).Should().BeFalse(); + ElkShapeBoundaries.IsGatewayBoundaryPoint(join, shifted).Should().BeTrue(); + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenJoinSourceStartsAtTip_ShouldCountVertexExitViolation() + { + var join = new ElkPositionedNode + { + Id = "join", + Label = "Parallel Execution Join", + Kind = "Join", + X = 1227, + Y = 116.5908203125, + Width = 176, + Height = 124, + }; + var target = new ElkPositionedNode + { + Id = "task", + Label = "Load Configuration", + Kind = "TransportCall", + X = 1516, + Y = 134.5908203125, + Width = 208, + Height = 88, + }; + var tip = new ElkPoint + { + X = join.X + join.Width, + Y = join.Y + (join.Height / 2d), + }; + + var edge = new ElkRoutedEdge + { + Id = "edge/join-out", + SourceNodeId = join.Id, + TargetNodeId = target.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = tip, + EndPoint = new ElkPoint { X = target.X, Y = tip.Y }, + BendPoints = [], + }, + ], + }; + + ElkEdgeRoutingScoring.CountGatewaySourceVertexExitViolations([edge], [join, target]).Should().Be(1); + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenTwoEdgesArriveNearParallelIntoJoin_ShouldCountTargetJoinViolation() + { + var join = new ElkPositionedNode + { + Id = "join", + Label = "Parallel Execution Join", + Kind = "Join", + X = 1227, + Y = 116.5908203125, + Width = 176, + Height = 124, + }; + var split = new ElkPositionedNode + { + Id = "split", + Label = "Parallel Execution", + Kind = "Fork", + X = 654, + Y = 116.5908203125, + Width = 176, + Height = 124, + }; + var processBatch = new ElkPositionedNode + { + Id = "process", + Label = "Process Batch", + Kind = "Repeat", + X = 992, + Y = 189.40644190788655, + Width = 208, + Height = 88, + }; + + var edge4 = new ElkRoutedEdge + { + Id = "edge/4", + SourceNodeId = split.Id, + TargetNodeId = join.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 823.6, Y = 189.3908203125 }, + EndPoint = new ElkPoint { X = 1294.4, Y = 189.3908203125 }, + BendPoints = [], + }, + ], + }; + var edge17 = new ElkRoutedEdge + { + Id = "edge/17", + SourceNodeId = processBatch.Id, + TargetNodeId = join.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 1200, Y = 189.6943678977273 }, + EndPoint = new ElkPoint { X = 1294.406364353676, Y = 189.40644190788655 }, + BendPoints = [], + }, + ], + }; + + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations([edge4, edge17], [split, processBatch, join]).Should().Be(1); + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenGatewayJoinReceivesDirectAndElbowedLeftArrivals_ShouldSpreadTargetSlots() + { + var join = new ElkPositionedNode + { + Id = "start/2/join", + Label = "Parallel Execution Join", + Kind = "Join", + X = 1290, + Y = 116.5908203125, + Width = 176, + Height = 124, + }; + var split = new ElkPositionedNode + { + Id = "start/2/split", + Label = "Parallel Execution", + Kind = "Fork", + X = 652, + Y = 116.5908203125, + Width = 176, + Height = 124, + }; + var processBatch = new ElkPositionedNode + { + Id = "start/2/branch-1/1", + Label = "Process Batch", + Kind = "Repeat", + X = 992, + Y = 247.181640625, + Width = 208, + Height = 88, + }; + + var edge4 = new ElkRoutedEdge + { + Id = "edge/4", + SourceNodeId = split.Id, + TargetNodeId = join.Id, + Label = "branch 2", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 823.6, Y = 189.3908203125 }, + EndPoint = new ElkPoint { X = 1294.4, Y = 189.3908203125 }, + BendPoints = [], + }, + ], + }; + + var edge17 = new ElkRoutedEdge + { + Id = "edge/17", + SourceNodeId = processBatch.Id, + TargetNodeId = join.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 1200, Y = 269.181640625 }, + EndPoint = new ElkPoint { X = 1301.962962962963, Y = 207.95445667613635 }, + BendPoints = + [ + new ElkPoint { X = 1282, Y = 269.181640625 }, + new ElkPoint { X = 1282, Y = 207.95445667613635 }, + ], + }, + ], + }; + + var nodes = new[] { split, processBatch, join }; + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations([edge4, edge17], nodes).Should().Be(1); + + var repaired = ElkEdgePostProcessor.SpreadTargetApproachJoins([edge4, edge17], nodes, 52.7d); + + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(repaired, nodes).Should().Be(0); + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenExteriorApproachIsBuilt_ShouldStayOutsideGatewayBounds() + { + var decision = new ElkPositionedNode + { + Id = "gate", + Label = "Gate", + Kind = "Decision", + X = 100, + Y = 80, + Width = 188, + Height = 132, + }; + + var anchor = new ElkPoint { X = 68, Y = 118 }; + var fallbackBoundary = ElkShapeBoundaries.ProjectOntoShapeBoundary(decision, anchor); + + var projected = ElkShapeBoundaries.TryProjectGatewayDiagonalBoundary(decision, anchor, fallbackBoundary, out var boundary); + + projected.Should().BeTrue(); + boundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(decision, boundary, anchor); + var exteriorApproach = ElkShapeBoundaries.BuildGatewayExteriorApproachPoint(decision, boundary); + + ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(decision, exteriorApproach).Should().BeFalse(); + ElkShapeBoundaries.HasValidGatewayBoundaryAngle(decision, boundary, exteriorApproach).Should().BeTrue(); + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenProjectingGatewaySideSlots_ShouldUsePolygonFacePoints() + { + var decision = new ElkPositionedNode + { + Id = "gate", + Label = "Gate", + Kind = "Decision", + X = 100, + Y = 80, + Width = 188, + Height = 132, + }; + + var projected = ElkShapeBoundaries.TryProjectGatewayBoundarySlot( + decision, + "left", + decision.Y + (decision.Height * 0.32d), + out var upperLeft); + + projected.Should().BeTrue(); + ElkShapeBoundaries.IsGatewayBoundaryPoint(decision, upperLeft).Should().BeTrue(); + ElkShapeBoundaries.IsNearGatewayVertex(decision, upperLeft).Should().BeFalse(); + upperLeft.X.Should().BeGreaterThan(decision.X); + Math.Abs(upperLeft.Y - (decision.Y + (decision.Height * 0.32d))).Should().BeLessThan(0.5d); + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenDecisionProjectsToTipForRightwardExit_ShouldPushOntoFaceInterior() + { + var decision = new ElkPositionedNode + { + Id = "gate", + Label = "Internal Notification", + Kind = "Decision", + X = 2557, + Y = 543.9360656738281, + Width = 188, + Height = 132, + }; + + var anchor = new ElkPoint + { + X = 3672, + Y = 605.352783203125, + }; + + var projected = ElkShapeBoundaries.ProjectOntoShapeBoundary(decision, anchor); + ElkShapeBoundaries.IsNearGatewayVertex(decision, projected).Should().BeTrue(); + + var shifted = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(decision, projected, anchor); + ElkShapeBoundaries.IsNearGatewayVertex(decision, shifted).Should().BeFalse(); + + var exteriorApproach = ElkShapeBoundaries.BuildGatewayExteriorApproachPoint(decision, shifted); + ElkShapeBoundaries.HasValidGatewayBoundaryAngle(decision, shifted, exteriorApproach).Should().BeTrue(); + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenDecisionTargetAdjacentPointIsInside_ShouldRebuildExteriorApproach() + { + var source = new ElkPositionedNode + { + Id = "source", + Label = "Set internalNotification...", + Kind = "Task", + X = 2939, + Y = 563.352783203125, + Width = 198, + Height = 84, + }; + + var target = new ElkPositionedNode + { + Id = "target", + Label = "Has Recipients", + Kind = "Decision", + X = 3578, + Y = 539.352783203125, + Width = 188, + Height = 132, + }; + + var edge = new ElkRoutedEdge + { + Id = "edge/27", + SourceNodeId = source.Id, + TargetNodeId = target.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 3137, Y = 605.352783203125 }, + EndPoint = new ElkPoint { X = 3592.047694753577, Y = 595.4895081633794 }, + BendPoints = + [ + new ElkPoint { X = 3161, Y = 605.352783203125 }, + new ElkPoint { X = 3161, Y = 539.9360656738281 }, + new ElkPoint { X = 3592.047694753577, Y = 539.9360656738281 }, + ], + }, + ], + }; + + var repaired = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry([edge], [source, target]); + var section = repaired[0].Sections.Single(); + var path = new List { section.StartPoint }; + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + + ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(target, path[^2]).Should().BeFalse(); + ElkShapeBoundaries.HasValidGatewayBoundaryAngle(target, path[^1], path[^2]).Should().BeTrue(); + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenDecisionTargetUsesRectangularStub_ShouldCollapseToDirectFaceEntry() + { + var source = new ElkPositionedNode + { + Id = "source", + Label = "Set batchTimedOut...", + Kind = "Task", + X = 2929, + Y = 265.9360656738281, + Width = 188, + Height = 88, + }; + + var target = new ElkPositionedNode + { + Id = "target", + Label = "Validate Success", + Kind = "Decision", + X = 3206, + Y = 225.181640625, + Width = 188, + Height = 132, + }; + + var edge = new ElkRoutedEdge + { + Id = "edge/13", + SourceNodeId = source.Id, + TargetNodeId = target.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 3101.3407689677683, Y = 320.94128643843135 }, + EndPoint = new ElkPoint { X = 3223.8892181376796, Y = 303.7421554876262 }, + BendPoints = + [ + new ElkPoint { X = 3223.8892181376796, Y = 320.94128643843135 }, + ], + }, + ], + }; + + var repaired = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry([edge], [source, target]); + var section = repaired[0].Sections.Single(); + var path = new List { section.StartPoint }; + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + + ElkShapeBoundaries.HasValidGatewayBoundaryAngle(target, path[^1], path[^2]).Should().BeTrue(); + ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(target, path[^2]).Should().BeFalse(); + path.Should().HaveCountLessThanOrEqualTo(2); + Math.Abs(path[^1].X - path[^2].X).Should().BeGreaterThan(3d); + Math.Abs(path[^1].Y - path[^2].Y).Should().BeGreaterThan(3d); + } + + [Test] + [Property("Intent", "Operational")] + public void BoundaryHelpers_WhenRectTargetApproachBacktracks_ShouldSnapToNearestTargetSide() + { + var source = new ElkPositionedNode + { + Id = "source", + Label = "Set emailDispatchFa...", + Kind = "SetState", + X = 3855, + Y = 527.352783203125, + Width = 224, + Height = 80, + }; + + var target = new ElkPositionedNode + { + Id = "target", + Label = "End", + Kind = "End", + X = 4643, + Y = 285.8013916015625, + Width = 264, + Height = 132, + }; + + var edge = new ElkRoutedEdge + { + Id = "edge/32", + SourceNodeId = source.Id, + TargetNodeId = target.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 4079, Y = 567.352783203125 }, + EndPoint = new ElkPoint { X = 4907, Y = 298.76534592201074 }, + BendPoints = + [ + new ElkPoint { X = 4103, Y = 567.352783203125 }, + new ElkPoint { X = 4103, Y = 298.76534592201074 }, + ], + }, + ], + }; + + var repaired = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry( + ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches([edge], [source, target], 52d), + [source, target]); + var section = repaired[0].Sections.Single(); + var path = new List { section.StartPoint }; + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + + path[^1].X.Should().Be(target.X); + path[^1].Y.Should().BeApproximately(298.76534592201074, 0.6d); + } + + [Test] + [Property("Intent", "Operational")] + public void ShortcutHelpers_WhenIntermediateBlockerForcesRectToGatewayPath_ShouldUseTightObstacleSkirt() + { + static double ComputePathLength(IReadOnlyList path) + { + var total = 0d; + for (var i = 1; i < path.Count; i++) + { + total += Math.Abs(path[i].X - path[i - 1].X) + Math.Abs(path[i].Y - path[i - 1].Y); + } + + return total; + } + + var source = new ElkPositionedNode + { + Id = "source", + Label = "Execute Batch", + Kind = "TransportCall", + X = 1604, + Y = 320.5908203125, + Width = 208, + Height = 88, + }; + + var blocker = new ElkPositionedNode + { + Id = "blocker", + Label = "Set batchTimedOut", + Kind = "SetState", + X = 2662, + Y = 319.4360656738281, + Width = 208, + Height = 88, + }; + + var target = new ElkPositionedNode + { + Id = "target", + Label = "Check Result", + Kind = "Decision", + X = 3034, + Y = 297.4360656738281, + Width = 188, + Height = 132, + }; + + var graphAnchorTop = new ElkPositionedNode + { + Id = "anchor-top", + Label = "Validate Success", + Kind = "Decision", + X = 3406, + Y = 225.181640625, + Width = 188, + Height = 132, + }; + + var edge = new ElkRoutedEdge + { + Id = "edge/7", + SourceNodeId = source.Id, + TargetNodeId = target.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 1812, Y = 342.5908203125 }, + EndPoint = new ElkPoint { X = 3048.7314344414744, Y = 353.092718087261 }, + BendPoints = + [ + new ElkPoint { X = 1836, Y = 342.5908203125 }, + new ElkPoint { X = 1836, Y = 257.07411887428975 }, + new ElkPoint { X = 3026, Y = 257.07411887428975 }, + ], + }, + ], + }; + + var nodes = new[] { source, blocker, target, graphAnchorTop }; + ElkEdgeRoutingScoring.CountExcessiveDetourViolations([edge], nodes).Should().Be(1); + + var repaired = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts([edge], nodes); + + var repairedPathText = string.Join(" -> ", repaired[0].Sections.Single().BendPoints + .Prepend(repaired[0].Sections.Single().StartPoint) + .Append(repaired[0].Sections.Single().EndPoint) + .Select(point => $"({point.X:F3},{point.Y:F3})")); + Assert.That(ElkEdgeRoutingScoring.CountExcessiveDetourViolations(repaired, nodes), Is.EqualTo(0), repairedPathText); + ElkEdgeRoutingScoring.CountEdgeNodeCrossings(repaired, nodes, null).Should().Be(0); + + var originalPath = new List { edge.Sections.Single().StartPoint }; + originalPath.AddRange(edge.Sections.Single().BendPoints); + originalPath.Add(edge.Sections.Single().EndPoint); + var repairedSection = repaired[0].Sections.Single(); + var repairedPath = new List { repairedSection.StartPoint }; + repairedPath.AddRange(repairedSection.BendPoints); + repairedPath.Add(repairedSection.EndPoint); + TestContext.Out.WriteLine(string.Join(" -> ", repairedPath.Select(point => $"({point.X:F3},{point.Y:F3})"))); + + ComputePathLength(repairedPath).Should().BeLessThan(ComputePathLength(originalPath) - 40d); + repairedPath.Min(point => point.Y).Should().BeGreaterThan(blocker.Y - 24d); + repairedPath.Min(point => point.Y).Should().BeLessThan(blocker.Y + 0.5d); + } + + [Test] + [Property("Intent", "Operational")] + public void ShortcutHelpers_WhenRectToGatewayPathMustPreserveGatewayApproach_ShouldLiftOnlyTheMiddleLane() + { + static double ComputePathLength(IReadOnlyList path) + { + var total = 0d; + for (var i = 1; i < path.Count; i++) + { + total += Math.Abs(path[i].X - path[i - 1].X) + Math.Abs(path[i].Y - path[i - 1].Y); + } + + return total; + } + + var graphAnchorTop = new ElkPositionedNode + { + Id = "anchor-top", + Label = "Evaluate Conditions", + Kind = "Decision", + X = 2290, + Y = 32.25, + Width = 188, + Height = 132, + }; + + var source = new ElkPositionedNode + { + Id = "source", + Label = "Internal Notification", + Kind = "TransportCall", + X = 3034, + Y = 653.352783203125, + Width = 208, + Height = 88, + }; + + var blocker = new ElkPositionedNode + { + Id = "blocker", + Label = "Set internalNotificationFailed", + Kind = "SetState", + X = 3406, + Y = 653.352783203125, + Width = 208, + Height = 88, + }; + + var target = new ElkPositionedNode + { + Id = "target", + Label = "Has Recipients", + Kind = "Decision", + X = 3778, + Y = 631.352783203125, + Width = 188, + Height = 132, + }; + + var edge = new ElkRoutedEdge + { + Id = "edge/27", + SourceNodeId = source.Id, + TargetNodeId = target.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 3242, Y = 719.352783203125 }, + EndPoint = new ElkPoint { X = 3797.5489111085726, Y = 683.6269519992335 }, + BendPoints = + [ + new ElkPoint { X = 3285.2727272727275, Y = 719.352783203125 }, + new ElkPoint { X = 3285.2727272727275, Y = 590.0800559303977 }, + new ElkPoint { X = 3773.4029566281924, Y = 590.0800559303977 }, + new ElkPoint { X = 3773.4029566281924, Y = 659.4809975188532 }, + ], + }, + ], + }; + + var nodes = new[] { graphAnchorTop, source, blocker, target }; + ElkEdgeRoutingScoring.CountExcessiveDetourViolations([edge], nodes).Should().Be(1); + + var expectedTightPath = new[] + { + new ElkPoint { X = 3242, Y = 719.352783203125 }, + new ElkPoint { X = 3285.2727272727275, Y = 719.352783203125 }, + new ElkPoint { X = 3285.2727272727275, Y = 645.352783203125 }, + new ElkPoint { X = 3773.4029566281924, Y = 645.352783203125 }, + new ElkPoint { X = 3773.4029566281924, Y = 659.4809975188532 }, + new ElkPoint { X = 3797.5489111085726, Y = 683.6269519992335 }, + }; + var expectedEdge = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Sections = + [ + new ElkEdgeSection + { + StartPoint = expectedTightPath[0], + EndPoint = expectedTightPath[^1], + BendPoints = expectedTightPath.Skip(1).Take(expectedTightPath.Length - 2).ToArray(), + }, + ], + }; + Assert.That(ElkShapeBoundaries.HasValidGatewayBoundaryAngle(target, expectedTightPath[^1], expectedTightPath[^2]), Is.True); + Assert.That(ElkEdgeRoutingScoring.CountEdgeNodeCrossings([expectedEdge], nodes, null), Is.EqualTo(0)); + Assert.That(ElkEdgeRoutingScoring.CountExcessiveDetourViolations([expectedEdge], nodes), Is.EqualTo(0)); + + var localSkirtMethod = typeof(ElkEdgePostProcessor).GetMethod( + "TryBuildLocalObstacleSkirtBoundaryShortcut", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); + localSkirtMethod.Should().NotBeNull(); + var originalPathPoints = new List { edge.Sections.Single().StartPoint }; + originalPathPoints.AddRange(edge.Sections.Single().BendPoints); + originalPathPoints.Add(edge.Sections.Single().EndPoint); + var localSkirtCandidate = (List?)localSkirtMethod!.Invoke( + null, + new object?[] + { + originalPathPoints, + edge.Sections.Single().StartPoint, + edge.Sections.Single().EndPoint, + nodes, + edge.SourceNodeId, + edge.TargetNodeId, + target, + 52.7d, + }); + localSkirtCandidate.Should().NotBeNull(); + var localSkirtPathText = string.Join(" -> ", localSkirtCandidate! + .Select(point => $"({point.X:F3},{point.Y:F3})")); + var localSkirtEdge = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Sections = + [ + new ElkEdgeSection + { + StartPoint = localSkirtCandidate[0], + EndPoint = localSkirtCandidate[^1], + BendPoints = localSkirtCandidate.Skip(1).Take(localSkirtCandidate.Count - 2).ToArray(), + }, + ], + }; + Assert.That(ElkEdgeRoutingScoring.CountExcessiveDetourViolations([localSkirtEdge], nodes), Is.EqualTo(0), localSkirtPathText); + Assert.That( + ElkShapeBoundaries.HasValidGatewayBoundaryAngle(target, localSkirtCandidate[^1], localSkirtCandidate[^2]), + Is.True, + localSkirtPathText); + Assert.That(ElkEdgeRoutingScoring.CountEdgeNodeCrossings([localSkirtEdge], nodes, null), Is.EqualTo(0), localSkirtPathText); + + var repaired = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts([edge], nodes); + + var repairedPathText = string.Join(" -> ", repaired[0].Sections.Single().BendPoints + .Prepend(repaired[0].Sections.Single().StartPoint) + .Append(repaired[0].Sections.Single().EndPoint) + .Select(point => $"({point.X:F3},{point.Y:F3})")); + Assert.That(ElkEdgeRoutingScoring.CountExcessiveDetourViolations(repaired, nodes), Is.EqualTo(0), repairedPathText); + ElkEdgeRoutingScoring.CountEdgeNodeCrossings(repaired, nodes, null).Should().Be(0); + + var originalPath = new List { edge.Sections.Single().StartPoint }; + originalPath.AddRange(edge.Sections.Single().BendPoints); + originalPath.Add(edge.Sections.Single().EndPoint); + var repairedSection = repaired[0].Sections.Single(); + var repairedPath = new List { repairedSection.StartPoint }; + repairedPath.AddRange(repairedSection.BendPoints); + repairedPath.Add(repairedSection.EndPoint); + + ComputePathLength(repairedPath).Should().BeLessThan(ComputePathLength(originalPath) - 40d); + repairedPath.Min(point => point.Y).Should().BeGreaterThan(blocker.Y - 24d); + repairedPath.Min(point => point.Y).Should().BeLessThan(blocker.Y + 0.5d); + } + + [Test] + [Property("Intent", "Operational")] + public void Debug_DumpDocumentProcessingFinalDetourOffenders() + { + static string DescribePath(IReadOnlyList path) => + string.Join(" -> ", path.Select(point => $"({point.X:F3},{point.Y:F3})")); + + static T InvokePrivate(MethodInfo method, params object?[] args) => + (T)method.Invoke(null, args)!; + + var artifactPath = Path.Combine( + TestContext.CurrentContext.TestDirectory, + "TestResults", + "workflow-renderings", + "20260326", + "DocumentProcessingWorkflow", + "elksharp.json"); + + File.Exists(artifactPath).Should().BeTrue(artifactPath); + + var layout = JsonSerializer.Deserialize( + File.ReadAllText(artifactPath), + new JsonSerializerOptions(JsonSerializerDefaults.Web)); + layout.Should().NotBeNull(); + var renderLayout = layout!; + + var elkNodes = renderLayout.Nodes.Select(node => new ElkPositionedNode + { + Id = node.Id, + Label = node.Label, + Kind = node.Kind, + X = node.X, + Y = node.Y, + Width = node.Width, + Height = node.Height, + }).ToArray(); + + var elkEdges = renderLayout.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 postProcessorType = typeof(ElkEdgePostProcessor); + var tryBuildDirectGatewaySourcePath = postProcessorType.GetMethod( + "TryBuildDirectGatewaySourcePath", + BindingFlags.Static | BindingFlags.NonPublic)!; + var normalizeGatewayExitPath = postProcessorType.GetMethod( + "NormalizeGatewayExitPath", + BindingFlags.Static | BindingFlags.NonPublic)!; + var repairGatewaySourceBoundaryPath = postProcessorType.GetMethod( + "RepairGatewaySourceBoundaryPath", + BindingFlags.Static | BindingFlags.NonPublic)!; + var tryBuildGatewaySourceDominantBlockerEscapePath = postProcessorType.GetMethod( + "TryBuildGatewaySourceDominantBlockerEscapePath", + BindingFlags.Static | BindingFlags.NonPublic)!; + var tryResolvePreferredGatewaySourceBoundary = postProcessorType + .GetMethods(BindingFlags.Static | BindingFlags.NonPublic) + .Single(method => method.Name == "TryResolvePreferredGatewaySourceBoundary" && method.GetParameters().Length == 4); + var buildGatewaySourceRepairPath = postProcessorType.GetMethod( + "BuildGatewaySourceRepairPath", + BindingFlags.Static | BindingFlags.NonPublic)!; + var hasAcceptableGatewayBoundaryPath = postProcessorType.GetMethod( + "HasAcceptableGatewayBoundaryPath", + BindingFlags.Static | BindingFlags.NonPublic)!; + var hasClearBoundarySegments = postProcessorType.GetMethod( + "HasClearBoundarySegments", + BindingFlags.Static | BindingFlags.NonPublic)!; + var hasNodeObstacleCrossing = postProcessorType.GetMethod( + "HasNodeObstacleCrossing", + BindingFlags.Static | BindingFlags.NonPublic)!; + var hasGatewaySourceExitBacktracking = postProcessorType.GetMethod( + "HasGatewaySourceExitBacktracking", + BindingFlags.Static | BindingFlags.NonPublic)!; + var hasGatewaySourceExitCurl = postProcessorType.GetMethod( + "HasGatewaySourceExitCurl", + BindingFlags.Static | BindingFlags.NonPublic)!; + var hasGatewaySourceDominantAxisDetour = postProcessorType.GetMethod( + "HasGatewaySourceDominantAxisDetour", + BindingFlags.Static | BindingFlags.NonPublic)!; + var hasGatewaySourcePreferredFaceMismatch = postProcessorType.GetMethod( + "HasGatewaySourcePreferredFaceMismatch", + BindingFlags.Static | BindingFlags.NonPublic)!; + var needsGatewaySourceBoundaryRepair = postProcessorType.GetMethod( + "NeedsGatewaySourceBoundaryRepair", + BindingFlags.Static | BindingFlags.NonPublic)!; + var tryBuildLocalObstacleSkirtBoundaryShortcut = postProcessorType.GetMethod( + "TryBuildLocalObstacleSkirtBoundaryShortcut", + BindingFlags.Static | BindingFlags.NonPublic)!; + var resolveUnderNodePeerTargetConflicts = postProcessorType.GetMethod( + "ResolveUnderNodePeerTargetConflicts", + BindingFlags.Static | BindingFlags.NonPublic)!; + var choosePreferredHardRuleLayout = typeof(ElkEdgeRouterIterative).GetMethod( + "ChoosePreferredHardRuleLayout", + BindingFlags.Static | BindingFlags.NonPublic)!; + var composeTransactionalFinalDetourCandidate = typeof(ElkEdgeRouterIterative).GetMethod( + "ComposeTransactionalFinalDetourCandidate", + BindingFlags.Static | BindingFlags.NonPublic)!; + + var offenders = elkEdges + .Where(edge => + ElkEdgeRoutingScoring.CountExcessiveDetourViolations([edge], elkNodes) > 0 + || ElkEdgeRoutingScoring.CountBadBoundaryAngles([edge], elkNodes) > 0) + .Select(edge => + { + var section = edge.Sections.Single(); + var rawPath = section.BendPoints + .Prepend(section.StartPoint) + .Append(section.EndPoint) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + var path = rawPath + .Prepend(section.StartPoint) + .Append(section.EndPoint) + .Select(point => $"({point.X:F3},{point.Y:F3})"); + var boundaryViolations = ElkEdgeRoutingScoring.CountBadBoundaryAngles([edge], elkNodes); + var detourViolations = ElkEdgeRoutingScoring.CountExcessiveDetourViolations([edge], elkNodes); + var isolatedRepair = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts([edge], elkNodes); + var repairedSection = isolatedRepair[0].Sections.Single(); + var repairedPath = repairedSection.BendPoints + .Prepend(repairedSection.StartPoint) + .Append(repairedSection.EndPoint) + .Select(point => $"({point.X:F3},{point.Y:F3})"); + var repairedDetour = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(isolatedRepair, elkNodes); + var repairedBoundary = ElkEdgeRoutingScoring.CountBadBoundaryAngles(isolatedRepair, elkNodes); + var finalized = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry([edge], elkNodes); + var finalizedSection = finalized[0].Sections.Single(); + var finalizedPath = finalizedSection.BendPoints + .Prepend(finalizedSection.StartPoint) + .Append(finalizedSection.EndPoint) + .Select(point => $"({point.X:F3},{point.Y:F3})"); + var finalizedDetour = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(finalized, elkNodes); + var finalizedBoundary = ElkEdgeRoutingScoring.CountBadBoundaryAngles(finalized, elkNodes); + var finalizedGatewaySource = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(finalized, elkNodes); + var edgeSpecificDebug = string.Empty; + if (edge.Id is "edge/7" or "edge/27") + { + var targetNode = elkNodes.Single(node => node.Id == edge.TargetNodeId); + var focusedLayoutRepair = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(elkEdges, elkNodes, [edge.Id]); + var transactionalLayoutRepair = (ElkRoutedEdge[])composeTransactionalFinalDetourCandidate.Invoke( + null, + new object?[] { elkEdges, elkNodes, 52.7d, new[] { edge.Id } })!; + var pairedTransactionalLayoutRepair = edge.Id == "edge/27" + ? (ElkRoutedEdge[])composeTransactionalFinalDetourCandidate.Invoke( + null, + new object?[] { elkEdges, elkNodes, 52.7d, new[] { "edge/27", "edge/28" } })! + : transactionalLayoutRepair; + var baselineScore = ElkEdgeRoutingScoring.ComputeScore(elkEdges, elkNodes); + var focusedLayoutScore = ElkEdgeRoutingScoring.ComputeScore(focusedLayoutRepair, elkNodes); + var transactionalLayoutScore = ElkEdgeRoutingScoring.ComputeScore(transactionalLayoutRepair, elkNodes); + var pairedTransactionalLayoutScore = ElkEdgeRoutingScoring.ComputeScore(pairedTransactionalLayoutRepair, elkNodes); + var peerConflictLayoutRepair = edge.Id == "edge/27" + ? focusedLayoutRepair.ToArray() + : focusedLayoutRepair; + if (edge.Id == "edge/27") + { + foreach (var repairEdgeId in new[] { "edge/27", "edge/28" }) + { + var repairIndex = Array.FindIndex(peerConflictLayoutRepair, candidate => candidate.Id == repairEdgeId); + if (repairIndex < 0) + { + continue; + } + + peerConflictLayoutRepair[repairIndex] = (ElkRoutedEdge)resolveUnderNodePeerTargetConflicts.Invoke( + null, + new object?[] + { + peerConflictLayoutRepair[repairIndex], + peerConflictLayoutRepair, + repairIndex, + elkNodes, + 52.7d, + })!; + } + } + var peerConflictLayoutScore = ElkEdgeRoutingScoring.ComputeScore(peerConflictLayoutRepair, elkNodes); + var focusedJoinSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(focusedLayoutRepair, elkNodes, focusedJoinSeverity, 1); + var focusedLayoutSection = focusedLayoutRepair.Single(candidate => candidate.Id == edge.Id).Sections.Single(); + var focusedLayoutPath = focusedLayoutSection.BendPoints + .Prepend(focusedLayoutSection.StartPoint) + .Append(focusedLayoutSection.EndPoint) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToArray(); + var transactionalLayoutSection = transactionalLayoutRepair.Single(candidate => candidate.Id == edge.Id).Sections.Single(); + var transactionalLayoutPath = transactionalLayoutSection.BendPoints + .Prepend(transactionalLayoutSection.StartPoint) + .Append(transactionalLayoutSection.EndPoint) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToArray(); + var peerConflictLayoutSection = peerConflictLayoutRepair.Single(candidate => candidate.Id == edge.Id).Sections.Single(); + var peerConflictLayoutPath = peerConflictLayoutSection.BendPoints + .Prepend(peerConflictLayoutSection.StartPoint) + .Append(peerConflictLayoutSection.EndPoint) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToArray(); + var chosenLayout = (ElkRoutedEdge[])choosePreferredHardRuleLayout.Invoke( + null, + new object?[] { elkEdges, focusedLayoutRepair, elkNodes })!; + var chosenTransactionalLayout = (ElkRoutedEdge[])choosePreferredHardRuleLayout.Invoke( + null, + new object?[] { elkEdges, transactionalLayoutRepair, elkNodes })!; + var chosenPairedTransactionalLayout = (ElkRoutedEdge[])choosePreferredHardRuleLayout.Invoke( + null, + new object?[] { elkEdges, pairedTransactionalLayoutRepair, elkNodes })!; + var localSkirtCandidate = (List?)tryBuildLocalObstacleSkirtBoundaryShortcut.Invoke( + null, + new object?[] + { + rawPath, + rawPath[0], + rawPath[^1], + elkNodes, + edge.SourceNodeId, + edge.TargetNodeId, + targetNode, + 52.7d, + }); + if (localSkirtCandidate is not null) + { + var localSkirtEdge = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Sections = + [ + new ElkEdgeSection + { + StartPoint = localSkirtCandidate[0], + EndPoint = localSkirtCandidate[^1], + BendPoints = localSkirtCandidate.Skip(1).Take(localSkirtCandidate.Count - 2).ToArray(), + }, + ], + }; + edgeSpecificDebug += $" | chooser={(ReferenceEquals(chosenLayout, focusedLayoutRepair) ? "candidate" : "baseline")}"; + edgeSpecificDebug += $" | baseline-score entry={baselineScore.EntryAngleViolations}," + + $" gateway-source={baselineScore.GatewaySourceExitViolations}," + + $" shared={baselineScore.SharedLaneViolations}," + + $" joins={baselineScore.TargetApproachJoinViolations}," + + $" backtracking={baselineScore.TargetApproachBacktrackingViolations}," + + $" detour={baselineScore.ExcessiveDetourViolations}," + + $" below={baselineScore.BelowGraphViolations}," + + $" under={baselineScore.UnderNodeViolations}," + + $" longdiag={baselineScore.LongDiagonalViolations}," + + $" proximity={baselineScore.ProximityViolations}," + + $" label={baselineScore.LabelProximityViolations}," + + $" crossings={baselineScore.EdgeCrossings}"; + edgeSpecificDebug += $" | focused-score entry={focusedLayoutScore.EntryAngleViolations}," + + $" gateway-source={focusedLayoutScore.GatewaySourceExitViolations}," + + $" shared={focusedLayoutScore.SharedLaneViolations}," + + $" joins={focusedLayoutScore.TargetApproachJoinViolations}," + + $" backtracking={focusedLayoutScore.TargetApproachBacktrackingViolations}," + + $" detour={focusedLayoutScore.ExcessiveDetourViolations}," + + $" below={focusedLayoutScore.BelowGraphViolations}," + + $" under={focusedLayoutScore.UnderNodeViolations}," + + $" longdiag={focusedLayoutScore.LongDiagonalViolations}," + + $" proximity={focusedLayoutScore.ProximityViolations}," + + $" label={focusedLayoutScore.LabelProximityViolations}," + + $" crossings={focusedLayoutScore.EdgeCrossings}"; + edgeSpecificDebug += $" | transactional-chooser={(ReferenceEquals(chosenTransactionalLayout, transactionalLayoutRepair) ? "candidate" : "baseline")}"; + edgeSpecificDebug += $" | transactional-score entry={transactionalLayoutScore.EntryAngleViolations}," + + $" gateway-source={transactionalLayoutScore.GatewaySourceExitViolations}," + + $" shared={transactionalLayoutScore.SharedLaneViolations}," + + $" joins={transactionalLayoutScore.TargetApproachJoinViolations}," + + $" backtracking={transactionalLayoutScore.TargetApproachBacktrackingViolations}," + + $" detour={transactionalLayoutScore.ExcessiveDetourViolations}," + + $" below={transactionalLayoutScore.BelowGraphViolations}," + + $" under={transactionalLayoutScore.UnderNodeViolations}," + + $" longdiag={transactionalLayoutScore.LongDiagonalViolations}," + + $" proximity={transactionalLayoutScore.ProximityViolations}," + + $" label={transactionalLayoutScore.LabelProximityViolations}," + + $" crossings={transactionalLayoutScore.EdgeCrossings}"; + edgeSpecificDebug += $" | paired-transactional-chooser={(ReferenceEquals(chosenPairedTransactionalLayout, pairedTransactionalLayoutRepair) ? "candidate" : "baseline")}"; + edgeSpecificDebug += $" | paired-transactional-score entry={pairedTransactionalLayoutScore.EntryAngleViolations}," + + $" gateway-source={pairedTransactionalLayoutScore.GatewaySourceExitViolations}," + + $" shared={pairedTransactionalLayoutScore.SharedLaneViolations}," + + $" joins={pairedTransactionalLayoutScore.TargetApproachJoinViolations}," + + $" backtracking={pairedTransactionalLayoutScore.TargetApproachBacktrackingViolations}," + + $" detour={pairedTransactionalLayoutScore.ExcessiveDetourViolations}," + + $" below={pairedTransactionalLayoutScore.BelowGraphViolations}," + + $" under={pairedTransactionalLayoutScore.UnderNodeViolations}," + + $" longdiag={pairedTransactionalLayoutScore.LongDiagonalViolations}," + + $" proximity={pairedTransactionalLayoutScore.ProximityViolations}," + + $" label={pairedTransactionalLayoutScore.LabelProximityViolations}," + + $" crossings={pairedTransactionalLayoutScore.EdgeCrossings}"; + edgeSpecificDebug += $" | peer-conflict-score entry={peerConflictLayoutScore.EntryAngleViolations}," + + $" gateway-source={peerConflictLayoutScore.GatewaySourceExitViolations}," + + $" shared={peerConflictLayoutScore.SharedLaneViolations}," + + $" joins={peerConflictLayoutScore.TargetApproachJoinViolations}," + + $" backtracking={peerConflictLayoutScore.TargetApproachBacktrackingViolations}," + + $" detour={peerConflictLayoutScore.ExcessiveDetourViolations}," + + $" below={peerConflictLayoutScore.BelowGraphViolations}," + + $" under={peerConflictLayoutScore.UnderNodeViolations}," + + $" longdiag={peerConflictLayoutScore.LongDiagonalViolations}," + + $" proximity={peerConflictLayoutScore.ProximityViolations}," + + $" label={peerConflictLayoutScore.LabelProximityViolations}," + + $" crossings={peerConflictLayoutScore.EdgeCrossings}"; + edgeSpecificDebug += $" | focused-layout detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedLayoutRepair, elkNodes)}," + + $" shared={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedLayoutRepair, elkNodes)}," + + $" gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedLayoutRepair, elkNodes)}:" + + $" {DescribePath(focusedLayoutPath)}"; + edgeSpecificDebug += $" | focused-join-edges=[{string.Join(", ", focusedJoinSeverity.Keys.OrderBy(id => id, StringComparer.Ordinal))}]"; + edgeSpecificDebug += $" | transactional-layout detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(transactionalLayoutRepair, elkNodes)}," + + $" shared={ElkEdgeRoutingScoring.CountSharedLaneViolations(transactionalLayoutRepair, elkNodes)}," + + $" gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(transactionalLayoutRepair, elkNodes)}," + + $" under={ElkEdgeRoutingScoring.CountUnderNodeViolations(transactionalLayoutRepair, elkNodes)}," + + $" joins={ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(transactionalLayoutRepair, elkNodes)}:" + + $" {DescribePath(transactionalLayoutPath)}"; + if (edge.Id == "edge/27") + { + var pairedTransactionalLayoutSection = pairedTransactionalLayoutRepair.Single(candidate => candidate.Id == edge.Id).Sections.Single(); + var pairedTransactionalLayoutPath = pairedTransactionalLayoutSection.BendPoints + .Prepend(pairedTransactionalLayoutSection.StartPoint) + .Append(pairedTransactionalLayoutSection.EndPoint) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToArray(); + edgeSpecificDebug += $" | paired-transactional-layout detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(pairedTransactionalLayoutRepair, elkNodes)}," + + $" shared={ElkEdgeRoutingScoring.CountSharedLaneViolations(pairedTransactionalLayoutRepair, elkNodes)}," + + $" gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(pairedTransactionalLayoutRepair, elkNodes)}," + + $" under={ElkEdgeRoutingScoring.CountUnderNodeViolations(pairedTransactionalLayoutRepair, elkNodes)}," + + $" joins={ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(pairedTransactionalLayoutRepair, elkNodes)}:" + + $" {DescribePath(pairedTransactionalLayoutPath)}"; + edgeSpecificDebug += $" | peer-conflict-layout detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(peerConflictLayoutRepair, elkNodes)}," + + $" shared={ElkEdgeRoutingScoring.CountSharedLaneViolations(peerConflictLayoutRepair, elkNodes)}," + + $" gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(peerConflictLayoutRepair, elkNodes)}," + + $" under={ElkEdgeRoutingScoring.CountUnderNodeViolations(peerConflictLayoutRepair, elkNodes)}," + + $" joins={ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(peerConflictLayoutRepair, elkNodes)}:" + + $" {DescribePath(peerConflictLayoutPath)}"; + } + edgeSpecificDebug += $" | local-skirt detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations([localSkirtEdge], elkNodes)}," + + $" crossings={ElkEdgeRoutingScoring.CountEdgeNodeCrossings([localSkirtEdge], elkNodes, null)}:" + + $" {DescribePath(localSkirtCandidate)}"; + } + else + { + edgeSpecificDebug += " | local-skirt=null"; + } + } + if (edge.Id == "edge/22") + { + var sourceNode = elkNodes.Single(node => node.Id == edge.SourceNodeId); + var directCandidate = InvokePrivate>( + tryBuildDirectGatewaySourcePath, + rawPath, + sourceNode, + elkNodes, + edge.SourceNodeId, + edge.TargetNodeId); + var normalizedCandidate = InvokePrivate>( + normalizeGatewayExitPath, + rawPath, + sourceNode, + elkNodes, + edge.SourceNodeId, + edge.TargetNodeId); + var repairedCandidate = InvokePrivate>( + repairGatewaySourceBoundaryPath, + rawPath, + sourceNode, + elkNodes, + edge.SourceNodeId, + edge.TargetNodeId); + var blockerEscapeCandidate = InvokePrivate>( + tryBuildGatewaySourceDominantBlockerEscapePath, + rawPath, + sourceNode, + elkNodes, + edge.SourceNodeId, + edge.TargetNodeId); + + static string DescribeGatewayCandidate( + string label, + IReadOnlyList original, + IReadOnlyList candidate, + ElkPositionedNode[] elkNodes, + ElkPositionedNode sourceNode, + string? sourceNodeId, + string? targetNodeId, + MethodInfo hasAcceptableGatewayBoundaryPath, + MethodInfo hasClearBoundarySegments, + MethodInfo hasNodeObstacleCrossing, + MethodInfo hasGatewaySourceExitBacktracking, + MethodInfo hasGatewaySourceExitCurl, + MethodInfo hasGatewaySourceDominantAxisDetour, + MethodInfo hasGatewaySourcePreferredFaceMismatch, + MethodInfo needsGatewaySourceBoundaryRepair) + { + var acceptable = InvokePrivate( + hasAcceptableGatewayBoundaryPath, + candidate, + elkNodes, + sourceNodeId, + targetNodeId, + sourceNode, + true); + var clear1 = InvokePrivate( + hasClearBoundarySegments, + candidate, + elkNodes, + sourceNodeId, + targetNodeId, + true, + 1); + var clear4 = InvokePrivate( + hasClearBoundarySegments, + candidate, + elkNodes, + sourceNodeId, + targetNodeId, + true, + Math.Min(4, candidate.Count - 1)); + var obstacle = InvokePrivate( + hasNodeObstacleCrossing, + candidate, + elkNodes, + sourceNodeId, + targetNodeId); + var backtracking = InvokePrivate(hasGatewaySourceExitBacktracking, candidate); + var curl = InvokePrivate(hasGatewaySourceExitCurl, candidate); + var dominant = InvokePrivate(hasGatewaySourceDominantAxisDetour, candidate, sourceNode); + var preferred = InvokePrivate(hasGatewaySourcePreferredFaceMismatch, candidate, sourceNode); + var needsRepair = InvokePrivate(needsGatewaySourceBoundaryRepair, candidate, sourceNode); + var changed = original.Count != candidate.Count + || original.Zip(candidate, (left, right) => left.X == right.X && left.Y == right.Y).Any(equal => !equal); + return $"{label}: changed={changed}, acceptable={acceptable}, clear1={clear1}, clear4={clear4}, obstacle={obstacle}, backtracking={backtracking}, curl={curl}, dominant={dominant}, preferred={preferred}, needsRepair={needsRepair}: {DescribePath(candidate)}"; + } + + edgeSpecificDebug = + " | edge/22-debug " + + DescribeGatewayCandidate( + "direct", + rawPath, + directCandidate, + elkNodes, + sourceNode, + edge.SourceNodeId, + edge.TargetNodeId, + hasAcceptableGatewayBoundaryPath, + hasClearBoundarySegments, + hasNodeObstacleCrossing, + hasGatewaySourceExitBacktracking, + hasGatewaySourceExitCurl, + hasGatewaySourceDominantAxisDetour, + hasGatewaySourcePreferredFaceMismatch, + needsGatewaySourceBoundaryRepair) + + " || " + + DescribeGatewayCandidate( + "normalized", + rawPath, + normalizedCandidate, + elkNodes, + sourceNode, + edge.SourceNodeId, + edge.TargetNodeId, + hasAcceptableGatewayBoundaryPath, + hasClearBoundarySegments, + hasNodeObstacleCrossing, + hasGatewaySourceExitBacktracking, + hasGatewaySourceExitCurl, + hasGatewaySourceDominantAxisDetour, + hasGatewaySourcePreferredFaceMismatch, + needsGatewaySourceBoundaryRepair) + + " || " + + DescribeGatewayCandidate( + "repaired", + rawPath, + repairedCandidate, + elkNodes, + sourceNode, + edge.SourceNodeId, + edge.TargetNodeId, + hasAcceptableGatewayBoundaryPath, + hasClearBoundarySegments, + hasNodeObstacleCrossing, + hasGatewaySourceExitBacktracking, + hasGatewaySourceExitCurl, + hasGatewaySourceDominantAxisDetour, + hasGatewaySourcePreferredFaceMismatch, + needsGatewaySourceBoundaryRepair); + edgeSpecificDebug += " || " + + DescribeGatewayCandidate( + "blocker-escape", + rawPath, + blockerEscapeCandidate, + elkNodes, + sourceNode, + edge.SourceNodeId, + edge.TargetNodeId, + hasAcceptableGatewayBoundaryPath, + hasClearBoundarySegments, + hasNodeObstacleCrossing, + hasGatewaySourceExitBacktracking, + hasGatewaySourceExitCurl, + hasGatewaySourceDominantAxisDetour, + hasGatewaySourcePreferredFaceMismatch, + needsGatewaySourceBoundaryRepair); + var blocker = elkNodes.Single(node => node.Id == "start/2/branch-1/1/body/4/failure/1/true/1"); + var boundaryArgs = new object?[] + { + sourceNode, + new ElkPoint { X = rawPath[1].X, Y = rawPath[^1].Y }, + rawPath[^1], + null, + }; + var hasBoundary = (bool)tryResolvePreferredGatewaySourceBoundary.Invoke(null, boundaryArgs)!; + if (hasBoundary && boundaryArgs[3] is ElkPoint manualBoundary) + { + var manualEscapeCandidate = InvokePrivate>( + buildGatewaySourceRepairPath, + rawPath, + sourceNode, + manualBoundary, + new ElkPoint { X = rawPath[1].X, Y = blocker.Y - 24d }, + 1, + new ElkPoint { X = rawPath[1].X, Y = blocker.Y - 24d }); + edgeSpecificDebug += " || " + + DescribeGatewayCandidate( + "manual-escape", + rawPath, + manualEscapeCandidate, + elkNodes, + sourceNode, + edge.SourceNodeId, + edge.TargetNodeId, + hasAcceptableGatewayBoundaryPath, + hasClearBoundarySegments, + hasNodeObstacleCrossing, + hasGatewaySourceExitBacktracking, + hasGatewaySourceExitCurl, + hasGatewaySourceDominantAxisDetour, + hasGatewaySourcePreferredFaceMismatch, + needsGatewaySourceBoundaryRepair); + } + } + + return + $"{edge.Id} [{edge.SourceNodeId}->{edge.TargetNodeId}]: detour={detourViolations}, boundary={boundaryViolations}: {string.Join(" -> ", path)}" + + $" | isolated detour={repairedDetour}, boundary={repairedBoundary}: {string.Join(" -> ", repairedPath)}" + + $" | finalized detour={finalizedDetour}, boundary={finalizedBoundary}, gateway-source={finalizedGatewaySource}: {string.Join(" -> ", finalizedPath)}" + + edgeSpecificDebug; + }) + .ToArray(); + + TestContext.Out.WriteLine(string.Join(Environment.NewLine, offenders)); + Assert.That(offenders, Is.Not.Empty); + } + + [Test] + [Property("Intent", "Operational")] + public void ShortcutHelpers_WhenRectSourceCanUseDirectGatewayLeftFaceShortcut_ShouldClearExcessiveDetour() + { + static double ComputePathLength(IReadOnlyList path) + { + var total = 0d; + for (var i = 1; i < path.Count; i++) + { + total += Math.Abs(path[i].X - path[i - 1].X) + Math.Abs(path[i].Y - path[i - 1].Y); + } + + return total; + } + + var source = new ElkPositionedNode + { + Id = "source", + Label = "Execute Batch", + Kind = "TransportCall", + X = 1604, + Y = 320.5908203125, + Width = 208, + Height = 88, + }; + + var target = new ElkPositionedNode + { + Id = "target", + Label = "Check Result", + Kind = "Decision", + X = 3034, + Y = 297.4360656738281, + Width = 188, + Height = 132, + }; + + var edge = new ElkRoutedEdge + { + Id = "edge/7", + SourceNodeId = source.Id, + TargetNodeId = target.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 1812, Y = 342.5908203125 }, + EndPoint = new ElkPoint { X = 3048.7314344414744, Y = 353.092718087261 }, + BendPoints = + [ + new ElkPoint { X = 1836, Y = 342.5908203125 }, + new ElkPoint { X = 1836, Y = 257.07411887428975 }, + new ElkPoint { X = 3026, Y = 257.07411887428975 }, + ], + }, + ], + }; + + var nodes = new[] { source, target }; + ElkEdgeRoutingScoring.CountExcessiveDetourViolations([edge], nodes).Should().Be(1); + + var repaired = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts([edge], nodes); + + ElkEdgeRoutingScoring.CountExcessiveDetourViolations(repaired, nodes).Should().Be(0); + + var originalPath = new List { edge.Sections.Single().StartPoint }; + originalPath.AddRange(edge.Sections.Single().BendPoints); + originalPath.Add(edge.Sections.Single().EndPoint); + var repairedSection = repaired[0].Sections.Single(); + var repairedPath = new List { repairedSection.StartPoint }; + repairedPath.AddRange(repairedSection.BendPoints); + repairedPath.Add(repairedSection.EndPoint); + + ComputePathLength(repairedPath).Should().BeLessThan(ComputePathLength(originalPath) - 40d); + repairedPath.Min(point => point.Y).Should().BeGreaterThan(300d); + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenDecisionSourceHeadsMostlyRight_ShouldUseDirectFaceExit() + { + var source = new ElkPositionedNode + { + Id = "source", + Label = "Retry Decision", + Kind = "Decision", + X = 1892, + Y = 359.968017578125, + Width = 188, + Height = 132, + }; + + var target = new ElkPositionedNode + { + Id = "target", + Label = "Set batchGenerateFailed", + Kind = "SetState", + X = 2557, + Y = 415.9360656738281, + Width = 208, + Height = 88, + }; + + var edge = new ElkRoutedEdge + { + Id = "edge/9", + SourceNodeId = source.Id, + TargetNodeId = target.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 2065.27, Y = 415.63 }, + EndPoint = new ElkPoint { X = 2557, Y = 433.57 }, + BendPoints = + [ + new ElkPoint { X = 2084.60, Y = 388.10 }, + new ElkPoint { X = 2533, Y = 388.10 }, + new ElkPoint { X = 2533, Y = 433.57 }, + ], + }, + ], + }; + + var repaired = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry([edge], [source, target]); + var section = repaired[0].Sections.Single(); + var path = new List { section.StartPoint }; + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + + path.Should().HaveCountLessThanOrEqualTo(4); + path[1].X.Should().BeGreaterThan(path[0].X + 3d); + path[1].Y.Should().BeApproximately(path[0].Y, 0.6d); + path[^1].Y.Should().BeApproximately(path[1].Y, 0.6d); + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenDecisionSourceHasBlockingNode_ShouldRepairOnlyTheLocalExitPrefix() + { + var source = new ElkPositionedNode + { + Id = "source", + Label = "Retry Decision", + Kind = "Decision", + X = 1892, + Y = 359.968017578125, + Width = 188, + Height = 132, + }; + + var blocker = new ElkPositionedNode + { + Id = "blocker", + Label = "Cooldown Timer", + Kind = "Timer", + X = 2185, + Y = 398.6280212402344, + Width = 208, + Height = 88, + }; + + var target = new ElkPositionedNode + { + Id = "target", + Label = "Set batchGenerateFailed", + Kind = "SetState", + X = 2557, + Y = 415.9360656738281, + Width = 208, + Height = 88, + }; + + var edge = new ElkRoutedEdge + { + Id = "edge/9", + SourceNodeId = source.Id, + TargetNodeId = target.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 2065.27, Y = 415.63 }, + EndPoint = new ElkPoint { X = 2557, Y = 433.57 }, + BendPoints = + [ + new ElkPoint { X = 2084.60, Y = 388.10 }, + new ElkPoint { X = 2533, Y = 388.10 }, + new ElkPoint { X = 2533, Y = 433.57 }, + ], + }, + ], + }; + + var repaired = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry([edge], [source, blocker, target]); + var section = repaired[0].Sections.Single(); + var path = new List { section.StartPoint }; + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + + path.Should().HaveCountGreaterThanOrEqualTo(4); + path[1].X.Should().BeGreaterThan(path[0].X + 3d); + path.Skip(1).Any(point => point.Y < blocker.Y - 0.5d).Should().BeTrue(); + CrossesRectObstacle(path, blocker).Should().BeFalse(); + + static bool CrossesRectObstacle(IReadOnlyList polyline, ElkPositionedNode node) + { + const double tolerance = 0.5d; + for (var i = 0; i < polyline.Count - 1; i++) + { + var start = polyline[i]; + var end = polyline[i + 1]; + if (Math.Abs(start.Y - end.Y) <= tolerance) + { + if (start.Y > node.Y + tolerance + && start.Y < node.Y + node.Height - tolerance + && Math.Max(start.X, end.X) > node.X + tolerance + && Math.Min(start.X, end.X) < node.X + node.Width - tolerance) + { + return true; + } + } + else if (Math.Abs(start.X - end.X) <= tolerance) + { + if (start.X > node.X + tolerance + && start.X < node.X + node.Width - tolerance + && Math.Max(start.Y, end.Y) > node.Y + tolerance + && Math.Min(start.Y, end.Y) < node.Y + node.Height - tolerance) + { + return true; + } + } + } + + return false; + } + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenDecisionSourceAlreadyTurnsDownIntoBlocker_ShouldRecoverRightFacingExitFirst() + { + var source = new ElkPositionedNode + { + Id = "source", + Label = "Retry Decision", + Kind = "Decision", + X = 1892, + Y = 359.9718017578125, + Width = 188, + Height = 132, + }; + + var blocker = new ElkPositionedNode + { + Id = "blocker", + Label = "Cooldown Timer", + Kind = "Timer", + X = 2185, + Y = 398.6265563964844, + Width = 208, + Height = 88, + }; + + var target = new ElkPositionedNode + { + Id = "target", + Label = "Set batchGenerateFailed", + Kind = "SetState", + X = 2557, + Y = 415.9360656738281, + Width = 208, + Height = 88, + }; + + var edge = new ElkRoutedEdge + { + Id = "edge/9", + SourceNodeId = source.Id, + TargetNodeId = target.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 2065.36, Y = 436.25 }, + EndPoint = new ElkPoint { X = 2557, Y = 499.94 }, + BendPoints = + [ + new ElkPoint { X = 2065.36, Y = 499.97 }, + ], + }, + ], + }; + + var repaired = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry([edge], [source, blocker, target]); + var section = repaired[0].Sections.Single(); + var path = new List { section.StartPoint }; + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + + path.Should().HaveCountGreaterThanOrEqualTo(4); + path[1].X.Should().BeGreaterThan(path[0].X + 3d); + path[1].Y.Should().BeApproximately(path[0].Y, 0.6d); + CrossesRectObstacle(path, blocker).Should().BeFalse(); + + static bool CrossesRectObstacle(IReadOnlyList polyline, ElkPositionedNode node) + { + const double tolerance = 0.5d; + for (var i = 0; i < polyline.Count - 1; i++) + { + var start = polyline[i]; + var end = polyline[i + 1]; + if (Math.Abs(start.Y - end.Y) <= tolerance) + { + if (start.Y > node.Y + tolerance + && start.Y < node.Y + node.Height - tolerance + && Math.Max(start.X, end.X) > node.X + tolerance + && Math.Min(start.X, end.X) < node.X + node.Width - tolerance) + { + return true; + } + } + else if (Math.Abs(start.X - end.X) <= tolerance) + { + if (start.X > node.X + tolerance + && start.X < node.X + node.Width - tolerance + && Math.Max(start.Y, end.Y) > node.Y + tolerance + && Math.Min(start.Y, end.Y) < node.Y + node.Height - tolerance) + { + return true; + } + } + } + + return false; + } + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenDominantAxisExitIsBlocked_ShouldNotCountAsBoundaryAngleViolation() + { + var source = new ElkPositionedNode + { + Id = "source", + Label = "Internal Notification", + Kind = "Decision", + X = 2557, + Y = 543.9360656738281, + Width = 188, + Height = 132, + }; + + var blocker = new ElkPositionedNode + { + Id = "blocker", + Label = "Set internalNotificationFailed", + Kind = "SetState", + X = 3206, + Y = 561.352783203125, + Width = 208, + Height = 88, + }; + + var target = new ElkPositionedNode + { + Id = "target", + Label = "Has Recipients", + Kind = "Decision", + X = 3578, + Y = 539.352783203125, + Width = 188, + Height = 132, + }; + + var edge = new ElkRoutedEdge + { + Id = "edge/25", + SourceNodeId = source.Id, + TargetNodeId = target.Id, + Label = "default", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 2711.16, Y = 633.70 }, + EndPoint = new ElkPoint { X = 3658.87, Y = 662.13 }, + BendPoints = + [ + new ElkPoint { X = 2745.41, Y = 682.48 }, + new ElkPoint { X = 3658.87, Y = 682.48 }, + ], + }, + ], + }; + + var repaired = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry([edge], [source, blocker, target]); + var violations = ElkEdgeRoutingScoring.CountBadBoundaryAngles(repaired, [source, blocker, target]); + + violations.Should().Be(0); + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenGatewaySourceHasShorterClearExit_ShouldExposeOpportunityAndShortenPath() + { + var source = new ElkPositionedNode + { + Id = "source", + Label = "Internal Notification", + Kind = "Decision", + X = 2557, + Y = 543.9360656738281, + Width = 188, + Height = 132, + }; + + var target = new ElkPositionedNode + { + Id = "target", + Label = "Has Recipients", + Kind = "Decision", + X = 3578, + Y = 539.352783203125, + Width = 188, + Height = 132, + }; + + var edge = new ElkRoutedEdge + { + Id = "edge/25", + SourceNodeId = source.Id, + TargetNodeId = target.Id, + Label = "default", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 2711.16, Y = 633.70 }, + EndPoint = new ElkPoint { X = 3658.87, Y = 662.13 }, + BendPoints = + [ + new ElkPoint { X = 2745.41, Y = 682.48 }, + new ElkPoint { X = 3658.87, Y = 682.48 }, + ], + }, + ], + }; + + var originalSection = edge.Sections.Single(); + var originalPath = new List { originalSection.StartPoint }; + originalPath.AddRange(originalSection.BendPoints); + originalPath.Add(originalSection.EndPoint); + + ElkEdgePostProcessor.HasClearGatewaySourceDirectRepairOpportunity( + originalPath, + source, + [source, target], + edge.SourceNodeId, + edge.TargetNodeId).Should().BeTrue(); + + var repaired = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry([edge], [source, target]); + var section = repaired[0].Sections.Single(); + var path = new List { section.StartPoint }; + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + + ComputePathLength(path).Should().BeLessThan(ComputePathLength(originalPath)); + ElkEdgeRoutingScoring.CountBadBoundaryAngles(repaired, [source, target]).Should().Be(0); + + static double ComputePathLength(IReadOnlyList points) + { + var length = 0d; + for (var i = 1; i < points.Count; i++) + { + var dx = points[i].X - points[i - 1].X; + var dy = points[i].Y - points[i - 1].Y; + length += Math.Sqrt((dx * dx) + (dy * dy)); + } + + return length; + } + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenDecisionSourceNeedsVerticalStemForValidExit_ShouldKeepBoundaryRepair() + { + var source = new ElkPositionedNode + { + Id = "source", + Label = "Evaluate Conditions", + Kind = "Decision", + X = 2290, + Y = 32.25, + Width = 188, + Height = 132, + }; + + var target = new ElkPositionedNode + { + Id = "target", + Label = "Internal Notification", + Kind = "Decision", + X = 2662, + Y = 639.4360656738281, + Width = 188, + Height = 132, + }; + + var edge = new ElkRoutedEdge + { + Id = "edge/22", + SourceNodeId = source.Id, + TargetNodeId = target.Id, + Label = "when state.notificationHasBody", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 2409.45679088221, Y = 172.25 }, + EndPoint = new ElkPoint { X = 2693.4842064714944, Y = 683.3301334704383 }, + BendPoints = + [ + new ElkPoint { X = 2546.7272727272725, Y = 172.25 }, + new ElkPoint { X = 2546.7272727272725, Y = 631.4360656738281 }, + ], + }, + ], + }; + + ElkEdgeRoutingScoring.CountBadBoundaryAngles([edge], [source, target]).Should().Be(1); + + var repaired = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry([edge], [source, target]); + var section = repaired[0].Sections.Single(); + var path = new List { section.StartPoint }; + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + var originalFirstBend = edge.Sections.Single().BendPoints.First(); + + ElkEdgeRoutingScoring.CountBadBoundaryAngles(repaired, [source, target]).Should().Be(0); + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(repaired, [source, target]).Should().Be(0); + path[1].Y.Should().BeGreaterThan(path[0].Y + 3d); + ElkEdgeRoutingGeometry.PointsEqual(path[1], originalFirstBend).Should().BeFalse(); + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenDecisionSourceVerticalStemWouldHitLocalBlocker_ShouldEscapeBeforeBlocker() + { + var source = new ElkPositionedNode + { + Id = "source", + Label = "Evaluate Conditions", + Kind = "Decision", + X = 2290, + Y = 32.25, + Width = 188, + Height = 132, + }; + + var blocker = new ElkPositionedNode + { + Id = "blocker", + Label = "Delay Notification", + Kind = "Timer", + X = 2290, + Y = 457.1265563964844, + Width = 208, + Height = 88, + }; + + var target = new ElkPositionedNode + { + Id = "target", + Label = "Internal Notification", + Kind = "Decision", + X = 2662, + Y = 639.4360656738281, + Width = 188, + Height = 132, + }; + + var edge = new ElkRoutedEdge + { + Id = "edge/22", + SourceNodeId = source.Id, + TargetNodeId = target.Id, + Label = "when state.notificationHasBody", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 2409.45679088221, Y = 172.25 }, + EndPoint = new ElkPoint { X = 2693.4842064714944, Y = 683.3301334704383 }, + BendPoints = + [ + new ElkPoint { X = 2546.7272727272725, Y = 172.25 }, + new ElkPoint { X = 2546.7272727272725, Y = 631.4360656738281 }, + ], + }, + ], + }; + + var repaired = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry([edge], [source, blocker, target]); + var section = repaired[0].Sections.Single(); + var path = new List { section.StartPoint }; + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + TestContext.Out.WriteLine( + $"{string.Join(" -> ", path.Select(point => $"({point.X:F3},{point.Y:F3})"))} " + + $"boundary={ElkEdgeRoutingScoring.CountBadBoundaryAngles(repaired, [source, blocker, target])} " + + $"gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(repaired, [source, blocker, target])}"); + + ElkEdgeRoutingScoring.CountBadBoundaryAngles(repaired, [source, blocker, target]).Should().Be(0); + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(repaired, [source, blocker, target]).Should().Be(0); + path[1].Y.Should().BeGreaterThan(path[0].Y + 3d); + path.Should().Contain(point => point.X > blocker.X + blocker.Width + 8d && point.Y < blocker.Y - 0.5d); + CrossesRectObstacle(path, blocker).Should().BeFalse(); + + static bool CrossesRectObstacle(IReadOnlyList polyline, ElkPositionedNode node) + { + const double tolerance = 0.5d; + for (var i = 0; i < polyline.Count - 1; i++) + { + var start = polyline[i]; + var end = polyline[i + 1]; + if (Math.Abs(start.Y - end.Y) <= tolerance) + { + if (start.Y > node.Y + tolerance + && start.Y < node.Y + node.Height - tolerance + && Math.Max(start.X, end.X) > node.X + tolerance + && Math.Min(start.X, end.X) < node.X + node.Width - tolerance) + { + return true; + } + } + else if (Math.Abs(start.X - end.X) <= tolerance) + { + if (start.X > node.X + tolerance + && start.X < node.X + node.Width - tolerance + && Math.Max(start.Y, end.Y) > node.Y + tolerance + && Math.Min(start.Y, end.Y) < node.Y + node.Height - tolerance) + { + return true; + } + } + } + + return false; + } + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenUpperGatewayArrivalsCollapseOntoSameLane_ShouldCountJoinAndSharedLaneViolations() + { + var target = new ElkPositionedNode + { + Id = "target", + Label = "Has Recipients", + Kind = "Decision", + X = 3578, + Y = 539.352783203125, + Width = 188, + Height = 132, + }; + + var leftArrival = new ElkRoutedEdge + { + Id = "edge/25", + SourceNodeId = "source-a", + TargetNodeId = target.Id, + Label = "default", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 2738.472294662938, Y = 605.352783203125 }, + EndPoint = new ElkPoint { X = 3586.8910474656404, Y = 599.1101328549094 }, + BendPoints = + [ + new ElkPoint { X = 2738.472294662938, Y = 535.9360656738281 }, + new ElkPoint { X = 3586.8910474656404, Y = 535.9360656738281 }, + ], + }, + ], + }; + + var rightArrival = new ElkRoutedEdge + { + Id = "edge/28", + SourceNodeId = "source-b", + TargetNodeId = target.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 3414, Y = 605.352783203125 }, + EndPoint = new ElkPoint { X = 3682.79150671785, Y = 546.9297985582112 }, + BendPoints = + [ + new ElkPoint { X = 3438, Y = 605.352783203125 }, + new ElkPoint { X = 3438, Y = 532.8054790069142 }, + new ElkPoint { X = 3682.79150671785, Y = 532.8054790069142 }, + ], + }, + ], + }; + + var nodes = new[] { target }; + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations([leftArrival, rightArrival], nodes) + .Should().Be(1); + ElkEdgeRoutingScoring.CountSharedLaneViolations([leftArrival, rightArrival], nodes) + .Should().Be(1); + } + + [Test] + [Property("Intent", "Operational")] + public void SourceDepartureHelpers_WhenOutgoingEdgesShareTheSameDepartureLane_ShouldSpreadOnlyTheConflictingPeer() + { + var source = new ElkPositionedNode + { + Id = "internal-notification", + Label = "Internal Notification", + Kind = "TransportCall", + X = 3034, + Y = 645.352783203125, + Width = 208, + Height = 104, + }; + + var handled = new ElkPositionedNode + { + Id = "handled", + Label = "Set internalNotificationFailed", + Kind = "SetState", + X = 3406, + Y = 645.352783203125, + Width = 208, + Height = 104, + }; + + var hasRecipients = new ElkPositionedNode + { + Id = "has-recipients", + Label = "Has Recipients", + Kind = "Decision", + X = 3578, + Y = 539.352783203125, + Width = 188, + Height = 132, + }; + + var direct = new ElkRoutedEdge + { + Id = "edge/26", + SourceNodeId = source.Id, + TargetNodeId = handled.Id, + Label = "on failure / timeout", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 3242, Y = 697.352783203125 }, + EndPoint = new ElkPoint { X = 3406, Y = 697.352783203125 }, + BendPoints = [], + }, + ], + }; + + var branching = new ElkRoutedEdge + { + Id = "edge/27", + SourceNodeId = source.Id, + TargetNodeId = hasRecipients.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 3242, Y = 697.352783203125 }, + EndPoint = new ElkPoint { X = 3806.7594033898904, Y = 717.5455557960269 }, + BendPoints = + [ + new ElkPoint { X = 3266, Y = 697.352783203125 }, + new ElkPoint { X = 3266, Y = 828.1806263316762 }, + ], + }, + ], + }; + + var nodes = new[] { source, handled, hasRecipients }; + ElkEdgeRoutingScoring.CountSharedLaneViolations([direct, branching], nodes) + .Should().Be(1); + + var repaired = ElkEdgePostProcessor.SpreadSourceDepartureJoins([direct, branching], nodes, 53d); + repaired = ElkEdgePostProcessor.NormalizeBoundaryAngles(repaired, nodes); + repaired = ElkEdgePostProcessor.NormalizeSourceExitAngles(repaired, nodes); + var repairedDirect = repaired.Single(edge => edge.Id == "edge/26").Sections.Single(); + repairedDirect.StartPoint.Y.Should().Be(697.352783203125); + var originalBranchingPoints = new[] + { + (3242d, 697.352783203125), + (3266d, 697.352783203125), + (3266d, 828.1806263316762), + (3806.7594033898904, 717.5455557960269), + }; + var repairedBranching = repaired.Single(edge => edge.Id == "edge/27").Sections.Single(); + var repairedBranchingPoints = new[] + { + (repairedBranching.StartPoint.X, repairedBranching.StartPoint.Y), + } + .Concat(repairedBranching.BendPoints.Select(point => (point.X, point.Y))) + .Concat([(repairedBranching.EndPoint.X, repairedBranching.EndPoint.Y)]) + .ToArray(); + repairedBranchingPoints.Should().NotBeEquivalentTo(originalBranchingPoints); + } + + [Test] + [Property("Intent", "Operational")] + public void MixedNodeFaceHelpers_WhenIncomingAndOutgoingEdgesShareTheSameFaceLane_ShouldSeparateThem() + { + var process = new ElkPositionedNode + { + Id = "process", + Label = "Process Batch", + Kind = "Repeat", + X = 992, + Y = 247.181640625, + Width = 208, + Height = 88, + }; + + var validateSuccess = new ElkPositionedNode + { + Id = "validate", + Label = "Validate Success", + Kind = "Decision", + X = 3406, + Y = 225.181640625, + Width = 188, + Height = 132, + }; + + var join = new ElkPositionedNode + { + Id = "join", + Label = "Parallel Execution Join", + Kind = "Join", + X = 1290, + Y = 116.5908203125, + Width = 176, + Height = 124, + }; + + var incoming = new ElkRoutedEdge + { + Id = "edge/in", + SourceNodeId = validateSuccess.Id, + TargetNodeId = process.Id, + Label = "repeat while state.printInsisAttempt eq 0", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 3439.84, Y = 267.421640625 }, + EndPoint = new ElkPoint { X = 1200, Y = 267.421640625 }, + BendPoints = [], + }, + ], + }; + + var outgoing = new ElkRoutedEdge + { + Id = "edge/out", + SourceNodeId = process.Id, + TargetNodeId = join.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 1200, Y = 269.181640625 }, + EndPoint = new ElkPoint { X = 1308.0475075276013, Y = 222.88924788024843 }, + BendPoints = + [ + new ElkPoint { X = 1282.5912611218437, Y = 269.181640625 }, + ], + }, + ], + }; + + var nodes = new[] { process, validateSuccess, join }; + ElkEdgeRoutingScoring.CountSharedLaneViolations([incoming, outgoing], nodes) + .Should().Be(1); + + var repaired = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts([incoming, outgoing], nodes, 53d); + repaired = ElkEdgePostProcessor.NormalizeBoundaryAngles(repaired, nodes); + repaired = ElkEdgePostProcessor.NormalizeSourceExitAngles(repaired, nodes); + + ElkEdgeRoutingScoring.CountSharedLaneViolations(repaired, nodes) + .Should().Be(0); + } + + [Test] + [Property("Intent", "Operational")] + public void MixedNodeFaceHelpers_WhenAlternateRepeatFaceCandidateIsBlocked_ShouldFallbackToDirectFaceShift() + { + var process = new ElkPositionedNode + { + Id = "process", + Label = "Process Batch", + Kind = "Repeat", + X = 992, + Y = 247.181640625, + Width = 208, + Height = 88, + }; + + var validateSuccess = new ElkPositionedNode + { + Id = "validate", + Label = "Validate Success", + Kind = "Decision", + X = 3406, + Y = 225.181640625, + Width = 188, + Height = 132, + }; + + var join = new ElkPositionedNode + { + Id = "join", + Label = "Parallel Execution Join", + Kind = "Join", + X = 1290, + Y = 116.5908203125, + Width = 176, + Height = 124, + }; + + var topBlocker = new ElkPositionedNode + { + Id = "blocker", + Label = "Top blocker", + Kind = "ServiceTask", + X = 1164, + Y = 168, + Width = 24, + Height = 70, + }; + + var incoming = new ElkRoutedEdge + { + Id = "edge/in", + SourceNodeId = validateSuccess.Id, + TargetNodeId = process.Id, + Label = "repeat while state.printInsisAttempt eq 0", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 3420.7314344414744, Y = 301.5249882115671 }, + EndPoint = new ElkPoint { X = 1200, Y = 269.181640625 }, + BendPoints = + [ + new ElkPoint { X = 3398, Y = 301.5249882115671 }, + new ElkPoint { X = 3398, Y = -133.9318181818182 }, + new ElkPoint { X = 1224, Y = -133.9318181818182 }, + new ElkPoint { X = 1224, Y = 269.181640625 }, + ], + }, + ], + }; + + var outgoing = new ElkRoutedEdge + { + Id = "edge/out", + SourceNodeId = process.Id, + TargetNodeId = join.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 1200, Y = 269.181640625 }, + EndPoint = new ElkPoint { X = 1302.152080344333, Y = 208.41865388495336 }, + BendPoints = + [ + new ElkPoint { X = 1282.5912611218437, Y = 269.181640625 }, + ], + }, + ], + }; + + var nodes = new[] { process, validateSuccess, join, topBlocker }; + ElkEdgeRoutingScoring.CountSharedLaneViolations([incoming, outgoing], nodes) + .Should().Be(1); + + var repaired = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts([incoming, outgoing], nodes, 53d); + repaired = ElkEdgePostProcessor.NormalizeBoundaryAngles(repaired, nodes); + repaired = ElkEdgePostProcessor.NormalizeSourceExitAngles(repaired, nodes); + + ElkEdgeRoutingScoring.CountSharedLaneViolations(repaired, nodes) + .Should().Be(0); + + var repairedIncoming = repaired.Single(edge => edge.Id == "edge/in").Sections.Single(); + repairedIncoming.EndPoint.X.Should().Be(1200); + repairedIncoming.EndPoint.Y.Should().NotBe(269.181640625); + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayTargetHelpers_WhenUnderNodeRepairNeedsAlternateGatewayEntryWithOccupiedPeerFaces_ShouldClearJoinAndUnderNodeViolations() + { + var source = new ElkPositionedNode + { + Id = "start/9/true/1", + Label = "Internal Notification", + Kind = "Decision", + X = 2662, + Y = 639.4360656738281, + Width = 188, + Height = 132, + }; + + var blockerA = new ElkPositionedNode + { + Id = "start/9/true/1/true/1", + Label = "Internal Notification", + Kind = "TransportCall", + X = 3034, + Y = 653.352783203125, + Width = 208, + Height = 88, + }; + + var blockerB = new ElkPositionedNode + { + Id = "start/9/true/1/true/1/handled/1", + Label = "Set internalNotificationFailed", + Kind = "SetState", + X = 3406, + Y = 653.352783203125, + Width = 208, + Height = 88, + }; + + var target = new ElkPositionedNode + { + Id = "start/9/true/2", + Label = "Has Recipients", + Kind = "Decision", + X = 3778, + Y = 631.352783203125, + Width = 188, + Height = 132, + }; + + var underNodeArrival = new ElkRoutedEdge + { + Id = "edge/25", + SourceNodeId = source.Id, + TargetNodeId = target.Id, + Label = "default", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 2835.2685655585256, Y = 715.7794132603952 }, + EndPoint = new ElkPoint { X = 3783.6816599209515, Y = 693.3635326203292 }, + BendPoints = + [ + new ElkPoint { X = 2858, Y = 715.7794132603952 }, + new ElkPoint { X = 2858, Y = 743.3078513580999 }, + new ElkPoint { X = 3773.4029566281924, Y = 743.3078513580999 }, + new ElkPoint { X = 3773.4029566281924, Y = 678.7241673245815 }, + ], + }, + ], + }; + + var topPeerArrival = new ElkRoutedEdge + { + Id = "edge/27", + SourceNodeId = blockerA.Id, + TargetNodeId = target.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 3242, Y = 675.352783203125 }, + EndPoint = new ElkPoint { X = 3842.7864663592964, Y = 651.8644132061722 }, + BendPoints = + [ + new ElkPoint { X = 3266, Y = 675.352783203125 }, + new ElkPoint { X = 3266, Y = 538.5286643288352 }, + new ElkPoint { X = 3770, Y = 538.5286643288352 }, + ], + }, + ], + }; + + var peerArrival = new ElkRoutedEdge + { + Id = "edge/28", + SourceNodeId = "start/9/true/1/true/1/handled/1", + TargetNodeId = target.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 3614, Y = 700.9177359381422 }, + EndPoint = new ElkPoint { X = 3782.4572593563435, Y = 700.4823482831107 }, + BendPoints = [], + }, + ], + }; + + var nodes = new[] { source, blockerA, blockerB, target }; + ElkEdgeRoutingScoring.CountUnderNodeViolations([underNodeArrival], nodes).Should().Be(1); + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations([underNodeArrival, topPeerArrival, peerArrival], nodes).Should().Be(0); + + var repaired = ElkEdgePostProcessor.ElevateUnderNodeViolations( + [underNodeArrival, topPeerArrival, peerArrival], + nodes, + 53d, + ["edge/25"]); + + ElkEdgeRoutingScoring.CountUnderNodeViolations(repaired, nodes).Should().Be(0); + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(repaired, nodes).Should().Be(0); + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenDecisionToDecisionPathWouldCurlAtSource_ShouldPreferMonotoneExit() + { + var source = new ElkPositionedNode + { + Id = "start/9/true/1", + Label = "Internal Notification", + Kind = "Decision", + X = 2662, + Y = 639.4360656738281, + Width = 188, + Height = 132, + }; + + var blockerA = new ElkPositionedNode + { + Id = "start/9/true/1/true/1", + Label = "Internal Notification", + Kind = "TransportCall", + X = 3034, + Y = 653.352783203125, + Width = 208, + Height = 88, + }; + + var blockerB = new ElkPositionedNode + { + Id = "start/9/true/1/true/1/handled/1", + Label = "Set internalNotificationFailed", + Kind = "SetState", + X = 3406, + Y = 653.352783203125, + Width = 208, + Height = 88, + }; + + var target = new ElkPositionedNode + { + Id = "start/9/true/2", + Label = "Has Recipients", + Kind = "Decision", + X = 3778, + Y = 631.352783203125, + Width = 188, + Height = 132, + }; + + var curledArrival = new ElkRoutedEdge + { + Id = "edge/25", + SourceNodeId = source.Id, + TargetNodeId = target.Id, + Label = "default", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 2835.2685655585256, Y = 695.092718087261 }, + EndPoint = new ElkPoint { X = 3843.4511576752675, Y = 743.3078513580999 }, + BendPoints = + [ + new ElkPoint { X = 2858, Y = 695.092718087261 }, + new ElkPoint { X = 2858, Y = 621.7164195667614 }, + new ElkPoint { X = 3824.7800132207826, Y = 621.7164195667614 }, + new ElkPoint { X = 3824.7800132207826, Y = 769.9000873993358 }, + ], + }, + ], + }; + + var topPeerArrival = new ElkRoutedEdge + { + Id = "edge/27", + SourceNodeId = blockerA.Id, + TargetNodeId = target.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 3242, Y = 675.352783203125 }, + EndPoint = new ElkPoint { X = 3842.7864663592964, Y = 651.8644132061722 }, + BendPoints = + [ + new ElkPoint { X = 3266, Y = 675.352783203125 }, + new ElkPoint { X = 3266, Y = 538.5286643288352 }, + new ElkPoint { X = 3770, Y = 538.5286643288352 }, + ], + }, + ], + }; + + var peerArrival = new ElkRoutedEdge + { + Id = "edge/28", + SourceNodeId = blockerB.Id, + TargetNodeId = target.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 3614, Y = 700.9177359381422 }, + EndPoint = new ElkPoint { X = 3782.4572593563435, Y = 700.4823482831107 }, + BendPoints = [], + }, + ], + }; + + var nodes = new[] { source, blockerA, blockerB, target }; + HasGatewaySourceCurl(ExtractPath(curledArrival)).Should().BeTrue(); + + var repaired = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry([curledArrival, topPeerArrival, peerArrival], nodes); + var repairedPath = ExtractPath(repaired.Single(edge => edge.Id == "edge/25")); + + HasGatewaySourceCurl(repairedPath).Should().BeFalse(); + } + + [Test] + [Property("Intent", "Operational")] + public void UnderNodeHelpers_WhenDecisionSourceTargetsRectLeftFaceWithPeerArrival_ShouldLiftAboveBlockerAndAvoidJoin() + { + var source = new ElkPositionedNode + { + Id = "start/2/branch-1/1/body/4/failure/1", + Label = "Retry Decision", + Kind = "Decision", + X = 1976, + Y = 413.9718017578125, + Width = 188, + Height = 132, + }; + + var blocker = new ElkPositionedNode + { + Id = "start/2/branch-1/1/body/4/failure/1/true/1", + Label = "Cooldown Timer", + Kind = "Timer", + X = 2290, + Y = 457.1265563964844, + Width = 208, + Height = 88, + }; + + var target = new ElkPositionedNode + { + Id = "start/2/branch-1/1/body/4/failure/2", + Label = "Set batchGenerateFailed", + Kind = "SetState", + X = 2662, + Y = 479.4360656738281, + Width = 208, + Height = 88, + }; + + var underNodeArrival = new ElkRoutedEdge + { + Id = "edge/9", + SourceNodeId = source.Id, + TargetNodeId = target.Id, + Label = "default", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 2148.281173919672, Y = 491.0084243248514 }, + EndPoint = new ElkPoint { X = 2662, Y = 563.4360656738281 }, + BendPoints = + [ + new ElkPoint { X = 2148.281173919672, Y = 553.9718017578125 }, + new ElkPoint { X = 2654, Y = 553.9718017578125 }, + new ElkPoint { X = 2654, Y = 563.4360656738281 }, + ], + }, + ], + }; + + var peerArrival = new ElkRoutedEdge + { + Id = "edge/10", + SourceNodeId = blocker.Id, + TargetNodeId = target.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 2498, Y = 523.1265563964844 }, + EndPoint = new ElkPoint { X = 2662, Y = 483.4360656738281 }, + BendPoints = + [ + new ElkPoint { X = 2435.090909090909, Y = 523.1265563964844 }, + new ElkPoint { X = 2435.090909090909, Y = 483.4360656738281 }, + ], + }, + ], + }; + + var nodes = new[] { source, blocker, target }; + ElkEdgeRoutingScoring.CountUnderNodeViolations([underNodeArrival], nodes).Should().Be(1); + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations([underNodeArrival, peerArrival], nodes).Should().Be(1); + + var repaired = ElkEdgePostProcessor.ElevateUnderNodeViolations( + [underNodeArrival, peerArrival], + nodes, + 52d, + ["edge/9"]); + var repairedPath = ExtractPath(repaired.Single(edge => edge.Id == "edge/9")); + + ElkEdgeRoutingScoring.CountUnderNodeViolations(repaired, nodes) + .Should() + .Be(0, $"path={string.Join(" -> ", repairedPath.Select(point => $"({point.X:F2},{point.Y:F2})"))}"); + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(repaired, nodes) + .Should() + .Be(0, $"path={string.Join(" -> ", repairedPath.Select(point => $"({point.X:F2},{point.Y:F2})"))}"); + repairedPath + .Any(point => point.Y < blocker.Y - 0.5d) + .Should() + .BeTrue(); + } + + [Test] + [Property("Intent", "Operational")] + public void GatewayBoundaryHelpers_WhenGatewayArrivalsUseDifferentApproachBandsOnSameLeftHalf_ShouldNotCountTargetJoinViolation() + { + var source = new ElkPositionedNode + { + Id = "start/9/true/1", + Label = "Internal Notification", + Kind = "Decision", + X = 2662, + Y = 639.4360656738281, + Width = 188, + Height = 132, + }; + + var peer = new ElkPositionedNode + { + Id = "start/9/true/1/true/1/handled/1", + Label = "Set internalNotificationFailed", + Kind = "SetState", + X = 3406, + Y = 653.352783203125, + Width = 208, + Height = 88, + }; + + var target = new ElkPositionedNode + { + Id = "start/9/true/2", + Label = "Has Recipients", + Kind = "Decision", + X = 3778, + Y = 631.352783203125, + Width = 188, + Height = 132, + }; + + var topBandArrival = new ElkRoutedEdge + { + Id = "edge/25", + SourceNodeId = source.Id, + TargetNodeId = target.Id, + Label = "default", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 2835.2685655585256, Y = 715.7794132603952 }, + EndPoint = new ElkPoint { X = 3783.6816599209515, Y = 693.3635326203292 }, + BendPoints = + [ + new ElkPoint { X = 2858, Y = 715.7794132603952 }, + new ElkPoint { X = 2858, Y = 621.72 }, + new ElkPoint { X = 3773.4029566281924, Y = 621.72 }, + new ElkPoint { X = 3773.4029566281924, Y = 678.7241673245815 }, + ], + }, + ], + }; + + var leftBandArrival = new ElkRoutedEdge + { + Id = "edge/28", + SourceNodeId = peer.Id, + TargetNodeId = target.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 3614, Y = 700.9177359381422 }, + EndPoint = new ElkPoint { X = 3782.4572593563435, Y = 700.4823482831107 }, + BendPoints = [], + }, + ], + }; + + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations([topBandArrival, leftBandArrival], [source, peer, target]) + .Should().Be(0); + } + + [Test] + [Property("Intent", "Operational")] + public void TargetApproachHelpers_WhenRepeatReturnCorridorEntriesShareTopFace_ShouldSlideBoundarySlotsWithoutCollapsingBands() + { + var upperSource = new ElkPositionedNode + { + Id = "start/2/branch-1/1/body/5", + Label = "Mark Batch Failed", + Kind = "Decision", + X = 2947, + Y = 292, + Width = 188, + Height = 132, + }; + + var lowerSource = new ElkPositionedNode + { + Id = "start/2/branch-1/1/body/5/true/1", + Label = "Increment Retry Counter", + Kind = "Decision", + X = 3343, + Y = 203, + Width = 188, + Height = 132, + }; + + var target = new ElkPositionedNode + { + Id = "start/2/branch-1/1", + Label = "Process Batch", + Kind = "Repeat", + X = 992, + Y = 247.181640625, + Width = 208, + Height = 88, + }; + + var upperReturn = new ElkRoutedEdge + { + Id = "edge/14", + SourceNodeId = upperSource.Id, + TargetNodeId = target.Id, + Label = "repeat while state.printInsisAttempt eq 0", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 3041.082354488216, Y = 358.4633486927404 }, + EndPoint = new ElkPoint { X = 1125.3636363636365, Y = 247.181640625 }, + BendPoints = + [ + new ElkPoint { X = 3026, Y = 358.4633486927404 }, + new ElkPoint { X = 3026, Y = -133.9318181818182 }, + new ElkPoint { X = 1125.3636363636365, Y = -133.9318181818182 }, + ], + }, + ], + }; + + var lowerReturn = new ElkRoutedEdge + { + Id = "edge/15", + SourceNodeId = lowerSource.Id, + TargetNodeId = target.Id, + Label = "repeat while state.printInsisAttempt eq 0", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 3437.3333333333335, Y = 269.181640625 }, + EndPoint = new ElkPoint { X = 1176, Y = 247.181640625 }, + BendPoints = + [ + new ElkPoint { X = 3398, Y = 269.181640625 }, + new ElkPoint { X = 3398, Y = 0.6136363636363669 }, + new ElkPoint { X = 1176, Y = 0.6136363636363669 }, + ], + }, + ], + }; + + var nodes = new[] { upperSource, lowerSource, target }; + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations([upperReturn, lowerReturn], nodes).Should().Be(1); + + var repaired = ElkEdgePostProcessor.SpreadTargetApproachJoins([upperReturn, lowerReturn], nodes, 53d); + + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(repaired, nodes).Should().Be(0); + repaired.Single(edge => edge.Id == "edge/15").Sections.Single().EndPoint.X.Should().BeGreaterThan(1180d); + repaired.Single(edge => edge.Id == "edge/14") + .Sections + .SelectMany(section => section.BendPoints) + .Any(point => Math.Abs(point.Y + 133.9318181818182) <= 0.5d) + .Should() + .BeTrue(); + repaired.Single(edge => edge.Id == "edge/15") + .Sections + .SelectMany(section => section.BendPoints) + .Any(point => Math.Abs(point.Y - 0.6136363636363669) <= 0.5d) + .Should() + .BeTrue(); + } + + [Test] + [Property("Intent", "Operational")] + public void SharedLaneHelpers_WhenStraightTwoPointLaneConflictsWithAnotherEdge_ShouldInsertDoglegAndSeparate() + { + var left = new ElkPositionedNode + { + Id = "left", + Label = "Left", + Kind = "ServiceTask", + X = 80, + Y = 120, + Width = 160, + Height = 80, + }; + var right = new ElkPositionedNode + { + Id = "right", + Label = "Right", + Kind = "ServiceTask", + X = 420, + Y = 120, + Width = 160, + Height = 80, + }; + var peerSource = new ElkPositionedNode + { + Id = "peer", + Label = "Peer", + Kind = "ServiceTask", + X = 220, + Y = 260, + Width = 160, + Height = 80, + }; + + var straight = new ElkRoutedEdge + { + Id = "edge/straight", + SourceNodeId = right.Id, + TargetNodeId = left.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 420, Y = 160 }, + EndPoint = new ElkPoint { X = 240, Y = 160 }, + BendPoints = [], + }, + ], + }; + + var overlapping = new ElkRoutedEdge + { + Id = "edge/other", + SourceNodeId = peerSource.Id, + TargetNodeId = right.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 260, Y = 164 }, + EndPoint = new ElkPoint { X = 500, Y = 164 }, + BendPoints = [], + }, + ], + }; + + var nodes = new[] { left, right, peerSource }; + ElkEdgeRoutingScoring.CountSharedLaneViolations([straight, overlapping], nodes) + .Should().Be(1); + + var repaired = ElkEdgePostProcessor.SeparateSharedLaneConflicts([straight, overlapping], nodes, 53d); + + ElkEdgeRoutingScoring.CountSharedLaneViolations(repaired, nodes) + .Should().Be(0); + } + + [Test] + [Property("Intent", "Operational")] + public void RepeatCollectorLaneHelpers_WhenPreferredShiftDirectionIsBlocked_ShouldTryTheOtherDirection() + { + var source = new ElkPositionedNode + { + Id = "source", + Label = "Validate Success", + Kind = "Decision", + X = 520, + Y = 120, + Width = 188, + Height = 132, + }; + var target = new ElkPositionedNode + { + Id = "target", + Label = "Process Batch", + Kind = "Repeat", + X = 120, + Y = 140, + Width = 208, + Height = 88, + }; + var blocker = new ElkPositionedNode + { + Id = "blocker", + Label = "Load Configuration", + Kind = "TransportCall", + X = 260, + Y = 80, + Width = 180, + Height = 120, + }; + + var repeatReturn = new ElkRoutedEdge + { + Id = "edge/repeat", + SourceNodeId = source.Id, + TargetNodeId = target.Id, + Label = "repeat while state.printInsisAttempt eq 0", + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 540, Y = 236 }, + EndPoint = new ElkPoint { X = 180, Y = 228 }, + BendPoints = + [ + new ElkPoint { X = 520, Y = 164 }, + new ElkPoint { X = 180, Y = 164 }, + ], + }, + ], + }; + + var occupying = new ElkRoutedEdge + { + Id = "edge/occupying", + SourceNodeId = blocker.Id, + TargetNodeId = source.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = 260, Y = 168 }, + EndPoint = new ElkPoint { X = 620, Y = 168 }, + BendPoints = [], + }, + ], + }; + + var nodes = new[] { source, target, blocker }; + ElkEdgeRoutingScoring.CountSharedLaneViolations([repeatReturn, occupying], nodes) + .Should().Be(1); + + var repaired = ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts([repeatReturn, occupying], nodes, 53d); + + ElkEdgeRoutingScoring.CountSharedLaneViolations(repaired, nodes) + .Should().Be(0); + } + + [Test] + [Property("Intent", "Operational")] + public void RepeatCollectorCorridors_WhenOuterReturnLanesAreTooClose_ShouldSeparateWholeBucket() + { + var target = new ElkPositionedNode + { + Id = "process", + Label = "Process Batch", + Kind = "Repeat", + X = 950, + Y = 247.181640625, + Width = 208, + Height = 88, + }; + + var sourceA = new ElkPositionedNode + { + Id = "source-a", + Label = "Check Result", + Kind = "Decision", + X = 2929, + Y = 265.9360656738281, + Width = 188, + Height = 132, + }; + + var sourceB = new ElkPositionedNode + { + Id = "source-b", + Label = "Validate Success", + Kind = "Decision", + X = 3206, + Y = 225.181640625, + Width = 188, + Height = 132, + }; + + var sourceC = new ElkPositionedNode + { + Id = "source-c", + Label = "Setting itemId", + Kind = "SetState", + X = 3578, + Y = 239.181640625, + Width = 224, + Height = 104, + }; + + var blocker = new ElkPositionedNode + { + Id = "blocker", + Label = "Load Configuration", + Kind = "TransportCall", + X = 1520, + Y = 60, + Width = 208, + Height = 88, + }; + + ElkRoutedEdge BuildEdge(string id, string sourceId, ElkPoint start, double corridorY, double targetX) + { + return new ElkRoutedEdge + { + Id = id, + SourceNodeId = sourceId, + TargetNodeId = target.Id, + Label = "repeat while state.printInsisAttempt eq 0", + Sections = + [ + new ElkEdgeSection + { + StartPoint = start, + EndPoint = new ElkPoint { X = targetX, Y = target.Y }, + BendPoints = + [ + new ElkPoint { X = start.X - 20d, Y = start.Y + 2d }, + new ElkPoint { X = start.X - 20d, Y = corridorY }, + new ElkPoint { X = targetX, Y = corridorY }, + ], + }, + ], + }; + } + + var edges = new[] + { + BuildEdge("edge/a", sourceA.Id, new ElkPoint { X = 2940.86, Y = 340.27 }, -81.21d, 954d), + BuildEdge("edge/b", sourceB.Id, new ElkPoint { X = 3217.86, Y = 299.51 }, -24.46d, 1054d), + BuildEdge("edge/c", sourceC.Id, new ElkPoint { X = 3784d, Y = 239.18 }, 12.27d, 1154d), + }; + + var nodes = new[] { sourceA, sourceB, sourceC, blocker, target }; + ElkRepeatCollectorCorridors.CountSharedLaneViolations(edges, nodes).Should().BeGreaterThan(0); + + var repaired = ElkRepeatCollectorCorridors.SeparateSharedLanes(edges, nodes); + ElkRepeatCollectorCorridors.CountSharedLaneViolations(repaired, nodes).Should().Be(0); + foreach (var repairedEdge in repaired) + { + HasNearNodeClearanceViolation(repairedEdge, blocker, 53d).Should().BeFalse(); + } + + var corridorYs = repaired + .Select(edge => + { + var section = edge.Sections.Single(); + var path = new List { section.StartPoint }; + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + return path + .Zip(path.Skip(1)) + .Where(pair => Math.Abs(pair.First.Y - pair.Second.Y) <= 0.5d && pair.First.Y < target.Y - 8d) + .Select(pair => pair.First.Y) + .DefaultIfEmpty(double.NaN) + .Min(); + }) + .Where(value => !double.IsNaN(value)) + .OrderBy(value => value) + .ToArray(); + corridorYs.Should().HaveCount(3); + var firstGap = corridorYs[1] - corridorYs[0]; + var secondGap = corridorYs[2] - corridorYs[1]; + firstGap.Should().BeGreaterThan(50d); + secondGap.Should().BeGreaterThan(50d); + + static bool HasNearNodeClearanceViolation(ElkRoutedEdge edge, ElkPositionedNode node, double minClearance) + { + foreach (var section in edge.Sections) + { + var points = new List { section.StartPoint }; + points.AddRange(section.BendPoints); + points.Add(section.EndPoint); + for (var i = 0; i < points.Count - 1; i++) + { + var start = points[i]; + var end = points[i + 1]; + var horizontal = Math.Abs(start.Y - end.Y) <= 0.5d; + var vertical = Math.Abs(start.X - end.X) <= 0.5d; + if (horizontal) + { + var minDist = Math.Min( + Math.Abs(start.Y - node.Y), + Math.Abs(start.Y - (node.Y + node.Height))); + var minX = Math.Min(start.X, end.X); + var maxX = Math.Max(start.X, end.X); + if (minDist > 0.5d + && minDist < minClearance + && maxX > node.X + && minX < node.X + node.Width) + { + return true; + } + } + else if (vertical) + { + var minDist = Math.Min( + Math.Abs(start.X - node.X), + Math.Abs(start.X - (node.X + node.Width))); + var minY = Math.Min(start.Y, end.Y); + var maxY = Math.Max(start.Y, end.Y); + if (minDist > 0.5d + && minDist < minClearance + && maxY > node.Y + && minY < node.Y + node.Height) + { + return true; + } + } + } + } + + return false; + } + } + + [Test] + public void UnderNodeScoring_WhenHorizontalLaneRunsUnderAnotherNode_ShouldCountBlockingViolation() + { + var source = new ElkPositionedNode + { + Id = "source", + Label = "Source", + Kind = "TransportCall", + X = 0, + Y = 100, + Width = 208, + Height = 88, + }; + var blocker = new ElkPositionedNode + { + Id = "blocker", + Label = "Load Configuration", + Kind = "TransportCall", + X = 300, + Y = 100, + Width = 208, + Height = 88, + }; + var target = new ElkPositionedNode + { + Id = "target", + Label = "Process Batch", + Kind = "Repeat", + X = 620, + Y = 100, + Width = 208, + Height = 88, + }; + + var offending = new ElkRoutedEdge + { + Id = "edge/offending", + SourceNodeId = source.Id, + TargetNodeId = target.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = source.X + source.Width, Y = 230 }, + EndPoint = new ElkPoint { X = target.X, Y = 230 }, + BendPoints = [], + }, + ], + }; + + var clear = new ElkRoutedEdge + { + Id = "edge/clear", + SourceNodeId = source.Id, + TargetNodeId = target.Id, + Sections = + [ + new ElkEdgeSection + { + StartPoint = new ElkPoint { X = source.X + source.Width, Y = 268 }, + EndPoint = new ElkPoint { X = target.X, Y = 268 }, + BendPoints = [], + }, + ], + }; + + var nodes = new[] { source, blocker, target }; + ElkEdgeRoutingScoring.CountUnderNodeViolations([offending], nodes).Should().Be(1); + ElkEdgeRoutingScoring.CountUnderNodeViolations([clear], nodes).Should().Be(0); + } + private static ElkGraph BuildElkSharpStressGraph() { return new ElkGraph @@ -94,4 +3458,94 @@ public class ElkSharpEdgeRefinementTests ], }; } + + private static List ExtractPath(ElkRoutedEdge edge) + { + var path = new List(); + foreach (var section in edge.Sections) + { + if (path.Count == 0) + { + path.Add(section.StartPoint); + } + + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + } + + return path; + } + + private static bool HasGatewaySourceCurl(IReadOnlyList path) + { + if (path.Count < 4) + { + return false; + } + + var sample = path.Take(Math.Min(path.Count, 6)).ToArray(); + var desiredDx = path[^1].X - path[0].X; + var desiredDy = path[^1].Y - path[0].Y; + return HasAxisReversalFromStart(sample.Select(point => point.X), desiredDx) + || HasAxisReversalFromStart(sample.Select(point => point.Y), desiredDy); + } + + private static bool HasAxisReversalFromStart(IEnumerable values, double desiredDelta) + { + const double tolerance = 0.5d; + var distinctValues = new List(); + foreach (var value in values) + { + if (distinctValues.Count == 0 || Math.Abs(distinctValues[^1] - value) > tolerance) + { + distinctValues.Add(value); + } + } + + if (distinctValues.Count < 3) + { + return false; + } + + var nonZeroDirections = new List(); + for (var i = 1; i < distinctValues.Count; i++) + { + var delta = distinctValues[i] - distinctValues[i - 1]; + if (Math.Abs(delta) <= tolerance) + { + continue; + } + + nonZeroDirections.Add(Math.Sign(delta)); + } + + if (nonZeroDirections.Count < 2) + { + return false; + } + + if (Math.Abs(desiredDelta) <= tolerance) + { + return nonZeroDirections.Distinct().Count() > 1; + } + + var desiredSign = Math.Sign(desiredDelta); + var sawOpposite = false; + foreach (var direction in nonZeroDirections) + { + if (direction == desiredSign) + { + if (sawOpposite) + { + return true; + } + + continue; + } + + sawOpposite = true; + } + + return false; + } } diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs index 0b368e756..bafafa099 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs @@ -2,6 +2,9 @@ namespace StellaOps.ElkSharp; internal static class ElkEdgePostProcessor { + private static readonly object UnderNodeDebugSync = new(); + private const string ProtectedUnderNodeKindMarker = "protected-undernode"; + internal static ElkRoutedEdge[] SnapAnchorsToNodeBoundary( ElkRoutedEdge[] edges, ElkPositionedNode[] nodes) @@ -63,10 +66,37 @@ internal static class ElkEdgePostProcessor return result; } + internal static ElkRoutedEdge[] ClearInternalRoutingMarkers(ElkRoutedEdge[] edges) + { + if (edges.Length == 0) + { + return edges; + } + + var changed = false; + var result = new ElkRoutedEdge[edges.Length]; + for (var i = 0; i < edges.Length; i++) + { + var edge = edges[i]; + var cleanedKind = RemoveInternalKindMarker(edge.Kind, ProtectedUnderNodeKindMarker); + if (string.Equals(cleanedKind, edge.Kind, StringComparison.Ordinal)) + { + result[i] = edge; + continue; + } + + changed = true; + result[i] = CloneEdgeWithKind(edge, cleanedKind); + } + + return changed ? result : edges; + } + internal static ElkRoutedEdge[] AvoidNodeCrossings( ElkRoutedEdge[] edges, ElkPositionedNode[] nodes, - ElkLayoutDirection direction) + ElkLayoutDirection direction, + IReadOnlyCollection? restrictedEdgeIds = null) { if (direction != ElkLayoutDirection.LeftToRight || nodes.Length == 0) { @@ -81,21 +111,24 @@ internal static class ElkEdgePostProcessor )).ToArray(); var graphMinY = nodes.Min(n => n.Y); var graphMaxY = nodes.Max(n => n.Y + n.Height); + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); var result = new ElkRoutedEdge[edges.Length]; for (var edgeIndex = 0; edgeIndex < edges.Length; edgeIndex++) { var edge = edges[edgeIndex]; - var sourceId = edge.SourceNodeId ?? ""; - var targetId = edge.TargetNodeId ?? ""; - - var hasCorridorPts = HasCorridorBendPoints(edge, graphMinY, graphMaxY); - if (hasCorridorPts && IsRepeatCollectorLabel(edge.Label)) + if (restrictedSet is not null && !restrictedSet.Contains(edge.Id)) { result[edgeIndex] = edge; continue; } + var sourceId = edge.SourceNodeId ?? ""; + var targetId = edge.TargetNodeId ?? ""; + + var hasCorridorPts = HasCorridorBendPoints(edge, graphMinY, graphMaxY); var hasCrossing = false; foreach (var section in edge.Sections) { @@ -288,13 +321,30 @@ internal static class ElkEdgePostProcessor } var normalized = path; - var preserveSourceExit = ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label) - || ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY); + var preserveSourceExit = ShouldPreserveSourceExitGeometry(edge, graphMinY, graphMaxY); if (!preserveSourceExit && nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)) { - var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[0], sourceNode); - var sourceNormalized = NormalizeExitPath(normalized, sourceNode, sourceSide); + List sourceNormalized; + if (ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + sourceNormalized = NormalizeGatewayExitPath(normalized, sourceNode, nodes, edge.SourceNodeId, edge.TargetNodeId); + if (PathStartsAtDecisionVertex(sourceNormalized, sourceNode)) + { + sourceNormalized = ForceDecisionSourceExitOffVertex( + sourceNormalized, + sourceNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId); + } + } + else + { + var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[0], sourceNode); + sourceNormalized = NormalizeExitPath(normalized, sourceNode, sourceSide); + } + if (HasClearSourceExitSegment(sourceNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId)) { normalized = sourceNormalized; @@ -303,8 +353,32 @@ internal static class ElkEdgePostProcessor if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)) { - var targetSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[^1], targetNode); - normalized = NormalizeEntryPath(normalized, targetNode, targetSide); + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + var gatewayNormalized = NormalizeGatewayEntryPath(normalized, targetNode, normalized[^1]); + if (HasAcceptableGatewayBoundaryPath(gatewayNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId, targetNode, fromStart: false)) + { + normalized = gatewayNormalized; + } + } + else + { + var targetSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[^1], targetNode); + ElkPoint? preferredEndpoint = null; + if (HasTargetApproachBacktracking(normalized, targetNode) + && TryResolveNonGatewayBacktrackingEndpoint(normalized, targetNode, out var preferredSide, out var preferredBoundary)) + { + targetSide = preferredSide; + preferredEndpoint = preferredBoundary; + } + + normalized = NormalizeEntryPath(normalized, targetNode, targetSide, preferredEndpoint); + if (HasTargetApproachBacktracking(normalized, targetNode) + && TryNormalizeNonGatewayBacktrackingEntry(normalized, targetNode, out var backtrackingRepair)) + { + normalized = backtrackingRepair; + } + } } if (normalized.Count == path.Count @@ -363,10 +437,8 @@ internal static class ElkEdgePostProcessor for (var i = 0; i < edges.Length; i++) { var edge = edges[i]; - var preserveSourceExit = ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label) - || ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY); - if (preserveSourceExit - || !nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)) + var preserveSourceExit = ShouldPreserveSourceExitGeometry(edge, graphMinY, graphMaxY); + if (!nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)) { result[i] = edge; continue; @@ -390,9 +462,57 @@ internal static class ElkEdgePostProcessor continue; } - var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(path[0], sourceNode); - var normalized = NormalizeExitPath(path, sourceNode, sourceSide); - if (!HasClearSourceExitSegment(normalized, nodes, edge.SourceNodeId, edge.TargetNodeId)) + if (preserveSourceExit) + { + var hasAcceptablePreservedExit = ElkShapeBoundaries.IsGatewayShape(sourceNode) + ? HasAcceptableGatewayBoundaryPath(path, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true) + : HasValidBoundaryAngle(path[0], path[1], sourceNode); + if (hasAcceptablePreservedExit) + { + result[i] = edge; + continue; + } + } + + var sourceSide = ElkShapeBoundaries.IsGatewayShape(sourceNode) + ? ElkEdgeRoutingGeometry.ResolveBoundarySide(path[0], sourceNode) + : ResolvePreferredRectSourceExitSide(path, sourceNode); + List normalized; + if (ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + normalized = NormalizeGatewayExitPath(path, sourceNode, nodes, edge.SourceNodeId, edge.TargetNodeId); + } + else + { + var sourcePath = path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (!HasValidBoundaryAngle(sourcePath[0], sourcePath[1], sourceNode)) + { + sourcePath[0] = BuildRectBoundaryPointForSide(sourceNode, sourceSide, sourcePath[1]); + } + + normalized = NormalizeExitPath(sourcePath, sourceNode, sourceSide); + } + if (ElkShapeBoundaries.IsGatewayShape(sourceNode) + && PathStartsAtDecisionVertex(normalized, sourceNode)) + { + normalized = ForceDecisionSourceExitOffVertex( + normalized, + sourceNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId); + } + if (ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + if (!HasAcceptableGatewayBoundaryPath(normalized, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true)) + { + result[i] = edge; + continue; + } + } + else if (!HasClearSourceExitSegment(normalized, nodes, edge.SourceNodeId, edge.TargetNodeId)) { result[i] = edge; continue; @@ -431,6 +551,716 @@ internal static class ElkEdgePostProcessor return result; } + internal static ElkRoutedEdge[] FinalizeDecisionTargetEntries( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + IReadOnlyCollection? restrictedEdgeIds = null) + { + if (edges.Length == 0 || nodes.Length == 0) + { + return edges; + } + + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); + var result = edges.ToArray(); + for (var i = 0; i < result.Length; i++) + { + var edge = result[i]; + if (restrictedSet is not null && !restrictedSet.Contains(edge.Id)) + { + continue; + } + + if (!nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode) + || targetNode.Kind != "Decision") + { + continue; + } + + var path = ExtractFullPath(edge); + List repaired; + if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode) + && TryBuildPreferredGatewayTargetEntryPath( + path, + sourceNode, + targetNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId, + out var preferredGatewayTargetRepair)) + { + repaired = preferredGatewayTargetRepair; + } + else + { + repaired = NormalizeGatewayEntryPath(path, targetNode, path[^1]); + } + + if ((!CanAcceptGatewayTargetRepair(repaired, targetNode) + || ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, repaired[^2])) + && targetNode.Kind == "Decision") + { + repaired = ForceDecisionExteriorTargetEntry(path, targetNode); + } + + if ((!PathChanged(path, repaired) || !CanAcceptGatewayTargetRepair(repaired, targetNode)) + && targetNode.Kind == "Decision") + { + repaired = ForceDecisionDirectTargetEntry(path, targetNode); + } + + if (!PathChanged(path, repaired) + || !CanAcceptGatewayTargetRepair(repaired, targetNode)) + { + continue; + } + + result[i] = BuildSingleSectionEdge(edge, repaired); + } + + return result; + } + + private static bool TryBuildPreferredGatewayTargetEntryPath( + IReadOnlyList path, + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + out List repaired) + { + repaired = []; + if (!ElkShapeBoundaries.IsGatewayShape(targetNode) || path.Count < 2) + { + return false; + } + + var sourceCenterX = sourceNode.X + (sourceNode.Width / 2d); + var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); + var targetCenterX = targetNode.X + (targetNode.Width / 2d); + var targetCenterY = targetNode.Y + (targetNode.Height / 2d); + var deltaX = targetCenterX - sourceCenterX; + var deltaY = targetCenterY - sourceCenterY; + var absDx = Math.Abs(deltaX); + var absDy = Math.Abs(deltaY); + var sameRowThreshold = Math.Max(40d, (sourceNode.Height + targetNode.Height) / 3d); + var sameColumnThreshold = Math.Max(40d, (sourceNode.Width + targetNode.Width) / 3d); + + string? preferredSide = null; + double preferredSlotCoordinate = 0d; + if (((absDx >= absDy * 1.15d && absDy <= sameRowThreshold) + || (absDx >= absDy * 1.75d)) + && Math.Sign(deltaX) != 0) + { + preferredSide = deltaX > 0d ? "left" : "right"; + preferredSlotCoordinate = sourceCenterY; + } + else if (((absDy >= absDx * 1.15d && absDx <= sameColumnThreshold) + || (absDy >= absDx * 1.75d)) + && Math.Sign(deltaY) != 0) + { + preferredSide = deltaY > 0d ? "top" : "bottom"; + preferredSlotCoordinate = sourceCenterX; + } + + if (preferredSide is null + || !ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, preferredSide, preferredSlotCoordinate, out var preferredBoundary)) + { + return false; + } + + var exteriorIndex = FindLastGatewayExteriorPointIndex(path, targetNode); + var exteriorAnchor = path[exteriorIndex]; + preferredBoundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(targetNode, preferredBoundary, exteriorAnchor); + + var directPreferred = path.Take(exteriorIndex + 1) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (directPreferred.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(directPreferred[^1], exteriorAnchor)) + { + directPreferred.Add(new ElkPoint { X = exteriorAnchor.X, Y = exteriorAnchor.Y }); + } + + directPreferred.Add(new ElkPoint { X = preferredBoundary.X, Y = preferredBoundary.Y }); + var directNormalized = NormalizePathPoints(directPreferred); + if (directNormalized.Count >= 2 + && string.Equals(ElkEdgeRoutingGeometry.ResolveBoundarySide(directNormalized[^1], targetNode), preferredSide, StringComparison.Ordinal) + && CanAcceptGatewayTargetRepair(directNormalized, targetNode) + && HasAcceptableGatewayBoundaryPath(directNormalized, nodes, sourceNodeId, targetNodeId, targetNode, fromStart: false)) + { + repaired = directNormalized; + return true; + } + + var normalizedPreferred = NormalizeGatewayEntryPath(path, targetNode, preferredBoundary); + if (normalizedPreferred.Count < 2 + || !string.Equals(ElkEdgeRoutingGeometry.ResolveBoundarySide(normalizedPreferred[^1], targetNode), preferredSide, StringComparison.Ordinal) + || !CanAcceptGatewayTargetRepair(normalizedPreferred, targetNode) + || !HasAcceptableGatewayBoundaryPath(normalizedPreferred, nodes, sourceNodeId, targetNodeId, targetNode, fromStart: false)) + { + return false; + } + + repaired = normalizedPreferred; + return true; + } + + internal static ElkRoutedEdge[] PreferShortestBoundaryShortcuts( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + IReadOnlyCollection? restrictedEdgeIds = null) + { + if (edges.Length == 0 || nodes.Length == 0) + { + return edges; + } + + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + var minLineClearance = ResolveMinLineClearance(nodes); + var result = edges.ToArray(); + + for (var i = 0; i < result.Length; i++) + { + var edge = result[i]; + if (restrictedSet is not null && !restrictedSet.Contains(edge.Id)) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(edge.SourcePortId) + || !string.IsNullOrWhiteSpace(edge.TargetPortId) + || !nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode) + || !nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(edge.Kind) + && edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (HasProtectedUnderNodeGeometry(edge)) + { + continue; + } + + if (IsRepeatCollectorLabel(edge.Label) + && HasCorridorBendPoints(edge, graphMinY, graphMaxY)) + { + continue; + } + + var path = ExtractFullPath(edge); + if (path.Count < 2) + { + continue; + } + + List? bestShortcut = null; + var currentLength = ComputePathLength(path); + + bool IsAcceptableShortcutCandidate(IReadOnlyList candidate) + { + if (candidate.Count < 2 + || HasNodeObstacleCrossing(candidate, nodes, edge.SourceNodeId, edge.TargetNodeId)) + { + return false; + } + + if (ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + if (!HasAcceptableGatewayBoundaryPath( + candidate, + nodes, + edge.SourceNodeId, + edge.TargetNodeId, + sourceNode, + fromStart: true)) + { + return false; + } + } + else if (!HasValidBoundaryAngle(candidate[0], candidate[1], sourceNode)) + { + return false; + } + + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + return CanAcceptGatewayTargetRepair(candidate, targetNode) + && HasAcceptableGatewayBoundaryPath( + candidate, + nodes, + edge.SourceNodeId, + edge.TargetNodeId, + targetNode, + fromStart: false); + } + + return !HasTargetApproachBacktracking(candidate, targetNode) + && HasValidBoundaryAngle(candidate[^1], candidate[^2], targetNode); + } + + void ConsiderShortcutCandidate(List? candidate) + { + if (candidate is null + || ComputePathLength(candidate) + 16d >= currentLength + || !IsAcceptableShortcutCandidate(candidate)) + { + return; + } + + if (bestShortcut is not null + && ComputePathLength(candidate) + 0.5d >= ComputePathLength(bestShortcut)) + { + return; + } + + bestShortcut = candidate; + } + + if (TryBuildDominantPreferredBoundaryShortcutPath( + sourceNode, + targetNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId, + out var dominantShortcut)) + { + ConsiderShortcutCandidate(dominantShortcut); + } + + if (TryBuildPreferredBoundaryShortcutPath( + sourceNode, + targetNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId, + out var preferredShortcut)) + { + ConsiderShortcutCandidate(preferredShortcut); + } + + var localSkirtShortcut = TryBuildLocalObstacleSkirtBoundaryShortcut( + path, + path[0], + path[^1], + nodes, + edge.SourceNodeId, + edge.TargetNodeId, + targetNode, + minLineClearance); + ConsiderShortcutCandidate(localSkirtShortcut); + + if (bestShortcut is null) + { + continue; + } + + result[i] = BuildSingleSectionEdge(edge, bestShortcut); + } + + return result; + } + + private static bool TryBuildDominantPreferredBoundaryShortcutPath( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + out List shortcut) + { + shortcut = []; + var sourceCenterX = sourceNode.X + (sourceNode.Width / 2d); + var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); + var targetCenterX = targetNode.X + (targetNode.Width / 2d); + var targetCenterY = targetNode.Y + (targetNode.Height / 2d); + var deltaX = targetCenterX - sourceCenterX; + var deltaY = targetCenterY - sourceCenterY; + var absDx = Math.Abs(deltaX); + var absDy = Math.Abs(deltaY); + if (absDx < 16d && absDy < 16d) + { + return false; + } + + var horizontalDominant = absDx >= absDy; + var preferredSourceSide = horizontalDominant + ? deltaX >= 0d ? "right" : "left" + : deltaY >= 0d ? "bottom" : "top"; + var preferredTargetSide = horizontalDominant + ? deltaX >= 0d ? "left" : "right" + : deltaY >= 0d ? "top" : "bottom"; + + return TryBuildPreferredBoundaryShortcutPath( + sourceNode, + targetNode, + preferredSourceSide, + preferredTargetSide, + nodes, + sourceNodeId, + targetNodeId, + out shortcut); + } + + internal static bool TryBuildPreferredBoundaryShortcutPath( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + out List shortcut) + { + shortcut = []; + var sourceCenterX = sourceNode.X + (sourceNode.Width / 2d); + var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); + var targetCenterX = targetNode.X + (targetNode.Width / 2d); + var targetCenterY = targetNode.Y + (targetNode.Height / 2d); + var deltaX = targetCenterX - sourceCenterX; + var deltaY = targetCenterY - sourceCenterY; + var absDx = Math.Abs(deltaX); + var absDy = Math.Abs(deltaY); + if (absDx < 16d && absDy < 16d) + { + return false; + } + + var horizontalDominant = absDx >= absDy; + var preferredSourceSide = horizontalDominant + ? deltaX >= 0d ? "right" : "left" + : deltaY >= 0d ? "bottom" : "top"; + var preferredTargetSide = horizontalDominant + ? deltaX >= 0d ? "left" : "right" + : deltaY >= 0d ? "top" : "bottom"; + + if (!ElkShapeBoundaries.IsGatewayShape(sourceNode) + && !ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + return TryBuildPreferredBoundaryShortcutPath( + sourceNode, + targetNode, + preferredSourceSide, + preferredTargetSide, + nodes, + sourceNodeId, + targetNodeId, + out shortcut); + } + + var minLineClearance = ResolveMinLineClearance(nodes); + var bestScore = double.PositiveInfinity; + foreach (var candidateTargetSide in EnumeratePreferredShortcutTargetSides(preferredTargetSide)) + { + if (!TryBuildPreferredBoundaryShortcutPath( + sourceNode, + targetNode, + preferredSourceSide, + candidateTargetSide, + nodes, + sourceNodeId, + targetNodeId, + out var candidate)) + { + continue; + } + + var score = + (CountUnderNodeSegments(candidate, nodes, sourceNodeId, targetNodeId, minLineClearance) * 100_000d) + + ComputePathLength(candidate) + + (Math.Max(0, candidate.Count - 2) * 4d); + if (!string.Equals(candidateTargetSide, preferredTargetSide, StringComparison.Ordinal)) + { + score += 40d; + } + + if (score >= bestScore) + { + continue; + } + + bestScore = score; + shortcut = candidate; + } + + return shortcut.Count > 0; + } + + internal static bool TryBuildPreferredBoundaryShortcutPath( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + string preferredSourceSide, + string preferredTargetSide, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + out List shortcut) + { + shortcut = []; + var start = BuildPreferredShortcutBoundaryPoint(sourceNode, preferredSourceSide, targetNode); + var end = BuildPreferredShortcutBoundaryPoint(targetNode, preferredTargetSide, sourceNode); + + bool SegmentIsClear(ElkPoint from, ElkPoint to) + { + var obstacles = nodes.Select(node => ( + Left: node.X, + Top: node.Y, + Right: node.X + node.Width, + Bottom: node.Y + node.Height, + Id: node.Id)).ToArray(); + return !SegmentCrossesObstacle(from, to, obstacles, sourceNodeId, targetNodeId); + } + + var prefix = new List { start }; + var routeStart = start; + if (ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + var sourceExterior = ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, start, end); + if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(sourceNode, sourceExterior) + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(sourceNode, start, sourceExterior)) + { + return false; + } + + if (!SegmentIsClear(start, sourceExterior)) + { + return false; + } + + prefix.Add(sourceExterior); + routeStart = sourceExterior; + } + + var routeEnd = end; + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + var targetExterior = ElkShapeBoundaries.BuildGatewayDirectionalExteriorPoint(targetNode, end, routeStart); + if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, targetExterior) + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, end, targetExterior)) + { + return false; + } + + routeEnd = targetExterior; + } + + List? bridge = null; + if (Math.Abs(routeStart.X - routeEnd.X) <= 0.5d || Math.Abs(routeStart.Y - routeEnd.Y) <= 0.5d) + { + if (SegmentIsClear(routeStart, routeEnd)) + { + bridge = + [ + routeStart, + routeEnd, + ]; + } + } + + if (bridge is null) + { + var pivots = new[] + { + new ElkPoint { X = routeEnd.X, Y = routeStart.Y }, + new ElkPoint { X = routeStart.X, Y = routeEnd.Y }, + }; + foreach (var pivot in pivots) + { + if (!SegmentIsClear(routeStart, pivot) || !SegmentIsClear(pivot, routeEnd)) + { + continue; + } + + bridge = + [ + routeStart, + pivot, + routeEnd, + ]; + break; + } + } + + if (bridge is null) + { + return false; + } + + var candidate = prefix + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + foreach (var point in bridge.Skip(1)) + { + if (!ElkEdgeRoutingGeometry.PointsEqual(candidate[^1], point)) + { + candidate.Add(new ElkPoint { X = point.X, Y = point.Y }); + } + } + + if (ElkShapeBoundaries.IsGatewayShape(targetNode) + && !ElkEdgeRoutingGeometry.PointsEqual(candidate[^1], end)) + { + candidate.Add(new ElkPoint { X = end.X, Y = end.Y }); + } + + shortcut = NormalizePathPoints(candidate); + if (shortcut.Count < 2) + { + shortcut = []; + return false; + } + + if (ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + if (!HasAcceptableGatewayBoundaryPath(shortcut, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true)) + { + shortcut = []; + return false; + } + } + else if (!HasValidBoundaryAngle(shortcut[0], shortcut[1], sourceNode)) + { + shortcut = []; + return false; + } + + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + if (!CanAcceptGatewayTargetRepair(shortcut, targetNode) + || !HasAcceptableGatewayBoundaryPath(shortcut, nodes, sourceNodeId, targetNodeId, targetNode, fromStart: false)) + { + shortcut = []; + return false; + } + } + else if (HasTargetApproachBacktracking(shortcut, targetNode) + || !HasValidBoundaryAngle(shortcut[^1], shortcut[^2], targetNode)) + { + shortcut = []; + return false; + } + + return true; + } + + private static ElkPoint BuildPreferredShortcutBoundaryPoint( + ElkPositionedNode node, + string side, + ElkPositionedNode otherNode) + { + var horizontalInset = Math.Min(24d, Math.Max(12d, node.Width / 4d)); + var verticalInset = Math.Min(24d, Math.Max(12d, node.Height / 4d)); + var otherCenterX = otherNode.X + (otherNode.Width / 2d); + var otherCenterY = otherNode.Y + (otherNode.Height / 2d); + var boundary = side switch + { + "left" => new ElkPoint + { + X = node.X, + Y = Math.Clamp(otherCenterY, node.Y + verticalInset, (node.Y + node.Height) - verticalInset), + }, + "right" => new ElkPoint + { + X = node.X + node.Width, + Y = Math.Clamp(otherCenterY, node.Y + verticalInset, (node.Y + node.Height) - verticalInset), + }, + "top" => new ElkPoint + { + X = Math.Clamp(otherCenterX, node.X + horizontalInset, (node.X + node.Width) - horizontalInset), + Y = node.Y, + }, + _ => new ElkPoint + { + X = Math.Clamp(otherCenterX, node.X + horizontalInset, (node.X + node.Width) - horizontalInset), + Y = node.Y + node.Height, + }, + }; + + if (!ElkShapeBoundaries.IsGatewayShape(node)) + { + return boundary; + } + + var referencePoint = side switch + { + "left" => new ElkPoint { X = node.X - Math.Max(24d, node.Width / 3d), Y = boundary.Y }, + "right" => new ElkPoint { X = node.X + node.Width + Math.Max(24d, node.Width / 3d), Y = boundary.Y }, + "top" => new ElkPoint { X = boundary.X, Y = node.Y - Math.Max(24d, node.Height / 3d) }, + _ => new ElkPoint { X = boundary.X, Y = node.Y + node.Height + Math.Max(24d, node.Height / 3d) }, + }; + + var projected = ElkShapeBoundaries.ProjectOntoShapeBoundary(node, referencePoint); + return ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(node, projected, referencePoint); + } + + private static IEnumerable EnumeratePreferredShortcutTargetSides(string preferredTargetSide) + { + var seen = new HashSet(StringComparer.Ordinal); + + bool Add(string side) + { + return side is "left" or "right" or "top" or "bottom" + && seen.Add(side); + } + + if (Add(preferredTargetSide)) + { + yield return preferredTargetSide; + } + + if (preferredTargetSide is "left" or "right") + { + if (Add("top")) + { + yield return "top"; + } + + if (Add("bottom")) + { + yield return "bottom"; + } + + var oppositeHorizontal = string.Equals(preferredTargetSide, "left", StringComparison.Ordinal) + ? "right" + : "left"; + if (Add(oppositeHorizontal)) + { + yield return oppositeHorizontal; + } + + yield break; + } + + if (Add("left")) + { + yield return "left"; + } + + if (Add("right")) + { + yield return "right"; + } + + var oppositeVertical = string.Equals(preferredTargetSide, "top", StringComparison.Ordinal) + ? "bottom" + : "top"; + if (Add(oppositeVertical)) + { + yield return oppositeVertical; + } + } + internal static ElkRoutedEdge[] RepairBoundaryAnglesAndTargetApproaches( ElkRoutedEdge[] edges, ElkPositionedNode[] nodes, @@ -472,10 +1302,37 @@ internal static class ElkEdgePostProcessor .ToList(); if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode) + && ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + var preserveSourceExit = ShouldPreserveSourceExitGeometry(edge, graphMinY, graphMaxY); + if (!preserveSourceExit) + { + var gatewaySourceNormalized = NormalizeGatewayExitPath(normalized, sourceNode, nodes, edge.SourceNodeId, edge.TargetNodeId); + if (PathStartsAtDecisionVertex(gatewaySourceNormalized, sourceNode)) + { + gatewaySourceNormalized = ForceDecisionSourceExitOffVertex( + gatewaySourceNormalized, + sourceNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId); + } + if (PathChanged(normalized, gatewaySourceNormalized) + && HasAcceptableGatewayBoundaryPath(gatewaySourceNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true)) + { + normalized = gatewaySourceNormalized; + } + } + } + else if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out sourceNode) && !HasValidBoundaryAngle(normalized[0], normalized[1], sourceNode)) { - var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[0], sourceNode); - var sourceNormalized = NormalizeExitPath(normalized, sourceNode, sourceSide); + var sourceSide = ResolvePreferredRectSourceExitSide(normalized, sourceNode); + var sourcePath = normalized + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + sourcePath[0] = BuildRectBoundaryPointForSide(sourceNode, sourceSide, sourcePath[1]); + var sourceNormalized = NormalizeExitPath(sourcePath, sourceNode, sourceSide); if (HasClearBoundarySegments(sourceNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId, true, 3)) { normalized = sourceNormalized; @@ -487,25 +1344,122 @@ internal static class ElkEdgePostProcessor var assignedEndpoint = targetSlots.TryGetValue(edge.Id, out var slot) ? slot : normalized[^1]; - var targetSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(assignedEndpoint, targetNode); - if (!ElkEdgeRoutingGeometry.PointsEqual(assignedEndpoint, normalized[^1]) - || !HasValidBoundaryAngle(normalized[^1], normalized[^2], targetNode)) + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) { - var targetNormalized = NormalizeEntryPath(normalized, targetNode, targetSide, assignedEndpoint); - if (HasClearBoundarySegments(targetNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId, false, 3)) + List? preferredGatewayTargetNormalized = null; + if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var gatewaySourceNode) + && TryBuildPreferredGatewayTargetEntryPath( + normalized, + gatewaySourceNode, + targetNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId, + out var preferredGatewayTargetRepair)) { - normalized = targetNormalized; + preferredGatewayTargetNormalized = preferredGatewayTargetRepair; + } + + var gatewayTargetNormalized = NormalizeGatewayEntryPath(normalized, targetNode, assignedEndpoint); + if (gatewayTargetNormalized.Count >= 2 + && !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, gatewayTargetNormalized[^1], gatewayTargetNormalized[^2])) + { + var projectedBoundary = ElkShapeBoundaries.ProjectOntoShapeBoundary(targetNode, gatewayTargetNormalized[^2]); + projectedBoundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(targetNode, projectedBoundary, gatewayTargetNormalized[^2]); + var projectedGatewayTargetNormalized = NormalizeGatewayEntryPath(normalized, targetNode, projectedBoundary); + if (PathChanged(gatewayTargetNormalized, projectedGatewayTargetNormalized) + && HasAcceptableGatewayBoundaryPath(projectedGatewayTargetNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId, targetNode, fromStart: false)) + { + gatewayTargetNormalized = projectedGatewayTargetNormalized; + } + } + + if (preferredGatewayTargetNormalized is not null + && (gatewayTargetNormalized.Count < 2 + || NeedsGatewayTargetBoundaryRepair(gatewayTargetNormalized, targetNode) + || !string.Equals( + ElkEdgeRoutingGeometry.ResolveBoundarySide(gatewayTargetNormalized[^1], targetNode), + ElkEdgeRoutingGeometry.ResolveBoundarySide(preferredGatewayTargetNormalized[^1], targetNode), + StringComparison.Ordinal) + || ComputePathLength(preferredGatewayTargetNormalized) + 4d < ComputePathLength(gatewayTargetNormalized) + || HasTargetApproachBacktracking(gatewayTargetNormalized, targetNode))) + { + gatewayTargetNormalized = preferredGatewayTargetNormalized; + } + + if (PathChanged(normalized, gatewayTargetNormalized) + && HasAcceptableGatewayBoundaryPath(gatewayTargetNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId, targetNode, fromStart: false)) + { + normalized = gatewayTargetNormalized; } } - - var shortenedApproach = TrimTargetApproachBacktracking(normalized, targetNode, targetSide, assignedEndpoint); - if (shortenedApproach.Count != normalized.Count - || !shortenedApproach.Zip(normalized, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal)) + else { - if (HasClearBoundarySegments(shortenedApproach, nodes, edge.SourceNodeId, edge.TargetNodeId, false, 3)) + if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var shortcutSourceNode) + && TryApplyPreferredBoundaryShortcut( + normalized, + shortcutSourceNode, + targetNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId, + requireUnderNodeImprovement: false, + minLineClearance, + out var preferredShortcut)) + { + normalized = preferredShortcut; + } + + var targetSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(assignedEndpoint, targetNode); + if (IsOnWrongSideOfTarget(normalized[^2], targetNode, targetSide, 0.5d) + && TryResolveNonGatewayBacktrackingEndpoint(normalized, targetNode, out var correctedSide, out var correctedBoundary)) + { + targetSide = correctedSide; + assignedEndpoint = correctedBoundary; + } + + if (HasTargetApproachBacktracking(normalized, targetNode) + && TryResolveNonGatewayBacktrackingEndpoint(normalized, targetNode, out var preferredSide, out var preferredBoundary)) + { + targetSide = preferredSide; + assignedEndpoint = preferredBoundary; + } + + if (!HasValidBoundaryAngle(normalized[^1], normalized[^2], targetNode)) + { + var preferredEntrySide = ResolvePreferredRectTargetEntrySide(normalized, targetNode); + if (!string.Equals(preferredEntrySide, targetSide, StringComparison.Ordinal)) + { + targetSide = preferredEntrySide; + assignedEndpoint = BuildRectBoundaryPointForSide(targetNode, targetSide, normalized[^2]); + } + } + + if (!ElkEdgeRoutingGeometry.PointsEqual(assignedEndpoint, normalized[^1]) + || !HasValidBoundaryAngle(normalized[^1], normalized[^2], targetNode)) + { + var targetNormalized = NormalizeEntryPath(normalized, targetNode, targetSide, assignedEndpoint); + if (HasClearBoundarySegments(targetNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId, false, 3)) + { + normalized = targetNormalized; + } + } + + var shortenedApproach = TrimTargetApproachBacktracking(normalized, targetNode, targetSide, assignedEndpoint); + if (PathChanged(normalized, shortenedApproach) + && HasClearBoundarySegments(shortenedApproach, nodes, edge.SourceNodeId, edge.TargetNodeId, false, 3) + && HasValidBoundaryAngle(shortenedApproach[^1], shortenedApproach[^2], targetNode)) { normalized = shortenedApproach; } + + if (HasTargetApproachBacktracking(normalized, targetNode) + && TryNormalizeNonGatewayBacktrackingEntry(normalized, targetNode, out var backtrackingRepair) + && HasClearBoundarySegments(backtrackingRepair, nodes, edge.SourceNodeId, edge.TargetNodeId, false, 3) + && HasValidBoundaryAngle(backtrackingRepair[^1], backtrackingRepair[^2], targetNode)) + { + normalized = backtrackingRepair; + } } } @@ -522,6 +1476,3969 @@ internal static class ElkEdgePostProcessor return result; } + internal static ElkRoutedEdge[] SpreadTargetApproachJoins( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + double minLineClearance, + IReadOnlyCollection? restrictedEdgeIds = null, + bool forceOutwardAxisSpacing = false) + { + if (edges.Length == 0 || nodes.Length == 0) + { + return edges; + } + + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); + var result = edges.ToArray(); + var groups = result + .Select((edge, index) => new + { + Edge = edge, + Index = index, + Path = ExtractFullPath(edge), + }) + .Where(item => item.Path.Count >= 2 + && nodesById.TryGetValue(item.Edge.TargetNodeId ?? string.Empty, out _)) + .GroupBy( + item => + { + var targetNode = nodesById[item.Edge.TargetNodeId ?? string.Empty]; + var side = ResolveTargetApproachSide(item.Path, targetNode); + return $"{targetNode.Id}|{side}"; + }, + StringComparer.Ordinal); + + foreach (var group in groups) + { + var entries = group + .Select(item => + { + var targetNode = nodesById[item.Edge.TargetNodeId ?? string.Empty]; + var side = ResolveTargetApproachSide(item.Path, targetNode); + var endpoint = item.Path[^1]; + return new + { + item.Edge, + item.Index, + item.Path, + TargetNode = targetNode, + Side = side, + Endpoint = endpoint, + }; + }) + .ToArray(); + if (entries.Length < 2) + { + continue; + } + + if (restrictedSet is not null && !entries.Any(entry => restrictedSet.Contains(entry.Edge.Id))) + { + continue; + } + + var joinEntries = entries + .Select(entry => (Path: (IReadOnlyList)entry.Path, Side: entry.Side)) + .ToArray(); + if (!GroupHasTargetApproachJoin(joinEntries, minLineClearance)) + { + continue; + } + + var targetNode = entries[0].TargetNode; + var side = entries[0].Side; + var isGatewayTarget = ElkShapeBoundaries.IsGatewayShape(targetNode); + var preserveBoundaryBandSlots = entries.Any(entry => + HasProtectedUnderNodeGeometry(entry.Edge) + || HasCorridorBendPoints(entry.Edge, graphMinY, graphMaxY)); + var sorted = side is "left" or "right" + ? entries.OrderBy(entry => entry.Endpoint.Y).ThenBy(entry => entry.Edge.Id, StringComparer.Ordinal).ToArray() + : entries.OrderBy(entry => entry.Endpoint.X).ThenBy(entry => entry.Edge.Id, StringComparer.Ordinal).ToArray(); + var sideLength = side is "left" or "right" + ? Math.Max(8d, targetNode.Height - 8d) + : Math.Max(8d, targetNode.Width - 8d); + var slotSpacing = sorted.Length > 1 + ? ResolveBoundaryJoinSlotSpacing(minLineClearance, sideLength, sorted.Length) + : 0d; + var totalSpan = (sorted.Length - 1) * slotSpacing; + var baseBoundaryCoordinate = side switch + { + "left" or "right" => entries.Min(entry => entry.Endpoint.Y), + "top" or "bottom" => entries.Min(entry => entry.Endpoint.X), + _ => double.NaN, + }; + var currentApproachAxes = sorted + .Select(entry => ResolveSpreadableTargetApproachAxis( + entry.Path, + targetNode, + entry.Side, + minLineClearance)) + .Where(axis => !double.IsNaN(axis)) + .ToArray(); + + var baseApproachAxis = isGatewayTarget + ? ResolveDefaultTargetApproachAxis(targetNode, side) + : currentApproachAxes.Length > 0 + ? forceOutwardAxisSpacing + ? side switch + { + "left" or "top" => currentApproachAxes.Max(), + "right" or "bottom" => currentApproachAxes.Min(), + _ => ResolveDefaultTargetApproachAxis(targetNode, side), + } + : currentApproachAxes.Min() + : ResolveDefaultTargetApproachAxis(targetNode, side); + + for (var i = 0; i < sorted.Length; i++) + { + var desiredBoundaryCoordinate = baseBoundaryCoordinate + (i * slotSpacing); + var desiredApproachAxis = ResolveDesiredTargetApproachAxis( + targetNode, + side, + baseApproachAxis, + slotSpacing, + i, + forceOutwardAxisSpacing); + + if (isGatewayTarget) + { + ElkPoint slotPoint; + if (side is "left" or "right") + { + var centerY = targetNode.Y + (targetNode.Height / 2d); + var startY = Math.Max(targetNode.Y + 4d, centerY - (totalSpan / 2d)); + var slotY = Math.Min(targetNode.Y + targetNode.Height - 4d, startY + (i * slotSpacing)); + if (!ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, side, slotY, out slotPoint)) + { + continue; + } + } + else + { + var centerX = targetNode.X + (targetNode.Width / 2d); + var startX = Math.Max(targetNode.X + 4d, centerX - (totalSpan / 2d)); + var slotX = Math.Min(targetNode.X + targetNode.Width - 4d, startX + (i * slotSpacing)); + if (!ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, side, slotX, out slotPoint)) + { + continue; + } + } + + var exteriorIndex = FindLastGatewayExteriorPointIndex(sorted[i].Path, targetNode); + var exteriorAnchor = sorted[i].Path[exteriorIndex]; + var gatewayCandidate = TryBuildSlottedGatewayEntryPath( + sorted[i].Path, + targetNode, + exteriorIndex, + exteriorAnchor, + slotPoint) + ?? NormalizeGatewayEntryPath(sorted[i].Path, targetNode, slotPoint); + var gatewayApproachAxis = double.IsNaN(desiredApproachAxis) + ? ResolveTargetApproachAxisValue(gatewayCandidate, sorted[i].Side) + : desiredApproachAxis; + if (double.IsNaN(gatewayApproachAxis)) + { + gatewayApproachAxis = ResolveDefaultTargetApproachAxis(targetNode, side); + } + + var spreadGatewayCandidate = RewriteTargetApproachRun( + gatewayCandidate, + sorted[i].Side, + slotPoint, + gatewayApproachAxis); + if (PathChanged(gatewayCandidate, spreadGatewayCandidate) + && CanAcceptGatewayTargetRepair(spreadGatewayCandidate, targetNode)) + { + gatewayCandidate = spreadGatewayCandidate; + } + + if (!PathChanged(sorted[i].Path, gatewayCandidate) + || !CanAcceptGatewayTargetRepair(gatewayCandidate, targetNode)) + { + continue; + } + + result[sorted[i].Index] = BuildSingleSectionEdge(sorted[i].Edge, gatewayCandidate); + continue; + } + + ElkPoint desiredEndpoint; + if (side is "left" or "right") + { + var centerY = targetNode.Y + (targetNode.Height / 2d); + var startY = Math.Max(targetNode.Y + 4d, centerY - (totalSpan / 2d)); + var slotY = preserveBoundaryBandSlots && !double.IsNaN(desiredBoundaryCoordinate) + ? Math.Clamp(desiredBoundaryCoordinate, targetNode.Y + 4d, targetNode.Y + targetNode.Height - 4d) + : Math.Min(targetNode.Y + targetNode.Height - 4d, startY + (i * slotSpacing)); + desiredEndpoint = new ElkPoint + { + X = side == "left" ? targetNode.X : targetNode.X + targetNode.Width, + Y = slotY, + }; + } + else + { + var centerX = targetNode.X + (targetNode.Width / 2d); + var startX = Math.Max(targetNode.X + 4d, centerX - (totalSpan / 2d)); + var slotX = preserveBoundaryBandSlots && !double.IsNaN(desiredBoundaryCoordinate) + ? Math.Clamp(desiredBoundaryCoordinate, targetNode.X + 4d, targetNode.X + targetNode.Width - 4d) + : Math.Min(targetNode.X + targetNode.Width - 4d, startX + (i * slotSpacing)); + desiredEndpoint = new ElkPoint + { + X = slotX, + Y = side == "top" ? targetNode.Y : targetNode.Y + targetNode.Height, + }; + } + + var currentRunAxis = ResolveTargetApproachAxisValue(sorted[i].Path, sorted[i].Side); + var preserveApproachBand = HasProtectedUnderNodeGeometry(sorted[i].Edge) + || HasCorridorBendPoints(sorted[i].Edge, graphMinY, graphMaxY); + var desiredRunAxis = preserveApproachBand + ? currentRunAxis + : double.IsNaN(desiredApproachAxis) + ? currentRunAxis + : desiredApproachAxis; + if (double.IsNaN(desiredRunAxis)) + { + desiredRunAxis = double.IsNaN(desiredApproachAxis) + ? ResolveDefaultTargetApproachAxis(targetNode, side) + : desiredApproachAxis; + } + + var candidate = RewriteTargetApproachRun( + sorted[i].Path, + sorted[i].Side, + desiredEndpoint, + desiredRunAxis); + if (!PathChanged(sorted[i].Path, candidate) + || !HasClearBoundarySegments(candidate, nodes, sorted[i].Edge.SourceNodeId, sorted[i].Edge.TargetNodeId, false, 4)) + { + continue; + } + + result[sorted[i].Index] = BuildSingleSectionEdge(sorted[i].Edge, candidate); + } + } + + return result; + } + + internal static ElkRoutedEdge[] SpreadSourceDepartureJoins( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + double minLineClearance, + IReadOnlyCollection? restrictedEdgeIds = null) + { + if (edges.Length == 0 || nodes.Length == 0) + { + return edges; + } + + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); + var result = edges.ToArray(); + var groups = result + .Select((edge, index) => new + { + Edge = edge, + Index = index, + Path = ExtractFullPath(edge), + }) + .Where(item => item.Path.Count >= 2 + && nodesById.TryGetValue(item.Edge.SourceNodeId ?? string.Empty, out _) + && ShouldSpreadSourceDeparture(item.Edge, graphMinY, graphMaxY)) + .GroupBy( + item => + { + var sourceNode = nodesById[item.Edge.SourceNodeId ?? string.Empty]; + var side = ResolveSourceDepartureSide(item.Path, sourceNode); + return $"{sourceNode.Id}|{side}"; + }, + StringComparer.Ordinal); + + foreach (var group in groups) + { + var entries = group + .Select(item => + { + var sourceNode = nodesById[item.Edge.SourceNodeId ?? string.Empty]; + var side = ResolveSourceDepartureSide(item.Path, sourceNode); + return new + { + item.Edge, + item.Index, + item.Path, + SourceNode = sourceNode, + Side = side, + Boundary = item.Path[0], + TargetReference = side is "left" or "right" + ? item.Path[^1].Y + : item.Path[^1].X, + PathLength = ComputePathLength(item.Path), + }; + }) + .ToArray(); + if (entries.Length < 2) + { + continue; + } + + if (restrictedSet is not null && !entries.Any(entry => restrictedSet.Contains(entry.Edge.Id))) + { + continue; + } + + var joinEntries = entries + .Select(entry => (Path: (IReadOnlyList)entry.Path, Side: entry.Side)) + .ToArray(); + if (!GroupHasSourceDepartureJoin(joinEntries, minLineClearance)) + { + continue; + } + + var sourceNode = entries[0].SourceNode; + var side = entries[0].Side; + var isGatewaySource = ElkShapeBoundaries.IsGatewayShape(sourceNode); + var boundaryCoordinate = side is "left" or "right" + ? entries[0].Boundary.Y + : entries[0].Boundary.X; + var anchor = entries + .OrderBy(entry => Math.Abs(entry.TargetReference - boundaryCoordinate)) + .ThenBy(entry => entry.PathLength) + .ThenBy(entry => entry.Edge.Id, StringComparer.Ordinal) + .First(); + var sideLength = side is "left" or "right" + ? Math.Max(8d, sourceNode.Height - 8d) + : Math.Max(8d, sourceNode.Width - 8d); + var slotSpacing = entries.Length > 1 + ? Math.Max(12d, Math.Min(minLineClearance, sideLength / entries.Length)) + : 0d; + var minSlot = side is "left" or "right" + ? sourceNode.Y + 4d + : sourceNode.X + 4d; + var maxSlot = side is "left" or "right" + ? sourceNode.Y + sourceNode.Height - 4d + : sourceNode.X + sourceNode.Width - 4d; + + var negativeEntries = entries + .Where(entry => !ReferenceEquals(entry, anchor) && entry.TargetReference < anchor.TargetReference) + .OrderByDescending(entry => entry.TargetReference) + .ThenBy(entry => entry.PathLength) + .ThenBy(entry => entry.Edge.Id, StringComparer.Ordinal) + .ToArray(); + var positiveEntries = entries + .Where(entry => !ReferenceEquals(entry, anchor) && entry.TargetReference >= anchor.TargetReference) + .OrderBy(entry => entry.TargetReference) + .ThenBy(entry => entry.PathLength) + .ThenBy(entry => entry.Edge.Id, StringComparer.Ordinal) + .ToArray(); + + var desiredCoordinateByEdgeId = new Dictionary(StringComparer.Ordinal) + { + [anchor.Edge.Id] = boundaryCoordinate, + }; + var anchorDepartureAxis = TryExtractSourceDepartureRun(anchor.Path, side, out _, out var anchorRunEndIndex) + ? side is "left" or "right" + ? anchor.Path[anchorRunEndIndex].X + : anchor.Path[anchorRunEndIndex].Y + : side switch + { + "left" => sourceNode.X - 24d, + "right" => sourceNode.X + sourceNode.Width + 24d, + "top" => sourceNode.Y - 24d, + "bottom" => sourceNode.Y + sourceNode.Height + 24d, + _ => 0d, + }; + var axisStep = Math.Max(12d, minLineClearance * 0.5d); + var desiredAxisByEdgeId = new Dictionary(StringComparer.Ordinal) + { + [anchor.Edge.Id] = anchorDepartureAxis, + }; + for (var i = 0; i < negativeEntries.Length; i++) + { + desiredCoordinateByEdgeId[negativeEntries[i].Edge.Id] = Math.Max(minSlot, boundaryCoordinate - ((i + 1) * slotSpacing)); + desiredAxisByEdgeId[negativeEntries[i].Edge.Id] = anchorDepartureAxis; + } + + for (var i = 0; i < positiveEntries.Length; i++) + { + desiredCoordinateByEdgeId[positiveEntries[i].Edge.Id] = Math.Min(maxSlot, boundaryCoordinate + ((i + 1) * slotSpacing)); + desiredAxisByEdgeId[positiveEntries[i].Edge.Id] = anchorDepartureAxis; + } + + foreach (var entry in entries) + { + if (!desiredCoordinateByEdgeId.TryGetValue(entry.Edge.Id, out var slotCoordinate)) + { + continue; + } + if (!desiredAxisByEdgeId.TryGetValue(entry.Edge.Id, out var desiredAxis)) + { + continue; + } + + var originalCoordinate = side is "left" or "right" + ? entry.Boundary.Y + : entry.Boundary.X; + var originalAxis = TryExtractSourceDepartureRun(entry.Path, side, out _, out var runEndIndex) + ? side is "left" or "right" + ? entry.Path[runEndIndex].X + : entry.Path[runEndIndex].Y + : desiredAxis; + if (Math.Abs(originalCoordinate - slotCoordinate) <= 0.5d + && Math.Abs(originalAxis - desiredAxis) <= 0.5d) + { + continue; + } + + ElkPoint boundaryPoint; + if (isGatewaySource) + { + if (!ElkShapeBoundaries.TryProjectGatewayBoundarySlot(sourceNode, side, slotCoordinate, out boundaryPoint)) + { + continue; + } + + var continuation = entry.Path.Count > 1 ? entry.Path[1] : entry.Path[0]; + boundaryPoint = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(sourceNode, boundaryPoint, continuation); + } + else + { + boundaryPoint = side switch + { + "left" => new ElkPoint { X = sourceNode.X, Y = slotCoordinate }, + "right" => new ElkPoint { X = sourceNode.X + sourceNode.Width, Y = slotCoordinate }, + "top" => new ElkPoint { X = slotCoordinate, Y = sourceNode.Y }, + "bottom" => new ElkPoint { X = slotCoordinate, Y = sourceNode.Y + sourceNode.Height }, + _ => entry.Boundary, + }; + } + + var candidate = RewriteSourceDepartureRun(entry.Path, side, boundaryPoint, desiredAxis); + if (!PathChanged(entry.Path, candidate)) + { + continue; + } + + if (isGatewaySource) + { + if (!HasAcceptableGatewayBoundaryPath(candidate, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId, sourceNode, fromStart: true)) + { + continue; + } + } + else + { + if (!HasClearBoundarySegments(candidate, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId, true, 2) + || !HasValidBoundaryAngle(candidate[0], candidate[1], sourceNode) + || HasNodeObstacleCrossing(candidate, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId)) + { + continue; + } + } + + result[entry.Index] = BuildSingleSectionEdge(entry.Edge, candidate); + } + } + + return result; + } + + internal static ElkRoutedEdge[] SpreadRectTargetApproachFeederBands( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + double minLineClearance, + IReadOnlyCollection? restrictedEdgeIds = null) + { + if (edges.Length < 2 || nodes.Length == 0) + { + return edges; + } + + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); + var result = edges.ToArray(); + var groups = result + .Select((edge, index) => new + { + Edge = edge, + Index = index, + Path = ExtractFullPath(edge), + }) + .Where(item => item.Path.Count >= 3 + && nodesById.TryGetValue(item.Edge.TargetNodeId ?? string.Empty, out var targetNode) + && !ElkShapeBoundaries.IsGatewayShape(targetNode)) + .GroupBy( + item => + { + var targetNode = nodesById[item.Edge.TargetNodeId ?? string.Empty]; + var side = ResolveTargetApproachSide(item.Path, targetNode); + return $"{targetNode.Id}|{side}"; + }, + StringComparer.Ordinal); + + foreach (var group in groups) + { + var entries = group + .Select(item => + { + var targetNode = nodesById[item.Edge.TargetNodeId ?? string.Empty]; + var side = ResolveTargetApproachSide(item.Path, targetNode); + return TryExtractTargetApproachFeeder(item.Path, side, out var feeder) + ? new + { + item.Edge, + item.Index, + item.Path, + TargetNode = targetNode, + Side = side, + Feeder = feeder, + } + : null; + }) + .Where(entry => entry is not null) + .Select(entry => entry!) + .ToArray(); + if (entries.Length < 2) + { + continue; + } + + if (restrictedSet is not null && !entries.Any(entry => restrictedSet.Contains(entry.Edge.Id))) + { + continue; + } + + var hasConflict = false; + for (var i = 0; i < entries.Length && !hasConflict; i++) + { + for (var j = i + 1; j < entries.Length; j++) + { + if (ElkEdgeRoutingGeometry.AreParallelAndClose( + entries[i].Feeder.Start, + entries[i].Feeder.End, + entries[j].Feeder.Start, + entries[j].Feeder.End, + minLineClearance)) + { + hasConflict = true; + break; + } + } + } + + if (!hasConflict) + { + continue; + } + + var spacing = Math.Max(12d, minLineClearance + 4d); + var sorted = entries + .OrderBy(entry => entry.Feeder.BandCoordinate) + .ThenBy(entry => entry.Edge.Id, StringComparer.Ordinal) + .ToArray(); + var baseBand = sorted[0].Side is "left" or "top" + ? sorted.Max(entry => entry.Feeder.BandCoordinate) + : sorted.Min(entry => entry.Feeder.BandCoordinate); + + for (var i = 0; i < sorted.Length; i++) + { + var desiredBand = ResolveDesiredTargetApproachAxis( + sorted[i].TargetNode, + sorted[i].Side, + baseBand, + spacing, + i, + forceOutwardFromBoundary: true); + + if (Math.Abs(sorted[i].Feeder.BandCoordinate - desiredBand) <= 0.5d) + { + continue; + } + + var candidate = RewriteTargetApproachFeederBand(sorted[i].Path, sorted[i].Side, desiredBand); + if (!PathChanged(sorted[i].Path, candidate) + || !HasClearBoundarySegments(candidate, nodes, sorted[i].Edge.SourceNodeId, sorted[i].Edge.TargetNodeId, false, 4) + || HasNodeObstacleCrossing(candidate, nodes, sorted[i].Edge.SourceNodeId, sorted[i].Edge.TargetNodeId)) + { + continue; + } + + result[sorted[i].Index] = BuildSingleSectionEdge(sorted[i].Edge, candidate); + } + } + + return result; + } + + internal static ElkRoutedEdge[] SeparateMixedNodeFaceLaneConflicts( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + double minLineClearance, + IReadOnlyCollection? restrictedEdgeIds = null) + { + 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 graphMaxY = nodes.Max(node => node.Y + node.Height); + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); + var result = edges.ToArray(); + var entries = new List<(int Index, ElkRoutedEdge Edge, IReadOnlyList Path, ElkPositionedNode Node, string Side, bool IsOutgoing, ElkPoint Boundary, double BoundaryCoordinate, double AxisValue)>(); + + for (var index = 0; index < result.Length; index++) + { + var edge = result[index]; + var path = ExtractFullPath(edge); + if (path.Count < 2) + { + continue; + } + + if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode) + && ShouldSpreadSourceDeparture(edge, graphMinY, graphMaxY)) + { + var side = ResolveSourceDepartureSide(path, sourceNode); + var axisValue = TryExtractSourceDepartureRun(path, side, out _, out var runEndIndex) + ? side is "left" or "right" + ? path[runEndIndex].X + : path[runEndIndex].Y + : ResolveDefaultSourceDepartureAxis(sourceNode, side); + entries.Add(( + index, + edge, + path, + sourceNode, + side, + true, + path[0], + side is "left" or "right" ? path[0].Y : path[0].X, + axisValue)); + } + + if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode) + && ShouldSpreadTargetApproach(edge, graphMinY, graphMaxY)) + { + var side = ResolveTargetApproachSide(path, targetNode); + var axisValue = ResolveTargetApproachAxisValue(path, side); + if (double.IsNaN(axisValue)) + { + axisValue = side is "left" or "right" ? path[^1].Y : path[^1].X; + } + + entries.Add(( + index, + edge, + path, + targetNode, + side, + false, + path[^1], + side is "left" or "right" ? path[^1].Y : path[^1].X, + axisValue)); + } + } + + foreach (var group in entries.GroupBy( + entry => $"{entry.Node.Id}|{entry.Side}", + StringComparer.Ordinal)) + { + var groupEntries = group.ToArray(); + if (groupEntries.Length < 2 + || !groupEntries.Any(entry => entry.IsOutgoing) + || !groupEntries.Any(entry => !entry.IsOutgoing) + || !GroupHasMixedNodeFaceLaneConflict(groupEntries, minLineClearance)) + { + continue; + } + + if (restrictedSet is not null && !groupEntries.Any(entry => restrictedSet.Contains(entry.Edge.Id))) + { + continue; + } + + var node = groupEntries[0].Node; + var side = groupEntries[0].Side; + var sideLength = side is "left" or "right" + ? Math.Max(8d, node.Height - 8d) + : Math.Max(8d, node.Width - 8d); + var slotSpacing = groupEntries.Length > 1 + ? groupEntries.Length == 2 + ? Math.Max( + 12d, + Math.Min( + ResolveBoundaryJoinSlotSpacing(minLineClearance, sideLength, groupEntries.Length), + Math.Min(28d, minLineClearance * 0.45d))) + : ResolveBoundaryJoinSlotSpacing(minLineClearance, sideLength, groupEntries.Length) + : 0d; + var centerCoordinate = side is "left" or "right" + ? node.Y + (node.Height / 2d) + : node.X + (node.Width / 2d); + var anchor = groupEntries + .OrderBy(entry => entry.IsOutgoing ? 0 : 1) + .ThenBy(entry => IsRepeatCollectorLabel(entry.Edge.Label) ? 1 : 0) + .ThenBy(entry => Math.Abs(entry.BoundaryCoordinate - centerCoordinate)) + .ThenBy(entry => ComputePathLength(entry.Path)) + .ThenBy(entry => entry.Edge.Id, StringComparer.Ordinal) + .First(); + + var minSlot = side is "left" or "right" ? node.Y + 4d : node.X + 4d; + var maxSlot = side is "left" or "right" ? node.Y + node.Height - 4d : node.X + node.Width - 4d; + var negativeEntries = groupEntries + .Where(entry => entry.Edge.Id != anchor.Edge.Id && entry.BoundaryCoordinate < anchor.BoundaryCoordinate) + .OrderByDescending(entry => entry.BoundaryCoordinate) + .ThenBy(entry => entry.IsOutgoing ? 0 : 1) + .ThenBy(entry => entry.Edge.Id, StringComparer.Ordinal) + .ToArray(); + var positiveEntries = groupEntries + .Where(entry => entry.Edge.Id != anchor.Edge.Id && entry.BoundaryCoordinate >= anchor.BoundaryCoordinate) + .OrderBy(entry => entry.BoundaryCoordinate) + .ThenBy(entry => entry.IsOutgoing ? 0 : 1) + .ThenBy(entry => entry.Edge.Id, StringComparer.Ordinal) + .ToArray(); + + var desiredCoordinateByEdgeId = new Dictionary(StringComparer.Ordinal) + { + [anchor.Edge.Id] = anchor.BoundaryCoordinate, + }; + for (var i = 0; i < negativeEntries.Length; i++) + { + desiredCoordinateByEdgeId[negativeEntries[i].Edge.Id] = Math.Max(minSlot, anchor.BoundaryCoordinate - ((i + 1) * slotSpacing)); + } + + for (var i = 0; i < positiveEntries.Length; i++) + { + desiredCoordinateByEdgeId[positiveEntries[i].Edge.Id] = Math.Min(maxSlot, anchor.BoundaryCoordinate + ((i + 1) * slotSpacing)); + } + + foreach (var entry in groupEntries) + { + if (!desiredCoordinateByEdgeId.TryGetValue(entry.Edge.Id, out var desiredCoordinate) + || Math.Abs(desiredCoordinate - entry.BoundaryCoordinate) <= 0.5d) + { + continue; + } + + var bestEdge = result[entry.Index]; + var currentGroupEdges = groupEntries + .Select(item => result[item.Index]) + .ToArray(); + var bestSharedLaneViolations = ElkEdgeRoutingScoring.CountSharedLaneViolations(currentGroupEdges, nodes); + var bestTargetJoinViolations = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(currentGroupEdges, nodes); + var bestBoundaryAngleViolations = ElkEdgeRoutingScoring.CountBadBoundaryAngles(currentGroupEdges, nodes); + var bestGatewaySourceExitViolations = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(currentGroupEdges, nodes); + var bestUnderNodeViolations = ElkEdgeRoutingScoring.CountUnderNodeViolations(currentGroupEdges, nodes); + var bestPathLength = ComputePathLength(entry.Path); + var prefersAlternateRepeatFace = !entry.IsOutgoing + && !ElkShapeBoundaries.IsGatewayShape(entry.Node) + && IsRepeatCollectorLabel(entry.Edge.Label) + && groupEntries.Any(other => other.IsOutgoing); + var candidatePaths = new List>(); + var directCandidate = entry.IsOutgoing + ? BuildMixedSourceFaceCandidate(entry.Path, entry.Node, side, desiredCoordinate, entry.AxisValue) + : BuildMixedTargetFaceCandidate(entry.Path, entry.Node, side, desiredCoordinate, entry.AxisValue); + AddUniquePathCandidate(candidatePaths, directCandidate); + var availableSpan = Math.Abs(desiredCoordinate - anchor.BoundaryCoordinate); + if ((prefersAlternateRepeatFace || availableSpan + 0.5d < minLineClearance) + && TryBuildAlternateMixedFaceCandidate(entry, nodes, minLineClearance, out var alternateCandidate)) + { + AddUniquePathCandidate(candidatePaths, alternateCandidate); + } + + foreach (var candidate in candidatePaths) + { + if (!PathChanged(entry.Path, candidate) + || HasNodeObstacleCrossing(candidate, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId)) + { + continue; + } + + if (entry.IsOutgoing) + { + if (ElkShapeBoundaries.IsGatewayShape(entry.Node)) + { + if (!HasAcceptableGatewayBoundaryPath(candidate, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId, entry.Node, fromStart: true)) + { + continue; + } + } + else if (!HasClearBoundarySegments(candidate, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId, true, 2) + || !HasValidBoundaryAngle(candidate[0], candidate[1], entry.Node)) + { + continue; + } + } + else + { + if (ElkShapeBoundaries.IsGatewayShape(entry.Node)) + { + if (!CanAcceptGatewayTargetRepair(candidate, entry.Node) + || !HasAcceptableGatewayBoundaryPath(candidate, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId, entry.Node, fromStart: false)) + { + continue; + } + } + else if (!HasClearBoundarySegments(candidate, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId, false, 4) + || !HasValidBoundaryAngle(candidate[^1], candidate[^2], entry.Node) + || HasTargetApproachBacktracking(candidate, entry.Node)) + { + continue; + } + } + + var candidateEdge = BuildSingleSectionEdge(entry.Edge, candidate); + var candidateGroupEdges = groupEntries + .Select(item => item.Index == entry.Index ? candidateEdge : result[item.Index]) + .ToArray(); + var candidateSharedLaneViolations = ElkEdgeRoutingScoring.CountSharedLaneViolations(candidateGroupEdges, nodes); + var candidateTargetJoinViolations = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(candidateGroupEdges, nodes); + var candidateBoundaryAngleViolations = ElkEdgeRoutingScoring.CountBadBoundaryAngles(candidateGroupEdges, nodes); + var candidateGatewaySourceExitViolations = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(candidateGroupEdges, nodes); + var candidateUnderNodeViolations = ElkEdgeRoutingScoring.CountUnderNodeViolations(candidateGroupEdges, nodes); + var candidatePathLength = ComputePathLength(candidate); + + if (!IsBetterMixedNodeFaceCandidate( + candidateSharedLaneViolations, + candidateTargetJoinViolations, + candidateBoundaryAngleViolations, + candidateGatewaySourceExitViolations, + candidateUnderNodeViolations, + candidatePathLength, + bestSharedLaneViolations, + bestTargetJoinViolations, + bestBoundaryAngleViolations, + bestGatewaySourceExitViolations, + bestUnderNodeViolations, + bestPathLength)) + { + continue; + } + + bestEdge = candidateEdge; + bestSharedLaneViolations = candidateSharedLaneViolations; + bestTargetJoinViolations = candidateTargetJoinViolations; + bestBoundaryAngleViolations = candidateBoundaryAngleViolations; + bestGatewaySourceExitViolations = candidateGatewaySourceExitViolations; + bestUnderNodeViolations = candidateUnderNodeViolations; + bestPathLength = candidatePathLength; + } + + result[entry.Index] = bestEdge; + } + } + + return result; + } + + internal static ElkRoutedEdge[] SeparateRepeatCollectorLocalLaneConflicts( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + double minLineClearance, + IReadOnlyCollection? restrictedEdgeIds = null) + { + if (edges.Length < 2 || nodes.Length == 0) + { + return edges; + } + + var result = edges.ToArray(); + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); + var nodeObstacles = nodes.Select(node => ( + Left: node.X, + Top: node.Y, + Right: node.X + node.Width, + Bottom: node.Y + node.Height, + Id: node.Id)).ToArray(); + + for (var i = 0; i < result.Length; i++) + { + var edge = result[i]; + if (restrictedSet is not null && !restrictedSet.Contains(edge.Id)) + { + continue; + } + + if (!IsRepeatCollectorLabel(edge.Label)) + { + continue; + } + + var path = ExtractFullPath(edge); + if (path.Count < 3) + { + continue; + } + + for (var segmentIndex = 0; segmentIndex < path.Count - 1; segmentIndex++) + { + var start = path[segmentIndex]; + var end = path[segmentIndex + 1]; + var isHorizontal = Math.Abs(start.Y - end.Y) <= 0.5d; + var isVertical = Math.Abs(start.X - end.X) <= 0.5d; + if (!isHorizontal && !isVertical) + { + continue; + } + + var conflictFound = false; + var desiredCoordinate = 0d; + foreach (var otherEdge in result) + { + if (otherEdge.Id == edge.Id) + { + continue; + } + + foreach (var otherSegment in ElkEdgeRoutingGeometry.FlattenSegments(otherEdge)) + { + if (!ElkEdgeRoutingGeometry.AreParallelAndClose(start, end, otherSegment.Start, otherSegment.End, minLineClearance)) + { + continue; + } + + if (isHorizontal) + { + desiredCoordinate = start.Y <= otherSegment.Start.Y + ? otherSegment.Start.Y - (minLineClearance + 4d) + : otherSegment.Start.Y + (minLineClearance + 4d); + } + else + { + desiredCoordinate = start.X <= otherSegment.Start.X + ? otherSegment.Start.X - (minLineClearance + 4d) + : otherSegment.Start.X + (minLineClearance + 4d); + } + + conflictFound = true; + break; + } + + if (conflictFound) + { + break; + } + } + + if (!conflictFound) + { + continue; + } + + var preferredCoordinate = desiredCoordinate; + var fallbackCoordinate = isHorizontal + ? start.Y + (start.Y - desiredCoordinate) + : start.X + (start.X - desiredCoordinate); + foreach (var alternateCoordinate in new[] { preferredCoordinate, fallbackCoordinate }.Distinct()) + { + var candidate = ShiftSingleOrthogonalRun(path, segmentIndex, alternateCoordinate); + if (!PathChanged(path, candidate) + || HasNodeObstacleCrossing(candidate, nodes, edge.SourceNodeId, edge.TargetNodeId) + || SegmentLeavesGraphBand(candidate, graphMinY, graphMaxY)) + { + continue; + } + + var crossesObstacle = false; + for (var candidateIndex = 0; candidateIndex < candidate.Count - 1; candidateIndex++) + { + if (!SegmentCrossesObstacle(candidate[candidateIndex], candidate[candidateIndex + 1], nodeObstacles, edge.SourceNodeId, edge.TargetNodeId)) + { + continue; + } + + crossesObstacle = true; + break; + } + + if (crossesObstacle) + { + continue; + } + + var repairedEdge = BuildSingleSectionEdge(edge, candidate); + repairedEdge = RepairBoundaryAnglesAndTargetApproaches( + [repairedEdge], + nodes, + minLineClearance)[0]; + var repairedPath = ExtractFullPath(repairedEdge); + if (HasNodeObstacleCrossing(repairedPath, nodes, edge.SourceNodeId, edge.TargetNodeId) + || SegmentLeavesGraphBand(repairedPath, graphMinY, graphMaxY)) + { + continue; + } + + result[i] = repairedEdge; + break; + } + } + } + + return result; + } + + internal static ElkRoutedEdge[] ElevateRepeatCollectorNodeClearanceViolations( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + double minLineClearance, + IReadOnlyCollection? restrictedEdgeIds = null) + { + if (edges.Length == 0 || nodes.Length == 0) + { + return edges; + } + + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var graphMinY = nodes.Min(node => node.Y); + var corridorY = graphMinY - Math.Max(24d, minLineClearance * 0.6d); + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); + var result = edges.ToArray(); + + for (var i = 0; i < result.Length; i++) + { + var edge = result[i]; + if (restrictedSet is not null && !restrictedSet.Contains(edge.Id)) + { + continue; + } + + if (!IsRepeatCollectorLabel(edge.Label) + || !nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)) + { + continue; + } + + var path = ExtractFullPath(edge); + if (path.Count < 2 + || !HasRepeatCollectorNodeClearanceViolation(path, nodes, edge.SourceNodeId, edge.TargetNodeId, minLineClearance)) + { + continue; + } + + var targetApproachY = Math.Min(corridorY, targetNode.Y - 24d); + ElkPoint targetEndpoint; + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + var slotCoordinate = Math.Max(targetNode.X + 4d, Math.Min(targetNode.X + targetNode.Width - 4d, path[^1].X)); + if (!ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, "top", slotCoordinate, out targetEndpoint)) + { + continue; + } + + targetEndpoint = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary( + targetNode, + targetEndpoint, + new ElkPoint { X = targetEndpoint.X, Y = targetApproachY }); + } + else + { + targetEndpoint = BuildRectBoundaryPointForSide(targetNode, "top", path[0]); + } + + var rebuilt = new List + { + new() { X = path[0].X, Y = path[0].Y }, + }; + + if (Math.Abs(rebuilt[^1].Y - corridorY) > 0.5d) + { + rebuilt.Add(new ElkPoint { X = rebuilt[^1].X, Y = corridorY }); + } + + if (Math.Abs(rebuilt[^1].X - targetEndpoint.X) > 0.5d) + { + rebuilt.Add(new ElkPoint { X = targetEndpoint.X, Y = rebuilt[^1].Y }); + } + + if (Math.Abs(rebuilt[^1].Y - targetApproachY) > 0.5d) + { + rebuilt.Add(new ElkPoint { X = targetEndpoint.X, Y = targetApproachY }); + } + + rebuilt.Add(targetEndpoint); + var candidate = NormalizePathPoints(rebuilt); + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + candidate = NormalizeGatewayEntryPath(candidate, targetNode, targetEndpoint); + } + + if (!PathChanged(path, candidate) + || HasNodeObstacleCrossing(candidate, nodes, edge.SourceNodeId, edge.TargetNodeId) + || HasRepeatCollectorNodeClearanceViolation(candidate, nodes, edge.SourceNodeId, edge.TargetNodeId, minLineClearance)) + { + continue; + } + + var repairedEdge = BuildSingleSectionEdge(edge, candidate); + repairedEdge = NormalizeSourceExitAngles([repairedEdge], nodes)[0]; + var repairedPath = ExtractFullPath(repairedEdge); + if (repairedPath.Count < 2 + || HasNodeObstacleCrossing(repairedPath, nodes, edge.SourceNodeId, edge.TargetNodeId) + || HasRepeatCollectorNodeClearanceViolation(repairedPath, nodes, edge.SourceNodeId, edge.TargetNodeId, minLineClearance) + || (ElkShapeBoundaries.IsGatewayShape(targetNode) + ? !CanAcceptGatewayTargetRepair(repairedPath, targetNode) + : !HasValidBoundaryAngle(repairedPath[^1], repairedPath[^2], targetNode))) + { + continue; + } + + result[i] = repairedEdge; + } + + return result; + } + + internal static ElkRoutedEdge[] ElevateUnderNodeViolations( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + double minLineClearance, + IReadOnlyCollection? restrictedEdgeIds = null) + { + if (edges.Length == 0 || nodes.Length == 0) + { + return edges; + } + + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); + var result = edges.ToArray(); + for (var i = 0; i < result.Length; i++) + { + var edge = result[i]; + if (restrictedSet is not null && !restrictedSet.Contains(edge.Id)) + { + continue; + } + + var path = ExtractFullPath(edge); + if (path.Count < 2) + { + continue; + } + + if (TryResolveUnderNodeWithPreferredShortcut( + edge, + path, + nodes, + minLineClearance, + out var directRepair)) + { + var currentLocalHardPressure = ComputeUnderNodeRepairLocalHardPressure(edge, nodes); + var repairedEdge = BuildSingleSectionEdge(edge, directRepair); + repairedEdge = ResolveUnderNodePeerTargetConflicts( + repairedEdge, + result, + i, + nodes, + minLineClearance); + var repairedPath = ExtractFullPath(repairedEdge); + var repairedUnderNodeSegments = CountUnderNodeSegments(repairedPath, nodes, edge.SourceNodeId, edge.TargetNodeId, minLineClearance); + var currentUnderNodeSegments = CountUnderNodeSegments(path, nodes, edge.SourceNodeId, edge.TargetNodeId, minLineClearance); + var repairedCrossesNode = HasNodeObstacleCrossing(repairedPath, nodes, edge.SourceNodeId, edge.TargetNodeId); + var repairedLocalHardPressure = ComputeUnderNodeRepairLocalHardPressure(repairedEdge, nodes); + WriteUnderNodeDebug( + edge.Id, + $"accept-check raw current={currentUnderNodeSegments} repaired={repairedUnderNodeSegments} crossing={repairedCrossesNode} local={repairedLocalHardPressure}/{currentLocalHardPressure} repaired={FormatPath(repairedPath)}"); + if (repairedUnderNodeSegments < currentUnderNodeSegments + && !repairedCrossesNode + && repairedLocalHardPressure <= currentLocalHardPressure) + { + WriteUnderNodeDebug(edge.Id, "accept-check raw accepted"); + result[i] = repairedUnderNodeSegments == 0 + ? ProtectUnderNodeGeometry(repairedEdge) + : repairedEdge; + continue; + } + + repairedEdge = RepairBoundaryAnglesAndTargetApproaches( + [repairedEdge], + nodes, + minLineClearance)[0]; + repairedEdge = FinalizeGatewayBoundaryGeometry([repairedEdge], nodes)[0]; + repairedEdge = NormalizeBoundaryAngles([repairedEdge], nodes)[0]; + repairedEdge = NormalizeSourceExitAngles([repairedEdge], nodes)[0]; + repairedEdge = ResolveUnderNodePeerTargetConflicts( + repairedEdge, + result, + i, + nodes, + minLineClearance); + repairedPath = ExtractFullPath(repairedEdge); + repairedUnderNodeSegments = CountUnderNodeSegments(repairedPath, nodes, edge.SourceNodeId, edge.TargetNodeId, minLineClearance); + repairedCrossesNode = HasNodeObstacleCrossing(repairedPath, nodes, edge.SourceNodeId, edge.TargetNodeId); + repairedLocalHardPressure = ComputeUnderNodeRepairLocalHardPressure(repairedEdge, nodes); + WriteUnderNodeDebug( + edge.Id, + $"accept-check normalized current={currentUnderNodeSegments} repaired={repairedUnderNodeSegments} crossing={repairedCrossesNode} local={repairedLocalHardPressure}/{currentLocalHardPressure} repaired={FormatPath(repairedPath)}"); + if (repairedUnderNodeSegments < currentUnderNodeSegments + && !repairedCrossesNode + && repairedLocalHardPressure <= currentLocalHardPressure) + { + WriteUnderNodeDebug(edge.Id, "accept-check normalized accepted"); + result[i] = repairedUnderNodeSegments == 0 + ? ProtectUnderNodeGeometry(repairedEdge) + : repairedEdge; + } + + continue; + } + + var lifted = TryLiftUnderNodeSegments( + path, + nodes, + edge.SourceNodeId, + edge.TargetNodeId, + minLineClearance); + if (!PathChanged(path, lifted)) + { + continue; + } + + var liftedEdge = BuildSingleSectionEdge(edge, lifted); + liftedEdge = NormalizeBoundaryAngles([liftedEdge], nodes)[0]; + liftedEdge = NormalizeSourceExitAngles([liftedEdge], nodes)[0]; + var liftedPath = ExtractFullPath(liftedEdge); + if (CountUnderNodeSegments(liftedPath, nodes, edge.SourceNodeId, edge.TargetNodeId, minLineClearance) + < CountUnderNodeSegments(path, nodes, edge.SourceNodeId, edge.TargetNodeId, minLineClearance) + && !HasNodeObstacleCrossing(liftedPath, nodes, edge.SourceNodeId, edge.TargetNodeId)) + { + result[i] = CountUnderNodeSegments(liftedPath, nodes, edge.SourceNodeId, edge.TargetNodeId, minLineClearance) == 0 + ? ProtectUnderNodeGeometry(liftedEdge) + : liftedEdge; + } + } + + return result; + } + + private static int ComputeUnderNodeRepairLocalHardPressure( + ElkRoutedEdge edge, + IReadOnlyCollection nodes) + { + return ElkEdgeRoutingScoring.CountBelowGraphViolations([edge], nodes) + + ElkEdgeRoutingScoring.CountUnderNodeViolations([edge], nodes) + + ElkEdgeRoutingScoring.CountLongDiagonalViolations([edge], nodes) + + ElkEdgeRoutingScoring.CountBadBoundaryAngles([edge], nodes) + + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations([edge], nodes) + + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations([edge], nodes) + + ElkEdgeRoutingScoring.CountSharedLaneViolations([edge], nodes) + + ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations([edge], nodes) + + ElkEdgeRoutingScoring.CountExcessiveDetourViolations([edge], nodes); + } + + internal static ElkRoutedEdge[] PolishTargetPeerConflicts( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + double minLineClearance, + IReadOnlyCollection? restrictedEdgeIds = null) + { + if (edges.Length < 2 || nodes.Length == 0) + { + return edges; + } + + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); + var result = edges.ToArray(); + for (var i = 0; i < result.Length; i++) + { + if (restrictedSet is not null && !restrictedSet.Contains(result[i].Id)) + { + continue; + } + + result[i] = ResolveUnderNodePeerTargetConflicts( + result[i], + result, + i, + nodes, + minLineClearance); + } + + return result; + } + + private static ElkRoutedEdge ResolveUnderNodePeerTargetConflicts( + ElkRoutedEdge candidateEdge, + IReadOnlyList currentEdges, + int candidateIndex, + ElkPositionedNode[] nodes, + double minLineClearance) + { + if (TryPolishGatewayUnderNodeTargetPeerConflicts( + candidateEdge, + currentEdges, + candidateIndex, + nodes, + minLineClearance, + out var gatewayPolishedEdge)) + { + return gatewayPolishedEdge; + } + + return TryPolishRectUnderNodeTargetPeerConflicts( + candidateEdge, + currentEdges, + candidateIndex, + nodes, + minLineClearance, + out var polishedEdge) + ? polishedEdge + : candidateEdge; + } + + private static bool TryPolishGatewayUnderNodeTargetPeerConflicts( + ElkRoutedEdge candidateEdge, + IReadOnlyList currentEdges, + int candidateIndex, + ElkPositionedNode[] nodes, + double minLineClearance, + out ElkRoutedEdge polishedEdge) + { + polishedEdge = candidateEdge; + if (string.IsNullOrWhiteSpace(candidateEdge.TargetNodeId)) + { + return false; + } + + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + if (!nodesById.TryGetValue(candidateEdge.TargetNodeId, out var targetNode) + || !ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + return false; + } + + nodesById.TryGetValue(candidateEdge.SourceNodeId ?? string.Empty, out var sourceNode); + var peerEdges = currentEdges + .Where((edge, index) => + index != candidateIndex + && string.Equals(edge.TargetNodeId, candidateEdge.TargetNodeId, StringComparison.Ordinal)) + .ToArray(); + if (peerEdges.Length == 0) + { + return false; + } + + var path = ExtractFullPath(candidateEdge); + if (path.Count < 2) + { + return false; + } + + var sourceNodeId = candidateEdge.SourceNodeId; + var targetNodeId = candidateEdge.TargetNodeId; + var currentBundle = peerEdges + .Append(candidateEdge) + .ToArray(); + var currentTargetJoinViolations = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(currentBundle, nodes); + var currentSharedLaneViolations = ElkEdgeRoutingScoring.CountSharedLaneViolations(currentBundle, nodes); + var currentUnderNodeSegments = CountUnderNodeSegments(path, nodes, sourceNodeId, targetNodeId, minLineClearance); + var currentUnderNodeViolations = ElkEdgeRoutingScoring.CountUnderNodeViolations([candidateEdge], nodes); + var currentLocalHardPressure = ComputeUnderNodeRepairLocalHardPressure(candidateEdge, nodes); + var currentPathLength = ComputePathLength(path); + if (currentTargetJoinViolations == 0 + && currentSharedLaneViolations == 0 + && currentUnderNodeSegments == 0 + && currentUnderNodeViolations == 0) + { + return false; + } + + var bestEdge = default(ElkRoutedEdge); + var bestTargetJoinViolations = currentTargetJoinViolations; + var bestSharedLaneViolations = currentSharedLaneViolations; + var bestUnderNodeSegments = currentUnderNodeSegments; + var bestUnderNodeViolations = currentUnderNodeViolations; + var bestLocalHardPressure = currentLocalHardPressure; + var bestPathLength = currentPathLength; + + foreach (var candidatePath in EnumerateGatewayUnderNodePeerConflictCandidates( + path, + targetNode, + sourceNode, + peerEdges, + nodes, + sourceNodeId, + targetNodeId, + minLineClearance)) + { + if (!PathChanged(path, candidatePath) + || candidatePath.Count < 2 + || HasNodeObstacleCrossing(candidatePath, nodes, sourceNodeId, targetNodeId) + || !CanAcceptGatewayTargetRepair(candidatePath, targetNode) + || !HasAcceptableGatewayBoundaryPath(candidatePath, nodes, sourceNodeId, targetNodeId, targetNode, fromStart: false)) + { + continue; + } + + var localCandidateEdge = BuildSingleSectionEdge(candidateEdge, candidatePath); + var localBundle = peerEdges + .Append(localCandidateEdge) + .ToArray(); + var candidateTargetJoinViolations = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(localBundle, nodes); + var candidateSharedLaneViolations = ElkEdgeRoutingScoring.CountSharedLaneViolations(localBundle, nodes); + var candidateUnderNodeSegments = CountUnderNodeSegments(candidatePath, nodes, sourceNodeId, targetNodeId, minLineClearance); + var candidateUnderNodeViolations = ElkEdgeRoutingScoring.CountUnderNodeViolations([localCandidateEdge], nodes); + var candidateLocalHardPressure = ComputeUnderNodeRepairLocalHardPressure(localCandidateEdge, nodes); + var candidatePathLength = ComputePathLength(candidatePath); + + if (!IsBetterGatewayUnderNodePeerConflictCandidate( + candidateTargetJoinViolations, + candidateSharedLaneViolations, + candidateUnderNodeSegments, + candidateUnderNodeViolations, + candidateLocalHardPressure, + candidatePathLength, + bestTargetJoinViolations, + bestSharedLaneViolations, + bestUnderNodeSegments, + bestUnderNodeViolations, + bestLocalHardPressure, + bestPathLength)) + { + continue; + } + + bestEdge = localCandidateEdge; + bestTargetJoinViolations = candidateTargetJoinViolations; + bestSharedLaneViolations = candidateSharedLaneViolations; + bestUnderNodeSegments = candidateUnderNodeSegments; + bestUnderNodeViolations = candidateUnderNodeViolations; + bestLocalHardPressure = candidateLocalHardPressure; + bestPathLength = candidatePathLength; + } + + if (bestEdge is null) + { + return false; + } + + polishedEdge = bestEdge; + return true; + } + + private static bool TryPolishRectUnderNodeTargetPeerConflicts( + ElkRoutedEdge candidateEdge, + IReadOnlyList currentEdges, + int candidateIndex, + ElkPositionedNode[] nodes, + double minLineClearance, + out ElkRoutedEdge polishedEdge) + { + polishedEdge = candidateEdge; + if (string.IsNullOrWhiteSpace(candidateEdge.TargetNodeId)) + { + return false; + } + + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + if (!nodesById.TryGetValue(candidateEdge.TargetNodeId, out var targetNode) + || ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + return false; + } + + var peerEdges = currentEdges + .Where((edge, index) => + index != candidateIndex + && string.Equals(edge.TargetNodeId, candidateEdge.TargetNodeId, StringComparison.Ordinal)) + .ToArray(); + if (peerEdges.Length == 0) + { + return false; + } + + var currentBundle = peerEdges + .Append(candidateEdge) + .ToArray(); + if (ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(currentBundle, nodes) == 0) + { + return false; + } + + var path = ExtractFullPath(candidateEdge); + if (path.Count < 2) + { + return false; + } + + var sourceNodeId = candidateEdge.SourceNodeId; + var targetNodeId = candidateEdge.TargetNodeId; + var currentUnderNodeSegments = CountUnderNodeSegments(path, nodes, sourceNodeId, targetNodeId, minLineClearance); + var currentSide = ResolveTargetApproachSide(path, targetNode); + var bestScore = double.PositiveInfinity; + ElkRoutedEdge? bestEdge = null; + + foreach (var side in EnumerateRectTargetPeerConflictSides(path, targetNode, currentSide)) + { + var axisCandidates = EnumerateRectTargetPeerConflictAxes(path, targetNode, side, minLineClearance).ToArray(); + if (axisCandidates.Length == 0) + { + continue; + } + + var boundaryCoordinates = EnumerateRectTargetPeerConflictBoundaryCoordinates(path, targetNode, side).ToArray(); + if (boundaryCoordinates.Length == 0) + { + continue; + } + + foreach (var axis in axisCandidates) + { + foreach (var boundaryCoordinate in boundaryCoordinates) + { + var candidatePath = BuildMixedTargetFaceCandidate(path, targetNode, side, boundaryCoordinate, axis); + if (!PathChanged(path, candidatePath) + || HasNodeObstacleCrossing(candidatePath, nodes, sourceNodeId, targetNodeId) + || HasTargetApproachBacktracking(candidatePath, targetNode) + || !HasValidBoundaryAngle(candidatePath[^1], candidatePath[^2], targetNode)) + { + continue; + } + + var candidateUnderNodeSegments = CountUnderNodeSegments(candidatePath, nodes, sourceNodeId, targetNodeId, minLineClearance); + if (candidateUnderNodeSegments > currentUnderNodeSegments) + { + continue; + } + + var localCandidateEdge = BuildSingleSectionEdge(candidateEdge, candidatePath); + var localBundle = peerEdges + .Append(localCandidateEdge) + .ToArray(); + if (ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(localBundle, nodes) > 0) + { + continue; + } + + var score = ComputeRectTargetPeerConflictPolishScore(candidatePath, currentSide, side); + if (score >= bestScore) + { + continue; + } + + bestScore = score; + bestEdge = localCandidateEdge; + } + } + } + + if (bestEdge is null) + { + return false; + } + + polishedEdge = bestEdge; + return true; + } + + private static IEnumerable> EnumerateGatewayUnderNodePeerConflictCandidates( + IReadOnlyList path, + ElkPositionedNode targetNode, + ElkPositionedNode? sourceNode, + IReadOnlyCollection peerEdges, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + double minLineClearance) + { + foreach (var side in EnumerateGatewayUnderNodePeerConflictSides(path, targetNode, peerEdges)) + { + var slotCoordinates = EnumerateGatewayUnderNodePeerConflictSlotCoordinates( + path, + targetNode, + sourceNode, + peerEdges, + side, + minLineClearance) + .ToArray(); + if (slotCoordinates.Length == 0) + { + continue; + } + + foreach (var slotCoordinate in slotCoordinates) + { + if (sourceNode is not null + && ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, side, slotCoordinate, out var bandBoundary) + && TryBuildSafeHorizontalBandCandidate( + sourceNode, + targetNode, + nodes, + sourceNodeId, + targetNodeId, + path[0], + bandBoundary, + minLineClearance, + preferredSourceExterior: null, + out var bandCandidate)) + { + yield return bandCandidate; + } + + foreach (var axis in EnumerateGatewayUnderNodePeerConflictAxes( + path, + targetNode, + side, + nodes, + sourceNodeId, + targetNodeId, + minLineClearance)) + { + yield return BuildMixedTargetFaceCandidate(path, targetNode, side, slotCoordinate, axis); + } + } + } + } + + private static IEnumerable EnumerateGatewayUnderNodePeerConflictSides( + IReadOnlyList path, + ElkPositionedNode targetNode, + IReadOnlyCollection peerEdges) + { + var seen = new HashSet(StringComparer.Ordinal); + var currentSide = ResolveTargetApproachSide(path, targetNode); + var peerSides = peerEdges + .Select(edge => ExtractFullPath(edge)) + .Where(peerPath => peerPath.Count >= 2) + .Select(peerPath => ResolveTargetApproachSide(peerPath, targetNode)) + .ToHashSet(StringComparer.Ordinal); + + foreach (var side in new[] { "top", "bottom", "right", "left" }) + { + if (!string.Equals(side, currentSide, StringComparison.Ordinal) + && !peerSides.Contains(side) + && seen.Add(side)) + { + yield return side; + } + } + + if (seen.Add(currentSide)) + { + yield return currentSide; + } + + foreach (var side in new[] { "top", "bottom", "right", "left" }) + { + if (seen.Add(side)) + { + yield return side; + } + } + } + + private static IEnumerable EnumerateGatewayUnderNodePeerConflictSlotCoordinates( + IReadOnlyList path, + ElkPositionedNode targetNode, + ElkPositionedNode? sourceNode, + IReadOnlyCollection peerEdges, + string side, + double minLineClearance) + { + var coordinates = new List(); + var inset = 10d; + var spacing = Math.Max(14d, minLineClearance + 6d); + var centerX = targetNode.X + (targetNode.Width / 2d); + var centerY = targetNode.Y + (targetNode.Height / 2d); + var slotMinimum = side is "left" or "right" ? targetNode.Y + inset : targetNode.X + inset; + var slotMaximum = side is "left" or "right" + ? targetNode.Y + targetNode.Height - inset + : targetNode.X + targetNode.Width - inset; + + void AddClamped(double value) + { + AddUniqueCoordinate(coordinates, Math.Max(slotMinimum, Math.Min(slotMaximum, value))); + } + + if (side is "left" or "right") + { + AddClamped(path[^1].Y); + foreach (var peer in peerEdges) + { + var peerPath = ExtractFullPath(peer); + if (peerPath.Count > 0) + { + AddClamped(peerPath[^1].Y - spacing); + AddClamped(peerPath[^1].Y + spacing); + AddClamped(peerPath[^1].Y); + } + } + + if (sourceNode is not null) + { + AddClamped(sourceNode.Y + (sourceNode.Height / 2d)); + } + + AddClamped(centerY - spacing); + AddClamped(centerY); + AddClamped(centerY + spacing); + } + else + { + AddClamped(path[^1].X); + foreach (var peer in peerEdges) + { + var peerPath = ExtractFullPath(peer); + if (peerPath.Count > 0) + { + AddClamped(peerPath[^1].X - spacing); + AddClamped(peerPath[^1].X + spacing); + AddClamped(peerPath[^1].X); + } + } + + if (sourceNode is not null) + { + AddClamped(sourceNode.X + (sourceNode.Width / 2d)); + } + + AddClamped(centerX - spacing); + AddClamped(centerX); + AddClamped(centerX + spacing); + } + + foreach (var coordinate in coordinates.Take(8)) + { + yield return coordinate; + } + } + + private static IEnumerable EnumerateGatewayUnderNodePeerConflictAxes( + IReadOnlyList path, + ElkPositionedNode targetNode, + string side, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + double minLineClearance) + { + var coordinates = new List(); + var currentAxis = ResolveTargetApproachAxisValue(path, side); + if (!double.IsNaN(currentAxis)) + { + AddUniqueCoordinate(coordinates, currentAxis); + } + + AddUniqueCoordinate(coordinates, ResolveDefaultTargetApproachAxis(targetNode, side)); + + var clearance = Math.Max(24d, minLineClearance * 0.6d); + if (side is "top" or "bottom") + { + var minX = Math.Min(path[0].X, targetNode.X); + var maxX = Math.Max(path[0].X, targetNode.X + targetNode.Width); + var blockers = nodes + .Where(node => + !string.Equals(node.Id, sourceNodeId, StringComparison.Ordinal) + && !string.Equals(node.Id, targetNodeId, StringComparison.Ordinal) + && maxX > node.X + 0.5d + && minX < node.X + node.Width - 0.5d) + .ToArray(); + if (side == "top") + { + var highestBlockerY = blockers.Length > 0 + ? blockers.Min(node => node.Y) + : Math.Min(path[0].Y, targetNode.Y); + AddUniqueCoordinate(coordinates, Math.Min(targetNode.Y - 8d, highestBlockerY - clearance)); + } + else + { + var lowestBlockerY = blockers.Length > 0 + ? blockers.Max(node => node.Y + node.Height) + : Math.Max(path[0].Y, targetNode.Y + targetNode.Height); + AddUniqueCoordinate(coordinates, Math.Max(targetNode.Y + targetNode.Height + 8d, lowestBlockerY + clearance)); + } + } + else + { + var minY = Math.Min(path[0].Y, targetNode.Y); + var maxY = Math.Max(path[0].Y, targetNode.Y + targetNode.Height); + var blockers = nodes + .Where(node => + !string.Equals(node.Id, sourceNodeId, StringComparison.Ordinal) + && !string.Equals(node.Id, targetNodeId, StringComparison.Ordinal) + && maxY > node.Y + 0.5d + && minY < node.Y + node.Height - 0.5d) + .ToArray(); + if (side == "left") + { + var leftmostBlockerX = blockers.Length > 0 + ? blockers.Min(node => node.X) + : Math.Min(path[0].X, targetNode.X); + AddUniqueCoordinate(coordinates, Math.Min(targetNode.X - 8d, leftmostBlockerX - clearance)); + } + else + { + var rightmostBlockerX = blockers.Length > 0 + ? blockers.Max(node => node.X + node.Width) + : Math.Max(path[0].X, targetNode.X + targetNode.Width); + AddUniqueCoordinate(coordinates, Math.Max(targetNode.X + targetNode.Width + 8d, rightmostBlockerX + clearance)); + } + } + + foreach (var coordinate in coordinates.Take(6)) + { + yield return coordinate; + } + } + + private static bool IsBetterGatewayUnderNodePeerConflictCandidate( + int candidateTargetJoinViolations, + int candidateSharedLaneViolations, + int candidateUnderNodeSegments, + int candidateUnderNodeViolations, + int candidateLocalHardPressure, + double candidatePathLength, + int currentTargetJoinViolations, + int currentSharedLaneViolations, + int currentUnderNodeSegments, + int currentUnderNodeViolations, + int currentLocalHardPressure, + double currentPathLength) + { + if (candidateTargetJoinViolations != currentTargetJoinViolations) + { + return candidateTargetJoinViolations < currentTargetJoinViolations; + } + + if (candidateUnderNodeViolations != currentUnderNodeViolations) + { + return candidateUnderNodeViolations < currentUnderNodeViolations; + } + + if (candidateUnderNodeSegments != currentUnderNodeSegments) + { + return candidateUnderNodeSegments < currentUnderNodeSegments; + } + + if (candidateSharedLaneViolations != currentSharedLaneViolations) + { + return candidateSharedLaneViolations < currentSharedLaneViolations; + } + + if (candidateLocalHardPressure != currentLocalHardPressure) + { + return candidateLocalHardPressure < currentLocalHardPressure; + } + + return candidatePathLength + 0.5d < currentPathLength; + } + + private static IEnumerable EnumerateRectTargetPeerConflictSides( + IReadOnlyList path, + ElkPositionedNode targetNode, + string currentSide) + { + var seen = new HashSet(StringComparer.Ordinal); + const double tolerance = 0.5d; + + if (path.Any(point => point.Y < targetNode.Y - tolerance) && seen.Add("top")) + { + yield return "top"; + } + + if (path.Any(point => point.Y > targetNode.Y + targetNode.Height + tolerance) && seen.Add("bottom")) + { + yield return "bottom"; + } + + if (seen.Add(currentSide)) + { + yield return currentSide; + } + } + + private static IEnumerable EnumerateRectTargetPeerConflictAxes( + IReadOnlyList path, + ElkPositionedNode targetNode, + string side, + double minLineClearance) + { + var coordinates = new List(); + var clearance = Math.Max(24d, minLineClearance * 0.6d); + const double tolerance = 0.5d; + + switch (side) + { + case "top": + foreach (var value in path + .Select(point => point.Y) + .Where(coordinate => coordinate < targetNode.Y - tolerance) + .OrderByDescending(coordinate => coordinate)) + { + AddUniqueCoordinate(coordinates, value); + } + + AddUniqueCoordinate(coordinates, targetNode.Y - clearance); + break; + + case "bottom": + foreach (var value in path + .Select(point => point.Y) + .Where(coordinate => coordinate > targetNode.Y + targetNode.Height + tolerance) + .OrderBy(coordinate => coordinate)) + { + AddUniqueCoordinate(coordinates, value); + } + + AddUniqueCoordinate(coordinates, targetNode.Y + targetNode.Height + clearance); + break; + + case "left": + foreach (var value in path + .Select(point => point.X) + .Where(coordinate => coordinate < targetNode.X - tolerance) + .OrderByDescending(coordinate => coordinate)) + { + AddUniqueCoordinate(coordinates, value); + } + + AddUniqueCoordinate(coordinates, targetNode.X - clearance); + break; + + case "right": + foreach (var value in path + .Select(point => point.X) + .Where(coordinate => coordinate > targetNode.X + targetNode.Width + tolerance) + .OrderBy(coordinate => coordinate)) + { + AddUniqueCoordinate(coordinates, value); + } + + AddUniqueCoordinate(coordinates, targetNode.X + targetNode.Width + clearance); + break; + } + + foreach (var coordinate in coordinates.Take(6)) + { + yield return coordinate; + } + } + + private static IEnumerable EnumerateRectTargetPeerConflictBoundaryCoordinates( + IReadOnlyList path, + ElkPositionedNode targetNode, + string side) + { + var coordinates = new List(); + var insetX = Math.Min(24d, Math.Max(8d, targetNode.Width / 4d)); + var insetY = Math.Min(24d, Math.Max(8d, targetNode.Height / 4d)); + + if (side is "top" or "bottom") + { + var referenceX = path.Count > 1 ? path[^2].X : path[^1].X; + AddUniqueCoordinate(coordinates, referenceX); + AddUniqueCoordinate(coordinates, targetNode.X + insetX); + AddUniqueCoordinate(coordinates, targetNode.X + (targetNode.Width / 2d)); + AddUniqueCoordinate(coordinates, targetNode.X + targetNode.Width - insetX); + + foreach (var coordinate in coordinates + .OrderBy(value => Math.Abs(Math.Clamp(value, targetNode.X + insetX, (targetNode.X + targetNode.Width) - insetX) - referenceX)) + .Take(6)) + { + yield return coordinate; + } + + yield break; + } + + var referenceY = path[^1].Y; + AddUniqueCoordinate(coordinates, referenceY); + AddUniqueCoordinate(coordinates, targetNode.Y + insetY); + AddUniqueCoordinate(coordinates, targetNode.Y + (targetNode.Height / 2d)); + AddUniqueCoordinate(coordinates, targetNode.Y + targetNode.Height - insetY); + + foreach (var coordinate in coordinates + .OrderBy(value => Math.Abs(Math.Clamp(value, targetNode.Y + insetY, (targetNode.Y + targetNode.Height) - insetY) - referenceY)) + .Take(6)) + { + yield return coordinate; + } + } + + private static double ComputeRectTargetPeerConflictPolishScore( + IReadOnlyList candidatePath, + string currentSide, + string candidateSide) + { + var score = ComputePathLength(candidatePath) + + (Math.Max(0, candidatePath.Count - 2) * 8d); + if (!string.Equals(currentSide, candidateSide, StringComparison.Ordinal)) + { + score += 12d; + } + + return score; + } + + internal static ElkRoutedEdge[] SeparateSharedLaneConflicts( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + double minLineClearance, + IReadOnlyCollection? restrictedEdgeIds = null) + { + if (edges.Length < 2 || nodes.Length == 0) + { + return edges; + } + + var result = edges.ToArray(); + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); + var nodeObstacles = nodes.Select(node => ( + Left: node.X, + Top: node.Y, + Right: node.X + node.Width, + Bottom: node.Y + node.Height, + Id: node.Id)).ToArray(); + + var conflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(result, nodes) + .Where(conflict => restrictedSet is null + || restrictedSet.Contains(conflict.LeftEdgeId) + || restrictedSet.Contains(conflict.RightEdgeId)) + .Distinct() + .ToArray(); + foreach (var conflict in conflicts) + { + var leftIndex = Array.FindIndex(result, edge => string.Equals(edge.Id, conflict.LeftEdgeId, StringComparison.Ordinal)); + var rightIndex = Array.FindIndex(result, edge => string.Equals(edge.Id, conflict.RightEdgeId, StringComparison.Ordinal)); + if (leftIndex < 0 || rightIndex < 0) + { + continue; + } + + var leftEdge = result[leftIndex]; + var rightEdge = result[rightIndex]; + if (TryResolveSharedLaneByPairedNodeHandoffSlotRepair( + result, + leftIndex, + leftEdge, + rightIndex, + rightEdge, + nodes, + minLineClearance, + graphMinY, + graphMaxY, + out var pairedLeftEdge, + out var pairedRightEdge)) + { + result[leftIndex] = pairedLeftEdge; + result[rightIndex] = pairedRightEdge; + continue; + } + + var repairOrder = new[] + { + (Index: ComputePathLength(ExtractFullPath(leftEdge)) >= ComputePathLength(ExtractFullPath(rightEdge)) ? leftIndex : rightIndex, + Other: ComputePathLength(ExtractFullPath(leftEdge)) >= ComputePathLength(ExtractFullPath(rightEdge)) ? rightEdge : leftEdge), + (Index: ComputePathLength(ExtractFullPath(leftEdge)) >= ComputePathLength(ExtractFullPath(rightEdge)) ? rightIndex : leftIndex, + Other: ComputePathLength(ExtractFullPath(leftEdge)) >= ComputePathLength(ExtractFullPath(rightEdge)) ? leftEdge : rightEdge), + }; + + foreach (var repairCandidate in repairOrder) + { + if (TryResolveSharedLaneByAlternateRepeatFace( + result[repairCandidate.Index], + repairCandidate.Other, + nodes, + minLineClearance, + graphMinY, + graphMaxY, + out var alternateFaceEdge)) + { + result[repairCandidate.Index] = alternateFaceEdge; + break; + } + + if (TryResolveSharedLaneByDirectSourceSlotRepair( + result, + repairCandidate.Index, + result[repairCandidate.Index], + repairCandidate.Other, + nodes, + minLineClearance, + graphMinY, + graphMaxY, + out var directSourceSlotEdge)) + { + result[repairCandidate.Index] = directSourceSlotEdge; + break; + } + + if (TryResolveSharedLaneByDirectNodeHandoffSlotRepair( + result, + repairCandidate.Index, + result[repairCandidate.Index], + repairCandidate.Other, + nodes, + minLineClearance, + graphMinY, + graphMaxY, + out var directNodeHandoffEdge)) + { + result[repairCandidate.Index] = directNodeHandoffEdge; + break; + } + + if (TryResolveSharedLaneByFocusedSourceDepartureSpread( + result, + repairCandidate.Index, + result[repairCandidate.Index], + repairCandidate.Other, + nodes, + minLineClearance, + graphMinY, + graphMaxY, + out var sourceSpreadEdge)) + { + result[repairCandidate.Index] = sourceSpreadEdge; + break; + } + + if (TryResolveSharedLaneByFocusedMixedNodeFaceRepair( + result, + repairCandidate.Index, + result[repairCandidate.Index], + repairCandidate.Other, + nodes, + minLineClearance, + graphMinY, + graphMaxY, + out var mixedFaceEdge)) + { + result[repairCandidate.Index] = mixedFaceEdge; + break; + } + + if (!TrySeparateSharedLaneConflict( + result[repairCandidate.Index], + repairCandidate.Other, + nodes, + minLineClearance, + graphMinY, + graphMaxY, + nodeObstacles, + out var repairedEdge)) + { + continue; + } + + result[repairCandidate.Index] = repairedEdge; + break; + } + } + + return result; + } + + private static bool TryResolveSharedLaneByPairedNodeHandoffSlotRepair( + ElkRoutedEdge[] currentEdges, + int leftIndex, + ElkRoutedEdge leftEdge, + int rightIndex, + ElkRoutedEdge rightEdge, + ElkPositionedNode[] nodes, + double minLineClearance, + double graphMinY, + double graphMaxY, + out ElkRoutedEdge repairedLeftEdge, + out ElkRoutedEdge repairedRightEdge) + { + repairedLeftEdge = leftEdge; + repairedRightEdge = rightEdge; + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + if (!TryResolveSharedLaneNodeHandoffContext(leftEdge, rightEdge, nodesById, graphMinY, graphMaxY, out var leftContext) + || !TryResolveSharedLaneNodeHandoffContext(rightEdge, leftEdge, nodesById, graphMinY, graphMaxY, out var rightContext) + || !string.Equals(leftContext.SharedNode.Id, rightContext.SharedNode.Id, StringComparison.Ordinal) + || !string.Equals(leftContext.Side, rightContext.Side, StringComparison.Ordinal) + || leftContext.IsOutgoing == rightContext.IsOutgoing) + { + return false; + } + + var baselineConflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(currentEdges, nodes); + var baselineConflictCount = baselineConflicts.Count; + var baselineLeftConflictCount = baselineConflicts.Count(conflict => + string.Equals(conflict.LeftEdgeId, leftEdge.Id, StringComparison.Ordinal) + || string.Equals(conflict.RightEdgeId, leftEdge.Id, StringComparison.Ordinal)); + var baselineRightConflictCount = baselineConflicts.Count(conflict => + string.Equals(conflict.LeftEdgeId, rightEdge.Id, StringComparison.Ordinal) + || string.Equals(conflict.RightEdgeId, rightEdge.Id, StringComparison.Ordinal)); + var baselineCombinedPathLength = ComputePathLength(leftContext.Path) + ComputePathLength(rightContext.Path); + + var peerCoordinates = CollectSharedLaneNodeFaceBoundaryCoordinates( + currentEdges, + leftContext.SharedNode, + leftContext.Side, + graphMinY, + graphMaxY, + leftEdge.Id); + var leftRepairCoordinates = EnumerateSharedLaneBoundaryRepairCoordinates( + leftContext.SharedNode, + leftContext.Side, + leftContext.CurrentBoundaryCoordinate, + minLineClearance, + peerCoordinates) + .ToArray(); + var rightRepairCoordinates = EnumerateSharedLaneBoundaryRepairCoordinates( + rightContext.SharedNode, + rightContext.Side, + rightContext.CurrentBoundaryCoordinate, + minLineClearance, + peerCoordinates) + .ToArray(); + + ElkRoutedEdge? bestLeft = null; + ElkRoutedEdge? bestRight = null; + var bestConflictCount = baselineConflictCount; + var bestLeftConflictCount = baselineLeftConflictCount; + var bestRightConflictCount = baselineRightConflictCount; + var bestCombinedPathLength = baselineCombinedPathLength; + + foreach (var leftCoordinate in leftRepairCoordinates) + { + var leftCandidatePath = leftContext.IsOutgoing + ? BuildMixedSourceFaceCandidate(leftContext.Path, leftContext.SharedNode, leftContext.Side, leftCoordinate, leftContext.AxisValue) + : BuildMixedTargetFaceCandidate(leftContext.Path, leftContext.SharedNode, leftContext.Side, leftCoordinate, leftContext.AxisValue); + if (!IsValidSharedLaneBoundaryRepairCandidate( + leftEdge, + leftContext.Path, + leftCandidatePath, + leftContext.SharedNode, + leftContext.IsOutgoing, + nodes, + graphMinY, + graphMaxY)) + { + continue; + } + + foreach (var rightCoordinate in rightRepairCoordinates) + { + var rightCandidatePath = rightContext.IsOutgoing + ? BuildMixedSourceFaceCandidate(rightContext.Path, rightContext.SharedNode, rightContext.Side, rightCoordinate, rightContext.AxisValue) + : BuildMixedTargetFaceCandidate(rightContext.Path, rightContext.SharedNode, rightContext.Side, rightCoordinate, rightContext.AxisValue); + if (!IsValidSharedLaneBoundaryRepairCandidate( + rightEdge, + rightContext.Path, + rightCandidatePath, + rightContext.SharedNode, + rightContext.IsOutgoing, + nodes, + graphMinY, + graphMaxY)) + { + continue; + } + + var candidateLeft = BuildSingleSectionEdge(leftEdge, leftCandidatePath); + var candidateRight = BuildSingleSectionEdge(rightEdge, rightCandidatePath); + if (ElkEdgeRoutingScoring.DetectSharedLaneConflicts([candidateLeft, candidateRight], nodes).Count > 0 + || ComputeUnderNodeRepairLocalHardPressure(candidateLeft, nodes) > ComputeUnderNodeRepairLocalHardPressure(leftEdge, nodes) + || ComputeUnderNodeRepairLocalHardPressure(candidateRight, nodes) > ComputeUnderNodeRepairLocalHardPressure(rightEdge, nodes)) + { + continue; + } + + var candidateEdges = currentEdges.ToArray(); + candidateEdges[leftIndex] = candidateLeft; + candidateEdges[rightIndex] = candidateRight; + var candidateConflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(candidateEdges, nodes); + var candidateConflictCount = candidateConflicts.Count; + var candidateLeftConflictCount = candidateConflicts.Count(conflict => + string.Equals(conflict.LeftEdgeId, leftEdge.Id, StringComparison.Ordinal) + || string.Equals(conflict.RightEdgeId, leftEdge.Id, StringComparison.Ordinal)); + var candidateRightConflictCount = candidateConflicts.Count(conflict => + string.Equals(conflict.LeftEdgeId, rightEdge.Id, StringComparison.Ordinal) + || string.Equals(conflict.RightEdgeId, rightEdge.Id, StringComparison.Ordinal)); + if (candidateConflictCount > bestConflictCount + || candidateLeftConflictCount > bestLeftConflictCount + || candidateRightConflictCount > bestRightConflictCount) + { + continue; + } + + var candidateCombinedPathLength = ComputePathLength(leftCandidatePath) + ComputePathLength(rightCandidatePath); + var isBetter = + candidateConflictCount < bestConflictCount + || candidateLeftConflictCount < bestLeftConflictCount + || candidateRightConflictCount < bestRightConflictCount + || candidateCombinedPathLength + 0.5d < bestCombinedPathLength; + if (!isBetter) + { + continue; + } + + bestLeft = candidateLeft; + bestRight = candidateRight; + bestConflictCount = candidateConflictCount; + bestLeftConflictCount = candidateLeftConflictCount; + bestRightConflictCount = candidateRightConflictCount; + bestCombinedPathLength = candidateCombinedPathLength; + } + } + + if (bestLeft is null || bestRight is null || bestConflictCount >= baselineConflictCount) + { + return false; + } + + repairedLeftEdge = bestLeft; + repairedRightEdge = bestRight; + return true; + } + + private static bool TryResolveSharedLaneByDirectSourceSlotRepair( + ElkRoutedEdge[] currentEdges, + int repairIndex, + ElkRoutedEdge edge, + ElkRoutedEdge otherEdge, + ElkPositionedNode[] nodes, + double minLineClearance, + double graphMinY, + double graphMaxY, + out ElkRoutedEdge repairedEdge) + { + repairedEdge = edge; + if (string.IsNullOrWhiteSpace(edge.SourceNodeId) + || !string.Equals(edge.SourceNodeId, otherEdge.SourceNodeId, StringComparison.Ordinal)) + { + return false; + } + + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + if (!nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)) + { + return false; + } + + var path = ExtractFullPath(edge); + var otherPath = ExtractFullPath(otherEdge); + if (path.Count < 2 + || otherPath.Count < 2 + || !ShouldSpreadSourceDeparture(edge, graphMinY, graphMaxY) + || !ShouldSpreadSourceDeparture(otherEdge, graphMinY, graphMaxY)) + { + return false; + } + + var side = ResolveSourceDepartureSide(path, sourceNode); + var otherSide = ResolveSourceDepartureSide(otherPath, sourceNode); + if (!string.Equals(side, otherSide, StringComparison.Ordinal)) + { + return false; + } + + var axisValue = TryExtractSourceDepartureRun(path, side, out _, out var runEndIndex) + ? side is "left" or "right" + ? path[runEndIndex].X + : path[runEndIndex].Y + : ResolveDefaultSourceDepartureAxis(sourceNode, side); + var currentBoundaryCoordinate = side is "left" or "right" ? path[0].Y : path[0].X; + var peerCoordinates = CollectSharedLaneSourceBoundaryCoordinates( + currentEdges, + sourceNode, + side, + graphMinY, + graphMaxY, + edge.Id); + + foreach (var desiredCoordinate in EnumerateSharedLaneBoundaryRepairCoordinates( + sourceNode, + side, + currentBoundaryCoordinate, + minLineClearance, + peerCoordinates)) + { + var candidatePath = BuildMixedSourceFaceCandidate(path, sourceNode, side, desiredCoordinate, axisValue); + if (!IsValidSharedLaneBoundaryRepairCandidate( + edge, + path, + candidatePath, + sourceNode, + isOutgoing: true, + nodes, + graphMinY, + graphMaxY)) + { + continue; + } + + var candidateEdges = currentEdges.ToArray(); + candidateEdges[repairIndex] = BuildSingleSectionEdge(edge, candidatePath); + if (TryAcceptFocusedSharedLanePairRepair( + currentEdges, + candidateEdges, + repairIndex, + edge, + otherEdge, + nodes, + graphMinY, + graphMaxY, + out repairedEdge)) + { + return true; + } + } + + return false; + } + + private static bool TryResolveSharedLaneByDirectNodeHandoffSlotRepair( + ElkRoutedEdge[] currentEdges, + int repairIndex, + ElkRoutedEdge edge, + ElkRoutedEdge otherEdge, + ElkPositionedNode[] nodes, + double minLineClearance, + double graphMinY, + double graphMaxY, + out ElkRoutedEdge repairedEdge) + { + repairedEdge = edge; + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + if (!TryResolveSharedLaneNodeHandoffContext(edge, otherEdge, nodesById, graphMinY, graphMaxY, out var context)) + { + return false; + } + + var peerCoordinates = CollectSharedLaneNodeFaceBoundaryCoordinates( + currentEdges, + context.SharedNode, + context.Side, + graphMinY, + graphMaxY, + edge.Id); + + foreach (var desiredCoordinate in EnumerateSharedLaneBoundaryRepairCoordinates( + context.SharedNode, + context.Side, + context.CurrentBoundaryCoordinate, + minLineClearance, + peerCoordinates)) + { + var candidatePath = context.IsOutgoing + ? BuildMixedSourceFaceCandidate(context.Path, context.SharedNode, context.Side, desiredCoordinate, context.AxisValue) + : BuildMixedTargetFaceCandidate(context.Path, context.SharedNode, context.Side, desiredCoordinate, context.AxisValue); + if (!IsValidSharedLaneBoundaryRepairCandidate( + edge, + context.Path, + candidatePath, + context.SharedNode, + context.IsOutgoing, + nodes, + graphMinY, + graphMaxY)) + { + continue; + } + + var candidateEdges = currentEdges.ToArray(); + candidateEdges[repairIndex] = BuildSingleSectionEdge(edge, candidatePath); + if (TryAcceptFocusedSharedLanePairRepair( + currentEdges, + candidateEdges, + repairIndex, + edge, + otherEdge, + nodes, + graphMinY, + graphMaxY, + out repairedEdge)) + { + return true; + } + } + + return false; + } + + private static bool TryResolveSharedLaneByFocusedSourceDepartureSpread( + ElkRoutedEdge[] currentEdges, + int repairIndex, + ElkRoutedEdge edge, + ElkRoutedEdge otherEdge, + ElkPositionedNode[] nodes, + double minLineClearance, + double graphMinY, + double graphMaxY, + out ElkRoutedEdge repairedEdge) + { + repairedEdge = edge; + if (string.IsNullOrWhiteSpace(edge.SourceNodeId) + || !string.Equals(edge.SourceNodeId, otherEdge.SourceNodeId, StringComparison.Ordinal)) + { + return false; + } + + var focusedIds = new[] { edge.Id, otherEdge.Id }; + var candidateEdges = SpreadSourceDepartureJoins(currentEdges, nodes, minLineClearance, focusedIds); + return TryAcceptFocusedSharedLanePairRepair( + currentEdges, + candidateEdges, + repairIndex, + edge, + otherEdge, + nodes, + graphMinY, + graphMaxY, + out repairedEdge); + } + + private static bool TryResolveSharedLaneByFocusedMixedNodeFaceRepair( + ElkRoutedEdge[] currentEdges, + int repairIndex, + ElkRoutedEdge edge, + ElkRoutedEdge otherEdge, + ElkPositionedNode[] nodes, + double minLineClearance, + double graphMinY, + double graphMaxY, + out ElkRoutedEdge repairedEdge) + { + repairedEdge = edge; + var sharesIncomingOutgoingNode = + (!string.IsNullOrWhiteSpace(edge.TargetNodeId) + && string.Equals(edge.TargetNodeId, otherEdge.SourceNodeId, StringComparison.Ordinal)) + || (!string.IsNullOrWhiteSpace(edge.SourceNodeId) + && string.Equals(edge.SourceNodeId, otherEdge.TargetNodeId, StringComparison.Ordinal)); + if (!sharesIncomingOutgoingNode) + { + return false; + } + + var focusedIds = new[] { edge.Id, otherEdge.Id }; + var candidateEdges = SeparateMixedNodeFaceLaneConflicts(currentEdges, nodes, minLineClearance, focusedIds); + return TryAcceptFocusedSharedLanePairRepair( + currentEdges, + candidateEdges, + repairIndex, + edge, + otherEdge, + nodes, + graphMinY, + graphMaxY, + out repairedEdge); + } + + private static bool TryAcceptFocusedSharedLanePairRepair( + ElkRoutedEdge[] currentEdges, + ElkRoutedEdge[] candidateEdges, + int repairIndex, + ElkRoutedEdge edge, + ElkRoutedEdge otherEdge, + ElkPositionedNode[] nodes, + double graphMinY, + double graphMaxY, + out ElkRoutedEdge repairedEdge) + { + repairedEdge = edge; + if (repairIndex < 0 || repairIndex >= candidateEdges.Length) + { + return false; + } + + var candidateEdge = candidateEdges[repairIndex]; + var currentPath = ExtractFullPath(edge); + var candidatePath = ExtractFullPath(candidateEdge); + if (!PathChanged(currentPath, candidatePath) + || HasNodeObstacleCrossing(candidatePath, nodes, edge.SourceNodeId, edge.TargetNodeId) + || SegmentLeavesGraphBand(candidatePath, graphMinY, graphMaxY)) + { + return false; + } + + var currentSharedLaneConflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(currentEdges, nodes); + var candidateSharedLaneConflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(candidateEdges, nodes); + var currentSharedLaneCount = currentSharedLaneConflicts.Count; + var candidateSharedLaneCount = candidateSharedLaneConflicts.Count; + var currentEdgeSharedLaneCount = currentSharedLaneConflicts.Count(conflict => + string.Equals(conflict.LeftEdgeId, edge.Id, StringComparison.Ordinal) + || string.Equals(conflict.RightEdgeId, edge.Id, StringComparison.Ordinal)); + var candidateEdgeSharedLaneCount = candidateSharedLaneConflicts.Count(conflict => + string.Equals(conflict.LeftEdgeId, candidateEdge.Id, StringComparison.Ordinal) + || string.Equals(conflict.RightEdgeId, candidateEdge.Id, StringComparison.Ordinal)); + if (candidateSharedLaneCount > currentSharedLaneCount + || candidateEdgeSharedLaneCount >= currentEdgeSharedLaneCount + || ElkEdgeRoutingScoring.DetectSharedLaneConflicts([candidateEdge, otherEdge], nodes).Count > 0 + || ComputeUnderNodeRepairLocalHardPressure(candidateEdge, nodes) > ComputeUnderNodeRepairLocalHardPressure(edge, nodes)) + { + return false; + } + + repairedEdge = candidateEdge; + return true; + } + + private static IReadOnlyList CollectSharedLaneSourceBoundaryCoordinates( + IReadOnlyCollection edges, + ElkPositionedNode sourceNode, + string side, + double graphMinY, + double graphMaxY, + string excludeEdgeId) + { + var coordinates = new List(); + foreach (var peerEdge in edges) + { + if (string.Equals(peerEdge.Id, excludeEdgeId, StringComparison.Ordinal) + || !string.Equals(peerEdge.SourceNodeId, sourceNode.Id, StringComparison.Ordinal) + || !ShouldSpreadSourceDeparture(peerEdge, graphMinY, graphMaxY)) + { + continue; + } + + var peerPath = ExtractFullPath(peerEdge); + if (peerPath.Count < 2) + { + continue; + } + + var peerSide = ResolveSourceDepartureSide(peerPath, sourceNode); + if (!string.Equals(peerSide, side, StringComparison.Ordinal)) + { + continue; + } + + AddUniqueCoordinate(coordinates, side is "left" or "right" ? peerPath[0].Y : peerPath[0].X); + } + + return coordinates + .OrderBy(value => value) + .ToArray(); + } + + private static IReadOnlyList CollectSharedLaneNodeFaceBoundaryCoordinates( + IReadOnlyCollection edges, + ElkPositionedNode node, + string side, + double graphMinY, + double graphMaxY, + string excludeEdgeId) + { + var coordinates = new List(); + foreach (var peerEdge in edges) + { + if (string.Equals(peerEdge.Id, excludeEdgeId, StringComparison.Ordinal)) + { + continue; + } + + var peerPath = ExtractFullPath(peerEdge); + if (peerPath.Count < 2) + { + continue; + } + + if (string.Equals(peerEdge.SourceNodeId, node.Id, StringComparison.Ordinal) + && ShouldSpreadSourceDeparture(peerEdge, graphMinY, graphMaxY)) + { + var peerSide = ResolveSourceDepartureSide(peerPath, node); + if (string.Equals(peerSide, side, StringComparison.Ordinal)) + { + AddUniqueCoordinate(coordinates, side is "left" or "right" ? peerPath[0].Y : peerPath[0].X); + } + } + + if (string.Equals(peerEdge.TargetNodeId, node.Id, StringComparison.Ordinal) + && ShouldSpreadTargetApproach(peerEdge, graphMinY, graphMaxY)) + { + var peerSide = ResolveTargetApproachSide(peerPath, node); + if (string.Equals(peerSide, side, StringComparison.Ordinal)) + { + AddUniqueCoordinate(coordinates, side is "left" or "right" ? peerPath[^1].Y : peerPath[^1].X); + } + } + } + + return coordinates + .OrderBy(value => value) + .ToArray(); + } + + private static IEnumerable EnumerateSharedLaneBoundaryRepairCoordinates( + ElkPositionedNode node, + string side, + double currentCoordinate, + double minLineClearance, + IReadOnlyList peerCoordinates) + { + var minSlot = side is "left" or "right" ? node.Y + 4d : node.X + 4d; + var maxSlot = side is "left" or "right" ? node.Y + node.Height - 4d : node.X + node.Width - 4d; + if (maxSlot <= minSlot + 0.5d) + { + yield break; + } + + var sideLength = side is "left" or "right" + ? Math.Max(8d, node.Height - 8d) + : Math.Max(8d, node.Width - 8d); + var spacing = ResolveBoundaryJoinSlotSpacing( + minLineClearance, + sideLength, + Math.Max(2, peerCoordinates.Count + 1)); + // Direct pair repairs do not need full group-spacing. Trying a closer + // escape slot first keeps the edge away from the face corners and avoids + // manufacturing new boundary-angle / join defects while still clearing + // the shared-lane tolerance band. + var repairSpacing = Math.Max( + 12d, + Math.Min( + spacing, + Math.Min(28d, minLineClearance * 0.45d))); + var candidates = new List(); + var sortedPeers = peerCoordinates + .OrderBy(value => value) + .ToArray(); + + AddUniqueCoordinate(candidates, Math.Max(minSlot, Math.Min(maxSlot, currentCoordinate - repairSpacing))); + AddUniqueCoordinate(candidates, Math.Max(minSlot, Math.Min(maxSlot, currentCoordinate + repairSpacing))); + AddUniqueCoordinate(candidates, Math.Max(minSlot, Math.Min(maxSlot, currentCoordinate - spacing))); + AddUniqueCoordinate(candidates, Math.Max(minSlot, Math.Min(maxSlot, currentCoordinate + spacing))); + AddUniqueCoordinate(candidates, minSlot); + AddUniqueCoordinate(candidates, maxSlot); + + foreach (var peerCoordinate in sortedPeers) + { + AddUniqueCoordinate(candidates, Math.Max(minSlot, Math.Min(maxSlot, peerCoordinate - repairSpacing))); + AddUniqueCoordinate(candidates, Math.Max(minSlot, Math.Min(maxSlot, peerCoordinate + repairSpacing))); + AddUniqueCoordinate(candidates, Math.Max(minSlot, Math.Min(maxSlot, peerCoordinate - spacing))); + AddUniqueCoordinate(candidates, Math.Max(minSlot, Math.Min(maxSlot, peerCoordinate + spacing))); + } + + for (var i = 0; i < sortedPeers.Length - 1; i++) + { + var midpoint = (sortedPeers[i] + sortedPeers[i + 1]) / 2d; + AddUniqueCoordinate(candidates, Math.Max(minSlot, Math.Min(maxSlot, midpoint))); + } + + var laneTolerance = Math.Max(4d, Math.Min(12d, minLineClearance * 0.2d)); + foreach (var coordinate in candidates + .Where(value => Math.Abs(value - currentCoordinate) > 0.5d) + .Select(value => new + { + Value = value, + TooCloseToPeer = sortedPeers.Any(peer => Math.Abs(peer - value) <= laneTolerance + 0.5d), + }) + .OrderBy(item => item.TooCloseToPeer ? 1 : 0) + .ThenBy(item => Math.Abs(item.Value - currentCoordinate)) + .ThenBy(item => item.Value)) + { + yield return coordinate.Value; + } + } + + private static void AddUniquePathCandidate( + ICollection> candidates, + IReadOnlyList candidate) + { + if (candidates.Any(existing => + existing.Count == candidate.Count + && existing.Zip(candidate, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal))) + { + return; + } + + candidates.Add(candidate); + } + + private static bool IsBetterMixedNodeFaceCandidate( + int candidateSharedLaneViolations, + int candidateTargetJoinViolations, + int candidateBoundaryAngleViolations, + int candidateGatewaySourceExitViolations, + int candidateUnderNodeViolations, + double candidatePathLength, + int currentSharedLaneViolations, + int currentTargetJoinViolations, + int currentBoundaryAngleViolations, + int currentGatewaySourceExitViolations, + int currentUnderNodeViolations, + double currentPathLength) + { + if (candidateSharedLaneViolations != currentSharedLaneViolations) + { + return candidateSharedLaneViolations < currentSharedLaneViolations; + } + + if (candidateTargetJoinViolations != currentTargetJoinViolations) + { + return candidateTargetJoinViolations < currentTargetJoinViolations; + } + + if (candidateBoundaryAngleViolations != currentBoundaryAngleViolations) + { + return candidateBoundaryAngleViolations < currentBoundaryAngleViolations; + } + + if (candidateGatewaySourceExitViolations != currentGatewaySourceExitViolations) + { + return candidateGatewaySourceExitViolations < currentGatewaySourceExitViolations; + } + + if (candidateUnderNodeViolations != currentUnderNodeViolations) + { + return candidateUnderNodeViolations < currentUnderNodeViolations; + } + + return candidatePathLength + 0.5d < currentPathLength; + } + + private static bool TryResolveSharedLaneNodeHandoffContext( + ElkRoutedEdge edge, + ElkRoutedEdge otherEdge, + IReadOnlyDictionary nodesById, + double graphMinY, + double graphMaxY, + out (ElkPositionedNode SharedNode, string Side, bool IsOutgoing, IReadOnlyList Path, double CurrentBoundaryCoordinate, double AxisValue) context) + { + context = default; + var path = ExtractFullPath(edge); + var otherPath = ExtractFullPath(otherEdge); + if (path.Count < 2 || otherPath.Count < 2) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(edge.TargetNodeId) + && string.Equals(edge.TargetNodeId, otherEdge.SourceNodeId, StringComparison.Ordinal) + && nodesById.TryGetValue(edge.TargetNodeId, out var incomingTargetNode) + && ShouldSpreadTargetApproach(edge, graphMinY, graphMaxY) + && ShouldSpreadSourceDeparture(otherEdge, graphMinY, graphMaxY)) + { + var incomingSide = ResolveTargetApproachSide(path, incomingTargetNode); + var outgoingSide = ResolveSourceDepartureSide(otherPath, incomingTargetNode); + if (string.Equals(incomingSide, outgoingSide, StringComparison.Ordinal)) + { + var axisValue = ResolveTargetApproachAxisValue(path, incomingSide); + if (double.IsNaN(axisValue)) + { + axisValue = ResolveDefaultTargetApproachAxis(incomingTargetNode, incomingSide); + } + + context = ( + incomingTargetNode, + incomingSide, + IsOutgoing: false, + path, + incomingSide is "left" or "right" ? path[^1].Y : path[^1].X, + axisValue); + return true; + } + } + + if (!string.IsNullOrWhiteSpace(edge.SourceNodeId) + && string.Equals(edge.SourceNodeId, otherEdge.TargetNodeId, StringComparison.Ordinal) + && nodesById.TryGetValue(edge.SourceNodeId, out var outgoingSourceNode) + && ShouldSpreadSourceDeparture(edge, graphMinY, graphMaxY) + && ShouldSpreadTargetApproach(otherEdge, graphMinY, graphMaxY)) + { + var outgoingSide = ResolveSourceDepartureSide(path, outgoingSourceNode); + var incomingSide = ResolveTargetApproachSide(otherPath, outgoingSourceNode); + if (string.Equals(outgoingSide, incomingSide, StringComparison.Ordinal)) + { + var axisValue = TryExtractSourceDepartureRun(path, outgoingSide, out _, out var runEndIndex) + ? outgoingSide is "left" or "right" + ? path[runEndIndex].X + : path[runEndIndex].Y + : ResolveDefaultSourceDepartureAxis(outgoingSourceNode, outgoingSide); + + context = ( + outgoingSourceNode, + outgoingSide, + IsOutgoing: true, + path, + outgoingSide is "left" or "right" ? path[0].Y : path[0].X, + axisValue); + return true; + } + } + + return false; + } + + private static bool IsValidSharedLaneBoundaryRepairCandidate( + ElkRoutedEdge edge, + IReadOnlyList currentPath, + IReadOnlyList candidatePath, + ElkPositionedNode node, + bool isOutgoing, + IReadOnlyCollection nodes, + double graphMinY, + double graphMaxY) + { + if (!PathChanged(currentPath, candidatePath) + || HasNodeObstacleCrossing(candidatePath, nodes, edge.SourceNodeId, edge.TargetNodeId) + || SegmentLeavesGraphBand(candidatePath, graphMinY, graphMaxY)) + { + return false; + } + + if (isOutgoing) + { + if (ElkShapeBoundaries.IsGatewayShape(node)) + { + return HasAcceptableGatewayBoundaryPath(candidatePath, nodes, edge.SourceNodeId, edge.TargetNodeId, node, fromStart: true); + } + + return HasClearBoundarySegments(candidatePath, nodes, edge.SourceNodeId, edge.TargetNodeId, true, 2) + && HasValidBoundaryAngle(candidatePath[0], candidatePath[1], node); + } + + if (ElkShapeBoundaries.IsGatewayShape(node)) + { + return CanAcceptGatewayTargetRepair(candidatePath, node) + && HasAcceptableGatewayBoundaryPath(candidatePath, nodes, edge.SourceNodeId, edge.TargetNodeId, node, fromStart: false); + } + + return HasClearBoundarySegments(candidatePath, nodes, edge.SourceNodeId, edge.TargetNodeId, false, 4) + && HasValidBoundaryAngle(candidatePath[^1], candidatePath[^2], node) + && !HasTargetApproachBacktracking(candidatePath, node); + } + + private static bool TryResolveSharedLaneByAlternateRepeatFace( + ElkRoutedEdge edge, + ElkRoutedEdge otherEdge, + ElkPositionedNode[] nodes, + double minLineClearance, + double graphMinY, + double graphMaxY, + out ElkRoutedEdge repairedEdge) + { + repairedEdge = edge; + if (!IsRepeatCollectorLabel(edge.Label)) + { + return false; + } + + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + if (!nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode) + || !nodesById.TryGetValue(otherEdge.SourceNodeId ?? string.Empty, out var sourceNode) + || !string.Equals(targetNode.Id, sourceNode.Id, StringComparison.Ordinal) + || ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + return false; + } + + var path = ExtractFullPath(edge); + var otherPath = ExtractFullPath(otherEdge); + if (path.Count < 2 || otherPath.Count < 2) + { + return false; + } + + var incomingSide = ResolveTargetApproachSide(path, targetNode); + var outgoingSide = ResolveSourceDepartureSide(otherPath, sourceNode); + if (!string.Equals(incomingSide, outgoingSide, StringComparison.Ordinal)) + { + return false; + } + + var axisValue = ResolveTargetApproachAxisValue(path, incomingSide); + if (double.IsNaN(axisValue)) + { + axisValue = incomingSide is "left" or "right" ? path[^1].Y : path[^1].X; + } + + var incomingEntry = ( + Index: 0, + Edge: edge, + Path: (IReadOnlyList)path, + Node: targetNode, + Side: incomingSide, + IsOutgoing: false, + Boundary: path[^1], + BoundaryCoordinate: incomingSide is "left" or "right" ? path[^1].Y : path[^1].X, + AxisValue: axisValue); + if (!TryBuildAlternateMixedFaceCandidate(incomingEntry, nodes, minLineClearance, out var candidate) + || !PathChanged(path, candidate) + || HasNodeObstacleCrossing(candidate, nodes, edge.SourceNodeId, edge.TargetNodeId) + || SegmentLeavesGraphBand(candidate, graphMinY, graphMaxY) + || !HasClearBoundarySegments(candidate, nodes, edge.SourceNodeId, edge.TargetNodeId, false, 4) + || !HasValidBoundaryAngle(candidate[^1], candidate[^2], targetNode)) + { + return false; + } + + repairedEdge = BuildSingleSectionEdge(edge, candidate); + var repairedPath = ExtractFullPath(repairedEdge); + if (!HasNodeObstacleCrossing(repairedPath, nodes, edge.SourceNodeId, edge.TargetNodeId) + && !SegmentLeavesGraphBand(repairedPath, graphMinY, graphMaxY) + && ElkEdgeRoutingScoring.DetectSharedLaneConflicts([repairedEdge, otherEdge], nodes).Count == 0) + { + return true; + } + + repairedEdge = RepairBoundaryAnglesAndTargetApproaches( + [repairedEdge], + nodes, + minLineClearance)[0]; + repairedEdge = NormalizeSourceExitAngles([repairedEdge], nodes)[0]; + repairedPath = ExtractFullPath(repairedEdge); + if (HasNodeObstacleCrossing(repairedPath, nodes, edge.SourceNodeId, edge.TargetNodeId) + || SegmentLeavesGraphBand(repairedPath, graphMinY, graphMaxY) + || ElkEdgeRoutingScoring.DetectSharedLaneConflicts([repairedEdge, otherEdge], nodes).Count > 0) + { + repairedEdge = edge; + return false; + } + + return true; + } + + internal static ElkRoutedEdge[] FinalizeGatewayBoundaryGeometry( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + IReadOnlyCollection? restrictedEdgeIds = null) + { + if (edges.Length == 0 || nodes.Length == 0) + { + return edges; + } + + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); + var result = new ElkRoutedEdge[edges.Length]; + + for (var i = 0; i < edges.Length; i++) + { + var edge = edges[i]; + if (restrictedSet is not null && !restrictedSet.Contains(edge.Id)) + { + result[i] = edge; + continue; + } + + var path = ExtractFullPath(edge); + if (path.Count < 2) + { + result[i] = edge; + continue; + } + + var normalized = path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + var changed = false; + + var preserveSourceExit = ShouldPreserveSourceExitGeometry(edge, graphMinY, graphMaxY); + + if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode) + && ElkShapeBoundaries.IsGatewayShape(sourceNode) + && NeedsGatewaySourceBoundaryRepair(normalized, sourceNode)) + { + var sourceRepaired = preserveSourceExit + ? RepairProtectedGatewaySourceBoundaryPath(normalized, sourceNode, graphMinY, graphMaxY) + : RepairGatewaySourceBoundaryPath( + normalized, + sourceNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId); + if (PathChanged(normalized, sourceRepaired) + && HasAcceptableGatewayBoundaryPath(sourceRepaired, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true)) + { + normalized = sourceRepaired; + changed = true; + } + } + + if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode) + && ElkShapeBoundaries.IsGatewayShape(targetNode) + && NeedsGatewayTargetBoundaryRepair(normalized, targetNode)) + { + var targetRepaired = NormalizeGatewayEntryPath(normalized, targetNode, normalized[^1]); + if (PathChanged(normalized, targetRepaired) + && CanAcceptGatewayTargetRepair(targetRepaired, targetNode)) + { + normalized = targetRepaired; + changed = true; + } + } + + if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out sourceNode) + && ElkShapeBoundaries.IsGatewayShape(sourceNode) + && NeedsGatewaySourceBoundaryRepair(normalized, sourceNode)) + { + var sourceRepaired = preserveSourceExit + ? RepairProtectedGatewaySourceBoundaryPath(normalized, sourceNode, graphMinY, graphMaxY) + : RepairGatewaySourceBoundaryPath( + normalized, + sourceNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId); + if (PathChanged(normalized, sourceRepaired) + && HasAcceptableGatewayBoundaryPath(sourceRepaired, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true)) + { + normalized = sourceRepaired; + changed = true; + } + } + + if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out targetNode) + && !ElkShapeBoundaries.IsGatewayShape(targetNode) + && normalized.Count >= 2 + && !HasValidBoundaryAngle(normalized[^1], normalized[^2], targetNode)) + { + var targetSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[^1], targetNode); + var targetRepaired = NormalizeEntryPath(normalized, targetNode, targetSide); + if (HasClearBoundarySegments(targetRepaired, nodes, edge.SourceNodeId, edge.TargetNodeId, false, 3)) + { + normalized = targetRepaired; + changed = true; + } + } + + if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out targetNode) + && !ElkShapeBoundaries.IsGatewayShape(targetNode) + && HasTargetApproachBacktracking(normalized, targetNode) + && TryNormalizeNonGatewayBacktrackingEntry(normalized, targetNode, out var backtrackingRepair)) + { + normalized = backtrackingRepair; + changed = true; + } + + if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out targetNode) + && ElkShapeBoundaries.IsGatewayShape(targetNode) + && NeedsGatewayTargetBoundaryRepair(normalized, targetNode)) + { + var targetRepaired = NormalizeGatewayEntryPath(normalized, targetNode, normalized[^1]); + if (PathChanged(normalized, targetRepaired) + && CanAcceptGatewayTargetRepair(targetRepaired, targetNode)) + { + normalized = targetRepaired; + changed = true; + } + } + + if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out sourceNode) + && ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + if (preserveSourceExit) + { + var protectedExitFixed = TryBuildProtectedGatewaySourcePath( + normalized, + sourceNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId); + if (PathChanged(normalized, protectedExitFixed) + && HasAcceptableGatewayBoundaryPath(protectedExitFixed, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true) + && !HasGatewaySourceExitBacktracking(protectedExitFixed) + && !HasGatewaySourceExitCurl(protectedExitFixed)) + { + normalized = protectedExitFixed; + changed = true; + } + } + else + { + var directExitFixed = TryBuildDirectGatewaySourcePath( + normalized, + sourceNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId); + if (PathChanged(normalized, directExitFixed) + && HasAcceptableGatewayBoundaryPath(directExitFixed, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true) + && !HasGatewaySourceExitBacktracking(directExitFixed) + && !HasGatewaySourceExitCurl(directExitFixed) + && !HasGatewaySourceDominantAxisDetour(directExitFixed, sourceNode) + && !HasGatewaySourcePreferredFaceMismatch(directExitFixed, sourceNode)) + { + normalized = directExitFixed; + changed = true; + } + + if (sourceNode.Kind == "Decision") + { + var diagonalExitFixed = ForceDecisionDiagonalSourceExit( + normalized, + sourceNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId); + if (PathChanged(normalized, diagonalExitFixed) + && HasAcceptableGatewayBoundaryPath(diagonalExitFixed, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true) + && HasClearBoundarySegments(diagonalExitFixed, nodes, edge.SourceNodeId, edge.TargetNodeId, true, Math.Min(4, diagonalExitFixed.Count - 1)) + && !HasGatewaySourceExitBacktracking(diagonalExitFixed) + && !HasGatewaySourceExitCurl(diagonalExitFixed)) + { + normalized = diagonalExitFixed; + changed = true; + } + } + + var faceFixed = FixGatewaySourcePreferredFace(normalized, sourceNode); + if (PathChanged(normalized, faceFixed) + && HasAcceptableGatewayBoundaryPath(faceFixed, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true)) + { + normalized = faceFixed; + changed = true; + } + + var curlFixed = FixGatewaySourceExitCurl(normalized, sourceNode); + if (PathChanged(normalized, curlFixed) + && HasAcceptableGatewayBoundaryPath(curlFixed, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true) + && !HasGatewaySourceExitCurl(curlFixed)) + { + normalized = curlFixed; + changed = true; + } + + var dominantAxisFixed = FixGatewaySourceDominantAxisDetour(normalized, sourceNode); + if (PathChanged(normalized, dominantAxisFixed) + && HasAcceptableGatewayBoundaryPath(dominantAxisFixed, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true)) + { + normalized = dominantAxisFixed; + changed = true; + } + + if (HasGatewaySourcePreferredFaceMismatch(normalized, sourceNode)) + { + var forceAligned = ForceGatewaySourcePreferredFaceAlignment(normalized, sourceNode); + if (PathChanged(normalized, forceAligned) + && HasAcceptableGatewayBoundaryPath(forceAligned, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true)) + { + normalized = forceAligned; + changed = true; + } + } + + var finalDirectExit = TryBuildDirectGatewaySourcePath( + normalized, + sourceNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId); + if (PathChanged(normalized, finalDirectExit) + && HasAcceptableGatewayBoundaryPath(finalDirectExit, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true) + && !HasGatewaySourceExitBacktracking(finalDirectExit) + && !HasGatewaySourceExitCurl(finalDirectExit) + && !HasGatewaySourceDominantAxisDetour(finalDirectExit, sourceNode) + && !HasGatewaySourcePreferredFaceMismatch(finalDirectExit, sourceNode)) + { + normalized = finalDirectExit; + changed = true; + } + } + } + + if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out targetNode) + && !ElkShapeBoundaries.IsGatewayShape(targetNode) + && normalized.Count >= 2 + && !HasValidBoundaryAngle(normalized[^1], normalized[^2], targetNode)) + { + var targetSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[^1], targetNode); + var targetRepaired = NormalizeEntryPath(normalized, targetNode, targetSide); + if (HasClearBoundarySegments(targetRepaired, nodes, edge.SourceNodeId, edge.TargetNodeId, false, 3)) + { + normalized = targetRepaired; + changed = true; + } + } + + if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out targetNode) + && ElkShapeBoundaries.IsGatewayShape(targetNode) + && normalized.Count >= 2 + && !HasValidBoundaryAngle(normalized[^1], normalized[^2], targetNode)) + { + var lateTargetRepair = NormalizeGatewayEntryPath(normalized, targetNode, normalized[^1]); + if (PathChanged(normalized, lateTargetRepair) + && CanAcceptGatewayTargetRepair(lateTargetRepair, targetNode)) + { + normalized = lateTargetRepair; + changed = true; + } + else if (targetNode.Kind == "Decision") + { + var directDecisionRepair = ForceDecisionDirectTargetEntry(normalized, targetNode); + if (PathChanged(normalized, directDecisionRepair) + && CanAcceptGatewayTargetRepair(directDecisionRepair, targetNode)) + { + normalized = directDecisionRepair; + changed = true; + } + } + } + + if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out targetNode) + && ElkShapeBoundaries.IsGatewayShape(targetNode) + && normalized.Count >= 2 + && !HasValidBoundaryAngle(normalized[^1], normalized[^2], targetNode)) + { + var forcedGatewayStub = ForceGatewayTargetBoundaryStub(normalized, targetNode); + if (PathChanged(normalized, forcedGatewayStub) + && CanAcceptGatewayTargetRepair(forcedGatewayStub, targetNode)) + { + normalized = forcedGatewayStub; + changed = true; + } + } + + if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out sourceNode) + && ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + var lateSourceRepaired = RepairGatewaySourceBoundaryPath( + normalized, + sourceNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId); + if (PathChanged(normalized, lateSourceRepaired) + && HasAcceptableGatewayBoundaryPath(lateSourceRepaired, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true) + && !HasGatewaySourceExitBacktracking(lateSourceRepaired) + && !HasGatewaySourceExitCurl(lateSourceRepaired) + && !HasGatewaySourceDominantAxisDetour(lateSourceRepaired, sourceNode) + && !HasGatewaySourcePreferredFaceMismatch(lateSourceRepaired, sourceNode)) + { + normalized = lateSourceRepaired; + changed = true; + } + } + + if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out targetNode)) + { + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + if (NeedsGatewayTargetBoundaryRepair(normalized, targetNode)) + { + var finalGatewayTargetRepair = NormalizeGatewayEntryPath(normalized, targetNode, normalized[^1]); + if (PathChanged(normalized, finalGatewayTargetRepair) + && CanAcceptGatewayTargetRepair(finalGatewayTargetRepair, targetNode)) + { + normalized = finalGatewayTargetRepair; + changed = true; + } + else if (targetNode.Kind == "Decision") + { + var directDecisionRepair = ForceDecisionDirectTargetEntry(normalized, targetNode); + if (PathChanged(normalized, directDecisionRepair) + && CanAcceptGatewayTargetRepair(directDecisionRepair, targetNode)) + { + normalized = directDecisionRepair; + changed = true; + } + } + } + } + else if (normalized.Count >= 2) + { + if (!HasValidBoundaryAngle(normalized[^1], normalized[^2], targetNode)) + { + var finalTargetSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[^1], targetNode); + var finalTargetRepair = NormalizeEntryPath(normalized, targetNode, finalTargetSide); + if (HasClearBoundarySegments(finalTargetRepair, nodes, edge.SourceNodeId, edge.TargetNodeId, false, 3)) + { + normalized = finalTargetRepair; + changed = true; + } + } + + if (HasTargetApproachBacktracking(normalized, targetNode) + && TryNormalizeNonGatewayBacktrackingEntry(normalized, targetNode, out var finalBacktrackingRepair) + && HasClearBoundarySegments(finalBacktrackingRepair, nodes, edge.SourceNodeId, edge.TargetNodeId, false, 3) + && HasValidBoundaryAngle(finalBacktrackingRepair[^1], finalBacktrackingRepair[^2], targetNode)) + { + normalized = finalBacktrackingRepair; + changed = true; + } + } + } + + if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out sourceNode) + && ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + var finalSourceRepair = EnforceGatewaySourceExitQuality( + normalized, + sourceNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId); + if (PathChanged(normalized, finalSourceRepair)) + { + normalized = finalSourceRepair; + changed = true; + } + } + + if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out sourceNode) + && ElkShapeBoundaries.IsGatewayShape(sourceNode) + && nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out targetNode) + && !ElkShapeBoundaries.IsGatewayShape(targetNode) + && normalized.Count >= 2 + && TryRealignNonGatewayTargetBoundarySlot( + normalized, + targetNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId, + out var finalSourceDrivenTargetRepair) + && PathChanged(normalized, finalSourceDrivenTargetRepair)) + { + normalized = finalSourceDrivenTargetRepair; + changed = true; + } + + result[i] = changed + ? BuildSingleSectionEdge(edge, normalized) + : edge; + } + + return result; + } + + private static List FixGatewaySourcePreferredFace( + IReadOnlyList sourcePath, + ElkPositionedNode sourceNode) + { + if (!HasGatewaySourcePreferredFaceMismatch(sourcePath, sourceNode)) + { + return sourcePath.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + var path = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + var firstExteriorIndex = FindFirstGatewayExteriorPointIndex(path, sourceNode); + var continuationIndex = FindGatewaySourceCurlRecoveryIndex(path, firstExteriorIndex) + ?? FindPreferredGatewayExitContinuationIndex(path, sourceNode, firstExteriorIndex); + var continuationPoint = path[continuationIndex]; + if (!TryResolvePreferredGatewaySourceBoundary(sourceNode, continuationPoint, path[^1], out var preferredBoundary)) + { + return path; + } + + return BuildGatewaySourceRepairPath( + path, + sourceNode, + preferredBoundary, + continuationPoint, + continuationIndex, + path[^1]); + } + + private static bool HasGatewaySourcePreferredFaceMismatch( + IReadOnlyList path, + ElkPositionedNode sourceNode) + { + if (!ElkShapeBoundaries.IsGatewayShape(sourceNode) || path.Count < 2) + { + return false; + } + + var centerX = sourceNode.X + (sourceNode.Width / 2d); + var centerY = sourceNode.Y + (sourceNode.Height / 2d); + var desiredDx = path[^1].X - centerX; + var desiredDy = path[^1].Y - centerY; + var boundaryDx = path[0].X - centerX; + var boundaryDy = path[0].Y - centerY; + + if (Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.25d) + { + return Math.Sign(boundaryDx) != Math.Sign(desiredDx) + || Math.Abs(boundaryDy) > sourceNode.Height * 0.28d; + } + + if (Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.25d) + { + return Math.Sign(boundaryDy) != Math.Sign(desiredDy) + || Math.Abs(boundaryDx) > sourceNode.Width * 0.28d; + } + + return false; + } + + private static List FixGatewaySourceExitCurl( + IReadOnlyList sourcePath, + ElkPositionedNode sourceNode) + { + if (!ElkShapeBoundaries.IsGatewayShape(sourceNode) || sourcePath.Count < 3) + { + return sourcePath.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + var sample = sourcePath + .Take(Math.Min(sourcePath.Count, 6)) + .ToArray(); + var desiredDx = sourcePath[^1].X - sourcePath[0].X; + var desiredDy = sourcePath[^1].Y - sourcePath[0].Y; + if (!HasGatewaySourceExitCurl(sample)) + { + return sourcePath.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + var path = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + var firstExteriorIndex = FindFirstGatewayExteriorPointIndex(path, sourceNode); + var continuationIndex = FindGatewaySourceCurlRecoveryIndex(path, firstExteriorIndex) + ?? FindPreferredGatewayExitContinuationIndex(path, sourceNode, firstExteriorIndex); + var continuationPoint = path[continuationIndex]; + var boundary = sourceNode.Kind == "Decision" + ? ResolveDecisionSourceExitBoundary(sourceNode, continuationPoint, continuationPoint) + : ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary( + sourceNode, + ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, continuationPoint), + continuationPoint); + var continuationAligned = BuildGatewaySourceRepairPath( + path, + sourceNode, + boundary, + continuationPoint, + continuationIndex, + continuationPoint); + if (PathChanged(path, continuationAligned) + && !HasGatewaySourceExitBacktracking(continuationAligned) + && !HasGatewaySourceExitCurl(continuationAligned)) + { + return continuationAligned; + } + + var collapsedCurl = TryBuildGatewaySourceDominantAxisShortcut(path, sourceNode, path[0]); + if (collapsedCurl is not null + && PathChanged(path, collapsedCurl) + && !HasGatewaySourceExitBacktracking(collapsedCurl) + && !HasGatewaySourceExitCurl(collapsedCurl)) + { + return collapsedCurl; + } + + const double axisTolerance = 4d; + var rebuilt = path; + var dominantHorizontal = Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.15d && Math.Sign(desiredDx) != 0; + var dominantVertical = Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.15d && Math.Sign(desiredDy) != 0; + + if (dominantHorizontal && Math.Abs(rebuilt[1].Y - rebuilt[0].Y) <= axisTolerance) + { + rebuilt[1] = new ElkPoint + { + X = rebuilt[1].X, + Y = rebuilt[0].Y, + }; + } + else if (dominantVertical && Math.Abs(rebuilt[1].X - rebuilt[0].X) <= axisTolerance) + { + rebuilt[1] = new ElkPoint + { + X = rebuilt[0].X, + Y = rebuilt[1].Y, + }; + } + + return NormalizePathPoints(rebuilt); + } + + private static List FixGatewaySourceDominantAxisDetour( + IReadOnlyList sourcePath, + ElkPositionedNode sourceNode) + { + var path = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (!HasGatewaySourceDominantAxisDetour(path, sourceNode)) + { + return path; + } + + var boundary = path[0]; + var desiredDx = path[^1].X - boundary.X; + var desiredDy = path[^1].Y - boundary.Y; + var dominantHorizontal = Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.15d && Math.Sign(desiredDx) != 0; + var dominantVertical = Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.15d && Math.Sign(desiredDy) != 0; + var firstExteriorIndex = FindFirstGatewayExteriorPointIndex(path, sourceNode); + var boundaryReferencePoint = path[firstExteriorIndex]; + if (!TryResolvePreferredGatewaySourceBoundary(sourceNode, boundaryReferencePoint, path[^1], out var preferredBoundary)) + { + return path; + } + + var localContinuationPoint = path[firstExteriorIndex]; + var localRepair = new List { preferredBoundary }; + if (!ElkEdgeRoutingGeometry.PointsEqual(localRepair[^1], localContinuationPoint)) + { + AppendGatewayOrthogonalCorner( + localRepair, + localRepair[^1], + localContinuationPoint, + firstExteriorIndex + 1 < path.Count ? path[firstExteriorIndex + 1] : null, + preferHorizontalFromReference: ShouldPreferHorizontalGatewayExit(localRepair[^1], localContinuationPoint)); + if (!ElkEdgeRoutingGeometry.PointsEqual(localRepair[^1], localContinuationPoint)) + { + localRepair.Add(localContinuationPoint); + } + } + + for (var i = firstExteriorIndex + 1; i < path.Count; i++) + { + localRepair.Add(path[i]); + } + + localRepair = NormalizePathPoints(localRepair); + if (PathChanged(path, localRepair) + && !HasGatewaySourceExitBacktracking(localRepair) + && !HasGatewaySourceExitCurl(localRepair) + && !HasGatewaySourceDominantAxisDetour(localRepair, sourceNode) + && !HasGatewaySourcePreferredFaceMismatch(localRepair, sourceNode)) + { + return localRepair; + } + + var dominantAxisShortcut = TryBuildGatewaySourceDominantAxisShortcut(path, sourceNode, preferredBoundary); + if (dominantAxisShortcut is not null + && PathChanged(path, dominantAxisShortcut) + && !HasGatewaySourceExitBacktracking(dominantAxisShortcut) + && !HasGatewaySourceExitCurl(dominantAxisShortcut) + && !HasGatewaySourceDominantAxisDetour(dominantAxisShortcut, sourceNode) + && !HasGatewaySourcePreferredFaceMismatch(dominantAxisShortcut, sourceNode)) + { + return dominantAxisShortcut; + } + + var preferredContinuationIndex = FindPreferredGatewayExitContinuationIndex(path, sourceNode, firstExteriorIndex); + var candidateContinuationIndices = new[] + { + firstExteriorIndex, + Math.Min(path.Count - 1, firstExteriorIndex + 1), + Math.Min(path.Count - 1, firstExteriorIndex + 2), + preferredContinuationIndex, + } + .Distinct() + .Where(index => index >= firstExteriorIndex && index < path.Count) + .ToArray(); + + List? bestCandidate = null; + var bestScore = double.PositiveInfinity; + foreach (var continuationIndex in candidateContinuationIndices) + { + var continuationCandidates = new List + { + path[continuationIndex], + }; + if (dominantHorizontal) + { + AddUniquePoint( + continuationCandidates, + new ElkPoint + { + X = path[continuationIndex].X, + Y = preferredBoundary.Y, + }); + } + else if (dominantVertical) + { + AddUniquePoint( + continuationCandidates, + new ElkPoint + { + X = preferredBoundary.X, + Y = path[continuationIndex].Y, + }); + } + + foreach (var continuationPoint in continuationCandidates) + { + var candidate = BuildGatewaySourceRepairPath( + path, + sourceNode, + preferredBoundary, + continuationPoint, + continuationIndex, + continuationPoint); + if (!PathChanged(path, candidate)) + { + continue; + } + + var score = ComputePathLength(candidate); + if (!ElkEdgeRoutingGeometry.PointsEqual(continuationPoint, path[continuationIndex])) + { + score -= 18d; + } + + if (HasGatewaySourceExitBacktracking(candidate) + || HasGatewaySourceExitCurl(candidate)) + { + score += 100_000d; + } + + if (HasGatewaySourceDominantAxisDetour(candidate, sourceNode)) + { + score += 50_000d; + } + + if (HasGatewaySourcePreferredFaceMismatch(candidate, sourceNode)) + { + score += 25_000d; + } + + if (score >= bestScore) + { + continue; + } + + bestScore = score; + bestCandidate = candidate; + } + } + + return bestCandidate ?? path; + } + + private static List? TryBuildGatewaySourceDominantAxisShortcut( + IReadOnlyList path, + ElkPositionedNode sourceNode, + ElkPoint preferredBoundary) + { + if (path.Count < 4) + { + return null; + } + + var desiredDx = path[^1].X - preferredBoundary.X; + var desiredDy = path[^1].Y - preferredBoundary.Y; + var dominantHorizontal = Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.15d && Math.Sign(desiredDx) != 0; + var dominantVertical = Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.15d && Math.Sign(desiredDy) != 0; + if (!dominantHorizontal && !dominantVertical) + { + return null; + } + + List? bestCandidate = null; + var bestLength = double.PositiveInfinity; + var maxShortcutIndex = Math.Min(path.Count - 2, 3); + for (var shortcutIndex = 1; shortcutIndex <= maxShortcutIndex; shortcutIndex++) + { + var shortcutAnchor = path[shortcutIndex]; + var shortcutPoint = dominantHorizontal + ? new ElkPoint { X = shortcutAnchor.X, Y = preferredBoundary.Y } + : new ElkPoint { X = preferredBoundary.X, Y = shortcutAnchor.Y }; + if (ElkEdgeRoutingGeometry.PointsEqual(preferredBoundary, shortcutPoint)) + { + continue; + } + + var candidate = new List { preferredBoundary, shortcutPoint }; + for (var i = shortcutIndex + 1; i < path.Count; i++) + { + candidate.Add(path[i]); + } + + candidate = NormalizePathPoints(candidate); + if (HasGatewaySourceExitBacktracking(candidate) + || HasGatewaySourceExitCurl(candidate) + || HasGatewaySourceDominantAxisDetour(candidate, sourceNode) + || HasGatewaySourcePreferredFaceMismatch(candidate, sourceNode)) + { + continue; + } + + var length = ComputePathLength(candidate); + if (length >= bestLength) + { + continue; + } + + bestLength = length; + bestCandidate = candidate; + } + + return bestCandidate; + } + + private static bool HasGatewaySourceDominantAxisDetour( + IReadOnlyList path, + ElkPositionedNode sourceNode) + { + if (!ElkShapeBoundaries.IsGatewayShape(sourceNode) || path.Count < 3) + { + return false; + } + + const double coordinateTolerance = 0.5d; + var centerX = sourceNode.X + (sourceNode.Width / 2d); + var centerY = sourceNode.Y + (sourceNode.Height / 2d); + var desiredDx = path[^1].X - centerX; + var desiredDy = path[^1].Y - centerY; + var dominantHorizontal = Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.25d && Math.Sign(desiredDx) != 0; + var dominantVertical = Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.25d && Math.Sign(desiredDy) != 0; + if (!dominantHorizontal && !dominantVertical) + { + return false; + } + + var boundary = path[0]; + var adjacent = path[1]; + var firstDx = adjacent.X - boundary.X; + var firstDy = adjacent.Y - boundary.Y; + if (dominantHorizontal) + { + if (Math.Sign(firstDx) != Math.Sign(desiredDx) || Math.Abs(firstDx) <= coordinateTolerance) + { + return true; + } + + if (Math.Abs(firstDy) > Math.Max(12d, Math.Abs(firstDx) + 6d)) + { + return true; + } + + return Math.Abs(firstDy) > Math.Max(24d, Math.Abs(desiredDy) + 12d) + && Math.Abs(firstDy) > Math.Abs(firstDx) * 1.25d; + } + + if (Math.Sign(firstDy) != Math.Sign(desiredDy) || Math.Abs(firstDy) <= coordinateTolerance) + { + return true; + } + + if (Math.Abs(firstDx) > Math.Max(12d, Math.Abs(firstDy) + 6d)) + { + return true; + } + + return Math.Abs(firstDx) > Math.Max(24d, Math.Abs(desiredDx) + 12d) + && Math.Abs(firstDx) > Math.Abs(firstDy) * 1.25d; + } + + private static List ForceGatewaySourcePreferredFaceAlignment( + IReadOnlyList sourcePath, + ElkPositionedNode sourceNode) + { + var path = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (!ElkShapeBoundaries.IsGatewayShape(sourceNode) || path.Count < 2) + { + return path; + } + + var centerX = sourceNode.X + (sourceNode.Width / 2d); + var centerY = sourceNode.Y + (sourceNode.Height / 2d); + var desiredDx = path[^1].X - centerX; + var desiredDy = path[^1].Y - centerY; + var dominantHorizontal = Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.25d && Math.Sign(desiredDx) != 0; + var dominantVertical = Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.25d && Math.Sign(desiredDy) != 0; + if (!dominantHorizontal && !dominantVertical) + { + return path; + } + + var preferredSide = dominantHorizontal + ? desiredDx >= 0d ? "right" : "left" + : desiredDy >= 0d ? "bottom" : "top"; + var slotCoordinate = dominantHorizontal + ? centerY + Math.Clamp(path[^1].Y - centerY, -sourceNode.Height * 0.18d, sourceNode.Height * 0.18d) + : centerX + Math.Clamp(path[^1].X - centerX, -sourceNode.Width * 0.18d, sourceNode.Width * 0.18d); + if (!ElkShapeBoundaries.TryProjectGatewayBoundarySlot(sourceNode, preferredSide, slotCoordinate, out var preferredBoundary)) + { + return path; + } + + preferredBoundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(sourceNode, preferredBoundary, path[^1]); + var firstExteriorIndex = FindFirstGatewayExteriorPointIndex(path, sourceNode); + var continuationPoint = path[firstExteriorIndex]; + var adjacentPoint = ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, preferredBoundary, continuationPoint); + + if (dominantHorizontal) + { + var candidateX = continuationPoint.X; + if (Math.Sign(candidateX - preferredBoundary.X) != Math.Sign(desiredDx) + || Math.Abs(candidateX - preferredBoundary.X) <= 0.5d) + { + candidateX = desiredDx >= 0d + ? sourceNode.X + sourceNode.Width + 8d + : sourceNode.X - 8d; + } + + adjacentPoint = new ElkPoint + { + X = candidateX, + Y = preferredBoundary.Y, + }; + } + else if (dominantVertical) + { + var candidateY = continuationPoint.Y; + if (Math.Sign(candidateY - preferredBoundary.Y) != Math.Sign(desiredDy) + || Math.Abs(candidateY - preferredBoundary.Y) <= 0.5d) + { + candidateY = desiredDy >= 0d + ? sourceNode.Y + sourceNode.Height + 8d + : sourceNode.Y - 8d; + } + + adjacentPoint = new ElkPoint + { + X = preferredBoundary.X, + Y = candidateY, + }; + } + + if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(sourceNode, adjacentPoint)) + { + adjacentPoint = ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, preferredBoundary, continuationPoint); + } + + var rebuilt = new List { preferredBoundary }; + if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], adjacentPoint)) + { + rebuilt.Add(adjacentPoint); + } + + if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], continuationPoint)) + { + AppendGatewayOrthogonalCorner( + rebuilt, + rebuilt[^1], + continuationPoint, + firstExteriorIndex + 1 < path.Count ? path[firstExteriorIndex + 1] : null, + preferHorizontalFromReference: ShouldPreferHorizontalGatewayExit(rebuilt[^1], continuationPoint)); + if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], continuationPoint)) + { + rebuilt.Add(continuationPoint); + } + } + + for (var i = firstExteriorIndex + 1; i < path.Count; i++) + { + rebuilt.Add(path[i]); + } + + return NormalizePathPoints(rebuilt); + } + internal static bool IsRepeatCollectorLabel(string? label) { if (string.IsNullOrWhiteSpace(label)) @@ -534,6 +5451,26 @@ internal static class ElkEdgePostProcessor || normalized.Equals("body", StringComparison.Ordinal); } + private static bool ShouldPreserveSourceExitGeometry( + ElkRoutedEdge edge, + double graphMinY, + double graphMaxY) + { + if (HasProtectedUnderNodeGeometry(edge)) + { + return true; + } + + if (!HasCorridorBendPoints(edge, graphMinY, graphMaxY)) + { + return false; + } + + return IsRepeatCollectorLabel(edge.Label) + || (!string.IsNullOrWhiteSpace(edge.Kind) + && edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase)); + } + internal static bool IsCorridorSegment(ElkPoint p1, ElkPoint p2, double graphMinY, double graphMaxY) { return p1.Y < graphMinY - 8d || p1.Y > graphMaxY + 8d @@ -632,24 +5569,25 @@ internal static class ElkEdgePostProcessor path.RemoveAt(1); } + var anchor = path[1]; + var boundaryPoint = BuildRectBoundaryPointForSide(sourceNode, side, anchor); var rebuilt = new List { - new() { X = sourceX, Y = path[0].Y }, + new() { X = sourceX, Y = boundaryPoint.Y }, }; - var anchor = path[1]; var stubX = side == "left" - ? sourceX - 24d - : sourceX + 24d; + ? Math.Min(sourceX - 24d, anchor.X) + : Math.Max(sourceX + 24d, anchor.X); if (Math.Abs(stubX - sourceX) > coordinateTolerance) { rebuilt.Add(new ElkPoint { X = stubX, - Y = path[0].Y, + Y = boundaryPoint.Y, }); } - if (Math.Abs(anchor.Y - path[0].Y) > coordinateTolerance) + if (Math.Abs(anchor.Y - boundaryPoint.Y) > coordinateTolerance) { rebuilt.Add(new ElkPoint { X = stubX, Y = anchor.Y }); } @@ -671,24 +5609,25 @@ internal static class ElkEdgePostProcessor path.RemoveAt(1); } + var verticalAnchor = path[1]; + var verticalBoundaryPoint = BuildRectBoundaryPointForSide(sourceNode, side, verticalAnchor); var verticalRebuilt = new List { - new() { X = path[0].X, Y = sourceY }, + new() { X = verticalBoundaryPoint.X, Y = sourceY }, }; - var verticalAnchor = path[1]; var stubY = side == "top" - ? sourceY - 24d - : sourceY + 24d; + ? Math.Min(sourceY - 24d, verticalAnchor.Y) + : Math.Max(sourceY + 24d, verticalAnchor.Y); if (Math.Abs(stubY - sourceY) > coordinateTolerance) { verticalRebuilt.Add(new ElkPoint { - X = path[0].X, + X = verticalBoundaryPoint.X, Y = stubY, }); } - if (Math.Abs(verticalAnchor.X - path[0].X) > coordinateTolerance) + if (Math.Abs(verticalAnchor.X - verticalBoundaryPoint.X) > coordinateTolerance) { verticalRebuilt.Add(new ElkPoint { X = verticalAnchor.X, Y = stubY }); } @@ -725,18 +5664,25 @@ internal static class ElkEdgePostProcessor return path; } + if (HasTargetApproachBacktracking(path, targetNode) + && TryResolveNonGatewayBacktrackingEndpoint(path, targetNode, out var correctedSide, out var correctedEndpoint)) + { + side = correctedSide; + explicitEndpoint = correctedEndpoint; + } + if (side is "left" or "right") { var targetX = side == "left" ? targetNode.X : targetNode.X + targetNode.Width; - var endpoint = explicitEndpoint ?? new ElkPoint { X = targetX, Y = path[^1].Y }; while (path.Count >= 3 && Math.Abs(path[^2].X - targetX) <= coordinateTolerance) { path.RemoveAt(path.Count - 2); } var anchor = path[^2]; + var endpoint = explicitEndpoint ?? BuildRectBoundaryPointForSide(targetNode, side, anchor); var rebuilt = path.Take(path.Count - 2).ToList(); if (rebuilt.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], anchor)) { @@ -763,13 +5709,13 @@ internal static class ElkEdgePostProcessor var targetY = side == "top" ? targetNode.Y : targetNode.Y + targetNode.Height; - var verticalEndpoint = explicitEndpoint ?? new ElkPoint { X = path[^1].X, Y = targetY }; while (path.Count >= 3 && Math.Abs(path[^2].Y - targetY) <= coordinateTolerance) { path.RemoveAt(path.Count - 2); } var verticalAnchor = path[^2]; + var verticalEndpoint = explicitEndpoint ?? BuildRectBoundaryPointForSide(targetNode, side, verticalAnchor); var verticalRebuilt = path.Take(path.Count - 2).ToList(); if (verticalRebuilt.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(verticalRebuilt[^1], verticalAnchor)) { @@ -793,6 +5739,3783 @@ internal static class ElkEdgePostProcessor return NormalizePathPoints(verticalRebuilt); } + private static string ResolvePreferredRectSourceExitSide( + IReadOnlyList path, + ElkPositionedNode sourceNode) + { + var currentSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(path[0], sourceNode); + if (path.Count < 2) + { + return currentSide; + } + + var overallDeltaX = path[^1].X - path[0].X; + var overallDeltaY = path[^1].Y - path[0].Y; + var overallAbsDx = Math.Abs(overallDeltaX); + var overallAbsDy = Math.Abs(overallDeltaY); + var sameRowThreshold = Math.Max(24d, sourceNode.Height / 3d); + var sameColumnThreshold = Math.Max(24d, sourceNode.Width / 3d); + if (overallAbsDx >= overallAbsDy * 1.15d + && overallAbsDy <= sameRowThreshold + && Math.Sign(overallDeltaX) != 0) + { + return overallDeltaX > 0d ? "right" : "left"; + } + + if (overallAbsDy >= overallAbsDx * 1.15d + && overallAbsDx <= sameColumnThreshold + && Math.Sign(overallDeltaY) != 0) + { + return overallDeltaY > 0d ? "bottom" : "top"; + } + + if (HasValidBoundaryAngle(path[0], path[1], sourceNode)) + { + return currentSide; + } + + var deltaX = path[1].X - path[0].X; + var deltaY = path[1].Y - path[0].Y; + var absDx = Math.Abs(deltaX); + var absDy = Math.Abs(deltaY); + if (absDx >= absDy * 1.15d && Math.Sign(deltaX) != 0) + { + return deltaX > 0d ? "right" : "left"; + } + + if (absDy >= absDx * 1.15d && Math.Sign(deltaY) != 0) + { + return deltaY > 0d ? "bottom" : "top"; + } + + return currentSide; + } + + private static string ResolvePreferredRectTargetEntrySide( + IReadOnlyList path, + ElkPositionedNode targetNode) + { + var currentSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(path[^1], targetNode); + if (path.Count < 2) + { + return currentSide; + } + + var overallDeltaX = path[^1].X - path[0].X; + var overallDeltaY = path[^1].Y - path[0].Y; + var overallAbsDx = Math.Abs(overallDeltaX); + var overallAbsDy = Math.Abs(overallDeltaY); + var sameRowThreshold = Math.Max(24d, targetNode.Height / 3d); + var sameColumnThreshold = Math.Max(24d, targetNode.Width / 3d); + if (overallAbsDx >= overallAbsDy * 1.15d + && overallAbsDy <= sameRowThreshold + && Math.Sign(overallDeltaX) != 0) + { + return overallDeltaX > 0d ? "left" : "right"; + } + + if (overallAbsDy >= overallAbsDx * 1.15d + && overallAbsDx <= sameColumnThreshold + && Math.Sign(overallDeltaY) != 0) + { + return overallDeltaY > 0d ? "top" : "bottom"; + } + + if (HasValidBoundaryAngle(path[^1], path[^2], targetNode)) + { + return currentSide; + } + + var deltaX = path[^1].X - path[^2].X; + var deltaY = path[^1].Y - path[^2].Y; + var absDx = Math.Abs(deltaX); + var absDy = Math.Abs(deltaY); + if (absDx >= absDy * 1.15d && Math.Sign(deltaX) != 0) + { + return deltaX > 0d ? "left" : "right"; + } + + if (absDy >= absDx * 1.15d && Math.Sign(deltaY) != 0) + { + return deltaY > 0d ? "top" : "bottom"; + } + + return currentSide; + } + + private static ElkPoint BuildRectBoundaryPointForSide( + ElkPositionedNode node, + string side, + ElkPoint referencePoint) + { + var insetX = Math.Min(24d, Math.Max(8d, node.Width / 4d)); + var insetY = Math.Min(24d, Math.Max(8d, node.Height / 4d)); + return side switch + { + "left" => new ElkPoint + { + X = node.X, + Y = Math.Clamp(referencePoint.Y, node.Y + insetY, (node.Y + node.Height) - insetY), + }, + "right" => new ElkPoint + { + X = node.X + node.Width, + Y = Math.Clamp(referencePoint.Y, node.Y + insetY, (node.Y + node.Height) - insetY), + }, + "top" => new ElkPoint + { + X = Math.Clamp(referencePoint.X, node.X + insetX, (node.X + node.Width) - insetX), + Y = node.Y, + }, + "bottom" => new ElkPoint + { + X = Math.Clamp(referencePoint.X, node.X + insetX, (node.X + node.Width) - insetX), + Y = node.Y + node.Height, + }, + _ => ElkShapeBoundaries.ProjectOntoShapeBoundary(node, referencePoint), + }; + } + + private static List NormalizeGatewayExitPath( + IReadOnlyList sourcePath, + ElkPositionedNode sourceNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId) + { + var path = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (path.Count < 2) + { + return path; + } + + var firstExteriorIndex = FindFirstGatewayExteriorPointIndex(path, sourceNode); + var firstContinuationIndex = FindPreferredGatewayExitContinuationIndex(path, sourceNode, firstExteriorIndex); + var forceLocalExitRepair = path.Count > 2 && ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(sourceNode, path[1]); + var minContinuationIndex = forceLocalExitRepair + ? firstExteriorIndex + : firstContinuationIndex; + var maxContinuationIndex = forceLocalExitRepair + ? Math.Min(path.Count - 1, firstExteriorIndex + 1) + : path.Count - 1; + var exitReferences = CollectGatewayExitReferencePoints(path, nodes, targetNodeId, firstContinuationIndex); + List? bestCandidate = null; + var bestScore = double.PositiveInfinity; + + foreach (var exitReference in exitReferences) + { + foreach (var boundary in ResolveGatewayExitBoundaryCandidates(sourceNode, exitReference)) + { + for (var continuationIndex = minContinuationIndex; continuationIndex <= maxContinuationIndex; continuationIndex++) + { + if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(sourceNode, path[continuationIndex])) + { + continue; + } + + var continuationReference = path[continuationIndex]; + foreach (var exteriorApproach in ResolveGatewayExteriorApproachCandidates(sourceNode, boundary, continuationReference)) + { + var candidate = BuildGatewayExitCandidate(path, boundary, exteriorApproach, continuationIndex); + if (!HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true) + || !HasClearBoundarySegments(candidate, nodes, sourceNodeId, targetNodeId, true, Math.Min(4, candidate.Count - 1)) + || HasGatewaySourceExitBacktracking(candidate) + || HasGatewaySourceExitCurl(candidate)) + { + continue; + } + + var score = ScoreGatewayExitCandidate(candidate, exitReference, continuationIndex, path, sourceNode); + if (score >= bestScore) + { + continue; + } + + bestScore = score; + bestCandidate = candidate; + } + } + } + } + + if (bestCandidate is not null) + { + return ForceDecisionDiagonalSourceExit(bestCandidate, sourceNode, nodes, sourceNodeId, targetNodeId); + } + + var exteriorAnchor = path[firstContinuationIndex]; + var fallbackReference = exitReferences[0]; + var fallbackBoundaryCandidates = ResolveGatewayExitBoundaryCandidates(sourceNode, fallbackReference).ToArray(); + if (fallbackBoundaryCandidates.Length == 0) + { + fallbackBoundaryCandidates = + [ + ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary( + sourceNode, + ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, fallbackReference), + fallbackReference), + ]; + } + + List? fallbackPath = null; + var fallbackScore = double.PositiveInfinity; + foreach (var fallbackBoundary in fallbackBoundaryCandidates) + { + var candidate = BuildGatewayFallbackExitPath(path, sourceNode, fallbackBoundary, exteriorAnchor, firstContinuationIndex); + var candidateScore = ScoreGatewayExitCandidate(candidate, fallbackReference, firstContinuationIndex, path, sourceNode); + if (!HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true)) + { + candidateScore += 5_000d; + } + + if (candidateScore >= fallbackScore) + { + continue; + } + + fallbackScore = candidateScore; + fallbackPath = candidate; + } + + if (fallbackPath is not null) + { + return ForceDecisionDiagonalSourceExit(fallbackPath, sourceNode, nodes, sourceNodeId, targetNodeId); + } + + if (sourceNode.Kind == "Decision" + && path.Count >= 3 + && ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(sourceNode, path[1])) + { + var continuationIndex = Math.Max(firstExteriorIndex, Math.Min(path.Count - 1, 2)); + var continuationPoint = path[continuationIndex]; + var directBoundary = ResolveDecisionSourceExitBoundary(sourceNode, continuationPoint, path[^1]); + var directApproach = ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, directBoundary, continuationPoint); + var directRepair = BuildGatewayExitCandidate(path, directBoundary, directApproach, continuationIndex); + if (HasAcceptableGatewayBoundaryPath(directRepair, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true) + && HasClearBoundarySegments(directRepair, nodes, sourceNodeId, targetNodeId, true, Math.Min(4, directRepair.Count - 1)) + && !HasGatewaySourceExitBacktracking(directRepair) + && !HasGatewaySourceExitCurl(directRepair)) + { + return ForceDecisionDiagonalSourceExit(directRepair, sourceNode, nodes, sourceNodeId, targetNodeId); + } + } + + return ForceDecisionDiagonalSourceExit(path, sourceNode, nodes, sourceNodeId, targetNodeId); + } + + private static List CollectGatewayExitReferencePoints( + IReadOnlyList path, + IReadOnlyCollection nodes, + string? targetNodeId, + int firstContinuationIndex) + { + var references = new List(); + if (!string.IsNullOrWhiteSpace(targetNodeId)) + { + var targetNode = nodes.FirstOrDefault(node => string.Equals(node.Id, targetNodeId, StringComparison.Ordinal)); + if (targetNode is not null) + { + AddUniquePoint(references, new ElkPoint + { + X = targetNode.X + (targetNode.Width / 2d), + Y = targetNode.Y + (targetNode.Height / 2d), + }); + } + } + + var maxReferenceIndex = Math.Min(path.Count - 1, firstContinuationIndex + 4); + for (var i = firstContinuationIndex; i <= maxReferenceIndex; i++) + { + AddUniquePoint(references, path[i]); + } + + AddUniquePoint(references, path[^1]); + if (references.Count == 0) + { + references.Add(path[^1]); + } + + return references; + } + + private static IEnumerable ResolveGatewayExitBoundaryCandidates( + ElkPositionedNode sourceNode, + ElkPoint exitReference) + { + var candidates = new List(); + AddUniquePoint( + candidates, + ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary( + sourceNode, + ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, exitReference), + exitReference)); + + foreach (var side in EnumeratePreferredGatewayExitSides(sourceNode, exitReference)) + { + var centerX = sourceNode.X + (sourceNode.Width / 2d); + var centerY = sourceNode.Y + (sourceNode.Height / 2d); + var slotCoordinate = side is "left" or "right" + ? centerY + Math.Clamp(exitReference.Y - centerY, -sourceNode.Height * 0.18d, sourceNode.Height * 0.18d) + : centerX + Math.Clamp(exitReference.X - centerX, -sourceNode.Width * 0.18d, sourceNode.Width * 0.18d); + if (!ElkShapeBoundaries.TryProjectGatewayBoundarySlot(sourceNode, side, slotCoordinate, out var slotBoundary)) + { + continue; + } + + AddUniquePoint( + candidates, + ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(sourceNode, slotBoundary, exitReference)); + } + + return candidates; + } + + private static IEnumerable EnumeratePreferredGatewayExitSides( + ElkPositionedNode sourceNode, + ElkPoint exitReference) + { + var centerX = sourceNode.X + (sourceNode.Width / 2d); + var centerY = sourceNode.Y + (sourceNode.Height / 2d); + var deltaX = exitReference.X - centerX; + var deltaY = exitReference.Y - centerY; + var absDx = Math.Abs(deltaX); + var absDy = Math.Abs(deltaY); + var primary = absDx >= absDy + ? (deltaX >= 0d ? "right" : "left") + : (deltaY >= 0d ? "bottom" : "top"); + yield return primary; + + if (absDx > 0.5d && absDy > 0.5d) + { + var secondary = primary is "left" or "right" + ? (deltaY >= 0d ? "bottom" : "top") + : (deltaX >= 0d ? "right" : "left"); + if (!string.Equals(primary, secondary, StringComparison.Ordinal)) + { + yield return secondary; + } + } + } + + private static void AddUniquePoint(ICollection points, ElkPoint point) + { + if (points.Any(existing => ElkEdgeRoutingGeometry.PointsEqual(existing, point))) + { + return; + } + + points.Add(point); + } + + private static List BuildGatewayExitCandidate( + IReadOnlyList path, + ElkPoint boundary, + ElkPoint exteriorApproach, + int continuationIndex) + { + var rebuilt = new List { boundary }; + if (!ElkEdgeRoutingGeometry.PointsEqual(boundary, exteriorApproach)) + { + rebuilt.Add(exteriorApproach); + } + + var continuationPoint = path[continuationIndex]; + if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], continuationPoint)) + { + var preferHorizontal = ShouldPreferHorizontalGatewayExit(rebuilt[^1], continuationPoint); + AppendGatewayOrthogonalCorner( + rebuilt, + rebuilt[^1], + continuationPoint, + continuationIndex + 1 < path.Count ? path[continuationIndex + 1] : null, + preferHorizontalFromReference: preferHorizontal); + if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], continuationPoint)) + { + rebuilt.Add(continuationPoint); + } + } + + for (var i = continuationIndex + 1; i < path.Count; i++) + { + rebuilt.Add(path[i]); + } + + return NormalizePathPoints(rebuilt); + } + + private static List BuildGatewayFallbackExitPath( + IReadOnlyList path, + ElkPositionedNode sourceNode, + ElkPoint boundary, + ElkPoint exteriorAnchor, + int continuationIndex) + { + var exteriorApproach = ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, boundary, exteriorAnchor); + + var rebuilt = new List { boundary }; + if (!ElkEdgeRoutingGeometry.PointsEqual(boundary, exteriorApproach)) + { + rebuilt.Add(exteriorApproach); + } + + AppendGatewayOrthogonalCorner( + rebuilt, + rebuilt[^1], + exteriorAnchor, + continuationIndex + 1 < path.Count ? path[continuationIndex + 1] : null, + preferHorizontalFromReference: ShouldPreferHorizontalGatewayExit(rebuilt[^1], exteriorAnchor)); + if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], exteriorAnchor)) + { + rebuilt.Add(exteriorAnchor); + } + + for (var i = continuationIndex + 1; i < path.Count; i++) + { + rebuilt.Add(path[i]); + } + + return NormalizePathPoints(rebuilt); + } + + private static bool PathStartsAtDecisionVertex( + IReadOnlyList path, + ElkPositionedNode sourceNode) + { + return sourceNode.Kind == "Decision" + && path.Count >= 2 + && ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, path[0]); + } + + private static List ForceDecisionSourceExitOffVertex( + IReadOnlyList sourcePath, + ElkPositionedNode sourceNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId) + { + var path = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (path.Count < 3 || sourceNode.Kind != "Decision") + { + return path; + } + + var continuationIndex = Math.Min(path.Count - 1, 2); + var reference = path[^1]; + var boundary = ResolveDecisionSourceExitBoundary(sourceNode, path[continuationIndex], reference); + if (ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, boundary)) + { + return path; + } + + var continuationPoint = path[continuationIndex]; + var exteriorApproach = ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, boundary, continuationPoint); + var rebuilt = new List { boundary }; + if (!ElkEdgeRoutingGeometry.PointsEqual(boundary, exteriorApproach)) + { + rebuilt.Add(exteriorApproach); + } + + AppendGatewayOrthogonalCorner( + rebuilt, + rebuilt[^1], + continuationPoint, + continuationIndex + 1 < path.Count ? path[continuationIndex + 1] : null, + preferHorizontalFromReference: ShouldPreferHorizontalGatewayExit(rebuilt[^1], continuationPoint)); + if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], continuationPoint)) + { + rebuilt.Add(continuationPoint); + } + + for (var i = continuationIndex + 1; i < path.Count; i++) + { + rebuilt.Add(path[i]); + } + + return NormalizePathPoints(rebuilt); + } + + private static ElkPoint ResolveDecisionSourceExitBoundary( + ElkPositionedNode sourceNode, + ElkPoint continuationPoint, + ElkPoint reference) + { + var projectedReference = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary( + sourceNode, + ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, reference), + reference); + var projectedContinuation = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary( + sourceNode, + ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, continuationPoint), + continuationPoint); + var candidates = new List(); + AddDecisionBoundaryCandidate(candidates, projectedReference); + AddDecisionBoundaryCandidate(candidates, projectedContinuation); + foreach (var side in EnumeratePreferredGatewaySourceSides(sourceNode, continuationPoint, reference)) + { + foreach (var slotBoundary in ResolveGatewaySourceBoundarySlotCandidates(sourceNode, side, continuationPoint, reference)) + { + AddDecisionBoundaryCandidate(candidates, slotBoundary); + } + } + + var bestCandidate = projectedReference; + var bestScore = double.PositiveInfinity; + foreach (var candidate in candidates) + { + var score = ScoreDecisionSourceExitBoundaryCandidate( + sourceNode, + candidate, + projectedReference, + projectedContinuation, + continuationPoint, + reference); + if (score >= bestScore) + { + continue; + } + + bestScore = score; + bestCandidate = candidate; + } + + return bestCandidate; + } + + private static void AddDecisionBoundaryCandidate( + ICollection candidates, + ElkPoint candidate) + { + if (candidates.Any(existing => ElkEdgeRoutingGeometry.PointsEqual(existing, candidate))) + { + return; + } + + candidates.Add(candidate); + } + + private static double ScoreDecisionSourceExitBoundaryCandidate( + ElkPositionedNode sourceNode, + ElkPoint candidate, + ElkPoint projectedReference, + ElkPoint projectedContinuation, + ElkPoint continuationPoint, + ElkPoint reference) + { + var centerX = sourceNode.X + (sourceNode.Width / 2d); + var centerY = sourceNode.Y + (sourceNode.Height / 2d); + var desiredDx = reference.X - centerX; + var desiredDy = reference.Y - centerY; + var candidateDx = candidate.X - centerX; + var candidateDy = candidate.Y - centerY; + + var score = Math.Abs(candidate.X - projectedReference.X) + Math.Abs(candidate.Y - projectedReference.Y); + score += (Math.Abs(candidate.X - projectedContinuation.X) + Math.Abs(candidate.Y - projectedContinuation.Y)) * 0.35d; + + var preferredSides = EnumeratePreferredGatewaySourceSides(sourceNode, continuationPoint, reference).ToArray(); + if (preferredSides.Length > 0 + && !IsBoundaryOnGatewaySourceSide(sourceNode, candidate, preferredSides[0])) + { + score += preferredSides[0] is "left" or "right" + ? 12_000d + : 8_000d; + } + else if (preferredSides.Length > 0 && preferredSides[0] is "left" or "right") + { + score -= Math.Abs(candidateDx) * 0.4d; + } + + if (preferredSides.Length > 1 + && !IsBoundaryOnGatewaySourceSide(sourceNode, candidate, preferredSides[0]) + && !IsBoundaryOnGatewaySourceSide(sourceNode, candidate, preferredSides[1])) + { + score += 4_000d; + } + + var dominantHorizontal = Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.25d; + var dominantVertical = Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.25d; + if (dominantHorizontal) + { + if (Math.Sign(candidateDx) != Math.Sign(desiredDx)) + { + score += 10_000d; + } + + if (Math.Abs(candidateDy) > sourceNode.Height * 0.28d) + { + score += 25_000d; + } + + score += Math.Abs(candidateDy) * 6d; + } + else if (dominantVertical) + { + if (Math.Sign(candidateDy) != Math.Sign(desiredDy)) + { + score += 10_000d; + } + + if (Math.Abs(candidateDx) > sourceNode.Width * 0.28d) + { + score += 25_000d; + } + + score += Math.Abs(candidateDx) * 6d; + } + else + { + score += (Math.Abs(candidateDx - desiredDx) + Math.Abs(candidateDy - desiredDy)) * 0.08d; + } + + if (ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, candidate, 8d)) + { + score += 4_000d; + } + + var exterior = ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, candidate, continuationPoint); + score += (Math.Abs(exterior.X - continuationPoint.X) + Math.Abs(exterior.Y - continuationPoint.Y)) * 0.04d; + return score; + } + + private static bool ShouldPreferHorizontalGatewayExit(ElkPoint from, ElkPoint to) + { + return Math.Abs(to.X - from.X) >= Math.Abs(to.Y - from.Y); + } + + private static bool CanUseDirectGatewayContinuation(ElkPoint from, ElkPoint to) + { + const double coordinateTolerance = 0.5d; + var deltaX = Math.Abs(to.X - from.X); + var deltaY = Math.Abs(to.Y - from.Y); + if (deltaX <= coordinateTolerance || deltaY <= coordinateTolerance) + { + return true; + } + + var length = Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY)); + return length <= 36d + && Math.Abs(deltaX - deltaY) <= Math.Max(6d, Math.Min(deltaX, deltaY) * 0.45d); + } + + private static void AddUniqueCoordinate(ICollection coordinates, double value) + { + if (coordinates.Any(existing => Math.Abs(existing - value) <= 0.5d)) + { + return; + } + + coordinates.Add(value); + } + + private static double ScoreGatewayExitCandidate( + IReadOnlyList candidate, + ElkPoint exitReference, + int continuationIndex, + IReadOnlyList originalPath, + ElkPositionedNode sourceNode) + { + var score = ComputePathLength(candidate); + score += Math.Max(0, candidate.Count - 2) * 3d; + score += continuationIndex * 2d; + score += (Math.Abs(candidate[0].X - exitReference.X) + Math.Abs(candidate[0].Y - exitReference.Y)) * 0.1d; + if (continuationIndex < originalPath.Count) + { + score += (Math.Abs(candidate[1].X - originalPath[continuationIndex].X) + + Math.Abs(candidate[1].Y - originalPath[continuationIndex].Y)) * 0.02d; + } + + score += ScoreGatewayExitProgress(sourceNode, candidate, exitReference); + if (HasGatewaySourceDominantAxisDetour(candidate, sourceNode)) + { + score += 50_000d; + } + + if (HasGatewaySourcePreferredFaceMismatch(candidate, sourceNode)) + { + score += 25_000d; + } + + if (NeedsDecisionSourcePreferredFaceRepair(candidate, sourceNode)) + { + score += 15_000d; + } + + return score; + } + + private static double ScoreGatewayExitProgress( + ElkPositionedNode sourceNode, + IReadOnlyList candidate, + ElkPoint exitReference) + { + if (candidate.Count < 2) + { + return 0d; + } + + var boundary = candidate[0]; + var next = candidate[1]; + var score = 0d; + if (ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, boundary)) + { + score += sourceNode.Kind == "Decision" + ? 5_000d + : 1_500d; + } + + var startDistance = Math.Abs(boundary.X - exitReference.X) + Math.Abs(boundary.Y - exitReference.Y); + var nextDistance = Math.Abs(next.X - exitReference.X) + Math.Abs(next.Y - exitReference.Y); + if (nextDistance > startDistance + 0.5d) + { + score += (nextDistance - startDistance) * 6d; + } + + var totalDx = exitReference.X - boundary.X; + var totalDy = exitReference.Y - boundary.Y; + var firstDx = next.X - boundary.X; + var firstDy = next.Y - boundary.Y; + var absTotalDx = Math.Abs(totalDx); + var absTotalDy = Math.Abs(totalDy); + var absFirstDx = Math.Abs(firstDx); + var absFirstDy = Math.Abs(firstDy); + const double coordinateTolerance = 0.5d; + + if (sourceNode.Kind == "Decision" + && (absFirstDx <= coordinateTolerance || absFirstDy <= coordinateTolerance)) + { + score += ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, boundary) + ? 350d + : 0d; + } + + if (absTotalDx >= absTotalDy * 1.25d) + { + if (absFirstDx <= coordinateTolerance || Math.Sign(firstDx) != Math.Sign(totalDx)) + { + score += 600d; + } + else if (absFirstDy > absFirstDx * 1.25d) + { + score += 120d; + } + } + else if (absTotalDy >= absTotalDx * 1.25d) + { + if (absFirstDy <= coordinateTolerance || Math.Sign(firstDy) != Math.Sign(totalDy)) + { + score += 600d; + } + else if (absFirstDx > absFirstDy * 1.25d) + { + score += 120d; + } + } + + return score; + } + + private static int FindPreferredGatewayExitContinuationIndex( + IReadOnlyList path, + ElkPositionedNode sourceNode, + int firstExteriorIndex) + { + if (path.Count <= firstExteriorIndex + 1) + { + return firstExteriorIndex; + } + + var firstContinuation = path[firstExteriorIndex]; + var finalTarget = path[^1]; + var start = path[0]; + var firstDx = firstContinuation.X - start.X; + var firstDy = firstContinuation.Y - start.Y; + var desiredDx = finalTarget.X - start.X; + var desiredDy = finalTarget.Y - start.Y; + const double coordinateTolerance = 0.5d; + + var bestIndex = firstExteriorIndex; + var bestScore = ScoreGatewayExitContinuationPoint(path[firstExteriorIndex], start, finalTarget, firstExteriorIndex, sourceNode.Kind, coordinateTolerance); + for (var i = firstExteriorIndex + 1; i < path.Count; i++) + { + if (!ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(sourceNode, path[i])) + { + var score = ScoreGatewayExitContinuationPoint(path[i], start, finalTarget, i, sourceNode.Kind, coordinateTolerance); + if (score < bestScore) + { + bestScore = score; + bestIndex = i; + } + } + } + + return bestIndex; + } + + private static int? FindGatewaySourceCurlRecoveryIndex( + IReadOnlyList path, + int firstExteriorIndex) + { + if (!HasGatewaySourceExitCurl(path) || path.Count <= firstExteriorIndex + 1) + { + return null; + } + + const double coordinateTolerance = 0.5d; + var start = path[0]; + var finalTarget = path[^1]; + var desiredDx = finalTarget.X - start.X; + var desiredDy = finalTarget.Y - start.Y; + for (var i = firstExteriorIndex + 1; i < path.Count; i++) + { + var point = path[i]; + var deltaX = point.X - start.X; + var deltaY = point.Y - start.Y; + if (IsGatewayExitAxisAlignedWithDesiredDirection(deltaX, desiredDx, coordinateTolerance) + && IsGatewayExitAxisAlignedWithDesiredDirection(deltaY, desiredDy, coordinateTolerance)) + { + return i; + } + } + + return null; + } + + private static bool IsGatewayExitAxisAlignedWithDesiredDirection( + double delta, + double desiredDelta, + double tolerance) + { + if (Math.Abs(desiredDelta) <= tolerance || Math.Abs(delta) <= tolerance) + { + return true; + } + + return Math.Sign(delta) == Math.Sign(desiredDelta); + } + + private static double ScoreGatewayExitContinuationPoint( + ElkPoint point, + ElkPoint start, + ElkPoint finalTarget, + int index, + string sourceKind, + double tolerance) + { + var desiredDx = finalTarget.X - start.X; + var desiredDy = finalTarget.Y - start.Y; + var deltaX = point.X - start.X; + var deltaY = point.Y - start.Y; + var score = index * 4d; + + if (Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.25d) + { + if (Math.Sign(deltaX) != Math.Sign(desiredDx) || Math.Abs(deltaX) <= tolerance) + { + score += 1_000d; + } + + if (Math.Abs(desiredDy) <= tolerance) + { + score += Math.Abs(point.Y - start.Y) * 1.25d; + } + else + { + if (Math.Sign(deltaY) != Math.Sign(desiredDy) && Math.Abs(deltaY) > tolerance) + { + score += 1_600d; + } + + if (desiredDy > tolerance && point.Y > finalTarget.Y + tolerance) + { + score += 4_000d; + } + else if (desiredDy < -tolerance && point.Y < finalTarget.Y - tolerance) + { + score += 4_000d; + } + + score += Math.Abs(point.Y - finalTarget.Y) * 2.4d; + } + + score -= Math.Abs(deltaX) * 0.2d; + } + else if (Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.25d) + { + if (Math.Sign(deltaY) != Math.Sign(desiredDy) || Math.Abs(deltaY) <= tolerance) + { + score += 1_000d; + } + + if (Math.Abs(desiredDx) <= tolerance) + { + score += Math.Abs(point.X - start.X) * 1.25d; + } + else + { + if (Math.Sign(deltaX) != Math.Sign(desiredDx) && Math.Abs(deltaX) > tolerance) + { + score += 1_600d; + } + + if (desiredDx > tolerance && point.X > finalTarget.X + tolerance) + { + score += 4_000d; + } + else if (desiredDx < -tolerance && point.X < finalTarget.X - tolerance) + { + score += 4_000d; + } + + score += Math.Abs(point.X - finalTarget.X) * 2.4d; + } + + score -= Math.Abs(deltaY) * 0.2d; + } + else + { + if (Math.Sign(deltaX) != Math.Sign(desiredDx)) + { + score += 500d; + } + + if (Math.Sign(deltaY) != Math.Sign(desiredDy)) + { + score += 500d; + } + + score -= (Math.Abs(deltaX) + Math.Abs(deltaY)) * 0.12d; + } + + if (sourceKind == "Decision" + && (Math.Abs(deltaX) <= tolerance || Math.Abs(deltaY) <= tolerance)) + { + score += 120d; + } + + return score; + } + + private static bool HasGatewaySourceExitBacktracking(IReadOnlyList path) + { + if (path.Count < 4) + { + return false; + } + + var reference = path[^1]; + var desiredDx = reference.X - path[0].X; + var desiredDy = reference.Y - path[0].Y; + var sampleCount = Math.Min(path.Count, 6); + var absDx = Math.Abs(desiredDx); + var absDy = Math.Abs(desiredDy); + if (absDx >= absDy * 1.35d) + { + return HasAxisReversalFromStart(path.Take(sampleCount).Select(point => point.X), desiredDx); + } + + if (absDy >= absDx * 1.35d) + { + return HasAxisReversalFromStart(path.Take(sampleCount).Select(point => point.Y), desiredDy); + } + + return HasAxisReversalFromStart(path.Take(sampleCount).Select(point => point.X), desiredDx) + && HasAxisReversalFromStart(path.Take(sampleCount).Select(point => point.Y), desiredDy); + } + + private static bool HasGatewaySourceExitCurl(IReadOnlyList path) + { + if (path.Count < 4) + { + return false; + } + + var sampleCount = Math.Min(path.Count, 6); + var desiredDx = path[^1].X - path[0].X; + var desiredDy = path[^1].Y - path[0].Y; + return HasAxisReversalFromStart(path.Take(sampleCount).Select(point => point.X), desiredDx) + || HasAxisReversalFromStart(path.Take(sampleCount).Select(point => point.Y), desiredDy); + } + + private static bool NeedsGatewaySourceBoundaryRepair( + IReadOnlyList path, + ElkPositionedNode sourceNode) + { + if (path.Count < 2) + { + return false; + } + + return ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, path[0]) + || ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(sourceNode, path[1]) + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(sourceNode, path[0], path[1]) + || NeedsDecisionSourcePreferredFaceRepair(path, sourceNode) + || HasGatewaySourceExitCurl(path) + || HasGatewaySourceExitBacktracking(path); + } + + private static List ForceDecisionDiagonalSourceExit( + IReadOnlyList sourcePath, + ElkPositionedNode sourceNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId) + { + var path = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (sourceNode.Kind != "Decision" || path.Count < 3) + { + return path; + } + + if (ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, path[0], 8d)) + { + return path; + } + + var firstDx = path[1].X - path[0].X; + var firstDy = path[1].Y - path[0].Y; + var startsAxisAligned = Math.Abs(firstDx) <= 0.5d || Math.Abs(firstDy) <= 0.5d; + if (!startsAxisAligned) + { + return path; + } + + var referenceDx = path[^1].X - path[0].X; + var referenceDy = path[^1].Y - path[0].Y; + if (Math.Abs(referenceDx) <= 24d + || Math.Abs(referenceDy) <= 24d + || Math.Abs(referenceDx) > Math.Abs(referenceDy) * 3d + || Math.Abs(referenceDy) > Math.Abs(referenceDx) * 3d) + { + return path; + } + + var firstExteriorIndex = FindFirstGatewayExteriorPointIndex(path, sourceNode); + var continuationIndex = FindPreferredGatewayExitContinuationIndex(path, sourceNode, firstExteriorIndex); + var continuationPoint = path[continuationIndex]; + var boundary = ResolveDecisionSourceExitBoundary(sourceNode, continuationPoint, path[^1]); + var diagonalExterior = ElkShapeBoundaries.BuildGatewayExteriorApproachPoint(sourceNode, boundary); + if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(sourceNode, diagonalExterior) + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(sourceNode, boundary, diagonalExterior)) + { + return path; + } + + var candidate = BuildGatewayExitCandidate(path, boundary, diagonalExterior, continuationIndex); + if (!PathChanged(path, candidate)) + { + return path; + } + + if (!HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true) + || HasGatewaySourceExitBacktracking(candidate) + || HasGatewaySourceExitCurl(candidate)) + { + return path; + } + + return ComputePathLength(candidate) <= ComputePathLength(path) + 2d + ? candidate + : path; + } + + private static bool NeedsDecisionSourcePreferredFaceRepair( + IReadOnlyList path, + ElkPositionedNode sourceNode) + { + if (sourceNode.Kind != "Decision" || path.Count < 3) + { + return false; + } + + var firstExteriorIndex = FindFirstGatewayExteriorPointIndex(path, sourceNode); + var continuationIndex = FindPreferredGatewayExitContinuationIndex(path, sourceNode, firstExteriorIndex); + var continuationPoint = path[continuationIndex]; + var preferredBoundary = ResolveDecisionSourceExitBoundary(sourceNode, continuationPoint, path[^1]); + if (ElkEdgeRoutingGeometry.PointsEqual(path[0], preferredBoundary)) + { + return false; + } + + return ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, path[0], 8d) + || ElkEdgeRoutingGeometry.ComputeSegmentLength(path[0], preferredBoundary) > 6d; + } + + private static bool NeedsGatewayTargetBoundaryRepair( + IReadOnlyList path, + ElkPositionedNode targetNode) + { + if (path.Count < 2) + { + return false; + } + + return ElkShapeBoundaries.IsNearGatewayVertex(targetNode, path[^1]) + || ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, path[^2]) + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, path[^1], path[^2]); + } + + private static List RepairGatewaySourceBoundaryPath( + IReadOnlyList sourcePath, + ElkPositionedNode sourceNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId) + { + var directSourceCandidate = TryBuildDirectGatewaySourcePath( + sourcePath, + sourceNode, + nodes, + sourceNodeId, + targetNodeId); + if (PathChanged(sourcePath, directSourceCandidate) + && HasAcceptableGatewayBoundaryPath(directSourceCandidate, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true) + && !HasGatewaySourceExitBacktracking(directSourceCandidate) + && !HasGatewaySourceExitCurl(directSourceCandidate) + && !HasGatewaySourceDominantAxisDetour(directSourceCandidate, sourceNode) + && !HasGatewaySourcePreferredFaceMismatch(directSourceCandidate, sourceNode)) + { + return directSourceCandidate; + } + + var normalizedCandidate = NormalizeGatewayExitPath( + sourcePath, + sourceNode, + nodes, + sourceNodeId, + targetNodeId); + if (PathChanged(sourcePath, normalizedCandidate) + && HasAcceptableGatewayBoundaryPath(normalizedCandidate, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true) + && !HasGatewaySourceExitBacktracking(normalizedCandidate) + && !HasGatewaySourceExitCurl(normalizedCandidate) + && !HasGatewaySourceDominantAxisDetour(normalizedCandidate, sourceNode) + && !HasGatewaySourcePreferredFaceMismatch(normalizedCandidate, sourceNode)) + { + return normalizedCandidate; + } + + var blockerEscapeCandidate = TryBuildGatewaySourceDominantBlockerEscapePath( + sourcePath, + sourceNode, + nodes, + sourceNodeId, + targetNodeId); + if (PathChanged(sourcePath, blockerEscapeCandidate) + && HasAcceptableGatewayBoundaryPath(blockerEscapeCandidate, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true) + && !HasGatewaySourceExitBacktracking(blockerEscapeCandidate) + && !HasGatewaySourceExitCurl(blockerEscapeCandidate) + && !HasGatewaySourceDominantAxisDetour(blockerEscapeCandidate, sourceNode) + && !HasGatewaySourcePreferredFaceMismatch(blockerEscapeCandidate, sourceNode)) + { + return blockerEscapeCandidate; + } + + var path = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (path.Count < 2) + { + return path; + } + + var firstExteriorIndex = FindFirstGatewayExteriorPointIndex(path, sourceNode); + var continuationIndex = FindPreferredGatewayExitContinuationIndex(path, sourceNode, firstExteriorIndex); + + var continuationPoint = path[continuationIndex]; + ElkPoint boundary; + if (sourceNode.Kind == "Decision") + { + boundary = ResolveDecisionSourceExitBoundary(sourceNode, continuationPoint, path[^1]); + } + else + { + boundary = ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, continuationPoint); + boundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(sourceNode, boundary, continuationPoint); + } + + var normalized = BuildGatewaySourceRepairPath( + path, + sourceNode, + boundary, + continuationPoint, + continuationIndex, + path[^1]); + return HasAcceptableGatewayBoundaryPath(normalized, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true) + ? normalized + : path; + } + + private static List TryBuildGatewaySourceDominantBlockerEscapePath( + IReadOnlyList sourcePath, + ElkPositionedNode sourceNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId) + { + var path = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (sourceNode.Kind != "Decision" + || path.Count < 3 + || !HasGatewaySourceDominantAxisDetour(path, sourceNode)) + { + return path; + } + + var centerX = sourceNode.X + (sourceNode.Width / 2d); + var centerY = sourceNode.Y + (sourceNode.Height / 2d); + var desiredDx = path[^1].X - centerX; + var desiredDy = path[^1].Y - centerY; + var dominantVertical = Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.25d && Math.Sign(desiredDy) != 0; + if (!dominantVertical) + { + return path; + } + + var clearance = 24d; + var direction = Math.Sign(desiredDy); + var targetNode = string.IsNullOrWhiteSpace(targetNodeId) + ? null + : nodes.FirstOrDefault(node => string.Equals(node.Id, targetNodeId, StringComparison.Ordinal)); + var firstExteriorIndex = FindFirstGatewayExteriorPointIndex(path, sourceNode); + List? bestCandidate = null; + var bestScore = double.PositiveInfinity; + + foreach (var continuationIndex in EnumerateGatewayDirectRepairContinuationIndices(path, sourceNode, firstExteriorIndex)) + { + var anchorX = path[continuationIndex].X; + if (Math.Abs(anchorX - path[0].X) <= 3d) + { + continue; + } + + var dominantReference = new ElkPoint { X = anchorX, Y = path[^1].Y }; + if (!TryResolvePreferredGatewaySourceBoundary(sourceNode, dominantReference, path[^1], out var provisionalBoundary)) + { + continue; + } + + var stemBlockers = nodes + .Where(node => + !string.Equals(node.Id, sourceNodeId, StringComparison.Ordinal) + && !string.Equals(node.Id, targetNodeId, StringComparison.Ordinal) + && provisionalBoundary.X > node.X + 0.5d + && provisionalBoundary.X < node.X + node.Width - 0.5d + && (direction > 0d + ? node.Y > provisionalBoundary.Y + 0.5d && node.Y < path[^1].Y - 0.5d + : node.Y + node.Height < provisionalBoundary.Y - 0.5d && node.Y + node.Height > path[^1].Y + 0.5d)) + .OrderBy(node => direction > 0d ? node.Y : -(node.Y + node.Height)) + .ToArray(); + if (stemBlockers.Length == 0) + { + continue; + } + + foreach (var blocker in stemBlockers) + { + var escapeY = direction > 0d + ? blocker.Y - clearance + : blocker.Y + blocker.Height + clearance; + if (direction > 0d) + { + if (escapeY <= provisionalBoundary.Y + 8d || escapeY >= blocker.Y - 0.5d) + { + continue; + } + } + else if (escapeY >= provisionalBoundary.Y - 8d || escapeY <= blocker.Y + blocker.Height + 0.5d) + { + continue; + } + + var continuationPoint = new ElkPoint { X = anchorX, Y = escapeY }; + var boundary = provisionalBoundary; + + var candidate = BuildGatewaySourceRepairPath( + path, + sourceNode, + boundary, + continuationPoint, + continuationIndex, + continuationPoint); + if (!PathChanged(path, candidate) + || !HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true) + || !HasClearBoundarySegments(candidate, nodes, sourceNodeId, targetNodeId, true, Math.Min(4, candidate.Count - 1)) + || (targetNode is not null + && (ElkShapeBoundaries.IsGatewayShape(targetNode) + ? !HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, targetNode, fromStart: false) + : candidate.Count < 2 || !HasValidBoundaryAngle(candidate[^1], candidate[^2], targetNode))) + || HasGatewaySourceExitBacktracking(candidate) + || HasGatewaySourceExitCurl(candidate) + || HasGatewaySourceDominantAxisDetour(candidate, sourceNode) + || HasGatewaySourcePreferredFaceMismatch(candidate, sourceNode)) + { + continue; + } + + var score = ScoreGatewayDirectRepairCandidate(path, candidate, sourceNode, continuationIndex); + if (score >= bestScore) + { + continue; + } + + bestScore = score; + bestCandidate = candidate; + } + } + + if (bestCandidate is null) + { + return path; + } + + return IsMaterialGatewaySourceRepairImprovement(path, bestCandidate) + || IsGatewaySourceGeometryRepairImprovement(path, bestCandidate, sourceNode) + ? bestCandidate + : path; + } + + private static List RepairProtectedGatewaySourceBoundaryPath( + IReadOnlyList sourcePath, + ElkPositionedNode sourceNode, + double graphMinY, + double graphMaxY) + { + var path = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (path.Count < 3) + { + return path; + } + + var corridorIndex = -1; + for (var i = 1; i < path.Count; i++) + { + if (path[i].Y < graphMinY - 8d || path[i].Y > graphMaxY + 8d) + { + corridorIndex = i; + break; + } + } + + if (corridorIndex < 1) + { + return path; + } + + var corridorPoint = path[corridorIndex]; + var boundary = sourceNode.Kind == "Decision" + ? ResolveDecisionSourceExitBoundary(sourceNode, corridorPoint, corridorPoint) + : ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary( + sourceNode, + ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, corridorPoint), + corridorPoint); + + return BuildGatewaySourceRepairPath( + path, + sourceNode, + boundary, + corridorPoint, + corridorIndex, + corridorPoint); + } + + private static List TryBuildProtectedGatewaySourcePath( + IReadOnlyList sourcePath, + ElkPositionedNode sourceNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId) + { + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + var candidate = RepairProtectedGatewaySourceBoundaryPath(sourcePath, sourceNode, graphMinY, graphMaxY); + if (!PathChanged(sourcePath, candidate)) + { + return sourcePath.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + if (!TryNormalizeTargetBoundaryAfterSourceRepair( + candidate, + nodes, + sourceNodeId, + targetNodeId, + out candidate)) + { + return sourcePath.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + return candidate; + } + + private static List BuildGatewaySourceRepairPath( + IReadOnlyList path, + ElkPositionedNode sourceNode, + ElkPoint boundary, + ElkPoint continuationPoint, + int continuationIndex, + ElkPoint referencePoint) + { + List? bestCandidate = null; + var bestScore = double.PositiveInfinity; + + foreach (var exteriorApproach in ResolveGatewayExteriorApproachCandidates(sourceNode, boundary, referencePoint)) + { + foreach (var preferDirectContinuation in new[] { true, false }) + { + var rebuilt = new List { boundary }; + if (!ElkEdgeRoutingGeometry.PointsEqual(boundary, exteriorApproach)) + { + rebuilt.Add(exteriorApproach); + } + + if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], continuationPoint)) + { + var allowDirectContinuation = preferDirectContinuation + && CanUseDirectGatewayContinuation(rebuilt[^1], continuationPoint); + if (!allowDirectContinuation) + { + var curlRecoveryCorner = ResolveGatewaySourceCurlRecoveryCorner(path, rebuilt[^1], continuationPoint); + if (curlRecoveryCorner is not null) + { + rebuilt.Add(curlRecoveryCorner); + } + else + { + AppendGatewayOrthogonalCorner( + rebuilt, + rebuilt[^1], + continuationPoint, + continuationIndex + 1 < path.Count ? path[continuationIndex + 1] : null, + preferHorizontalFromReference: ShouldPreferHorizontalGatewayExit(rebuilt[^1], continuationPoint)); + } + } + + if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], continuationPoint)) + { + rebuilt.Add(continuationPoint); + } + } + + for (var i = continuationIndex + 1; i < path.Count; i++) + { + rebuilt.Add(path[i]); + } + + var candidate = NormalizePathPoints(rebuilt); + candidate = SnapGatewaySourceStubToDominantAxis(candidate, sourceNode, referencePoint); + var score = ComputePathLength(candidate) + ScoreGatewayExitProgress(sourceNode, candidate, referencePoint); + if (preferDirectContinuation) + { + score -= 6d; + } + + if (HasGatewaySourceExitBacktracking(candidate) + || HasGatewaySourceExitCurl(candidate)) + { + score += 100_000d; + } + + if (HasGatewaySourceDominantAxisDetour(candidate, sourceNode)) + { + score += 100_000d; + } + + if (HasGatewaySourcePreferredFaceMismatch(candidate, sourceNode)) + { + score += 50_000d; + } + + if (NeedsDecisionSourcePreferredFaceRepair(candidate, sourceNode)) + { + score += 50_000d; + } + + if (score >= bestScore) + { + continue; + } + + bestScore = score; + bestCandidate = candidate; + } + } + + return bestCandidate + ?? path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + private static ElkPoint? ResolveGatewaySourceCurlRecoveryCorner( + IReadOnlyList path, + ElkPoint from, + ElkPoint to) + { + const double coordinateTolerance = 0.5d; + if (!HasGatewaySourceExitCurl(path) + || Math.Abs(from.X - to.X) <= coordinateTolerance + || Math.Abs(from.Y - to.Y) <= coordinateTolerance) + { + return null; + } + + var desiredDx = path[^1].X - path[0].X; + var desiredDy = path[^1].Y - path[0].Y; + if (Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.25d + && Math.Abs(desiredDy) > coordinateTolerance + && Math.Sign(to.Y - from.Y) == Math.Sign(desiredDy)) + { + return new ElkPoint { X = from.X, Y = to.Y }; + } + + if (Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.25d + && Math.Abs(desiredDx) > coordinateTolerance + && Math.Sign(to.X - from.X) == Math.Sign(desiredDx)) + { + return new ElkPoint { X = to.X, Y = from.Y }; + } + + return null; + } + + private static List TryBuildDirectGatewaySourcePath( + IReadOnlyList sourcePath, + ElkPositionedNode sourceNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId) + { + var path = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (path.Count < 2 || !ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + return path; + } + + var firstExteriorIndex = FindFirstGatewayExteriorPointIndex(path, sourceNode); + List? bestCandidate = null; + var bestScore = double.PositiveInfinity; + foreach (var continuationIndex in EnumerateGatewayDirectRepairContinuationIndices(path, sourceNode, firstExteriorIndex)) + { + var continuationPoint = path[continuationIndex]; + var boundaryCandidates = new List(); + if (TryResolvePreferredGatewaySourceBoundary(sourceNode, continuationPoint, path[^1], out var preferredBoundary)) + { + AddUniquePoint(boundaryCandidates, preferredBoundary); + } + + AddUniquePoint( + boundaryCandidates, + ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary( + sourceNode, + ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, continuationPoint), + continuationPoint)); + + foreach (var candidateBoundary in ResolveGatewayExitBoundaryCandidates(sourceNode, path[^1])) + { + AddUniquePoint(boundaryCandidates, candidateBoundary); + } + + foreach (var boundaryCandidate in boundaryCandidates) + { + var candidate = BuildGatewaySourceRepairPath( + path, + sourceNode, + boundaryCandidate, + continuationPoint, + continuationIndex, + path[^1]); + + if (!PathChanged(path, candidate)) + { + continue; + } + + if (!HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true) + || !HasClearBoundarySegments(candidate, nodes, sourceNodeId, targetNodeId, true, Math.Min(4, candidate.Count - 1)) + || HasGatewaySourceExitBacktracking(candidate) + || HasGatewaySourceExitCurl(candidate) + || HasGatewaySourceDominantAxisDetour(candidate, sourceNode) + || HasGatewaySourcePreferredFaceMismatch(candidate, sourceNode)) + { + continue; + } + + var score = ScoreGatewayDirectRepairCandidate(path, candidate, sourceNode, continuationIndex); + if (score >= bestScore) + { + continue; + } + + bestScore = score; + bestCandidate = candidate; + } + } + + if (bestCandidate is null) + { + return path; + } + + if (!IsMaterialGatewaySourceRepairImprovement(path, bestCandidate) + && !IsGatewaySourceGeometryRepairImprovement(path, bestCandidate, sourceNode)) + { + return path; + } + + return bestCandidate; + } + + internal static bool HasClearGatewaySourceDirectRepairOpportunity( + IReadOnlyList sourcePath, + ElkPositionedNode sourceNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId) + { + if (!ElkShapeBoundaries.IsGatewayShape(sourceNode) || sourcePath.Count < 2) + { + return false; + } + + var candidate = TryBuildDirectGatewaySourcePath( + sourcePath, + sourceNode, + nodes, + sourceNodeId, + targetNodeId); + return IsMaterialGatewaySourceRepairImprovement(sourcePath, candidate); + } + + internal static bool HasClearGatewaySourceScoringOpportunity( + IReadOnlyList sourcePath, + ElkPositionedNode sourceNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId) + { + if (!ElkShapeBoundaries.IsGatewayShape(sourceNode) || sourcePath.Count < 2) + { + return false; + } + + var candidate = HasProtectedGatewaySourceCorridorPath(sourcePath, nodes) + ? TryBuildProtectedGatewaySourcePath( + sourcePath, + sourceNode, + nodes, + sourceNodeId, + targetNodeId) + : TryBuildDirectGatewaySourcePath( + sourcePath, + sourceNode, + nodes, + sourceNodeId, + targetNodeId); + if (!IsMaterialGatewaySourceRepairImprovement(sourcePath, candidate) + || !HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true)) + { + return false; + } + + if (!TryNormalizeTargetBoundaryAfterSourceRepair( + candidate, + nodes, + sourceNodeId, + targetNodeId, + out candidate)) + { + return false; + } + + if (HasGatewaySourceExitBacktracking(candidate) + || HasGatewaySourceExitCurl(candidate) + || HasGatewaySourceDominantAxisDetour(candidate, sourceNode) + || HasGatewaySourcePreferredFaceMismatch(candidate, sourceNode) + || HasGatewaySourceLeadIntoDominantBlocker( + candidate, + sourceNode, + nodes, + sourceNodeId, + targetNodeId)) + { + return false; + } + + var lengthGain = ComputePathLength(sourcePath) - ComputePathLength(candidate); + var originalBends = Math.Max(0, sourcePath.Count - 2); + var candidateBends = Math.Max(0, candidate.Count - 2); + if (lengthGain < 12d && candidateBends >= originalBends) + { + return false; + } + + return IsMaterialGatewaySourceRepairImprovement(sourcePath, candidate); + } + + private static List EnforceGatewaySourceExitQuality( + IReadOnlyList sourcePath, + ElkPositionedNode sourceNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId) + { + var path = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (!ElkShapeBoundaries.IsGatewayShape(sourceNode) || path.Count < 2) + { + return path; + } + + var centerX = sourceNode.X + (sourceNode.Width / 2d); + var centerY = sourceNode.Y + (sourceNode.Height / 2d); + var desiredDx = path[^1].X - centerX; + var desiredDy = path[^1].Y - centerY; + var dominantHorizontal = Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.25d && Math.Sign(desiredDx) != 0; + var dominantVertical = Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.25d && Math.Sign(desiredDy) != 0; + var allowDominantAxisRepair = sourceNode.Kind is not "Decision" || dominantHorizontal || dominantVertical; + var scoringCandidate = HasProtectedGatewaySourceCorridorPath(path, nodes) + ? TryBuildProtectedGatewaySourcePath( + path, + sourceNode, + nodes, + sourceNodeId, + targetNodeId) + : TryBuildDirectGatewaySourcePath( + path, + sourceNode, + nodes, + sourceNodeId, + targetNodeId); + var directDominantCandidate = TryBuildDirectDominantGatewaySourcePath(path, sourceNode, nodes, sourceNodeId, targetNodeId); + var hasDominantDirectOpportunity = allowDominantAxisRepair + && PathChanged(path, directDominantCandidate) + && ComputePathLength(directDominantCandidate) + 1d < ComputePathLength(path); + var requiresRepair = HasGatewaySourceExitBacktracking(path) + || HasGatewaySourceExitCurl(path) + || hasDominantDirectOpportunity + || (allowDominantAxisRepair && HasGatewaySourcePreferredFaceMismatch(path, sourceNode)) + || (allowDominantAxisRepair && HasGatewaySourceDominantAxisDetour(path, sourceNode)) + || (allowDominantAxisRepair && HasClearGatewaySourceScoringOpportunity(path, sourceNode, nodes, sourceNodeId, targetNodeId)); + if (!requiresRepair) + { + return path; + } + + List? bestCandidate = null; + var bestScore = double.PositiveInfinity; + + void ConsiderCandidate(IReadOnlyList rawCandidate) + { + if (!PathChanged(path, rawCandidate)) + { + return; + } + + var candidate = rawCandidate + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (!TryNormalizeTargetBoundaryAfterSourceRepair( + candidate, + nodes, + sourceNodeId, + targetNodeId, + out candidate)) + { + return; + } + + if (!HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true) + || HasGatewaySourceExitBacktracking(candidate) + || HasGatewaySourceExitCurl(candidate) + || HasGatewaySourceDominantAxisDetour(candidate, sourceNode) + || HasGatewaySourcePreferredFaceMismatch(candidate, sourceNode) + || HasGatewaySourceLeadIntoDominantBlocker(candidate, sourceNode, nodes, sourceNodeId, targetNodeId) + || HasClearGatewaySourceScoringOpportunity(candidate, sourceNode, nodes, sourceNodeId, targetNodeId)) + { + return; + } + + var score = ComputePathLength(candidate) + (Math.Max(0, candidate.Count - 2) * 6d); + if (score >= bestScore) + { + return; + } + + bestScore = score; + bestCandidate = candidate; + } + + ConsiderCandidate(scoringCandidate); + ConsiderCandidate(directDominantCandidate); + ConsiderCandidate(TryBuildDirectGatewaySourcePath(path, sourceNode, nodes, sourceNodeId, targetNodeId)); + ConsiderCandidate(ForceGatewaySourcePreferredFaceAlignment(path, sourceNode)); + ConsiderCandidate(FixGatewaySourceDominantAxisDetour(path, sourceNode)); + ConsiderCandidate(NormalizeGatewayExitPath(path, sourceNode, nodes, sourceNodeId, targetNodeId)); + + return bestCandidate ?? path; + } + + private static List TryBuildDirectDominantGatewaySourcePath( + IReadOnlyList sourcePath, + ElkPositionedNode sourceNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId) + { + var path = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (!ElkShapeBoundaries.IsGatewayShape(sourceNode) + || path.Count < 2 + || string.IsNullOrWhiteSpace(targetNodeId)) + { + return path; + } + + var targetNode = nodes.FirstOrDefault(node => string.Equals(node.Id, targetNodeId, StringComparison.Ordinal)); + if (targetNode is null || ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + return path; + } + + var centerX = sourceNode.X + (sourceNode.Width / 2d); + var centerY = sourceNode.Y + (sourceNode.Height / 2d); + var targetCenterX = targetNode.X + (targetNode.Width / 2d); + var targetCenterY = targetNode.Y + (targetNode.Height / 2d); + var desiredDx = targetCenterX - centerX; + var desiredDy = targetCenterY - centerY; + var dominantHorizontal = Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.25d && Math.Sign(desiredDx) != 0; + var dominantVertical = Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.25d && Math.Sign(desiredDy) != 0; + if (!dominantHorizontal && !dominantVertical) + { + return path; + } + + var firstExteriorIndex = FindFirstGatewayExteriorPointIndex(path, sourceNode); + var continuationIndex = FindPreferredGatewayExitContinuationIndex(path, sourceNode, firstExteriorIndex); + var continuationPoint = path[continuationIndex]; + if (!TryResolvePreferredGatewaySourceBoundary(sourceNode, continuationPoint, path[^1], out var boundary)) + { + return path; + } + + var targetEndpoint = dominantHorizontal + ? new ElkPoint + { + X = desiredDx >= 0d ? targetNode.X : targetNode.X + targetNode.Width, + Y = Math.Clamp(boundary.Y, targetNode.Y, targetNode.Y + targetNode.Height), + } + : new ElkPoint + { + X = Math.Clamp(boundary.X, targetNode.X, targetNode.X + targetNode.Width), + Y = desiredDy >= 0d ? targetNode.Y : targetNode.Y + targetNode.Height, + }; + + var obstacles = nodes + .Select(node => ( + Left: node.X, + Top: node.Y, + Right: node.X + node.Width, + Bottom: node.Y + node.Height, + Id: node.Id)) + .ToArray(); + if (SegmentCrossesObstacle(boundary, targetEndpoint, obstacles, sourceNodeId ?? string.Empty, targetNodeId)) + { + var bypassCandidate = TryBuildDominantAxisGatewaySourceBypassPath( + sourceNode, + targetNode, + boundary, + targetEndpoint, + obstacles, + sourceNodeId, + targetNodeId, + dominantHorizontal, + desiredDx, + desiredDy); + return bypassCandidate ?? path; + } + + var rebuilt = new List { boundary }; + var gatewayStub = dominantHorizontal + ? new ElkPoint + { + X = boundary.X + (desiredDx >= 0d ? 24d : -24d), + Y = boundary.Y, + } + : new ElkPoint + { + X = boundary.X, + Y = boundary.Y + (desiredDy >= 0d ? 24d : -24d), + }; + if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], gatewayStub)) + { + rebuilt.Add(gatewayStub); + } + + AppendNonGatewayTargetBoundaryApproach(rebuilt, targetNode, targetEndpoint); + + return NormalizePathPoints(rebuilt); + } + + private static List? TryBuildDominantAxisGatewaySourceBypassPath( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint boundary, + ElkPoint targetEndpoint, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + string? sourceNodeId, + string? targetNodeId, + bool dominantHorizontal, + double desiredDx, + double desiredDy) + { + const double padding = 8d; + const double coordinateTolerance = 0.5d; + var sourceId = sourceNodeId ?? string.Empty; + var targetId = targetNodeId ?? string.Empty; + List? bestCandidate = null; + var bestScore = double.PositiveInfinity; + + void ConsiderCandidate(List rawCandidate) + { + var candidate = NormalizePathPoints(rawCandidate); + if (candidate.Count < 2 + || !IsPathClearOfObstacles(candidate, obstacles, sourceId, targetId) + || !HasValidBoundaryAngle(candidate[^1], candidate[^2], targetNode) + || HasGatewaySourceExitBacktracking(candidate) + || HasGatewaySourceExitCurl(candidate) + || HasGatewaySourceDominantAxisDetour(candidate, sourceNode) + || HasGatewaySourcePreferredFaceMismatch(candidate, sourceNode)) + { + return; + } + + var score = ComputePathLength(candidate) + (Math.Max(0, candidate.Count - 2) * 6d); + if (score >= bestScore) + { + return; + } + + bestScore = score; + bestCandidate = candidate; + } + + if (dominantHorizontal) + { + var movingRight = desiredDx >= 0d; + var firstBlocker = obstacles + .Where(ob => !string.Equals(ob.Id, sourceId, StringComparison.Ordinal) + && !string.Equals(ob.Id, targetId, StringComparison.Ordinal) + && boundary.Y > ob.Top + coordinateTolerance + && boundary.Y < ob.Bottom - coordinateTolerance + && (movingRight + ? ob.Left > boundary.X + coordinateTolerance && ob.Left < targetEndpoint.X - coordinateTolerance + : ob.Right < boundary.X - coordinateTolerance && ob.Right > targetEndpoint.X + coordinateTolerance)) + .OrderBy(ob => movingRight ? ob.Left : -ob.Right) + .FirstOrDefault(); + if (string.IsNullOrWhiteSpace(firstBlocker.Id)) + { + return null; + } + + var axisX = movingRight + ? firstBlocker.Left - padding + : firstBlocker.Right + padding; + if (movingRight ? axisX <= boundary.X + 2d : axisX >= boundary.X - 2d) + { + return null; + } + + var bypassYCandidates = new List(); + AddUniqueCoordinate(bypassYCandidates, targetEndpoint.Y); + AddUniqueCoordinate(bypassYCandidates, firstBlocker.Top - padding); + AddUniqueCoordinate(bypassYCandidates, firstBlocker.Bottom + padding); + foreach (var bypassY in bypassYCandidates) + { + var diagonalLead = new List { boundary }; + var diagonalLeadPoint = new ElkPoint { X = axisX, Y = bypassY }; + if (!ElkEdgeRoutingGeometry.PointsEqual(diagonalLead[^1], diagonalLeadPoint)) + { + diagonalLead.Add(diagonalLeadPoint); + } + + AppendNonGatewayTargetBoundaryApproach(diagonalLead, targetNode, targetEndpoint); + ConsiderCandidate(diagonalLead); + + var rebuilt = new List + { + boundary, + new() { X = axisX, Y = boundary.Y }, + }; + + if (Math.Abs(rebuilt[^1].Y - bypassY) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = axisX, Y = bypassY }); + } + + AppendNonGatewayTargetBoundaryApproach(rebuilt, targetNode, targetEndpoint); + + ConsiderCandidate(rebuilt); + } + + return bestCandidate; + } + + var movingDown = desiredDy >= 0d; + var verticalBlocker = obstacles + .Where(ob => !string.Equals(ob.Id, sourceId, StringComparison.Ordinal) + && !string.Equals(ob.Id, targetId, StringComparison.Ordinal) + && boundary.X > ob.Left + coordinateTolerance + && boundary.X < ob.Right - coordinateTolerance + && (movingDown + ? ob.Top > boundary.Y + coordinateTolerance && ob.Top < targetEndpoint.Y - coordinateTolerance + : ob.Bottom < boundary.Y - coordinateTolerance && ob.Bottom > targetEndpoint.Y + coordinateTolerance)) + .OrderBy(ob => movingDown ? ob.Top : -ob.Bottom) + .FirstOrDefault(); + if (string.IsNullOrWhiteSpace(verticalBlocker.Id)) + { + return null; + } + + var axisY = movingDown + ? verticalBlocker.Top - padding + : verticalBlocker.Bottom + padding; + if (movingDown ? axisY <= boundary.Y + 2d : axisY >= boundary.Y - 2d) + { + return null; + } + + var bypassXCandidates = new List(); + AddUniqueCoordinate(bypassXCandidates, targetEndpoint.X); + AddUniqueCoordinate(bypassXCandidates, verticalBlocker.Left - padding); + AddUniqueCoordinate(bypassXCandidates, verticalBlocker.Right + padding); + foreach (var bypassX in bypassXCandidates) + { + var diagonalLead = new List { boundary }; + var diagonalLeadPoint = new ElkPoint { X = bypassX, Y = axisY }; + if (!ElkEdgeRoutingGeometry.PointsEqual(diagonalLead[^1], diagonalLeadPoint)) + { + diagonalLead.Add(diagonalLeadPoint); + } + + AppendNonGatewayTargetBoundaryApproach(diagonalLead, targetNode, targetEndpoint); + ConsiderCandidate(diagonalLead); + + var rebuilt = new List + { + boundary, + new() { X = boundary.X, Y = axisY }, + }; + + if (Math.Abs(rebuilt[^1].X - bypassX) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = bypassX, Y = axisY }); + } + + AppendNonGatewayTargetBoundaryApproach(rebuilt, targetNode, targetEndpoint); + + ConsiderCandidate(rebuilt); + } + + return bestCandidate; + } + + private static bool IsPathClearOfObstacles( + IReadOnlyList path, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + string sourceId, + string targetId) + { + for (var i = 0; i < path.Count - 1; i++) + { + if (SegmentCrossesObstacle(path[i], path[i + 1], obstacles, sourceId, targetId)) + { + return false; + } + } + + return true; + } + + private static void AppendNonGatewayTargetBoundaryApproach( + ICollection rawPoints, + ElkPositionedNode targetNode, + ElkPoint targetEndpoint) + { + var rebuilt = rawPoints as List; + if (rebuilt is null || rebuilt.Count == 0) + { + return; + } + + var targetSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(targetEndpoint, targetNode); + ElkPoint approachPoint; + switch (targetSide) + { + case "left": + approachPoint = new ElkPoint { X = targetEndpoint.X - 24d, Y = targetEndpoint.Y }; + break; + case "right": + approachPoint = new ElkPoint { X = targetEndpoint.X + 24d, Y = targetEndpoint.Y }; + break; + case "top": + approachPoint = new ElkPoint { X = targetEndpoint.X, Y = targetEndpoint.Y - 24d }; + break; + case "bottom": + approachPoint = new ElkPoint { X = targetEndpoint.X, Y = targetEndpoint.Y + 24d }; + break; + default: + approachPoint = targetEndpoint; + break; + } + + if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], approachPoint)) + { + AppendGatewayOrthogonalCorner( + rebuilt, + rebuilt[^1], + approachPoint, + null, + preferHorizontalFromReference: targetSide is "top" or "bottom"); + + if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], approachPoint)) + { + rebuilt.Add(approachPoint); + } + } + + if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], targetEndpoint)) + { + rebuilt.Add(targetEndpoint); + } + } + + private static bool HasGatewaySourceLeadIntoDominantBlocker( + IReadOnlyList path, + ElkPositionedNode sourceNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId) + { + if (!ElkShapeBoundaries.IsGatewayShape(sourceNode) || path.Count < 2) + { + return false; + } + + const double tolerance = 0.5d; + var centerX = sourceNode.X + (sourceNode.Width / 2d); + var centerY = sourceNode.Y + (sourceNode.Height / 2d); + var desiredDx = path[^1].X - centerX; + var desiredDy = path[^1].Y - centerY; + var dominantHorizontal = Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.25d && Math.Sign(desiredDx) != 0; + var dominantVertical = Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.25d && Math.Sign(desiredDy) != 0; + if (!dominantHorizontal && !dominantVertical) + { + return false; + } + + var boundary = path[0]; + var adjacent = path[1]; + if (dominantHorizontal) + { + var movingRight = desiredDx > 0d; + var blocker = nodes + .Where(node => !string.Equals(node.Id, sourceNodeId, StringComparison.Ordinal) + && !string.Equals(node.Id, targetNodeId, StringComparison.Ordinal) + && boundary.Y > node.Y + tolerance + && boundary.Y < node.Y + node.Height - tolerance + && (movingRight + ? node.X > boundary.X + tolerance && node.X < path[^1].X - tolerance + : node.X + node.Width < boundary.X - tolerance && node.X + node.Width > path[^1].X + tolerance)) + .OrderBy(node => movingRight ? node.X : -(node.X + node.Width)) + .FirstOrDefault(); + if (blocker is null) + { + return false; + } + + return adjacent.Y > blocker.Y + tolerance + && adjacent.Y < blocker.Y + blocker.Height - tolerance; + } + + var movingDown = desiredDy > 0d; + var verticalBlocker = nodes + .Where(node => !string.Equals(node.Id, sourceNodeId, StringComparison.Ordinal) + && !string.Equals(node.Id, targetNodeId, StringComparison.Ordinal) + && boundary.X > node.X + tolerance + && boundary.X < node.X + node.Width - tolerance + && (movingDown + ? node.Y > boundary.Y + tolerance && node.Y < path[^1].Y - tolerance + : node.Y + node.Height < boundary.Y - tolerance && node.Y + node.Height > path[^1].Y + tolerance)) + .OrderBy(node => movingDown ? node.Y : -(node.Y + node.Height)) + .FirstOrDefault(); + if (verticalBlocker is null) + { + return false; + } + + return adjacent.X > verticalBlocker.X + tolerance + && adjacent.X < verticalBlocker.X + verticalBlocker.Width - tolerance; + } + + private static bool TryNormalizeTargetBoundaryAfterSourceRepair( + IReadOnlyList candidatePath, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + out List normalized) + { + normalized = candidatePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (string.IsNullOrWhiteSpace(targetNodeId) || normalized.Count < 2) + { + return true; + } + + var targetNode = nodes.FirstOrDefault(node => string.Equals(node.Id, targetNodeId, StringComparison.Ordinal)); + if (targetNode is null) + { + return true; + } + + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + if (!NeedsGatewayTargetBoundaryRepair(normalized, targetNode)) + { + return true; + } + + var repairedTargetCandidate = NormalizeGatewayEntryPath(normalized, targetNode, normalized[^1]); + if (!CanAcceptGatewayTargetRepair(repairedTargetCandidate, targetNode)) + { + return false; + } + + normalized = repairedTargetCandidate; + return true; + } + + if (TryRealignNonGatewayTargetBoundarySlot(normalized, targetNode, nodes, sourceNodeId, targetNodeId, out var realignedTargetCandidate)) + { + normalized = realignedTargetCandidate; + } + + if (HasValidBoundaryAngle(normalized[^1], normalized[^2], targetNode)) + { + return true; + } + + var targetSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[^1], targetNode); + var repairedNonGatewayTarget = NormalizeEntryPath(normalized, targetNode, targetSide); + if (!HasClearBoundarySegments(repairedNonGatewayTarget, nodes, sourceNodeId, targetNodeId, false, 3) + || !HasValidBoundaryAngle(repairedNonGatewayTarget[^1], repairedNonGatewayTarget[^2], targetNode)) + { + return false; + } + + normalized = repairedNonGatewayTarget; + return true; + } + + private static bool TryRealignNonGatewayTargetBoundarySlot( + IReadOnlyList path, + ElkPositionedNode targetNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + out List realigned) + { + realigned = path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (path.Count < 2) + { + return false; + } + + var side = ElkEdgeRoutingGeometry.ResolveBoundarySide(path[^1], targetNode); + if (side is not "left" and not "right" and not "top" and not "bottom") + { + return false; + } + + var approach = path[^2]; + var candidateEndpoint = side switch + { + "left" => new ElkPoint + { + X = targetNode.X, + Y = Math.Clamp(approach.Y, targetNode.Y, targetNode.Y + targetNode.Height), + }, + "right" => new ElkPoint + { + X = targetNode.X + targetNode.Width, + Y = Math.Clamp(approach.Y, targetNode.Y, targetNode.Y + targetNode.Height), + }, + "top" => new ElkPoint + { + X = Math.Clamp(approach.X, targetNode.X, targetNode.X + targetNode.Width), + Y = targetNode.Y, + }, + "bottom" => new ElkPoint + { + X = Math.Clamp(approach.X, targetNode.X, targetNode.X + targetNode.Width), + Y = targetNode.Y + targetNode.Height, + }, + _ => path[^1], + }; + + if (ElkEdgeRoutingGeometry.PointsEqual(candidateEndpoint, path[^1])) + { + return false; + } + + realigned[^1] = candidateEndpoint; + realigned = NormalizePathPoints(realigned); + if (!HasClearBoundarySegments(realigned, nodes, sourceNodeId, targetNodeId, false, 3) + || !HasValidBoundaryAngle(realigned[^1], realigned[^2], targetNode)) + { + realigned = path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + return false; + } + + var originalLength = ComputePathLength(path); + var realignedLength = ComputePathLength(realigned); + if (realignedLength + 0.5d < originalLength) + { + return true; + } + + var directlyAligned = side is "left" or "right" + ? Math.Abs(realigned[^1].Y - approach.Y) <= 0.6d + : Math.Abs(realigned[^1].X - approach.X) <= 0.6d; + return directlyAligned && realignedLength <= originalLength + 0.5d; + } + + private static IEnumerable EnumerateGatewayDirectRepairContinuationIndices( + IReadOnlyList path, + ElkPositionedNode sourceNode, + int firstExteriorIndex) + { + if (path.Count <= firstExteriorIndex) + { + yield return firstExteriorIndex; + yield break; + } + + var preferredIndex = FindPreferredGatewayExitContinuationIndex(path, sourceNode, firstExteriorIndex); + var curlRecoveryIndex = FindGatewaySourceCurlRecoveryIndex(path, firstExteriorIndex); + var seen = new HashSet(); + var candidates = new[] + { + firstExteriorIndex, + Math.Min(path.Count - 1, firstExteriorIndex + 1), + Math.Min(path.Count - 1, firstExteriorIndex + 2), + preferredIndex, + Math.Min(path.Count - 1, preferredIndex + 1), + curlRecoveryIndex ?? -1, + curlRecoveryIndex is int recoveryIndex + ? Math.Min(path.Count - 1, recoveryIndex + 1) + : -1, + }; + + foreach (var candidate in candidates) + { + if (candidate < firstExteriorIndex + || candidate >= path.Count + || ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(sourceNode, path[candidate]) + || !seen.Add(candidate)) + { + continue; + } + + yield return candidate; + } + } + + private static double ScoreGatewayDirectRepairCandidate( + IReadOnlyList originalPath, + IReadOnlyList candidate, + ElkPositionedNode sourceNode, + int continuationIndex) + { + var score = ComputePathLength(candidate) + + (Math.Max(0, candidate.Count - 2) * 4d) + + (continuationIndex * 6d) + + ScoreGatewayExitProgress(sourceNode, candidate, originalPath[^1]); + if (HasGatewaySourceExitBacktracking(candidate) + || HasGatewaySourceExitCurl(candidate)) + { + score += 100_000d; + } + + if (HasGatewaySourceDominantAxisDetour(candidate, sourceNode)) + { + score += 50_000d; + } + + if (HasGatewaySourcePreferredFaceMismatch(candidate, sourceNode)) + { + score += 25_000d; + } + + return score; + } + + private static bool IsMaterialGatewaySourceRepairImprovement( + IReadOnlyList originalPath, + IReadOnlyList candidate) + { + if (!PathChanged(originalPath, candidate)) + { + return false; + } + + var originalLength = ComputePathLength(originalPath); + var candidateLength = ComputePathLength(candidate); + var originalBends = Math.Max(0, originalPath.Count - 2); + var candidateBends = Math.Max(0, candidate.Count - 2); + var lengthGain = originalLength - candidateLength; + if (originalPath.Count <= 3 + && lengthGain < 24d + && candidateBends <= originalBends) + { + return false; + } + + if (lengthGain > 4d) + { + return true; + } + + if (lengthGain > 1d && candidateBends <= originalBends) + { + return true; + } + + if (candidateBends + 1 < originalBends + && candidateLength <= originalLength + 4d) + { + return true; + } + + return candidateBends < originalBends + && candidateLength <= originalLength + 1d; + } + + private static bool IsGatewaySourceGeometryRepairImprovement( + IReadOnlyList originalPath, + IReadOnlyList candidate, + ElkPositionedNode sourceNode) + { + if (!PathChanged(originalPath, candidate)) + { + return false; + } + + var originalHasGeometryDefect = NeedsGatewaySourceBoundaryRepair(originalPath, sourceNode) + || HasGatewaySourceExitBacktracking(originalPath) + || HasGatewaySourceExitCurl(originalPath) + || HasGatewaySourceDominantAxisDetour(originalPath, sourceNode) + || HasGatewaySourcePreferredFaceMismatch(originalPath, sourceNode) + || NeedsDecisionSourcePreferredFaceRepair(originalPath, sourceNode); + if (!originalHasGeometryDefect) + { + return false; + } + + var candidateIsClean = !NeedsGatewaySourceBoundaryRepair(candidate, sourceNode) + && !HasGatewaySourceExitBacktracking(candidate) + && !HasGatewaySourceExitCurl(candidate) + && !HasGatewaySourceDominantAxisDetour(candidate, sourceNode) + && !HasGatewaySourcePreferredFaceMismatch(candidate, sourceNode) + && !NeedsDecisionSourcePreferredFaceRepair(candidate, sourceNode); + if (!candidateIsClean) + { + return false; + } + + var originalLength = ComputePathLength(originalPath); + var candidateLength = ComputePathLength(candidate); + return candidateLength <= originalLength + 120d; + } + + private static bool TryResolvePreferredGatewaySourceBoundary( + ElkPositionedNode sourceNode, + ElkPoint referencePoint, + out ElkPoint boundary) + { + return TryResolvePreferredGatewaySourceBoundary( + sourceNode, + referencePoint, + referencePoint, + out boundary); + } + + private static bool TryResolvePreferredGatewaySourceBoundary( + ElkPositionedNode sourceNode, + ElkPoint continuationPoint, + ElkPoint referencePoint, + out ElkPoint boundary) + { + boundary = default!; + if (!ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + return false; + } + + if (sourceNode.Kind == "Decision") + { + boundary = ResolveDecisionSourceExitBoundary(sourceNode, continuationPoint, referencePoint); + return true; + } + + foreach (var preferredSide in EnumeratePreferredGatewaySourceSides(sourceNode, continuationPoint, referencePoint)) + { + foreach (var candidate in ResolveGatewaySourceBoundarySlotCandidates(sourceNode, preferredSide, continuationPoint, referencePoint)) + { + boundary = candidate; + return true; + } + } + + boundary = sourceNode.Kind == "Decision" + ? ResolveDecisionSourceExitBoundary(sourceNode, continuationPoint, referencePoint) + : ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary( + sourceNode, + ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, continuationPoint), + continuationPoint); + return true; + } + + private static bool HasProtectedGatewaySourceCorridorPath( + IReadOnlyList path, + IReadOnlyCollection nodes) + { + if (path.Count < 3 || nodes.Count == 0) + { + return false; + } + + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + return path.Skip(1).Any(point => point.Y < graphMinY - 8d || point.Y > graphMaxY + 8d); + } + + private static List SnapGatewaySourceStubToDominantAxis( + IReadOnlyList sourcePath, + ElkPositionedNode sourceNode, + ElkPoint referencePoint) + { + if (!ElkShapeBoundaries.IsGatewayShape(sourceNode) || sourcePath.Count < 2) + { + return sourcePath.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + const double axisTolerance = 4d; + var boundary = sourcePath[0]; + var adjacent = sourcePath[1]; + var desiredDx = referencePoint.X - boundary.X; + var desiredDy = referencePoint.Y - boundary.Y; + var dominantHorizontal = Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.15d && Math.Sign(desiredDx) != 0; + var dominantVertical = Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.15d && Math.Sign(desiredDy) != 0; + + if (!dominantHorizontal && !dominantVertical) + { + return sourcePath.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + var snapped = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + + if (dominantHorizontal && Math.Abs(adjacent.Y - boundary.Y) <= axisTolerance) + { + snapped[1] = new ElkPoint + { + X = adjacent.X, + Y = boundary.Y, + }; + } + else if (dominantVertical && Math.Abs(adjacent.X - boundary.X) <= axisTolerance) + { + snapped[1] = new ElkPoint + { + X = boundary.X, + Y = adjacent.Y, + }; + } + + return NormalizePathPoints(snapped); + } + + private static bool HasAxisReversalFromStart(IEnumerable values, double desiredDelta) + { + const double tolerance = 0.5d; + var distinctValues = new List(); + foreach (var value in values) + { + if (distinctValues.Count == 0 || Math.Abs(distinctValues[^1] - value) > tolerance) + { + distinctValues.Add(value); + } + } + + if (distinctValues.Count < 3) + { + return false; + } + + var nonZeroDirections = new List(); + for (var i = 1; i < distinctValues.Count; i++) + { + var delta = distinctValues[i] - distinctValues[i - 1]; + if (Math.Abs(delta) <= tolerance) + { + continue; + } + + nonZeroDirections.Add(Math.Sign(delta)); + } + + if (nonZeroDirections.Count < 2) + { + return false; + } + + if (Math.Abs(desiredDelta) <= tolerance) + { + return nonZeroDirections.Distinct().Count() > 1; + } + + var desiredSign = Math.Sign(desiredDelta); + var sawOpposite = false; + foreach (var direction in nonZeroDirections) + { + if (direction == desiredSign) + { + if (sawOpposite) + { + return true; + } + + continue; + } + + sawOpposite = true; + } + + return false; + } + + private static List NormalizeGatewayEntryPath( + IReadOnlyList sourcePath, + ElkPositionedNode targetNode, + ElkPoint assignedEndpoint) + { + var path = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (path.Count < 2) + { + return path; + } + + var exteriorIndex = FindLastGatewayExteriorPointIndex(path, targetNode); + var exteriorAnchor = path[exteriorIndex]; + var actualAdjacent = path[^2]; + var assignedApproach = ElkShapeBoundaries.IsGatewayBoundaryPoint(targetNode, assignedEndpoint) + ? ElkShapeBoundaries.BuildGatewayDirectionalExteriorPoint(targetNode, assignedEndpoint, exteriorAnchor) + : assignedEndpoint; + ElkPoint boundary; + var assignedEndpointUsable = ElkShapeBoundaries.IsGatewayBoundaryPoint(targetNode, assignedEndpoint) + && !ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, assignedApproach) + && ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, assignedEndpoint, assignedApproach) + && !ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, exteriorAnchor); + if (assignedEndpointUsable) + { + boundary = assignedEndpoint; + } + else + { + var boundaryCandidates = ResolveGatewayEntryBoundaryCandidates(targetNode, exteriorAnchor, assignedEndpoint).ToArray(); + boundary = boundaryCandidates.Length > 0 + ? boundaryCandidates + .OrderBy(candidate => ScoreGatewayEntryBoundaryCandidate(targetNode, candidate, exteriorAnchor, assignedEndpoint)) + .First() + : ElkShapeBoundaries.ProjectOntoShapeBoundary(targetNode, exteriorAnchor); + + if (!ElkShapeBoundaries.IsGatewayBoundaryPoint(targetNode, boundary) + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, boundary, exteriorAnchor)) + { + var fallbackBoundary = ElkShapeBoundaries.ProjectOntoShapeBoundary(targetNode, exteriorAnchor); + if (!ElkShapeBoundaries.TryProjectGatewayDiagonalBoundary(targetNode, exteriorAnchor, fallbackBoundary, out boundary)) + { + boundary = fallbackBoundary; + } + } + } + + boundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(targetNode, boundary, exteriorAnchor); + var directEntryCandidate = TryBuildDirectGatewayTargetEntry( + path, + targetNode, + exteriorIndex, + exteriorAnchor, + boundary, + assignedEndpoint); + if (ShouldPreferDirectGatewayTargetEntry( + directEntryCandidate, + targetNode, + assignedEndpoint, + preserveAssignedSlot: assignedEndpointUsable)) + { + return directEntryCandidate!; + } + + var exteriorApproach = ElkShapeBoundaries.BuildGatewayDirectionalExteriorPoint(targetNode, boundary, exteriorAnchor); + var rebuilt = path.Take(exteriorIndex + 1).ToList(); + if (rebuilt.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], exteriorAnchor)) + { + rebuilt.Add(exteriorAnchor); + } + + AppendGatewayTargetOrthogonalCorner( + rebuilt, + rebuilt[^1], + exteriorApproach, + rebuilt.Count >= 2 ? rebuilt[^2] : null, + preferHorizontalFromReference: ShouldPreferHorizontalGatewayExit(rebuilt[^1], exteriorApproach), + targetNode); + if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], exteriorApproach)) + { + rebuilt.Add(exteriorApproach); + } + + rebuilt.Add(boundary); + var normalizedRebuilt = NormalizePathPoints(rebuilt); + if (normalizedRebuilt.Count >= 2 + && ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, normalizedRebuilt[^2])) + { + var repairedAnchorIndex = FindLastGatewayExteriorPointIndex(normalizedRebuilt, targetNode); + var repairedAnchor = normalizedRebuilt[repairedAnchorIndex]; + var repairedApproach = ElkShapeBoundaries.BuildGatewayDirectionalExteriorPoint(targetNode, boundary, repairedAnchor); + var repaired = normalizedRebuilt.Take(repairedAnchorIndex + 1).ToList(); + if (repaired.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(repaired[^1], repairedAnchor)) + { + repaired.Add(repairedAnchor); + } + + AppendGatewayTargetOrthogonalCorner( + repaired, + repaired[^1], + repairedApproach, + repaired.Count >= 2 ? repaired[^2] : null, + preferHorizontalFromReference: ShouldPreferHorizontalGatewayExit(repaired[^1], repairedApproach), + targetNode); + if (!ElkEdgeRoutingGeometry.PointsEqual(repaired[^1], repairedApproach)) + { + repaired.Add(repairedApproach); + } + + repaired.Add(boundary); + normalizedRebuilt = NormalizePathPoints(repaired); + } + + if (normalizedRebuilt.Count >= 2 + && (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, normalizedRebuilt[^2]) + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, normalizedRebuilt[^1], normalizedRebuilt[^2]))) + { + normalizedRebuilt = ForceGatewayExteriorTargetApproach( + normalizedRebuilt, + targetNode, + boundary); + } + + normalizedRebuilt = PreferGatewayDiagonalTargetEntry(normalizedRebuilt, targetNode); + if (normalizedRebuilt.Count >= 2 + && !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, normalizedRebuilt[^1], normalizedRebuilt[^2])) + { + var slottedGatewayTargetRepair = TryBuildSlottedGatewayEntryPath(path, targetNode, exteriorIndex, exteriorAnchor); + if (slottedGatewayTargetRepair is not null + && ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, slottedGatewayTargetRepair[^1], slottedGatewayTargetRepair[^2])) + { + normalizedRebuilt = slottedGatewayTargetRepair; + } + } + + var preserveAssignedSlot = assignedEndpointUsable; + if (directEntryCandidate is not null + && (!preserveAssignedSlot + || ElkEdgeRoutingGeometry.ComputeSegmentLength(directEntryCandidate[^1], assignedEndpoint) <= 6d) + && (normalizedRebuilt.Count < 2 + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, normalizedRebuilt[^1], normalizedRebuilt[^2]) + || ComputePathLength(directEntryCandidate) <= ComputePathLength(normalizedRebuilt) + 2d + || directEntryCandidate.Count < normalizedRebuilt.Count)) + { + normalizedRebuilt = directEntryCandidate; + } + + normalizedRebuilt = CollapseGatewayTargetTailIfPossible(normalizedRebuilt, targetNode); + + return normalizedRebuilt; + } + + private static List ForceGatewayTargetBoundaryStub( + IReadOnlyList sourcePath, + ElkPositionedNode targetNode) + { + var path = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (path.Count < 2) + { + return path; + } + + var boundary = path[^1]; + var exteriorAnchor = path[^2]; + var exteriorApproach = ElkShapeBoundaries.BuildGatewayDirectionalExteriorPoint(targetNode, boundary, exteriorAnchor); + if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, exteriorApproach) + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, boundary, exteriorApproach)) + { + return path; + } + + var rebuilt = path.Take(path.Count - 1) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (rebuilt.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], exteriorAnchor)) + { + rebuilt.Add(exteriorAnchor); + } + + AppendGatewayTargetOrthogonalCorner( + rebuilt, + rebuilt[^1], + exteriorApproach, + rebuilt.Count >= 2 ? rebuilt[^2] : null, + preferHorizontalFromReference: ShouldPreferHorizontalGatewayExit(rebuilt[^1], exteriorApproach), + targetNode); + if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], exteriorApproach)) + { + rebuilt.Add(exteriorApproach); + } + + rebuilt.Add(boundary); + var normalized = NormalizePathPoints(rebuilt); + return normalized.Count >= 2 + && !ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, normalized[^2]) + && ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, normalized[^1], normalized[^2]) + ? normalized + : path; + } + + private static List? TryBuildSlottedGatewayEntryPath( + IReadOnlyList sourcePath, + ElkPositionedNode targetNode, + int exteriorIndex, + ElkPoint exteriorAnchor) + { + if (!ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + return null; + } + + var centerX = targetNode.X + (targetNode.Width / 2d); + var centerY = targetNode.Y + (targetNode.Height / 2d); + var deltaX = exteriorAnchor.X - centerX; + var deltaY = exteriorAnchor.Y - centerY; + string side; + double slotCoordinate; + if (Math.Abs(deltaX) >= Math.Abs(deltaY) * 1.15d) + { + side = deltaX <= 0d ? "left" : "right"; + slotCoordinate = exteriorAnchor.Y; + } + else + { + side = deltaY <= 0d ? "top" : "bottom"; + slotCoordinate = exteriorAnchor.X; + } + + if (!ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, side, slotCoordinate, out var boundary)) + { + return null; + } + + return TryBuildSlottedGatewayEntryPath(sourcePath, targetNode, exteriorIndex, exteriorAnchor, boundary); + } + + private static List? TryBuildSlottedGatewayEntryPath( + IReadOnlyList sourcePath, + ElkPositionedNode targetNode, + int exteriorIndex, + ElkPoint exteriorAnchor, + ElkPoint boundary) + { + if (!ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + return null; + } + + boundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(targetNode, boundary, exteriorAnchor); + var exteriorApproach = ElkShapeBoundaries.BuildGatewayDirectionalExteriorPoint(targetNode, boundary, exteriorAnchor); + var rebuilt = sourcePath.Take(exteriorIndex + 1) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (rebuilt.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], exteriorAnchor)) + { + rebuilt.Add(exteriorAnchor); + } + + AppendGatewayTargetOrthogonalCorner( + rebuilt, + rebuilt[^1], + exteriorApproach, + rebuilt.Count >= 2 ? rebuilt[^2] : null, + preferHorizontalFromReference: ShouldPreferHorizontalGatewayExit(rebuilt[^1], exteriorApproach), + targetNode); + if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], exteriorApproach)) + { + rebuilt.Add(exteriorApproach); + } + + rebuilt.Add(boundary); + var normalized = NormalizePathPoints(rebuilt); + if (normalized.Count >= 2 + && (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, normalized[^2]) + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, normalized[^1], normalized[^2]))) + { + normalized = ForceGatewayExteriorTargetApproach(normalized, targetNode, boundary); + } + + return normalized.Count >= 2 + && !ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, normalized[^2]) + && ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, normalized[^1], normalized[^2]) + ? normalized + : null; + } + + private static List? TryBuildDirectGatewayTargetEntry( + IReadOnlyList sourcePath, + ElkPositionedNode targetNode, + int exteriorIndex, + ElkPoint exteriorAnchor, + ElkPoint boundaryPoint, + ElkPoint assignedEndpoint) + { + if (!ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + return null; + } + + var prefix = sourcePath.Take(exteriorIndex + 1) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (prefix.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(prefix[^1], exteriorAnchor)) + { + prefix.Add(new ElkPoint { X = exteriorAnchor.X, Y = exteriorAnchor.Y }); + } + + var bestPath = default(List); + var bestScore = double.PositiveInfinity; + foreach (var candidate in ResolveDirectGatewayTargetBoundaryCandidates(targetNode, exteriorAnchor, boundaryPoint, assignedEndpoint)) + { + if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, exteriorAnchor) + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, candidate, exteriorAnchor)) + { + continue; + } + + var rebuilt = prefix + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + rebuilt.Add(candidate); + + var normalized = NormalizePathPoints(rebuilt); + if (normalized.Count < 2 + || ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, normalized[^2]) + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, normalized[^1], normalized[^2])) + { + continue; + } + + var score = ComputePathLength(normalized); + score += Math.Abs(candidate.X - boundaryPoint.X) + Math.Abs(candidate.Y - boundaryPoint.Y); + if (ElkShapeBoundaries.IsNearGatewayVertex(targetNode, candidate, 8d)) + { + score += 1_000d; + } + + if (score >= bestScore) + { + continue; + } + + bestScore = score; + bestPath = normalized; + } + + return bestPath; + } + + private static List ForceDecisionDirectTargetEntry( + IReadOnlyList sourcePath, + ElkPositionedNode targetNode) + { + if (targetNode.Kind != "Decision" || sourcePath.Count < 3) + { + return sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + } + + var anchor = sourcePath[^3]; + if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, anchor)) + { + return sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + } + + var boundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary( + targetNode, + ElkShapeBoundaries.ProjectOntoShapeBoundary(targetNode, anchor), + anchor); + if (ElkShapeBoundaries.TryProjectGatewayDiagonalBoundary(targetNode, anchor, boundary, out var diagonalBoundary)) + { + boundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(targetNode, diagonalBoundary, anchor); + } + + var rebuilt = sourcePath.Take(sourcePath.Count - 2) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + rebuilt.Add(boundary); + var normalized = NormalizePathPoints(rebuilt); + return CanAcceptGatewayTargetRepair(normalized, targetNode) + ? normalized + : sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + } + + private static List ForceDecisionExteriorTargetEntry( + IReadOnlyList sourcePath, + ElkPositionedNode targetNode) + { + var current = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (targetNode.Kind != "Decision" || current.Count < 2) + { + return current; + } + + var exteriorIndex = FindLastGatewayExteriorPointIndex(current, targetNode); + var exteriorAnchor = current[exteriorIndex]; + if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, exteriorAnchor)) + { + return current; + } + + var projectedBoundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary( + targetNode, + ElkShapeBoundaries.ProjectOntoShapeBoundary(targetNode, exteriorAnchor), + exteriorAnchor); + + List? bestPath = null; + var bestScore = double.PositiveInfinity; + foreach (var boundary in ResolveDirectGatewayTargetBoundaryCandidates( + targetNode, + exteriorAnchor, + projectedBoundary, + projectedBoundary)) + { + foreach (var exteriorApproach in ResolveForcedGatewayExteriorApproachCandidates( + targetNode, + boundary, + exteriorAnchor)) + { + var rebuilt = current.Take(exteriorIndex + 1) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (rebuilt.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], exteriorAnchor)) + { + rebuilt.Add(new ElkPoint { X = exteriorAnchor.X, Y = exteriorAnchor.Y }); + } + + AppendGatewayTargetOrthogonalCorner( + rebuilt, + rebuilt[^1], + exteriorApproach, + rebuilt.Count >= 2 ? rebuilt[^2] : null, + preferHorizontalFromReference: ShouldPreferHorizontalGatewayExit(rebuilt[^1], exteriorApproach), + targetNode); + if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], exteriorApproach)) + { + rebuilt.Add(new ElkPoint { X = exteriorApproach.X, Y = exteriorApproach.Y }); + } + + rebuilt.Add(new ElkPoint { X = boundary.X, Y = boundary.Y }); + var normalized = NormalizePathPoints(rebuilt); + if (!CanAcceptGatewayTargetRepair(normalized, targetNode)) + { + continue; + } + + var score = ComputePathLength(normalized); + if (score >= bestScore) + { + continue; + } + + bestScore = score; + bestPath = normalized; + } + } + + return bestPath ?? current; + } + + private static bool ShouldPreferDirectGatewayTargetEntry( + IReadOnlyList? candidate, + ElkPositionedNode targetNode, + ElkPoint assignedEndpoint, + bool preserveAssignedSlot) + { + if (candidate is null || candidate.Count < 2) + { + return false; + } + + if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, candidate[^2]) + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, candidate[^1], candidate[^2])) + { + return false; + } + + if (!preserveAssignedSlot) + { + return true; + } + + var endpointDelta = ElkEdgeRoutingGeometry.ComputeSegmentLength(candidate[^1], assignedEndpoint); + if (endpointDelta <= 6d) + { + return true; + } + + // Decision targets can still prefer a direct face entry, but join/fork + // targets must honor materially different assigned slots so target-side + // lane separation survives the normalization pass. + return targetNode.Kind == "Decision"; + } + + private static List CollapseGatewayTargetTailIfPossible( + IReadOnlyList sourcePath, + ElkPositionedNode targetNode) + { + if (!ElkShapeBoundaries.IsGatewayShape(targetNode) || sourcePath.Count < 3) + { + return sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + } + + var current = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + var boundary = current[^1]; + for (var anchorIndex = current.Count - 3; anchorIndex >= 0; anchorIndex--) + { + var anchor = current[anchorIndex]; + if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, anchor)) + { + continue; + } + + foreach (var candidateBoundary in ResolveDirectGatewayTargetBoundaryCandidates(targetNode, anchor, boundary, boundary)) + { + var rebuilt = current.Take(anchorIndex + 1) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + rebuilt.Add(candidateBoundary); + var normalized = NormalizePathPoints(rebuilt); + if (normalized.Count >= 2 + && CanAcceptGatewayTargetRepair(normalized, targetNode)) + { + return normalized; + } + } + } + + return current; + } + + private static IEnumerable ResolveDirectGatewayTargetBoundaryCandidates( + ElkPositionedNode targetNode, + ElkPoint exteriorAnchor, + ElkPoint boundaryPoint, + ElkPoint assignedEndpoint) + { + var candidates = new List(); + AddUniquePoint(candidates, boundaryPoint); + + var projectedBoundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary( + targetNode, + ElkShapeBoundaries.ProjectOntoShapeBoundary(targetNode, exteriorAnchor), + exteriorAnchor); + AddUniquePoint(candidates, projectedBoundary); + + if (ElkShapeBoundaries.IsGatewayBoundaryPoint(targetNode, assignedEndpoint)) + { + AddUniquePoint( + candidates, + ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(targetNode, assignedEndpoint, exteriorAnchor)); + } + + if (ElkShapeBoundaries.TryProjectGatewayDiagonalBoundary(targetNode, exteriorAnchor, projectedBoundary, out var diagonalProjected)) + { + AddUniquePoint( + candidates, + ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(targetNode, diagonalProjected, exteriorAnchor)); + } + + if (ElkShapeBoundaries.TryProjectGatewayDiagonalBoundary(targetNode, exteriorAnchor, boundaryPoint, out var diagonalBoundary)) + { + AddUniquePoint( + candidates, + ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(targetNode, diagonalBoundary, exteriorAnchor)); + } + + return candidates; + } + + private static List PreferGatewayDiagonalTargetEntry( + IReadOnlyList sourcePath, + ElkPositionedNode targetNode) + { + var path = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (!ElkShapeBoundaries.IsGatewayShape(targetNode) || path.Count < 3) + { + return path; + } + + const double tolerance = 0.5d; + var boundary = path[^1]; + var adjacent = path[^2]; + var previous = path[^3]; + var lastOrthogonal = Math.Abs(boundary.X - adjacent.X) <= tolerance || Math.Abs(boundary.Y - adjacent.Y) <= tolerance; + var previousOrthogonal = path.Count == 3 + || Math.Abs(adjacent.X - previous.X) <= tolerance + || Math.Abs(adjacent.Y - previous.Y) <= tolerance; + if (!lastOrthogonal || !previousOrthogonal) + { + return path; + } + + if (Math.Abs(boundary.X - previous.X) <= tolerance + || Math.Abs(boundary.Y - previous.Y) <= tolerance + || ElkShapeBoundaries.IsNearGatewayVertex(targetNode, boundary, 8d) + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, boundary, previous)) + { + var projectedBoundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary( + targetNode, + ElkShapeBoundaries.ProjectOntoShapeBoundary(targetNode, previous), + previous); + if (ElkShapeBoundaries.TryProjectGatewayDiagonalBoundary(targetNode, previous, projectedBoundary, out var diagonalBoundary)) + { + projectedBoundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(targetNode, diagonalBoundary, previous); + } + + boundary = projectedBoundary; + } + + if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, previous) + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, boundary, previous)) + { + return path; + } + + var rebuilt = path.Take(path.Count - 2) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + rebuilt.Add(new ElkPoint { X = boundary.X, Y = boundary.Y }); + return NormalizePathPoints(rebuilt); + } + + private static IEnumerable ResolveGatewayEntryBoundaryCandidates( + ElkPositionedNode targetNode, + ElkPoint exteriorAnchor, + ElkPoint assignedEndpoint) + { + var candidates = new List(); + AddUniquePoint( + candidates, + ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary( + targetNode, + ElkShapeBoundaries.ProjectOntoShapeBoundary(targetNode, exteriorAnchor), + exteriorAnchor)); + + var projectedAssigned = ElkShapeBoundaries.ProjectOntoShapeBoundary(targetNode, assignedEndpoint); + AddUniquePoint( + candidates, + ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(targetNode, projectedAssigned, exteriorAnchor)); + + if (ElkShapeBoundaries.IsGatewayBoundaryPoint(targetNode, assignedEndpoint)) + { + AddUniquePoint( + candidates, + ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(targetNode, assignedEndpoint, exteriorAnchor)); + } + + foreach (var side in EnumeratePreferredGatewayEntrySides(targetNode, exteriorAnchor)) + { + var centerX = targetNode.X + (targetNode.Width / 2d); + var centerY = targetNode.Y + (targetNode.Height / 2d); + var slotCoordinate = side is "left" or "right" + ? centerY + Math.Clamp(exteriorAnchor.Y - centerY, -targetNode.Height * 0.18d, targetNode.Height * 0.18d) + : centerX + Math.Clamp(exteriorAnchor.X - centerX, -targetNode.Width * 0.18d, targetNode.Width * 0.18d); + if (!ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, side, slotCoordinate, out var slotBoundary)) + { + continue; + } + + AddUniquePoint( + candidates, + ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(targetNode, slotBoundary, exteriorAnchor)); + } + + if (ElkShapeBoundaries.TryProjectGatewayDiagonalBoundary(targetNode, exteriorAnchor, projectedAssigned, out var diagonalBoundary)) + { + AddUniquePoint( + candidates, + ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(targetNode, diagonalBoundary, exteriorAnchor)); + } + + return candidates; + } + + private static IEnumerable ResolveGatewayExteriorApproachCandidates( + ElkPositionedNode node, + ElkPoint boundary, + ElkPoint referencePoint, + double padding = 8d) + { + var candidates = new List(); + AddUniquePoint( + candidates, + ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(node, boundary, referencePoint, padding)); + var faceNormalCandidate = ElkShapeBoundaries.BuildGatewayExteriorApproachPoint(node, boundary, padding); + AddUniquePoint(candidates, faceNormalCandidate); + + var horizontalDirection = Math.Sign(referencePoint.X - boundary.X); + if (horizontalDirection != 0d) + { + AddUniquePoint( + candidates, + new ElkPoint + { + X = horizontalDirection > 0d + ? node.X + node.Width + padding + : node.X - padding, + Y = boundary.Y, + }); + } + + var verticalDirection = Math.Sign(referencePoint.Y - boundary.Y); + if (verticalDirection != 0d) + { + AddUniquePoint( + candidates, + new ElkPoint + { + X = boundary.X, + Y = verticalDirection > 0d + ? node.Y + node.Height + padding + : node.Y - padding, + }); + } + + return candidates + .Where(candidate => !ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(node, candidate) + && ElkShapeBoundaries.HasValidGatewayBoundaryAngle(node, boundary, candidate)) + .OrderBy(candidate => ScoreGatewayExteriorApproachCandidate(node, boundary, candidate, referencePoint)) + .ToArray(); + } + + private static IEnumerable EnumeratePreferredGatewaySourceSides( + ElkPositionedNode sourceNode, + ElkPoint continuationPoint, + ElkPoint referencePoint) + { + var centerX = sourceNode.X + (sourceNode.Width / 2d); + var centerY = sourceNode.Y + (sourceNode.Height / 2d); + var continuationDx = continuationPoint.X - centerX; + var continuationDy = continuationPoint.Y - centerY; + var referenceDx = referencePoint.X - centerX; + var referenceDy = referencePoint.Y - centerY; + var effectiveDx = Math.Abs(continuationDx) > 12d ? continuationDx : referenceDx; + var effectiveDy = Math.Abs(continuationDy) > 12d ? continuationDy : referenceDy; + var absDx = Math.Abs(effectiveDx); + var absDy = Math.Abs(effectiveDy); + + var primary = absDx > 12d && (absDx >= absDy * 0.55d || absDy < 20d) + ? effectiveDx >= 0d ? "right" : "left" + : absDy > 12d + ? effectiveDy >= 0d ? "bottom" : "top" + : Math.Abs(referenceDx) >= Math.Abs(referenceDy) + ? referenceDx >= 0d ? "right" : "left" + : referenceDy >= 0d ? "bottom" : "top"; + yield return primary; + + string? secondary = null; + if (primary is "left" or "right") + { + if (absDy > 12d) + { + secondary = effectiveDy >= 0d ? "bottom" : "top"; + } + } + else if (absDx > 12d) + { + secondary = effectiveDx >= 0d ? "right" : "left"; + } + + if (secondary is not null + && !string.Equals(primary, secondary, StringComparison.Ordinal)) + { + yield return secondary; + } + + var referencePrimary = Math.Abs(referenceDx) > 12d && Math.Abs(referenceDx) >= Math.Abs(referenceDy) * 0.55d + ? referenceDx >= 0d ? "right" : "left" + : Math.Abs(referenceDy) > 12d + ? referenceDy >= 0d ? "bottom" : "top" + : null; + if (referencePrimary is not null + && !string.Equals(referencePrimary, primary, StringComparison.Ordinal) + && !string.Equals(referencePrimary, secondary, StringComparison.Ordinal)) + { + yield return referencePrimary; + } + } + + private static bool TryProjectGatewaySourceBoundarySlot( + ElkPositionedNode sourceNode, + string side, + ElkPoint continuationPoint, + ElkPoint referencePoint, + out ElkPoint boundary) + { + boundary = default!; + var slotCoordinate = ResolveGatewaySourceSlotCoordinate(sourceNode, side, continuationPoint, referencePoint); + if (!ElkShapeBoundaries.TryProjectGatewayBoundarySlot(sourceNode, side, slotCoordinate, out boundary)) + { + return false; + } + + boundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(sourceNode, boundary, continuationPoint); + return true; + } + + private static IEnumerable ResolveGatewaySourceBoundarySlotCandidates( + ElkPositionedNode sourceNode, + string side, + ElkPoint continuationPoint, + ElkPoint referencePoint) + { + var candidates = new List(); + foreach (var slotCoordinate in EnumerateGatewaySourceSlotCoordinates(sourceNode, side, continuationPoint, referencePoint)) + { + if (!ElkShapeBoundaries.TryProjectGatewayBoundarySlot(sourceNode, side, slotCoordinate, out var boundary)) + { + continue; + } + + boundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(sourceNode, boundary, continuationPoint); + AddUniquePoint(candidates, boundary); + } + + return candidates; + } + + private static double ResolveGatewaySourceSlotCoordinate( + ElkPositionedNode sourceNode, + string side, + ElkPoint continuationPoint, + ElkPoint referencePoint) + { + var centerX = sourceNode.X + (sourceNode.Width / 2d); + var centerY = sourceNode.Y + (sourceNode.Height / 2d); + var referenceDx = referencePoint.X - centerX; + var referenceDy = referencePoint.Y - centerY; + var dominantHorizontal = Math.Abs(referenceDx) >= Math.Abs(referenceDy) * 1.25d; + var dominantVertical = Math.Abs(referenceDy) >= Math.Abs(referenceDx) * 1.25d; + + return side is "left" or "right" + ? Math.Clamp( + dominantHorizontal + ? centerY + Math.Clamp(referenceDy, -sourceNode.Height * 0.18d, sourceNode.Height * 0.18d) + : Math.Abs(continuationPoint.Y - centerY) > 2d + ? continuationPoint.Y + : referencePoint.Y, + sourceNode.Y + 4d, + sourceNode.Y + sourceNode.Height - 4d) + : Math.Clamp( + dominantVertical + ? centerX + Math.Clamp(referenceDx, -sourceNode.Width * 0.18d, sourceNode.Width * 0.18d) + : Math.Abs(continuationPoint.X - centerX) > 2d + ? continuationPoint.X + : referencePoint.X, + sourceNode.X + 4d, + sourceNode.X + sourceNode.Width - 4d); + } + + private static IEnumerable EnumerateGatewaySourceSlotCoordinates( + ElkPositionedNode sourceNode, + string side, + ElkPoint continuationPoint, + ElkPoint referencePoint) + { + var primary = ResolveGatewaySourceSlotCoordinate(sourceNode, side, continuationPoint, referencePoint); + yield return primary; + + var center = side is "left" or "right" + ? sourceNode.Y + (sourceNode.Height / 2d) + : sourceNode.X + (sourceNode.Width / 2d); + if (Math.Abs(center - primary) > 1d) + { + yield return center; + } + + var alternate = side is "left" or "right" + ? Math.Clamp(referencePoint.Y, sourceNode.Y + 4d, sourceNode.Y + sourceNode.Height - 4d) + : Math.Clamp(referencePoint.X, sourceNode.X + 4d, sourceNode.X + sourceNode.Width - 4d); + if (Math.Abs(alternate - primary) > 1d) + { + yield return alternate; + } + + var blended = side is "left" or "right" + ? Math.Clamp((continuationPoint.Y + referencePoint.Y) / 2d, sourceNode.Y + 4d, sourceNode.Y + sourceNode.Height - 4d) + : Math.Clamp((continuationPoint.X + referencePoint.X) / 2d, sourceNode.X + 4d, sourceNode.X + sourceNode.Width - 4d); + if (Math.Abs(blended - primary) > 1d && Math.Abs(blended - alternate) > 1d && Math.Abs(blended - center) > 1d) + { + yield return blended; + } + } + + private static bool IsBoundaryOnGatewaySourceSide( + ElkPositionedNode sourceNode, + ElkPoint boundary, + string side) + { + var centerX = sourceNode.X + (sourceNode.Width / 2d); + var centerY = sourceNode.Y + (sourceNode.Height / 2d); + var deltaX = boundary.X - centerX; + var deltaY = boundary.Y - centerY; + + return side switch + { + "right" => deltaX > 0d && Math.Abs(deltaX) >= Math.Abs(deltaY) * 0.35d, + "left" => deltaX < 0d && Math.Abs(deltaX) >= Math.Abs(deltaY) * 0.35d, + "bottom" => deltaY > 0d && Math.Abs(deltaY) >= Math.Abs(deltaX) * 0.35d, + "top" => deltaY < 0d && Math.Abs(deltaY) >= Math.Abs(deltaX) * 0.35d, + _ => false, + }; + } + + private static IEnumerable EnumeratePreferredGatewayEntrySides( + ElkPositionedNode targetNode, + ElkPoint exteriorAnchor) + { + var centerX = targetNode.X + (targetNode.Width / 2d); + var centerY = targetNode.Y + (targetNode.Height / 2d); + var deltaX = exteriorAnchor.X - centerX; + var deltaY = exteriorAnchor.Y - centerY; + var absDx = Math.Abs(deltaX); + var absDy = Math.Abs(deltaY); + var primary = absDx >= absDy + ? (deltaX >= 0d ? "right" : "left") + : (deltaY >= 0d ? "bottom" : "top"); + yield return primary; + + if (absDx > 0.5d && absDy > 0.5d) + { + var secondary = primary is "left" or "right" + ? (deltaY >= 0d ? "bottom" : "top") + : (deltaX >= 0d ? "right" : "left"); + if (!string.Equals(primary, secondary, StringComparison.Ordinal)) + { + yield return secondary; + } + } + } + + private static double ScoreGatewayEntryBoundaryCandidate( + ElkPositionedNode targetNode, + ElkPoint candidate, + ElkPoint exteriorAnchor, + ElkPoint assignedEndpoint) + { + if (!ElkShapeBoundaries.IsGatewayBoundaryPoint(targetNode, candidate)) + { + return double.PositiveInfinity; + } + + var exteriorApproach = ElkShapeBoundaries.BuildGatewayDirectionalExteriorPoint(targetNode, candidate, exteriorAnchor); + if (!ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, candidate, exteriorApproach)) + { + return double.PositiveInfinity; + } + + var centerX = targetNode.X + (targetNode.Width / 2d); + var centerY = targetNode.Y + (targetNode.Height / 2d); + var desiredDx = exteriorAnchor.X - centerX; + var desiredDy = exteriorAnchor.Y - centerY; + var candidateDx = candidate.X - centerX; + var candidateDy = candidate.Y - centerY; + var score = Math.Abs(candidate.X - exteriorAnchor.X) + Math.Abs(candidate.Y - exteriorAnchor.Y); + score += (Math.Abs(candidate.X - assignedEndpoint.X) + Math.Abs(candidate.Y - assignedEndpoint.Y)) * 0.2d; + score += (Math.Abs(exteriorApproach.X - exteriorAnchor.X) + Math.Abs(exteriorApproach.Y - exteriorAnchor.Y)) * 0.05d; + + var dominantHorizontal = Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.25d; + var dominantVertical = Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.25d; + if (dominantHorizontal) + { + if (Math.Sign(candidateDx) != Math.Sign(desiredDx)) + { + score += 10_000d; + } + + score += Math.Abs(candidateDy) * 6d; + } + else if (dominantVertical) + { + if (Math.Sign(candidateDy) != Math.Sign(desiredDy)) + { + score += 10_000d; + } + + score += Math.Abs(candidateDx) * 6d; + } + else + { + score += (Math.Abs(candidateDx - desiredDx) + Math.Abs(candidateDy - desiredDy)) * 0.08d; + } + + if (ElkShapeBoundaries.IsNearGatewayVertex(targetNode, candidate, 8d)) + { + score += 4_000d; + } + + return score; + } + + private static double ScoreGatewayExteriorApproachCandidate( + ElkPositionedNode node, + ElkPoint boundary, + ElkPoint candidate, + ElkPoint referencePoint) + { + var deltaX = candidate.X - boundary.X; + var deltaY = candidate.Y - boundary.Y; + var moveLength = Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY)); + var referenceDistance = Math.Abs(referencePoint.X - candidate.X) + Math.Abs(referencePoint.Y - candidate.Y); + var score = moveLength + (referenceDistance * 0.1d); + var dominantHorizontal = Math.Abs(referencePoint.X - boundary.X) >= Math.Abs(referencePoint.Y - boundary.Y) * 1.2d; + var dominantVertical = Math.Abs(referencePoint.Y - boundary.Y) >= Math.Abs(referencePoint.X - boundary.X) * 1.2d; + + if (node.Kind == "Decision" + && !ElkShapeBoundaries.IsNearGatewayVertex(node, boundary, 8d)) + { + var preferredCandidate = ElkShapeBoundaries.BuildGatewayExteriorApproachPoint(node, boundary); + var isAxisAlignedStub = Math.Abs(deltaX) <= 0.5d || Math.Abs(deltaY) <= 0.5d; + if (!dominantHorizontal && !dominantVertical) + { + if (ElkEdgeRoutingGeometry.PointsEqual(candidate, preferredCandidate)) + { + score -= 220d; + } + else + { + if (isAxisAlignedStub) + { + score += 120d; + } + + score += Math.Abs(Math.Abs(deltaX) - Math.Abs(deltaY)) * 0.25d; + } + } + else + { + if (dominantHorizontal) + { + if (Math.Abs(deltaX) <= 0.5d || Math.Sign(deltaX) != Math.Sign(referencePoint.X - boundary.X)) + { + score += 8_000d; + } + + if (Math.Abs(deltaY) <= 0.5d) + { + score -= 120d; + } + + score += Math.Abs(deltaY) * 8d; + } + else if (dominantVertical) + { + if (Math.Abs(deltaY) <= 0.5d || Math.Sign(deltaY) != Math.Sign(referencePoint.Y - boundary.Y)) + { + score += 8_000d; + } + + if (Math.Abs(deltaX) <= 0.5d) + { + score -= 120d; + } + + score += Math.Abs(deltaX) * 8d; + } + } + } + + if (dominantHorizontal) + { + if (Math.Sign(deltaX) != Math.Sign(referencePoint.X - boundary.X)) + { + score += 10_000d; + } + + score += Math.Abs(deltaY) * 0.35d; + } + else if (dominantVertical) + { + if (Math.Sign(deltaY) != Math.Sign(referencePoint.Y - boundary.Y)) + { + score += 10_000d; + } + + score += Math.Abs(deltaX) * 0.35d; + } + + return score; + } + private static List TrimTargetApproachBacktracking( IReadOnlyList sourcePath, ElkPositionedNode targetNode, @@ -837,6 +9560,168 @@ internal static class ElkEdgePostProcessor return NormalizeEntryPath(trimmed, targetNode, side, explicitEndpoint); } + private static bool TryNormalizeNonGatewayBacktrackingEntry( + IReadOnlyList sourcePath, + ElkPositionedNode targetNode, + out List repairedPath) + { + repairedPath = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (sourcePath.Count < 2) + { + return false; + } + + if (!TryResolveNonGatewayBacktrackingEndpoint(sourcePath, targetNode, out var side, out var endpoint)) + { + return false; + } + + var candidate = NormalizeEntryPath(sourcePath, targetNode, side, endpoint); + if (HasTargetApproachBacktracking(candidate, targetNode)) + { + return false; + } + + repairedPath = candidate; + return true; + } + + private static bool TryResolveNonGatewayBacktrackingEndpoint( + IReadOnlyList sourcePath, + ElkPositionedNode targetNode, + out string side, + out ElkPoint endpoint) + { + side = string.Empty; + endpoint = default!; + if (sourcePath.Count < 2) + { + return false; + } + + var anchor = sourcePath[^2]; + var centerX = targetNode.X + (targetNode.Width / 2d); + var centerY = targetNode.Y + (targetNode.Height / 2d); + var deltaX = anchor.X - centerX; + var deltaY = anchor.Y - centerY; + var dominantHorizontal = Math.Abs(deltaX) >= Math.Abs(deltaY) * 1.15d; + side = dominantHorizontal + ? (deltaX <= 0d ? "left" : "right") + : (deltaY <= 0d ? "top" : "bottom"); + + if (side is "left" or "right") + { + endpoint = new ElkPoint + { + X = side == "left" ? targetNode.X : targetNode.X + targetNode.Width, + Y = Math.Clamp(anchor.Y, targetNode.Y + 4d, targetNode.Y + targetNode.Height - 4d), + }; + } + else + { + endpoint = new ElkPoint + { + X = Math.Clamp(anchor.X, targetNode.X + 4d, targetNode.X + targetNode.Width - 4d), + Y = side == "top" ? targetNode.Y : targetNode.Y + targetNode.Height, + }; + } + + return true; + } + + private static bool HasTargetApproachBacktracking( + IReadOnlyList path, + ElkPositionedNode targetNode) + { + if (path.Count < 3 || ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + return false; + } + + var side = ElkEdgeRoutingGeometry.ResolveBoundarySide(path[^1], targetNode); + if (side is not "left" and not "right" and not "top" and not "bottom") + { + return false; + } + + const double tolerance = 0.5d; + var startIndex = Math.Max(0, path.Count - (side is "left" or "right" ? 4 : 3)); + var axisValues = new List(path.Count - startIndex); + for (var i = startIndex; i < path.Count; i++) + { + var value = side is "left" or "right" + ? path[i].X + : path[i].Y; + if (axisValues.Count == 0 || Math.Abs(axisValues[^1] - value) > tolerance) + { + axisValues.Add(value); + } + } + + if (axisValues.Count < 3) + { + return false; + } + + var targetAxis = side switch + { + "left" => targetNode.X, + "right" => targetNode.X + targetNode.Width, + "top" => targetNode.Y, + "bottom" => targetNode.Y + targetNode.Height, + _ => double.NaN, + }; + + var overshootsTargetSide = side switch + { + "left" or "top" => axisValues.Any(value => value > targetAxis + tolerance), + "right" or "bottom" => axisValues.Any(value => value < targetAxis - tolerance), + _ => false, + }; + if (overshootsTargetSide) + { + return true; + } + + var expectsIncreasing = side is "left" or "top"; + var sawProgress = false; + for (var i = 1; i < axisValues.Count; i++) + { + var delta = axisValues[i] - axisValues[i - 1]; + if (Math.Abs(delta) <= tolerance) + { + continue; + } + + if (expectsIncreasing) + { + if (delta > tolerance) + { + sawProgress = true; + } + else if (sawProgress) + { + return true; + } + } + else + { + if (delta < -tolerance) + { + sawProgress = true; + } + else if (sawProgress) + { + return true; + } + } + } + + return false; + } + private static bool IsOnWrongSideOfTarget( ElkPoint point, ElkPositionedNode targetNode, @@ -866,11 +9751,6 @@ internal static class ElkEdgePostProcessor foreach (var edge in edges) { - if (restrictedEdgeIds is not null && !restrictedEdgeIds.Contains(edge.Id)) - { - continue; - } - if (!ShouldSpreadTargetApproach(edge, graphMinY, graphMaxY) || !nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)) { @@ -884,7 +9764,7 @@ internal static class ElkEdgePostProcessor } var endpoint = path[^1]; - var side = ElkEdgeRoutingGeometry.ResolveBoundarySide(endpoint, targetNode); + var side = ResolveTargetApproachSide(path, targetNode); var key = $"{targetNode.Id}|{side}"; if (!groups.TryGetValue(key, out var group)) { @@ -910,11 +9790,17 @@ internal static class ElkEdgePostProcessor continue; } + if (restrictedEdgeIds is not null + && !group.Any(item => restrictedEdgeIds.Contains(item.EdgeId))) + { + continue; + } + var sideLength = side is "left" or "right" ? Math.Max(8d, targetNode.Height - 8d) : Math.Max(8d, targetNode.Width - 8d); var slotSpacing = group.Count > 1 - ? Math.Max(12d, Math.Min(minLineClearance, sideLength / (group.Count - 1))) + ? ResolveBoundaryJoinSlotSpacing(minLineClearance, sideLength, group.Count) : 0d; var totalSpan = (group.Count - 1) * slotSpacing; @@ -926,11 +9812,22 @@ internal static class ElkEdgePostProcessor for (var i = 0; i < sorted.Length; i++) { var slotY = Math.Min(targetNode.Y + targetNode.Height - 4d, startY + (i * slotSpacing)); - result[sorted[i].EdgeId] = new ElkPoint + if (restrictedEdgeIds is not null && !restrictedEdgeIds.Contains(sorted[i].EdgeId)) + { + continue; + } + + var slotPoint = new ElkPoint { X = side == "left" ? targetNode.X : targetNode.X + targetNode.Width, Y = slotY, }; + if (ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, side, slotY, out var gatewaySlot)) + { + slotPoint = gatewaySlot; + } + + result[sorted[i].EdgeId] = slotPoint; } } else @@ -941,11 +9838,22 @@ internal static class ElkEdgePostProcessor for (var i = 0; i < sorted.Length; i++) { var slotX = Math.Min(targetNode.X + targetNode.Width - 4d, startX + (i * slotSpacing)); - result[sorted[i].EdgeId] = new ElkPoint + if (restrictedEdgeIds is not null && !restrictedEdgeIds.Contains(sorted[i].EdgeId)) + { + continue; + } + + var slotPoint = new ElkPoint { X = slotX, Y = side == "top" ? targetNode.Y : targetNode.Y + targetNode.Height, }; + if (ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, side, slotX, out var gatewaySlot)) + { + slotPoint = gatewaySlot; + } + + result[sorted[i].EdgeId] = slotPoint; } } } @@ -953,6 +9861,2629 @@ internal static class ElkEdgePostProcessor return result; } + private static bool GroupHasTargetApproachJoin( + IReadOnlyList<(IReadOnlyList Path, string Side)> entries, + double minLineClearance) + { + for (var i = 0; i < entries.Count; i++) + { + var left = entries[i]; + if (!TryExtractTargetApproachRun(left.Path, left.Side, out var leftRunStartIndex, out var leftRunEndIndex)) + { + continue; + } + + var leftStart = left.Path[leftRunStartIndex]; + var leftEnd = left.Path[leftRunEndIndex]; + for (var j = i + 1; j < entries.Count; j++) + { + var right = entries[j]; + if (!string.Equals(left.Side, right.Side, StringComparison.Ordinal) + || !TryExtractTargetApproachRun(right.Path, right.Side, out var rightRunStartIndex, out var rightRunEndIndex)) + { + continue; + } + + var rightStart = right.Path[rightRunStartIndex]; + var rightEnd = right.Path[rightRunEndIndex]; + if (ElkEdgeRoutingGeometry.AreParallelAndClose(leftStart, leftEnd, rightStart, rightEnd, minLineClearance)) + { + return true; + } + } + } + + return false; + } + + private static double ResolveBoundaryJoinSlotSpacing( + double minLineClearance, + double sideLength, + int entryCount) + { + if (entryCount <= 1) + { + return 0d; + } + + // Keep slot spacing slightly above the violation threshold so a final + // normalize pass does not collapse two target lanes back into the same + // effective rail by a fraction of a pixel. + var desiredSpacing = minLineClearance + 6d; + return Math.Max(12d, Math.Min(desiredSpacing, sideLength / (entryCount - 1))); + } + + private static string ResolveTargetApproachSide( + IReadOnlyList path, + ElkPositionedNode targetNode) + { + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + if (path.Count >= 2) + { + return ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(path[^1], path[^2], targetNode); + } + + return ElkEdgeRoutingGeometry.ResolveBoundarySide(path[^1], targetNode); + } + + if (path.Count < 2) + { + return ElkEdgeRoutingGeometry.ResolveBoundarySide(path[^1], targetNode); + } + + return ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(path[^1], path[^2], targetNode); + } + + private static double ResolveTargetApproachAxisValue( + IReadOnlyList path, + string side) + { + if (!TryExtractTargetApproachRun(path, side, out var runStartIndex, out _)) + { + return double.NaN; + } + + return side switch + { + "left" or "right" => path[runStartIndex].X, + "top" or "bottom" => path[runStartIndex].Y, + _ => double.NaN, + }; + } + + private static double ResolveSpreadableTargetApproachAxis( + IReadOnlyList path, + ElkPositionedNode targetNode, + string side, + double minLineClearance) + { + if (!TryExtractTargetApproachRun(path, side, out var runStartIndex, out _)) + { + return double.NaN; + } + + var rawAxis = ResolveTargetApproachAxisValue(path, side); + if (double.IsNaN(rawAxis)) + { + return double.NaN; + } + + var maxOffset = Math.Max( + Math.Max(targetNode.Width, targetNode.Height), + (minLineClearance * 2d) + 16d); + + return side switch + { + "left" => runStartIndex == 0 + ? Math.Max(rawAxis, targetNode.X - maxOffset) + : Math.Max(rawAxis, targetNode.X - maxOffset), + "right" => runStartIndex == 0 + ? Math.Min(rawAxis, targetNode.X + targetNode.Width + maxOffset) + : Math.Min(rawAxis, targetNode.X + targetNode.Width + maxOffset), + "top" => runStartIndex == 0 + ? Math.Max(rawAxis, targetNode.Y - maxOffset) + : Math.Max(rawAxis, targetNode.Y - maxOffset), + "bottom" => runStartIndex == 0 + ? Math.Min(rawAxis, targetNode.Y + targetNode.Height + maxOffset) + : Math.Min(rawAxis, targetNode.Y + targetNode.Height + maxOffset), + _ => rawAxis, + }; + } + + private static string ResolveSourceDepartureSide( + IReadOnlyList path, + ElkPositionedNode sourceNode) + { + if (path.Count < 2) + { + return ElkEdgeRoutingGeometry.ResolveBoundarySide(path[0], sourceNode); + } + + return ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(path[0], path[1], sourceNode); + } + + private static double ResolveDefaultSourceDepartureAxis( + ElkPositionedNode sourceNode, + string side) + { + return side switch + { + "left" => sourceNode.X - 24d, + "right" => sourceNode.X + sourceNode.Width + 24d, + "top" => sourceNode.Y - 24d, + "bottom" => sourceNode.Y + sourceNode.Height + 24d, + _ => 0d, + }; + } + + private static double ResolveDefaultTargetApproachAxis( + ElkPositionedNode targetNode, + string side) + { + return side switch + { + "left" => targetNode.X - 24d, + "right" => targetNode.X + targetNode.Width + 24d, + "top" => targetNode.Y - 24d, + "bottom" => targetNode.Y + targetNode.Height + 24d, + _ => double.NaN, + }; + } + + private static double ResolveDesiredTargetApproachAxis( + ElkPositionedNode targetNode, + string side, + double baseApproachAxis, + double slotSpacing, + int slotIndex, + bool forceOutwardFromBoundary = false) + { + var originAxis = double.IsNaN(baseApproachAxis) + ? ResolveDefaultTargetApproachAxis(targetNode, side) + : baseApproachAxis; + + var axis = forceOutwardFromBoundary + ? side switch + { + "left" or "top" => originAxis - (slotIndex * slotSpacing), + "right" or "bottom" => originAxis + (slotIndex * slotSpacing), + _ => originAxis, + } + : originAxis + (slotIndex * slotSpacing); + + return side switch + { + "left" => Math.Min(axis, targetNode.X - 8d), + "right" => Math.Max(axis, targetNode.X + targetNode.Width + 8d), + "top" => Math.Min(axis, targetNode.Y - 8d), + "bottom" => Math.Max(axis, targetNode.Y + targetNode.Height + 8d), + _ => axis, + }; + } + + private static bool GroupHasMixedNodeFaceLaneConflict( + IReadOnlyList<(int Index, ElkRoutedEdge Edge, IReadOnlyList Path, ElkPositionedNode Node, string Side, bool IsOutgoing, ElkPoint Boundary, double BoundaryCoordinate, double AxisValue)> entries, + double minLineClearance) + { + for (var i = 0; i < entries.Count; i++) + { + for (var j = i + 1; j < entries.Count; j++) + { + if (entries[i].IsOutgoing == entries[j].IsOutgoing + || !string.Equals(entries[i].Side, entries[j].Side, StringComparison.Ordinal)) + { + continue; + } + + var outgoing = entries[i].IsOutgoing ? entries[i] : entries[j]; + var incoming = entries[i].IsOutgoing ? entries[j] : entries[i]; + if (!TryExtractSourceDepartureRun(outgoing.Path, outgoing.Side, out _, out var outgoingRunEndIndex) + || !TryExtractTargetApproachRun(incoming.Path, incoming.Side, out var incomingRunStartIndex, out _)) + { + continue; + } + + if (ElkEdgeRoutingGeometry.AreParallelAndClose( + outgoing.Path[0], + outgoing.Path[outgoingRunEndIndex], + incoming.Path[incomingRunStartIndex], + incoming.Path[^1], + minLineClearance)) + { + return true; + } + } + } + + return false; + } + + private static List BuildMixedSourceFaceCandidate( + IReadOnlyList path, + ElkPositionedNode sourceNode, + string side, + double desiredCoordinate, + double axisValue) + { + ElkPoint boundaryPoint; + if (ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + if (!ElkShapeBoundaries.TryProjectGatewayBoundarySlot(sourceNode, side, desiredCoordinate, out boundaryPoint)) + { + return path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + var continuation = path.Count > 1 ? path[1] : path[0]; + boundaryPoint = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(sourceNode, boundaryPoint, continuation); + } + else + { + boundaryPoint = side switch + { + "left" => new ElkPoint { X = sourceNode.X, Y = desiredCoordinate }, + "right" => new ElkPoint { X = sourceNode.X + sourceNode.Width, Y = desiredCoordinate }, + "top" => new ElkPoint { X = desiredCoordinate, Y = sourceNode.Y }, + "bottom" => new ElkPoint { X = desiredCoordinate, Y = sourceNode.Y + sourceNode.Height }, + _ => path[0], + }; + } + + return RewriteSourceDepartureRun( + path, + side, + boundaryPoint, + double.IsNaN(axisValue) ? ResolveDefaultSourceDepartureAxis(sourceNode, side) : axisValue); + } + + private static List BuildMixedTargetFaceCandidate( + IReadOnlyList path, + ElkPositionedNode targetNode, + string side, + double desiredCoordinate, + double axisValue) + { + ElkPoint desiredEndpoint; + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + if (!ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, side, desiredCoordinate, out desiredEndpoint)) + { + return path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + return BuildTargetApproachCandidatePath( + path, + targetNode, + side, + desiredEndpoint, + axisValue); + } + + desiredEndpoint = side switch + { + "left" => new ElkPoint { X = targetNode.X, Y = desiredCoordinate }, + "right" => new ElkPoint { X = targetNode.X + targetNode.Width, Y = desiredCoordinate }, + "top" => new ElkPoint { X = desiredCoordinate, Y = targetNode.Y }, + "bottom" => new ElkPoint { X = desiredCoordinate, Y = targetNode.Y + targetNode.Height }, + _ => path[^1], + }; + + return BuildTargetApproachCandidatePath( + path, + targetNode, + side, + desiredEndpoint, + axisValue); + } + + private static List BuildTargetApproachCandidatePath( + IReadOnlyList path, + ElkPositionedNode targetNode, + string side, + ElkPoint desiredEndpoint, + double axisValue) + { + List normalized; + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + var exteriorIndex = FindLastGatewayExteriorPointIndex(path, targetNode); + var exteriorAnchor = path[exteriorIndex]; + normalized = TryBuildSlottedGatewayEntryPath( + path, + targetNode, + exteriorIndex, + exteriorAnchor, + desiredEndpoint) + ?? NormalizeGatewayEntryPath(path, targetNode, desiredEndpoint); + } + else + { + normalized = NormalizeEntryPath(path, targetNode, side, desiredEndpoint); + } + var targetAxis = double.IsNaN(axisValue) + ? ResolveDefaultTargetApproachAxis(targetNode, side) + : axisValue; + + if (!TryExtractTargetApproachFeeder(normalized, side, out _)) + { + return normalized; + } + + var rewritten = RewriteTargetApproachRun( + normalized, + side, + desiredEndpoint, + targetAxis); + if (!PathChanged(normalized, rewritten)) + { + return normalized; + } + + if (ElkShapeBoundaries.IsGatewayShape(targetNode) + && !CanAcceptGatewayTargetRepair(rewritten, targetNode)) + { + return normalized; + } + + return rewritten; + } + + private static bool TryBuildAlternateMixedFaceCandidate( + (int Index, ElkRoutedEdge Edge, IReadOnlyList Path, ElkPositionedNode Node, string Side, bool IsOutgoing, ElkPoint Boundary, double BoundaryCoordinate, double AxisValue) entry, + IReadOnlyCollection nodes, + double minLineClearance, + out List candidate) + { + candidate = entry.Path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (ElkShapeBoundaries.IsGatewayShape(entry.Node) + || string.IsNullOrWhiteSpace(entry.Edge.Label) + || !IsRepeatCollectorLabel(entry.Edge.Label)) + { + return false; + } + + var alternateSide = entry.Side switch + { + "left" or "right" => "top", + "top" or "bottom" => "right", + _ => string.Empty, + }; + if (string.IsNullOrWhiteSpace(alternateSide)) + { + return false; + } + + if (entry.IsOutgoing) + { + var sourcePath = entry.Path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + var toward = sourcePath.Count > 1 ? sourcePath[1] : sourcePath[0]; + sourcePath[0] = BuildRectBoundaryPointForSide(entry.Node, alternateSide, toward); + candidate = NormalizeExitPath(sourcePath, entry.Node, alternateSide); + return true; + } + + var explicitEndpoint = BuildRectBoundaryPointForSide(entry.Node, alternateSide, entry.Path[^2]); + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + if (nodesById.TryGetValue(entry.Edge.SourceNodeId ?? string.Empty, out var sourceNode) + && (alternateSide is "top" or "bottom") + && TryBuildSafeHorizontalBandCandidate( + sourceNode, + entry.Node, + nodes, + entry.Edge.SourceNodeId, + entry.Edge.TargetNodeId, + entry.Path[0], + explicitEndpoint, + minLineClearance, + preferredSourceExterior: null, + out var bandCandidate)) + { + candidate = bandCandidate; + return true; + } + + candidate = NormalizeEntryPath(entry.Path, entry.Node, alternateSide, explicitEndpoint); + return true; + } + + private static bool TryBuildSafeHorizontalBandCandidate( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + ElkPoint startBoundary, + ElkPoint endBoundary, + double minClearance, + ElkPoint? preferredSourceExterior, + out List candidate) + { + candidate = []; + + var route = new List + { + new() { X = startBoundary.X, Y = startBoundary.Y }, + }; + + var routeStart = route[0]; + if (ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + var gatewayExteriorCandidates = new List(); + if (preferredSourceExterior is { } preferredExterior) + { + gatewayExteriorCandidates.Add(preferredExterior); + } + + gatewayExteriorCandidates.Add(ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, startBoundary, endBoundary)); + gatewayExteriorCandidates.Add(ElkShapeBoundaries.BuildGatewayExteriorApproachPoint(sourceNode, startBoundary)); + + ElkPoint? sourceExterior = null; + foreach (var exteriorCandidate in gatewayExteriorCandidates) + { + if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(sourceNode, exteriorCandidate) + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(sourceNode, startBoundary, exteriorCandidate)) + { + continue; + } + + sourceExterior = exteriorCandidate; + break; + } + + if (sourceExterior is null) + { + return false; + } + + if (!ElkEdgeRoutingGeometry.PointsEqual(route[^1], sourceExterior)) + { + route.Add(sourceExterior); + routeStart = sourceExterior; + } + } + + var clearance = Math.Max(24d, minClearance * 0.6d); + var minX = Math.Min(routeStart.X, endBoundary.X); + var maxX = Math.Max(routeStart.X, endBoundary.X); + var graphMinY = nodes.Min(node => node.Y); + var blockers = nodes + .Where(node => + !string.Equals(node.Id, sourceNodeId, StringComparison.Ordinal) + && !string.Equals(node.Id, targetNodeId, StringComparison.Ordinal) + && maxX > node.X + 0.5d + && minX < node.X + node.Width - 0.5d + && node.Y <= Math.Max(routeStart.Y, endBoundary.Y) + clearance) + .ToArray(); + var baseY = Math.Min(Math.Min(routeStart.Y, endBoundary.Y), targetNode.Y); + if (blockers.Length > 0) + { + baseY = Math.Min(baseY, blockers.Min(node => node.Y)); + } + + var bandY = Math.Max(graphMinY - 72d, baseY - clearance); + if (bandY >= Math.Min(routeStart.Y, endBoundary.Y) - 0.5d) + { + return false; + } + + if (Math.Abs(route[^1].Y - bandY) > 0.5d) + { + route.Add(new ElkPoint { X = route[^1].X, Y = bandY }); + } + + if (Math.Abs(route[^1].X - endBoundary.X) > 0.5d) + { + route.Add(new ElkPoint { X = endBoundary.X, Y = bandY }); + } + + if (Math.Abs(route[^1].Y - endBoundary.Y) > 0.5d) + { + route.Add(new ElkPoint { X = endBoundary.X, Y = endBoundary.Y }); + } + + candidate = NormalizePathPoints(route); + if (candidate.Count < 2 || HasNodeObstacleCrossing(candidate, nodes, sourceNodeId, targetNodeId)) + { + candidate = []; + return false; + } + + if (ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + if (!HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true)) + { + candidate = []; + return false; + } + } + else if (!HasValidBoundaryAngle(candidate[0], candidate[1], sourceNode)) + { + candidate = []; + return false; + } + + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + if (!CanAcceptGatewayTargetRepair(candidate, targetNode) + || !HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, targetNode, fromStart: false)) + { + candidate = []; + return false; + } + } + else if (HasTargetApproachBacktracking(candidate, targetNode) + || !HasValidBoundaryAngle(candidate[^1], candidate[^2], targetNode)) + { + candidate = []; + return false; + } + + return true; + } + + private static List RewriteTargetApproachRun( + IReadOnlyList path, + string side, + ElkPoint endpoint, + double desiredAxis) + { + if (!TryExtractTargetApproachRun(path, side, out var runStartIndex, out _)) + { + return path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + } + + var prefixEndExclusive = runStartIndex; + if (runStartIndex > 0 && !IsOrthogonal(path[runStartIndex - 1], path[runStartIndex])) + { + prefixEndExclusive = runStartIndex + 1; + } + else if (prefixEndExclusive < 2 && path.Count > 2) + { + // Preserve the initial source-exit stub while spreading only the target-side run. + prefixEndExclusive = 2; + } + + var rebuilt = path.Take(prefixEndExclusive) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (rebuilt.Count == 0) + { + rebuilt.Add(new ElkPoint { X = path[0].X, Y = path[0].Y }); + } + + const double coordinateTolerance = 0.5d; + if (side is "top" or "bottom") + { + var approachY = double.IsNaN(desiredAxis) ? rebuilt[^1].Y : desiredAxis; + + if (Math.Abs(rebuilt[^1].Y - approachY) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = rebuilt[^1].X, Y = approachY }); + } + + if (Math.Abs(rebuilt[^1].X - endpoint.X) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = endpoint.X, Y = approachY }); + } + + if (Math.Abs(rebuilt[^1].Y - endpoint.Y) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = endpoint.X, Y = endpoint.Y }); + } + } + else + { + var approachX = double.IsNaN(desiredAxis) ? rebuilt[^1].X : desiredAxis; + + if (Math.Abs(rebuilt[^1].X - approachX) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = approachX, Y = rebuilt[^1].Y }); + } + + if (Math.Abs(rebuilt[^1].Y - endpoint.Y) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = approachX, Y = endpoint.Y }); + } + + if (Math.Abs(rebuilt[^1].X - endpoint.X) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = endpoint.X, Y = endpoint.Y }); + } + } + + if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], endpoint)) + { + rebuilt.Add(new ElkPoint { X = endpoint.X, Y = endpoint.Y }); + } + + return NormalizePathPoints(rebuilt); + } + + private static bool TryExtractTargetApproachFeeder( + IReadOnlyList path, + string side, + out (ElkPoint Start, ElkPoint End, double BandCoordinate) feeder) + { + feeder = default; + if (!TryExtractTargetApproachRun(path, side, out var runStartIndex, out _) + || runStartIndex < 1) + { + return false; + } + + var start = path[runStartIndex - 1]; + var end = path[runStartIndex]; + const double coordinateTolerance = 0.5d; + if (side is "top" or "bottom") + { + if (Math.Abs(start.Y - end.Y) > coordinateTolerance) + { + return false; + } + + feeder = (start, end, start.Y); + return true; + } + + if (Math.Abs(start.X - end.X) > coordinateTolerance) + { + return false; + } + + feeder = (start, end, start.X); + return true; + } + + private static List RewriteTargetApproachFeederBand( + IReadOnlyList path, + string side, + double desiredBand) + { + if (!TryExtractTargetApproachRun(path, side, out var runStartIndex, out var runEndIndex) + || runStartIndex < 1) + { + return path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + } + + var prefix = path.Take(runStartIndex) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (prefix.Count == 0) + { + prefix.Add(new ElkPoint { X = path[0].X, Y = path[0].Y }); + } + + var endpoint = path[^1]; + const double coordinateTolerance = 0.5d; + if (side is "top" or "bottom") + { + if (Math.Abs(prefix[^1].Y - desiredBand) > coordinateTolerance) + { + prefix.Add(new ElkPoint { X = prefix[^1].X, Y = desiredBand }); + } + + var approachAxis = path[runEndIndex].X; + if (Math.Abs(prefix[^1].X - approachAxis) > coordinateTolerance) + { + prefix.Add(new ElkPoint { X = approachAxis, Y = desiredBand }); + } + + if (Math.Abs(prefix[^1].Y - endpoint.Y) > coordinateTolerance) + { + prefix.Add(new ElkPoint { X = approachAxis, Y = endpoint.Y }); + } + } + else + { + if (Math.Abs(prefix[^1].X - desiredBand) > coordinateTolerance) + { + prefix.Add(new ElkPoint { X = desiredBand, Y = prefix[^1].Y }); + } + + var approachAxis = path[runEndIndex].Y; + if (Math.Abs(prefix[^1].Y - approachAxis) > coordinateTolerance) + { + prefix.Add(new ElkPoint { X = desiredBand, Y = approachAxis }); + } + + if (Math.Abs(prefix[^1].X - endpoint.X) > coordinateTolerance) + { + prefix.Add(new ElkPoint { X = endpoint.X, Y = approachAxis }); + } + } + + prefix.Add(new ElkPoint { X = endpoint.X, Y = endpoint.Y }); + return NormalizePathPoints(prefix); + } + + private static List ShiftSingleOrthogonalRun( + IReadOnlyList path, + int segmentIndex, + double desiredCoordinate) + { + var candidate = path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (segmentIndex < 0 || segmentIndex >= candidate.Count - 1) + { + return candidate; + } + + var start = candidate[segmentIndex]; + var end = candidate[segmentIndex + 1]; + if (Math.Abs(start.Y - end.Y) <= 0.5d) + { + var original = start.Y; + for (var i = 0; i < candidate.Count; i++) + { + if (Math.Abs(candidate[i].Y - original) <= 0.5d) + { + candidate[i] = new ElkPoint { X = candidate[i].X, Y = desiredCoordinate }; + } + } + } + else if (Math.Abs(start.X - end.X) <= 0.5d) + { + var original = start.X; + for (var i = 0; i < candidate.Count; i++) + { + if (Math.Abs(candidate[i].X - original) <= 0.5d) + { + candidate[i] = new ElkPoint { X = desiredCoordinate, Y = candidate[i].Y }; + } + } + } + + return NormalizePathPoints(candidate); + } + + private static List ShiftStraightOrthogonalPath( + IReadOnlyList path, + double desiredCoordinate) + { + var candidate = path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (candidate.Count != 2) + { + return candidate; + } + + var start = candidate[0]; + var end = candidate[1]; + if (Math.Abs(start.Y - end.Y) <= 0.5d) + { + return NormalizePathPoints( + [ + new ElkPoint { X = start.X, Y = start.Y }, + new ElkPoint { X = start.X, Y = desiredCoordinate }, + new ElkPoint { X = end.X, Y = desiredCoordinate }, + new ElkPoint { X = end.X, Y = end.Y }, + ]); + } + + if (Math.Abs(start.X - end.X) <= 0.5d) + { + return NormalizePathPoints( + [ + new ElkPoint { X = start.X, Y = start.Y }, + new ElkPoint { X = desiredCoordinate, Y = start.Y }, + new ElkPoint { X = desiredCoordinate, Y = end.Y }, + new ElkPoint { X = end.X, Y = end.Y }, + ]); + } + + return candidate; + } + + private static double[] ResolveLaneShiftCoordinates( + ElkPoint start, + ElkPoint end, + ElkPoint otherStart, + ElkPoint otherEnd, + double minLineClearance) + { + var offset = minLineClearance + 4d; + if (Math.Abs(start.Y - end.Y) <= 0.5d && Math.Abs(otherStart.Y - otherEnd.Y) <= 0.5d) + { + var lower = otherStart.Y - offset; + var upper = otherStart.Y + offset; + return start.Y <= otherStart.Y + ? [lower, upper] + : [upper, lower]; + } + + if (Math.Abs(start.X - end.X) <= 0.5d && Math.Abs(otherStart.X - otherEnd.X) <= 0.5d) + { + var lower = otherStart.X - offset; + var upper = otherStart.X + offset; + return start.X <= otherStart.X + ? [lower, upper] + : [upper, lower]; + } + + return []; + } + + private static bool SegmentLeavesGraphBand( + IReadOnlyList path, + double graphMinY, + double graphMaxY) + { + return path.Any(point => point.Y < graphMinY - 96d || point.Y > graphMaxY + 96d); + } + + private static bool TrySeparateSharedLaneConflict( + ElkRoutedEdge edge, + ElkRoutedEdge otherEdge, + ElkPositionedNode[] nodes, + double minLineClearance, + double graphMinY, + double graphMaxY, + (double Left, double Top, double Right, double Bottom, string Id)[] nodeObstacles, + out ElkRoutedEdge repairedEdge) + { + repairedEdge = edge; + var path = ExtractFullPath(edge); + if (path.Count < 2) + { + return false; + } + + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var originalTargetSide = nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode) + && !ElkShapeBoundaries.IsGatewayShape(targetNode) + ? ResolveTargetApproachSide(path, targetNode) + : null; + + for (var segmentIndex = 0; segmentIndex < path.Count - 1; segmentIndex++) + { + var start = path[segmentIndex]; + var end = path[segmentIndex + 1]; + var isHorizontal = Math.Abs(start.Y - end.Y) <= 0.5d; + var isVertical = Math.Abs(start.X - end.X) <= 0.5d; + if (!isHorizontal && !isVertical) + { + continue; + } + + foreach (var otherSegment in ElkEdgeRoutingGeometry.FlattenSegments(otherEdge)) + { + if (!SegmentsShareLane( + start, + end, + otherSegment.Start, + otherSegment.End, + minLineClearance)) + { + continue; + } + + foreach (var alternateCoordinate in ResolveLaneShiftCoordinates( + start, + end, + otherSegment.Start, + otherSegment.End, + minLineClearance)) + { + var candidate = path.Count == 2 + ? ShiftStraightOrthogonalPath(path, alternateCoordinate) + : ShiftSingleOrthogonalRun(path, segmentIndex, alternateCoordinate); + if (!PathChanged(path, candidate) + || (originalTargetSide is not null + && ResolveTargetApproachSide(candidate, targetNode) != originalTargetSide) + || HasNodeObstacleCrossing(candidate, nodes, edge.SourceNodeId, edge.TargetNodeId) + || SegmentLeavesGraphBand(candidate, graphMinY, graphMaxY)) + { + continue; + } + + var crossesObstacle = false; + for (var candidateIndex = 0; candidateIndex < candidate.Count - 1; candidateIndex++) + { + if (!SegmentCrossesObstacle(candidate[candidateIndex], candidate[candidateIndex + 1], nodeObstacles, edge.SourceNodeId, edge.TargetNodeId)) + { + continue; + } + + crossesObstacle = true; + break; + } + + if (crossesObstacle) + { + continue; + } + + repairedEdge = BuildSingleSectionEdge(edge, candidate); + repairedEdge = RepairBoundaryAnglesAndTargetApproaches( + [repairedEdge], + nodes, + minLineClearance)[0]; + repairedEdge = NormalizeSourceExitAngles([repairedEdge], nodes)[0]; + var repairedPath = ExtractFullPath(repairedEdge); + if ((originalTargetSide is not null + && ResolveTargetApproachSide(repairedPath, targetNode) != originalTargetSide) + || HasNodeObstacleCrossing(repairedPath, nodes, edge.SourceNodeId, edge.TargetNodeId) + || SegmentLeavesGraphBand(repairedPath, graphMinY, graphMaxY)) + { + repairedEdge = edge; + continue; + } + + if (ElkEdgeRoutingScoring.DetectSharedLaneConflicts([repairedEdge, otherEdge], nodes).Count > 0) + { + repairedEdge = edge; + continue; + } + + return true; + } + } + } + + repairedEdge = edge; + return false; + } + + private static bool SegmentsShareLane( + ElkPoint leftStart, + ElkPoint leftEnd, + ElkPoint rightStart, + ElkPoint rightEnd, + double minLineClearance) + { + var laneTolerance = Math.Max(4d, Math.Min(12d, minLineClearance * 0.2d)); + var minSharedLength = Math.Max(24d, minLineClearance * 0.4d); + if (Math.Abs(leftStart.Y - leftEnd.Y) <= 0.5d + && Math.Abs(rightStart.Y - rightEnd.Y) <= 0.5d + && Math.Abs(leftStart.Y - rightStart.Y) <= laneTolerance) + { + var leftMinX = Math.Min(leftStart.X, leftEnd.X); + var leftMaxX = Math.Max(leftStart.X, leftEnd.X); + var rightMinX = Math.Min(rightStart.X, rightEnd.X); + var rightMaxX = Math.Max(rightStart.X, rightEnd.X); + return Math.Min(leftMaxX, rightMaxX) - Math.Max(leftMinX, rightMinX) >= minSharedLength; + } + + if (Math.Abs(leftStart.X - leftEnd.X) <= 0.5d + && Math.Abs(rightStart.X - rightEnd.X) <= 0.5d + && Math.Abs(leftStart.X - rightStart.X) <= laneTolerance) + { + var leftMinY = Math.Min(leftStart.Y, leftEnd.Y); + var leftMaxY = Math.Max(leftStart.Y, leftEnd.Y); + var rightMinY = Math.Min(rightStart.Y, rightEnd.Y); + var rightMaxY = Math.Max(rightStart.Y, rightEnd.Y); + return Math.Min(leftMaxY, rightMaxY) - Math.Max(leftMinY, rightMinY) >= minSharedLength; + } + + return false; + } + + private static List RewriteSourceDepartureRun( + IReadOnlyList path, + string side, + ElkPoint boundaryPoint, + double desiredAxis) + { + if (!TryExtractSourceDepartureRun(path, side, out _, out var runEndIndex)) + { + return path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + } + + var suffixStartIndex = runEndIndex + 1; + if (suffixStartIndex >= path.Count) + { + return path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + } + + const double coordinateTolerance = 0.5d; + var suffixStart = path[suffixStartIndex]; + var rebuilt = new List + { + new() { X = boundaryPoint.X, Y = boundaryPoint.Y }, + }; + + if (side is "left" or "right") + { + 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 }); + } + } + else + { + if (Math.Abs(rebuilt[^1].Y - desiredAxis) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = rebuilt[^1].X, Y = desiredAxis }); + } + + if (Math.Abs(rebuilt[^1].X - suffixStart.X) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint { X = suffixStart.X, Y = desiredAxis }); + } + } + + 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 bool TryExtractTargetApproachRun( + IReadOnlyList path, + string side, + out int runStartIndex, + out int runEndIndex) + { + runStartIndex = -1; + runEndIndex = -1; + if (path.Count < 2 || side is not ("left" or "right" or "top" or "bottom")) + { + return false; + } + + const double coordinateTolerance = 0.5d; + runEndIndex = path.Count - 1; + if (side is "top" or "bottom") + { + var axis = path[runEndIndex].X; + runStartIndex = runEndIndex; + while (runStartIndex > 0 && Math.Abs(path[runStartIndex - 1].X - axis) <= coordinateTolerance) + { + runStartIndex--; + } + + return runEndIndex >= runStartIndex; + } + + var xAxis = path[runEndIndex].Y; + runStartIndex = runEndIndex; + while (runStartIndex > 0 && Math.Abs(path[runStartIndex - 1].Y - xAxis) <= coordinateTolerance) + { + runStartIndex--; + } + + return runEndIndex >= runStartIndex; + } + + private static bool TryExtractSourceDepartureRun( + IReadOnlyList path, + string side, + out int runStartIndex, + out int runEndIndex) + { + runStartIndex = -1; + runEndIndex = -1; + if (path.Count < 2 || side is not ("left" or "right" or "top" or "bottom")) + { + return false; + } + + const double coordinateTolerance = 0.5d; + runStartIndex = 0; + runEndIndex = 1; + if (side is "left" or "right") + { + var axis = path[1].Y; + if (Math.Abs(path[0].Y - axis) > coordinateTolerance) + { + return false; + } + + while (runEndIndex + 1 < path.Count && Math.Abs(path[runEndIndex + 1].Y - axis) <= coordinateTolerance) + { + runEndIndex++; + } + + return runEndIndex > runStartIndex; + } + + var xAxis = path[1].X; + if (Math.Abs(path[0].X - xAxis) > coordinateTolerance) + { + return false; + } + + while (runEndIndex + 1 < path.Count && Math.Abs(path[runEndIndex + 1].X - xAxis) <= coordinateTolerance) + { + runEndIndex++; + } + + return runEndIndex > runStartIndex; + } + + private static bool GroupHasSourceDepartureJoin( + IReadOnlyList<(IReadOnlyList Path, string Side)> entries, + double minLineClearance) + { + for (var i = 0; i < entries.Count; i++) + { + var left = entries[i]; + var leftSegments = FlattenSegmentsNearStart(left.Path, 3); + for (var j = i + 1; j < entries.Count; j++) + { + var right = entries[j]; + if (!string.Equals(left.Side, right.Side, StringComparison.Ordinal)) + { + continue; + } + + var rightSegments = FlattenSegmentsNearStart(right.Path, 3); + foreach (var leftSegment in leftSegments) + { + foreach (var rightSegment in rightSegments) + { + if (!ElkEdgeRoutingGeometry.AreParallelAndClose( + leftSegment.Start, + leftSegment.End, + rightSegment.Start, + rightSegment.End, + minLineClearance)) + { + continue; + } + + var overlap = ElkEdgeRoutingGeometry.ComputeSharedSegmentLength( + leftSegment.Start, + leftSegment.End, + rightSegment.Start, + rightSegment.End); + if (overlap > 8d) + { + return true; + } + } + } + } + } + + return false; + } + + private static bool HasRepeatCollectorNodeClearanceViolation( + IReadOnlyList path, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + double minClearance) + { + for (var i = 0; i < path.Count - 1; i++) + { + var start = path[i]; + var end = path[i + 1]; + var horizontal = Math.Abs(start.Y - end.Y) < 2d; + var vertical = Math.Abs(start.X - end.X) < 2d; + if (!horizontal && !vertical) + { + continue; + } + + foreach (var node in nodes) + { + if (string.Equals(node.Id, sourceNodeId, StringComparison.Ordinal) + || string.Equals(node.Id, targetNodeId, StringComparison.Ordinal)) + { + continue; + } + + if (horizontal) + { + var overlapX = Math.Max(start.X, end.X) > node.X + && Math.Min(start.X, end.X) < node.X + node.Width; + if (!overlapX) + { + continue; + } + + var distance = Math.Min(Math.Abs(start.Y - node.Y), Math.Abs(start.Y - (node.Y + node.Height))); + if (distance > 0.5d && distance < minClearance) + { + return true; + } + + continue; + } + + var overlapY = Math.Max(start.Y, end.Y) > node.Y + && Math.Min(start.Y, end.Y) < node.Y + node.Height; + if (!overlapY) + { + continue; + } + + var verticalDistance = Math.Min(Math.Abs(start.X - node.X), Math.Abs(start.X - (node.X + node.Width))); + if (verticalDistance > 0.5d && verticalDistance < minClearance) + { + return true; + } + } + } + + return false; + } + + private static List TryLiftUnderNodeSegments( + IReadOnlyList path, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + double minClearance) + { + var current = path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + for (var pass = 0; pass < 6; pass++) + { + var changed = false; + for (var segmentIndex = 0; segmentIndex < current.Count - 1; segmentIndex++) + { + if (!TryResolveUnderNodeBlockers( + current[segmentIndex], + current[segmentIndex + 1], + nodes, + sourceNodeId, + targetNodeId, + minClearance, + out var blockers)) + { + continue; + } + + var minX = Math.Min(current[segmentIndex].X, current[segmentIndex + 1].X); + var maxX = Math.Max(current[segmentIndex].X, current[segmentIndex + 1].X); + var maxRelevantDistance = Math.Max(minClearance * 1.75d, 96d); + var overlappingNodes = nodes + .Where(node => + !string.Equals(node.Id, sourceNodeId, StringComparison.Ordinal) + && !string.Equals(node.Id, targetNodeId, StringComparison.Ordinal) + && maxX > node.X + 0.5d + && minX < node.X + node.Width - 0.5d) + .Where(node => + { + var distanceBelowNode = current[segmentIndex].Y - (node.Y + node.Height); + return distanceBelowNode > 0.5d && distanceBelowNode < maxRelevantDistance; + }) + .ToArray(); + var liftY = (overlappingNodes.Length > 0 ? overlappingNodes.Min(node => node.Y) : blockers.Min(node => node.Y)) + - Math.Max(24d, minClearance * 0.6d); + if (liftY >= current[segmentIndex].Y - 0.5d) + { + continue; + } + + var rebuilt = new List(current.Count + 2); + rebuilt.AddRange(current.Take(segmentIndex + 1).Select(point => new ElkPoint { X = point.X, Y = point.Y })); + rebuilt.Add(new ElkPoint { X = current[segmentIndex].X, Y = liftY }); + rebuilt.Add(new ElkPoint { X = current[segmentIndex + 1].X, Y = liftY }); + rebuilt.AddRange(current.Skip(segmentIndex + 1).Select(point => new ElkPoint { X = point.X, Y = point.Y })); + + var candidate = NormalizePathPoints(rebuilt); + if (!PathChanged(current, candidate) + || HasNodeObstacleCrossing(candidate, nodes, sourceNodeId, targetNodeId) + || CountUnderNodeSegments(candidate, nodes, sourceNodeId, targetNodeId, minClearance) + >= CountUnderNodeSegments(current, nodes, sourceNodeId, targetNodeId, minClearance)) + { + continue; + } + + current = candidate; + changed = true; + break; + } + + if (!changed) + { + break; + } + } + + return current; + } + + private static bool TryResolveUnderNodeWithPreferredShortcut( + ElkRoutedEdge edge, + IReadOnlyList path, + IReadOnlyCollection nodes, + double minClearance, + out List repairedPath) + { + repairedPath = path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (path.Count < 2) + { + return false; + } + + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + if (!nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode) + || !nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)) + { + return false; + } + + if (TryApplyPreferredBoundaryShortcut( + path, + sourceNode, + targetNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId, + requireUnderNodeImprovement: true, + minClearance, + out repairedPath)) + { + return true; + } + + if (TryBuildBestUnderNodeBandCandidate( + path, + sourceNode, + targetNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId, + minClearance, + edge.Id, + out repairedPath)) + { + return true; + } + + var targetSide = ResolveTargetApproachSide(path, targetNode); + if (!IsRepeatCollectorLabel(edge.Label) + && TryBuildGatewaySourceUnderNodeDropCandidate( + path, + sourceNode, + targetNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId, + minClearance, + out repairedPath)) + { + return true; + } + + if (TryBuildSafeGatewaySourceBandCandidate( + sourceNode, + targetNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId, + path[^1], + minClearance, + out repairedPath)) + { + return true; + } + + if (targetSide is "top" or "bottom" + && TryBuildSafeHorizontalBandCandidate( + sourceNode, + targetNode, + nodes, + edge.SourceNodeId, + edge.TargetNodeId, + path[0], + path[^1], + minClearance, + preferredSourceExterior: path.Count > 1 ? path[1] : null, + out repairedPath)) + { + return true; + } + + repairedPath = path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + return false; + } + + private static bool TryBuildBestUnderNodeBandCandidate( + IReadOnlyList path, + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + double minClearance, + string? debugEdgeId, + out List candidate) + { + candidate = []; + if (path.Count < 2 + || !TryResolveUnderNodeBand(path, nodes, sourceNodeId, targetNodeId, minClearance, out var bandY)) + { + WriteUnderNodeDebug(debugEdgeId, $"band-unavailable path={FormatPath(path)}"); + return false; + } + + var currentUnderNodeSegments = CountUnderNodeSegments(path, nodes, sourceNodeId, targetNodeId, minClearance); + if (currentUnderNodeSegments == 0) + { + WriteUnderNodeDebug(debugEdgeId, $"band-skip no-under-node path={FormatPath(path)}"); + return false; + } + + WriteUnderNodeDebug(debugEdgeId, $"band-try y={bandY:F2} under={currentUnderNodeSegments} path={FormatPath(path)}"); + + var bestScore = double.PositiveInfinity; + List? bestCandidate = null; + var preferredTargetSide = ResolveTargetApproachSide(path, targetNode); + foreach (var (side, endBoundary, sideBias) in EnumerateUnderNodeBandTargetBoundaries(path, sourceNode, targetNode, bandY)) + { + if (!TryBuildExplicitUnderNodeBandCandidate( + path, + sourceNode, + targetNode, + nodes, + sourceNodeId, + targetNodeId, + side, + bandY, + endBoundary, + minClearance, + out var bandCandidate)) + { + WriteUnderNodeDebug(debugEdgeId, $"band-reject build side={side} end={FormatPoint(endBoundary)}"); + continue; + } + + if (!PathChanged(path, bandCandidate)) + { + WriteUnderNodeDebug(debugEdgeId, $"band-reject unchanged side={side} candidate={FormatPath(bandCandidate)}"); + continue; + } + + var candidateUnderNodeSegments = CountUnderNodeSegments(bandCandidate, nodes, sourceNodeId, targetNodeId, minClearance); + if (candidateUnderNodeSegments >= currentUnderNodeSegments) + { + WriteUnderNodeDebug(debugEdgeId, $"band-reject no-improvement side={side} under={candidateUnderNodeSegments} blockers={FormatUnderNodeBlockers(bandCandidate, nodes, sourceNodeId, targetNodeId, minClearance)} candidate={FormatPath(bandCandidate)}"); + continue; + } + + if (ComputePathLength(bandCandidate) > ComputePathLength(path) + 260d) + { + WriteUnderNodeDebug(debugEdgeId, $"band-reject long side={side} candidate={FormatPath(bandCandidate)}"); + continue; + } + + var score = ScoreUnderNodeBandCandidate( + path, + bandCandidate, + targetNode, + side, + preferredTargetSide, + sideBias, + nodes, + sourceNodeId, + targetNodeId, + minClearance); + if (score >= bestScore) + { + WriteUnderNodeDebug(debugEdgeId, $"band-reject score side={side} score={score:F2} candidate={FormatPath(bandCandidate)}"); + continue; + } + + WriteUnderNodeDebug(debugEdgeId, $"band-candidate side={side} score={score:F2} candidate={FormatPath(bandCandidate)}"); + bestScore = score; + bestCandidate = bandCandidate; + } + + if (bestCandidate is null) + { + WriteUnderNodeDebug(debugEdgeId, "band-result none"); + return false; + } + + WriteUnderNodeDebug(debugEdgeId, $"band-result selected score={bestScore:F2} candidate={FormatPath(bestCandidate)}"); + candidate = bestCandidate; + return true; + } + + private static bool TryBuildExplicitUnderNodeBandCandidate( + IReadOnlyList originalPath, + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + string targetSide, + double bandY, + ElkPoint endBoundary, + double minClearance, + out List candidate) + { + candidate = []; + if (originalPath.Count < 2) + { + return false; + } + + var currentUnderNodeSegments = CountUnderNodeSegments(originalPath, nodes, sourceNodeId, targetNodeId, minClearance); + if (currentUnderNodeSegments == 0) + { + return false; + } + + var bestLength = double.PositiveInfinity; + List? bestCandidate = null; + foreach (var bandEntryX in EnumerateUnderNodeBandEntryXs(originalPath, endBoundary, nodes, sourceNodeId, targetNodeId, minClearance)) + { + var candidateBandY = bandY; + for (var refinement = 0; refinement < 4; refinement++) + { + if (!TryResolveUnderNodeBandTargetGeometry( + targetNode, + targetSide, + endBoundary, + candidateBandY, + minClearance, + out var targetBoundary, + out var targetExterior)) + { + break; + } + + if (!TryResolveUnderNodeBandSourceGeometry( + originalPath, + sourceNode, + targetBoundary, + bandEntryX, + candidateBandY, + out var startBoundary, + out var sourceExterior)) + { + break; + } + + var route = BuildUnderNodeBandCandidatePath( + startBoundary, + sourceExterior, + bandEntryX, + candidateBandY, + targetExterior, + targetBoundary); + var bandCandidate = NormalizePathPoints(route); + if (!IsUsableUnderNodeBandCandidate( + bandCandidate, + sourceNode, + targetNode, + nodes, + sourceNodeId, + targetNodeId)) + { + break; + } + + var candidateUnderNodeSegments = CountUnderNodeSegments(bandCandidate, nodes, sourceNodeId, targetNodeId, minClearance); + if (candidateUnderNodeSegments == 0) + { + var candidateLength = ComputePathLength(bandCandidate); + if (candidateLength < bestLength) + { + bestLength = candidateLength; + bestCandidate = bandCandidate; + } + + break; + } + + if (!TryResolveUnderNodeBand( + bandCandidate, + nodes, + sourceNodeId, + targetNodeId, + minClearance, + out var refinedBandY) + || Math.Abs(refinedBandY - candidateBandY) <= 0.5d) + { + break; + } + + candidateBandY = refinedBandY; + } + } + + if (bestCandidate is null) + { + return false; + } + + candidate = bestCandidate; + return true; + } + + private static IEnumerable EnumerateUnderNodeBandEntryXs( + IReadOnlyList originalPath, + ElkPoint endBoundary, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + double minClearance) + { + var clearance = Math.Max(24d, minClearance * 0.6d); + var preferredX = originalPath.Count > 1 ? originalPath[1].X : originalPath[0].X; + var coordinates = new List + { + preferredX, + originalPath[0].X, + endBoundary.X, + }; + + var minX = Math.Min(preferredX, endBoundary.X) - (clearance * 2d); + var maxX = Math.Max(preferredX, endBoundary.X) + (clearance * 2d); + foreach (var node in nodes) + { + if (string.Equals(node.Id, sourceNodeId, StringComparison.Ordinal) + || string.Equals(node.Id, targetNodeId, StringComparison.Ordinal) + || node.X > maxX + || node.X + node.Width < minX) + { + continue; + } + + AddUniqueCoordinate(coordinates, node.X - clearance); + AddUniqueCoordinate(coordinates, node.X + node.Width + clearance); + } + + foreach (var coordinate in coordinates + .OrderBy(value => Math.Abs(value - preferredX)) + .ThenBy(value => Math.Abs(value - endBoundary.X)) + .Take(10)) + { + yield return coordinate; + } + } + + private static bool TryResolveUnderNodeBandTargetGeometry( + ElkPositionedNode targetNode, + string targetSide, + ElkPoint preferredBoundary, + double bandY, + double minClearance, + out ElkPoint targetBoundary, + out ElkPoint targetExterior) + { + targetBoundary = default!; + targetExterior = default!; + + var clearance = Math.Max(24d, minClearance * 0.6d); + var axisCoordinate = targetSide is "top" or "bottom" + ? preferredBoundary.X + : preferredBoundary.Y; + if (!TryBuildUnderNodeBandTargetBoundary(targetNode, targetSide, axisCoordinate, bandY, out targetBoundary)) + { + return false; + } + + var targetAnchor = targetSide switch + { + "left" => new ElkPoint { X = targetBoundary.X - clearance, Y = targetBoundary.Y }, + "right" => new ElkPoint { X = targetBoundary.X + clearance, Y = targetBoundary.Y }, + _ => new ElkPoint { X = targetBoundary.X, Y = bandY }, + }; + + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + targetBoundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(targetNode, targetBoundary, targetAnchor); + targetExterior = ElkShapeBoundaries.BuildGatewayDirectionalExteriorPoint(targetNode, targetBoundary, targetAnchor); + return !ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, targetExterior) + && ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, targetBoundary, targetExterior); + } + + targetExterior = targetSide switch + { + "left" => new ElkPoint { X = targetBoundary.X - clearance, Y = targetBoundary.Y }, + "right" => new ElkPoint { X = targetBoundary.X + clearance, Y = targetBoundary.Y }, + "top" => new ElkPoint { X = targetBoundary.X, Y = targetBoundary.Y - clearance }, + "bottom" => new ElkPoint { X = targetBoundary.X, Y = targetBoundary.Y + clearance }, + _ => targetBoundary, + }; + return true; + } + + private static bool TryResolveUnderNodeBandSourceGeometry( + IReadOnlyList originalPath, + ElkPositionedNode sourceNode, + ElkPoint targetBoundary, + double bandEntryX, + double bandY, + out ElkPoint startBoundary, + out ElkPoint sourceExterior) + { + startBoundary = new ElkPoint { X = originalPath[0].X, Y = originalPath[0].Y }; + sourceExterior = startBoundary; + if (!ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + return true; + } + + var bandEntry = new ElkPoint { X = bandEntryX, Y = bandY }; + sourceExterior = ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, startBoundary, bandEntry); + var canReuseCurrentBoundary = CanReuseGatewayBoundaryForBandRoute( + sourceNode, + startBoundary, + sourceExterior, + bandEntry); + if (!canReuseCurrentBoundary) + { + if (!TryResolvePreferredGatewaySourceBoundary(sourceNode, bandEntry, targetBoundary, out startBoundary)) + { + return false; + } + + sourceExterior = ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, startBoundary, bandEntry); + if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(sourceNode, sourceExterior) + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(sourceNode, startBoundary, sourceExterior)) + { + return false; + } + } + + return true; + } + + private static List BuildUnderNodeBandCandidatePath( + ElkPoint startBoundary, + ElkPoint sourceExterior, + double bandEntryX, + double bandY, + ElkPoint targetExterior, + ElkPoint targetBoundary) + { + var route = new List { startBoundary }; + if (!ElkEdgeRoutingGeometry.PointsEqual(route[^1], sourceExterior)) + { + route.Add(sourceExterior); + } + + if (Math.Abs(route[^1].X - bandEntryX) > 0.5d) + { + route.Add(new ElkPoint { X = bandEntryX, Y = route[^1].Y }); + } + + if (Math.Abs(route[^1].Y - bandY) > 0.5d) + { + route.Add(new ElkPoint { X = bandEntryX, Y = bandY }); + } + + if (Math.Abs(route[^1].X - targetExterior.X) > 0.5d) + { + route.Add(new ElkPoint { X = targetExterior.X, Y = bandY }); + } + + if (Math.Abs(route[^1].Y - targetExterior.Y) > 0.5d) + { + route.Add(new ElkPoint { X = targetExterior.X, Y = targetExterior.Y }); + } + + if (!ElkEdgeRoutingGeometry.PointsEqual(route[^1], targetExterior)) + { + route.Add(targetExterior); + } + + if (!ElkEdgeRoutingGeometry.PointsEqual(route[^1], targetBoundary)) + { + route.Add(targetBoundary); + } + + return route; + } + + private static bool IsUsableUnderNodeBandCandidate( + IReadOnlyList candidate, + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId) + { + if (candidate.Count < 2 + || HasNodeObstacleCrossing(candidate, nodes, sourceNodeId, targetNodeId)) + { + return false; + } + + if (ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + if (!HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true) + || !HasCleanGatewaySourceBandPath(candidate, sourceNode)) + { + return false; + } + } + else if (!HasValidBoundaryAngle(candidate[0], candidate[1], sourceNode)) + { + return false; + } + + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + return CanAcceptGatewayTargetRepair(candidate, targetNode) + && HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, targetNode, fromStart: false); + } + + return !HasTargetApproachBacktracking(candidate, targetNode) + && HasValidBoundaryAngle(candidate[^1], candidate[^2], targetNode); + } + + private static bool TryResolveUnderNodeBand( + IReadOnlyList path, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + double minClearance, + out double bandY) + { + bandY = double.NaN; + var blockers = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < path.Count - 1; i++) + { + if (!TryResolveUnderNodeBlockers( + path[i], + path[i + 1], + nodes, + sourceNodeId, + targetNodeId, + minClearance, + out var segmentBlockers)) + { + continue; + } + + foreach (var blocker in segmentBlockers) + { + blockers[blocker.Id] = blocker; + } + } + + if (blockers.Count == 0) + { + return false; + } + + var clearance = Math.Max(24d, minClearance * 0.6d); + var graphMinY = nodes.Min(node => node.Y); + bandY = Math.Max(graphMinY - 72d, blockers.Values.Min(node => node.Y) - clearance); + return true; + } + + private static IEnumerable<(string Side, ElkPoint Boundary, double SideBias)> EnumerateUnderNodeBandTargetBoundaries( + IReadOnlyList path, + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + double bandY) + { + const double coordinateTolerance = 0.5d; + var referenceXs = new List + { + path[0].X, + path[^1].X, + path.Count > 1 ? path[1].X : path[0].X, + path.Count > 1 ? path[^2].X : path[^1].X, + sourceNode.X + (sourceNode.Width / 2d), + targetNode.X + (targetNode.Width / 2d), + }; + var referenceYs = new List + { + path[0].Y, + path[^1].Y, + path.Count > 1 ? path[1].Y : path[0].Y, + path.Count > 1 ? path[^2].Y : path[^1].Y, + sourceNode.Y + (sourceNode.Height / 2d), + targetNode.Y + (targetNode.Height / 2d), + }; + + var sides = new List<(string Side, double SideBias)>(); + if (bandY < targetNode.Y - coordinateTolerance) + { + sides.Add(("top", -200d)); + } + + if (bandY > targetNode.Y + targetNode.Height + coordinateTolerance) + { + sides.Add(("bottom", -200d)); + } + + if (path[0].X <= targetNode.X - coordinateTolerance + || path[^1].X <= targetNode.X - coordinateTolerance) + { + sides.Add(("left", -120d)); + } + + if (path[0].X >= targetNode.X + targetNode.Width + coordinateTolerance + || path[^1].X >= targetNode.X + targetNode.Width + coordinateTolerance) + { + sides.Add(("right", -120d)); + } + + if (sides.Count == 0) + { + yield break; + } + + var seenBoundaries = new HashSet(StringComparer.Ordinal); + foreach (var (side, sideBias) in sides) + { + var coordinates = side is "top" or "bottom" ? referenceXs : referenceYs; + foreach (var coordinate in coordinates) + { + if (!TryBuildUnderNodeBandTargetBoundary(targetNode, side, coordinate, bandY, out var boundary)) + { + continue; + } + + var key = $"{side}|{Math.Round(boundary.X, 2):F2}|{Math.Round(boundary.Y, 2):F2}"; + if (!seenBoundaries.Add(key)) + { + continue; + } + + yield return (side, boundary, sideBias); + } + } + } + + private static bool TryBuildUnderNodeBandTargetBoundary( + ElkPositionedNode targetNode, + string side, + double axisCoordinate, + double bandY, + out ElkPoint boundary) + { + boundary = default!; + var referencePoint = side switch + { + "top" or "bottom" => new ElkPoint { X = axisCoordinate, Y = bandY }, + "left" => new ElkPoint { X = targetNode.X - 24d, Y = axisCoordinate }, + "right" => new ElkPoint { X = targetNode.X + targetNode.Width + 24d, Y = axisCoordinate }, + _ => new ElkPoint { X = axisCoordinate, Y = bandY }, + }; + + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + var slotCoordinate = side is "top" or "bottom" + ? referencePoint.X + : referencePoint.Y; + if (!ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, side, slotCoordinate, out boundary)) + { + return false; + } + + boundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(targetNode, boundary, referencePoint); + return true; + } + + boundary = BuildRectBoundaryPointForSide(targetNode, side, referencePoint); + return true; + } + + private static double ScoreUnderNodeBandCandidate( + IReadOnlyList originalPath, + IReadOnlyList candidate, + ElkPositionedNode targetNode, + string requestedTargetSide, + string preferredTargetSide, + double sideBias, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + double minClearance) + { + var candidateUnderNodeSegments = CountUnderNodeSegments(candidate, nodes, sourceNodeId, targetNodeId, minClearance); + var score = (candidateUnderNodeSegments * 100_000d) + + ComputePathLength(candidate) + + (Math.Max(0, candidate.Count - 2) * 8d) + + sideBias; + + var actualTargetSide = ResolveTargetApproachSide(candidate, targetNode); + if (!string.Equals(actualTargetSide, preferredTargetSide, StringComparison.Ordinal)) + { + score += 1_000d; + } + + if (!string.Equals(actualTargetSide, requestedTargetSide, StringComparison.Ordinal)) + { + score += 350d; + } + + if (ComputePathLength(candidate) > ComputePathLength(originalPath)) + { + score += (ComputePathLength(candidate) - ComputePathLength(originalPath)) * 0.5d; + } + + return score; + } + + private static void WriteUnderNodeDebug(string? edgeId, string message) + { + if (edgeId is not ("edge/9" or "edge/25")) + { + return; + } + + var path = System.IO.Path.Combine(System.AppContext.BaseDirectory, "elksharp.undernode-debug.log"); + lock (UnderNodeDebugSync) + { + System.IO.File.AppendAllText(path, $"[{System.DateTime.UtcNow:O}] {edgeId} {message}{System.Environment.NewLine}"); + } + } + + private static string FormatPath(IReadOnlyList path) + { + return string.Join(" -> ", path.Select(FormatPoint)); + } + + private static string FormatPoint(ElkPoint point) + { + return $"({point.X:F2},{point.Y:F2})"; + } + + private static string FormatUnderNodeBlockers( + IReadOnlyList path, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + double minClearance) + { + var blockers = new List(); + for (var i = 0; i < path.Count - 1; i++) + { + if (!TryResolveUnderNodeBlockers(path[i], path[i + 1], nodes, sourceNodeId, targetNodeId, minClearance, out var segmentBlockers)) + { + continue; + } + + blockers.Add($"{FormatPoint(path[i])}->{FormatPoint(path[i + 1])}:{string.Join(",", segmentBlockers.Select(node => node.Id))}"); + } + + return blockers.Count == 0 ? "" : string.Join(" | ", blockers); + } + + private static bool TryBuildGatewaySourceUnderNodeDropCandidate( + IReadOnlyList path, + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + double minClearance, + out List candidate) + { + candidate = []; + if (!ElkShapeBoundaries.IsGatewayShape(sourceNode) + || path.Count < 2) + { + return false; + } + + ElkPositionedNode[] blockers = []; + for (var i = 0; i < path.Count - 1; i++) + { + if (TryResolveUnderNodeBlockers( + path[i], + path[i + 1], + nodes, + sourceNodeId, + targetNodeId, + minClearance, + out blockers)) + { + break; + } + } + + if (blockers.Length == 0) + { + return false; + } + + if (path[1].Y <= path[0].Y + 0.5d) + { + return false; + } + + var targetSide = + targetNode.X >= sourceNode.X + sourceNode.Width - 0.5d ? "left" : + targetNode.X + targetNode.Width <= sourceNode.X + 0.5d ? "right" : + ResolveTargetApproachSide(path, targetNode); + if (targetSide is not "left" and not "right") + { + return false; + } + + var currentUnderNodeSegments = CountUnderNodeSegments(path, nodes, sourceNodeId, targetNodeId, minClearance); + if (currentUnderNodeSegments == 0) + { + return false; + } + + var clearance = Math.Max(24d, minClearance * 0.6d); + var graphMinY = nodes.Min(node => node.Y); + var bandY = blockers.Min(node => node.Y) - clearance; + if (bandY <= graphMinY - 96d) + { + return false; + } + + var targetAnchor = targetSide == "left" + ? new ElkPoint { X = targetNode.X - clearance, Y = bandY } + : new ElkPoint { X = targetNode.X + targetNode.Width + clearance, Y = bandY }; + + ElkPoint targetBoundary; + ElkPoint targetExterior; + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + if (!ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, targetSide, bandY, out targetBoundary)) + { + return false; + } + + targetBoundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(targetNode, targetBoundary, targetAnchor); + targetExterior = ElkShapeBoundaries.BuildGatewayDirectionalExteriorPoint(targetNode, targetBoundary, targetAnchor); + } + else + { + targetBoundary = BuildRectBoundaryPointForSide(targetNode, targetSide, targetAnchor); + targetExterior = new ElkPoint + { + X = targetSide == "left" + ? targetBoundary.X - clearance + : targetBoundary.X + clearance, + Y = targetBoundary.Y, + }; + } + + var bandEntry = new ElkPoint { X = targetExterior.X, Y = bandY }; + var startBoundary = path[0]; + var sourceExterior = ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, startBoundary, bandEntry); + var canReuseCurrentBoundary = CanReuseGatewayBoundaryForBandRoute( + sourceNode, + startBoundary, + sourceExterior, + bandEntry); + if (!canReuseCurrentBoundary) + { + if (!TryResolvePreferredGatewaySourceBoundary(sourceNode, bandEntry, targetBoundary, out startBoundary)) + { + return false; + } + + sourceExterior = ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, startBoundary, bandEntry); + if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(sourceNode, sourceExterior) + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(sourceNode, startBoundary, sourceExterior)) + { + return false; + } + } + + var route = new List { startBoundary }; + if (!ElkEdgeRoutingGeometry.PointsEqual(route[^1], sourceExterior)) + { + route.Add(sourceExterior); + } + + if (Math.Abs(route[^1].Y - bandY) > 0.5d) + { + route.Add(new ElkPoint { X = route[^1].X, Y = bandY }); + } + + if (Math.Abs(route[^1].X - targetExterior.X) > 0.5d) + { + route.Add(new ElkPoint { X = targetExterior.X, Y = bandY }); + } + + if (Math.Abs(route[^1].Y - targetExterior.Y) > 0.5d) + { + route.Add(new ElkPoint { X = targetExterior.X, Y = targetExterior.Y }); + } + + if (!ElkEdgeRoutingGeometry.PointsEqual(route[^1], targetExterior)) + { + route.Add(targetExterior); + } + + route.Add(targetBoundary); + candidate = NormalizePathPoints(route); + if (candidate.Count < 2 + || HasNodeObstacleCrossing(candidate, nodes, sourceNodeId, targetNodeId) + || !HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true) + || !HasCleanGatewaySourceBandPath(candidate, sourceNode) + || HasTargetApproachBacktracking(candidate, targetNode) + || (ElkShapeBoundaries.IsGatewayShape(targetNode) + ? !HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, targetNode, fromStart: false) + : !HasValidBoundaryAngle(candidate[^1], candidate[^2], targetNode)) + || CountUnderNodeSegments(candidate, nodes, sourceNodeId, targetNodeId, minClearance) >= currentUnderNodeSegments) + { + candidate = []; + return false; + } + + if (ComputePathLength(candidate) > ComputePathLength(path) + 220d) + { + candidate = []; + return false; + } + + return true; + } + + private static bool TryBuildSafeGatewaySourceBandCandidate( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + ElkPoint endBoundary, + double minClearance, + out List candidate) + { + candidate = []; + if (!ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + return false; + } + + var clearance = Math.Max(24d, minClearance * 0.6d); + var graphMinY = nodes.Min(node => node.Y); + var minX = Math.Min(sourceNode.X, endBoundary.X); + var maxX = Math.Max(sourceNode.X + sourceNode.Width, endBoundary.X); + var blockers = nodes + .Where(node => + !string.Equals(node.Id, sourceNodeId, StringComparison.Ordinal) + && !string.Equals(node.Id, targetNodeId, StringComparison.Ordinal) + && maxX > node.X + 0.5d + && minX < node.X + node.Width - 0.5d + && node.Y <= Math.Max(sourceNode.Y + sourceNode.Height, endBoundary.Y) + clearance) + .ToArray(); + + var baseY = Math.Min(targetNode.Y, sourceNode.Y); + if (blockers.Length > 0) + { + baseY = Math.Min(baseY, blockers.Min(node => node.Y)); + } + + var bandY = Math.Max(graphMinY - 72d, baseY - clearance); + var continuationPoint = new ElkPoint { X = endBoundary.X, Y = bandY }; + if (!TryResolvePreferredGatewaySourceBoundary(sourceNode, continuationPoint, endBoundary, out var startBoundary)) + { + return false; + } + + var sourceExterior = ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, startBoundary, continuationPoint); + if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(sourceNode, sourceExterior) + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(sourceNode, startBoundary, sourceExterior)) + { + return false; + } + + if (bandY >= Math.Min(sourceExterior.Y, endBoundary.Y) - 0.5d) + { + return false; + } + + var route = new List + { + new() { X = startBoundary.X, Y = startBoundary.Y }, + new() { X = sourceExterior.X, Y = sourceExterior.Y }, + }; + + if (Math.Abs(route[^1].Y - bandY) > 0.5d) + { + route.Add(new ElkPoint { X = route[^1].X, Y = bandY }); + } + + if (Math.Abs(route[^1].X - endBoundary.X) > 0.5d) + { + route.Add(new ElkPoint { X = endBoundary.X, Y = bandY }); + } + + if (Math.Abs(route[^1].Y - endBoundary.Y) > 0.5d) + { + route.Add(new ElkPoint { X = endBoundary.X, Y = endBoundary.Y }); + } + + candidate = NormalizePathPoints(route); + if (candidate.Count < 2 || HasNodeObstacleCrossing(candidate, nodes, sourceNodeId, targetNodeId)) + { + candidate = []; + return false; + } + + if (!HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true)) + { + candidate = []; + return false; + } + + if (!HasCleanGatewaySourceBandPath(candidate, sourceNode)) + { + candidate = []; + return false; + } + + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + if (!CanAcceptGatewayTargetRepair(candidate, targetNode) + || !HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, targetNode, fromStart: false)) + { + candidate = []; + return false; + } + } + else if (HasTargetApproachBacktracking(candidate, targetNode) + || !HasValidBoundaryAngle(candidate[^1], candidate[^2], targetNode)) + { + candidate = []; + return false; + } + + return true; + } + + private static bool CanReuseGatewayBoundaryForBandRoute( + ElkPositionedNode sourceNode, + ElkPoint startBoundary, + ElkPoint sourceExterior, + ElkPoint bandEntry) + { + if (!ElkShapeBoundaries.IsGatewayShape(sourceNode) + || !ElkShapeBoundaries.IsGatewayBoundaryPoint(sourceNode, startBoundary) + || ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(sourceNode, sourceExterior) + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(sourceNode, startBoundary, sourceExterior)) + { + return false; + } + + var prefix = new List { startBoundary }; + if (!ElkEdgeRoutingGeometry.PointsEqual(prefix[^1], sourceExterior)) + { + prefix.Add(sourceExterior); + } + + if (Math.Abs(prefix[^1].X - bandEntry.X) > 0.5d) + { + prefix.Add(new ElkPoint { X = bandEntry.X, Y = prefix[^1].Y }); + } + + if (Math.Abs(prefix[^1].Y - bandEntry.Y) > 0.5d) + { + prefix.Add(new ElkPoint { X = bandEntry.X, Y = bandEntry.Y }); + } + + prefix = NormalizePathPoints(prefix); + return HasCleanGatewaySourceBandPath(prefix, sourceNode); + } + + private static bool HasCleanGatewaySourceBandPath( + IReadOnlyList path, + ElkPositionedNode sourceNode) + { + var prefix = ExtractGatewaySourceBandPrefix(path); + return !HasGatewaySourceExitBacktracking(prefix) + && !HasGatewaySourceExitCurl(prefix) + && !HasGatewaySourceDominantAxisDetour(prefix, sourceNode); + } + + private static IReadOnlyList ExtractGatewaySourceBandPrefix(IReadOnlyList path) + { + if (path.Count < 4) + { + return path; + } + + var bandY = path.Min(point => point.Y); + var bandIndex = -1; + for (var i = 1; i < path.Count; i++) + { + if (Math.Abs(path[i].Y - bandY) <= 0.5d) + { + bandIndex = i; + break; + } + } + + if (bandIndex < 1) + { + bandIndex = Math.Min(path.Count - 1, 3); + } + + return NormalizePathPoints( + path.Take(bandIndex + 1) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList()); + } + + private static bool TryApplyPreferredBoundaryShortcut( + IReadOnlyList path, + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + bool requireUnderNodeImprovement, + double minClearance, + out List repairedPath) + { + repairedPath = path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (!TryBuildPreferredBoundaryShortcutPath( + sourceNode, + targetNode, + nodes, + sourceNodeId, + targetNodeId, + out var shortcut)) + { + return false; + } + + if (HasNodeObstacleCrossing(shortcut, nodes, sourceNodeId, targetNodeId)) + { + return false; + } + + if (ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + if (!HasAcceptableGatewayBoundaryPath(shortcut, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true)) + { + return false; + } + } + else if (shortcut.Count < 2 || !HasValidBoundaryAngle(shortcut[0], shortcut[1], sourceNode)) + { + return false; + } + + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + if (!CanAcceptGatewayTargetRepair(shortcut, targetNode) + || !HasAcceptableGatewayBoundaryPath(shortcut, nodes, sourceNodeId, targetNodeId, targetNode, fromStart: false)) + { + return false; + } + } + else if (shortcut.Count < 2 + || HasTargetApproachBacktracking(shortcut, targetNode) + || !HasValidBoundaryAngle(shortcut[^1], shortcut[^2], targetNode)) + { + return false; + } + + var currentUnderNodeSegments = CountUnderNodeSegments(path, nodes, sourceNodeId, targetNodeId, minClearance); + var shortcutUnderNodeSegments = CountUnderNodeSegments(shortcut, nodes, sourceNodeId, targetNodeId, minClearance); + if (requireUnderNodeImprovement && shortcutUnderNodeSegments >= currentUnderNodeSegments) + { + return false; + } + + var currentLength = ComputePathLength(path); + var shortcutLength = ComputePathLength(shortcut); + var boundaryInvalid = ElkShapeBoundaries.IsGatewayShape(targetNode) + ? NeedsGatewayTargetBoundaryRepair(path, targetNode) + : path.Count >= 2 && !HasValidBoundaryAngle(path[^1], path[^2], targetNode); + var underNodeImproved = shortcutUnderNodeSegments < currentUnderNodeSegments; + if (!underNodeImproved + && !boundaryInvalid + && shortcutLength > currentLength - 8d) + { + return false; + } + + repairedPath = shortcut; + return true; + } + + private static int CountUnderNodeSegments( + IReadOnlyList path, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + double minClearance) + { + var count = 0; + for (var i = 0; i < path.Count - 1; i++) + { + if (TryResolveUnderNodeBlockers( + path[i], + path[i + 1], + nodes, + sourceNodeId, + targetNodeId, + minClearance, + out _)) + { + count++; + } + } + + return count; + } + + private static bool TryResolveUnderNodeBlockers( + ElkPoint start, + ElkPoint end, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + double minClearance, + out ElkPositionedNode[] blockers) + { + blockers = []; + if (Math.Abs(start.Y - end.Y) > 2d) + { + return false; + } + + var minX = Math.Min(start.X, end.X); + var maxX = Math.Max(start.X, end.X); + blockers = nodes + .Where(node => + !string.Equals(node.Id, sourceNodeId, StringComparison.Ordinal) + && !string.Equals(node.Id, targetNodeId, StringComparison.Ordinal) + && maxX > node.X + 0.5d + && minX < node.X + node.Width - 0.5d) + .Where(node => + { + var distanceBelowNode = start.Y - (node.Y + node.Height); + return distanceBelowNode > 0.5d && distanceBelowNode < minClearance; + }) + .ToArray(); + + return blockers.Length > 0; + } + + private static IReadOnlyList<(ElkPoint Start, ElkPoint End)> FlattenSegmentsNearStart( + IReadOnlyList path, + int maxSegmentsFromStart) + { + if (path.Count < 2 || maxSegmentsFromStart <= 0) + { + return []; + } + + var segments = new List<(ElkPoint Start, ElkPoint End)>(Math.Min(path.Count - 1, maxSegmentsFromStart)); + var segmentCount = Math.Min(path.Count - 1, maxSegmentsFromStart); + for (var i = 0; i < segmentCount; i++) + { + segments.Add((path[i], path[i + 1])); + } + + return segments; + } + + private static bool IsOrthogonal(ElkPoint start, ElkPoint end) + { + return Math.Abs(start.X - end.X) <= 0.5d + || Math.Abs(start.Y - end.Y) <= 0.5d; + } + private static bool ShouldSpreadTargetApproach( ElkRoutedEdge edge, double graphMinY, @@ -974,6 +12505,11 @@ internal static class ElkEdgePostProcessor return true; } + if (HasProtectedUnderNodeGeometry(edge)) + { + return false; + } + if (HasCorridorBendPoints(edge, graphMinY, graphMaxY)) { return false; @@ -982,6 +12518,30 @@ internal static class ElkEdgePostProcessor return true; } + private static bool ShouldSpreadSourceDeparture( + ElkRoutedEdge edge, + double graphMinY, + double graphMaxY) + { + if (!string.IsNullOrWhiteSpace(edge.SourcePortId) || !string.IsNullOrWhiteSpace(edge.TargetPortId)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(edge.Kind) + && edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (ShouldPreserveSourceExitGeometry(edge, graphMinY, graphMaxY)) + { + return false; + } + + return true; + } + private static bool HasClearBoundarySegments( IReadOnlyList path, IReadOnlyCollection nodes, @@ -1032,6 +12592,11 @@ internal static class ElkEdgePostProcessor ElkPoint adjacentPoint, ElkPositionedNode node) { + if (ElkShapeBoundaries.IsGatewayShape(node)) + { + return ElkShapeBoundaries.HasValidGatewayBoundaryAngle(node, boundaryPoint, adjacentPoint); + } + var segDx = Math.Abs(boundaryPoint.X - adjacentPoint.X); var segDy = Math.Abs(boundaryPoint.Y - adjacentPoint.Y); if (segDx < 3d && segDy < 3d) @@ -1050,6 +12615,481 @@ internal static class ElkEdgePostProcessor }; } + private static bool PathChanged(IReadOnlyList left, IReadOnlyList right) + { + return left.Count != right.Count + || !left.Zip(right, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal); + } + + private static bool HasAcceptableGatewayBoundaryPath( + IReadOnlyList path, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + ElkPositionedNode gatewayNode, + bool fromStart) + { + if (path.Count < 2) + { + return false; + } + + var boundaryPoint = fromStart ? path[0] : path[^1]; + var adjacentPoint = fromStart ? path[1] : path[^2]; + if (!ElkShapeBoundaries.HasValidGatewayBoundaryAngle(gatewayNode, boundaryPoint, adjacentPoint)) + { + return false; + } + + return HasClearBoundarySegments(path, nodes, sourceNodeId, targetNodeId, fromStart, 1) + && !HasExcessiveGatewayDiagonalLength(path, gatewayNode) + && !HasNodeObstacleCrossing(path, nodes, sourceNodeId, targetNodeId); + } + + private static bool HasNodeObstacleCrossing( + IReadOnlyList path, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId) + { + if (path.Count < 2) + { + return false; + } + + var obstacles = nodes.Select(node => ( + Left: node.X, + Top: node.Y, + Right: node.X + node.Width, + Bottom: node.Y + node.Height, + Id: node.Id)).ToArray(); + for (var i = 0; i < path.Count - 1; i++) + { + if (SegmentCrossesObstacle(path[i], path[i + 1], obstacles, sourceNodeId ?? string.Empty, targetNodeId ?? string.Empty)) + { + return true; + } + } + + return false; + } + + private static bool CanAcceptGatewayTargetRepair( + IReadOnlyList path, + ElkPositionedNode targetNode) + { + return path.Count >= 2 + && !ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, path[^2]) + && !HasExcessiveGatewayDiagonalLength(path, targetNode) + && ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, path[^1], path[^2]); + } + + private static bool HasExcessiveGatewayDiagonalLength( + IReadOnlyList path, + ElkPositionedNode gatewayNode) + { + var maxDiagonalLength = Math.Max(96d, gatewayNode.Width + gatewayNode.Height); + for (var i = 0; i < path.Count - 1; i++) + { + var start = path[i]; + var end = path[i + 1]; + var dx = Math.Abs(end.X - start.X); + var dy = Math.Abs(end.Y - start.Y); + if (dx <= 3d || dy <= 3d) + { + continue; + } + + if (ElkEdgeRoutingGeometry.ComputeSegmentLength(start, end) > maxDiagonalLength) + { + return true; + } + } + + return false; + } + + private static int FindFirstGatewayExteriorPointIndex( + IReadOnlyList path, + ElkPositionedNode node) + { + for (var i = 1; i < path.Count; i++) + { + if (!ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(node, path[i])) + { + return i; + } + } + + return Math.Min(1, path.Count - 1); + } + + private static int FindLastGatewayExteriorPointIndex( + IReadOnlyList path, + ElkPositionedNode node) + { + for (var i = path.Count - 2; i >= 0; i--) + { + if (!ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(node, path[i])) + { + return i; + } + } + + return Math.Max(0, path.Count - 2); + } + + private static List ForceGatewayExteriorTargetApproach( + IReadOnlyList sourcePath, + ElkPositionedNode targetNode, + ElkPoint boundaryPoint) + { + var path = sourcePath + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (path.Count < 2) + { + return path; + } + + var exteriorIndex = FindLastGatewayExteriorPointIndex(path, targetNode); + var exteriorAnchor = path[exteriorIndex]; + var boundary = ElkShapeBoundaries.IsGatewayBoundaryPoint(targetNode, boundaryPoint) + ? boundaryPoint + : ElkShapeBoundaries.ProjectOntoShapeBoundary(targetNode, exteriorAnchor); + boundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(targetNode, boundary, exteriorAnchor); + + var candidates = ResolveForcedGatewayExteriorApproachCandidates(targetNode, boundary, exteriorAnchor).ToArray(); + if (candidates.Length == 0) + { + return path; + } + + var prefix = path.Take(exteriorIndex + 1) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (prefix.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(prefix[^1], exteriorAnchor)) + { + prefix.Add(exteriorAnchor); + } + + foreach (var candidate in candidates) + { + var rebuilt = prefix + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + AppendGatewayTargetOrthogonalCorner( + rebuilt, + rebuilt[^1], + candidate, + rebuilt.Count >= 2 ? rebuilt[^2] : null, + preferHorizontalFromReference: ShouldPreferHorizontalGatewayExit(rebuilt[^1], candidate), + targetNode); + if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], candidate)) + { + rebuilt.Add(candidate); + } + + rebuilt.Add(boundary); + var normalized = NormalizePathPoints(rebuilt); + if (normalized.Count < 2 + || ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, normalized[^2]) + || !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, normalized[^1], normalized[^2])) + { + continue; + } + + return normalized; + } + + return path; + } + + private static IEnumerable ResolveForcedGatewayExteriorApproachCandidates( + ElkPositionedNode targetNode, + ElkPoint boundaryPoint, + ElkPoint exteriorAnchor) + { + const double padding = 8d; + var centerX = targetNode.X + (targetNode.Width / 2d); + var centerY = targetNode.Y + (targetNode.Height / 2d); + var candidates = new List(); + + AddUniquePoint( + candidates, + ElkShapeBoundaries.BuildGatewayDirectionalExteriorPoint(targetNode, boundaryPoint, exteriorAnchor, padding)); + AddUniquePoint( + candidates, + ElkShapeBoundaries.BuildGatewayExteriorApproachPoint(targetNode, boundaryPoint, padding)); + + if (boundaryPoint.X <= centerX + 0.5d) + { + AddUniquePoint( + candidates, + new ElkPoint + { + X = targetNode.X - padding, + Y = boundaryPoint.Y, + }); + } + + if (boundaryPoint.X >= centerX - 0.5d) + { + AddUniquePoint( + candidates, + new ElkPoint + { + X = targetNode.X + targetNode.Width + padding, + Y = boundaryPoint.Y, + }); + } + + if (boundaryPoint.Y <= centerY + 0.5d) + { + AddUniquePoint( + candidates, + new ElkPoint + { + X = boundaryPoint.X, + Y = targetNode.Y - padding, + }); + } + + if (boundaryPoint.Y >= centerY - 0.5d) + { + AddUniquePoint( + candidates, + new ElkPoint + { + X = boundaryPoint.X, + Y = targetNode.Y + targetNode.Height + padding, + }); + } + + return candidates + .Where(candidate => !ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, candidate) + && ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, boundaryPoint, candidate)) + .OrderBy(candidate => ScoreForcedGatewayExteriorApproachCandidate(targetNode, boundaryPoint, candidate, exteriorAnchor)) + .ToArray(); + } + + private static double ScoreForcedGatewayExteriorApproachCandidate( + ElkPositionedNode targetNode, + ElkPoint boundaryPoint, + ElkPoint candidate, + ElkPoint exteriorAnchor) + { + var score = Math.Abs(candidate.X - exteriorAnchor.X) + Math.Abs(candidate.Y - exteriorAnchor.Y); + score += (Math.Abs(candidate.X - boundaryPoint.X) + Math.Abs(candidate.Y - boundaryPoint.Y)) * 0.25d; + + var desiredDx = boundaryPoint.X - exteriorAnchor.X; + var desiredDy = boundaryPoint.Y - exteriorAnchor.Y; + var approachDx = candidate.X - boundaryPoint.X; + var approachDy = candidate.Y - boundaryPoint.Y; + + if (Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.15d + && Math.Sign(approachDx) != 0 + && Math.Sign(approachDx) != Math.Sign(exteriorAnchor.X - boundaryPoint.X)) + { + score += 10_000d; + } + + if (Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.15d + && Math.Sign(approachDy) != 0 + && Math.Sign(approachDy) != Math.Sign(exteriorAnchor.Y - boundaryPoint.Y)) + { + score += 10_000d; + } + + var preferredExterior = ElkShapeBoundaries.BuildGatewayExteriorApproachPoint(targetNode, boundaryPoint); + if (ElkEdgeRoutingGeometry.PointsEqual(candidate, preferredExterior)) + { + score -= 8d; + } + + return score; + } + + private static bool NeedsGatewayDiagonalStub(ElkPoint start, ElkPoint end) + { + var deltaX = Math.Abs(end.X - start.X); + var deltaY = Math.Abs(end.Y - start.Y); + if (deltaX < 3d || deltaY < 3d) + { + return false; + } + + var ratio = deltaX / Math.Max(deltaY, 0.001d); + return ratio < 0.55d || ratio > 1.85d; + } + + private static bool ShouldUseGatewayDiagonalStub( + ElkPositionedNode node, + ElkPoint start, + ElkPoint end) + { + return !ElkShapeBoundaries.IsNearGatewayVertex(node, start) + && !ElkShapeBoundaries.IsNearGatewayVertex(node, end) + && NeedsGatewayDiagonalStub(start, end); + } + + private static List BuildGatewayExitStubbedPath( + IReadOnlyList path, + ElkPoint boundary, + ElkPoint anchor) + { + var stub = BuildGatewayDiagonalStubPoint(boundary, anchor); + var rebuilt = new List { boundary, stub }; + AppendGatewayOrthogonalCorner( + rebuilt, + stub, + anchor, + path.Count > 2 ? path[2] : null, + preferHorizontalFromReference: true); + rebuilt.Add(anchor); + rebuilt.AddRange(path.Skip(2)); + return NormalizePathPoints(rebuilt); + } + + private static List BuildGatewayEntryStubbedPath( + IReadOnlyList path, + ElkPoint anchor, + ElkPoint boundary) + { + var stub = BuildGatewayDiagonalStubPoint(boundary, anchor); + var rebuilt = path.Take(path.Count - 2).ToList(); + if (rebuilt.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], anchor)) + { + rebuilt.Add(anchor); + } + + AppendGatewayOrthogonalCorner( + rebuilt, + anchor, + stub, + rebuilt.Count >= 2 ? rebuilt[^2] : null, + preferHorizontalFromReference: false); + rebuilt.Add(stub); + rebuilt.Add(boundary); + return NormalizePathPoints(rebuilt); + } + + private static ElkPoint BuildGatewayDiagonalStubPoint(ElkPoint boundary, ElkPoint anchor) + { + var deltaX = Math.Abs(anchor.X - boundary.X); + var deltaY = Math.Abs(anchor.Y - boundary.Y); + var stubLength = Math.Min(24d, Math.Max(12d, Math.Min(deltaX, deltaY) * 0.5d)); + return new ElkPoint + { + X = boundary.X + (Math.Sign(anchor.X - boundary.X) * stubLength), + Y = boundary.Y + (Math.Sign(anchor.Y - boundary.Y) * stubLength), + }; + } + + private static void AppendGatewayOrthogonalCorner( + IList points, + ElkPoint from, + ElkPoint to, + ElkPoint? referencePoint, + bool preferHorizontalFromReference) + { + const double coordinateTolerance = 0.5d; + if (Math.Abs(from.X - to.X) <= coordinateTolerance || Math.Abs(from.Y - to.Y) <= coordinateTolerance) + { + return; + } + + var cornerA = new ElkPoint { X = to.X, Y = from.Y }; + var cornerB = new ElkPoint { X = from.X, Y = to.Y }; + var scoreA = ScoreGatewayOrthogonalCorner(cornerA, from, to, referencePoint, preferHorizontalFromReference); + var scoreB = ScoreGatewayOrthogonalCorner(cornerB, from, to, referencePoint, !preferHorizontalFromReference); + points.Add(scoreA <= scoreB ? cornerA : cornerB); + } + + private static void AppendGatewayTargetOrthogonalCorner( + IList points, + ElkPoint from, + ElkPoint to, + ElkPoint? referencePoint, + bool preferHorizontalFromReference, + ElkPositionedNode targetNode) + { + const double coordinateTolerance = 0.5d; + if (Math.Abs(from.X - to.X) <= coordinateTolerance || Math.Abs(from.Y - to.Y) <= coordinateTolerance) + { + return; + } + + var cornerA = new ElkPoint { X = to.X, Y = from.Y }; + var cornerB = new ElkPoint { X = from.X, Y = to.Y }; + var scoreA = ScoreGatewayOrthogonalCorner(cornerA, from, to, referencePoint, preferHorizontalFromReference); + var scoreB = ScoreGatewayOrthogonalCorner(cornerB, from, to, referencePoint, !preferHorizontalFromReference); + if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, cornerA)) + { + scoreA += 100_000d; + } + + if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, cornerB)) + { + scoreB += 100_000d; + } + + points.Add(scoreA <= scoreB ? cornerA : cornerB); + } + + private static double ScoreGatewayOrthogonalCorner( + ElkPoint corner, + ElkPoint from, + ElkPoint to, + ElkPoint? referencePoint, + bool preferHorizontalFirst) + { + const double coordinateTolerance = 0.5d; + var score = preferHorizontalFirst ? 0d : 1d; + var totalDx = to.X - from.X; + var totalDy = to.Y - from.Y; + var firstDx = corner.X - from.X; + var firstDy = corner.Y - from.Y; + var secondDx = to.X - corner.X; + var secondDy = to.Y - corner.Y; + + if (Math.Abs(firstDx) > coordinateTolerance && Math.Abs(totalDx) > coordinateTolerance && Math.Sign(firstDx) != Math.Sign(totalDx)) + { + score += 50d; + } + + if (Math.Abs(firstDy) > coordinateTolerance && Math.Abs(totalDy) > coordinateTolerance && Math.Sign(firstDy) != Math.Sign(totalDy)) + { + score += 50d; + } + + if (Math.Abs(secondDx) > coordinateTolerance && Math.Abs(totalDx) > coordinateTolerance && Math.Sign(secondDx) != Math.Sign(totalDx)) + { + score += 25d; + } + + if (Math.Abs(secondDy) > coordinateTolerance && Math.Abs(totalDy) > coordinateTolerance && Math.Sign(secondDy) != Math.Sign(totalDy)) + { + score += 25d; + } + + if (referencePoint is not null) + { + var reference = referencePoint; + score += (Math.Abs(corner.X - reference.X) + Math.Abs(corner.Y - reference.Y)) * 0.02d; + if (Math.Abs(reference.Y - from.Y) <= coordinateTolerance) + { + score -= Math.Abs(corner.Y - from.Y) <= coordinateTolerance ? 1d : 0d; + } + else if (Math.Abs(reference.X - from.X) <= coordinateTolerance) + { + score -= Math.Abs(corner.X - from.X) <= coordinateTolerance ? 1d : 0d; + } + } + + return score; + } + private static List ExtractFullPath(ElkRoutedEdge edge) { var path = new List(); @@ -1067,6 +13107,422 @@ internal static class ElkEdgePostProcessor return path; } + private static double ComputePathLength(IReadOnlyList points) + { + var length = 0d; + for (var i = 1; i < points.Count; i++) + { + length += ElkEdgeRoutingGeometry.ComputeSegmentLength(points[i - 1], points[i]); + } + + return length; + } + + private static List? TryBuildLocalObstacleSkirtBoundaryShortcut( + IReadOnlyList currentPath, + ElkPoint start, + ElkPoint end, + IReadOnlyCollection nodes, + string? sourceNodeId, + string? targetNodeId, + ElkPositionedNode? targetNode, + double obstaclePadding) + { + var rawObstacles = nodes.Select(node => ( + Left: node.X, + Top: node.Y, + Right: node.X + node.Width, + Bottom: node.Y + node.Height, + Id: node.Id)).ToArray(); + var sourceId = sourceNodeId ?? string.Empty; + var targetId = targetNodeId ?? string.Empty; + var sourceNode = nodes.FirstOrDefault(node => string.Equals(node.Id, sourceId, StringComparison.Ordinal)); + var minLineClearance = ResolveMinLineClearance(nodes); + List? bestPath = null; + var bestScore = double.MaxValue; + + static (double Left, double Top, double Right, double Bottom, string Id)[] ExpandObstacles( + IReadOnlyList<(double Left, double Top, double Right, double Bottom, string Id)> obstacles, + double clearance) + { + return obstacles + .Select(obstacle => ( + Left: obstacle.Left - clearance, + Top: obstacle.Top - clearance, + Right: obstacle.Right + clearance, + Bottom: obstacle.Bottom + clearance, + obstacle.Id)) + .ToArray(); + } + + var candidateClearances = new List(); + AddUniqueCoordinate(candidateClearances, Math.Max(0d, obstaclePadding)); + AddUniqueCoordinate(candidateClearances, Math.Min(Math.Max(0d, obstaclePadding), 24d)); + AddUniqueCoordinate(candidateClearances, 8d); + candidateClearances.Sort((left, right) => right.CompareTo(left)); + + void ConsiderCandidate( + IReadOnlyList rawCandidate, + IReadOnlyList<(double Left, double Top, double Right, double Bottom, string Id)> obstacles) + { + var candidate = NormalizePathPoints(rawCandidate); + if (candidate.Count < 2) + { + return; + } + + for (var i = 1; i < candidate.Count; i++) + { + if (SegmentCrossesObstacle(candidate[i - 1], candidate[i], obstacles.ToArray(), sourceNodeId, targetNodeId)) + { + return; + } + } + + if (sourceNode is not null) + { + if (ElkShapeBoundaries.IsGatewayShape(sourceNode) + && !HasAcceptableGatewayBoundaryPath( + candidate, + nodes, + sourceNodeId, + targetNodeId, + sourceNode, + fromStart: true)) + { + return; + } + + if (!ElkShapeBoundaries.IsGatewayShape(sourceNode) + && !HasValidBoundaryAngle(candidate[0], candidate[1], sourceNode)) + { + return; + } + } + + if (targetNode is not null + && !ElkShapeBoundaries.IsGatewayShape(targetNode) + && HasTargetApproachBacktracking(candidate, targetNode)) + { + return; + } + + if (targetNode is not null + && ElkShapeBoundaries.IsGatewayShape(targetNode) + && (!CanAcceptGatewayTargetRepair(candidate, targetNode) + || !HasAcceptableGatewayBoundaryPath( + candidate, + nodes, + sourceNodeId, + targetNodeId, + targetNode, + fromStart: false))) + { + return; + } + + var underNodeSegments = CountUnderNodeSegments( + candidate, + nodes, + sourceNodeId, + targetNodeId, + minLineClearance); + var score = + (underNodeSegments * 100_000d) + + ComputePathLength(candidate) + + (Math.Max(0, candidate.Count - 2) * 4d); + if (score >= bestScore - 0.5d) + { + return; + } + + bestScore = score; + bestPath = candidate; + } + + static bool IsUsableForwardBridgeAxis(double startAxis, double endAxis, double candidateAxis) + { + const double tolerance = 0.5d; + var desiredDelta = endAxis - startAxis; + var candidateDelta = candidateAxis - startAxis; + if (Math.Abs(desiredDelta) <= tolerance + || Math.Abs(candidateDelta) <= tolerance + || Math.Sign(candidateDelta) != Math.Sign(desiredDelta)) + { + return false; + } + + var minAxis = Math.Min(startAxis, endAxis) + tolerance; + var maxAxis = Math.Max(startAxis, endAxis) - tolerance; + return candidateAxis >= minAxis && candidateAxis <= maxAxis; + } + + static void AddForwardBridgeAxisCandidates(List axes, double startAxis, double endAxis) + { + var desiredDelta = endAxis - startAxis; + if (Math.Abs(desiredDelta) <= 1d) + { + return; + } + + var midpoint = startAxis + (desiredDelta / 2d); + if (IsUsableForwardBridgeAxis(startAxis, endAxis, midpoint)) + { + AddUniqueCoordinate(axes, midpoint); + } + + var forwardStep = startAxis + (Math.Sign(desiredDelta) * Math.Min(48d, Math.Abs(desiredDelta) / 2d)); + if (IsUsableForwardBridgeAxis(startAxis, endAxis, forwardStep)) + { + AddUniqueCoordinate(axes, forwardStep); + } + } + + var horizontalDominant = Math.Abs(end.X - start.X) >= Math.Abs(end.Y - start.Y); + var startAxis = horizontalDominant ? start.X : start.Y; + var endAxis = horizontalDominant ? end.X : end.Y; + var sourceBridgeAxes = new List(); + AddUniqueCoordinate(sourceBridgeAxes, startAxis); + if (currentPath.Count >= 2 && !ElkEdgeRoutingGeometry.PointsEqual(currentPath[1], start)) + { + var currentBridgeAxis = horizontalDominant ? currentPath[1].X : currentPath[1].Y; + if (IsUsableForwardBridgeAxis(startAxis, endAxis, currentBridgeAxis)) + { + AddUniqueCoordinate(sourceBridgeAxes, currentBridgeAxis); + } + } + AddForwardBridgeAxisCandidates(sourceBridgeAxes, startAxis, endAxis); + var targetBridgeAxis = horizontalDominant ? end.X : end.Y; + ElkPoint? preservedGatewayTargetApproach = null; + if (targetNode is not null && ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + if (currentPath.Count >= 2 + && !ElkEdgeRoutingGeometry.PointsEqual(currentPath[^2], end) + && ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, end, currentPath[^2])) + { + preservedGatewayTargetApproach = currentPath[^2]; + targetBridgeAxis = horizontalDominant ? currentPath[^2].X : currentPath[^2].Y; + } + else + { + var targetExterior = ElkShapeBoundaries.BuildGatewayDirectionalExteriorPoint(targetNode, end, start); + if (!ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, targetExterior) + && ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, end, targetExterior)) + { + targetBridgeAxis = horizontalDominant ? targetExterior.X : targetExterior.Y; + } + } + } + + if (horizontalDominant) + { + foreach (var clearance in candidateClearances) + { + var obstacles = ExpandObstacles(rawObstacles, clearance); + var minX = Math.Min(start.X, end.X) + 0.5d; + var maxX = Math.Max(start.X, end.X) - 0.5d; + var corridorTop = Math.Min(start.Y, end.Y) - clearance; + var corridorBottom = Math.Max(start.Y, end.Y) + clearance; + var bypassYCandidates = new List { start.Y, end.Y }; + var cornerBridgeXCandidates = new List(); + + foreach (var obstacle in obstacles) + { + if (string.Equals(obstacle.Id, sourceId, StringComparison.Ordinal) + || string.Equals(obstacle.Id, targetId, StringComparison.Ordinal) + || obstacle.Right <= minX + || obstacle.Left >= maxX + || obstacle.Bottom <= corridorTop + || obstacle.Top >= corridorBottom) + { + continue; + } + + AddUniqueCoordinate(bypassYCandidates, obstacle.Top); + AddUniqueCoordinate(bypassYCandidates, obstacle.Bottom); + if (IsUsableForwardBridgeAxis(start.X, end.X, obstacle.Left)) + { + AddUniqueCoordinate(cornerBridgeXCandidates, obstacle.Left); + } + + if (IsUsableForwardBridgeAxis(start.X, end.X, obstacle.Right)) + { + AddUniqueCoordinate(cornerBridgeXCandidates, obstacle.Right); + } + } + + foreach (var bypassY in bypassYCandidates) + { + foreach (var sourceBridgeAxis in sourceBridgeAxes) + { + ConsiderCandidate( + [ + start, + new ElkPoint { X = sourceBridgeAxis, Y = start.Y }, + new ElkPoint { X = sourceBridgeAxis, Y = bypassY }, + end, + ], + obstacles); + + foreach (var cornerBridgeX in cornerBridgeXCandidates) + { + if (!IsUsableForwardBridgeAxis(sourceBridgeAxis, end.X, cornerBridgeX)) + { + continue; + } + + ConsiderCandidate( + [ + start, + new ElkPoint { X = sourceBridgeAxis, Y = start.Y }, + new ElkPoint { X = sourceBridgeAxis, Y = bypassY }, + new ElkPoint { X = cornerBridgeX, Y = bypassY }, + end, + ], + obstacles); + } + + if (targetNode is not null + && ElkShapeBoundaries.IsGatewayShape(targetNode) + && Math.Abs(targetBridgeAxis - end.X) > 0.5d) + { + ConsiderCandidate( + [ + start, + new ElkPoint { X = sourceBridgeAxis, Y = start.Y }, + new ElkPoint { X = sourceBridgeAxis, Y = bypassY }, + new ElkPoint { X = targetBridgeAxis, Y = bypassY }, + end, + ], + obstacles); + } + + if (preservedGatewayTargetApproach is not null + && !ElkEdgeRoutingGeometry.PointsEqual( + new ElkPoint { X = preservedGatewayTargetApproach.X, Y = bypassY }, + preservedGatewayTargetApproach)) + { + foreach (var cornerBridgeX in cornerBridgeXCandidates) + { + if (!IsUsableForwardBridgeAxis(sourceBridgeAxis, preservedGatewayTargetApproach.X, cornerBridgeX)) + { + continue; + } + + ConsiderCandidate( + [ + start, + new ElkPoint { X = sourceBridgeAxis, Y = start.Y }, + new ElkPoint { X = sourceBridgeAxis, Y = bypassY }, + new ElkPoint { X = cornerBridgeX, Y = bypassY }, + preservedGatewayTargetApproach, + end, + ], + obstacles); + } + + ConsiderCandidate( + [ + start, + new ElkPoint { X = sourceBridgeAxis, Y = start.Y }, + new ElkPoint { X = sourceBridgeAxis, Y = bypassY }, + new ElkPoint { X = preservedGatewayTargetApproach.X, Y = bypassY }, + preservedGatewayTargetApproach, + end, + ], + obstacles); + } + } + } + } + } + else + { + foreach (var clearance in candidateClearances) + { + var obstacles = ExpandObstacles(rawObstacles, clearance); + var minY = Math.Min(start.Y, end.Y) + 0.5d; + var maxY = Math.Max(start.Y, end.Y) - 0.5d; + var corridorLeft = Math.Min(start.X, end.X) - clearance; + var corridorRight = Math.Max(start.X, end.X) + clearance; + var bypassXCandidates = new List { start.X, end.X }; + + foreach (var obstacle in obstacles) + { + if (string.Equals(obstacle.Id, sourceId, StringComparison.Ordinal) + || string.Equals(obstacle.Id, targetId, StringComparison.Ordinal) + || obstacle.Bottom <= minY + || obstacle.Top >= maxY + || obstacle.Right <= corridorLeft + || obstacle.Left >= corridorRight) + { + continue; + } + + AddUniqueCoordinate(bypassXCandidates, obstacle.Left); + AddUniqueCoordinate(bypassXCandidates, obstacle.Right); + } + + foreach (var bypassX in bypassXCandidates) + { + foreach (var sourceBridgeAxis in sourceBridgeAxes) + { + ConsiderCandidate( + [ + start, + new ElkPoint { X = start.X, Y = sourceBridgeAxis }, + new ElkPoint { X = bypassX, Y = sourceBridgeAxis }, + end, + ], + obstacles); + + if (targetNode is not null + && ElkShapeBoundaries.IsGatewayShape(targetNode) + && Math.Abs(targetBridgeAxis - end.Y) > 0.5d) + { + ConsiderCandidate( + [ + start, + new ElkPoint { X = start.X, Y = sourceBridgeAxis }, + new ElkPoint { X = bypassX, Y = sourceBridgeAxis }, + new ElkPoint { X = bypassX, Y = targetBridgeAxis }, + end, + ], + obstacles); + } + + if (preservedGatewayTargetApproach is not null + && !ElkEdgeRoutingGeometry.PointsEqual( + new ElkPoint { X = bypassX, Y = preservedGatewayTargetApproach.Y }, + preservedGatewayTargetApproach)) + { + ConsiderCandidate( + [ + start, + new ElkPoint { X = start.X, Y = sourceBridgeAxis }, + new ElkPoint { X = bypassX, Y = sourceBridgeAxis }, + new ElkPoint { X = bypassX, Y = preservedGatewayTargetApproach.Y }, + preservedGatewayTargetApproach, + end, + ], + obstacles); + } + } + } + } + } + + return bestPath; + } + + private static double ResolveMinLineClearance(IReadOnlyCollection nodes) + { + var serviceNodes = nodes.Where(node => node.Kind is not "Start" and not "End").ToArray(); + return serviceNodes.Length > 0 + ? Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d + : 50d; + } + private static ElkRoutedEdge BuildSingleSectionEdge( ElkRoutedEdge edge, IReadOnlyList path) @@ -1094,6 +13550,86 @@ internal static class ElkEdgePostProcessor }; } + private static bool HasProtectedUnderNodeGeometry(ElkRoutedEdge edge) + { + return ContainsInternalKindMarker(edge.Kind, ProtectedUnderNodeKindMarker); + } + + private static ElkRoutedEdge ProtectUnderNodeGeometry(ElkRoutedEdge edge) + { + if (HasProtectedUnderNodeGeometry(edge)) + { + return edge; + } + + return CloneEdgeWithKind(edge, AppendInternalKindMarker(edge.Kind, ProtectedUnderNodeKindMarker)); + } + + private static ElkRoutedEdge CloneEdgeWithKind(ElkRoutedEdge edge, string? kind) + { + return new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + SourcePortId = edge.SourcePortId, + TargetPortId = edge.TargetPortId, + Kind = kind, + Label = edge.Label, + Sections = edge.Sections + .Select(section => new ElkEdgeSection + { + StartPoint = section.StartPoint, + EndPoint = section.EndPoint, + BendPoints = section.BendPoints.ToArray(), + }) + .ToArray(), + }; + } + + private static bool ContainsInternalKindMarker(string? kind, string marker) + { + if (string.IsNullOrWhiteSpace(kind)) + { + return false; + } + + return kind + .Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Any(part => string.Equals(part, marker, StringComparison.OrdinalIgnoreCase)); + } + + private static string AppendInternalKindMarker(string? kind, string marker) + { + if (ContainsInternalKindMarker(kind, marker)) + { + return kind!; + } + + return string.IsNullOrWhiteSpace(kind) + ? marker + : $"{kind}|{marker}"; + } + + private static string? RemoveInternalKindMarker(string? kind, string marker) + { + if (string.IsNullOrWhiteSpace(kind)) + { + return kind; + } + + var parts = kind + .Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(part => !string.Equals(part, marker, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (parts.Length == 0) + { + return null; + } + + return string.Join("|", parts); + } + private static List NormalizePathPoints(IReadOnlyList points) { const double coordinateTolerance = 0.5d; diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.cs index 4eca7f9c5..f2749114c 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.cs @@ -1,4 +1,6 @@ +using System.Collections.Concurrent; using System.Diagnostics; +using System.Globalization; namespace StellaOps.ElkSharp; @@ -70,6 +72,7 @@ internal static class ElkEdgeRouterIterative : $"invalid({DescribeRetryState(baselineRetryState)})", BestEdges = baselineProcessed, }); + ElkLayoutDiagnostics.FlushSnapshot(diagnostics); } if (ShouldKeepBaselineSolution(baselineEdges, nodes, baselineRetryState)) @@ -83,7 +86,7 @@ internal static class ElkEdgeRouterIterative diagnostics.FinalBrokenShortHighwayCount = baselineBrokenHighways.Count; } - return baselineProcessed; + return ElkEdgePostProcessor.ClearInternalRoutingMarkers(baselineProcessed); } var strategyInputs = GenerateStrategies(baselineEdges, nodes, config, minLineClearance) @@ -118,13 +121,19 @@ internal static class ElkEdgeRouterIterative if (diagnostics is not null) { - diagnostics.IterativeStrategies.Add(strategyResult.Diagnostics); + if (!strategyResult.Diagnostics.RegisteredLive) + { + diagnostics.IterativeStrategies.Add(strategyResult.Diagnostics); + } + + ElkLayoutDiagnostics.FlushSnapshot(diagnostics); } } var best = validSolutions.Count > 0 ? SelectBestValidSolution(validSolutions) : SelectBestFallbackSolution(fallbackSolutions); + best = RefineWinningSolution(best, nodes, layoutOptions.Direction, minLineClearance); if (diagnostics is not null) { @@ -133,9 +142,366 @@ internal static class ElkEdgeRouterIterative diagnostics.FinalBrokenShortHighwayCount = HighwayProcessingEnabled ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(best.Edges, nodes).Count : 0; + ElkLayoutDiagnostics.FlushSnapshot(diagnostics); } - return best.Edges; + return ElkEdgePostProcessor.ClearInternalRoutingMarkers(best.Edges); + } + + private static CandidateSolution RefineWinningSolution( + CandidateSolution best, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance) + { + var current = best; + for (var round = 0; round < 3; round++) + { + var severityByEdgeId = new Dictionary(StringComparer.Ordinal); + var pressure = + ElkEdgeRoutingScoring.CountBadBoundaryAngles(current.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(current.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(current.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountSharedLaneViolations(current.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountBelowGraphViolations(current.Edges, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountUnderNodeViolations(current.Edges, nodes, severityByEdgeId, 10); + if (pressure == 0) + { + break; + } + + var improved = false; + foreach (var focusRootEdgeId in severityByEdgeId + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Key)) + { + var focusEdgeIds = ExpandWinningSolutionFocus(current.Edges, [focusRootEdgeId]).ToArray(); + if (focusEdgeIds.Length == 0) + { + continue; + } + + var candidateEdges = CloseRemainingTerminalViolations( + current.Edges, + nodes, + direction, + minLineClearance, + focusEdgeIds); + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); + var candidateRetryState = BuildRetryState( + candidateScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count + : 0); + + if (!IsBetterCandidate(candidateScore, candidateRetryState, current.Score, current.RetryState)) + { + continue; + } + + current = current with + { + Score = candidateScore, + RetryState = candidateRetryState, + Edges = candidateEdges, + }; + improved = true; + break; + } + + if (!improved) + { + break; + } + } + + current = ApplyFinalDirectUnderNodePolish(current, nodes, minLineClearance); + current = ApplyFinalProtectedLocalBundlePolish(current, nodes, minLineClearance); + current = ApplyFinalSharedLanePolish(current, nodes, direction, minLineClearance); + return current; + } + + private static IEnumerable ExpandWinningSolutionFocus( + IReadOnlyCollection edges, + IEnumerable focusEdgeIds) + { + var edgesById = edges.ToDictionary(edge => edge.Id, StringComparer.Ordinal); + var expanded = new HashSet(StringComparer.Ordinal); + + foreach (var edgeId in focusEdgeIds) + { + if (!expanded.Add(edgeId) || !edgesById.TryGetValue(edgeId, out var edge)) + { + continue; + } + + foreach (var peer in edges) + { + if (string.Equals(peer.Id, edge.Id, StringComparison.Ordinal)) + { + continue; + } + + if (string.Equals(peer.SourceNodeId, edge.SourceNodeId, StringComparison.Ordinal) + || string.Equals(peer.TargetNodeId, edge.TargetNodeId, StringComparison.Ordinal) + || string.Equals(peer.SourceNodeId, edge.TargetNodeId, StringComparison.Ordinal) + || string.Equals(peer.TargetNodeId, edge.SourceNodeId, StringComparison.Ordinal)) + { + expanded.Add(peer.Id); + } + } + } + + return expanded.OrderBy(edgeId => edgeId, StringComparer.Ordinal); + } + + private static CandidateSolution ApplyFinalDirectUnderNodePolish( + CandidateSolution solution, + ElkPositionedNode[] nodes, + double minLineClearance) + { + var current = solution; + var underNodeSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountUnderNodeViolations(current.Edges, nodes, underNodeSeverity, 10); + if (underNodeSeverity.Count == 0) + { + return current; + } + + foreach (var edgeId in underNodeSeverity + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Key)) + { + var candidateEdges = ElkEdgePostProcessor.ElevateUnderNodeViolations( + current.Edges, + nodes, + minLineClearance, + [edgeId]); + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); + var candidateRetryState = BuildRetryState( + candidateScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count + : 0); + if (!IsBetterCandidate(candidateScore, candidateRetryState, current.Score, current.RetryState)) + { + continue; + } + + current = current with + { + Score = candidateScore, + RetryState = candidateRetryState, + Edges = candidateEdges, + }; + } + + return current; + } + + private static CandidateSolution ApplyFinalProtectedLocalBundlePolish( + CandidateSolution solution, + ElkPositionedNode[] nodes, + double minLineClearance) + { + var current = solution; + var underNodeSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountUnderNodeViolations(current.Edges, nodes, underNodeSeverity, 10); + + var focusSeverity = new Dictionary(underNodeSeverity, StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(current.Edges, nodes, focusSeverity, 10); + if (focusSeverity.Count == 0) + { + return current; + } + + foreach (var edgeId in focusSeverity + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Key)) + { + var focusEdgeIds = ExpandWinningSolutionFocus(current.Edges, [edgeId]).ToArray(); + if (focusEdgeIds.Length == 0) + { + continue; + } + + var focusedRepairSeeds = focusEdgeIds + .Where(underNodeSeverity.ContainsKey) + .OrderBy(id => id, StringComparer.Ordinal) + .ToArray(); + if (focusedRepairSeeds.Length == 0) + { + focusedRepairSeeds = [edgeId]; + } + + var candidateEdges = ElkEdgePostProcessor.ElevateUnderNodeViolations( + current.Edges, + nodes, + minLineClearance, + focusedRepairSeeds); + candidateEdges = ElkEdgePostProcessor.SpreadTargetApproachJoins( + candidateEdges, + nodes, + minLineClearance, + focusEdgeIds, + forceOutwardAxisSpacing: true); + candidateEdges = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands( + candidateEdges, + nodes, + minLineClearance, + focusEdgeIds); + candidateEdges = ElkEdgePostProcessor.SeparateSharedLaneConflicts( + candidateEdges, + nodes, + minLineClearance, + focusEdgeIds); + candidateEdges = ElkEdgePostProcessor.ElevateUnderNodeViolations( + candidateEdges, + nodes, + minLineClearance, + focusEdgeIds); + candidateEdges = ElkEdgePostProcessor.SpreadTargetApproachJoins( + candidateEdges, + nodes, + minLineClearance, + focusEdgeIds, + forceOutwardAxisSpacing: true); + candidateEdges = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands( + candidateEdges, + nodes, + minLineClearance, + focusEdgeIds); + candidateEdges = ChoosePreferredHardRuleLayout(current.Edges, candidateEdges, nodes); + + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); + var candidateRetryState = BuildRetryState( + candidateScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count + : 0); + if (!IsBetterCandidate(candidateScore, candidateRetryState, current.Score, current.RetryState)) + { + continue; + } + + current = current with + { + Score = candidateScore, + RetryState = candidateRetryState, + Edges = candidateEdges, + }; + + underNodeSeverity.Clear(); + ElkEdgeRoutingScoring.CountUnderNodeViolations(current.Edges, nodes, underNodeSeverity, 10); + } + + return current; + } + + private static CandidateSolution ApplyFinalSharedLanePolish( + CandidateSolution solution, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance) + { + var current = solution; + for (var round = 0; round < 3; round++) + { + var sharedLaneSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountSharedLaneViolations(current.Edges, nodes, sharedLaneSeverity, 10); + if (sharedLaneSeverity.Count == 0) + { + break; + } + + var improved = false; + foreach (var edgeId in sharedLaneSeverity + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Key)) + { + var focusEdgeIds = ExpandWinningSolutionFocus(current.Edges, [edgeId]).ToArray(); + if (focusEdgeIds.Length == 0) + { + continue; + } + + var closureCandidate = CloseRemainingTerminalViolations( + current.Edges, + nodes, + direction, + minLineClearance, + focusEdgeIds); + var aggressiveCandidate = ApplyAggressiveSharedLaneClosure( + current.Edges, + nodes, + direction, + minLineClearance, + focusEdgeIds); + var candidateEdges = ChoosePreferredHardRuleLayout(closureCandidate, aggressiveCandidate, nodes); + candidateEdges = ChoosePreferredHardRuleLayout(current.Edges, candidateEdges, nodes); + + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); + var candidateRetryState = BuildRetryState( + candidateScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count + : 0); + + var improvedSharedLanes = candidateRetryState.SharedLaneViolations < current.RetryState.SharedLaneViolations; + if (HasHardRuleRegression(candidateRetryState, current.RetryState) + || (!improvedSharedLanes + && !IsBetterCandidate(candidateScore, candidateRetryState, current.Score, current.RetryState))) + { + continue; + } + + current = current with + { + Score = candidateScore, + RetryState = candidateRetryState, + Edges = candidateEdges, + }; + improved = true; + break; + } + + if (!improved) + { + break; + } + } + + return current; + } + + private static ElkRoutedEdge[] ApplyAggressiveSharedLaneClosure( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance, + IReadOnlyCollection focusEdgeIds) + { + var result = edges; + result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, focusEdgeIds); + result = ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(result, nodes, minLineClearance, focusEdgeIds); + result = ElkRepeatCollectorCorridors.SeparateSharedLanes(result, nodes, focusEdgeIds); + result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, focusEdgeIds); + result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, focusEdgeIds); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, focusEdgeIds, forceOutwardAxisSpacing: true); + result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, focusEdgeIds); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, focusEdgeIds); + result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes, focusEdgeIds); + result = ClampBelowGraphEdges(result, nodes, focusEdgeIds); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, focusEdgeIds); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, focusEdgeIds); + result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, focusEdgeIds); + return result; } private static StrategyEvaluationResult EvaluateStrategy( @@ -162,6 +528,30 @@ internal static class ElkEdgeRouterIterative var bestAttemptScore = (EdgeRoutingScore?)null; ElkRoutedEdge[]? bestAttemptEdges = null; var bestAttemptRetryState = new RoutingRetryState( + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue); + var repairSeedScore = (EdgeRoutingScore?)null; + ElkRoutedEdge[]? repairSeedEdges = null; + var repairSeedRetryState = new RoutingRetryState( + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, @@ -176,10 +566,61 @@ internal static class ElkEdgeRouterIterative CandidateSolution? validSolution = null; var outcome = "no-valid"; var attempts = 0; + var maxAttempts = config.MaxAdaptationsPerStrategy; var stagnantAttempts = 0; + string? lastPlateauFingerprint = null; + var repeatedPlateauFingerprintCount = 0; + var recentBlockingCycleFingerprints = new List(4); + string? lastPlannedRepairFocusFingerprint = null; + var repeatedPlannedRepairFocusCount = 0; + string? lastRepairFocusFingerprint = null; + var repeatedRepairFocusCount = 0; + var hasLastAttemptState = false; + var lastAttemptRetryState = new RoutingRetryState( + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue); + var lastAttemptNodeCrossings = int.MaxValue; + var consecutiveNonImprovingAttempts = 0; var strategyStopwatch = Stopwatch.StartNew(); + ElkIterativeStrategyDiagnostics? liveStrategyDiagnostics = null; - for (var attempt = 0; attempt < config.MaxAdaptationsPerStrategy; attempt++) + if (diagnostics is not null) + { + liveStrategyDiagnostics = new ElkIterativeStrategyDiagnostics + { + StrategyIndex = workItem.StrategyIndex, + OrderingName = workItem.StrategyName, + Attempts = 0, + TotalDurationMs = 0, + BestScore = null, + Outcome = "running", + BendPenalty = workItem.Strategy.RoutingParams.BendPenalty, + DiagonalPenalty = workItem.Strategy.RoutingParams.DiagonalPenalty, + SoftObstacleWeight = workItem.Strategy.RoutingParams.SoftObstacleWeight, + RegisteredLive = true, + }; + lock (diagnostics.SyncRoot) + { + diagnostics.IterativeStrategies.Add(liveStrategyDiagnostics); + } + + ElkLayoutDiagnostics.FlushSnapshot(diagnostics); + } + + for (var attempt = 0; attempt < maxAttempts; attempt++) { cancellationToken.ThrowIfCancellationRequested(); attempts++; @@ -203,7 +644,7 @@ internal static class ElkEdgeRouterIterative RepairPlan? repairPlan = null; RouteAllEdgesResult? routeResult; - if (attempt == 0 || bestAttemptEdges is null || bestAttemptScore is null) + if (attempt == 0 || repairSeedEdges is null || repairSeedScore is null) { routeResult = MeasurePhase( "route-all-edges", @@ -213,22 +654,49 @@ internal static class ElkEdgeRouterIterative { repairPlan = MeasurePhase( "select-repair-targets", - () => BuildRepairPlan(bestAttemptEdges, nodes, bestAttemptScore.Value, bestAttemptRetryState, strategy, attempt)); + () => BuildRepairPlan(repairSeedEdges, nodes, repairSeedScore.Value, repairSeedRetryState, strategy, attempt)); if (repairPlan is null) { - outcome = $"no-repair-targets({DescribeRetryState(bestAttemptRetryState)})@attempt{attempt + 1}"; + outcome = $"no-repair-targets({DescribeRetryState(repairSeedRetryState)})@attempt{attempt + 1}"; ElkLayoutDiagnostics.LogProgress( $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); break; } + var plannedRepairFocusFingerprint = BuildRepairFocusFingerprint(repairPlan); + if (!string.IsNullOrEmpty(plannedRepairFocusFingerprint)) + { + if (string.Equals(plannedRepairFocusFingerprint, lastPlannedRepairFocusFingerprint, StringComparison.Ordinal)) + { + repeatedPlannedRepairFocusCount++; + } + else + { + lastPlannedRepairFocusFingerprint = plannedRepairFocusFingerprint; + repeatedPlannedRepairFocusCount = 0; + } + + if (repeatedPlannedRepairFocusCount >= 1 && attempt >= 2) + { + outcome = $"stalled-same-repair-plan({DescribeRetryState(repairSeedRetryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + } + else + { + lastPlannedRepairFocusFingerprint = null; + repeatedPlannedRepairFocusCount = 0; + } + ElkLayoutDiagnostics.LogProgress( $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] attempt {attempt + 1} local-repair " + $"edges=[{string.Join(", ", repairPlan.Value.EdgeIds)}] reasons=[{string.Join(", ", repairPlan.Value.Reasons)}]"); routeResult = MeasurePhase( "route-penalized-edges", () => RepairPenalizedEdges( - bestAttemptEdges, + repairSeedEdges, nodes, config.ObstacleMargin, strategy, @@ -244,61 +712,52 @@ internal static class ElkEdgeRouterIterative } var candidateEdges = routeResult.Edges; - candidateEdges = MeasurePhase( - "snap-anchors", - () => ElkEdgePostProcessor.SnapAnchorsToNodeBoundary(candidateEdges, nodes)); - candidateEdges = MeasurePhase( - "eliminate-diagonals", - () => ElkEdgePostProcessor.EliminateDiagonalSegments(candidateEdges, nodes)); - candidateEdges = MeasurePhase( - "avoid-node-crossings-1", - () => ElkEdgePostProcessor.AvoidNodeCrossings(candidateEdges, nodes, layoutOptions.Direction)); - candidateEdges = MeasurePhase( - "simplify-1", - () => ElkEdgePostProcessorSimplify.SimplifyEdgePaths(candidateEdges, nodes)); - candidateEdges = MeasurePhase( - "tighten-corridors", - () => ElkEdgePostProcessorSimplify.TightenOuterCorridors(candidateEdges, nodes)); - if (HighwayProcessingEnabled) + var scopedCleanupEdgeIds = routeResult.Diagnostics?.Mode == "local-repair" + ? routeResult.Diagnostics.RepairedEdgeIds.ToArray() + : null; + if (scopedCleanupEdgeIds is { Length: > 0 }) { candidateEdges = MeasurePhase( - "break-short-highways", - () => ElkEdgeRouterHighway.BreakShortHighways(candidateEdges, nodes)); + "targeted-terminal-rule-cleanup", + () => ApplyTerminalRuleCleanupRound( + candidateEdges, + nodes, + layoutOptions.Direction, + strategy.MinLineClearance, + scopedCleanupEdgeIds)); } + else + { + candidateEdges = MeasurePhase( + "snap-anchors", + () => ElkEdgePostProcessor.SnapAnchorsToNodeBoundary(candidateEdges, nodes)); + candidateEdges = MeasurePhase( + "eliminate-diagonals", + () => ElkEdgePostProcessor.EliminateDiagonalSegments(candidateEdges, nodes)); + candidateEdges = MeasurePhase( + "avoid-node-crossings-1", + () => ElkEdgePostProcessor.AvoidNodeCrossings(candidateEdges, nodes, layoutOptions.Direction)); + candidateEdges = MeasurePhase( + "simplify-1", + () => ElkEdgePostProcessorSimplify.SimplifyEdgePaths(candidateEdges, nodes)); + candidateEdges = MeasurePhase( + "tighten-corridors", + () => ElkEdgePostProcessorSimplify.TightenOuterCorridors(candidateEdges, nodes)); + if (HighwayProcessingEnabled) + { + candidateEdges = MeasurePhase( + "break-short-highways", + () => ElkEdgeRouterHighway.BreakShortHighways(candidateEdges, nodes)); + } - candidateEdges = MeasurePhase( - "normalize-boundary-angles", - () => ElkEdgePostProcessor.NormalizeBoundaryAngles(candidateEdges, nodes)); - candidateEdges = MeasurePhase( - "avoid-node-crossings-2", - () => ElkEdgePostProcessor.AvoidNodeCrossings(candidateEdges, nodes, layoutOptions.Direction)); - candidateEdges = MeasurePhase( - "simplify-2", - () => ElkEdgePostProcessorSimplify.SimplifyEdgePaths(candidateEdges, nodes)); - candidateEdges = MeasurePhase( - "avoid-node-crossings-3", - () => ElkEdgePostProcessor.AvoidNodeCrossings(candidateEdges, nodes, layoutOptions.Direction)); - candidateEdges = MeasurePhase( - "normalize-boundary-angles-final", - () => ElkEdgePostProcessor.NormalizeBoundaryAngles(candidateEdges, nodes)); - candidateEdges = MeasurePhase( - "normalize-source-exits-final", - () => ElkEdgePostProcessor.NormalizeSourceExitAngles(candidateEdges, nodes)); - candidateEdges = MeasurePhase( - "repair-boundary-lanes", - () => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches( - candidateEdges, - nodes, - strategy.MinLineClearance)); - candidateEdges = MeasurePhase( - "avoid-node-crossings-4", - () => ElkEdgePostProcessor.AvoidNodeCrossings(candidateEdges, nodes, layoutOptions.Direction)); - candidateEdges = MeasurePhase( - "repair-boundary-lanes-final", - () => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches( - candidateEdges, - nodes, - strategy.MinLineClearance)); + candidateEdges = MeasurePhase( + "terminal-rule-cleanup", + () => ApplyTerminalRuleCleanupRound( + candidateEdges, + nodes, + layoutOptions.Direction, + strategy.MinLineClearance)); + } var score = MeasurePhase( "compute-score", @@ -309,24 +768,191 @@ internal static class ElkEdgeRouterIterative () => ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count) : 0; var retryState = BuildRetryState(score, remainingBrokenHighways); + + if (retryState.RequiresBlockingRetry || retryState.RequiresLengthRetry) + { + var focusedRepair = MeasurePhase( + "repair-verified-issues", + () => TryApplyVerifiedIssueRepairRound( + candidateEdges, + nodes, + config.ObstacleMargin, + strategy, + retryState, + layoutOptions.Direction, + cancellationToken)); + if (focusedRepair is { } repaired + && IsBetterCandidate(repaired.Score, repaired.RetryState, score, retryState)) + { + candidateEdges = repaired.Edges; + score = repaired.Score; + remainingBrokenHighways = repaired.RemainingBrokenHighways; + retryState = repaired.RetryState; + } + } + + if (retryState.RequiresBlockingRetry || retryState.RequiresLengthRetry) + { + var stabilizedEdges = MeasurePhase( + "stabilize-terminal-rules", + () => ApplyTerminalRuleCleanupRound( + candidateEdges, + nodes, + layoutOptions.Direction, + strategy.MinLineClearance, + scopedCleanupEdgeIds)); + var stabilizedScore = MeasurePhase( + "compute-score-stabilized", + () => ElkEdgeRoutingScoring.ComputeScore(stabilizedEdges, nodes)); + var stabilizedBrokenHighways = HighwayProcessingEnabled + ? MeasurePhase( + "detect-broken-highways-stabilized", + () => ElkEdgeRouterHighway.DetectRemainingBrokenHighways(stabilizedEdges, nodes).Count) + : 0; + var stabilizedRetryState = BuildRetryState(stabilizedScore, stabilizedBrokenHighways); + if (IsBetterCandidate(stabilizedScore, stabilizedRetryState, score, retryState)) + { + candidateEdges = stabilizedEdges; + score = stabilizedScore; + remainingBrokenHighways = stabilizedBrokenHighways; + retryState = stabilizedRetryState; + } + } + + if (attempt == 0) + { + maxAttempts = DetermineAdaptiveAttemptBudget(retryState, config.MaxAdaptationsPerStrategy); + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] attempt-budget={maxAttempts}"); + } + ElkLayoutDiagnostics.LogProgress( $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] attempt {attempt + 1} " + $"score={score.Value:F0} retry={DescribeRetryState(retryState)}"); var candidate = new CandidateSolution(score, retryState, candidateEdges, workItem.StrategyIndex); fallbackSolutions.Add(candidate); + repairSeedScore = score; + repairSeedEdges = candidateEdges; + repairSeedRetryState = retryState; + + var repairFocusFingerprint = BuildRepairFocusFingerprint(repairPlan); + if (!string.IsNullOrEmpty(repairFocusFingerprint)) + { + if (string.Equals(repairFocusFingerprint, lastRepairFocusFingerprint, StringComparison.Ordinal)) + { + repeatedRepairFocusCount++; + } + else + { + lastRepairFocusFingerprint = repairFocusFingerprint; + repeatedRepairFocusCount = 0; + } + + if (repeatedRepairFocusCount >= 1 && attempt >= 3) + { + outcome = $"stalled-same-focus({DescribeRetryState(retryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + } + else + { + lastRepairFocusFingerprint = null; + repeatedRepairFocusCount = 0; + } + + var plateauFingerprint = BuildPlateauFingerprint(retryState, repairPlan); + if (string.Equals(plateauFingerprint, lastPlateauFingerprint, StringComparison.Ordinal)) + { + repeatedPlateauFingerprintCount++; + } + else + { + lastPlateauFingerprint = plateauFingerprint; + repeatedPlateauFingerprintCount = 0; + } + + if (repeatedPlateauFingerprintCount >= 2 && attempt >= 3) + { + outcome = $"stalled-repeat({DescribeRetryState(retryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + + var blockingCycleFingerprint = BuildBlockingCycleFingerprint(retryState, repairPlan); + if (ShouldStopForBlockingCycle( + recentBlockingCycleFingerprints, + blockingCycleFingerprint, + retryState, + bestAttemptRetryState, + attempt)) + { + outcome = $"stalled-cycle({DescribeRetryState(retryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + + AppendRecentFingerprint(recentBlockingCycleFingerprints, blockingCycleFingerprint, 4); + + if (hasLastAttemptState) + { + var retryStateComparison = CompareRetryStates(retryState, lastAttemptRetryState); + if (retryStateComparison >= 0 && score.NodeCrossings >= lastAttemptNodeCrossings) + { + consecutiveNonImprovingAttempts++; + } + else + { + consecutiveNonImprovingAttempts = 0; + } + + if (consecutiveNonImprovingAttempts >= 2 && attempt >= 3) + { + outcome = $"stalled-no-progress({DescribeRetryState(retryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + } + + hasLastAttemptState = true; + lastAttemptRetryState = retryState; + lastAttemptNodeCrossings = score.NodeCrossings; var improvedAttempt = bestAttemptScore is null || IsBetterCandidate(score, retryState, bestAttemptScore.Value, bestAttemptRetryState); + var improvedRuleState = bestAttemptScore is null + || CompareRetryStates(retryState, bestAttemptRetryState) < 0 + || score.NodeCrossings < bestAttemptScore.Value.NodeCrossings; if (improvedAttempt) { bestAttemptScore = score; bestAttemptEdges = candidateEdges; bestAttemptRetryState = retryState; - stagnantAttempts = 0; + stagnantAttempts = improvedRuleState + ? 0 + : stagnantAttempts + 1; + if (ShouldStopForStagnation(stagnantAttempts, attempt, config.MaxAdaptationsPerStrategy)) + { + outcome = $"stalled({DescribeRetryState(retryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } } else { stagnantAttempts++; + if (ShouldStopForStagnation(stagnantAttempts, attempt, config.MaxAdaptationsPerStrategy)) + { + outcome = $"stalled({DescribeRetryState(retryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } } var attemptOutcome = score.NodeCrossings > maxAllowedNodeCrossings @@ -340,7 +966,7 @@ internal static class ElkEdgeRouterIterative if (diagnostics is not null) { attemptStopwatch.Stop(); - attemptDetails.Add(new ElkIterativeAttemptDiagnostics + var attemptDiagnostics = new ElkIterativeAttemptDiagnostics { Attempt = attempt + 1, TotalDurationMs = Math.Round(attemptStopwatch.Elapsed.TotalMilliseconds, 3), @@ -348,20 +974,27 @@ internal static class ElkEdgeRouterIterative Outcome = attemptOutcome, RouteDiagnostics = routeResult.Diagnostics, Edges = candidateEdges, - }); - attemptDetails[^1].PhaseTimings.AddRange(phaseTimings); + }; + attemptDiagnostics.PhaseTimings.AddRange(phaseTimings); + attemptDetails.Add(attemptDiagnostics); + + if (liveStrategyDiagnostics is not null) + { + lock (diagnostics.SyncRoot) + { + liveStrategyDiagnostics.Attempts = attempts; + liveStrategyDiagnostics.TotalDurationMs = Math.Round(strategyStopwatch.Elapsed.TotalMilliseconds, 3); + liveStrategyDiagnostics.BestScore = bestAttemptScore; + liveStrategyDiagnostics.Outcome = attemptOutcome; + liveStrategyDiagnostics.AttemptDetails.Add(attemptDiagnostics); + } + + ElkLayoutDiagnostics.FlushSnapshot(diagnostics); + } } if (score.NodeCrossings > maxAllowedNodeCrossings) { - if (ShouldStopForStagnation(stagnantAttempts, attempt)) - { - outcome = $"stalled({DescribeRetryState(bestAttemptRetryState)})@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); - break; - } - outcome = $"{attemptOutcome}@attempt{attempt + 1}"; ElkLayoutDiagnostics.LogProgress( $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] adapting after node crossing violation"); @@ -371,19 +1004,18 @@ internal static class ElkEdgeRouterIterative if (retryState.RemainingShortHighways > 0 || retryState.RepeatCollectorCorridorViolations > 0 + || retryState.RepeatCollectorNodeClearanceViolations > 0 || retryState.TargetApproachJoinViolations > 0 - || retryState.TargetApproachBacktrackingViolations > 0) + || retryState.TargetApproachBacktrackingViolations > 0 + || retryState.SharedLaneViolations > 0 + || retryState.BelowGraphViolations > 0 + || retryState.UnderNodeViolations > 0 + || retryState.LongDiagonalViolations > 0 + || retryState.EntryAngleViolations > 0 + || retryState.GatewaySourceExitViolations > 0) { if (ShouldRetryForPrimaryViolations(retryState, attempt, config.MaxAdaptationsPerStrategy)) { - if (ShouldStopForStagnation(stagnantAttempts, attempt)) - { - outcome = $"stalled({DescribeRetryState(bestAttemptRetryState)})@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); - break; - } - outcome = $"{attemptOutcome}@attempt{attempt + 1}"; ElkLayoutDiagnostics.LogProgress( $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] adapting for blocking violations"); @@ -397,42 +1029,37 @@ internal static class ElkEdgeRouterIterative break; } + if (retryState.RequiresLengthRetry) + { + if (ShouldRetryForPrimaryViolations(retryState, attempt, config.MaxAdaptationsPerStrategy)) + { + outcome = $"{attemptOutcome}@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] adapting for shortest-path / detour violations"); + strategy.AdaptForViolations(score, attempt, retryState); + continue; + } + + outcome = $"invalid({DescribeRetryState(retryState)})@attempt{attempt + 1}"; + ElkLayoutDiagnostics.LogProgress( + $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); + break; + } + if (retryState.RequiresQualityRetry) { if (ShouldRetryForPrimaryViolations(retryState, attempt, config.MaxAdaptationsPerStrategy)) { - if (ShouldStopForStagnation(stagnantAttempts, attempt)) - { - outcome = $"stalled({DescribeRetryState(bestAttemptRetryState)})@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); - break; - } - outcome = $"{attemptOutcome}@attempt{attempt + 1}"; ElkLayoutDiagnostics.LogProgress( $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] adapting for quality/length violations"); strategy.AdaptForViolations(score, attempt, retryState); continue; } - - outcome = $"valid-after-retry@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); - validSolution = candidate; - break; } if (ShouldRetryForEdgeCrossings(retryState, attempt, config.MaxAdaptationsPerStrategy)) { - if (ShouldStopForStagnation(stagnantAttempts, attempt)) - { - outcome = $"stalled({DescribeRetryState(bestAttemptRetryState)})@attempt{attempt + 1}"; - ElkLayoutDiagnostics.LogProgress( - $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); - break; - } - outcome = $"{attemptOutcome}@attempt{attempt + 1}"; ElkLayoutDiagnostics.LogProgress( $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] adapting for edge crossings"); @@ -440,7 +1067,10 @@ internal static class ElkEdgeRouterIterative continue; } - outcome = $"valid@attempt{attempt + 1}"; + var residualSoftViolations = retryState.RequiresQualityRetry || retryState.EdgeCrossings > 0; + outcome = residualSoftViolations + ? $"valid-soft({DescribeRetryState(retryState)})@attempt{attempt + 1}" + : $"valid@attempt{attempt + 1}"; ElkLayoutDiagnostics.LogProgress( $"Strategy {workItem.StrategyIndex} [{workItem.StrategyName}] {outcome}"); validSolution = candidate; @@ -462,6 +1092,20 @@ internal static class ElkEdgeRouterIterative }; stratDiag.AttemptDetails.AddRange(attemptDetails); + if (liveStrategyDiagnostics is not null && diagnostics is not null) + { + lock (diagnostics.SyncRoot) + { + liveStrategyDiagnostics.Attempts = attempts; + liveStrategyDiagnostics.TotalDurationMs = Math.Round(strategyStopwatch.Elapsed.TotalMilliseconds, 3); + liveStrategyDiagnostics.BestScore = bestAttemptScore; + liveStrategyDiagnostics.Outcome = outcome; + liveStrategyDiagnostics.BestEdges = bestAttemptEdges; + } + + ElkLayoutDiagnostics.FlushSnapshot(diagnostics); + } + return new StrategyEvaluationResult( workItem.StrategyIndex, fallbackSolutions, @@ -526,10 +1170,16 @@ internal static class ElkEdgeRouterIterative var endPoint = spreadEndpoints.TryGetValue(edge.Id, out var spread) ? spread : section.EndPoint; - - var rerouted = ElkEdgeRouterAStar8Dir.Route( + var (startPoint, adjustedEndPoint) = ResolveRoutingEndpoints( section.StartPoint, endPoint, + edge.SourceNodeId, + edge.TargetNodeId, + nodesById); + + var rerouted = ElkEdgeRouterAStar8Dir.Route( + startPoint, + adjustedEndPoint, obstacles, edge.SourceNodeId ?? "", edge.TargetNodeId ?? "", @@ -611,11 +1261,13 @@ internal static class ElkEdgeRouterIterative var routedSectionCount = 0; var fallbackSectionCount = 0; var repairSet = repairPlan.EdgeIndices.ToHashSet(); + var routeRepairEdgeIdSet = repairPlan.RouteRepairEdgeIds.ToHashSet(StringComparer.Ordinal); var collectorRepairSet = repairPlan.Reasons.Contains("collector-corridors", StringComparer.Ordinal) ? repairSet .Where(edgeIndex => edgeIndex >= 0 && edgeIndex < existingEdges.Length && ElkEdgePostProcessor.IsRepeatCollectorLabel(existingEdges[edgeIndex].Label)) + .Where(edgeIndex => !routeRepairEdgeIdSet.Contains(existingEdges[edgeIndex].Id)) .ToHashSet() : []; var preferredShortestEdgeIdSet = repairPlan.PreferredShortestEdgeIds.ToHashSet(StringComparer.Ordinal); @@ -652,6 +1304,53 @@ internal static class ElkEdgeRouterIterative .Concat(aStarRepairSet.Where(edgeIndex => !strategy.EdgeOrder.Contains(edgeIndex))) .Distinct() .ToArray(); + var repairBuilderParallelism = DetermineRepairBuildParallelism(orderedRepairIndices.Length); + var builtRepairResults = new ConcurrentDictionary(); + var repairBuildLocks = new ConcurrentDictionary(StringComparer.Ordinal); + if (repairBuilderParallelism > 1 && orderedRepairIndices.Length > 1) + { + var immutableSoftObstacles = softObstacles.ToArray(); + var parallelOptions = new ParallelOptions + { + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = repairBuilderParallelism, + }; + + Parallel.ForEach( + orderedRepairIndices, + parallelOptions, + edgeIndex => + { + if (edgeIndex < 0 || edgeIndex >= existingEdges.Length) + { + return; + } + + var edge = existingEdges[edgeIndex]; + var lockKeys = GetRepairBuildLockKeys(edge); + ExecuteWithRepairBuildLocks( + repairBuildLocks, + lockKeys, + () => + { + builtRepairResults[edgeIndex] = BuildRepairEdgeResult( + edgeIndex, + existingEdges, + nodes, + obstacles, + spreadEndpoints, + nodesById, + immutableSoftObstacles, + routeRepairEdgeIdSet, + preferredShortestEdgeIdSet, + repairPlan.Reasons, + graphMinY, + graphMaxY, + strategy, + cancellationToken); + }); + }); + } foreach (var edgeIndex in orderedRepairIndices) { @@ -662,11 +1361,27 @@ internal static class ElkEdgeRouterIterative continue; } - var edge = existingEdges[edgeIndex]; - if (!ShouldRouteEdge(edge, graphMinY, graphMaxY)) + var buildResult = builtRepairResults.TryGetValue(edgeIndex, out var parallelBuildResult) + ? parallelBuildResult + : BuildRepairEdgeResult( + edgeIndex, + existingEdges, + nodes, + obstacles, + spreadEndpoints, + nodesById, + softObstacles, + routeRepairEdgeIdSet, + preferredShortestEdgeIdSet, + repairPlan.Reasons, + graphMinY, + graphMaxY, + strategy, + cancellationToken); + if (buildResult.WasSkipped) { skippedEdgeCount++; - foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge)) + foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(buildResult.Edge)) { softObstacles.Add(new OrthogonalSoftObstacle(segment.Start, segment.End)); } @@ -674,68 +1389,10 @@ internal static class ElkEdgeRouterIterative continue; } - var newSections = new List(edge.Sections.Count); - foreach (var section in edge.Sections) - { - var endPoint = spreadEndpoints.TryGetValue(edge.Id, out var spread) - ? spread - : section.EndPoint; - List? rerouted = null; - if (preferredShortestEdgeIdSet.Contains(edge.Id)) - { - var targetNode = nodesById.GetValueOrDefault(edge.TargetNodeId ?? string.Empty); - rerouted = TryRouteShortestRepair( - section.StartPoint, - endPoint, - nodes, - obstacles, - edge.SourceNodeId ?? string.Empty, - edge.TargetNodeId ?? string.Empty, - targetNode, - strategy.RoutingParams, - softObstacles, - cancellationToken); - } - - rerouted ??= ElkEdgeRouterAStar8Dir.Route( - section.StartPoint, - endPoint, - obstacles, - edge.SourceNodeId ?? "", - edge.TargetNodeId ?? "", - strategy.RoutingParams, - softObstacles, - cancellationToken); - - if (rerouted is not null && rerouted.Count >= 2) - { - routedSectionCount++; - newSections.Add(new ElkEdgeSection - { - StartPoint = rerouted[0], - EndPoint = rerouted[^1], - BendPoints = rerouted.Skip(1).Take(rerouted.Count - 2).ToArray(), - }); - } - else - { - fallbackSectionCount++; - newSections.Add(section); - } - } - + routedSectionCount += buildResult.RoutedSections; + fallbackSectionCount += buildResult.FallbackSections; routedEdgeCount++; - routedEdges[edgeIndex] = new ElkRoutedEdge - { - Id = edge.Id, - SourceNodeId = edge.SourceNodeId, - TargetNodeId = edge.TargetNodeId, - SourcePortId = edge.SourcePortId, - TargetPortId = edge.TargetPortId, - Kind = edge.Kind, - Label = edge.Label, - Sections = newSections, - }; + routedEdges[edgeIndex] = buildResult.Edge; foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(routedEdges[edgeIndex])) { @@ -743,6 +1400,16 @@ internal static class ElkEdgeRouterIterative } } + var repeatRouteRepairIds = repairPlan.RouteRepairEdgeIds + .Where(edgeId => routedEdges.Any(edge => + string.Equals(edge.Id, edgeId, StringComparison.Ordinal) + && ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label))) + .ToArray(); + if (repeatRouteRepairIds.Length > 0) + { + routedEdges = ElkRepeatCollectorCorridors.SeparateSharedLanes(routedEdges, nodes, repeatRouteRepairIds); + } + return new RouteAllEdgesResult( routedEdges, new ElkIterativeRouteDiagnostics @@ -756,9 +1423,54 @@ internal static class ElkEdgeRouterIterative SoftObstacleSegments = softObstacles.Count, RepairedEdgeIds = repairPlan.EdgeIds, RepairReasons = repairPlan.Reasons, + BuilderMode = repairBuilderParallelism > 1 ? "parallel-locked-local-build" : "sequential-local-build", + BuilderParallelism = repairBuilderParallelism, }); } + private static (ElkRoutedEdge[] Edges, EdgeRoutingScore Score, RoutingRetryState RetryState, int RemainingBrokenHighways)? TryApplyVerifiedIssueRepairRound( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + double baseObstacleMargin, + RoutingStrategy strategy, + RoutingRetryState retryState, + ElkLayoutDirection direction, + CancellationToken cancellationToken) + { + var score = ElkEdgeRoutingScoring.ComputeScore(edges, nodes); + var focusedPlan = BuildRepairPlan( + edges, + nodes, + score, + retryState, + strategy, + int.MaxValue); + if (focusedPlan is null || focusedPlan.Value.EdgeIds.Length == 0) + { + return null; + } + + var rerouted = RepairPenalizedEdges( + edges, + nodes, + baseObstacleMargin, + strategy, + focusedPlan.Value, + cancellationToken).Edges; + var cleaned = ApplyTerminalRuleCleanupRound( + rerouted, + nodes, + direction, + strategy.MinLineClearance, + focusedPlan.Value.EdgeIds); + var cleanedScore = ElkEdgeRoutingScoring.ComputeScore(cleaned, nodes); + var remainingBrokenHighways = HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(cleaned, nodes).Count + : 0; + var cleanedRetryState = BuildRetryState(cleanedScore, remainingBrokenHighways); + return (cleaned, cleanedScore, cleanedRetryState, remainingBrokenHighways); + } + private static RepairPlan? BuildRepairPlan( ElkRoutedEdge[] edges, ElkPositionedNode[] nodes, @@ -774,7 +1486,11 @@ internal static class ElkEdgeRouterIterative var severityByEdgeId = new Dictionary(StringComparer.Ordinal); var preferredShortestEdgeIds = new HashSet(StringComparer.Ordinal); + var routeRepairEdgeIds = new HashSet(StringComparer.Ordinal); + var mandatoryEdgeIds = new HashSet(StringComparer.Ordinal); + var severityByReason = new Dictionary>(StringComparer.Ordinal); var reasons = new List(); + var prioritizeBlockingAndLengthOnly = retryState.RequiresBlockingRetry || retryState.RequiresLengthRetry; void AddReason(string reason) { @@ -784,19 +1500,42 @@ internal static class ElkEdgeRouterIterative } } - void AddEdgeIds(IEnumerable edgeIds, int severity, string reason) + void AddEdgeIds( + IEnumerable edgeIds, + int severity, + string reason, + bool requiresRouteRepair = false, + bool mandatoryRepair = false) { AddReason(reason); + if (!severityByReason.TryGetValue(reason, out var reasonSeverity)) + { + reasonSeverity = new Dictionary(StringComparer.Ordinal); + severityByReason[reason] = reasonSeverity; + } + foreach (var edgeId in edgeIds) { severityByEdgeId[edgeId] = severityByEdgeId.GetValueOrDefault(edgeId) + severity; + reasonSeverity[edgeId] = reasonSeverity.GetValueOrDefault(edgeId) + severity; + if (requiresRouteRepair) + { + routeRepairEdgeIds.Add(edgeId); + } + + if (mandatoryRepair) + { + mandatoryEdgeIds.Add(edgeId); + } } } void MergeSeverity( Dictionary metricSeverity, string reason, - bool preferShortestRepair = false) + bool preferShortestRepair = false, + bool requiresRouteRepair = false, + bool mandatoryRepair = false) { if (metricSeverity.Count == 0) { @@ -804,9 +1543,26 @@ internal static class ElkEdgeRouterIterative } AddReason(reason); + if (!severityByReason.TryGetValue(reason, out var reasonSeverity)) + { + reasonSeverity = new Dictionary(StringComparer.Ordinal); + severityByReason[reason] = reasonSeverity; + } + foreach (var (edgeId, severity) in metricSeverity) { severityByEdgeId[edgeId] = severityByEdgeId.GetValueOrDefault(edgeId) + severity; + reasonSeverity[edgeId] = reasonSeverity.GetValueOrDefault(edgeId) + severity; + if (preferShortestRepair || requiresRouteRepair) + { + routeRepairEdgeIds.Add(edgeId); + } + + if (mandatoryRepair) + { + mandatoryEdgeIds.Add(edgeId); + } + if (preferShortestRepair) { preferredShortestEdgeIds.Add(edgeId); @@ -820,22 +1576,23 @@ internal static class ElkEdgeRouterIterative { var backtrackingSeverity = new Dictionary(StringComparer.Ordinal); ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(edges, nodes, backtrackingSeverity, 2_250); - MergeSeverity(backtrackingSeverity, "approach-backtracking", preferShortestRepair: true); + MergeSeverity(backtrackingSeverity, "approach-backtracking", preferShortestRepair: true, mandatoryRepair: true); } if (retryState.ExcessiveDetourViolations > 0) { var detourSeverity = new Dictionary(StringComparer.Ordinal); ElkEdgeRoutingScoring.CountExcessiveDetourViolations(edges, nodes, detourSeverity, 2_000); - MergeSeverity(detourSeverity, "detour-priority", preferShortestRepair: true); + MergeSeverity(detourSeverity, "detour-priority", preferShortestRepair: true, mandatoryRepair: true); } } else { if (score.NodeCrossings > 0) { - ElkEdgeRoutingScoring.CountEdgeNodeCrossings(edges, nodes, severityByEdgeId, 2_500); - AddReason("node-crossings"); + var nodeCrossingSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountEdgeNodeCrossings(edges, nodes, nodeCrossingSeverity, 2_500); + MergeSeverity(nodeCrossingSeverity, "node-crossings", requiresRouteRepair: true, mandatoryRepair: true); } if (retryState.RemainingShortHighways > 0) @@ -844,57 +1601,107 @@ internal static class ElkEdgeRouterIterative AddEdgeIds( brokenHighways.SelectMany(highway => highway.EdgeIds).Distinct(StringComparer.Ordinal), 2_000, - "short-highways"); + "short-highways", + requiresRouteRepair: true, + mandatoryRepair: true); } if (retryState.RepeatCollectorCorridorViolations > 0) { - ElkEdgeRoutingScoring.CountRepeatCollectorCorridorViolations(edges, nodes, severityByEdgeId, 2_000); - AddReason("collector-corridors"); + var collectorSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountRepeatCollectorCorridorViolations(edges, nodes, collectorSeverity, 2_000); + MergeSeverity(collectorSeverity, "collector-corridors", requiresRouteRepair: true, mandatoryRepair: true); + } + + if (retryState.RepeatCollectorNodeClearanceViolations > 0) + { + var collectorClearanceSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountRepeatCollectorNodeClearanceViolations(edges, nodes, collectorClearanceSeverity, 2_000); + MergeSeverity(collectorClearanceSeverity, "collector-clearance", requiresRouteRepair: true, mandatoryRepair: true); } if (retryState.TargetApproachJoinViolations > 0) { - ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(edges, nodes, severityByEdgeId, 1_500); - AddReason("target-joins"); + var targetJoinSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(edges, nodes, targetJoinSeverity, 1_500); + MergeSeverity(targetJoinSeverity, "target-joins", requiresRouteRepair: true, mandatoryRepair: true); } if (retryState.TargetApproachBacktrackingViolations > 0) { var backtrackingSeverity = new Dictionary(StringComparer.Ordinal); ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(edges, nodes, backtrackingSeverity, 1_600); - MergeSeverity(backtrackingSeverity, "approach-backtracking", preferShortestRepair: true); + MergeSeverity(backtrackingSeverity, "approach-backtracking", preferShortestRepair: true, requiresRouteRepair: true, mandatoryRepair: true); + } + + if (retryState.BelowGraphViolations > 0) + { + var belowGraphSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountBelowGraphViolations(edges, nodes, belowGraphSeverity, 2_500); + MergeSeverity(belowGraphSeverity, "below-graph", preferShortestRepair: true, requiresRouteRepair: true, mandatoryRepair: true); + } + + if (retryState.UnderNodeViolations > 0) + { + var underNodeSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountUnderNodeViolations(edges, nodes, underNodeSeverity, 2_500); + MergeSeverity(underNodeSeverity, "under-node", preferShortestRepair: true, requiresRouteRepair: true, mandatoryRepair: true); + } + + if (retryState.LongDiagonalViolations > 0) + { + var longDiagonalSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountLongDiagonalViolations(edges, nodes, longDiagonalSeverity, 2_250); + MergeSeverity(longDiagonalSeverity, "long-diagonal", preferShortestRepair: true, requiresRouteRepair: true, mandatoryRepair: true); } if (retryState.ExcessiveDetourViolations > 0) { var detourSeverity = new Dictionary(StringComparer.Ordinal); ElkEdgeRoutingScoring.CountExcessiveDetourViolations(edges, nodes, detourSeverity, 1_250); - MergeSeverity(detourSeverity, "detour", preferShortestRepair: true); + MergeSeverity(detourSeverity, "detour", preferShortestRepair: true, requiresRouteRepair: true, mandatoryRepair: true); } - if (retryState.ProximityViolations > 0) + if (retryState.SharedLaneViolations > 0) { - ElkEdgeRoutingScoring.CountProximityViolations(edges, nodes, severityByEdgeId, 350); - AddReason("proximity"); + var sharedLaneSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountSharedLaneViolations(edges, nodes, sharedLaneSeverity, 2_000); + MergeSeverity(sharedLaneSeverity, "shared-lanes", requiresRouteRepair: true, mandatoryRepair: true); + } + + if (!prioritizeBlockingAndLengthOnly && retryState.ProximityViolations > 0) + { + var proximitySeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountProximityViolations(edges, nodes, proximitySeverity, 350); + MergeSeverity(proximitySeverity, "proximity", requiresRouteRepair: true); } if (retryState.EntryAngleViolations > 0) { - ElkEdgeRoutingScoring.CountBadBoundaryAngles(edges, nodes, severityByEdgeId, 450); - AddReason("entry"); + var entrySeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountBadBoundaryAngles(edges, nodes, entrySeverity, 450); + MergeSeverity(entrySeverity, "entry", requiresRouteRepair: true, mandatoryRepair: true); } - if (retryState.LabelProximityViolations > 0) + if (retryState.GatewaySourceExitViolations > 0) { - ElkEdgeRoutingScoring.CountLabelProximityViolations(edges, nodes, severityByEdgeId, 300); - AddReason("label"); + var gatewaySourceSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(edges, nodes, gatewaySourceSeverity, 2_500); + MergeSeverity(gatewaySourceSeverity, "gateway-source-exit", preferShortestRepair: true, requiresRouteRepair: true, mandatoryRepair: true); } - if (retryState.EdgeCrossings > 0) + if (!prioritizeBlockingAndLengthOnly && retryState.LabelProximityViolations > 0) { - ElkEdgeRoutingScoring.CountEdgeEdgeCrossings(edges, severityByEdgeId, 200); - AddReason("edge-crossings"); + var labelSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountLabelProximityViolations(edges, nodes, labelSeverity, 300); + MergeSeverity(labelSeverity, "label", requiresRouteRepair: true); + } + + if (!prioritizeBlockingAndLengthOnly && retryState.EdgeCrossings > 0) + { + var edgeCrossingSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountEdgeEdgeCrossings(edges, edgeCrossingSeverity, 200); + MergeSeverity(edgeCrossingSeverity, "edge-crossings", requiresRouteRepair: true); } } @@ -907,19 +1714,91 @@ internal static class ElkEdgeRouterIterative .Select((edgeIndex, rank) => new { edgeIndex, rank }) .Where(item => item.edgeIndex >= 0 && item.edgeIndex < edges.Length) .ToDictionary(item => edges[item.edgeIndex].Id, item => item.rank, StringComparer.Ordinal); - var maxEdgeRepairs = DetermineRepairEdgeBudget(retryState, attempt == 1 && retryState.RequiresLengthRetry); - var selectedEdgeIds = severityByEdgeId + var seedSelectedEdgeIds = new List(); + foreach (var reason in reasons) + { + if (!severityByReason.TryGetValue(reason, out var reasonSeverity) + || reasonSeverity.Count == 0) + { + continue; + } + + var picksForReason = reason is "detour" or "detour-priority" or "approach-backtracking" + ? 2 + : 1; + var rankedReasonEdgeIds = reasonSeverity + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => orderRankByEdgeId.GetValueOrDefault(pair.Key, int.MaxValue)) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Key) + .ToArray(); + foreach (var edgeId in RotateOrderedEdgeIds(rankedReasonEdgeIds, attempt)) + { + if (seedSelectedEdgeIds.Contains(edgeId, StringComparer.Ordinal)) + { + continue; + } + + seedSelectedEdgeIds.Add(edgeId); + if (--picksForReason == 0) + { + break; + } + } + } + + var maxEdgeRepairs = DetermineRepairEdgeBudget( + retryState, + attempt == 1 && retryState.RequiresLengthRetry, + seedSelectedEdgeIds.Count, + mandatoryEdgeIds.Count); + var orderedEdgeIds = severityByEdgeId .OrderByDescending(pair => pair.Value) .ThenBy(pair => orderRankByEdgeId.GetValueOrDefault(pair.Key, int.MaxValue)) .ThenBy(pair => pair.Key, StringComparer.Ordinal) - .Take(maxEdgeRepairs) .Select(pair => pair.Key) .ToArray(); + var prioritizedShortestEdgeIds = orderedEdgeIds + .Where(preferredShortestEdgeIds.Contains) + .Take(Math.Min(Math.Max(2, seedSelectedEdgeIds.Count), maxEdgeRepairs)) + .ToArray(); + var orderedMandatoryEdgeIds = orderedEdgeIds + .Where(mandatoryEdgeIds.Contains) + .ToArray(); + var mandatoryFocusBudget = DetermineMandatoryFocusBudget( + orderedMandatoryEdgeIds.Length, + maxEdgeRepairs, + seedSelectedEdgeIds.Count); + var focusedMandatoryEdgeIds = RotateOrderedEdgeIds(orderedMandatoryEdgeIds, attempt) + .Take(mandatoryFocusBudget) + .ToArray(); + var effectiveEdgeRepairBudget = Math.Max(maxEdgeRepairs, focusedMandatoryEdgeIds.Length); + var selectedEdgeIds = focusedMandatoryEdgeIds + .Concat(seedSelectedEdgeIds) + .Concat(prioritizedShortestEdgeIds) + .Distinct(StringComparer.Ordinal) + .Take(effectiveEdgeRepairBudget) + .ToArray(); if (reasons.Contains("collector-corridors", StringComparer.Ordinal)) { selectedEdgeIds = ExpandRepeatCollectorRepairSet(selectedEdgeIds, edges, nodes); } + if (reasons.Contains("target-joins", StringComparer.Ordinal)) + { + selectedEdgeIds = ExpandTargetApproachJoinRepairSet(selectedEdgeIds, edges, nodes, strategy.MinLineClearance); + } + + if (reasons.Contains("under-node", StringComparer.Ordinal)) + { + selectedEdgeIds = ExpandUnderNodeRepairSet(selectedEdgeIds, edges, nodes); + } + + if (reasons.Contains("shared-lanes", StringComparer.Ordinal)) + { + selectedEdgeIds = ExpandSharedLaneRepairSet(selectedEdgeIds, edges, nodes); + } + if (selectedEdgeIds.Length == 0) { return null; @@ -928,6 +1807,9 @@ internal static class ElkEdgeRouterIterative var preferredSelectedEdgeIds = selectedEdgeIds .Where(preferredShortestEdgeIds.Contains) .ToArray(); + var routeRepairSelectedEdgeIds = selectedEdgeIds + .Where(routeRepairEdgeIds.Contains) + .ToArray(); var edgeIndices = selectedEdgeIds .Select(edgeId => Array.FindIndex(edges, edge => string.Equals(edge.Id, edgeId, StringComparison.Ordinal))) @@ -942,6 +1824,7 @@ internal static class ElkEdgeRouterIterative edgeIndices, selectedEdgeIds, preferredSelectedEdgeIds, + routeRepairSelectedEdgeIds, reasons.ToArray()); } @@ -969,26 +1852,570 @@ internal static class ElkEdgeRouterIterative .ToArray(); } - private static int DetermineRepairEdgeBudget(RoutingRetryState retryState, bool detourPriority) + private static string[] ExpandTargetApproachJoinRepairSet( + IReadOnlyCollection selectedEdgeIds, + IReadOnlyCollection edges, + IReadOnlyCollection nodes, + double minLineClearance) + { + var selected = selectedEdgeIds.ToHashSet(StringComparer.Ordinal); + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var edgeArray = edges.ToArray(); + + foreach (var group in edgeArray.GroupBy(edge => edge.TargetNodeId ?? string.Empty, StringComparer.Ordinal)) + { + if (string.IsNullOrWhiteSpace(group.Key) + || !nodesById.TryGetValue(group.Key, out var targetNode)) + { + continue; + } + + var targetEdges = group.ToArray(); + for (var i = 0; i < targetEdges.Length; i++) + { + var leftEdge = targetEdges[i]; + var leftPath = ExtractPath(leftEdge); + if (leftPath.Count < 2) + { + continue; + } + + var leftSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(leftPath[^1], leftPath[^2], targetNode); + for (var j = i + 1; j < targetEdges.Length; j++) + { + var rightEdge = targetEdges[j]; + var rightPath = ExtractPath(rightEdge); + if (rightPath.Count < 2) + { + continue; + } + + var rightSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(rightPath[^1], rightPath[^2], targetNode); + if (!string.Equals(leftSide, rightSide, StringComparison.Ordinal)) + { + continue; + } + + if (!HasTargetApproachJoinPair(leftPath, rightPath, minLineClearance)) + { + continue; + } + + selected.Add(leftEdge.Id); + selected.Add(rightEdge.Id); + } + } + } + + return selected + .OrderBy(edgeId => edgeId, StringComparer.Ordinal) + .ToArray(); + } + + private static string[] ExpandSharedLaneRepairSet( + IReadOnlyCollection selectedEdgeIds, + IReadOnlyCollection edges, + IReadOnlyCollection nodes) + { + var selected = selectedEdgeIds.ToHashSet(StringComparer.Ordinal); + foreach (var (leftEdgeId, rightEdgeId) in ElkEdgeRoutingScoring.DetectSharedLaneConflicts(edges, nodes)) + { + if (!selected.Contains(leftEdgeId) && !selected.Contains(rightEdgeId)) + { + continue; + } + + selected.Add(leftEdgeId); + selected.Add(rightEdgeId); + } + + return selected + .OrderBy(edgeId => edgeId, StringComparer.Ordinal) + .ToArray(); + } + + private static string[] ExpandUnderNodeRepairSet( + IReadOnlyCollection selectedEdgeIds, + IReadOnlyCollection edges, + IReadOnlyCollection nodes) + { + var selected = selectedEdgeIds.ToHashSet(StringComparer.Ordinal); + foreach (var edge in edges) + { + if (ElkEdgeRoutingScoring.CountUnderNodeViolations([edge], nodes) > 0) + { + selected.Add(edge.Id); + } + } + + return selected + .OrderBy(edgeId => edgeId, StringComparer.Ordinal) + .ToArray(); + } + + private static List ExtractPath(ElkRoutedEdge edge) + { + var path = new List(); + foreach (var section in edge.Sections) + { + if (path.Count == 0) + { + path.Add(section.StartPoint); + } + + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + } + + return path; + } + + private static bool HasTargetApproachJoinPair( + IReadOnlyList leftPath, + IReadOnlyList rightPath, + double minLineClearance, + int maxSegmentsFromEnd = 3) + { + var leftSegments = FlattenSegmentsNearEnd(leftPath, maxSegmentsFromEnd); + var rightSegments = FlattenSegmentsNearEnd(rightPath, maxSegmentsFromEnd); + + foreach (var leftSegment in leftSegments) + { + foreach (var rightSegment in rightSegments) + { + if (!ElkEdgeRoutingGeometry.AreParallelAndClose( + leftSegment.Start, + leftSegment.End, + rightSegment.Start, + rightSegment.End, + minLineClearance)) + { + continue; + } + + var overlap = ElkEdgeRoutingGeometry.ComputeSharedSegmentLength( + leftSegment.Start, + leftSegment.End, + rightSegment.Start, + rightSegment.End); + if (overlap > 8d) + { + return true; + } + + var leftLength = ElkEdgeRoutingGeometry.ComputeSegmentLength(leftSegment.Start, leftSegment.End); + var rightLength = ElkEdgeRoutingGeometry.ComputeSegmentLength(rightSegment.Start, rightSegment.End); + if (Math.Min(leftLength, rightLength) > 8d) + { + return true; + } + } + } + + return false; + } + + private static IReadOnlyList FlattenSegmentsNearEnd( + IReadOnlyList path, + int maxSegmentsFromEnd) + { + if (path.Count < 2 || maxSegmentsFromEnd <= 0) + { + return []; + } + + var startIndex = Math.Max(0, path.Count - (maxSegmentsFromEnd + 1)); + var segments = new List(); + for (var i = startIndex; i < path.Count - 1; i++) + { + segments.Add(new RoutedEdgeSegment(string.Empty, path[i], path[i + 1])); + } + + return segments; + } + + private static int DetermineRepairBuildParallelism(int repairEdgeCount) + { + if (repairEdgeCount <= 1) + { + return 1; + } + + var cpuBudget = Math.Clamp(Environment.ProcessorCount / 4, 2, 8); + return Math.Min(repairEdgeCount, cpuBudget); + } + + private static RepairEdgeBuildResult BuildRepairEdgeResult( + int edgeIndex, + ElkRoutedEdge[] existingEdges, + ElkPositionedNode[] nodes, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + IReadOnlyDictionary spreadEndpoints, + IReadOnlyDictionary nodesById, + IReadOnlyList softObstacles, + IReadOnlySet routeRepairEdgeIdSet, + IReadOnlySet preferredShortestEdgeIdSet, + IReadOnlyCollection repairReasons, + double graphMinY, + double graphMaxY, + RoutingStrategy strategy, + CancellationToken cancellationToken) + { + var edge = existingEdges[edgeIndex]; + if (!CanRouteSelectedRepairEdge(edge, graphMinY, graphMaxY, routeRepairEdgeIdSet)) + { + return new RepairEdgeBuildResult(edge, 0, 0, true); + } + + var sourceNode = nodesById.GetValueOrDefault(edge.SourceNodeId ?? string.Empty); + var targetNode = nodesById.GetValueOrDefault(edge.TargetNodeId ?? string.Empty); + var aggressiveRepairNeeded = routeRepairEdgeIdSet.Contains(edge.Id) + && repairReasons.Any(static reason => + reason is "under-node" + or "entry" + or "gateway-source-exit" + or "target-joins" + or "below-graph"); + var newSections = new List(edge.Sections.Count); + var routedSections = 0; + var fallbackSections = 0; + + foreach (var section in edge.Sections) + { + var endPoint = spreadEndpoints.TryGetValue(edge.Id, out var spread) + ? spread + : section.EndPoint; + var (startPoint, adjustedEndPoint) = ResolveRoutingEndpoints( + section.StartPoint, + endPoint, + edge.SourceNodeId, + edge.TargetNodeId, + nodesById); + List? rerouted = null; + if (preferredShortestEdgeIdSet.Contains(edge.Id)) + { + rerouted = TryRouteShortestRepair( + startPoint, + adjustedEndPoint, + nodes, + obstacles, + edge.SourceNodeId ?? string.Empty, + edge.TargetNodeId ?? string.Empty, + sourceNode, + targetNode, + strategy.RoutingParams, + softObstacles, + cancellationToken); + } + + if (aggressiveRepairNeeded) + { + var aggressiveReroute = TryRouteAggressiveRepair( + startPoint, + adjustedEndPoint, + nodes, + obstacles, + edge.SourceNodeId ?? string.Empty, + edge.TargetNodeId ?? string.Empty, + sourceNode, + targetNode, + strategy.RoutingParams, + cancellationToken); + rerouted = ChooseBetterLocalRepairCandidate( + edge, + nodes, + rerouted, + aggressiveReroute); + } + + rerouted ??= ElkEdgeRouterAStar8Dir.Route( + startPoint, + adjustedEndPoint, + obstacles, + edge.SourceNodeId ?? string.Empty, + edge.TargetNodeId ?? string.Empty, + strategy.RoutingParams, + softObstacles, + cancellationToken); + + if (rerouted is not null && rerouted.Count >= 2) + { + routedSections++; + newSections.Add(new ElkEdgeSection + { + StartPoint = rerouted[0], + EndPoint = rerouted[^1], + BendPoints = rerouted.Skip(1).Take(rerouted.Count - 2).ToArray(), + }); + } + else + { + fallbackSections++; + newSections.Add(section); + } + } + + return new RepairEdgeBuildResult( + new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + SourcePortId = edge.SourcePortId, + TargetPortId = edge.TargetPortId, + Kind = edge.Kind, + Label = edge.Label, + Sections = newSections, + }, + routedSections, + fallbackSections, + false); + } + + private static string[] GetRepairBuildLockKeys(ElkRoutedEdge edge) + { + return new[] + { + $"source:{edge.SourceNodeId ?? string.Empty}", + $"target:{edge.TargetNodeId ?? string.Empty}", + } + .Where(static key => !key.EndsWith(':')) + .Distinct(StringComparer.Ordinal) + .OrderBy(static key => key, StringComparer.Ordinal) + .ToArray(); + } + + private static void ExecuteWithRepairBuildLocks( + ConcurrentDictionary lockRegistry, + IReadOnlyList lockKeys, + Action action) + { + static void ExecuteLocked( + IReadOnlyList locks, + int index, + Action action) + { + if (index >= locks.Count) + { + action(); + return; + } + + lock (locks[index]) + { + ExecuteLocked(locks, index + 1, action); + } + } + + if (lockKeys.Count == 0) + { + action(); + return; + } + + var locks = lockKeys + .Select(key => lockRegistry.GetOrAdd(key, static _ => new object())) + .ToArray(); + ExecuteLocked(locks, 0, action); + } + + private static int DetermineRepairEdgeBudget( + RoutingRetryState retryState, + bool detourPriority, + int seededEdgeCount, + int mandatoryEdgeCount) { if (detourPriority) { - return 2; + return Math.Clamp(Math.Max(2, seededEdgeCount), 2, 6); } var budget = retryState.RequiresBlockingRetry - ? 4 + ? 3 : retryState.RequiresLengthRetry - ? 3 + ? 2 : 2; if (retryState.ProximityViolations >= 6 || retryState.EdgeCrossings >= 8) { budget++; } + budget = Math.Max(budget, Math.Min(6, Math.Max(2, seededEdgeCount))); return Math.Clamp(budget, 2, 6); } + private static int DetermineMandatoryFocusBudget( + int mandatoryEdgeCount, + int repairBudget, + int seededEdgeCount) + { + if (mandatoryEdgeCount <= 0) + { + return 0; + } + + if (mandatoryEdgeCount <= repairBudget) + { + return mandatoryEdgeCount; + } + + var focusBudget = Math.Max(2, Math.Max(seededEdgeCount, repairBudget - 1)); + return Math.Min(mandatoryEdgeCount, Math.Clamp(focusBudget, 2, 6)); + } + + private static IEnumerable RotateOrderedEdgeIds( + IReadOnlyList edgeIds, + int attempt) + { + if (edgeIds.Count == 0) + { + yield break; + } + + var offset = edgeIds.Count == 0 + ? 0 + : Math.Max(0, attempt - 1) % edgeIds.Count; + for (var i = 0; i < edgeIds.Count; i++) + { + yield return edgeIds[(offset + i) % edgeIds.Count]; + } + } + + private static string BuildPlateauFingerprint( + RoutingRetryState retryState, + RepairPlan? repairPlan) + { + if (repairPlan is null) + { + return $"full::{DescribeRetryState(retryState)}"; + } + + return string.Create( + CultureInfo.InvariantCulture, + $"{DescribeRetryState(retryState)}::{string.Join(",", repairPlan.Value.EdgeIds.OrderBy(id => id, StringComparer.Ordinal))}"); + } + + private static string BuildBlockingCycleFingerprint( + RoutingRetryState retryState, + RepairPlan? repairPlan) + { + var classes = new List(8); + if (retryState.RemainingShortHighways > 0) + { + classes.Add("short-highways"); + } + + if (retryState.RepeatCollectorCorridorViolations > 0) + { + classes.Add("collector-corridors"); + } + + if (retryState.RepeatCollectorNodeClearanceViolations > 0) + { + classes.Add("collector-clearance"); + } + + if (retryState.TargetApproachJoinViolations > 0) + { + classes.Add("target-joins"); + } + + if (retryState.TargetApproachBacktrackingViolations > 0) + { + classes.Add("approach-backtracking"); + } + + if (retryState.SharedLaneViolations > 0) + { + classes.Add("shared-lanes"); + } + + if (retryState.BelowGraphViolations > 0) + { + classes.Add("below-graph"); + } + + if (retryState.UnderNodeViolations > 0) + { + classes.Add("under-node"); + } + + if (retryState.LongDiagonalViolations > 0) + { + classes.Add("long-diagonal"); + } + + if (retryState.EntryAngleViolations > 0) + { + classes.Add("entry"); + } + + if (retryState.GatewaySourceExitViolations > 0) + { + classes.Add("gateway-source"); + } + + if (retryState.ExcessiveDetourViolations > 0) + { + classes.Add("detour"); + } + + var repairReasons = repairPlan is null + ? "full" + : string.Join(",", repairPlan.Value.Reasons.OrderBy(reason => reason, StringComparer.Ordinal)); + return string.Create( + CultureInfo.InvariantCulture, + $"{string.Join("|", classes)}::{repairReasons}"); + } + + private static bool ShouldStopForBlockingCycle( + IReadOnlyList recentFingerprints, + string fingerprint, + RoutingRetryState retryState, + RoutingRetryState bestRetryState, + int attempt) + { + if (attempt < 3 || recentFingerprints.Count < 3) + { + return false; + } + + var repeatCount = recentFingerprints.Count(item => string.Equals(item, fingerprint, StringComparison.Ordinal)); + return repeatCount >= 2 + && retryState.BlockingViolationCount >= bestRetryState.BlockingViolationCount + && retryState.LengthViolationCount >= bestRetryState.LengthViolationCount; + } + + private static void AppendRecentFingerprint( + List recentFingerprints, + string fingerprint, + int capacity) + { + if (capacity <= 0) + { + return; + } + + if (recentFingerprints.Count >= capacity) + { + recentFingerprints.RemoveAt(0); + } + + recentFingerprints.Add(fingerprint); + } + + private static string? BuildRepairFocusFingerprint(RepairPlan? repairPlan) + { + if (repairPlan is null) + { + return null; + } + + return string.Create( + CultureInfo.InvariantCulture, + $"{string.Join(",", repairPlan.Value.Reasons.OrderBy(reason => reason, StringComparer.Ordinal))}::{string.Join(",", repairPlan.Value.EdgeIds.OrderBy(id => id, StringComparer.Ordinal))}"); + } + private static ElkRoutedEdge[] ApplyPostProcessing( ElkRoutedEdge[] edges, ElkPositionedNode[] nodes, @@ -1008,15 +2435,531 @@ internal static class ElkEdgeRouterIterative result = ElkEdgePostProcessorSimplify.SimplifyEdgePaths(result, nodes); result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction); result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); - result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); var serviceNodes = nodes.Where(n => n.Kind is not "Start" and not "End").ToArray(); var minLineClearance = serviceNodes.Length > 0 ? Math.Min(serviceNodes.Average(n => n.Width), serviceNodes.Average(n => n.Height)) / 2d : 50d; + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance); result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance); + result = RestoreProtectedRepeatCollectorCorridors(result, edges, nodes); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction); + result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction); + result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction); + result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction); + result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction); + result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction); + result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes); + result = ClampBelowGraphEdges(result, nodes); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(result, nodes); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes); + result = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(result, nodes); + result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(result, nodes, minLineClearance); + result = ClampBelowGraphEdges(result, nodes); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(result, nodes, minLineClearance); + result = ElkRepeatCollectorCorridors.SeparateSharedLanes(result, nodes); + result = ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(result, nodes); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + // The final hard-rule closure must end on lane separation so later + // boundary slot normalizers cannot collapse a repaired handoff strip + // back onto the same effective rail. + result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance); + result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance); + result = ClampBelowGraphEdges(result, nodes); + + var score = ElkEdgeRoutingScoring.ComputeScore(result, nodes); + var remainingBrokenHighways = HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(result, nodes).Count + : 0; + var retryState = BuildRetryState(score, remainingBrokenHighways); + if (retryState.RequiresBlockingRetry || retryState.RequiresLengthRetry) + { + var stabilized = ApplyTerminalRuleCleanupRound( + result, + nodes, + layoutOptions.Direction, + minLineClearance); + var stabilizedScore = ElkEdgeRoutingScoring.ComputeScore(stabilized, nodes); + var stabilizedBrokenHighways = HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(stabilized, nodes).Count + : 0; + var stabilizedRetryState = BuildRetryState(stabilizedScore, stabilizedBrokenHighways); + if (IsBetterCandidate(stabilizedScore, stabilizedRetryState, score, retryState)) + { + result = stabilized; + } + } + return result; } + private static ElkRoutedEdge[] ApplyTerminalRuleCleanupRound( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance, + IReadOnlyCollection? restrictedEdgeIds = null) + { + var result = edges; + result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkRepeatCollectorCorridors.SeparateSharedLanes(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes, restrictedEdgeIds); + result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes, restrictedEdgeIds); + result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + // Final late-stage verification: source/target boundary normalization can collapse + // lanes back onto the same node face, so restabilize the local geometry once more. + result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes, restrictedEdgeIds); + result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + // Final hard-rule restabilization after the last normalize pass: the final + // boundary normalization can still pull target slots and horizontal lanes back + // into a bad state, so re-apply the local rule fixers once more before scoring. + result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes); + result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(result, nodes, minLineClearance, restrictedEdgeIds); + result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds); + result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds); + result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds); + result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes); + result = CloseRemainingTerminalViolations(result, nodes, direction, minLineClearance, restrictedEdgeIds); + var lateDetourShortcuts = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(result, nodes, restrictedEdgeIds); + result = ChoosePreferredHardRuleLayout(result, lateDetourShortcuts, nodes); + result = ApplyFinalDetourPolish(result, nodes, minLineClearance, restrictedEdgeIds); + return result; + } + + private static ElkRoutedEdge[] ApplyFinalDetourPolish( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + double minLineClearance, + IReadOnlyCollection? restrictedEdgeIds) + { + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); + var result = edges; + + for (var round = 0; round < 3; round++) + { + var detourSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountExcessiveDetourViolations(result, nodes, detourSeverity, 10); + if (detourSeverity.Count == 0) + { + break; + } + + var currentScore = ElkEdgeRoutingScoring.ComputeScore(result, nodes); + var currentRetryState = BuildRetryState( + currentScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(result, nodes).Count + : 0); + + var improved = false; + foreach (var edgeId in detourSeverity + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Key)) + { + if (restrictedSet is not null && !restrictedSet.Contains(edgeId)) + { + continue; + } + + var focused = (IReadOnlyCollection)[edgeId]; + var candidateEdges = ComposeTransactionalFinalDetourCandidate( + result, + nodes, + minLineClearance, + focused); + candidateEdges = ChoosePreferredHardRuleLayout(result, candidateEdges, nodes); + if (ReferenceEquals(candidateEdges, result)) + { + continue; + } + + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes); + var candidateRetryState = BuildRetryState( + candidateScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count + : 0); + + var improvedDetours = candidateRetryState.ExcessiveDetourViolations < currentRetryState.ExcessiveDetourViolations; + if (HasHardRuleRegression(candidateRetryState, currentRetryState) + || (!improvedDetours + && !IsBetterCandidate(candidateScore, candidateRetryState, currentScore, currentRetryState))) + { + continue; + } + + result = candidateEdges; + improved = true; + break; + } + + if (!improved) + { + break; + } + } + + return result; + } + + private static ElkRoutedEdge[] ComposeTransactionalFinalDetourCandidate( + ElkRoutedEdge[] baseline, + ElkPositionedNode[] nodes, + double minLineClearance, + IReadOnlyCollection focusedEdgeIds) + { + var candidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(baseline, nodes, focusedEdgeIds); + if (ReferenceEquals(candidate, baseline)) + { + return baseline; + } + + candidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(candidate, nodes, minLineClearance, focusedEdgeIds); + candidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(candidate, nodes, minLineClearance, focusedEdgeIds); + candidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(candidate, nodes, minLineClearance, focusedEdgeIds); + candidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(candidate, nodes, minLineClearance, focusedEdgeIds); + candidate = ClampBelowGraphEdges(candidate, nodes, focusedEdgeIds); + candidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(candidate, nodes, minLineClearance, focusedEdgeIds); + candidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(candidate, nodes, minLineClearance, focusedEdgeIds); + candidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(candidate, nodes, minLineClearance, focusedEdgeIds); + candidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(candidate, nodes, focusedEdgeIds); + candidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(candidate, nodes, focusedEdgeIds); + candidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(candidate, nodes, minLineClearance, focusedEdgeIds); + return candidate; + } + + private static ElkRoutedEdge[] CloseRemainingTerminalViolations( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction, + double minLineClearance, + IReadOnlyCollection? restrictedEdgeIds) + { + var result = edges; + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); + + for (var round = 0; round < 4; round++) + { + var severityByEdgeId = new Dictionary(StringComparer.Ordinal); + var previousHardPressure = + ElkEdgeRoutingScoring.CountBadBoundaryAngles(result, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(result, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(result, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountSharedLaneViolations(result, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountBelowGraphViolations(result, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountUnderNodeViolations(result, nodes, severityByEdgeId, 10); + var previousLengthPressure = 0; + if (previousHardPressure == 0) + { + previousLengthPressure = + ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(result, nodes, severityByEdgeId, 10) + + ElkEdgeRoutingScoring.CountExcessiveDetourViolations(result, nodes, severityByEdgeId, 10); + } + + var previousRetryState = BuildRetryState( + ElkEdgeRoutingScoring.ComputeScore(result, nodes), + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(result, nodes).Count + : 0); + if (previousHardPressure == 0 && previousLengthPressure == 0) + { + break; + } + + var focusEdgeIds = severityByEdgeId.Keys + .Where(edgeId => restrictedSet is null || restrictedSet.Contains(edgeId)) + .OrderBy(edgeId => edgeId, StringComparer.Ordinal) + .ToArray(); + if (focusEdgeIds.Length == 0) + { + break; + } + + var focused = (IReadOnlyCollection)focusEdgeIds; + var candidate = result; + if (previousHardPressure > 0) + { + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateSharedLaneConflicts(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkRepeatCollectorCorridors.SeparateSharedLanes(current, nodes, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadSourceDepartureJoins(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.ElevateUnderNodeViolations(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(current, nodes, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ClampBelowGraphEdges(current, nodes, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadTargetApproachJoins(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(current, nodes, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.AvoidNodeCrossings(current, nodes, direction, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.ElevateUnderNodeViolations(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateSharedLaneConflicts(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkRepeatCollectorCorridors.SeparateSharedLanes(current, nodes, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadSourceDepartureJoins(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadTargetApproachJoins(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ClampBelowGraphEdges(current, nodes, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.AvoidNodeCrossings(current, nodes, direction, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.ElevateUnderNodeViolations(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadSourceDepartureJoins(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateSharedLaneConflicts(current, nodes, minLineClearance, focused)); + } + else + { + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(current, nodes, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadTargetApproachJoins(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeDecisionTargetEntries(current, nodes, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(current, nodes, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ClampBelowGraphEdges(current, nodes, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.AvoidNodeCrossings(current, nodes, direction, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(current, nodes, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadTargetApproachJoins(current, nodes, minLineClearance, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeDecisionTargetEntries(current, nodes, focused)); + candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(current, nodes, focused)); + } + + var currentHardPressure = + ElkEdgeRoutingScoring.CountBadBoundaryAngles(candidate, nodes) + + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(candidate, nodes) + + ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(candidate, nodes) + + ElkEdgeRoutingScoring.CountSharedLaneViolations(candidate, nodes) + + ElkEdgeRoutingScoring.CountBelowGraphViolations(candidate, nodes) + + ElkEdgeRoutingScoring.CountUnderNodeViolations(candidate, nodes); + var currentLengthPressure = + ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(candidate, nodes) + + ElkEdgeRoutingScoring.CountExcessiveDetourViolations(candidate, nodes); + var candidateRetryState = BuildRetryState( + ElkEdgeRoutingScoring.ComputeScore(candidate, nodes), + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count + : 0); + if (HasHardRuleRegression(candidateRetryState, previousRetryState) + || (previousHardPressure > 0 + ? currentHardPressure >= previousHardPressure + : currentLengthPressure >= previousLengthPressure)) + { + break; + } + + result = candidate; + } + + return result; + } + + private static ElkRoutedEdge[] ApplyGuardedFocusedHardRulePass( + ElkRoutedEdge[] current, + ElkPositionedNode[] nodes, + Func pass) + { + var candidate = pass(current); + return ChoosePreferredHardRuleLayout(current, candidate, nodes); + } + + private static ElkRoutedEdge[] ChoosePreferredHardRuleLayout( + ElkRoutedEdge[] baseline, + ElkRoutedEdge[] candidate, + ElkPositionedNode[] nodes) + { + if (ReferenceEquals(candidate, baseline)) + { + return baseline; + } + + var baselineScore = ElkEdgeRoutingScoring.ComputeScore(baseline, nodes); + var baselineRetryState = BuildRetryState( + baselineScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(baseline, nodes).Count + : 0); + var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes); + var candidateRetryState = BuildRetryState( + candidateScore, + HighwayProcessingEnabled + ? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count + : 0); + + if (HasHardRuleRegression(candidateRetryState, baselineRetryState)) + { + return baseline; + } + + var retryComparison = CompareRetryStates(candidateRetryState, baselineRetryState); + if (retryComparison < 0) + { + return candidate; + } + + if (retryComparison > 0) + { + return baseline; + } + + if (candidateScore.NodeCrossings != baselineScore.NodeCrossings) + { + return candidateScore.NodeCrossings < baselineScore.NodeCrossings + ? candidate + : baseline; + } + + return candidateScore.Value > baselineScore.Value + ? candidate + : baseline; + } + private static IEnumerable GenerateStrategies( ElkRoutedEdge[] edges, ElkPositionedNode[] nodes, @@ -1097,11 +3040,17 @@ internal static class ElkEdgeRouterIterative return new RoutingRetryState( RemainingShortHighways: remainingBrokenHighways, RepeatCollectorCorridorViolations: score.RepeatCollectorCorridorViolations, + RepeatCollectorNodeClearanceViolations: score.RepeatCollectorNodeClearanceViolations, TargetApproachJoinViolations: score.TargetApproachJoinViolations, TargetApproachBacktrackingViolations: score.TargetApproachBacktrackingViolations, ExcessiveDetourViolations: score.ExcessiveDetourViolations, + SharedLaneViolations: score.SharedLaneViolations, + BelowGraphViolations: score.BelowGraphViolations, + UnderNodeViolations: score.UnderNodeViolations, + LongDiagonalViolations: score.LongDiagonalViolations, ProximityViolations: score.ProximityViolations, EntryAngleViolations: score.EntryAngleViolations, + GatewaySourceExitViolations: score.GatewaySourceExitViolations, LabelProximityViolations: score.LabelProximityViolations, EdgeCrossings: score.EdgeCrossings); } @@ -1119,6 +3068,11 @@ internal static class ElkEdgeRouterIterative parts.Add($"collector-corridors={retryState.RepeatCollectorCorridorViolations}"); } + if (retryState.RepeatCollectorNodeClearanceViolations > 0) + { + parts.Add($"collector-clearance={retryState.RepeatCollectorNodeClearanceViolations}"); + } + if (retryState.TargetApproachJoinViolations > 0) { parts.Add($"target-joins={retryState.TargetApproachJoinViolations}"); @@ -1134,6 +3088,31 @@ internal static class ElkEdgeRouterIterative parts.Add($"detour={retryState.ExcessiveDetourViolations}"); } + if (retryState.GatewaySourceExitViolations > 0) + { + parts.Add($"gateway-source={retryState.GatewaySourceExitViolations}"); + } + + if (retryState.SharedLaneViolations > 0) + { + parts.Add($"shared-lanes={retryState.SharedLaneViolations}"); + } + + if (retryState.BelowGraphViolations > 0) + { + parts.Add($"below-graph={retryState.BelowGraphViolations}"); + } + + if (retryState.UnderNodeViolations > 0) + { + parts.Add($"under-node={retryState.UnderNodeViolations}"); + } + + if (retryState.LongDiagonalViolations > 0) + { + parts.Add($"long-diagonal={retryState.LongDiagonalViolations}"); + } + if (retryState.ProximityViolations > 0) { parts.Add($"proximity={retryState.ProximityViolations}"); @@ -1169,15 +3148,46 @@ internal static class ElkEdgeRouterIterative return false; } - var crossingRetryBudget = Math.Min( - retryState.EdgeCrossings >= 12 ? 2 : 1, - Math.Max(0, maxAdaptationsPerStrategy - 1)); - return attempt < crossingRetryBudget; + return attempt < Math.Max(0, maxAdaptationsPerStrategy - 1); } - private static bool ShouldStopForStagnation(int stagnantAttempts, int attempt) + private static int DetermineAdaptiveAttemptBudget( + RoutingRetryState retryState, + int maxAdaptationsPerStrategy) { - return attempt >= 2 && stagnantAttempts >= 2; + var boundedMaximum = Math.Clamp(maxAdaptationsPerStrategy, 1, 12); + if (retryState.RequiresBlockingRetry) + { + var complexBlocking = + retryState.UnderNodeViolations > 0 + || retryState.SharedLaneViolations > 0 + || retryState.TargetApproachJoinViolations > 0 + || retryState.GatewaySourceExitViolations > 0; + return Math.Min(boundedMaximum, complexBlocking ? 6 : 5); + } + + if (retryState.RequiresLengthRetry) + { + return Math.Min(boundedMaximum, 4); + } + + if (retryState.RequiresQualityRetry || retryState.EdgeCrossings > 0) + { + return Math.Min(boundedMaximum, 3); + } + + return 1; + } + + private static bool ShouldStopForStagnation(int stagnantAttempts, int attempt, int maxAdaptationsPerStrategy) + { + if (stagnantAttempts <= 0 || attempt < 2) + { + return false; + } + + var stagnationBudget = Math.Min(Math.Max(4, maxAdaptationsPerStrategy / 12), 8); + return stagnantAttempts >= stagnationBudget; } private static bool ShouldRetryForPrimaryViolations( @@ -1190,50 +3200,7 @@ internal static class ElkEdgeRouterIterative return false; } - var primaryRetryBudget = DeterminePrimaryRetryBudget(retryState, maxAdaptationsPerStrategy); - return attempt < primaryRetryBudget; - } - - private static int DeterminePrimaryRetryBudget( - RoutingRetryState retryState, - int maxAdaptationsPerStrategy) - { - var maxRetries = Math.Max(0, maxAdaptationsPerStrategy - 1); - if (maxRetries == 0) - { - return 0; - } - - var budget = 0; - if (retryState.RequiresBlockingRetry) - { - budget = Math.Max( - budget, - Math.Min(4, 2 + retryState.BlockingViolationCount)); - } - - if (retryState.RequiresLengthRetry) - { - budget = Math.Max( - budget, - Math.Min(3, 1 + retryState.LengthViolationCount)); - } - - if (retryState.RequiresQualityRetry) - { - var qualitySeverity = retryState.ProximityViolations - + retryState.EntryAngleViolations - + retryState.LabelProximityViolations; - budget = Math.Max( - budget, - qualitySeverity >= 12 - ? 3 - : qualitySeverity >= 6 - ? 2 - : 1); - } - - return Math.Min(maxRetries, Math.Max(1, budget)); + return attempt < Math.Max(0, maxAdaptationsPerStrategy - 1); } private static int DetermineTargetValidSolutionCount( @@ -1308,6 +3275,11 @@ internal static class ElkEdgeRouterIterative EdgeRoutingScore best, RoutingRetryState bestRetryState) { + if (HasHardRuleRegression(candidateRetryState, bestRetryState)) + { + return false; + } + var retryComparison = CompareRetryStates(candidateRetryState, bestRetryState); if (retryComparison != 0) { @@ -1322,6 +3294,22 @@ internal static class ElkEdgeRouterIterative return candidate.Value > best.Value; } + private static bool HasHardRuleRegression(RoutingRetryState candidate, RoutingRetryState baseline) + { + return candidate.RemainingShortHighways > baseline.RemainingShortHighways + || candidate.RepeatCollectorCorridorViolations > baseline.RepeatCollectorCorridorViolations + || candidate.RepeatCollectorNodeClearanceViolations > baseline.RepeatCollectorNodeClearanceViolations + || candidate.BelowGraphViolations > baseline.BelowGraphViolations + || candidate.UnderNodeViolations > baseline.UnderNodeViolations + || candidate.LongDiagonalViolations > baseline.LongDiagonalViolations + || candidate.EntryAngleViolations > baseline.EntryAngleViolations + || candidate.GatewaySourceExitViolations > baseline.GatewaySourceExitViolations + || candidate.SharedLaneViolations > baseline.SharedLaneViolations + || candidate.TargetApproachJoinViolations > baseline.TargetApproachJoinViolations + || candidate.TargetApproachBacktrackingViolations > baseline.TargetApproachBacktrackingViolations + || candidate.ExcessiveDetourViolations > baseline.ExcessiveDetourViolations; + } + private static int CompareRetryStates(RoutingRetryState left, RoutingRetryState right) { if (left.RemainingShortHighways != right.RemainingShortHighways) @@ -1334,6 +3322,41 @@ internal static class ElkEdgeRouterIterative return left.RepeatCollectorCorridorViolations.CompareTo(right.RepeatCollectorCorridorViolations); } + if (left.RepeatCollectorNodeClearanceViolations != right.RepeatCollectorNodeClearanceViolations) + { + return left.RepeatCollectorNodeClearanceViolations.CompareTo(right.RepeatCollectorNodeClearanceViolations); + } + + if (left.BelowGraphViolations != right.BelowGraphViolations) + { + return left.BelowGraphViolations.CompareTo(right.BelowGraphViolations); + } + + if (left.UnderNodeViolations != right.UnderNodeViolations) + { + return left.UnderNodeViolations.CompareTo(right.UnderNodeViolations); + } + + if (left.LongDiagonalViolations != right.LongDiagonalViolations) + { + return left.LongDiagonalViolations.CompareTo(right.LongDiagonalViolations); + } + + if (left.EntryAngleViolations != right.EntryAngleViolations) + { + return left.EntryAngleViolations.CompareTo(right.EntryAngleViolations); + } + + if (left.GatewaySourceExitViolations != right.GatewaySourceExitViolations) + { + return left.GatewaySourceExitViolations.CompareTo(right.GatewaySourceExitViolations); + } + + if (left.SharedLaneViolations != right.SharedLaneViolations) + { + return left.SharedLaneViolations.CompareTo(right.SharedLaneViolations); + } + if (left.TargetApproachJoinViolations != right.TargetApproachJoinViolations) { return left.TargetApproachJoinViolations.CompareTo(right.TargetApproachJoinViolations); @@ -1354,11 +3377,6 @@ internal static class ElkEdgeRouterIterative return left.ProximityViolations.CompareTo(right.ProximityViolations); } - if (left.EntryAngleViolations != right.EntryAngleViolations) - { - return left.EntryAngleViolations.CompareTo(right.EntryAngleViolations); - } - if (left.LabelProximityViolations != right.LabelProximityViolations) { return left.LabelProximityViolations.CompareTo(right.LabelProximityViolations); @@ -1395,12 +3413,14 @@ internal static class ElkEdgeRouterIterative for (var i = 1; i < solutions.Count; i++) { var candidate = solutions[i]; - var retryComparison = CompareRetryStates(candidate.RetryState, best.RetryState); - if (retryComparison < 0 - || (retryComparison == 0 && candidate.Score.NodeCrossings < best.Score.NodeCrossings) - || (retryComparison == 0 && candidate.Score.NodeCrossings == best.Score.NodeCrossings + if (candidate.Score.NodeCrossings < best.Score.NodeCrossings + || (candidate.Score.NodeCrossings == best.Score.NodeCrossings + && CompareRetryStates(candidate.RetryState, best.RetryState) < 0) + || (candidate.Score.NodeCrossings == best.Score.NodeCrossings + && CompareRetryStates(candidate.RetryState, best.RetryState) == 0 && candidate.Score.EdgeCrossings < best.Score.EdgeCrossings) - || (retryComparison == 0 && candidate.Score.NodeCrossings == best.Score.NodeCrossings + || (candidate.Score.NodeCrossings == best.Score.NodeCrossings + && CompareRetryStates(candidate.RetryState, best.RetryState) == 0 && candidate.Score.EdgeCrossings == best.Score.EdgeCrossings && candidate.Score.Value > best.Score.Value)) { @@ -1501,7 +3521,8 @@ internal static class ElkEdgeRouterIterative } var ep = lastSection.EndPoint; - var side = ResolveEntrySide(ep, targetNode); + var adjacentPoint = lastSection.BendPoints.LastOrDefault() ?? lastSection.StartPoint; + var side = ResolveEntrySide(ep, adjacentPoint, targetNode); var key = $"{edge.TargetNodeId}|{side}"; if (!groups.TryGetValue(key, out var list)) @@ -1572,17 +3593,9 @@ internal static class ElkEdgeRouterIterative return result; } - private static string ResolveEntrySide(ElkPoint endpoint, ElkPositionedNode node) + private static string ResolveEntrySide(ElkPoint endpoint, ElkPoint adjacentPoint, ElkPositionedNode node) { - var distLeft = Math.Abs(endpoint.X - node.X); - var distRight = Math.Abs(endpoint.X - (node.X + node.Width)); - var distTop = Math.Abs(endpoint.Y - node.Y); - var distBottom = Math.Abs(endpoint.Y - (node.Y + node.Height)); - var min = Math.Min(Math.Min(distLeft, distRight), Math.Min(distTop, distBottom)); - if (Math.Abs(min - distLeft) < 0.1d) return "left"; - if (Math.Abs(min - distRight) < 0.1d) return "right"; - if (Math.Abs(min - distTop) < 0.1d) return "top"; - return "bottom"; + return ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(endpoint, adjacentPoint, node); } private static bool ShouldRouteEdge(ElkRoutedEdge edge, double graphMinY, double graphMaxY) @@ -1641,6 +3654,37 @@ internal static class ElkEdgeRouterIterative && HasClearOrthogonalShortcut(edge, nodes); } + private static bool CanRouteSelectedRepairEdge( + ElkRoutedEdge edge, + double graphMinY, + double graphMaxY, + IReadOnlySet routeRepairEdgeIds) + { + if (ShouldRouteEdge(edge, graphMinY, graphMaxY)) + { + return true; + } + + if (!routeRepairEdgeIds.Contains(edge.Id)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(edge.SourcePortId) || !string.IsNullOrWhiteSpace(edge.TargetPortId)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(edge.Kind) + && edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label) + && !ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY); + } + private static bool HasClearOrthogonalShortcut( ElkRoutedEdge edge, IReadOnlyCollection nodes) @@ -1685,6 +3729,161 @@ internal static class ElkEdgeRouterIterative && SegmentIsClear(verticalThenHorizontal, end); } + private static int CountRepeatCollectorNodeClearanceViolations( + IReadOnlyCollection edges, + IReadOnlyCollection nodes, + Dictionary? severityByEdgeId, + int severityWeight = 1) + { + if (edges.Count == 0 || nodes.Count == 0) + { + return 0; + } + + var serviceNodes = nodes.Where(node => node.Kind is not "Start" and not "End").ToArray(); + var minClearance = serviceNodes.Length > 0 + ? Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d + : 50d; + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + var count = 0; + + foreach (var edge in edges) + { + if (!ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label)) + { + continue; + } + + var edgeViolations = 0; + foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge)) + { + var horizontal = Math.Abs(segment.Start.Y - segment.End.Y) < 2d; + var vertical = Math.Abs(segment.Start.X - segment.End.X) < 2d; + if (!horizontal && !vertical) + { + continue; + } + + foreach (var node in nodes) + { + if (node.Id == edge.SourceNodeId || node.Id == edge.TargetNodeId) + { + continue; + } + + if (horizontal) + { + var overlapX = Math.Max(segment.Start.X, segment.End.X) > node.X + && Math.Min(segment.Start.X, segment.End.X) < node.X + node.Width; + if (!overlapX) + { + continue; + } + + var distance = Math.Min( + Math.Abs(segment.Start.Y - node.Y), + Math.Abs(segment.Start.Y - (node.Y + node.Height))); + if (distance > 0.5d && distance < minClearance) + { + edgeViolations++; + } + } + else + { + var overlapY = Math.Max(segment.Start.Y, segment.End.Y) > node.Y + && Math.Min(segment.Start.Y, segment.End.Y) < node.Y + node.Height; + if (!overlapY) + { + continue; + } + + var distance = Math.Min( + Math.Abs(segment.Start.X - node.X), + Math.Abs(segment.Start.X - (node.X + node.Width))); + if (distance > 0.5d && distance < minClearance) + { + edgeViolations++; + } + } + } + } + + if (edgeViolations <= 0) + { + continue; + } + + count += edgeViolations; + if (severityByEdgeId is not null) + { + severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + (edgeViolations * severityWeight); + } + } + + return count; + } + + private static (ElkPoint StartPoint, ElkPoint EndPoint) ResolveRoutingEndpoints( + ElkPoint startPoint, + ElkPoint endPoint, + string? sourceNodeId, + string? targetNodeId, + IReadOnlyDictionary nodesById) + { + var adjustedStart = startPoint; + if (nodesById.TryGetValue(sourceNodeId ?? string.Empty, out var sourceNode) + && ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + adjustedStart = ResolveGatewayRoutingDeparturePoint(sourceNode, endPoint, startPoint); + } + + var adjustedEnd = endPoint; + if (nodesById.TryGetValue(targetNodeId ?? string.Empty, out var targetNode) + && ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + adjustedEnd = ResolveGatewayRoutingApproachPoint(targetNode, adjustedStart, endPoint); + } + + return (adjustedStart, adjustedEnd); + } + + private static ElkPoint ResolveGatewayRoutingBoundary( + ElkPositionedNode node, + ElkPoint referencePoint) + { + var boundary = ElkShapeBoundaries.ProjectOntoShapeBoundary(node, referencePoint); + return ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(node, boundary, referencePoint); + } + + private static ElkPoint ResolveGatewayRoutingDeparturePoint( + ElkPositionedNode node, + ElkPoint referencePoint, + ElkPoint preferredBoundary) + { + var boundary = ElkShapeBoundaries.IsGatewayBoundaryPoint(node, preferredBoundary) + ? ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(node, preferredBoundary, referencePoint) + : ResolveGatewayRoutingBoundary(node, referencePoint); + var exteriorDeparture = ElkShapeBoundaries.BuildGatewayDirectionalExteriorPoint(node, boundary, referencePoint); + return ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(node, exteriorDeparture) + ? boundary + : exteriorDeparture; + } + + private static ElkPoint ResolveGatewayRoutingApproachPoint( + ElkPositionedNode node, + ElkPoint referencePoint, + ElkPoint preferredBoundary) + { + var boundary = ElkShapeBoundaries.IsGatewayBoundaryPoint(node, preferredBoundary) + ? ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(node, preferredBoundary, referencePoint) + : ResolveGatewayRoutingBoundary(node, referencePoint); + var exteriorApproach = ElkShapeBoundaries.BuildGatewayDirectionalExteriorPoint(node, boundary, referencePoint); + return ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(node, exteriorApproach) + ? boundary + : exteriorApproach; + } + private static List? TryRouteShortestRepair( ElkPoint start, ElkPoint end, @@ -1692,18 +3891,43 @@ internal static class ElkEdgeRouterIterative (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, string sourceId, string targetId, + ElkPositionedNode? sourceNode, ElkPositionedNode? targetNode, AStarRoutingParams routingParams, IReadOnlyList softObstacles, CancellationToken cancellationToken) { + if (sourceNode is not null && targetNode is not null) + { + if (ElkEdgePostProcessor.TryBuildPreferredBoundaryShortcutPath( + sourceNode, + targetNode, + nodes, + sourceId, + targetId, + out var preferredShortcut) + && (targetNode is null || !HasTargetApproachBacktracking(preferredShortcut, targetNode))) + { + return preferredShortcut; + } + } + var candidateEndpoints = EnumerateShortestRepairEndpoints(start, end, targetNode).ToArray(); + var minLineClearance = ResolveMinLineClearance(nodes); + var shortcutObstaclePadding = Math.Max(12d, Math.Min(routingParams.Margin, Math.Max(18d, minLineClearance - 4d))); List? bestPath = null; var bestLength = double.MaxValue; foreach (var candidateEnd in candidateEndpoints) { - var orthogonalShortcut = TryBuildShortestOrthogonalPath(start, candidateEnd, nodes, sourceId, targetId, targetNode); + var orthogonalShortcut = TryBuildShortestOrthogonalPath( + start, + candidateEnd, + nodes, + sourceId, + targetId, + targetNode, + shortcutObstaclePadding); if (orthogonalShortcut is null || (targetNode is not null && HasTargetApproachBacktracking(orthogonalShortcut, targetNode))) { @@ -1718,6 +3942,30 @@ internal static class ElkEdgeRouterIterative } } + foreach (var candidateEnd in candidateEndpoints) + { + var localSkirtShortcut = TryBuildLocalObstacleSkirtPath( + start, + candidateEnd, + nodes, + sourceId, + targetId, + targetNode, + shortcutObstaclePadding); + if (localSkirtShortcut is null + || (targetNode is not null && HasTargetApproachBacktracking(localSkirtShortcut, targetNode))) + { + continue; + } + + var shortcutLength = ComputePolylineLength(localSkirtShortcut); + if (shortcutLength < bestLength - 0.5d) + { + bestPath = localSkirtShortcut; + bestLength = shortcutLength; + } + } + if (bestPath is not null) { return bestPath; @@ -1725,11 +3973,11 @@ internal static class ElkEdgeRouterIterative var shortestParams = routingParams with { - Margin = Math.Max(4d, Math.Min(8d, routingParams.Margin * 0.4d)), + Margin = Math.Max(shortcutObstaclePadding, Math.Min(routingParams.Margin, minLineClearance)), BendPenalty = Math.Min(routingParams.BendPenalty, 80d), DiagonalPenalty = Math.Min(routingParams.DiagonalPenalty, 40d), SoftObstacleWeight = Math.Max(0.25d, routingParams.SoftObstacleWeight * 0.35d), - SoftObstacleClearance = Math.Max(12d, routingParams.SoftObstacleClearance * 0.5d), + SoftObstacleClearance = Math.Max(Math.Max(18d, minLineClearance * 0.6d), routingParams.SoftObstacleClearance * 0.5d), IntermediateGridSpacing = Math.Max(12d, routingParams.IntermediateGridSpacing - 8d), }; var shortestObstacles = nodes @@ -1769,19 +4017,293 @@ internal static class ElkEdgeRouterIterative return bestPath; } + private static List? TryRouteAggressiveRepair( + ElkPoint start, + ElkPoint end, + IReadOnlyCollection nodes, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + string sourceId, + string targetId, + ElkPositionedNode? sourceNode, + ElkPositionedNode? targetNode, + AStarRoutingParams routingParams, + CancellationToken cancellationToken) + { + var candidateEndpoints = EnumerateShortestRepairEndpoints(start, end, targetNode).ToArray(); + if (candidateEndpoints.Length == 0) + { + return null; + } + + var minLineClearance = ResolveMinLineClearance(nodes); + var aggressiveParams = routingParams with + { + Margin = Math.Max(10d, Math.Min(routingParams.Margin, Math.Max(14d, minLineClearance * 0.45d))), + BendPenalty = Math.Min(routingParams.BendPenalty, 60d), + DiagonalPenalty = Math.Min(routingParams.DiagonalPenalty, 35d), + SoftObstacleWeight = 0d, + SoftObstacleClearance = 0d, + IntermediateGridSpacing = Math.Max(12d, Math.Min(routingParams.IntermediateGridSpacing, Math.Max(12d, minLineClearance * 0.45d))), + }; + + var aggressiveObstacles = obstacles + .Select(obstacle => ( + Left: obstacle.Left + Math.Min(0d, aggressiveParams.Margin - routingParams.Margin), + Top: obstacle.Top + Math.Min(0d, aggressiveParams.Margin - routingParams.Margin), + Right: obstacle.Right - Math.Min(0d, aggressiveParams.Margin - routingParams.Margin), + Bottom: obstacle.Bottom - Math.Min(0d, aggressiveParams.Margin - routingParams.Margin), + obstacle.Id)) + .ToArray(); + + List? bestPath = null; + ElkRoutedEdge? bestEdge = null; + + foreach (var candidateEnd in candidateEndpoints) + { + var candidate = ElkEdgeRouterAStar8Dir.Route( + start, + candidateEnd, + aggressiveObstacles, + sourceId, + targetId, + aggressiveParams, + [], + cancellationToken); + if (candidate is null) + { + continue; + } + + if (targetNode is not null && HasTargetApproachBacktracking(candidate, targetNode)) + { + continue; + } + + var candidateEdge = BuildCandidateRepairEdge( + sourceId, + targetId, + sourceNode, + targetNode, + candidate); + if (bestPath is null || CompareSingleEdgeRepairQuality(candidateEdge, bestEdge!, nodes) < 0) + { + bestPath = candidate; + bestEdge = candidateEdge; + } + } + + return bestPath; + } + + private static List? ChooseBetterLocalRepairCandidate( + ElkRoutedEdge originalEdge, + IReadOnlyCollection nodes, + List? primaryCandidate, + List? secondaryCandidate) + { + if (primaryCandidate is null || primaryCandidate.Count < 2) + { + return secondaryCandidate; + } + + if (secondaryCandidate is null || secondaryCandidate.Count < 2) + { + return primaryCandidate; + } + + var primaryEdge = BuildCandidateRepairEdge( + originalEdge.SourceNodeId, + originalEdge.TargetNodeId, + null, + null, + primaryCandidate); + var secondaryEdge = BuildCandidateRepairEdge( + originalEdge.SourceNodeId, + originalEdge.TargetNodeId, + null, + null, + secondaryCandidate); + return CompareSingleEdgeRepairQuality(secondaryEdge, primaryEdge, nodes) < 0 + ? secondaryCandidate + : primaryCandidate; + } + + private static ElkRoutedEdge BuildCandidateRepairEdge( + string? sourceNodeId, + string? targetNodeId, + ElkPositionedNode? sourceNode, + ElkPositionedNode? targetNode, + IReadOnlyList path) + { + return new ElkRoutedEdge + { + Id = "__candidate__", + SourceNodeId = sourceNodeId, + TargetNodeId = targetNodeId, + Kind = string.Empty, + Label = string.Empty, + Sections = + [ + new ElkEdgeSection + { + StartPoint = path[0], + EndPoint = path[^1], + BendPoints = path.Count > 2 + ? path.Skip(1).Take(path.Count - 2).ToArray() + : [], + }, + ], + }; + } + + private static int CompareSingleEdgeRepairQuality( + ElkRoutedEdge left, + ElkRoutedEdge right, + IReadOnlyCollection nodes) + { + var leftBlocking = CountSingleEdgeBlockingViolations(left, nodes); + var rightBlocking = CountSingleEdgeBlockingViolations(right, nodes); + if (leftBlocking != rightBlocking) + { + return leftBlocking.CompareTo(rightBlocking); + } + + var leftDetour = ElkEdgeRoutingScoring.CountExcessiveDetourViolations([left], nodes); + var rightDetour = ElkEdgeRoutingScoring.CountExcessiveDetourViolations([right], nodes); + if (leftDetour != rightDetour) + { + return leftDetour.CompareTo(rightDetour); + } + + var leftLength = ComputePolylineLength(ExtractCandidatePath(left)); + var rightLength = ComputePolylineLength(ExtractCandidatePath(right)); + return leftLength.CompareTo(rightLength); + } + + private static int CountSingleEdgeBlockingViolations( + ElkRoutedEdge edge, + IReadOnlyCollection nodes) + { + return ElkEdgeRoutingScoring.CountUnderNodeViolations([edge], nodes) + + ElkEdgeRoutingScoring.CountBelowGraphViolations([edge], nodes) + + ElkEdgeRoutingScoring.CountBadBoundaryAngles([edge], nodes) + + ElkEdgeRoutingScoring.CountGatewaySourceExitViolations([edge], nodes); + } + + private static List ExtractCandidatePath(ElkRoutedEdge edge) + { + var path = new List(); + foreach (var section in edge.Sections) + { + if (path.Count == 0) + { + path.Add(section.StartPoint); + } + + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + } + + return path; + } + + private static List? TryBuildPreferredSideShortcut( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + IReadOnlyCollection nodes, + string sourceId, + string targetId) + { + var sourceCenterX = sourceNode.X + (sourceNode.Width / 2d); + var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); + var targetCenterX = targetNode.X + (targetNode.Width / 2d); + var targetCenterY = targetNode.Y + (targetNode.Height / 2d); + var deltaX = targetCenterX - sourceCenterX; + var deltaY = targetCenterY - sourceCenterY; + var absDx = Math.Abs(deltaX); + var absDy = Math.Abs(deltaY); + if (absDx < 16d && absDy < 16d) + { + return null; + } + + var horizontalDominant = absDx >= absDy; + var preferredSourceSide = horizontalDominant + ? deltaX >= 0d ? "right" : "left" + : deltaY >= 0d ? "bottom" : "top"; + var preferredTargetSide = horizontalDominant + ? deltaX >= 0d ? "left" : "right" + : deltaY >= 0d ? "top" : "bottom"; + var start = BuildPreferredBoundaryPoint(sourceNode, preferredSourceSide, targetNode); + var end = BuildPreferredBoundaryPoint(targetNode, preferredTargetSide, sourceNode); + return TryBuildShortestOrthogonalPath(start, end, nodes, sourceId, targetId, targetNode, 0d); + } + + private static ElkPoint BuildPreferredBoundaryPoint( + ElkPositionedNode node, + string side, + ElkPositionedNode otherNode) + { + var horizontalInset = Math.Min(24d, Math.Max(12d, node.Width / 4d)); + var verticalInset = Math.Min(24d, Math.Max(12d, node.Height / 4d)); + var otherCenterX = otherNode.X + (otherNode.Width / 2d); + var otherCenterY = otherNode.Y + (otherNode.Height / 2d); + + var boundary = side switch + { + "left" => new ElkPoint + { + X = node.X, + Y = Math.Clamp(otherCenterY, node.Y + verticalInset, (node.Y + node.Height) - verticalInset), + }, + "right" => new ElkPoint + { + X = node.X + node.Width, + Y = Math.Clamp(otherCenterY, node.Y + verticalInset, (node.Y + node.Height) - verticalInset), + }, + "top" => new ElkPoint + { + X = Math.Clamp(otherCenterX, node.X + horizontalInset, (node.X + node.Width) - horizontalInset), + Y = node.Y, + }, + _ => new ElkPoint + { + X = Math.Clamp(otherCenterX, node.X + horizontalInset, (node.X + node.Width) - horizontalInset), + Y = node.Y + node.Height, + }, + }; + + if (!ElkShapeBoundaries.IsGatewayShape(node)) + { + return boundary; + } + + var referencePoint = side switch + { + "left" => new ElkPoint { X = node.X - Math.Max(24d, node.Width / 3d), Y = boundary.Y }, + "right" => new ElkPoint { X = node.X + node.Width + Math.Max(24d, node.Width / 3d), Y = boundary.Y }, + "top" => new ElkPoint { X = boundary.X, Y = node.Y - Math.Max(24d, node.Height / 3d) }, + _ => new ElkPoint { X = boundary.X, Y = node.Y + node.Height + Math.Max(24d, node.Height / 3d) }, + }; + + var projected = ElkShapeBoundaries.ProjectOntoShapeBoundary(node, referencePoint); + return ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(node, projected, referencePoint); + } + private static List? TryBuildShortestOrthogonalPath( ElkPoint start, ElkPoint end, IReadOnlyCollection nodes, string sourceId, string targetId, - ElkPositionedNode? targetNode) + ElkPositionedNode? targetNode, + double obstaclePadding) { var rawObstacles = nodes.Select(node => ( - Left: node.X, - Top: node.Y, - Right: node.X + node.Width, - Bottom: node.Y + node.Height, + Left: node.X - obstaclePadding, + Top: node.Y - obstaclePadding, + Right: node.X + node.Width + obstaclePadding, + Bottom: node.Y + node.Height + obstaclePadding, Id: node.Id)).ToArray(); bool SegmentIsClear(ElkPoint from, ElkPoint to) => @@ -1834,10 +4356,45 @@ internal static class ElkEdgeRouterIterative ElkPoint currentEnd, ElkPositionedNode? targetNode) { - yield return currentEnd; + var endpoints = new List(); + + void AddCandidate(ElkPoint candidate) + { + if (!endpoints.Any(existing => ElkEdgeRoutingGeometry.PointsEqual(existing, candidate))) + { + endpoints.Add(candidate); + } + } + + AddCandidate(currentEnd); if (targetNode is null) { - yield break; + return endpoints; + } + + if (ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + if (ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, "left", start.Y, out var left)) + { + AddCandidate(left); + } + + if (ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, "right", start.Y, out var right)) + { + AddCandidate(right); + } + + if (ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, "top", start.X, out var top)) + { + AddCandidate(top); + } + + if (ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, "bottom", start.X, out var bottom)) + { + AddCandidate(bottom); + } + + return endpoints; } var horizontalInset = Math.Min(24d, Math.Max(12d, targetNode.Width / 4d)); @@ -1868,11 +4425,149 @@ internal static class ElkEdgeRouterIterative foreach (var candidate in candidateEndpoints) { - if (!ElkEdgeRoutingGeometry.PointsEqual(candidate, currentEnd)) + AddCandidate(candidate); + } + + return endpoints; + } + + private static List? TryBuildLocalObstacleSkirtPath( + ElkPoint start, + ElkPoint end, + IReadOnlyCollection nodes, + string sourceId, + string targetId, + ElkPositionedNode? targetNode, + double obstaclePadding) + { + var obstacles = nodes.Select(node => ( + Left: node.X - obstaclePadding, + Top: node.Y - obstaclePadding, + Right: node.X + node.Width + obstaclePadding, + Bottom: node.Y + node.Height + obstaclePadding, + Id: node.Id)).ToArray(); + List? bestPath = null; + var bestScore = double.MaxValue; + + bool SegmentIsClear(ElkPoint from, ElkPoint to) => + !ElkEdgePostProcessor.SegmentCrossesObstacle(from, to, obstacles, sourceId, targetId); + + void ConsiderCandidate(IReadOnlyList rawCandidate) + { + var candidate = NormalizePolyline(rawCandidate); + if (candidate.Count < 2) { - yield return candidate; + return; + } + + for (var i = 1; i < candidate.Count; i++) + { + if (!SegmentIsClear(candidate[i - 1], candidate[i])) + { + return; + } + } + + if (targetNode is not null + && !ElkShapeBoundaries.IsGatewayShape(targetNode) + && HasTargetApproachBacktracking(candidate, targetNode)) + { + return; + } + + var score = ComputePolylineLength(candidate) + (Math.Max(0, candidate.Count - 2) * 4d); + if (score >= bestScore - 0.5d) + { + return; + } + + bestScore = score; + bestPath = candidate; + } + + var horizontalDominant = Math.Abs(end.X - start.X) >= Math.Abs(end.Y - start.Y); + if (horizontalDominant) + { + var targetBridgeX = end.X; + if (targetNode is not null && ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + targetBridgeX = ResolveGatewayRoutingApproachPoint(targetNode, start, end).X; + } + + var minX = Math.Min(start.X, end.X) + 0.5d; + var maxX = Math.Max(start.X, end.X) - 0.5d; + var corridorTop = Math.Min(start.Y, end.Y) - obstaclePadding; + var corridorBottom = Math.Max(start.Y, end.Y) + obstaclePadding; + var bypassYCandidates = new List { start.Y, end.Y }; + foreach (var obstacle in obstacles) + { + if (string.Equals(obstacle.Id, sourceId, StringComparison.Ordinal) + || string.Equals(obstacle.Id, targetId, StringComparison.Ordinal) + || obstacle.Right <= minX + || obstacle.Left >= maxX + || obstacle.Bottom <= corridorTop + || obstacle.Top >= corridorBottom) + { + continue; + } + + AddUniqueCoordinate(bypassYCandidates, obstacle.Top); + AddUniqueCoordinate(bypassYCandidates, obstacle.Bottom); + } + + foreach (var bypassY in bypassYCandidates) + { + ConsiderCandidate( + [ + start, + new ElkPoint { X = start.X, Y = bypassY }, + new ElkPoint { X = targetBridgeX, Y = bypassY }, + end, + ]); } } + else + { + var targetBridgeY = end.Y; + if (targetNode is not null && ElkShapeBoundaries.IsGatewayShape(targetNode)) + { + targetBridgeY = ResolveGatewayRoutingApproachPoint(targetNode, start, end).Y; + } + + var minY = Math.Min(start.Y, end.Y) + 0.5d; + var maxY = Math.Max(start.Y, end.Y) - 0.5d; + var corridorLeft = Math.Min(start.X, end.X) - obstaclePadding; + var corridorRight = Math.Max(start.X, end.X) + obstaclePadding; + var bypassXCandidates = new List { start.X, end.X }; + foreach (var obstacle in obstacles) + { + if (string.Equals(obstacle.Id, sourceId, StringComparison.Ordinal) + || string.Equals(obstacle.Id, targetId, StringComparison.Ordinal) + || obstacle.Bottom <= minY + || obstacle.Top >= maxY + || obstacle.Right <= corridorLeft + || obstacle.Left >= corridorRight) + { + continue; + } + + AddUniqueCoordinate(bypassXCandidates, obstacle.Left); + AddUniqueCoordinate(bypassXCandidates, obstacle.Right); + } + + foreach (var bypassX in bypassXCandidates) + { + ConsiderCandidate( + [ + start, + new ElkPoint { X = bypassX, Y = start.Y }, + new ElkPoint { X = bypassX, Y = targetBridgeY }, + end, + ]); + } + } + + return bestPath; } private static double ComputePolylineLength(IReadOnlyList points) @@ -1886,6 +4581,14 @@ internal static class ElkEdgeRouterIterative return length; } + private static double ResolveMinLineClearance(IReadOnlyCollection nodes) + { + var serviceNodes = nodes.Where(node => node.Kind is not "Start" and not "End").ToArray(); + return serviceNodes.Length > 0 + ? Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d + : 50d; + } + private static bool HasTargetApproachBacktracking( IReadOnlyList path, ElkPositionedNode targetNode) @@ -1979,6 +4682,545 @@ internal static class ElkEdgeRouterIterative return false; } + private static ElkRoutedEdge[] RestoreProtectedRepeatCollectorCorridors( + ElkRoutedEdge[] candidateEdges, + ElkRoutedEdge[] referenceEdges, + ElkPositionedNode[] nodes) + { + if (candidateEdges.Length == 0 || referenceEdges.Length == 0 || nodes.Length == 0) + { + return candidateEdges; + } + + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + var minLineClearance = ResolveMinLineClearance(nodes); + var obstacles = BuildObstacles(nodes, 0d); + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var restoredIds = new List(); + var result = candidateEdges.ToArray(); + + for (var i = 0; i < result.Length && i < referenceEdges.Length; i++) + { + var reference = referenceEdges[i]; + if (!ElkEdgePostProcessor.IsRepeatCollectorLabel(reference.Label) + || !ElkEdgePostProcessor.HasCorridorBendPoints(reference, graphMinY, graphMaxY) + || ElkEdgePostProcessor.HasCorridorBendPoints(result[i], graphMinY, graphMaxY) + || EdgeCrossesNode(reference, obstacles) + || EdgeViolatesNodeClearance(reference, nodes, minLineClearance)) + { + continue; + } + + var restored = reference; + if (nodesById.TryGetValue(reference.SourceNodeId ?? string.Empty, out var sourceNode) + && ElkShapeBoundaries.IsGatewayShape(sourceNode)) + { + restored = AlignProtectedCollectorGatewaySourceExit(restored, sourceNode, graphMinY, graphMaxY); + } + + result[i] = restored; + restoredIds.Add(restored.Id); + } + + return restoredIds.Count > 0 + ? ElkRepeatCollectorCorridors.SeparateSharedLanes(result, nodes, restoredIds) + : result; + } + + private static bool EdgeViolatesNodeClearance( + ElkRoutedEdge edge, + IReadOnlyCollection nodes, + double minLineClearance) + { + if (minLineClearance <= 0d) + { + return false; + } + + foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge)) + { + var horizontal = Math.Abs(segment.Start.Y - segment.End.Y) <= 0.5d; + var vertical = Math.Abs(segment.Start.X - segment.End.X) <= 0.5d; + if (!horizontal && !vertical) + { + continue; + } + + foreach (var node in nodes) + { + if (node.Id == edge.SourceNodeId || node.Id == edge.TargetNodeId) + { + continue; + } + + if (horizontal) + { + var minX = Math.Min(segment.Start.X, segment.End.X); + var maxX = Math.Max(segment.Start.X, segment.End.X); + if (maxX <= node.X || minX >= node.X + node.Width) + { + continue; + } + + var distance = Math.Min( + Math.Abs(segment.Start.Y - node.Y), + Math.Abs(segment.Start.Y - (node.Y + node.Height))); + if (distance > 0.5d && distance < minLineClearance) + { + return true; + } + + continue; + } + + var minY = Math.Min(segment.Start.Y, segment.End.Y); + var maxY = Math.Max(segment.Start.Y, segment.End.Y); + if (maxY <= node.Y || minY >= node.Y + node.Height) + { + continue; + } + + var horizontalDistance = Math.Min( + Math.Abs(segment.Start.X - node.X), + Math.Abs(segment.Start.X - (node.X + node.Width))); + if (horizontalDistance > 0.5d && horizontalDistance < minLineClearance) + { + return true; + } + } + } + + return false; + } + + private static bool EdgeCrossesNode( + ElkRoutedEdge edge, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles) + { + foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge)) + { + if (ElkEdgePostProcessor.SegmentCrossesObstacle( + segment.Start, + segment.End, + obstacles, + edge.SourceNodeId, + edge.TargetNodeId)) + { + return true; + } + } + + return false; + } + + private static ElkRoutedEdge AlignProtectedCollectorGatewaySourceExit( + ElkRoutedEdge edge, + ElkPositionedNode sourceNode, + double graphMinY, + double graphMaxY) + { + var path = new List(); + foreach (var section in edge.Sections) + { + if (path.Count == 0) + { + path.Add(section.StartPoint); + } + + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + } + + if (path.Count < 2) + { + return edge; + } + + var corridorIndex = 1; + for (var i = 1; i < path.Count; i++) + { + if (path[i].Y < graphMinY - 8d || path[i].Y > graphMaxY + 8d) + { + corridorIndex = i; + break; + } + } + + var corridorPoint = path[corridorIndex]; + var boundaryReferences = sourceNode.Kind == "Decision" + ? new[] + { + (BoundaryReference: path[^1], ExitReference: path[^1]), + (BoundaryReference: corridorPoint, ExitReference: corridorPoint), + (BoundaryReference: corridorPoint, ExitReference: path[^1]), + } + : new[] + { + (BoundaryReference: corridorPoint, ExitReference: corridorPoint), + }; + + List? rebuilt = null; + var bestScore = double.PositiveInfinity; + foreach (var (boundaryReference, exitReference) in boundaryReferences) + { + var boundary = ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, boundaryReference); + boundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(sourceNode, boundary, boundaryReference); + var exteriorApproach = ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, boundary, exitReference); + var desiredExitDx = exitReference.X - boundary.X; + var desiredExitDy = exitReference.Y - boundary.Y; + if (Math.Abs(desiredExitDx) >= Math.Abs(desiredExitDy) * 1.15d && Math.Sign(desiredExitDx) != 0) + { + exteriorApproach = new ElkPoint + { + X = desiredExitDx > 0d + ? sourceNode.X + sourceNode.Width + 8d + : sourceNode.X - 8d, + Y = boundary.Y, + }; + } + else if (Math.Abs(desiredExitDy) >= Math.Abs(desiredExitDx) * 1.15d && Math.Sign(desiredExitDy) != 0) + { + exteriorApproach = new ElkPoint + { + X = boundary.X, + Y = desiredExitDy > 0d + ? sourceNode.Y + sourceNode.Height + 8d + : sourceNode.Y - 8d, + }; + } + + var candidate = new List { boundary }; + if (!ElkEdgeRoutingGeometry.PointsEqual(boundary, exteriorApproach)) + { + candidate.Add(exteriorApproach); + } + + if (!ElkEdgeRoutingGeometry.PointsEqual(candidate[^1], corridorPoint)) + { + var corner = BuildOrthogonalCollectorCorner(candidate[^1], corridorPoint); + if (corner is not null && !ElkEdgeRoutingGeometry.PointsEqual(candidate[^1], corner)) + { + candidate.Add(corner); + } + + if (!ElkEdgeRoutingGeometry.PointsEqual(candidate[^1], corridorPoint)) + { + candidate.Add(corridorPoint); + } + } + + for (var i = corridorIndex + 1; i < path.Count; i++) + { + candidate.Add(path[i]); + } + + candidate = NormalizeProtectedCollectorTail(candidate, graphMinY, graphMaxY); + var score = ScoreProtectedCollectorGatewaySourceExitCandidate(candidate, sourceNode, exitReference); + if (score >= bestScore) + { + continue; + } + + bestScore = score; + rebuilt = candidate; + } + + rebuilt ??= NormalizeProtectedCollectorTail(path, graphMinY, graphMaxY); + + return new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + SourcePortId = edge.SourcePortId, + TargetPortId = edge.TargetPortId, + Kind = edge.Kind, + Label = edge.Label, + Sections = + [ + new ElkEdgeSection + { + StartPoint = rebuilt[0], + EndPoint = rebuilt[^1], + BendPoints = rebuilt.Count > 2 + ? rebuilt.Skip(1).Take(rebuilt.Count - 2).ToArray() + : [], + }, + ], + }; + } + + private static double ScoreProtectedCollectorGatewaySourceExitCandidate( + IReadOnlyList path, + ElkPositionedNode sourceNode, + ElkPoint exitReference) + { + var score = 0d; + if (path.Count < 2) + { + return double.PositiveInfinity; + } + + var boundary = path[0]; + var adjacent = path[1]; + var centerX = sourceNode.X + (sourceNode.Width / 2d); + var centerY = sourceNode.Y + (sourceNode.Height / 2d); + var desiredDx = exitReference.X - centerX; + var desiredDy = exitReference.Y - centerY; + var boundaryDx = boundary.X - centerX; + var boundaryDy = boundary.Y - centerY; + var firstDx = adjacent.X - boundary.X; + var firstDy = adjacent.Y - boundary.Y; + const double tolerance = 0.5d; + + var dominantHorizontal = Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.15d && Math.Sign(desiredDx) != 0; + var dominantVertical = Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.15d && Math.Sign(desiredDy) != 0; + if (dominantHorizontal) + { + if (Math.Sign(firstDx) != Math.Sign(desiredDx) || Math.Abs(firstDx) <= tolerance) + { + score += 100_000d; + } + + if (Math.Abs(boundaryDy) > sourceNode.Height * 0.28d) + { + score += 25_000d; + } + + score += Math.Abs(boundaryDy) * 6d; + } + else if (dominantVertical) + { + if (Math.Sign(firstDy) != Math.Sign(desiredDy) || Math.Abs(firstDy) <= tolerance) + { + score += 100_000d; + } + + if (Math.Abs(boundaryDx) > sourceNode.Width * 0.28d) + { + score += 25_000d; + } + + score += Math.Abs(boundaryDx) * 6d; + } + + if (ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, boundary, 8d)) + { + score += 4_000d; + } + + var length = 0d; + for (var i = 1; i < path.Count; i++) + { + length += ElkEdgeRoutingGeometry.ComputeSegmentLength(path[i - 1], path[i]); + } + + return score + + length + + (Math.Max(0, path.Count - 2) * 6d); + } + + private static List NormalizeProtectedCollectorTail( + IReadOnlyList path, + double graphMinY, + double graphMaxY) + { + if (path.Count < 5) + { + return path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + const double corridorTolerance = 8d; + const double coordinateTolerance = 0.5d; + var firstCorridorIndex = -1; + var lastCorridorIndex = -1; + for (var i = 0; i < path.Count; i++) + { + if (path[i].Y < graphMinY - corridorTolerance || path[i].Y > graphMaxY + corridorTolerance) + { + if (firstCorridorIndex < 0) + { + firstCorridorIndex = i; + } + + lastCorridorIndex = i; + } + } + + if (firstCorridorIndex < 0 + || lastCorridorIndex <= firstCorridorIndex + || lastCorridorIndex >= path.Count - 1 + || path[firstCorridorIndex].Y > graphMinY - corridorTolerance) + { + return path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + var desiredDx = path[^1].X - path[0].X; + if (Math.Abs(desiredDx) <= coordinateTolerance) + { + return path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + var preCorridorHorizontalIndex = -1; + for (var i = 1; i < lastCorridorIndex; i++) + { + if (Math.Abs(path[i].Y - path[i + 1].Y) > coordinateTolerance + || Math.Abs(path[i].X - path[i + 1].X) <= coordinateTolerance) + { + continue; + } + + var horizontalDelta = path[i + 1].X - path[i].X; + if (Math.Sign(horizontalDelta) == 0 || Math.Sign(horizontalDelta) == Math.Sign(desiredDx)) + { + continue; + } + + preCorridorHorizontalIndex = i; + break; + } + + if (preCorridorHorizontalIndex >= 0) + { + var rebuiltPrefix = path + .Take(preCorridorHorizontalIndex + 1) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + var rewrittenCorridorY = path[lastCorridorIndex].Y; + var rewrittenReentryPoint = path[lastCorridorIndex + 1]; + + if (Math.Abs(rebuiltPrefix[^1].Y - rewrittenCorridorY) > coordinateTolerance) + { + rebuiltPrefix.Add(new ElkPoint + { + X = rebuiltPrefix[^1].X, + Y = rewrittenCorridorY, + }); + } + + if (Math.Abs(rebuiltPrefix[^1].X - rewrittenReentryPoint.X) > coordinateTolerance) + { + rebuiltPrefix.Add(new ElkPoint + { + X = rewrittenReentryPoint.X, + Y = rewrittenCorridorY, + }); + } + + for (var i = lastCorridorIndex + 1; i < path.Count; i++) + { + rebuiltPrefix.Add(new ElkPoint { X = path[i].X, Y = path[i].Y }); + } + + return NormalizeCollectorPoints(rebuiltPrefix); + } + + var firstHorizontalIndex = -1; + for (var i = firstCorridorIndex; i < lastCorridorIndex; i++) + { + if (Math.Abs(path[i].Y - path[i + 1].Y) <= coordinateTolerance + && Math.Abs(path[i].X - path[i + 1].X) > coordinateTolerance) + { + firstHorizontalIndex = i; + break; + } + } + + if (firstHorizontalIndex < 0) + { + return path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + var firstHorizontalDelta = path[firstHorizontalIndex + 1].X - path[firstHorizontalIndex].X; + if (Math.Sign(firstHorizontalDelta) == 0 || Math.Sign(firstHorizontalDelta) == Math.Sign(desiredDx)) + { + return path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToList(); + } + + var rebuilt = path + .Take(firstCorridorIndex + 1) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + var targetCorridorY = path[lastCorridorIndex].Y; + var reentryPoint = path[lastCorridorIndex + 1]; + + if (Math.Abs(rebuilt[^1].Y - targetCorridorY) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint + { + X = rebuilt[^1].X, + Y = targetCorridorY, + }); + } + + if (Math.Abs(rebuilt[^1].X - reentryPoint.X) > coordinateTolerance) + { + rebuilt.Add(new ElkPoint + { + X = reentryPoint.X, + Y = targetCorridorY, + }); + } + + for (var i = lastCorridorIndex + 1; i < path.Count; i++) + { + rebuilt.Add(new ElkPoint { X = path[i].X, Y = path[i].Y }); + } + + return NormalizeCollectorPoints(rebuilt); + } + + private static ElkPoint? BuildOrthogonalCollectorCorner(ElkPoint from, ElkPoint to) + { + const double tolerance = 0.5d; + if (Math.Abs(from.X - to.X) <= tolerance || Math.Abs(from.Y - to.Y) <= tolerance) + { + return null; + } + + return Math.Abs(to.Y - from.Y) >= Math.Abs(to.X - from.X) + ? new ElkPoint { X = from.X, Y = to.Y } + : new ElkPoint { X = to.X, Y = from.Y }; + } + + private static List NormalizeCollectorPoints(IReadOnlyList points) + { + const double coordinateTolerance = 0.5d; + var deduped = new List(); + foreach (var point in points) + { + if (deduped.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(deduped[^1], point)) + { + deduped.Add(point); + } + } + + if (deduped.Count <= 2) + { + return deduped; + } + + var simplified = new List { deduped[0] }; + for (var i = 1; i < deduped.Count - 1; i++) + { + var previous = simplified[^1]; + var current = deduped[i]; + var next = deduped[i + 1]; + var sameX = Math.Abs(previous.X - current.X) <= coordinateTolerance + && Math.Abs(current.X - next.X) <= coordinateTolerance; + var sameY = Math.Abs(previous.Y - current.Y) <= coordinateTolerance + && Math.Abs(current.Y - next.Y) <= coordinateTolerance; + if (!sameX && !sameY) + { + simplified.Add(current); + } + } + + simplified.Add(deduped[^1]); + return simplified; + } + private static List NormalizePolyline(IReadOnlyList points) { var result = new List(points.Count); @@ -2013,6 +5255,19 @@ internal static class ElkEdgeRouterIterative return collapsed; } + private static void AddUniqueCoordinate(ICollection coordinates, double candidate, double tolerance = 0.5d) + { + foreach (var coordinate in coordinates) + { + if (Math.Abs(coordinate - candidate) <= tolerance) + { + return; + } + } + + coordinates.Add(candidate); + } + private static (double Left, double Top, double Right, double Bottom, string Id)[] BuildObstacles( ElkPositionedNode[] nodes, double margin) @@ -2026,6 +5281,96 @@ internal static class ElkEdgeRouterIterative )).ToArray(); } + private static ElkRoutedEdge[] ClampBelowGraphEdges( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + IReadOnlyCollection? restrictedEdgeIds = null) + { + if (edges.Length == 0 || nodes.Length == 0) + { + return edges; + } + + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + var limitY = graphMaxY + 4d; + var obstacles = BuildObstacles(nodes, 0d); + var restrictedSet = restrictedEdgeIds is null + ? null + : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); + var result = edges.ToArray(); + + for (var i = 0; i < result.Length; i++) + { + var edge = result[i]; + if (restrictedSet is not null && !restrictedSet.Contains(edge.Id)) + { + continue; + } + + var path = ExtractPath(edge) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + if (path.Count < 3) + { + continue; + } + + var changed = false; + for (var pointIndex = 1; pointIndex < path.Count - 1; pointIndex++) + { + if (path[pointIndex].Y <= limitY) + { + continue; + } + + path[pointIndex] = new ElkPoint + { + X = path[pointIndex].X, + Y = limitY, + }; + changed = true; + } + + if (!changed) + { + continue; + } + + var normalized = NormalizePolyline(path); + var candidate = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + SourcePortId = edge.SourcePortId, + TargetPortId = edge.TargetPortId, + Kind = edge.Kind, + Label = edge.Label, + Sections = + [ + new ElkEdgeSection + { + StartPoint = normalized[0], + EndPoint = normalized[^1], + BendPoints = normalized.Count > 2 + ? normalized.Skip(1).Take(normalized.Count - 2).ToArray() + : [], + }, + ], + }; + + if (EdgeCrossesNode(candidate, obstacles)) + { + continue; + } + + result[i] = candidate; + } + + return result; + } + private static IterativeRoutingConfig ResolveConfig(ElkLayoutOptions layoutOptions) { var requested = layoutOptions.IterativeRouting ?? new IterativeRoutingOptions(); @@ -2033,7 +5378,7 @@ internal static class ElkEdgeRouterIterative return new IterativeRoutingConfig( Enabled: enabled, - MaxAdaptationsPerStrategy: Math.Max(1, requested.MaxAdaptationsPerStrategy), + MaxAdaptationsPerStrategy: Math.Clamp(requested.MaxAdaptationsPerStrategy, 1, 100), RequiredValidSolutions: Math.Max(1, requested.RequiredValidSolutions), ObstacleMargin: 18d); } @@ -2059,9 +5404,16 @@ internal static class ElkEdgeRouterIterative int[] EdgeIndices, string[] EdgeIds, string[] PreferredShortestEdgeIds, + string[] RouteRepairEdgeIds, string[] Reasons); private sealed record RouteAllEdgesResult( ElkRoutedEdge[] Edges, ElkIterativeRouteDiagnostics Diagnostics); + + private sealed record RepairEdgeBuildResult( + ElkRoutedEdge Edge, + int RoutedSections, + int FallbackSections, + bool WasSkipped); }