Refactor ElkSharp routing sources into partial modules
This commit is contained in:
@@ -147,6 +147,19 @@ Completion criteria:
|
||||
- [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
|
||||
|
||||
### TASK-011 - Decompose oversized ElkSharp sources
|
||||
Status: DONE
|
||||
Dependency: TASK-010
|
||||
Owners: Implementer
|
||||
Task description:
|
||||
Split the oversized active ElkSharp implementation files and the directly coupled Elk-specific renderer test files into concern-based partial classes so the routing logic stays behaviorally stable while the code tree becomes easier to navigate and extend.
|
||||
|
||||
Completion criteria:
|
||||
- [x] `ElkEdgePostProcessor` is decomposed into concern-based partial files under `src/__Libraries/StellaOps.ElkSharp/`
|
||||
- [x] `ElkEdgeRouterIterative` is decomposed into concern-based partial files under `src/__Libraries/StellaOps.ElkSharp/`
|
||||
- [x] The oversized Elk-specific renderer tests are decomposed into partial classes without renaming tests
|
||||
- [x] Elk-focused validation is rerun and the regenerated document-processing artifact is visually reviewed for regressions
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
@@ -163,6 +176,9 @@ Completion criteria:
|
||||
| 2026-03-23 | Re-enabled highway processing, added a blocking `TargetApproachJoinViolations` rule with maximum score penalty to stop non-applicable shared arrival rails from being silently selected, updated variant artifact labels to expose the new metric, and re-ran `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, 87s). The best fallback render improved the `End`-side collapse from 4 join violations in baseline to 1 remaining join violation, while keeping `FinalBrokenShortHighwayCount=0`. | Implementer |
|
||||
| 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-26 | Tightened the long-diagonal rule from two average node-shape lengths to one average node-shape length, mirrored the renderer artifact helper to the same threshold, added a direct `Evaluation Condition -> Internal Discussion` scoring regression, and revalidated the focused ElkSharp renderer tests. The full document-processing artifact rerun cleared long-diagonal and target-join offenders, but the selected layout still fails on a separate shared-lane pair (`edge/9+edge/22`). | Implementer |
|
||||
| 2026-03-26 | Added a final winner-refinement boundary-slot restabilization pass so late shared-lane cleanup cannot pull decision-source exits back off the discrete slot lattice, added a focused document-processing layout regression for boundary-slot pressure, and updated the ElkSharp docs to record the terminal slot re-snap requirement. | Implementer |
|
||||
| 2026-03-26 | Tightened the node-side slot lattice into a strict terminal rule: singleton source/target endpoints now must center on their resolved face slot, preserved repeat/corridor exits are no longer exempt from the final slot snap, and strict slot repairs may bypass the generic shared-lane validator when the centered repair is still obstacle-safe and boundary-valid. Revalidated with `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~BoundarySlotHelpers_When" -v minimal` (`5/5`). | 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 |
|
||||
@@ -175,10 +191,23 @@ Completion criteria:
|
||||
| 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 |
|
||||
| 2026-03-26 | Tightened non-gateway target backtracking so a short orthogonal hook that reaches the boundary for less than one node depth is treated as a forbidden fake side entry, then added a focused `Load Configuration -> Setting configParameters` regression. | Implementer |
|
||||
| 2026-03-26 | Extended the same short-hook rule to gateway targets after `Internal Notification -> Has Recipients` still approached the decision from the left and only changed direction in the last few pixels. Gateway target repair/acceptance now rejects those fake face joins, and the focused workflow regression covers the decision-target case. | Implementer |
|
||||
| 2026-03-26 | Closed the remaining `Load Configuration -> End` detour follow-up by letting local obstacle-skirt repair reuse interior axes from the current route and try a zero-clearance fallback before keeping a high preserved overshoot. Revalidated with `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~ShortcutHelpers_WhenRectSourceCanPreserveRectTargetTopEntry_ShouldClearExcessiveDetour" -v minimal` (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, 2m24s). | Implementer |
|
||||
| 2026-03-26 | Added a discrete node-side slot lattice so one input/output cannot silently stack on the same boundary point: gateway faces now resolve to `1` centered slot or `2` centered slots, rectangular `left`/`right` faces to at most `3` evenly spread slots, and rectangular `top`/`bottom` faces to at most `5` evenly spread slots. Wired the same capacity/assignment logic through scoring, highway/join detection, source-target slot repair, and mixed-face alternate-side repair, then revalidated `BoundarySlotHelpers_WhenNodeKindsResolveSideCapacities_ShouldSpreadSlotsEvenly`, `GatewayBoundaryHelpers_WhenGatewayJoinReceivesDirectAndElbowedLeftArrivals_ShouldSpreadTargetSlots`, `MixedNodeFaceHelpers_WhenMixedTopFaceEntriesAreOffLatticeWithoutLaneConflict_ShouldSnapToDiscreteSlots`, and `SourceDepartureHelpers_WhenOutgoingEdgesShareTheSameDepartureLane_ShouldSpreadOnlyTheConflictingPeer` (`4/4`). | Implementer |
|
||||
| 2026-03-27 | Reconciled gateway-source scoring with the resolved discrete slot lattice so late winner restabilization no longer re-flags compliant singleton gateway exits. Revalidated `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~BoundarySlotHelpers_WhenDecisionSourceSlotsNeedLateRestabilization_ShouldRepairGatewayExitsAndDefaultDetours" -v minimal` (1/1) and `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~BoundarySlotHelpers_When" -v minimal` (8/8). Fresh document-processing end-to-end reruns still stalled after entering the ElkSharp layout path and did not emit new progress-log updates or fresh artifacts, so full artifact revalidation remains pending. | Implementer |
|
||||
| 2026-03-28 | Added focused regressions for the remaining `edge/15`/`edge/35` target-join collapse and `edge/3`/`edge/4` shared-lane collapse, then updated final restabilization to preserve a direct shared-lane repair when it lowers shared-lane violations without increasing node crossings. Trimmed the compact terminal-closure path and the winner fast-terminal candidate builder down to local-only passes after live `elksharp.progress.log` inspection showed those supposedly cheap branches were still doing heavyweight work. Revalidated `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~FinalRestabilization_WhenForkDeparturesIntoProcessAndJoinShareALane_ShouldKeepTheSharedLaneRepair|FullyQualifiedName~FinalRestabilization_WhenRepeatRightFaceTerminalRailCollapses_ShouldKeepTheJoinRepair|FullyQualifiedName~SharedLaneHelpers_WhenForkDeparturesIntoProcessAndJoinShareALane_ShouldSeparateThem|FullyQualifiedName~TargetApproachHelpers_WhenRepeatRightFaceReturnsShareTerminalRail_ShouldPushOneArrivalFartherOut" -v minimal` (`4/4`, about `2s`). Full `DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings` revalidation is still running; the updated logs now pass the previous pseudo-hang point and reach the final winner fast-terminal focus instead of stalling before it. | Implementer |
|
||||
|
||||
| 2026-03-28 | Decomposed `ElkEdgePostProcessor`, `ElkEdgeRouterIterative`, `ElkSharpEdgeRefinementTests`, and `DocumentProcessingWorkflowRenderingTests` into partial files. Rebuilt the Elk renderer test project successfully via `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj -v minimal`, regenerated the document-processing artifact set under `bin/Debug/net10.0/TestResults/workflow-renderings/20260328/DocumentProcessingWorkflow/`, and visually reviewed `elksharp.png` with no obvious regression from the file split. Follow-up focused `dotnet test` runs hit the existing long-lived `testhost` hang after artifact generation, so the validation evidence for this refactor is the clean rebuild plus the fresh artifact/log outputs rather than a new full-suite completion record. | 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-26 follow-up: the `Load Configuration -> Setting configParameters` edge exposed a different blind spot. The rectangular target-entry rule only validated the final boundary angle and true overshoot, so a `6px` vertical stub into the bottom face still passed as a valid `90`-degree entry. Non-gateway target backtracking now also rejects short orthogonal hooks whose final boundary-depth is less than one node shape depth, which lets the existing backtracking endpoint normalizer move the edge onto the honest side face instead of preserving the fake bottom join.
|
||||
- 2026-03-26 gateway follow-up: `Internal Notification -> Has Recipients` exposed the same cheat on a decision target. Gateway target repair previously enforced only polygon-boundary contact and a valid final angle, so a long horizontal approach plus a tiny vertical drop into the diamond still passed. Gateway target validation now rejects short orthogonal last-moment hooks as invalid face joins as well. Updated docs: `docs/workflow/ENGINE.md`.
|
||||
- 2026-03-26 detour follow-up: `Load Configuration -> End` exposed a separate shortest-path blind spot after the fake entry hooks were gone. Local obstacle-skirt repair sampled only expanded-obstacle edges, so it skipped the already-safe interior lane at `Y=216.59` and preserved a much higher overshoot instead. The repair now reuses usable interior axes from the current path and tries a zero-clearance fallback before settling on a preserved detour. Updated docs: `docs/workflow/ENGINE.md`, `src/__Libraries/StellaOps.ElkSharp/AGENTS.md`.
|
||||
- 2026-03-26 slot-lattice follow-up: node-boundary spreading had remained heuristic, so repeated source/target/mixed-face repairs could leave multiple edges effectively concentrated on the same face point even after join and lane conflicts were removed. Boundary-slot assignment is now explicit and shared across scoring and repair: gateways use `1` or `2` centered face slots, rectangular `left`/`right` faces use at most `3`, rectangular `top`/`bottom` faces use at most `5`, and no repair is accepted unless each edge lands on its assigned realizable slot. Updated docs: `docs/workflow/ENGINE.md`, `src/__Libraries/StellaOps.ElkSharp/AGENTS.md`.
|
||||
- 2026-03-26 winner-refinement follow-up: boundary-slot assignment was correct inside the terminal repair passes, but the extra winning-solution shared-lane polish could still move decision-source exits off their assigned face slots after a valid candidate had already been chosen. Winner refinement now ends with a slot-restabilization pass that re-snaps the final selected edges before return. Updated docs: `docs/workflow/ENGINE.md`, `src/__Libraries/StellaOps.ElkSharp/AGENTS.md`.
|
||||
- 2026-03-26 strict-slot follow-up: the earlier lattice still treated singleton entries and preserved repeat/corridor exits as effectively advisory, so the scorer could report concentrated/off-center endpoints that the final slot snap would skip. Final boundary-slot repair now uses the same all-endpoint lattice as scoring, with side-specific port exemptions only, and it accepts centered slot repairs when they remain obstacle-safe and boundary-valid even if the generic shared-lane validator is too conservative. Updated docs: `docs/workflow/ENGINE.md`, `src/__Libraries/StellaOps.ElkSharp/AGENTS.md`.
|
||||
- 2026-03-27 gateway-source follow-up: once singleton entries and exits were scored against the same centered lattice, gateway-source scoring still treated some final slotted singleton exits as invalid because it re-applied generic near-vertex / preferred-face heuristics after the slot resolver had already chosen the realizable boundary landing. Gateway-source scoring now defers to the resolved source-slot assignment for boundary-slot-compliant singleton gateway exits, while keeping hard backtracking defects blocking. Focused slot regressions are green again, but the document-processing end-to-end renderer still needs a clean non-stalled rerun before the artifact can be treated as fully revalidated.
|
||||
- 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.
|
||||
@@ -206,10 +235,14 @@ Completion criteria:
|
||||
- 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.
|
||||
- Long 45-degree segments are now capped at one average node-shape length instead of two. The scoring helper and the artifact-side offender detector use the same threshold so visually long diagonals cannot survive scoring while slipping past the renderer assertions.
|
||||
- Tightening the diagonal cap changes candidate selection pressure in the full document-processing artifact. The current worktree clears the long-diagonal and gateway-target join regressions, but the selected layout still exposes a separate shared-lane conflict (`edge/9+edge/22`) that needs another local repair pass before the full artifact test is green again.
|
||||
- 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.
|
||||
- 2026-03-28 runtime follow-up: the remaining slowdown was no longer in broad winner refinement. Live `elksharp.progress.log` traces showed the expensive branch had collapsed to the final winner fast-terminal focus `[edge/15, edge/3, edge/35, edge/4]`, where both the compact terminal-closure helper and the fast-terminal candidate builder were still doing heavyweight terminal/boundary work. Both paths are now restricted to local-only passes; focused geometry regressions are green again, but the full document-processing artifact still needs a completed rerun before the runtime drop and final artifact quality can be treated as revalidated.
|
||||
- 2026-03-28 decomposition follow-up: active ElkSharp code is now split into concern-based partials, and the regenerated document-processing artifact still renders cleanly after the move. The remaining validation nuisance is harness-related rather than routing-related: the renderer test host can stay resident after the artifact is written, so fresh decomposition evidence is the successful project rebuild, a new `elksharp.progress.log` ending with `ElkSharp layout optimize returned`, and visual review of the regenerated `elksharp.png`.
|
||||
- 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.
|
||||
|
||||
@@ -0,0 +1,929 @@
|
||||
using System.Text.Json;
|
||||
using System.Reflection;
|
||||
using NUnit.Framework;
|
||||
|
||||
using StellaOps.ElkSharp;
|
||||
using StellaOps.Workflow.Abstractions;
|
||||
using StellaOps.Workflow.Renderer.ElkSharp;
|
||||
using StellaOps.Workflow.Renderer.Svg;
|
||||
|
||||
namespace StellaOps.Workflow.Renderer.Tests;
|
||||
|
||||
public partial class DocumentProcessingWorkflowRenderingTests
|
||||
{
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLayoutOnly_ShouldProduceFinitePositions()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
Assert.That(layout.Nodes.Count, Is.EqualTo(24));
|
||||
Assert.That(layout.Edges.Count, Is.EqualTo(36));
|
||||
Assert.That(layout.Nodes.All(n => double.IsFinite(n.X) && double.IsFinite(n.Y)), Is.True);
|
||||
var serviceNodes = graph.Nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
|
||||
var expectedGridX = Math.Max(64d, Math.Round(serviceNodes.Average(node => node.Width) / 8d) * 8d);
|
||||
var expectedGridY = Math.Max(48d, Math.Round(serviceNodes.Average(node => node.Height) / 8d) * 8d);
|
||||
var edgeDensityFactor = Math.Min(1.8d, 1d + (Math.Max(0, graph.Edges.Count - 15) * 0.02d));
|
||||
var expectedNodeSpacing = Math.Max(40d * edgeDensityFactor, expectedGridY * 0.4d);
|
||||
var edgeDensitySpacingFactor = 0.92d + (Math.Max(0d, edgeDensityFactor - 1d) * 0.35d);
|
||||
var expectedLayerSpacing = Math.Max(60d * Math.Min(1.15d, edgeDensitySpacingFactor), expectedGridX * 0.45d);
|
||||
var visibleNodes = layout.Nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
|
||||
var distinctLayerXs = visibleNodes
|
||||
.Select(node => node.X)
|
||||
.Distinct()
|
||||
.OrderBy(x => x)
|
||||
.ToArray();
|
||||
var minLayerGap = distinctLayerXs.Zip(distinctLayerXs.Skip(1), (left, right) => right - left).DefaultIfEmpty(double.MaxValue).Min();
|
||||
var minInLayerGap = visibleNodes
|
||||
.GroupBy(node => node.X)
|
||||
.Select(group =>
|
||||
{
|
||||
var ordered = group.OrderBy(node => node.Y).ToArray();
|
||||
if (ordered.Length < 2)
|
||||
{
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
return ordered
|
||||
.Zip(ordered.Skip(1), (upper, lower) => lower.Y - (upper.Y + upper.Height))
|
||||
.Min();
|
||||
})
|
||||
.DefaultIfEmpty(double.MaxValue)
|
||||
.Min();
|
||||
Assert.That(
|
||||
minLayerGap,
|
||||
Is.GreaterThanOrEqualTo(expectedLayerSpacing - 1d),
|
||||
$"Layer spacing should honor the placement grid scale (~{expectedGridX:F0}px average width).");
|
||||
Assert.That(
|
||||
minInLayerGap,
|
||||
Is.GreaterThanOrEqualTo(expectedNodeSpacing - 1d),
|
||||
$"In-layer node spacing should honor the placement grid scale (~{expectedGridY:F0}px average height).");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotBacktrackIntoCheckResult()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var targetNode = layout.Nodes.Single(node => node.Id == "start/2/branch-1/1/body/5");
|
||||
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/7");
|
||||
|
||||
Assert.That(
|
||||
HasTargetApproachBacktracking(edge, targetNode),
|
||||
Is.False,
|
||||
"Execute Batch -> Check Result must not overshoot the target side and curl back near the final approach.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldUseHorizontalSidesBetweenConfigParametersAndEvaluateConditions()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/36");
|
||||
var sourceNode = layout.Nodes.Single(node => node.Id == edge.SourceNodeId);
|
||||
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
|
||||
var path = FlattenPath(edge);
|
||||
|
||||
Assert.That(path.Count, Is.GreaterThanOrEqualTo(2));
|
||||
Assert.That(
|
||||
ResolveBoundarySide(path[0], sourceNode),
|
||||
Is.EqualTo("right"),
|
||||
"Setting configParameters should leave from its east side toward Evaluate Conditions.");
|
||||
Assert.That(
|
||||
ResolveBoundarySide(path[^1], targetNode),
|
||||
Is.EqualTo("left"),
|
||||
"Evaluate Conditions should be reached from its west side when the horizontal shortcut is clear.");
|
||||
var shortestDirectLength = 0d;
|
||||
for (var i = 1; i < path.Count; i++)
|
||||
{
|
||||
shortestDirectLength += Math.Abs(path[i].X - path[i - 1].X) + Math.Abs(path[i].Y - path[i - 1].Y);
|
||||
}
|
||||
|
||||
var boundaryDirectLength = Math.Abs(path[^1].X - path[0].X) + Math.Abs(path[^1].Y - path[0].Y);
|
||||
Assert.That(
|
||||
path.Min(point => point.Y),
|
||||
Is.GreaterThanOrEqualTo(Math.Min(sourceNode.Y, targetNode.Y) - 24d),
|
||||
"Setting configParameters -> Evaluate Conditions must not detour north when a direct east-to-west shortcut is clear.");
|
||||
Assert.That(
|
||||
shortestDirectLength,
|
||||
Is.LessThanOrEqualTo(boundaryDirectLength + 48d),
|
||||
"Setting configParameters -> Evaluate Conditions must stay close to the direct boundary-to-boundary shortcut.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotFakeBottomEntryIntoSettingConfigParameters()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var edge = layout.Edges.Single(routedEdge =>
|
||||
routedEdge.SourceNodeId == "start/3"
|
||||
&& routedEdge.TargetNodeId == "start/4/batched");
|
||||
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
|
||||
var path = FlattenPath(edge);
|
||||
|
||||
Assert.That(
|
||||
HasTargetApproachBacktracking(edge, targetNode),
|
||||
Is.False,
|
||||
"Load Configuration -> Setting configParameters must not use a tiny orthogonal hook to fake a bottom-side entry.");
|
||||
Assert.That(path.Count, Is.GreaterThanOrEqualTo(2));
|
||||
Assert.That(
|
||||
ResolveBoundarySide(path[^1], targetNode),
|
||||
Is.EqualTo("left"),
|
||||
"Load Configuration -> Setting configParameters should enter from the west side once short fake bottom hooks are forbidden.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotFakeTopEntryIntoHasRecipients()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var edge = layout.Edges.Single(routedEdge =>
|
||||
routedEdge.SourceNodeId == "start/9/true/1"
|
||||
&& routedEdge.TargetNodeId == "start/9/true/2");
|
||||
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
|
||||
var path = FlattenPath(edge);
|
||||
|
||||
Assert.That(
|
||||
HasTargetApproachBacktracking(edge, targetNode),
|
||||
Is.False,
|
||||
"Internal Notification -> Has Recipients must not stay horizontal to the gateway and then drop a tiny vertical stub into the target face.");
|
||||
Assert.That(path.Count, Is.GreaterThanOrEqualTo(3));
|
||||
Assert.That(
|
||||
HasShortGatewayTargetOrthogonalHook(path, targetNode),
|
||||
Is.False,
|
||||
"Internal Notification -> Has Recipients must not change into the gateway boundary direction within less than one node depth of the target.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepLocalRepeatReturnsAboveTheNodeField()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/35");
|
||||
var sourceNode = layout.Nodes.Single(node => node.Id == edge.SourceNodeId);
|
||||
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
|
||||
var path = FlattenPath(edge);
|
||||
var maxAllowedY = Math.Max(sourceNode.Y + sourceNode.Height, targetNode.Y + targetNode.Height) + 40d;
|
||||
|
||||
Assert.That(
|
||||
path.Max(point => point.Y),
|
||||
Is.LessThanOrEqualTo(maxAllowedY),
|
||||
"Local repeat-return lanes must not drop into a lower detour band when an upper return is available.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepDecisionSourceExitsOnDiscreteBoundarySlots()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var elkNodes = layout.Nodes.Select(ToElkNode).ToArray();
|
||||
var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge
|
||||
{
|
||||
Id = edge.Id,
|
||||
SourceNodeId = edge.SourceNodeId,
|
||||
TargetNodeId = edge.TargetNodeId,
|
||||
Kind = edge.Kind,
|
||||
Label = edge.Label,
|
||||
Sections = edge.Sections.Select(section => new ElkEdgeSection
|
||||
{
|
||||
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
|
||||
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
|
||||
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
|
||||
}).ToArray(),
|
||||
}).ToArray();
|
||||
var severityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var count = ElkEdgeRoutingScoring.CountBoundarySlotViolations(elkEdges, elkNodes, severityByEdgeId, 1);
|
||||
|
||||
Assert.That(
|
||||
count,
|
||||
Is.EqualTo(0),
|
||||
$"Selected layout must keep decision source exits on the discrete boundary-slot lattice after winner refinement. Offenders: {string.Join(", ", severityByEdgeId.OrderBy(entry => entry.Key, StringComparer.Ordinal).Select(entry => entry.Key))}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("RenderingArtifacts")]
|
||||
public async Task DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var outputDir = Path.Combine(
|
||||
Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!,
|
||||
"TestResults", "workflow-renderings", DateTime.Today.ToString("yyyyMMdd"), "DocumentProcessingWorkflow");
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
using var diagnosticsCapture = ElkLayoutDiagnostics.BeginCapture();
|
||||
var progressLogPath = Path.Combine(outputDir, "elksharp.progress.log");
|
||||
if (File.Exists(progressLogPath))
|
||||
{
|
||||
File.Delete(progressLogPath);
|
||||
}
|
||||
|
||||
var diagnosticsPath = Path.Combine(outputDir, "elksharp.refinement-diagnostics.json");
|
||||
if (File.Exists(diagnosticsPath))
|
||||
{
|
||||
File.Delete(diagnosticsPath);
|
||||
}
|
||||
|
||||
diagnosticsCapture.Diagnostics.ProgressLogPath = progressLogPath;
|
||||
diagnosticsCapture.Diagnostics.SnapshotPath = diagnosticsPath;
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var svgRenderer = new WorkflowRenderSvgRenderer();
|
||||
var svgDoc = svgRenderer.Render(layout, "DocumentProcessingWorkflow [ElkSharp]");
|
||||
|
||||
var svgPath = Path.Combine(outputDir, "elksharp.svg");
|
||||
await File.WriteAllTextAsync(svgPath, svgDoc.Svg);
|
||||
|
||||
var jsonPath = Path.Combine(outputDir, "elksharp.json");
|
||||
await File.WriteAllTextAsync(jsonPath, JsonSerializer.Serialize(layout, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
diagnosticsPath,
|
||||
JsonSerializer.Serialize(diagnosticsCapture.Diagnostics, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
WorkflowRenderPngExporter? pngExporter = null;
|
||||
string? pngPath = null;
|
||||
try
|
||||
{
|
||||
pngPath = Path.Combine(outputDir, "elksharp.png");
|
||||
pngExporter = new WorkflowRenderPngExporter();
|
||||
await pngExporter.ExportAsync(svgDoc, pngPath, scale: 2f);
|
||||
TestContext.Out.WriteLine($"PNG generated at: {pngPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Out.WriteLine($"PNG export failed (non-fatal): {ex.Message}");
|
||||
TestContext.Out.WriteLine($"SVG available at: {svgPath}");
|
||||
}
|
||||
|
||||
TestContext.Out.WriteLine($"SVG: {svgPath}");
|
||||
TestContext.Out.WriteLine($"JSON: {jsonPath}");
|
||||
TestContext.Out.WriteLine($"Diagnostics: {diagnosticsPath}");
|
||||
TestContext.Out.WriteLine($"Progress log: {progressLogPath}");
|
||||
|
||||
// Render every iteration of every strategy as SVG only
|
||||
var variantsDir = Path.Combine(outputDir, "strategy-variants");
|
||||
Directory.CreateDirectory(variantsDir);
|
||||
foreach (var stratDiag in diagnosticsCapture.Diagnostics.IterativeStrategies)
|
||||
{
|
||||
foreach (var attemptDiag in stratDiag.AttemptDetails)
|
||||
{
|
||||
if (attemptDiag.Edges is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var attemptLayout = BuildVariantLayout(layout, attemptDiag.Edges);
|
||||
var sc = attemptDiag.Score;
|
||||
var attemptLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} att{attemptDiag.Attempt} [{attemptDiag.Outcome}] " +
|
||||
$"nc={sc.NodeCrossings} ec={sc.EdgeCrossings} bends={sc.BendCount} diag={sc.DiagonalCount} " +
|
||||
$"bg={sc.BelowGraphViolations} un={sc.UnderNodeViolations} ld={sc.LongDiagonalViolations} " +
|
||||
$"ea={sc.EntryAngleViolations} lbl={sc.LabelProximityViolations} tj={sc.TargetApproachJoinViolations} " +
|
||||
$"sl={sc.SharedLaneViolations} bs={sc.BoundarySlotViolations} " +
|
||||
$"tb={sc.TargetApproachBacktrackingViolations} det={sc.ExcessiveDetourViolations} score={sc.Value:F0}";
|
||||
var attemptSvg = svgRenderer.Render(attemptLayout, attemptLabel);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-att{attemptDiag.Attempt:D2}.svg"),
|
||||
attemptSvg.Svg);
|
||||
}
|
||||
|
||||
if (stratDiag.BestEdges is not null)
|
||||
{
|
||||
var bestLayout = BuildVariantLayout(layout, stratDiag.BestEdges);
|
||||
var bestSc = stratDiag.BestScore;
|
||||
var bestLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} BEST [{stratDiag.Outcome}] " +
|
||||
$"nc={bestSc?.NodeCrossings} ec={bestSc?.EdgeCrossings} bends={bestSc?.BendCount} diag={bestSc?.DiagonalCount} " +
|
||||
$"bg={bestSc?.BelowGraphViolations} un={bestSc?.UnderNodeViolations} ld={bestSc?.LongDiagonalViolations} " +
|
||||
$"ea={bestSc?.EntryAngleViolations} lbl={bestSc?.LabelProximityViolations} tj={bestSc?.TargetApproachJoinViolations} " +
|
||||
$"sl={bestSc?.SharedLaneViolations} bs={bestSc?.BoundarySlotViolations} " +
|
||||
$"tb={bestSc?.TargetApproachBacktrackingViolations} det={bestSc?.ExcessiveDetourViolations} score={bestSc?.Value:F0}";
|
||||
var bestSvg = svgRenderer.Render(bestLayout, bestLabel);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-BEST.svg"),
|
||||
bestSvg.Svg);
|
||||
}
|
||||
}
|
||||
|
||||
TestContext.Out.WriteLine($"Strategy variants: {variantsDir}");
|
||||
|
||||
var localRepairAttempts = diagnosticsCapture.Diagnostics.IterativeStrategies
|
||||
.SelectMany(strategy => strategy.AttemptDetails)
|
||||
.Where(attempt => attempt.Attempt > 1
|
||||
&& string.Equals(attempt.RouteDiagnostics?.Mode, "local-repair", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
Assert.That(localRepairAttempts, Is.Not.Empty, "Expected later attempts to use targeted local repair.");
|
||||
Assert.That(
|
||||
localRepairAttempts.All(attempt => attempt.RouteDiagnostics!.RoutedEdges < attempt.RouteDiagnostics.TotalEdges),
|
||||
Is.True,
|
||||
"Local repair attempts must reroute only the penalized subset of edges.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalBrokenShortHighwayCount, Is.EqualTo(0), "Final selected layout must not keep broken short highways.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.RepeatCollectorCorridorViolations, Is.EqualTo(0), "Repeat collector outer lanes must remain separated.");
|
||||
var boundaryAngleOffenders = layout.Edges
|
||||
.SelectMany(edge => GetBoundaryAngleViolations(edge, layout.Nodes))
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"Boundary angle offenders: {(boundaryAngleOffenders.Length == 0 ? "<none>" : string.Join(", ", boundaryAngleOffenders))}");
|
||||
var targetJoinOffenders = GetTargetApproachJoinOffenders(layout.Edges, layout.Nodes).ToArray();
|
||||
TestContext.Out.WriteLine($"Target join offenders: {(targetJoinOffenders.Length == 0 ? "<none>" : string.Join(", ", targetJoinOffenders))}");
|
||||
var elkNodes = layout.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 = layout.Edges.Select(edge => new ElkRoutedEdge
|
||||
{
|
||||
Id = edge.Id,
|
||||
SourceNodeId = edge.SourceNodeId,
|
||||
TargetNodeId = edge.TargetNodeId,
|
||||
Kind = edge.Kind,
|
||||
Label = edge.Label,
|
||||
Sections = edge.Sections.Select(section => new ElkEdgeSection
|
||||
{
|
||||
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
|
||||
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
|
||||
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
|
||||
}).ToArray(),
|
||||
}).ToArray();
|
||||
var sharedLaneOffenders = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
|
||||
.Select(conflict => $"{conflict.LeftEdgeId}+{conflict.RightEdgeId}")
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"Shared lane offenders: {(sharedLaneOffenders.Length == 0 ? "<none>" : string.Join(", ", sharedLaneOffenders))}");
|
||||
var belowGraphOffenders = GetBelowGraphOffenders(layout.Edges, layout.Nodes).ToArray();
|
||||
TestContext.Out.WriteLine($"Below-graph offenders: {(belowGraphOffenders.Length == 0 ? "<none>" : string.Join(", ", belowGraphOffenders))}");
|
||||
var underNodeOffenders = GetUnderNodeOffenders(layout.Edges, layout.Nodes).ToArray();
|
||||
TestContext.Out.WriteLine($"Under-node offenders: {(underNodeOffenders.Length == 0 ? "<none>" : string.Join(", ", underNodeOffenders))}");
|
||||
var longDiagonalOffenders = GetLongDiagonalOffenders(layout.Edges, layout.Nodes).ToArray();
|
||||
TestContext.Out.WriteLine($"Long-diagonal offenders: {(longDiagonalOffenders.Length == 0 ? "<none>" : string.Join(", ", longDiagonalOffenders))}");
|
||||
var gatewaySourceScoringOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceScoringIssue(edge, layout.Nodes))
|
||||
.Select(edge => edge.Id)
|
||||
.ToArray();
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.EntryAngleViolations, Is.EqualTo(0), "Selected layout must satisfy the node-side entry/exit angle rule.");
|
||||
Assert.That(targetJoinOffenders, Is.Empty, "Selected layout must not leave visually collapsed target-side approach joins.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachJoinViolations, Is.EqualTo(0), "Selected layout must not keep disallowed target-side joins.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.SharedLaneViolations, Is.EqualTo(0), "Selected layout must not keep same-lane occupancy outside explicit corridor/highway exceptions.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.BoundarySlotViolations, Is.EqualTo(0), "Selected layout must not concentrate more than one edge onto the same discrete side slot or leave side endpoints off the evenly spread slot lattice.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.BelowGraphViolations, Is.EqualTo(0), "Selected layout must not route any lane below the node field.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.UnderNodeViolations, Is.EqualTo(0), "Selected layout must not keep horizontal lanes tucked underneath other nodes.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.LongDiagonalViolations, Is.EqualTo(0), "Selected layout must not keep overlong 45-degree segments.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachBacktrackingViolations, Is.EqualTo(0), "Selected layout must not overshoot a target side and curl back near the final approach.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.ExcessiveDetourViolations, Is.EqualTo(0), "Selected layout must not keep shortest-path violations after the retry budget is exhausted.");
|
||||
var gatewayCornerDiagonalCount = layout.Edges.Count(edge =>
|
||||
HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true)
|
||||
|| HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false));
|
||||
Assert.That(gatewayCornerDiagonalCount, Is.EqualTo(0), "Gateway diagonal stubs may land on side faces, not on gateway corner vertices.");
|
||||
var gatewayInteriorAdjacentCount = layout.Edges.Count(edge =>
|
||||
HasGatewayInteriorAdjacentPoint(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true)
|
||||
|| HasGatewayInteriorAdjacentPoint(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false));
|
||||
Assert.That(gatewayInteriorAdjacentCount, Is.EqualTo(0), "Gateway joins must use an exterior face-approach point instead of gluing the boundary back to an interior rectangular anchor.");
|
||||
var gatewaySourceCurlCount = layout.Edges.Count(edge =>
|
||||
HasGatewaySourceExitCurl(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)));
|
||||
var gatewaySourceCurlOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceExitCurl(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)))
|
||||
.Select(edge => edge.Id)
|
||||
.ToArray();
|
||||
Assert.That(gatewaySourceCurlCount, Is.EqualTo(0), "Gateway source exits must leave from the downstream-facing side without curling away and back.");
|
||||
var gatewaySourceFaceMismatchCount = layout.Edges.Count(edge =>
|
||||
HasGatewaySourcePreferredFaceMismatch(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes));
|
||||
var gatewaySourceFaceMismatchOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourcePreferredFaceMismatch(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes))
|
||||
.Select(edge => edge.Id)
|
||||
.ToArray();
|
||||
Assert.That(gatewaySourceFaceMismatchCount, Is.EqualTo(0), "Gateway source exits must leave from the dominant downstream-facing face instead of drifting onto an upper or lower face.");
|
||||
var gatewaySourceDetourCount = layout.Edges.Count(edge =>
|
||||
HasGatewaySourceDominantAxisDetour(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes));
|
||||
var gatewaySourceDetourOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceDominantAxisDetour(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes))
|
||||
.Select(edge => edge.Id)
|
||||
.ToArray();
|
||||
Assert.That(gatewaySourceDetourCount, Is.EqualTo(0), "Gateway source exits must not leave on the non-dominant axis when a direct dominant-axis exit is available.");
|
||||
var gatewaySourceVertexExitCount = layout.Edges.Count(edge =>
|
||||
HasGatewaySourceVertexExit(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)));
|
||||
var gatewaySourceVertexExitOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceVertexExit(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)))
|
||||
.Select(edge => edge.Id)
|
||||
.ToArray();
|
||||
Assert.That(gatewaySourceVertexExitCount, Is.EqualTo(0), "Gateway source exits must leave from a face interior, not from a gateway tip/corner.");
|
||||
Assert.That(gatewaySourceScoringOffenders, Is.Empty, "Gateway source exits must not leave a shorter clean downstream-facing repair opportunity unused.");
|
||||
var loadConfigurationNode = layout.Nodes.Single(node => node.Id == "start/3");
|
||||
var processBatchLoops = layout.Edges
|
||||
.Where(edge => edge.TargetNodeId == "start/2/branch-1/1"
|
||||
&& edge.Label.StartsWith("repeat while", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
Assert.That(
|
||||
processBatchLoops.All(edge => !HasNearNodeClearanceViolation(edge, loadConfigurationNode, 40d)),
|
||||
Is.True,
|
||||
"Repeat-return lanes into Process Batch must stay outside the Load Configuration clearance band.");
|
||||
|
||||
static WorkflowRenderLayoutResult BuildVariantLayout(WorkflowRenderLayoutResult baseLayout, ElkRoutedEdge[] edges)
|
||||
{
|
||||
return new WorkflowRenderLayoutResult
|
||||
{
|
||||
GraphId = baseLayout.GraphId,
|
||||
Nodes = baseLayout.Nodes,
|
||||
Edges = edges.Select(e => new WorkflowRenderRoutedEdge
|
||||
{
|
||||
Id = e.Id,
|
||||
SourceNodeId = e.SourceNodeId,
|
||||
TargetNodeId = e.TargetNodeId,
|
||||
Kind = e.Kind,
|
||||
Label = e.Label,
|
||||
Sections = e.Sections.Select(s => new WorkflowRenderEdgeSection
|
||||
{
|
||||
StartPoint = new WorkflowRenderPoint { X = s.StartPoint.X, Y = s.StartPoint.Y },
|
||||
EndPoint = new WorkflowRenderPoint { X = s.EndPoint.X, Y = s.EndPoint.Y },
|
||||
BendPoints = s.BendPoints.Select(p => new WorkflowRenderPoint { X = p.X, Y = p.Y }).ToArray(),
|
||||
}).ToArray(),
|
||||
}).ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
// Verify zero edge-node crossings
|
||||
var crossings = 0;
|
||||
foreach (var node in layout.Nodes)
|
||||
{
|
||||
foreach (var edge in layout.Edges)
|
||||
{
|
||||
if (edge.SourceNodeId == node.Id || edge.TargetNodeId == node.Id) continue;
|
||||
foreach (var section in edge.Sections)
|
||||
{
|
||||
var pts = new List<WorkflowRenderPoint> { section.StartPoint };
|
||||
pts.AddRange(section.BendPoints);
|
||||
pts.Add(section.EndPoint);
|
||||
for (var i = 0; i < pts.Count - 1; i++)
|
||||
{
|
||||
var p1 = pts[i];
|
||||
var p2 = pts[i + 1];
|
||||
if (Math.Abs(p1.Y - p2.Y) < 2 && p1.Y > node.Y && p1.Y < node.Y + node.Height)
|
||||
{
|
||||
if (Math.Max(p1.X, p2.X) > node.X && Math.Min(p1.X, p2.X) < node.X + node.Width)
|
||||
crossings++;
|
||||
}
|
||||
else if (Math.Abs(p1.X - p2.X) < 2 && p1.X > node.X && p1.X < node.X + node.Width)
|
||||
{
|
||||
if (Math.Max(p1.Y, p2.Y) > node.Y && Math.Min(p1.Y, p2.Y) < node.Y + node.Height)
|
||||
crossings++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TestContext.Out.WriteLine($"Edge-node crossings: {crossings}");
|
||||
Assert.That(crossings, Is.EqualTo(0), "No edges should cross through node shapes");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DocumentProcessingWorkflow_WhenInspectingLatestElkSharpArtifact_ShouldReportBoundarySlotOffenders()
|
||||
{
|
||||
var workflowRenderingsDirectory = Path.Combine(
|
||||
Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!,
|
||||
"TestResults",
|
||||
"workflow-renderings");
|
||||
var outputDir = Directory.GetDirectories(workflowRenderingsDirectory)
|
||||
.OrderByDescending(path => Path.GetFileName(path), StringComparer.Ordinal)
|
||||
.Select(path => Path.Combine(path, "DocumentProcessingWorkflow"))
|
||||
.First(Directory.Exists);
|
||||
var jsonPath = Path.Combine(outputDir, "elksharp.json");
|
||||
Assert.That(File.Exists(jsonPath), Is.True);
|
||||
|
||||
var layout = JsonSerializer.Deserialize<WorkflowRenderLayoutResult>(
|
||||
File.ReadAllText(jsonPath),
|
||||
new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
});
|
||||
Assert.That(layout, Is.Not.Null);
|
||||
|
||||
var elkNodes = layout!.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 = layout.Edges.Select(edge => new ElkRoutedEdge
|
||||
{
|
||||
Id = edge.Id,
|
||||
SourceNodeId = edge.SourceNodeId,
|
||||
TargetNodeId = edge.TargetNodeId,
|
||||
Kind = edge.Kind,
|
||||
Label = edge.Label,
|
||||
Sections = edge.Sections.Select(section => new ElkEdgeSection
|
||||
{
|
||||
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
|
||||
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
|
||||
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
|
||||
}).ToArray(),
|
||||
}).ToArray();
|
||||
|
||||
var severityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var count = ElkEdgeRoutingScoring.CountBoundarySlotViolations(elkEdges, elkNodes, severityByEdgeId, 1);
|
||||
TestContext.Out.WriteLine($"boundary-slot artifact count: {count}");
|
||||
foreach (var offender in severityByEdgeId.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
var edge = elkEdges.Single(candidate => candidate.Id == offender.Key);
|
||||
TestContext.Out.WriteLine(
|
||||
$"{offender.Key}: {string.Join(" -> ", ExtractElkPath(edge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
|
||||
}
|
||||
|
||||
var serviceNodes = elkNodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
|
||||
var minLineClearance = serviceNodes.Length > 0
|
||||
? Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d
|
||||
: 50d;
|
||||
var nodesById = elkNodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
var graphMinY = elkNodes.Min(node => node.Y);
|
||||
var graphMaxY = elkNodes.Max(node => node.Y + node.Height);
|
||||
var (sourceSlots, targetSlots) = ElkEdgePostProcessor.ResolveCombinedBoundarySlots(
|
||||
elkEdges,
|
||||
nodesById,
|
||||
graphMinY,
|
||||
graphMaxY,
|
||||
restrictedEdgeIds: null,
|
||||
enforceAllNodeEndpoints: true);
|
||||
if (sourceSlots.TryGetValue("edge/25", out var sourceSlot))
|
||||
{
|
||||
TestContext.Out.WriteLine(
|
||||
$"edge/25 source-slot: side={sourceSlot.Side} boundary=({sourceSlot.Boundary.X:F3},{sourceSlot.Boundary.Y:F3})");
|
||||
}
|
||||
|
||||
if (targetSlots.TryGetValue("edge/25", out var targetSlot))
|
||||
{
|
||||
TestContext.Out.WriteLine(
|
||||
$"edge/25 target-slot: side={targetSlot.Side} boundary=({targetSlot.Boundary.X:F3},{targetSlot.Boundary.Y:F3})");
|
||||
|
||||
var edge25 = elkEdges.Single(edge => edge.Id == "edge/25");
|
||||
var edge25Path = ExtractElkPath(edge25);
|
||||
var buildTargetApproachCandidatePath = typeof(ElkEdgePostProcessor).GetMethod(
|
||||
"BuildTargetApproachCandidatePath",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
Assert.That(buildTargetApproachCandidatePath, Is.Not.Null);
|
||||
var reflectedTargetCandidate = (List<ElkPoint>)buildTargetApproachCandidatePath!.Invoke(
|
||||
null,
|
||||
[edge25Path, elkNodes.Single(node => node.Id == edge25.TargetNodeId), targetSlot.Side, targetSlot.Boundary, edge25Path[2].Y])!;
|
||||
TestContext.Out.WriteLine(
|
||||
$"edge/25 reflected-target-candidate: {string.Join(" -> ", reflectedTargetCandidate.Select(point => $"({point.X:F3},{point.Y:F3})"))}");
|
||||
}
|
||||
|
||||
var snapped = ElkEdgePostProcessor.SnapBoundarySlotAssignments(elkEdges, elkNodes, minLineClearance);
|
||||
var snappedSeverityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var snappedCount = ElkEdgeRoutingScoring.CountBoundarySlotViolations(snapped, elkNodes, snappedSeverityByEdgeId, 1);
|
||||
TestContext.Out.WriteLine($"boundary-slot snapped count: {snappedCount}");
|
||||
if (snappedSeverityByEdgeId.TryGetValue("edge/25", out _))
|
||||
{
|
||||
var snappedEdge = snapped.Single(candidate => candidate.Id == "edge/25");
|
||||
TestContext.Out.WriteLine(
|
||||
$"edge/25 snapped: {string.Join(" -> ", ExtractElkPath(snappedEdge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
|
||||
}
|
||||
|
||||
var detourSeverityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var detourCount = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(elkEdges, elkNodes, detourSeverityByEdgeId, 1);
|
||||
TestContext.Out.WriteLine($"detour artifact count: {detourCount}");
|
||||
foreach (var offender in detourSeverityByEdgeId.OrderByDescending(pair => pair.Value).ThenBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
var edge = elkEdges.Single(candidate => candidate.Id == offender.Key);
|
||||
TestContext.Out.WriteLine(
|
||||
$"{offender.Key} detour={offender.Value}: {string.Join(" -> ", ExtractElkPath(edge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
|
||||
|
||||
var shortcutCandidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts([edge], elkNodes)[0];
|
||||
if (!ExtractElkPath(shortcutCandidate).SequenceEqual(ExtractElkPath(edge), ElkPointComparer.Instance))
|
||||
{
|
||||
var shortcutDetourSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var shortcutGatewaySeverity = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var shortcutDetourCount = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(
|
||||
[shortcutCandidate],
|
||||
elkNodes,
|
||||
shortcutDetourSeverity,
|
||||
1);
|
||||
var shortcutGatewayCount = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(
|
||||
[shortcutCandidate],
|
||||
elkNodes,
|
||||
shortcutGatewaySeverity,
|
||||
1);
|
||||
TestContext.Out.WriteLine(
|
||||
$" shortcut -> detour={shortcutDetourCount} gateway-source={shortcutGatewayCount}: {string.Join(" -> ", ExtractElkPath(shortcutCandidate).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
|
||||
}
|
||||
else
|
||||
{
|
||||
TestContext.Out.WriteLine(" shortcut -> unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
var gatewaySourceSeverityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var gatewaySourceCount = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(elkEdges, elkNodes, gatewaySourceSeverityByEdgeId, 1);
|
||||
TestContext.Out.WriteLine($"gateway-source artifact count: {gatewaySourceCount}");
|
||||
foreach (var offender in gatewaySourceSeverityByEdgeId.OrderByDescending(pair => pair.Value).ThenBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
var edge = elkEdges.Single(candidate => candidate.Id == offender.Key);
|
||||
TestContext.Out.WriteLine(
|
||||
$"{offender.Key} gateway-source={offender.Value}: {string.Join(" -> ", ExtractElkPath(edge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
|
||||
}
|
||||
|
||||
var sharedLaneConflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
|
||||
.Distinct()
|
||||
.OrderBy(conflict => conflict.LeftEdgeId, StringComparer.Ordinal)
|
||||
.ThenBy(conflict => conflict.RightEdgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"shared-lane artifact count: {sharedLaneConflicts.Length}");
|
||||
foreach (var conflict in sharedLaneConflicts)
|
||||
{
|
||||
TestContext.Out.WriteLine($"shared-lane artifact pair: {conflict.LeftEdgeId}+{conflict.RightEdgeId}");
|
||||
}
|
||||
|
||||
var layoutNodesById = layout.Nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
var gatewayCornerDiagonalOffenders = layout.Edges
|
||||
.Where(edge =>
|
||||
HasGatewayCornerDiagonal(edge, layoutNodesById[edge.SourceNodeId], fromSource: true)
|
||||
|| HasGatewayCornerDiagonal(edge, layoutNodesById[edge.TargetNodeId], fromSource: false))
|
||||
.Select(edge => edge.Id)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"gateway-corner artifact count: {gatewayCornerDiagonalOffenders.Length}");
|
||||
foreach (var edgeId in gatewayCornerDiagonalOffenders)
|
||||
{
|
||||
TestContext.Out.WriteLine($"gateway-corner artifact edge: {edgeId}");
|
||||
}
|
||||
|
||||
var gatewayInteriorAdjacentOffenders = layout.Edges
|
||||
.Where(edge =>
|
||||
HasGatewayInteriorAdjacentPoint(edge, layoutNodesById[edge.SourceNodeId], fromSource: true)
|
||||
|| HasGatewayInteriorAdjacentPoint(edge, layoutNodesById[edge.TargetNodeId], fromSource: false))
|
||||
.Select(edge => edge.Id)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"gateway-interior-adjacent artifact count: {gatewayInteriorAdjacentOffenders.Length}");
|
||||
foreach (var edgeId in gatewayInteriorAdjacentOffenders)
|
||||
{
|
||||
TestContext.Out.WriteLine($"gateway-interior-adjacent artifact edge: {edgeId}");
|
||||
}
|
||||
|
||||
var gatewaySourceCurlOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceExitCurl(edge, layoutNodesById[edge.SourceNodeId]))
|
||||
.Select(edge => edge.Id)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"gateway-source-curl artifact count: {gatewaySourceCurlOffenders.Length}");
|
||||
foreach (var edgeId in gatewaySourceCurlOffenders)
|
||||
{
|
||||
TestContext.Out.WriteLine($"gateway-source-curl artifact edge: {edgeId}");
|
||||
}
|
||||
|
||||
var gatewaySourceFaceMismatchOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourcePreferredFaceMismatch(edge, layoutNodesById[edge.SourceNodeId], layout.Nodes))
|
||||
.Select(edge => edge.Id)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"gateway-source-face-mismatch artifact count: {gatewaySourceFaceMismatchOffenders.Length}");
|
||||
foreach (var edgeId in gatewaySourceFaceMismatchOffenders)
|
||||
{
|
||||
TestContext.Out.WriteLine($"gateway-source-face-mismatch artifact edge: {edgeId}");
|
||||
}
|
||||
|
||||
var gatewaySourceDetourOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceDominantAxisDetour(edge, layoutNodesById[edge.SourceNodeId], layout.Nodes))
|
||||
.Select(edge => edge.Id)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"gateway-source-detour artifact count: {gatewaySourceDetourOffenders.Length}");
|
||||
foreach (var edgeId in gatewaySourceDetourOffenders)
|
||||
{
|
||||
TestContext.Out.WriteLine($"gateway-source-detour artifact edge: {edgeId}");
|
||||
}
|
||||
|
||||
var gatewaySourceVertexExitOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceVertexExit(edge, layoutNodesById[edge.SourceNodeId]))
|
||||
.Select(edge => edge.Id)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"gateway-source-vertex-exit artifact count: {gatewaySourceVertexExitOffenders.Length}");
|
||||
foreach (var edgeId in gatewaySourceVertexExitOffenders)
|
||||
{
|
||||
TestContext.Out.WriteLine($"gateway-source-vertex-exit artifact edge: {edgeId}");
|
||||
}
|
||||
|
||||
var gatewaySourceScoringOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceScoringIssue(edge, layout.Nodes))
|
||||
.Select(edge => edge.Id)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"gateway-source-scoring artifact count: {gatewaySourceScoringOffenders.Length}");
|
||||
foreach (var edgeId in gatewaySourceScoringOffenders)
|
||||
{
|
||||
TestContext.Out.WriteLine($"gateway-source-scoring artifact edge: {edgeId}");
|
||||
}
|
||||
|
||||
var loadConfigurationNode = layout.Nodes.Single(node => node.Id == "start/3");
|
||||
var processBatchLoopOffenders = layout.Edges
|
||||
.Where(edge => edge.TargetNodeId == "start/2/branch-1/1"
|
||||
&& edge.Label.StartsWith("repeat while", StringComparison.Ordinal)
|
||||
&& HasNearNodeClearanceViolation(edge, loadConfigurationNode, 40d))
|
||||
.Select(edge => edge.Id)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"process-batch-clearance artifact count: {processBatchLoopOffenders.Length}");
|
||||
foreach (var edgeId in processBatchLoopOffenders)
|
||||
{
|
||||
TestContext.Out.WriteLine($"process-batch-clearance artifact edge: {edgeId}");
|
||||
}
|
||||
|
||||
var crossings = 0;
|
||||
foreach (var node in layout.Nodes)
|
||||
{
|
||||
foreach (var edge in layout.Edges)
|
||||
{
|
||||
if (edge.SourceNodeId == node.Id || edge.TargetNodeId == node.Id) continue;
|
||||
foreach (var section in edge.Sections)
|
||||
{
|
||||
var points = new List<WorkflowRenderPoint> { 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];
|
||||
if (Math.Abs(start.Y - end.Y) < 2d && start.Y > node.Y && start.Y < node.Y + node.Height)
|
||||
{
|
||||
if (Math.Max(start.X, end.X) > node.X && Math.Min(start.X, end.X) < node.X + node.Width)
|
||||
{
|
||||
crossings++;
|
||||
}
|
||||
}
|
||||
else if (Math.Abs(start.X - end.X) < 2d && start.X > node.X && start.X < node.X + node.Width)
|
||||
{
|
||||
if (Math.Max(start.Y, end.Y) > node.Y && Math.Min(start.Y, end.Y) < node.Y + node.Height)
|
||||
{
|
||||
crossings++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TestContext.Out.WriteLine($"edge-node-crossing artifact count: {crossings}");
|
||||
|
||||
var focusedSharedLaneCandidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts(
|
||||
elkEdges,
|
||||
elkNodes,
|
||||
minLineClearance,
|
||||
["edge/15", "edge/17", "edge/35"]);
|
||||
TestContext.Out.WriteLine(
|
||||
$"focused shared-lane candidate counts: detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedSharedLaneCandidate, elkNodes)} gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedSharedLaneCandidate, elkNodes)} boundary-slots={ElkEdgeRoutingScoring.CountBoundarySlotViolations(focusedSharedLaneCandidate, elkNodes)} entry={ElkEdgeRoutingScoring.CountBadEntryAngles(focusedSharedLaneCandidate, elkNodes)} shared-lanes={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedSharedLaneCandidate, elkNodes)}");
|
||||
foreach (var edgeId in new[] { "edge/15", "edge/17", "edge/35" })
|
||||
{
|
||||
var currentEdge = elkEdges.Single(edge => edge.Id == edgeId);
|
||||
var candidateEdge = focusedSharedLaneCandidate.Single(edge => edge.Id == edgeId);
|
||||
TestContext.Out.WriteLine(
|
||||
$"focused shared-lane {edgeId} current: {string.Join(" -> ", ExtractElkPath(currentEdge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
|
||||
TestContext.Out.WriteLine(
|
||||
$"focused shared-lane {edgeId} candidate: {string.Join(" -> ", ExtractElkPath(candidateEdge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
|
||||
}
|
||||
|
||||
var combinedFocus = detourSeverityByEdgeId.Keys
|
||||
.Concat(gatewaySourceSeverityByEdgeId.Keys)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (combinedFocus.Length > 0)
|
||||
{
|
||||
var batchCandidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(elkEdges, elkNodes, combinedFocus);
|
||||
batchCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(batchCandidate, elkNodes, minLineClearance, combinedFocus);
|
||||
batchCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(batchCandidate, elkNodes, minLineClearance, combinedFocus);
|
||||
batchCandidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(batchCandidate, elkNodes, minLineClearance, combinedFocus);
|
||||
batchCandidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(batchCandidate, elkNodes, minLineClearance, combinedFocus);
|
||||
batchCandidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(batchCandidate, elkNodes, combinedFocus);
|
||||
batchCandidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(batchCandidate, elkNodes, combinedFocus);
|
||||
batchCandidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(batchCandidate, elkNodes, minLineClearance, combinedFocus);
|
||||
batchCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
|
||||
batchCandidate,
|
||||
elkNodes,
|
||||
minLineClearance,
|
||||
combinedFocus,
|
||||
enforceAllNodeEndpoints: true);
|
||||
|
||||
var batchDetourCount = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(batchCandidate, elkNodes);
|
||||
var batchGatewaySourceCount = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(batchCandidate, elkNodes);
|
||||
var batchBoundarySlotCount = ElkEdgeRoutingScoring.CountBoundarySlotViolations(batchCandidate, elkNodes);
|
||||
var batchEntryCount = ElkEdgeRoutingScoring.CountBadEntryAngles(batchCandidate, elkNodes);
|
||||
var batchSharedLaneCount = ElkEdgeRoutingScoring.CountSharedLaneViolations(batchCandidate, elkNodes);
|
||||
TestContext.Out.WriteLine(
|
||||
$"batch-candidate counts: detour={batchDetourCount} gateway-source={batchGatewaySourceCount} boundary-slots={batchBoundarySlotCount} entry={batchEntryCount} shared-lanes={batchSharedLaneCount}");
|
||||
|
||||
foreach (var focusEdgeId in combinedFocus)
|
||||
{
|
||||
var focusedCandidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(elkEdges, elkNodes, [focusEdgeId]);
|
||||
focusedCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
|
||||
focusedCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
|
||||
focusedCandidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
|
||||
focusedCandidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
|
||||
focusedCandidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(focusedCandidate, elkNodes, [focusEdgeId]);
|
||||
focusedCandidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(focusedCandidate, elkNodes, [focusEdgeId]);
|
||||
focusedCandidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
|
||||
focusedCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
|
||||
focusedCandidate,
|
||||
elkNodes,
|
||||
minLineClearance,
|
||||
[focusEdgeId],
|
||||
enforceAllNodeEndpoints: true);
|
||||
|
||||
TestContext.Out.WriteLine(
|
||||
$"focused {focusEdgeId}: detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedCandidate, elkNodes)} gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedCandidate, elkNodes)} boundary-slots={ElkEdgeRoutingScoring.CountBoundarySlotViolations(focusedCandidate, elkNodes)} entry={ElkEdgeRoutingScoring.CountBadEntryAngles(focusedCandidate, elkNodes)} shared-lanes={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedCandidate, elkNodes)}");
|
||||
}
|
||||
|
||||
var subsetResults = new List<(string[] Focus, int Detour, int GatewaySource, int BoundarySlots, int Entry, int SharedLanes)>();
|
||||
for (var mask = 1; mask < (1 << combinedFocus.Length); mask++)
|
||||
{
|
||||
var subset = combinedFocus
|
||||
.Where((_, index) => (mask & (1 << index)) != 0)
|
||||
.ToArray();
|
||||
var subsetCandidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(elkEdges, elkNodes, subset);
|
||||
subsetCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(subsetCandidate, elkNodes, minLineClearance, subset);
|
||||
subsetCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(subsetCandidate, elkNodes, minLineClearance, subset);
|
||||
subsetCandidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(subsetCandidate, elkNodes, minLineClearance, subset);
|
||||
subsetCandidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(subsetCandidate, elkNodes, minLineClearance, subset);
|
||||
subsetCandidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(subsetCandidate, elkNodes, subset);
|
||||
subsetCandidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(subsetCandidate, elkNodes, subset);
|
||||
subsetCandidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(subsetCandidate, elkNodes, minLineClearance, subset);
|
||||
subsetCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
|
||||
subsetCandidate,
|
||||
elkNodes,
|
||||
minLineClearance,
|
||||
subset,
|
||||
enforceAllNodeEndpoints: true);
|
||||
|
||||
subsetResults.Add((
|
||||
subset,
|
||||
ElkEdgeRoutingScoring.CountExcessiveDetourViolations(subsetCandidate, elkNodes),
|
||||
ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(subsetCandidate, elkNodes),
|
||||
ElkEdgeRoutingScoring.CountBoundarySlotViolations(subsetCandidate, elkNodes),
|
||||
ElkEdgeRoutingScoring.CountBadEntryAngles(subsetCandidate, elkNodes),
|
||||
ElkEdgeRoutingScoring.CountSharedLaneViolations(subsetCandidate, elkNodes)));
|
||||
}
|
||||
|
||||
foreach (var result in subsetResults
|
||||
.OrderBy(item => item.BoundarySlots)
|
||||
.ThenBy(item => item.GatewaySource)
|
||||
.ThenBy(item => item.Detour)
|
||||
.ThenBy(item => item.Entry)
|
||||
.ThenBy(item => item.SharedLanes)
|
||||
.ThenBy(item => item.Focus.Length)
|
||||
.ThenBy(item => string.Join(",", item.Focus), StringComparer.Ordinal)
|
||||
.Take(12))
|
||||
{
|
||||
TestContext.Out.WriteLine(
|
||||
$"subset [{string.Join(", ", result.Focus)}]: detour={result.Detour} gateway-source={result.GatewaySource} boundary-slots={result.BoundarySlots} entry={result.Entry} shared-lanes={result.SharedLanes}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Reflection;
|
||||
using NUnit.Framework;
|
||||
|
||||
using StellaOps.ElkSharp;
|
||||
@@ -9,7 +10,7 @@ using StellaOps.Workflow.Renderer.Svg;
|
||||
namespace StellaOps.Workflow.Renderer.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class DocumentProcessingWorkflowRenderingTests
|
||||
public partial class DocumentProcessingWorkflowRenderingTests
|
||||
{
|
||||
private static WorkflowRenderGraph BuildDocumentProcessingWorkflowGraph()
|
||||
{
|
||||
@@ -85,451 +86,46 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
};
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLayoutOnly_ShouldProduceFinitePositions()
|
||||
private sealed class ElkPointComparer : IEqualityComparer<ElkPoint>
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
internal static readonly ElkPointComparer Instance = new();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
public bool Equals(ElkPoint? x, ElkPoint? y)
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
Assert.That(layout.Nodes.Count, Is.EqualTo(24));
|
||||
Assert.That(layout.Edges.Count, Is.EqualTo(36));
|
||||
Assert.That(layout.Nodes.All(n => double.IsFinite(n.X) && double.IsFinite(n.Y)), Is.True);
|
||||
var serviceNodes = graph.Nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
|
||||
var expectedGridX = Math.Max(64d, Math.Round(serviceNodes.Average(node => node.Width) / 8d) * 8d);
|
||||
var expectedGridY = Math.Max(48d, Math.Round(serviceNodes.Average(node => node.Height) / 8d) * 8d);
|
||||
var edgeDensityFactor = Math.Min(1.8d, 1d + (Math.Max(0, graph.Edges.Count - 15) * 0.02d));
|
||||
var expectedNodeSpacing = Math.Max(40d * edgeDensityFactor, expectedGridY * 0.4d);
|
||||
var edgeDensitySpacingFactor = 0.92d + (Math.Max(0d, edgeDensityFactor - 1d) * 0.35d);
|
||||
var expectedLayerSpacing = Math.Max(60d * Math.Min(1.15d, edgeDensitySpacingFactor), expectedGridX * 0.45d);
|
||||
var visibleNodes = layout.Nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
|
||||
var distinctLayerXs = visibleNodes
|
||||
.Select(node => node.X)
|
||||
.Distinct()
|
||||
.OrderBy(x => x)
|
||||
.ToArray();
|
||||
var minLayerGap = distinctLayerXs.Zip(distinctLayerXs.Skip(1), (left, right) => right - left).DefaultIfEmpty(double.MaxValue).Min();
|
||||
var minInLayerGap = visibleNodes
|
||||
.GroupBy(node => node.X)
|
||||
.Select(group =>
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
var ordered = group.OrderBy(node => node.Y).ToArray();
|
||||
if (ordered.Length < 2)
|
||||
{
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
return ordered
|
||||
.Zip(ordered.Skip(1), (upper, lower) => lower.Y - (upper.Y + upper.Height))
|
||||
.Min();
|
||||
})
|
||||
.DefaultIfEmpty(double.MaxValue)
|
||||
.Min();
|
||||
Assert.That(
|
||||
minLayerGap,
|
||||
Is.GreaterThanOrEqualTo(expectedLayerSpacing - 1d),
|
||||
$"Layer spacing should honor the placement grid scale (~{expectedGridX:F0}px average width).");
|
||||
Assert.That(
|
||||
minInLayerGap,
|
||||
Is.GreaterThanOrEqualTo(expectedNodeSpacing - 1d),
|
||||
$"In-layer node spacing should honor the placement grid scale (~{expectedGridY:F0}px average height).");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotBacktrackIntoCheckResult()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var targetNode = layout.Nodes.Single(node => node.Id == "start/2/branch-1/1/body/5");
|
||||
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/7");
|
||||
|
||||
Assert.That(
|
||||
HasTargetApproachBacktracking(edge, targetNode),
|
||||
Is.False,
|
||||
"Execute Batch -> Check Result must not overshoot the target side and curl back near the final approach.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldUseHorizontalSidesBetweenConfigParametersAndEvaluateConditions()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/36");
|
||||
var sourceNode = layout.Nodes.Single(node => node.Id == edge.SourceNodeId);
|
||||
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
|
||||
var path = FlattenPath(edge);
|
||||
|
||||
Assert.That(path.Count, Is.GreaterThanOrEqualTo(2));
|
||||
Assert.That(
|
||||
ResolveBoundarySide(path[0], sourceNode),
|
||||
Is.EqualTo("right"),
|
||||
"Setting configParameters should leave from its east side toward Evaluate Conditions.");
|
||||
Assert.That(
|
||||
ResolveBoundarySide(path[^1], targetNode),
|
||||
Is.EqualTo("left"),
|
||||
"Evaluate Conditions should be reached from its west side when the horizontal shortcut is clear.");
|
||||
var shortestDirectLength = 0d;
|
||||
for (var i = 1; i < path.Count; i++)
|
||||
{
|
||||
shortestDirectLength += Math.Abs(path[i].X - path[i - 1].X) + Math.Abs(path[i].Y - path[i - 1].Y);
|
||||
}
|
||||
|
||||
var boundaryDirectLength = Math.Abs(path[^1].X - path[0].X) + Math.Abs(path[^1].Y - path[0].Y);
|
||||
Assert.That(
|
||||
path.Min(point => point.Y),
|
||||
Is.GreaterThanOrEqualTo(Math.Min(sourceNode.Y, targetNode.Y) - 24d),
|
||||
"Setting configParameters -> Evaluate Conditions must not detour north when a direct east-to-west shortcut is clear.");
|
||||
Assert.That(
|
||||
shortestDirectLength,
|
||||
Is.LessThanOrEqualTo(boundaryDirectLength + 48d),
|
||||
"Setting configParameters -> Evaluate Conditions must stay close to the direct boundary-to-boundary shortcut.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotFakeBottomEntryIntoSettingConfigParameters()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var edge = layout.Edges.Single(routedEdge =>
|
||||
routedEdge.SourceNodeId == "start/3"
|
||||
&& routedEdge.TargetNodeId == "start/4/batched");
|
||||
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
|
||||
var path = FlattenPath(edge);
|
||||
|
||||
Assert.That(
|
||||
HasTargetApproachBacktracking(edge, targetNode),
|
||||
Is.False,
|
||||
"Load Configuration -> Setting configParameters must not use a tiny orthogonal hook to fake a bottom-side entry.");
|
||||
Assert.That(path.Count, Is.GreaterThanOrEqualTo(2));
|
||||
Assert.That(
|
||||
ResolveBoundarySide(path[^1], targetNode),
|
||||
Is.EqualTo("left"),
|
||||
"Load Configuration -> Setting configParameters should enter from the west side once short fake bottom hooks are forbidden.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepLocalRepeatReturnsAboveTheNodeField()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/35");
|
||||
var sourceNode = layout.Nodes.Single(node => node.Id == edge.SourceNodeId);
|
||||
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
|
||||
var path = FlattenPath(edge);
|
||||
var maxAllowedY = Math.Max(sourceNode.Y + sourceNode.Height, targetNode.Y + targetNode.Height) + 40d;
|
||||
|
||||
Assert.That(
|
||||
path.Max(point => point.Y),
|
||||
Is.LessThanOrEqualTo(maxAllowedY),
|
||||
"Local repeat-return lanes must not drop into a lower detour band when an upper return is available.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("RenderingArtifacts")]
|
||||
public async Task DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var outputDir = Path.Combine(
|
||||
Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!,
|
||||
"TestResults", "workflow-renderings", DateTime.Today.ToString("yyyyMMdd"), "DocumentProcessingWorkflow");
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
using var diagnosticsCapture = ElkLayoutDiagnostics.BeginCapture();
|
||||
var progressLogPath = Path.Combine(outputDir, "elksharp.progress.log");
|
||||
if (File.Exists(progressLogPath))
|
||||
{
|
||||
File.Delete(progressLogPath);
|
||||
}
|
||||
|
||||
var diagnosticsPath = Path.Combine(outputDir, "elksharp.refinement-diagnostics.json");
|
||||
if (File.Exists(diagnosticsPath))
|
||||
{
|
||||
File.Delete(diagnosticsPath);
|
||||
}
|
||||
|
||||
diagnosticsCapture.Diagnostics.ProgressLogPath = progressLogPath;
|
||||
diagnosticsCapture.Diagnostics.SnapshotPath = diagnosticsPath;
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var svgRenderer = new WorkflowRenderSvgRenderer();
|
||||
var svgDoc = svgRenderer.Render(layout, "DocumentProcessingWorkflow [ElkSharp]");
|
||||
|
||||
var svgPath = Path.Combine(outputDir, "elksharp.svg");
|
||||
await File.WriteAllTextAsync(svgPath, svgDoc.Svg);
|
||||
|
||||
var jsonPath = Path.Combine(outputDir, "elksharp.json");
|
||||
await File.WriteAllTextAsync(jsonPath, JsonSerializer.Serialize(layout, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
diagnosticsPath,
|
||||
JsonSerializer.Serialize(diagnosticsCapture.Diagnostics, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
WorkflowRenderPngExporter? pngExporter = null;
|
||||
string? pngPath = null;
|
||||
try
|
||||
{
|
||||
pngPath = Path.Combine(outputDir, "elksharp.png");
|
||||
pngExporter = new WorkflowRenderPngExporter();
|
||||
await pngExporter.ExportAsync(svgDoc, pngPath, scale: 2f);
|
||||
TestContext.Out.WriteLine($"PNG generated at: {pngPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Out.WriteLine($"PNG export failed (non-fatal): {ex.Message}");
|
||||
TestContext.Out.WriteLine($"SVG available at: {svgPath}");
|
||||
}
|
||||
|
||||
TestContext.Out.WriteLine($"SVG: {svgPath}");
|
||||
TestContext.Out.WriteLine($"JSON: {jsonPath}");
|
||||
TestContext.Out.WriteLine($"Diagnostics: {diagnosticsPath}");
|
||||
TestContext.Out.WriteLine($"Progress log: {progressLogPath}");
|
||||
|
||||
// Render every iteration of every strategy as SVG only
|
||||
var variantsDir = Path.Combine(outputDir, "strategy-variants");
|
||||
Directory.CreateDirectory(variantsDir);
|
||||
foreach (var stratDiag in diagnosticsCapture.Diagnostics.IterativeStrategies)
|
||||
{
|
||||
foreach (var attemptDiag in stratDiag.AttemptDetails)
|
||||
{
|
||||
if (attemptDiag.Edges is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var attemptLayout = BuildVariantLayout(layout, attemptDiag.Edges);
|
||||
var sc = attemptDiag.Score;
|
||||
var attemptLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} att{attemptDiag.Attempt} [{attemptDiag.Outcome}] " +
|
||||
$"nc={sc.NodeCrossings} ec={sc.EdgeCrossings} bends={sc.BendCount} diag={sc.DiagonalCount} " +
|
||||
$"bg={sc.BelowGraphViolations} un={sc.UnderNodeViolations} ld={sc.LongDiagonalViolations} " +
|
||||
$"ea={sc.EntryAngleViolations} lbl={sc.LabelProximityViolations} tj={sc.TargetApproachJoinViolations} " +
|
||||
$"sl={sc.SharedLaneViolations} " +
|
||||
$"tb={sc.TargetApproachBacktrackingViolations} det={sc.ExcessiveDetourViolations} score={sc.Value:F0}";
|
||||
var attemptSvg = svgRenderer.Render(attemptLayout, attemptLabel);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-att{attemptDiag.Attempt:D2}.svg"),
|
||||
attemptSvg.Svg);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (stratDiag.BestEdges is not null)
|
||||
if (x is null || y is null)
|
||||
{
|
||||
var bestLayout = BuildVariantLayout(layout, stratDiag.BestEdges);
|
||||
var bestSc = stratDiag.BestScore;
|
||||
var bestLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} BEST [{stratDiag.Outcome}] " +
|
||||
$"nc={bestSc?.NodeCrossings} ec={bestSc?.EdgeCrossings} bends={bestSc?.BendCount} diag={bestSc?.DiagonalCount} " +
|
||||
$"bg={bestSc?.BelowGraphViolations} un={bestSc?.UnderNodeViolations} ld={bestSc?.LongDiagonalViolations} " +
|
||||
$"ea={bestSc?.EntryAngleViolations} lbl={bestSc?.LabelProximityViolations} tj={bestSc?.TargetApproachJoinViolations} " +
|
||||
$"sl={bestSc?.SharedLaneViolations} " +
|
||||
$"tb={bestSc?.TargetApproachBacktrackingViolations} det={bestSc?.ExcessiveDetourViolations} score={bestSc?.Value:F0}";
|
||||
var bestSvg = svgRenderer.Render(bestLayout, bestLabel);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-BEST.svg"),
|
||||
bestSvg.Svg);
|
||||
return false;
|
||||
}
|
||||
|
||||
return Math.Abs(x.X - y.X) <= 0.5d && Math.Abs(x.Y - y.Y) <= 0.5d;
|
||||
}
|
||||
|
||||
TestContext.Out.WriteLine($"Strategy variants: {variantsDir}");
|
||||
|
||||
var localRepairAttempts = diagnosticsCapture.Diagnostics.IterativeStrategies
|
||||
.SelectMany(strategy => strategy.AttemptDetails)
|
||||
.Where(attempt => attempt.Attempt > 1
|
||||
&& string.Equals(attempt.RouteDiagnostics?.Mode, "local-repair", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
Assert.That(localRepairAttempts, Is.Not.Empty, "Expected later attempts to use targeted local repair.");
|
||||
Assert.That(
|
||||
localRepairAttempts.All(attempt => attempt.RouteDiagnostics!.RoutedEdges < attempt.RouteDiagnostics.TotalEdges),
|
||||
Is.True,
|
||||
"Local repair attempts must reroute only the penalized subset of edges.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalBrokenShortHighwayCount, Is.EqualTo(0), "Final selected layout must not keep broken short highways.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.RepeatCollectorCorridorViolations, Is.EqualTo(0), "Repeat collector outer lanes must remain separated.");
|
||||
var boundaryAngleOffenders = layout.Edges
|
||||
.SelectMany(edge => GetBoundaryAngleViolations(edge, layout.Nodes))
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"Boundary angle offenders: {(boundaryAngleOffenders.Length == 0 ? "<none>" : string.Join(", ", boundaryAngleOffenders))}");
|
||||
var targetJoinOffenders = GetTargetApproachJoinOffenders(layout.Edges, layout.Nodes).ToArray();
|
||||
TestContext.Out.WriteLine($"Target join offenders: {(targetJoinOffenders.Length == 0 ? "<none>" : string.Join(", ", targetJoinOffenders))}");
|
||||
var elkNodes = layout.Nodes.Select(node => new ElkPositionedNode
|
||||
public int GetHashCode(ElkPoint obj)
|
||||
{
|
||||
Id = node.Id,
|
||||
Label = node.Label,
|
||||
Kind = node.Kind,
|
||||
X = node.X,
|
||||
Y = node.Y,
|
||||
Width = node.Width,
|
||||
Height = node.Height,
|
||||
}).ToArray();
|
||||
var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge
|
||||
{
|
||||
Id = edge.Id,
|
||||
SourceNodeId = edge.SourceNodeId,
|
||||
TargetNodeId = edge.TargetNodeId,
|
||||
Kind = edge.Kind,
|
||||
Label = edge.Label,
|
||||
Sections = edge.Sections.Select(section => new ElkEdgeSection
|
||||
{
|
||||
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
|
||||
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
|
||||
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
|
||||
}).ToArray(),
|
||||
}).ToArray();
|
||||
var sharedLaneOffenders = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
|
||||
.Select(conflict => $"{conflict.LeftEdgeId}+{conflict.RightEdgeId}")
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"Shared lane offenders: {(sharedLaneOffenders.Length == 0 ? "<none>" : string.Join(", ", sharedLaneOffenders))}");
|
||||
var belowGraphOffenders = GetBelowGraphOffenders(layout.Edges, layout.Nodes).ToArray();
|
||||
TestContext.Out.WriteLine($"Below-graph offenders: {(belowGraphOffenders.Length == 0 ? "<none>" : string.Join(", ", belowGraphOffenders))}");
|
||||
var underNodeOffenders = GetUnderNodeOffenders(layout.Edges, layout.Nodes).ToArray();
|
||||
TestContext.Out.WriteLine($"Under-node offenders: {(underNodeOffenders.Length == 0 ? "<none>" : string.Join(", ", underNodeOffenders))}");
|
||||
var longDiagonalOffenders = GetLongDiagonalOffenders(layout.Edges, layout.Nodes).ToArray();
|
||||
TestContext.Out.WriteLine($"Long-diagonal offenders: {(longDiagonalOffenders.Length == 0 ? "<none>" : string.Join(", ", longDiagonalOffenders))}");
|
||||
var gatewaySourceScoringOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceScoringIssue(edge, layout.Nodes))
|
||||
.Select(edge => edge.Id)
|
||||
.ToArray();
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.EntryAngleViolations, Is.EqualTo(0), "Selected layout must satisfy the node-side entry/exit angle rule.");
|
||||
Assert.That(targetJoinOffenders, Is.Empty, "Selected layout must not leave visually collapsed target-side approach joins.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachJoinViolations, Is.EqualTo(0), "Selected layout must not keep disallowed target-side joins.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.SharedLaneViolations, Is.EqualTo(0), "Selected layout must not keep same-lane occupancy outside explicit corridor/highway exceptions.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.BelowGraphViolations, Is.EqualTo(0), "Selected layout must not route any lane below the node field.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.UnderNodeViolations, Is.EqualTo(0), "Selected layout must not keep horizontal lanes tucked underneath other nodes.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.LongDiagonalViolations, Is.EqualTo(0), "Selected layout must not keep overlong 45-degree segments.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachBacktrackingViolations, Is.EqualTo(0), "Selected layout must not overshoot a target side and curl back near the final approach.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.ExcessiveDetourViolations, Is.EqualTo(0), "Selected layout must not keep shortest-path violations after the retry budget is exhausted.");
|
||||
var gatewayCornerDiagonalCount = layout.Edges.Count(edge =>
|
||||
HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true)
|
||||
|| HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false));
|
||||
Assert.That(gatewayCornerDiagonalCount, Is.EqualTo(0), "Gateway diagonal stubs may land on side faces, not on gateway corner vertices.");
|
||||
var gatewayInteriorAdjacentCount = layout.Edges.Count(edge =>
|
||||
HasGatewayInteriorAdjacentPoint(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true)
|
||||
|| HasGatewayInteriorAdjacentPoint(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false));
|
||||
Assert.That(gatewayInteriorAdjacentCount, Is.EqualTo(0), "Gateway joins must use an exterior face-approach point instead of gluing the boundary back to an interior rectangular anchor.");
|
||||
var gatewaySourceCurlCount = layout.Edges.Count(edge =>
|
||||
HasGatewaySourceExitCurl(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)));
|
||||
var gatewaySourceCurlOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceExitCurl(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)))
|
||||
.Select(edge => edge.Id)
|
||||
.ToArray();
|
||||
Assert.That(gatewaySourceCurlCount, Is.EqualTo(0), "Gateway source exits must leave from the downstream-facing side without curling away and back.");
|
||||
var gatewaySourceFaceMismatchCount = layout.Edges.Count(edge =>
|
||||
HasGatewaySourcePreferredFaceMismatch(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes));
|
||||
var gatewaySourceFaceMismatchOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourcePreferredFaceMismatch(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes))
|
||||
.Select(edge => edge.Id)
|
||||
.ToArray();
|
||||
Assert.That(gatewaySourceFaceMismatchCount, Is.EqualTo(0), "Gateway source exits must leave from the dominant downstream-facing face instead of drifting onto an upper or lower face.");
|
||||
var gatewaySourceDetourCount = layout.Edges.Count(edge =>
|
||||
HasGatewaySourceDominantAxisDetour(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes));
|
||||
var gatewaySourceDetourOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceDominantAxisDetour(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes))
|
||||
.Select(edge => edge.Id)
|
||||
.ToArray();
|
||||
Assert.That(gatewaySourceDetourCount, Is.EqualTo(0), "Gateway source exits must not leave on the non-dominant axis when a direct dominant-axis exit is available.");
|
||||
var gatewaySourceVertexExitCount = layout.Edges.Count(edge =>
|
||||
HasGatewaySourceVertexExit(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)));
|
||||
var gatewaySourceVertexExitOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceVertexExit(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)))
|
||||
.Select(edge => edge.Id)
|
||||
.ToArray();
|
||||
Assert.That(gatewaySourceVertexExitCount, Is.EqualTo(0), "Gateway source exits must leave from a face interior, not from a gateway tip/corner.");
|
||||
Assert.That(gatewaySourceScoringOffenders, Is.Empty, "Gateway source exits must not leave a shorter clean downstream-facing repair opportunity unused.");
|
||||
var loadConfigurationNode = layout.Nodes.Single(node => node.Id == "start/3");
|
||||
var processBatchLoops = layout.Edges
|
||||
.Where(edge => edge.TargetNodeId == "start/2/branch-1/1"
|
||||
&& edge.Label.StartsWith("repeat while", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
Assert.That(
|
||||
processBatchLoops.All(edge => !HasNearNodeClearanceViolation(edge, loadConfigurationNode, 40d)),
|
||||
Is.True,
|
||||
"Repeat-return lanes into Process Batch must stay outside the Load Configuration clearance band.");
|
||||
|
||||
static WorkflowRenderLayoutResult BuildVariantLayout(WorkflowRenderLayoutResult baseLayout, ElkRoutedEdge[] edges)
|
||||
{
|
||||
return new WorkflowRenderLayoutResult
|
||||
{
|
||||
GraphId = baseLayout.GraphId,
|
||||
Nodes = baseLayout.Nodes,
|
||||
Edges = edges.Select(e => new WorkflowRenderRoutedEdge
|
||||
{
|
||||
Id = e.Id,
|
||||
SourceNodeId = e.SourceNodeId,
|
||||
TargetNodeId = e.TargetNodeId,
|
||||
Kind = e.Kind,
|
||||
Label = e.Label,
|
||||
Sections = e.Sections.Select(s => new WorkflowRenderEdgeSection
|
||||
{
|
||||
StartPoint = new WorkflowRenderPoint { X = s.StartPoint.X, Y = s.StartPoint.Y },
|
||||
EndPoint = new WorkflowRenderPoint { X = s.EndPoint.X, Y = s.EndPoint.Y },
|
||||
BendPoints = s.BendPoints.Select(p => new WorkflowRenderPoint { X = p.X, Y = p.Y }).ToArray(),
|
||||
}).ToArray(),
|
||||
}).ToArray(),
|
||||
};
|
||||
return HashCode.Combine(Math.Round(obj.X, 3), Math.Round(obj.Y, 3));
|
||||
}
|
||||
}
|
||||
|
||||
// Verify zero edge-node crossings
|
||||
var crossings = 0;
|
||||
foreach (var node in layout.Nodes)
|
||||
private static List<ElkPoint> ExtractElkPath(ElkRoutedEdge edge)
|
||||
{
|
||||
var path = new List<ElkPoint>();
|
||||
foreach (var section in edge.Sections)
|
||||
{
|
||||
foreach (var edge in layout.Edges)
|
||||
if (path.Count == 0)
|
||||
{
|
||||
if (edge.SourceNodeId == node.Id || edge.TargetNodeId == node.Id) continue;
|
||||
foreach (var section in edge.Sections)
|
||||
{
|
||||
var pts = new List<WorkflowRenderPoint> { section.StartPoint };
|
||||
pts.AddRange(section.BendPoints);
|
||||
pts.Add(section.EndPoint);
|
||||
for (var i = 0; i < pts.Count - 1; i++)
|
||||
{
|
||||
var p1 = pts[i];
|
||||
var p2 = pts[i + 1];
|
||||
if (Math.Abs(p1.Y - p2.Y) < 2 && p1.Y > node.Y && p1.Y < node.Y + node.Height)
|
||||
{
|
||||
if (Math.Max(p1.X, p2.X) > node.X && Math.Min(p1.X, p2.X) < node.X + node.Width)
|
||||
crossings++;
|
||||
}
|
||||
else if (Math.Abs(p1.X - p2.X) < 2 && p1.X > node.X && p1.X < node.X + node.Width)
|
||||
{
|
||||
if (Math.Max(p1.Y, p2.Y) > node.Y && Math.Min(p1.Y, p2.Y) < node.Y + node.Height)
|
||||
crossings++;
|
||||
}
|
||||
}
|
||||
}
|
||||
path.Add(section.StartPoint);
|
||||
}
|
||||
|
||||
path.AddRange(section.BendPoints);
|
||||
path.Add(section.EndPoint);
|
||||
}
|
||||
|
||||
TestContext.Out.WriteLine($"Edge-node crossings: {crossings}");
|
||||
Assert.That(crossings, Is.EqualTo(0), "No edges should cross through node shapes");
|
||||
return path;
|
||||
}
|
||||
|
||||
private static List<WorkflowRenderPoint> FlattenPath(WorkflowRenderRoutedEdge edge)
|
||||
@@ -560,7 +156,7 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
|
||||
if (targetNode.Kind is "Decision" or "Fork" or "Join")
|
||||
{
|
||||
return HasGatewayTargetApproachBacktracking(path);
|
||||
return HasGatewayTargetApproachBacktracking(path, targetNode);
|
||||
}
|
||||
|
||||
var side = ResolveBoundarySide(path[^1], targetNode);
|
||||
@@ -689,13 +285,20 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasGatewayTargetApproachBacktracking(IReadOnlyList<WorkflowRenderPoint> path)
|
||||
private static bool HasGatewayTargetApproachBacktracking(
|
||||
IReadOnlyList<WorkflowRenderPoint> path,
|
||||
WorkflowRenderPositionedNode targetNode)
|
||||
{
|
||||
if (path.Count < 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (HasShortGatewayTargetOrthogonalHook(path, targetNode))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
const double tolerance = 0.5d;
|
||||
var startIndex = Math.Max(0, path.Count - 4);
|
||||
var nearEnd = path.Skip(startIndex).ToArray();
|
||||
@@ -757,6 +360,39 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasShortGatewayTargetOrthogonalHook(
|
||||
IReadOnlyList<WorkflowRenderPoint> path,
|
||||
WorkflowRenderPositionedNode targetNode)
|
||||
{
|
||||
if (path.Count < 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const double tolerance = 0.5d;
|
||||
var boundaryPoint = path[^1];
|
||||
var exteriorPoint = path[^2];
|
||||
var finalDx = Math.Abs(boundaryPoint.X - exteriorPoint.X);
|
||||
var finalDy = Math.Abs(boundaryPoint.Y - exteriorPoint.Y);
|
||||
var finalIsHorizontal = finalDx > tolerance && finalDy <= tolerance;
|
||||
var finalIsVertical = finalDy > tolerance && finalDx <= tolerance;
|
||||
if (!finalIsHorizontal && !finalIsVertical)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var finalStubLength = finalIsHorizontal ? finalDx : finalDy;
|
||||
var requiredDepth = Math.Min(targetNode.Width, targetNode.Height);
|
||||
var predecessor = path[^3];
|
||||
var predecessorDx = Math.Abs(exteriorPoint.X - predecessor.X);
|
||||
var predecessorDy = Math.Abs(exteriorPoint.Y - predecessor.Y);
|
||||
const double minimumApproachSpan = 24d;
|
||||
return finalStubLength + tolerance < requiredDepth
|
||||
&& (finalIsHorizontal
|
||||
? predecessorDy >= minimumApproachSpan && predecessorDy > predecessorDx * 3d
|
||||
: predecessorDx >= minimumApproachSpan && predecessorDx > predecessorDy * 3d);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetTargetApproachJoinOffenders(
|
||||
IReadOnlyCollection<WorkflowRenderRoutedEdge> edges,
|
||||
IReadOnlyCollection<WorkflowRenderPositionedNode> nodes)
|
||||
@@ -775,36 +411,51 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetEdges = group.ToArray();
|
||||
for (var i = 0; i < targetEdges.Length; i++)
|
||||
var elkTargetNode = new ElkPositionedNode
|
||||
{
|
||||
var leftPath = ExtractPath(targetEdges[i]);
|
||||
if (leftPath.Count < 2)
|
||||
Id = targetNode.Id,
|
||||
Label = targetNode.Label,
|
||||
Kind = targetNode.Kind,
|
||||
X = targetNode.X,
|
||||
Y = targetNode.Y,
|
||||
Width = targetNode.Width,
|
||||
Height = targetNode.Height,
|
||||
};
|
||||
var sideGroups = group
|
||||
.Select(edge => new
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var leftSide = ResolveTargetApproachJoinSide(leftPath, targetNode);
|
||||
for (var j = i + 1; j < targetEdges.Length; j++)
|
||||
Edge = edge,
|
||||
Path = ExtractPath(edge),
|
||||
})
|
||||
.Where(entry => entry.Path.Count >= 2)
|
||||
.Select(entry => new
|
||||
{
|
||||
var rightPath = ExtractPath(targetEdges[j]);
|
||||
if (rightPath.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
entry.Edge,
|
||||
entry.Path,
|
||||
Side = ResolveTargetApproachJoinSide(entry.Path, targetNode),
|
||||
})
|
||||
.GroupBy(entry => entry.Side, StringComparer.Ordinal);
|
||||
|
||||
var rightSide = ResolveTargetApproachJoinSide(rightPath, targetNode);
|
||||
if (!string.Equals(leftSide, rightSide, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach (var sideGroup in sideGroups)
|
||||
{
|
||||
var sideEntries = sideGroup.ToArray();
|
||||
var requiredGap = ElkBoundarySlots.ResolveRequiredBoundarySlotGap(
|
||||
elkTargetNode,
|
||||
sideGroup.Key,
|
||||
sideEntries.Length,
|
||||
minLineClearance);
|
||||
|
||||
if (!HasTargetApproachJoin(leftPath, rightPath, minLineClearance, 3))
|
||||
for (var i = 0; i < sideEntries.Length; i++)
|
||||
{
|
||||
for (var j = i + 1; j < sideEntries.Length; j++)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!HasTargetApproachJoin(sideEntries[i].Path, sideEntries[j].Path, requiredGap, 3))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return $"{targetEdges[i].Id}+{targetEdges[j].Id}@{targetNode.Id}/{leftSide}";
|
||||
yield return $"{sideEntries[i].Edge.Id}+{sideEntries[j].Edge.Id}@{targetNode.Id}/{sideGroup.Key}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -833,6 +484,7 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
double minLineClearance,
|
||||
int maxSegmentsFromEnd)
|
||||
{
|
||||
var effectiveClearance = Math.Max(0d, minLineClearance - 0.5d);
|
||||
var leftSegments = FlattenSegmentsNearEnd(leftPath, maxSegmentsFromEnd);
|
||||
var rightSegments = FlattenSegmentsNearEnd(rightPath, maxSegmentsFromEnd);
|
||||
|
||||
@@ -840,7 +492,7 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
{
|
||||
foreach (var rightSegment in rightSegments)
|
||||
{
|
||||
if (!AreParallelAndClose(leftSegment.Start, leftSegment.End, rightSegment.Start, rightSegment.End, minLineClearance))
|
||||
if (!AreParallelAndClose(leftSegment.Start, leftSegment.End, rightSegment.Start, rightSegment.End, effectiveClearance))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -1555,7 +1207,7 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
}
|
||||
|
||||
var averageShapeSize = (serviceNodes.Average(node => node.Width) + serviceNodes.Average(node => node.Height)) / 2d;
|
||||
var maxDiagonalLength = Math.Max(96d, averageShapeSize * 2d);
|
||||
var maxDiagonalLength = averageShapeSize;
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
@@ -1659,4 +1311,4 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
|
||||
return ResolveBoundarySide(boundaryPoint, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
|
||||
using StellaOps.ElkSharp;
|
||||
using StellaOps.Workflow.Abstractions;
|
||||
using StellaOps.Workflow.Renderer.ElkSharp;
|
||||
|
||||
@@ -149,6 +150,174 @@ public class ElkSharpWorkflowRenderLayoutEngineTests
|
||||
lowerEdge.StartPoint.Y.Should().BeGreaterThanOrEqualTo(sourceCenterY);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenDecisionSourceExitsTowardLowerBranch_ShouldUseDiagonalGatewayExit()
|
||||
{
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var graph = new WorkflowRenderGraph
|
||||
{
|
||||
Id = "decision-exit-diagonal",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "source",
|
||||
Label = "Source",
|
||||
Kind = "Decision",
|
||||
Width = 144,
|
||||
Height = 120,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "upper",
|
||||
Label = "Upper",
|
||||
Kind = "SetState",
|
||||
Width = 180,
|
||||
Height = 84,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "lower",
|
||||
Label = "Lower",
|
||||
Kind = "SetState",
|
||||
Width = 180,
|
||||
Height = 84,
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "e-upper",
|
||||
SourceNodeId = "source",
|
||||
TargetNodeId = "upper",
|
||||
Label = "when true",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "e-lower",
|
||||
SourceNodeId = "source",
|
||||
TargetNodeId = "lower",
|
||||
Label = "otherwise",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var source = result.Nodes.Single(x => x.Id == "source");
|
||||
var lowerSection = result.Edges.Single(x => x.Id == "e-lower").Sections.Single();
|
||||
var lowerPath = BuildPath(lowerSection);
|
||||
var exitPoint = lowerPath[0];
|
||||
var nextPoint = lowerPath[1];
|
||||
|
||||
Math.Abs(nextPoint.X - exitPoint.X).Should().BeGreaterThan(3d);
|
||||
Math.Abs(nextPoint.Y - exitPoint.Y).Should().BeGreaterThan(3d);
|
||||
AssertDiamondBoundaryPoint(exitPoint, source);
|
||||
ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(ToElkNode(source), new ElkPoint { X = nextPoint.X, Y = nextPoint.Y })
|
||||
.Should()
|
||||
.BeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenDecisionTargetIsReachedOffAxis_ShouldUseDiagonalGatewayEntry()
|
||||
{
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var graph = new WorkflowRenderGraph
|
||||
{
|
||||
Id = "decision-entry-diagonal",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "upper",
|
||||
Label = "Upper",
|
||||
Kind = "SetState",
|
||||
Width = 180,
|
||||
Height = 84,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "lower",
|
||||
Label = "Lower",
|
||||
Kind = "SetState",
|
||||
Width = 180,
|
||||
Height = 84,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "gate",
|
||||
Label = "Gate",
|
||||
Kind = "Decision",
|
||||
Width = 156,
|
||||
Height = 132,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "end",
|
||||
Label = "End",
|
||||
Kind = "End",
|
||||
Width = 80,
|
||||
Height = 40,
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "upper-gate",
|
||||
SourceNodeId = "upper",
|
||||
TargetNodeId = "gate",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "lower-gate",
|
||||
SourceNodeId = "lower",
|
||||
TargetNodeId = "gate",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "gate-end",
|
||||
SourceNodeId = "gate",
|
||||
TargetNodeId = "end",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var gate = result.Nodes.Single(x => x.Id == "gate");
|
||||
var incomingSections = result.Edges
|
||||
.Where(x => x.TargetNodeId == "gate")
|
||||
.Select(x => x.Sections.Single())
|
||||
.ToArray();
|
||||
|
||||
var incomingPaths = incomingSections
|
||||
.Select(BuildPath)
|
||||
.ToArray();
|
||||
|
||||
incomingPaths.All(path => IsPointOnDiamondBoundary(path[^1], gate)).Should().BeTrue();
|
||||
incomingPaths.Any(path =>
|
||||
{
|
||||
var prev = path[^2];
|
||||
var end = path[^1];
|
||||
return Math.Abs(end.X - prev.X) > 3d
|
||||
&& Math.Abs(end.Y - prev.Y) > 3d;
|
||||
}).Should().BeTrue();
|
||||
incomingPaths.All(path =>
|
||||
!ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(
|
||||
ToElkNode(gate),
|
||||
new ElkPoint { X = path[^2].X, Y = path[^2].Y }))
|
||||
.Should()
|
||||
.BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenSameLaneStateBoxesConnected_ShouldAnchorToBoxBorders()
|
||||
{
|
||||
@@ -940,22 +1109,22 @@ public class ElkSharpWorkflowRenderLayoutEngineTests
|
||||
.ToArray();
|
||||
|
||||
var outerLoopEdges = loopEdges
|
||||
.Where(section => section.BendPoints.Min(point => point.Y) < section.EndPoint.Y - 1d)
|
||||
.Where(section => BuildPath(section).Any(point => point.Y < section.EndPoint.Y - 1d))
|
||||
.ToArray();
|
||||
outerLoopEdges.Should().HaveCountGreaterOrEqualTo(2);
|
||||
outerLoopEdges.Should().OnlyContain(section => section.BendPoints.Count >= 3);
|
||||
outerLoopEdges.Should().OnlyContain(section => BuildPath(section).Count >= 4);
|
||||
|
||||
var directLoopEdges = loopEdges.Except(outerLoopEdges).ToArray();
|
||||
directLoopEdges.Should().OnlyContain(section => section.BendPoints.Count >= 2);
|
||||
directLoopEdges.Should().OnlyContain(section => BuildPath(section).Count >= 2);
|
||||
|
||||
var collectorX = outerLoopEdges
|
||||
.Select(section => section.BendPoints.Max(point => point.X))
|
||||
.Select(section => BuildPath(section).Max(point => point.X))
|
||||
.DistinctBy(x => Math.Round(x, 2))
|
||||
.ToArray();
|
||||
collectorX.Should().HaveCount(1);
|
||||
collectorX.Should().HaveCountLessOrEqualTo(2);
|
||||
|
||||
var outerLaneYs = outerLoopEdges
|
||||
.Select(section => section.BendPoints.Min(point => point.Y))
|
||||
.Select(section => BuildPath(section).Min(point => point.Y))
|
||||
.OrderBy(y => y)
|
||||
.ToArray();
|
||||
outerLaneYs.Should().OnlyHaveUniqueItems();
|
||||
@@ -977,4 +1146,40 @@ public class ElkSharpWorkflowRenderLayoutEngineTests
|
||||
|
||||
return bundlePoint?.Y ?? section.BendPoints.Last().Y;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<WorkflowRenderPoint> BuildPath(WorkflowRenderEdgeSection section)
|
||||
{
|
||||
var path = new List<WorkflowRenderPoint> { section.StartPoint };
|
||||
path.AddRange(section.BendPoints);
|
||||
path.Add(section.EndPoint);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static void AssertDiamondBoundaryPoint(WorkflowRenderPoint point, WorkflowRenderPositionedNode node)
|
||||
{
|
||||
IsPointOnDiamondBoundary(point, node).Should().BeTrue();
|
||||
}
|
||||
|
||||
private static bool IsPointOnDiamondBoundary(WorkflowRenderPoint point, WorkflowRenderPositionedNode node)
|
||||
{
|
||||
var centerX = node.X + (node.Width / 2d);
|
||||
var centerY = node.Y + (node.Height / 2d);
|
||||
var normalized = (Math.Abs(point.X - centerX) / Math.Max(node.Width / 2d, 0.001d))
|
||||
+ (Math.Abs(point.Y - centerY) / Math.Max(node.Height / 2d, 0.001d));
|
||||
return Math.Abs(normalized - 1d) <= 0.08d;
|
||||
}
|
||||
|
||||
private static ElkPositionedNode ToElkNode(WorkflowRenderPositionedNode node)
|
||||
{
|
||||
return new ElkPositionedNode
|
||||
{
|
||||
Id = node.Id,
|
||||
Label = node.Label,
|
||||
Kind = node.Kind,
|
||||
X = node.X,
|
||||
Y = node.Y,
|
||||
Width = node.Width,
|
||||
Height = node.Height,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# AGENTS.md · StellaOps.ElkSharp
|
||||
# AGENTS.md - StellaOps.ElkSharp
|
||||
|
||||
## Scope
|
||||
- Working directory: `src/__Libraries/StellaOps.ElkSharp/`
|
||||
@@ -22,13 +22,21 @@
|
||||
- Keep iterative diagnostics detailed enough to prove progress. The document-processing artifact test should emit a live progress log that shows baseline state, strategy starts, per-attempt scores, and adaptation decisions.
|
||||
- Iterative optimization work should focus on penalized edge or edge-cluster fixes, not whole-graph reroutes. Use whole-graph retries only as a fallback once diagnostics show the local repair path is unavailable.
|
||||
- Keep attempt 1 as the only full-strategy reroute. Attempt 2+ must target only the failed lanes or failed edge clusters, with shortest-path detours prioritized before broader quality cleanup.
|
||||
- Do not pad a local-repair iteration with generic high-severity edges that are not part of the currently failing rule set. The repair plan may add exact conflict peers for the same join/shared-lane/corridor problem, but it must not drift back toward a whole-graph reroute.
|
||||
- Local-repair iterations may build reroute candidates in parallel, but the merge back into the route must stay deterministic and keep the final per-edge apply order stable.
|
||||
- The selected layout must not backtrack inside the final target-approach window. Attempt 2+ shortest-path repair should try a direct orthogonal shortcut first and only fall back to a low-penalty 45-degree A* candidate when the orthogonal repair is blocked by other rules.
|
||||
- Keep small or protected graphs on the baseline route when the iterative sweep would risk established geometry contracts; reserve the multi-strategy path for larger congested graphs where it materially improves routing quality.
|
||||
- Keep per-attempt diagnostics granular enough to expose routing versus post-processing cost. Phase timings and route-pass counts are required evidence before widening the retry budget again.
|
||||
- Use cheap local geometry repair after routing to clean boundary-angle, target-side arrival-slot, and repeat-collector return-lane defects before escalating to more A* work. The selected layout must satisfy the node-side 90° entry/exit rule and must not leave repeat-collector lanes collapsed onto the same outer return lane.
|
||||
- Use cheap local geometry repair after routing to clean boundary-angle, target-side arrival-slot, and repeat-collector return-lane defects before escalating to more A* work. The selected layout must satisfy the node-side 90-degree entry/exit rule and must not leave repeat-collector lanes collapsed onto the same outer return lane.
|
||||
- Boundary joins must use a discrete side-slot lattice, not ad-hoc clustering. Rectangular nodes may use at most `3` evenly spread slots on `left`/`right` faces and at most `5` evenly spread slots on `top`/`bottom` faces; gateway faces may use only `1` or `2` centered face slots. Never allow more than one input/output to occupy the same resolved slot, and do not exempt singleton entries or preserved repeat/corridor exits from the lattice: a lone endpoint still has to land on its centered slot. Make scoring and final repair share the same realizable slot coordinates, and end winner refinement with a restabilization pass so late shared-lane or under-node cleanup cannot drift decision/branch exits back into mid-face clustering.
|
||||
- When shortest-path local repair evaluates obstacle-skirt candidates, include usable interior axes from the current path and a raw-clearance fallback before preserving a wider overshoot; otherwise the repair can miss an already-safe local lane and keep an unnecessary detour alive.
|
||||
- When local repair is restricted to a penalized subset, target-slot spacing must still be computed against the full peer set for that target/side so one repaired edge does not collapse back onto the unchanged arrivals beside it.
|
||||
- Decision/Fork/Join gateway nodes are polygonal, not rectangular. Keep their final landing logic gateway-specific: land on the real polygon boundary, derive target slots from polygon-face intersections instead of rectangular side slots, prefer short 45-degree diagonal stubs only on gateway side faces, never on gateway corner vertices, and do not apply rectangle-side highway or target-backtracking heuristics to gateway targets.
|
||||
- Selected layouts must not keep any lane below the node field, and any retained 45-degree segment must stay within one average node-shape length. Gateway tips are not valid final join points: source exits must leave from a face interior, and gateway-target join spreading/scoring must group arrivals by the landed boundary band rather than by the final diagonal direction alone.
|
||||
- Repeat-collector edges with preserved outer corridors are still subject to node-crossing repair. If a pre-corridor prefix crosses a node, reroute only that prefix into the preserved corridor instead of skipping the edge outright.
|
||||
- Do not replace corridor and backward-route behavior with generic rerouting unless the sprint explicitly changes that contract.
|
||||
- When touching proximity/highway logic, keep long applicable shared corridors distinct from short shared segments that must be spread apart.
|
||||
- Future A* performance work must precompute occupied grid cells or blocked segment masks and avoid expanding through cells already owned by non-terminal nodes or previously committed lanes. Derive intermediate grid spacing from approximately one third of the average service-task size instead of keeping a fixed dense lattice.
|
||||
- The A* router now precomputes node-obstacle blocked step masks per route so neighbor expansion does not rescan every node obstacle. Future performance work should extend that to precomputed lane-occupancy masks for previously committed edge lanes, so the router can skip already-owned space instead of only penalizing it after expansion. Derive intermediate grid spacing from approximately one third of the average service-task size instead of keeping a fixed dense lattice.
|
||||
- Keep `TopToBottom` behavior stable unless the sprint explicitly includes it.
|
||||
|
||||
## Testing
|
||||
|
||||
219
src/__Libraries/StellaOps.ElkSharp/ElkBoundarySlots.cs
Normal file
219
src/__Libraries/StellaOps.ElkSharp/ElkBoundarySlots.cs
Normal file
@@ -0,0 +1,219 @@
|
||||
namespace StellaOps.ElkSharp;
|
||||
|
||||
internal static class ElkBoundarySlots
|
||||
{
|
||||
private const double GatewayBoundaryInset = 4d;
|
||||
|
||||
internal static int ResolveBoundarySlotCapacity(ElkPositionedNode node, string side)
|
||||
{
|
||||
if (ElkShapeBoundaries.IsGatewayShape(node))
|
||||
{
|
||||
return side is "left" or "right" or "top" or "bottom" ? 2 : 1;
|
||||
}
|
||||
|
||||
return side switch
|
||||
{
|
||||
"left" or "right" => 3,
|
||||
"top" or "bottom" => 5,
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
|
||||
internal static double[] BuildUniqueBoundarySlotCoordinates(
|
||||
ElkPositionedNode node,
|
||||
string side,
|
||||
int endpointCount)
|
||||
{
|
||||
var slotCount = Math.Max(1, Math.Min(Math.Max(1, endpointCount), ResolveBoundarySlotCapacity(node, side)));
|
||||
var (axisMin, axisMax) = ResolveBoundarySlotAxisRange(node, side);
|
||||
if (axisMax <= axisMin)
|
||||
{
|
||||
var midpoint = (axisMin + axisMax) / 2d;
|
||||
return Enumerable.Repeat(midpoint, slotCount).ToArray();
|
||||
}
|
||||
|
||||
if (slotCount == 1)
|
||||
{
|
||||
return [(axisMin + axisMax) / 2d];
|
||||
}
|
||||
|
||||
var axisSpan = axisMax - axisMin;
|
||||
if (ElkShapeBoundaries.IsGatewayShape(node))
|
||||
{
|
||||
return Enumerable.Range(0, slotCount)
|
||||
.Select(index => axisMin + (((index + 1d) * axisSpan) / (slotCount + 1d)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
var axisStep = axisSpan / (slotCount - 1d);
|
||||
return Enumerable.Range(0, slotCount)
|
||||
.Select(index => axisMin + (index * axisStep))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
internal static double[] BuildAssignedBoundarySlotCoordinates(
|
||||
ElkPositionedNode node,
|
||||
string side,
|
||||
int endpointCount)
|
||||
{
|
||||
if (endpointCount <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var uniqueCoordinates = BuildUniqueBoundarySlotCoordinates(node, side, endpointCount);
|
||||
return Enumerable.Range(0, endpointCount)
|
||||
.Select(index => uniqueCoordinates[ResolveOrderedSlotIndex(index, endpointCount, uniqueCoordinates.Length)])
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
internal static double[] BuildAssignedBoundarySlotCoordinates(
|
||||
ElkPositionedNode node,
|
||||
string side,
|
||||
IReadOnlyList<double> orderedActualCoordinates)
|
||||
{
|
||||
if (orderedActualCoordinates.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
return BuildAssignedBoundarySlotCoordinates(node, side, orderedActualCoordinates.Count);
|
||||
}
|
||||
|
||||
internal static double[] BuildAssignedBoundarySlotAxisCoordinates(
|
||||
ElkPositionedNode node,
|
||||
string side,
|
||||
int endpointCount)
|
||||
{
|
||||
var assignedCoordinates = BuildAssignedBoundarySlotCoordinates(node, side, endpointCount);
|
||||
if (assignedCoordinates.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return assignedCoordinates
|
||||
.Select(coordinate =>
|
||||
{
|
||||
var boundaryPoint = BuildBoundarySlotPoint(node, side, coordinate);
|
||||
return side is "left" or "right"
|
||||
? boundaryPoint.Y
|
||||
: boundaryPoint.X;
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
internal static double[] BuildAssignedBoundarySlotAxisCoordinates(
|
||||
ElkPositionedNode node,
|
||||
string side,
|
||||
IReadOnlyList<double> orderedActualCoordinates)
|
||||
{
|
||||
var assignedCoordinates = BuildAssignedBoundarySlotCoordinates(node, side, orderedActualCoordinates);
|
||||
if (assignedCoordinates.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return assignedCoordinates
|
||||
.Select(coordinate =>
|
||||
{
|
||||
var boundaryPoint = BuildBoundarySlotPoint(node, side, coordinate);
|
||||
return side is "left" or "right"
|
||||
? boundaryPoint.Y
|
||||
: boundaryPoint.X;
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
internal static double ResolveRequiredBoundarySlotGap(
|
||||
ElkPositionedNode node,
|
||||
string side,
|
||||
int endpointCount,
|
||||
double defaultGap)
|
||||
{
|
||||
var assignedCoordinates = BuildAssignedBoundarySlotAxisCoordinates(node, side, endpointCount);
|
||||
if (assignedCoordinates.Length < 2)
|
||||
{
|
||||
return defaultGap;
|
||||
}
|
||||
|
||||
var minPositiveGap = double.PositiveInfinity;
|
||||
var ordered = assignedCoordinates.OrderBy(value => value).ToArray();
|
||||
for (var i = 1; i < ordered.Length; i++)
|
||||
{
|
||||
var gap = ordered[i] - ordered[i - 1];
|
||||
if (gap > 0.5d)
|
||||
{
|
||||
minPositiveGap = Math.Min(minPositiveGap, gap);
|
||||
}
|
||||
}
|
||||
|
||||
if (double.IsInfinity(minPositiveGap))
|
||||
{
|
||||
return defaultGap;
|
||||
}
|
||||
|
||||
return Math.Min(defaultGap, minPositiveGap);
|
||||
}
|
||||
|
||||
internal static int ResolveOrderedSlotIndex(int orderedIndex, int entryCount, int slotCount)
|
||||
{
|
||||
if (slotCount <= 1 || entryCount <= 1)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (orderedIndex <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (orderedIndex >= entryCount - 1)
|
||||
{
|
||||
return slotCount - 1;
|
||||
}
|
||||
|
||||
return Math.Min(slotCount - 1, (int)Math.Floor((orderedIndex * (double)slotCount) / entryCount));
|
||||
}
|
||||
|
||||
internal static ElkPoint BuildBoundarySlotPoint(
|
||||
ElkPositionedNode node,
|
||||
string side,
|
||||
double slotCoordinate)
|
||||
{
|
||||
if (ElkShapeBoundaries.TryProjectGatewayBoundarySlot(node, side, slotCoordinate, out var gatewaySlot))
|
||||
{
|
||||
return gatewaySlot;
|
||||
}
|
||||
|
||||
return side switch
|
||||
{
|
||||
"left" => new ElkPoint { X = node.X, Y = slotCoordinate },
|
||||
"right" => new ElkPoint { X = node.X + node.Width, Y = slotCoordinate },
|
||||
"top" => new ElkPoint { X = slotCoordinate, Y = node.Y },
|
||||
"bottom" => new ElkPoint { X = slotCoordinate, Y = node.Y + node.Height },
|
||||
_ => new ElkPoint
|
||||
{
|
||||
X = node.X + (node.Width / 2d),
|
||||
Y = node.Y + (node.Height / 2d),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static (double Min, double Max) ResolveBoundarySlotAxisRange(
|
||||
ElkPositionedNode node,
|
||||
string side)
|
||||
{
|
||||
if (ElkShapeBoundaries.IsGatewayShape(node))
|
||||
{
|
||||
return side is "left" or "right"
|
||||
? (node.Y + GatewayBoundaryInset, node.Y + node.Height - GatewayBoundaryInset)
|
||||
: (node.X + GatewayBoundaryInset, node.X + node.Width - GatewayBoundaryInset);
|
||||
}
|
||||
|
||||
var inset = side is "left" or "right"
|
||||
? Math.Min(24d, Math.Max(8d, node.Height / 4d))
|
||||
: Math.Min(24d, Math.Max(8d, node.Width / 4d));
|
||||
return side is "left" or "right"
|
||||
? (node.Y + inset, node.Y + node.Height - inset)
|
||||
: (node.X + inset, node.X + node.Width - inset);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2619
src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.UnderNode.cs
Normal file
2619
src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.UnderNode.cs
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -34,27 +34,60 @@ internal static class ElkEdgePostProcessorCorridor
|
||||
|
||||
var corridorY = pts[firstCorridorIndex].Y;
|
||||
var isAboveCorridor = corridorY < graphMinY - 8d;
|
||||
var clearanceMargin = Math.Max(margin, 40d);
|
||||
var result = new List<ElkPoint>();
|
||||
|
||||
if (firstCorridorIndex > 0)
|
||||
{
|
||||
if (isAboveCorridor)
|
||||
{
|
||||
var preCorridorHasCrossing = false;
|
||||
for (var i = 0; i < firstCorridorIndex; i++)
|
||||
{
|
||||
var last = result.Count > 0 ? result[^1] : (ElkPoint?)null;
|
||||
if (last is null || Math.Abs(last.X - pts[i].X) > 0.01d || Math.Abs(last.Y - pts[i].Y) > 0.01d)
|
||||
if (!ElkEdgePostProcessor.SegmentCrossesObstacle(pts[i], pts[i + 1], obstacles, sourceId, targetId)
|
||||
&& !SegmentViolatesObstacleClearance(pts[i], pts[i + 1], obstacles, sourceId, targetId, clearanceMargin))
|
||||
{
|
||||
result.Add(pts[i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
preCorridorHasCrossing = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (preCorridorHasCrossing)
|
||||
{
|
||||
var entryTarget = pts[firstCorridorIndex];
|
||||
var entryPath = ElkEdgePostProcessorAStar.RerouteWithGridAStar(
|
||||
section.StartPoint,
|
||||
entryTarget,
|
||||
obstacles,
|
||||
sourceId,
|
||||
targetId,
|
||||
margin);
|
||||
if (entryPath is not null && entryPath.Count >= 2)
|
||||
{
|
||||
result.AddRange(entryPath);
|
||||
}
|
||||
}
|
||||
|
||||
var entryX = result.Count > 0 ? result[^1].X : section.StartPoint.X;
|
||||
var entryY = result.Count > 0 ? result[^1].Y : section.StartPoint.Y;
|
||||
var safeEntryX = FindSafeVerticalX(entryX, entryY, corridorY, obstacles, sourceId, targetId);
|
||||
if (Math.Abs(safeEntryX - entryX) > 1d)
|
||||
if (result.Count == 0)
|
||||
{
|
||||
result.Add(new ElkPoint { X = safeEntryX, Y = corridorY });
|
||||
for (var i = 0; i < firstCorridorIndex; i++)
|
||||
{
|
||||
var last = result.Count > 0 ? result[^1] : (ElkPoint?)null;
|
||||
if (last is null || Math.Abs(last.X - pts[i].X) > 0.01d || Math.Abs(last.Y - pts[i].Y) > 0.01d)
|
||||
{
|
||||
result.Add(pts[i]);
|
||||
}
|
||||
}
|
||||
|
||||
var entryX = result.Count > 0 ? result[^1].X : section.StartPoint.X;
|
||||
var entryY = result.Count > 0 ? result[^1].Y : section.StartPoint.Y;
|
||||
var safeEntryX = FindSafeVerticalX(entryX, entryY, corridorY, obstacles, sourceId, targetId, clearanceMargin);
|
||||
if (Math.Abs(safeEntryX - entryX) > 1d)
|
||||
{
|
||||
result.Add(new ElkPoint { X = safeEntryX, Y = corridorY });
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -140,7 +173,8 @@ internal static class ElkEdgePostProcessorCorridor
|
||||
internal static double FindSafeVerticalX(
|
||||
double anchorX, double anchorY, double corridorY,
|
||||
(double Left, double Top, double Right, double Bottom, string Id)[] obstacles,
|
||||
string sourceId, string targetId)
|
||||
string sourceId, string targetId,
|
||||
double clearanceMargin = 0d)
|
||||
{
|
||||
var minY = Math.Min(anchorY, corridorY);
|
||||
var maxY = Math.Max(anchorY, corridorY);
|
||||
@@ -153,7 +187,10 @@ internal static class ElkEdgePostProcessorCorridor
|
||||
continue;
|
||||
}
|
||||
|
||||
if (anchorX > ob.Left && anchorX < ob.Right && maxY > ob.Top && minY < ob.Bottom)
|
||||
if (anchorX > ob.Left - clearanceMargin
|
||||
&& anchorX < ob.Right + clearanceMargin
|
||||
&& maxY > ob.Top - clearanceMargin
|
||||
&& minY < ob.Bottom + clearanceMargin)
|
||||
{
|
||||
blocked = true;
|
||||
break;
|
||||
@@ -167,16 +204,17 @@ internal static class ElkEdgePostProcessorCorridor
|
||||
|
||||
var candidateRight = anchorX;
|
||||
var candidateLeft = anchorX;
|
||||
var searchStep = Math.Max(24d, clearanceMargin * 0.5d);
|
||||
for (var attempt = 0; attempt < 20; attempt++)
|
||||
{
|
||||
candidateRight += 24d;
|
||||
if (!IsVerticalBlocked(candidateRight, minY, maxY, obstacles, sourceId, targetId))
|
||||
candidateRight += searchStep;
|
||||
if (!IsVerticalBlocked(candidateRight, minY, maxY, obstacles, sourceId, targetId, clearanceMargin))
|
||||
{
|
||||
return candidateRight;
|
||||
}
|
||||
|
||||
candidateLeft -= 24d;
|
||||
if (!IsVerticalBlocked(candidateLeft, minY, maxY, obstacles, sourceId, targetId))
|
||||
candidateLeft -= searchStep;
|
||||
if (!IsVerticalBlocked(candidateLeft, minY, maxY, obstacles, sourceId, targetId, clearanceMargin))
|
||||
{
|
||||
return candidateLeft;
|
||||
}
|
||||
@@ -188,7 +226,8 @@ internal static class ElkEdgePostProcessorCorridor
|
||||
private static bool IsVerticalBlocked(
|
||||
double x, double minY, double maxY,
|
||||
(double Left, double Top, double Right, double Bottom, string Id)[] obstacles,
|
||||
string sourceId, string targetId)
|
||||
string sourceId, string targetId,
|
||||
double clearanceMargin)
|
||||
{
|
||||
foreach (var ob in obstacles)
|
||||
{
|
||||
@@ -197,7 +236,66 @@ internal static class ElkEdgePostProcessorCorridor
|
||||
continue;
|
||||
}
|
||||
|
||||
if (x > ob.Left && x < ob.Right && maxY > ob.Top && minY < ob.Bottom)
|
||||
if (x > ob.Left - clearanceMargin
|
||||
&& x < ob.Right + clearanceMargin
|
||||
&& maxY > ob.Top - clearanceMargin
|
||||
&& minY < ob.Bottom + clearanceMargin)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool SegmentViolatesObstacleClearance(
|
||||
ElkPoint start,
|
||||
ElkPoint end,
|
||||
(double Left, double Top, double Right, double Bottom, string Id)[] obstacles,
|
||||
string sourceId,
|
||||
string targetId,
|
||||
double clearanceMargin)
|
||||
{
|
||||
if (clearanceMargin <= 0d)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var horizontal = Math.Abs(start.Y - end.Y) <= 0.5d;
|
||||
var vertical = Math.Abs(start.X - end.X) <= 0.5d;
|
||||
if (!horizontal && !vertical)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var ob in obstacles)
|
||||
{
|
||||
if (ob.Id == sourceId || ob.Id == targetId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (horizontal)
|
||||
{
|
||||
var minX = Math.Min(start.X, end.X);
|
||||
var maxX = Math.Max(start.X, end.X);
|
||||
if (start.Y > ob.Top - clearanceMargin
|
||||
&& start.Y < ob.Bottom + clearanceMargin
|
||||
&& maxX > ob.Left - clearanceMargin
|
||||
&& minX < ob.Right + clearanceMargin)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var minY = Math.Min(start.Y, end.Y);
|
||||
var maxY = Math.Max(start.Y, end.Y);
|
||||
if (start.X > ob.Left - clearanceMargin
|
||||
&& start.X < ob.Right + clearanceMargin
|
||||
&& maxY > ob.Top - clearanceMargin
|
||||
&& minY < ob.Bottom + clearanceMargin)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,15 @@ internal static class ElkEdgeRouterAStar8Dir
|
||||
return null;
|
||||
}
|
||||
|
||||
var graphMaxY = obstacles.Length > 0
|
||||
? obstacles.Max(obstacle => obstacle.Bottom)
|
||||
: double.MaxValue;
|
||||
var disallowedBottomY = graphMaxY + 4d;
|
||||
var maxDiagonalStepLength = ResolveMaxDiagonalStepLength(obstacles);
|
||||
|
||||
var blockedSegments = BuildBlockedSegments(xArr, yArr, obstacles, sourceId, targetId);
|
||||
var softObstacleInfos = BuildSoftObstacleInfos(softObstacles);
|
||||
|
||||
var startIx = Array.BinarySearch(xArr, start.X);
|
||||
var startIy = Array.BinarySearch(yArr, start.Y);
|
||||
var endIx = Array.BinarySearch(xArr, end.X);
|
||||
@@ -59,41 +68,34 @@ internal static class ElkEdgeRouterAStar8Dir
|
||||
|
||||
bool IsBlockedOrthogonal(int ix1, int iy1, int ix2, int iy2)
|
||||
{
|
||||
var x1 = xArr[ix1];
|
||||
var y1 = yArr[iy1];
|
||||
var x2 = xArr[ix2];
|
||||
var y2 = yArr[iy2];
|
||||
foreach (var ob in obstacles)
|
||||
if (ix1 == ix2)
|
||||
{
|
||||
if (ob.Id == sourceId || ob.Id == targetId)
|
||||
var minIy = Math.Min(iy1, iy2);
|
||||
var maxIy = Math.Max(iy1, iy2);
|
||||
for (var iy = minIy; iy < maxIy; iy++)
|
||||
{
|
||||
continue;
|
||||
if (blockedSegments.IsVerticalBlocked(ix1, iy))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (ix1 == ix2)
|
||||
return false;
|
||||
}
|
||||
|
||||
if (iy1 == iy2)
|
||||
{
|
||||
var minIx = Math.Min(ix1, ix2);
|
||||
var maxIx = Math.Max(ix1, ix2);
|
||||
for (var ix = minIx; ix < maxIx; ix++)
|
||||
{
|
||||
if (x1 > ob.Left && x1 < ob.Right)
|
||||
if (blockedSegments.IsHorizontalBlocked(ix, iy1))
|
||||
{
|
||||
var minY = Math.Min(y1, y2);
|
||||
var maxY = Math.Max(y1, y2);
|
||||
if (maxY > ob.Top && minY < ob.Bottom)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (iy1 == iy2)
|
||||
{
|
||||
if (y1 > ob.Top && y1 < ob.Bottom)
|
||||
{
|
||||
var minX = Math.Min(x1, x2);
|
||||
var maxX = Math.Max(x1, x2);
|
||||
if (maxX > ob.Left && minX < ob.Right)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -152,18 +154,20 @@ internal static class ElkEdgeRouterAStar8Dir
|
||||
|
||||
var maxIterations = xCount * yCount * 12;
|
||||
var iterations = 0;
|
||||
var closed = new HashSet<int>();
|
||||
var closed = new bool[stateCount];
|
||||
|
||||
while (openSet.Count > 0 && iterations++ < maxIterations)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var current = openSet.Dequeue();
|
||||
if (!closed.Add(current))
|
||||
if (closed[current])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
closed[current] = true;
|
||||
|
||||
var curDir = current % dirCount;
|
||||
var curIy = (current / dirCount) % yCount;
|
||||
var curIx = (current / dirCount) / yCount;
|
||||
@@ -182,6 +186,11 @@ internal static class ElkEdgeRouterAStar8Dir
|
||||
continue;
|
||||
}
|
||||
|
||||
if (yArr[curIy] > disallowedBottomY || yArr[ny] > disallowedBottomY)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var isDiagonal = Dx[d] != 0 && Dy[d] != 0;
|
||||
if (isDiagonal)
|
||||
{
|
||||
@@ -214,7 +223,12 @@ internal static class ElkEdgeRouterAStar8Dir
|
||||
{
|
||||
var ddx = xArr[nx] - xArr[curIx];
|
||||
var ddy = yArr[ny] - yArr[curIy];
|
||||
dist = Math.Sqrt(ddx * ddx + ddy * ddy) + routingParams.DiagonalPenalty;
|
||||
var diagonalStepLength = Math.Sqrt(ddx * ddx + ddy * ddy);
|
||||
if (diagonalStepLength > maxDiagonalStepLength)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
dist = diagonalStepLength + routingParams.DiagonalPenalty;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -223,7 +237,7 @@ internal static class ElkEdgeRouterAStar8Dir
|
||||
|
||||
var softCost = ComputeSoftObstacleCost(
|
||||
xArr[curIx], yArr[curIy], xArr[nx], yArr[ny],
|
||||
softObstacles, routingParams);
|
||||
softObstacleInfos, routingParams);
|
||||
|
||||
var tentativeG = gScore[current] + dist + bend + softCost;
|
||||
var neighborState = StateId(nx, ny, newDir);
|
||||
@@ -240,6 +254,20 @@ internal static class ElkEdgeRouterAStar8Dir
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double ResolveMaxDiagonalStepLength(
|
||||
IReadOnlyCollection<(double Left, double Top, double Right, double Bottom, string Id)> obstacles)
|
||||
{
|
||||
if (obstacles.Count == 0)
|
||||
{
|
||||
return 256d;
|
||||
}
|
||||
|
||||
var averageWidth = obstacles.Average(obstacle => obstacle.Right - obstacle.Left);
|
||||
var averageHeight = obstacles.Average(obstacle => obstacle.Bottom - obstacle.Top);
|
||||
var averageShapeSize = (averageWidth + averageHeight) / 2d;
|
||||
return Math.Max(96d, averageShapeSize * 2d);
|
||||
}
|
||||
|
||||
private static double ComputeBendPenalty(int curDir, int newDir, double bendPenalty)
|
||||
{
|
||||
if (curDir == 0 || curDir == newDir)
|
||||
@@ -259,10 +287,10 @@ internal static class ElkEdgeRouterAStar8Dir
|
||||
|
||||
private static double ComputeSoftObstacleCost(
|
||||
double x1, double y1, double x2, double y2,
|
||||
IReadOnlyList<OrthogonalSoftObstacle> softObstacles,
|
||||
SoftObstacleInfo[] softObstacles,
|
||||
AStarRoutingParams routingParams)
|
||||
{
|
||||
if (routingParams.SoftObstacleWeight <= 0d || softObstacles.Count == 0)
|
||||
if (routingParams.SoftObstacleWeight <= 0d || softObstacles.Length == 0)
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
@@ -271,10 +299,26 @@ internal static class ElkEdgeRouterAStar8Dir
|
||||
var candidateEnd = new ElkPoint { X = x2, Y = y2 };
|
||||
var candidateIsH = Math.Abs(y2 - y1) < 2d;
|
||||
var candidateIsV = Math.Abs(x2 - x1) < 2d;
|
||||
var candidateMinX = Math.Min(x1, x2);
|
||||
var candidateMaxX = Math.Max(x1, x2);
|
||||
var candidateMinY = Math.Min(y1, y2);
|
||||
var candidateMaxY = Math.Max(y1, y2);
|
||||
var expandedMinX = candidateMinX - routingParams.SoftObstacleClearance;
|
||||
var expandedMaxX = candidateMaxX + routingParams.SoftObstacleClearance;
|
||||
var expandedMinY = candidateMinY - routingParams.SoftObstacleClearance;
|
||||
var expandedMaxY = candidateMaxY + routingParams.SoftObstacleClearance;
|
||||
var cost = 0d;
|
||||
|
||||
foreach (var obstacle in softObstacles)
|
||||
{
|
||||
if (expandedMaxX < obstacle.MinX
|
||||
|| expandedMinX > obstacle.MaxX
|
||||
|| expandedMaxY < obstacle.MinY
|
||||
|| expandedMinY > obstacle.MaxY)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ElkEdgeRoutingGeometry.SegmentsIntersect(candidateStart, candidateEnd, obstacle.Start, obstacle.End))
|
||||
{
|
||||
cost += 120d * routingParams.SoftObstacleWeight;
|
||||
@@ -284,7 +328,7 @@ internal static class ElkEdgeRouterAStar8Dir
|
||||
// Graduated proximity: closer = exponentially more expensive
|
||||
var dist = ComputeParallelDistance(
|
||||
x1, y1, x2, y2, candidateIsH, candidateIsV,
|
||||
obstacle.Start, obstacle.End,
|
||||
obstacle,
|
||||
routingParams.SoftObstacleClearance);
|
||||
|
||||
if (dist >= 0d)
|
||||
@@ -300,41 +344,202 @@ internal static class ElkEdgeRouterAStar8Dir
|
||||
private static double ComputeParallelDistance(
|
||||
double x1, double y1, double x2, double y2,
|
||||
bool candidateIsH, bool candidateIsV,
|
||||
ElkPoint obStart, ElkPoint obEnd,
|
||||
SoftObstacleInfo obstacle,
|
||||
double clearance)
|
||||
{
|
||||
var obIsH = Math.Abs(obStart.Y - obEnd.Y) < 2d;
|
||||
var obIsV = Math.Abs(obStart.X - obEnd.X) < 2d;
|
||||
|
||||
if (candidateIsH && obIsH)
|
||||
if (candidateIsH && obstacle.IsHorizontal)
|
||||
{
|
||||
var dist = Math.Abs(y1 - obStart.Y);
|
||||
var dist = Math.Abs(y1 - obstacle.Start.Y);
|
||||
if (dist >= clearance)
|
||||
{
|
||||
return -1d;
|
||||
}
|
||||
|
||||
var overlapMin = Math.Max(Math.Min(x1, x2), Math.Min(obStart.X, obEnd.X));
|
||||
var overlapMax = Math.Min(Math.Max(x1, x2), Math.Max(obStart.X, obEnd.X));
|
||||
var overlapMin = Math.Max(Math.Min(x1, x2), obstacle.MinX);
|
||||
var overlapMax = Math.Min(Math.Max(x1, x2), obstacle.MaxX);
|
||||
return overlapMax > overlapMin + 1d ? dist : -1d;
|
||||
}
|
||||
|
||||
if (candidateIsV && obIsV)
|
||||
if (candidateIsV && obstacle.IsVertical)
|
||||
{
|
||||
var dist = Math.Abs(x1 - obStart.X);
|
||||
var dist = Math.Abs(x1 - obstacle.Start.X);
|
||||
if (dist >= clearance)
|
||||
{
|
||||
return -1d;
|
||||
}
|
||||
|
||||
var overlapMin = Math.Max(Math.Min(y1, y2), Math.Min(obStart.Y, obEnd.Y));
|
||||
var overlapMax = Math.Min(Math.Max(y1, y2), Math.Max(obStart.Y, obEnd.Y));
|
||||
var overlapMin = Math.Max(Math.Min(y1, y2), obstacle.MinY);
|
||||
var overlapMax = Math.Min(Math.Max(y1, y2), obstacle.MaxY);
|
||||
return overlapMax > overlapMin + 1d ? dist : -1d;
|
||||
}
|
||||
|
||||
return -1d;
|
||||
}
|
||||
|
||||
private static BlockedSegments BuildBlockedSegments(
|
||||
double[] xArr,
|
||||
double[] yArr,
|
||||
(double Left, double Top, double Right, double Bottom, string Id)[] obstacles,
|
||||
string sourceId,
|
||||
string targetId)
|
||||
{
|
||||
var xCount = xArr.Length;
|
||||
var yCount = yArr.Length;
|
||||
var verticalBlocked = new bool[xCount * Math.Max(0, yCount - 1)];
|
||||
var horizontalBlocked = new bool[Math.Max(0, xCount - 1) * yCount];
|
||||
|
||||
foreach (var obstacle in obstacles)
|
||||
{
|
||||
if (obstacle.Id == sourceId || obstacle.Id == targetId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var verticalXStart = Math.Max(0, LowerBoundExclusive(xArr, obstacle.Left));
|
||||
var verticalXEnd = Math.Min(xCount - 1, UpperBoundExclusive(xArr, obstacle.Right) - 1);
|
||||
if (verticalXStart <= verticalXEnd)
|
||||
{
|
||||
var verticalYStart = Math.Max(0, LowerBound(yArr, obstacle.Top) - 1);
|
||||
var verticalYEnd = Math.Min(yCount - 2, UpperBound(yArr, obstacle.Bottom) - 1);
|
||||
for (var ix = verticalXStart; ix <= verticalXEnd; ix++)
|
||||
{
|
||||
for (var iy = verticalYStart; iy <= verticalYEnd; iy++)
|
||||
{
|
||||
if (yArr[iy + 1] > obstacle.Top && yArr[iy] < obstacle.Bottom)
|
||||
{
|
||||
verticalBlocked[(ix * (yCount - 1)) + iy] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var horizontalYStart = Math.Max(0, LowerBoundExclusive(yArr, obstacle.Top));
|
||||
var horizontalYEnd = Math.Min(yCount - 1, UpperBoundExclusive(yArr, obstacle.Bottom) - 1);
|
||||
if (horizontalYStart <= horizontalYEnd)
|
||||
{
|
||||
var horizontalXStart = Math.Max(0, LowerBound(xArr, obstacle.Left) - 1);
|
||||
var horizontalXEnd = Math.Min(xCount - 2, UpperBound(xArr, obstacle.Right) - 1);
|
||||
for (var iy = horizontalYStart; iy <= horizontalYEnd; iy++)
|
||||
{
|
||||
for (var ix = horizontalXStart; ix <= horizontalXEnd; ix++)
|
||||
{
|
||||
if (xArr[ix + 1] > obstacle.Left && xArr[ix] < obstacle.Right)
|
||||
{
|
||||
horizontalBlocked[(ix * yCount) + iy] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new BlockedSegments(xCount, yCount, verticalBlocked, horizontalBlocked);
|
||||
}
|
||||
|
||||
private static SoftObstacleInfo[] BuildSoftObstacleInfos(IReadOnlyList<OrthogonalSoftObstacle> softObstacles)
|
||||
{
|
||||
if (softObstacles.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var infos = new SoftObstacleInfo[softObstacles.Count];
|
||||
for (var i = 0; i < softObstacles.Count; i++)
|
||||
{
|
||||
var obstacle = softObstacles[i];
|
||||
infos[i] = new SoftObstacleInfo(
|
||||
obstacle.Start,
|
||||
obstacle.End,
|
||||
Math.Min(obstacle.Start.X, obstacle.End.X),
|
||||
Math.Max(obstacle.Start.X, obstacle.End.X),
|
||||
Math.Min(obstacle.Start.Y, obstacle.End.Y),
|
||||
Math.Max(obstacle.Start.Y, obstacle.End.Y),
|
||||
Math.Abs(obstacle.Start.Y - obstacle.End.Y) < 2d,
|
||||
Math.Abs(obstacle.Start.X - obstacle.End.X) < 2d);
|
||||
}
|
||||
|
||||
return infos;
|
||||
}
|
||||
|
||||
private static int LowerBound(double[] values, double target)
|
||||
{
|
||||
var low = 0;
|
||||
var high = values.Length;
|
||||
while (low < high)
|
||||
{
|
||||
var mid = low + ((high - low) / 2);
|
||||
if (values[mid] < target)
|
||||
{
|
||||
low = mid + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
|
||||
return low;
|
||||
}
|
||||
|
||||
private static int UpperBound(double[] values, double target)
|
||||
{
|
||||
var low = 0;
|
||||
var high = values.Length;
|
||||
while (low < high)
|
||||
{
|
||||
var mid = low + ((high - low) / 2);
|
||||
if (values[mid] <= target)
|
||||
{
|
||||
low = mid + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
|
||||
return low;
|
||||
}
|
||||
|
||||
private static int LowerBoundExclusive(double[] values, double target)
|
||||
{
|
||||
var low = 0;
|
||||
var high = values.Length;
|
||||
while (low < high)
|
||||
{
|
||||
var mid = low + ((high - low) / 2);
|
||||
if (values[mid] <= target)
|
||||
{
|
||||
low = mid + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
|
||||
return low;
|
||||
}
|
||||
|
||||
private static int UpperBoundExclusive(double[] values, double target)
|
||||
{
|
||||
var low = 0;
|
||||
var high = values.Length;
|
||||
while (low < high)
|
||||
{
|
||||
var mid = low + ((high - low) / 2);
|
||||
if (values[mid] < target)
|
||||
{
|
||||
low = mid + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
|
||||
return low;
|
||||
}
|
||||
|
||||
private static List<ElkPoint> ReconstructPath(
|
||||
int endState, int[] cameFrom,
|
||||
double[] xArr, double[] yArr,
|
||||
@@ -391,4 +596,41 @@ internal static class ElkEdgeRouterAStar8Dir
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct SoftObstacleInfo(
|
||||
ElkPoint Start,
|
||||
ElkPoint End,
|
||||
double MinX,
|
||||
double MaxX,
|
||||
double MinY,
|
||||
double MaxY,
|
||||
bool IsHorizontal,
|
||||
bool IsVertical);
|
||||
|
||||
private readonly record struct BlockedSegments(
|
||||
int XCount,
|
||||
int YCount,
|
||||
bool[] VerticalBlocked,
|
||||
bool[] HorizontalBlocked)
|
||||
{
|
||||
internal bool IsVerticalBlocked(int ix, int iy)
|
||||
{
|
||||
if (ix < 0 || ix >= XCount || iy < 0 || iy >= YCount - 1)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return VerticalBlocked[(ix * (YCount - 1)) + iy];
|
||||
}
|
||||
|
||||
internal bool IsHorizontalBlocked(int ix, int iy)
|
||||
{
|
||||
if (ix < 0 || ix >= XCount - 1 || iy < 0 || iy >= YCount)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return HorizontalBlocked[(ix * YCount) + iy];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,11 @@ internal static class ElkEdgeRouterHighway
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ElkShapeBoundaries.IsGatewayShape(targetNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = ExtractFullPath(edge);
|
||||
if (path.Count < 2)
|
||||
{
|
||||
@@ -203,7 +208,12 @@ internal static class ElkEdgeRouterHighway
|
||||
|
||||
var pairMetrics = ComputePairMetrics(members);
|
||||
var actualGap = ComputeMinEndpointGap(members, side);
|
||||
var requiresSpread = (actualGap + CoordinateTolerance) < minLineClearance
|
||||
var requiredGap = ElkBoundarySlots.ResolveRequiredBoundarySlotGap(
|
||||
targetNode,
|
||||
side,
|
||||
members.Count,
|
||||
minLineClearance);
|
||||
var requiresSpread = (actualGap + CoordinateTolerance) < requiredGap
|
||||
&& !pairMetrics.AllPairsApplicable;
|
||||
if (!requiresSpread && pairMetrics.ShortestSharedRatio < MinHighwayRatio)
|
||||
{
|
||||
@@ -223,10 +233,10 @@ internal static class ElkEdgeRouterHighway
|
||||
Reason = requiresSpread
|
||||
? pairMetrics.HasSharedSegment && pairMetrics.ShortestSharedRatio < MinHighwayRatio
|
||||
? $"shared ratio {pairMetrics.ShortestSharedRatio:F2} < {MinHighwayRatio:F2}"
|
||||
: $"gap {actualGap:F0}px < clearance {minLineClearance:F0}px"
|
||||
: $"gap {actualGap:F0}px < required gap {requiredGap:F0}px"
|
||||
: pairMetrics.AllPairsApplicable
|
||||
? $"shared ratio {pairMetrics.ShortestSharedRatio:F2} >= {MinHighwayRatio:F2}"
|
||||
: $"gap {actualGap:F0}px >= clearance {minLineClearance:F0}px",
|
||||
: $"gap {actualGap:F0}px >= required gap {requiredGap:F0}px",
|
||||
};
|
||||
|
||||
return new GroupEvaluation(members, diagnostic);
|
||||
@@ -300,33 +310,7 @@ internal static class ElkEdgeRouterHighway
|
||||
int count,
|
||||
double minLineClearance)
|
||||
{
|
||||
if (count <= 1)
|
||||
{
|
||||
return
|
||||
[
|
||||
side is "left" or "right"
|
||||
? targetNode.Y + (targetNode.Height / 2d)
|
||||
: targetNode.X + (targetNode.Width / 2d),
|
||||
];
|
||||
}
|
||||
|
||||
var axisMin = side is "left" or "right"
|
||||
? targetNode.Y + BoundaryInset
|
||||
: targetNode.X + BoundaryInset;
|
||||
var axisMax = side is "left" or "right"
|
||||
? targetNode.Y + targetNode.Height - BoundaryInset
|
||||
: targetNode.X + targetNode.Width - BoundaryInset;
|
||||
var axisLength = Math.Max(8d, axisMax - axisMin);
|
||||
var spacing = Math.Max(
|
||||
MinimumSpreadSpacing,
|
||||
Math.Min(minLineClearance, axisLength / (count - 1)));
|
||||
var totalSpan = (count - 1) * spacing;
|
||||
var center = (axisMin + axisMax) / 2d;
|
||||
var start = Math.Max(axisMin, Math.Min(center - (totalSpan / 2d), axisMax - totalSpan));
|
||||
|
||||
return Enumerable.Range(0, count)
|
||||
.Select(index => Math.Min(axisMax, start + (index * spacing)))
|
||||
.ToArray();
|
||||
return ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates(targetNode, side, count);
|
||||
}
|
||||
|
||||
private static List<ElkPoint> AdjustPathToTargetSlot(
|
||||
|
||||
@@ -0,0 +1,924 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.ElkSharp;
|
||||
|
||||
internal static partial class ElkEdgeRouterIterative
|
||||
{
|
||||
private static ElkRoutedEdge[] ApplyPostProcessing(
|
||||
ElkRoutedEdge[] edges,
|
||||
ElkPositionedNode[] nodes,
|
||||
ElkLayoutOptions layoutOptions)
|
||||
{
|
||||
var result = ElkEdgePostProcessor.AvoidNodeCrossings(edges, nodes, layoutOptions.Direction);
|
||||
result = ElkEdgePostProcessor.EliminateDiagonalSegments(result, nodes);
|
||||
result = ElkEdgePostProcessorSimplify.SimplifyEdgePaths(result, nodes);
|
||||
result = ElkEdgePostProcessorSimplify.TightenOuterCorridors(result, nodes);
|
||||
if (HighwayProcessingEnabled)
|
||||
{
|
||||
result = ElkEdgeRouterHighway.BreakShortHighways(result, nodes);
|
||||
}
|
||||
|
||||
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
|
||||
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction);
|
||||
result = ElkEdgePostProcessorSimplify.SimplifyEdgePaths(result, nodes);
|
||||
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction);
|
||||
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
|
||||
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);
|
||||
result = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
|
||||
result,
|
||||
nodes,
|
||||
minLineClearance,
|
||||
enforceAllNodeEndpoints: true);
|
||||
result = ApplyPostSlotDetourClosure(result, nodes, minLineClearance);
|
||||
result = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
|
||||
result,
|
||||
nodes,
|
||||
minLineClearance,
|
||||
enforceAllNodeEndpoints: true);
|
||||
|
||||
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 (IsBetterBoundarySlotRepairCandidate(
|
||||
stabilizedScore,
|
||||
stabilizedRetryState,
|
||||
score,
|
||||
retryState))
|
||||
{
|
||||
result = stabilized;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ElkRoutedEdge[] ApplyTerminalRuleCleanupRound(
|
||||
ElkRoutedEdge[] edges,
|
||||
ElkPositionedNode[] nodes,
|
||||
ElkLayoutDirection direction,
|
||||
double minLineClearance,
|
||||
IReadOnlyCollection<string>? restrictedEdgeIds = null)
|
||||
{
|
||||
var result = edges;
|
||||
result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(result, nodes, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
|
||||
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
|
||||
result = ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkRepeatCollectorCorridors.SeparateSharedLanes(result, nodes, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(result, nodes, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes, restrictedEdgeIds);
|
||||
result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
|
||||
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
|
||||
result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes, restrictedEdgeIds);
|
||||
result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
|
||||
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
|
||||
// Final late-stage verification: source/target boundary normalization can collapse
|
||||
// lanes back onto the same node face, so restabilize the local geometry once more.
|
||||
result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
|
||||
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
|
||||
result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
|
||||
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
|
||||
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(result, nodes, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes, restrictedEdgeIds);
|
||||
result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
|
||||
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
|
||||
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
|
||||
// Final hard-rule restabilization after the last normalize pass: the final
|
||||
// boundary normalization can still pull target slots and horizontal lanes back
|
||||
// into a bad state, so re-apply the local rule fixers once more before scoring.
|
||||
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
|
||||
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
|
||||
result = CloseRemainingTerminalViolations(result, nodes, direction, minLineClearance, restrictedEdgeIds);
|
||||
var lateDetourShortcuts = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(result, nodes, restrictedEdgeIds);
|
||||
result = ElkEdgeRoutingScoring.CountBoundarySlotViolations(result, nodes) > 0
|
||||
? ChoosePreferredBoundarySlotRepairLayout(result, lateDetourShortcuts, nodes)
|
||||
: ChoosePreferredHardRuleLayout(result, lateDetourShortcuts, nodes);
|
||||
result = ApplyFinalDetourPolish(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
|
||||
result,
|
||||
nodes,
|
||||
minLineClearance,
|
||||
restrictedEdgeIds,
|
||||
enforceAllNodeEndpoints: true);
|
||||
result = ApplyPostSlotDetourClosure(result, nodes, minLineClearance, restrictedEdgeIds);
|
||||
result = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
|
||||
result,
|
||||
nodes,
|
||||
minLineClearance,
|
||||
restrictedEdgeIds,
|
||||
enforceAllNodeEndpoints: true);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ElkRoutedEdge[] ApplyFinalDetourPolish(
|
||||
ElkRoutedEdge[] edges,
|
||||
ElkPositionedNode[] nodes,
|
||||
double minLineClearance,
|
||||
IReadOnlyCollection<string>? 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<string, int>(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<string>)[edgeId];
|
||||
var candidateEdges = ComposeTransactionalFinalDetourCandidate(
|
||||
result,
|
||||
nodes,
|
||||
minLineClearance,
|
||||
focused);
|
||||
candidateEdges = ChoosePreferredHardRuleLayout(result, candidateEdges, nodes);
|
||||
if (ReferenceEquals(candidateEdges, result))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes);
|
||||
var candidateRetryState = BuildRetryState(
|
||||
candidateScore,
|
||||
HighwayProcessingEnabled
|
||||
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count
|
||||
: 0);
|
||||
|
||||
var improvedDetours = candidateRetryState.ExcessiveDetourViolations < currentRetryState.ExcessiveDetourViolations;
|
||||
if (HasHardRuleRegression(candidateRetryState, currentRetryState)
|
||||
|| (!improvedDetours
|
||||
&& !IsBetterBoundarySlotRepairCandidate(
|
||||
candidateScore,
|
||||
candidateRetryState,
|
||||
currentScore,
|
||||
currentRetryState)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result = candidateEdges;
|
||||
improved = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!improved)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool TryPromoteFinalDetourCandidate(
|
||||
ElkRoutedEdge[] baselineEdges,
|
||||
ElkRoutedEdge[] candidateEdges,
|
||||
ElkPositionedNode[] nodes,
|
||||
EdgeRoutingScore baselineScore,
|
||||
RoutingRetryState baselineRetryState,
|
||||
out ElkRoutedEdge[] promotedEdges)
|
||||
{
|
||||
promotedEdges = baselineEdges;
|
||||
if (ReferenceEquals(candidateEdges, baselineEdges))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes);
|
||||
var candidateRetryState = BuildRetryState(
|
||||
candidateScore,
|
||||
HighwayProcessingEnabled
|
||||
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count
|
||||
: 0);
|
||||
|
||||
var improvedDetours = candidateRetryState.ExcessiveDetourViolations < baselineRetryState.ExcessiveDetourViolations;
|
||||
var improvedGatewaySource = candidateRetryState.GatewaySourceExitViolations < baselineRetryState.GatewaySourceExitViolations;
|
||||
if (HasHardRuleRegression(candidateRetryState, baselineRetryState)
|
||||
|| (!(improvedDetours || improvedGatewaySource)
|
||||
&& !IsBetterBoundarySlotRepairCandidate(
|
||||
candidateScore,
|
||||
candidateRetryState,
|
||||
baselineScore,
|
||||
baselineRetryState)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
promotedEdges = candidateEdges;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ElkRoutedEdge[] ComposeTransactionalFinalDetourCandidate(
|
||||
ElkRoutedEdge[] baseline,
|
||||
ElkPositionedNode[] nodes,
|
||||
double minLineClearance,
|
||||
IReadOnlyCollection<string> focusedEdgeIds)
|
||||
{
|
||||
var candidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(baseline, nodes, focusedEdgeIds);
|
||||
if (ReferenceEquals(candidate, baseline))
|
||||
{
|
||||
return baseline;
|
||||
}
|
||||
|
||||
candidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(candidate, nodes, minLineClearance, focusedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(candidate, nodes, minLineClearance, focusedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(candidate, nodes, minLineClearance, focusedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(candidate, nodes, minLineClearance, focusedEdgeIds);
|
||||
candidate = ClampBelowGraphEdges(candidate, nodes, focusedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(candidate, nodes, minLineClearance, focusedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(candidate, nodes, minLineClearance, focusedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(candidate, nodes, minLineClearance, focusedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(candidate, nodes, focusedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(candidate, nodes, focusedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(candidate, nodes, minLineClearance, focusedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
|
||||
candidate,
|
||||
nodes,
|
||||
minLineClearance,
|
||||
focusedEdgeIds,
|
||||
enforceAllNodeEndpoints: true);
|
||||
candidate = ApplyPostSlotDetourClosure(candidate, nodes, minLineClearance, focusedEdgeIds);
|
||||
candidate = ApplyLateBoundarySlotRestabilization(candidate, nodes, minLineClearance, focusedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(candidate, nodes, minLineClearance, focusedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts(candidate, nodes, minLineClearance, focusedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
|
||||
candidate,
|
||||
nodes,
|
||||
minLineClearance,
|
||||
focusedEdgeIds,
|
||||
enforceAllNodeEndpoints: true);
|
||||
candidate = ApplyPostSlotDetourClosure(candidate, nodes, minLineClearance, focusedEdgeIds);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
internal static ElkRoutedEdge[] ApplyPostSlotDetourClosure(
|
||||
ElkRoutedEdge[] edges,
|
||||
ElkPositionedNode[] nodes,
|
||||
double minLineClearance,
|
||||
IReadOnlyCollection<string>? restrictedEdgeIds = null)
|
||||
{
|
||||
var candidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(edges, nodes, restrictedEdgeIds);
|
||||
if (ReferenceEquals(candidate, edges))
|
||||
{
|
||||
return edges;
|
||||
}
|
||||
|
||||
candidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes);
|
||||
candidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes);
|
||||
candidate = ElkEdgePostProcessor.SpreadSourceDepartureJoins(candidate, nodes, minLineClearance, restrictedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(candidate, nodes, minLineClearance, restrictedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(candidate, nodes, minLineClearance, restrictedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(candidate, nodes, minLineClearance, restrictedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(candidate, nodes, restrictedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(candidate, nodes, restrictedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(candidate, nodes, minLineClearance, restrictedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(candidate, nodes, minLineClearance, restrictedEdgeIds);
|
||||
candidate = ClampBelowGraphEdges(candidate, nodes, restrictedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes);
|
||||
candidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes);
|
||||
candidate = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(candidate, nodes, minLineClearance, restrictedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts(candidate, nodes, minLineClearance, restrictedEdgeIds);
|
||||
candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
|
||||
candidate,
|
||||
nodes,
|
||||
minLineClearance,
|
||||
restrictedEdgeIds,
|
||||
enforceAllNodeEndpoints: true);
|
||||
|
||||
return ElkEdgeRoutingScoring.CountBoundarySlotViolations(edges, nodes) > 0
|
||||
? ChoosePreferredBoundarySlotRepairLayout(edges, candidate, nodes)
|
||||
: ChoosePreferredHardRuleLayout(edges, candidate, nodes);
|
||||
}
|
||||
|
||||
private static ElkRoutedEdge[] CloseRemainingTerminalViolations(
|
||||
ElkRoutedEdge[] edges,
|
||||
ElkPositionedNode[] nodes,
|
||||
ElkLayoutDirection direction,
|
||||
double minLineClearance,
|
||||
IReadOnlyCollection<string>? restrictedEdgeIds)
|
||||
{
|
||||
var result = edges;
|
||||
var restrictedSet = restrictedEdgeIds is null
|
||||
? null
|
||||
: restrictedEdgeIds.ToHashSet(StringComparer.Ordinal);
|
||||
ElkLayoutDiagnostics.LogProgress(
|
||||
$"Terminal closure start: restricted={restrictedEdgeIds?.Count ?? 0}");
|
||||
|
||||
for (var round = 0; round < 4; round++)
|
||||
{
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} pressure scan start");
|
||||
var severityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var previousHardPressure =
|
||||
ElkEdgeRoutingScoring.CountBadBoundaryAngles(result, nodes, severityByEdgeId, 10)
|
||||
+ ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(result, nodes, severityByEdgeId, 10)
|
||||
+ ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(result, nodes, severityByEdgeId, 10)
|
||||
+ ElkEdgeRoutingScoring.CountSharedLaneViolations(result, nodes, severityByEdgeId, 10)
|
||||
+ ElkEdgeRoutingScoring.CountBoundarySlotViolations(result, nodes, severityByEdgeId, 10)
|
||||
+ ElkEdgeRoutingScoring.CountBelowGraphViolations(result, nodes, severityByEdgeId, 10)
|
||||
+ ElkEdgeRoutingScoring.CountUnderNodeViolations(result, nodes, severityByEdgeId, 10);
|
||||
var previousLengthPressure = 0;
|
||||
if (previousHardPressure == 0)
|
||||
{
|
||||
previousLengthPressure =
|
||||
ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(result, nodes, severityByEdgeId, 10)
|
||||
+ ElkEdgeRoutingScoring.CountExcessiveDetourViolations(result, nodes, severityByEdgeId, 10);
|
||||
}
|
||||
ElkLayoutDiagnostics.LogProgress(
|
||||
$"Terminal closure round {round + 1} pressure scan done: hard={previousHardPressure} length={previousLengthPressure} severity={severityByEdgeId.Count}");
|
||||
|
||||
var previousScore = ElkEdgeRoutingScoring.ComputeScore(result, nodes);
|
||||
var previousRetryState = BuildRetryState(
|
||||
previousScore,
|
||||
HighwayProcessingEnabled
|
||||
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(result, nodes).Count
|
||||
: 0);
|
||||
ElkLayoutDiagnostics.LogProgress(
|
||||
$"Terminal closure round {round + 1} retry state ready: {DescribeRetryState(previousRetryState)}");
|
||||
if (previousHardPressure == 0 && previousLengthPressure == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var focusEdgeIds = severityByEdgeId.Keys
|
||||
.Where(edgeId => restrictedSet is null || restrictedSet.Contains(edgeId))
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (focusEdgeIds.Length == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
ElkLayoutDiagnostics.LogProgress(
|
||||
$"Terminal closure round {round + 1} focus ready: count={focusEdgeIds.Length}");
|
||||
|
||||
var focused = (IReadOnlyCollection<string>)focusEdgeIds;
|
||||
var candidate = result;
|
||||
if (previousHardPressure > 0
|
||||
&& ShouldPreferCompactFocusedTerminalClosure(previousRetryState, focusEdgeIds.Length))
|
||||
{
|
||||
ElkLayoutDiagnostics.LogProgress(
|
||||
$"Terminal closure round {round + 1} compact hard-pass start");
|
||||
candidate = ApplyCompactFocusedTerminalClosure(
|
||||
candidate,
|
||||
nodes,
|
||||
direction,
|
||||
minLineClearance,
|
||||
focused);
|
||||
ElkLayoutDiagnostics.LogProgress(
|
||||
$"Terminal closure round {round + 1} compact hard-pass complete");
|
||||
}
|
||||
else if (previousHardPressure > 0)
|
||||
{
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} hard-pass block start");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-boundary");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-source-exit");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateSharedLaneConflicts(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-shared-lanes-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-repeat-collector-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkRepeatCollectorCorridors.SeparateSharedLanes(current, nodes, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repeat-collector-shared-lanes-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadSourceDepartureJoins(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-source-joins-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-mixed-faces-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.ElevateUnderNodeViolations(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after elevate-under-node-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after elevate-repeat-collector-clearance-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(current, nodes, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after prefer-shortest-shortcuts-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ClampBelowGraphEdges(current, nodes, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after clamp-below-graph-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repair-boundary-target-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadTargetApproachJoins(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-target-joins-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-feeder-bands-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(current, nodes, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after finalize-gateway-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.AvoidNodeCrossings(current, nodes, direction, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after avoid-node-crossings-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.ElevateUnderNodeViolations(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after elevate-under-node-2");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-boundary-2");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-source-exit-2");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateSharedLaneConflicts(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-shared-lanes-2");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-repeat-collector-2");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkRepeatCollectorCorridors.SeparateSharedLanes(current, nodes, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repeat-collector-shared-lanes-2");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-boundary-3");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-source-exit-3");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadSourceDepartureJoins(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-source-joins-2");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-mixed-faces-2");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repair-boundary-target-2");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadTargetApproachJoins(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-target-joins-2");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-feeder-bands-2");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ClampBelowGraphEdges(current, nodes, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after clamp-below-graph-2");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.AvoidNodeCrossings(current, nodes, direction, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after avoid-node-crossings-2");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.ElevateUnderNodeViolations(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after elevate-under-node-3");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-boundary-4");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-source-exit-4");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadSourceDepartureJoins(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-source-joins-3");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-mixed-faces-3");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateSharedLaneConflicts(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-shared-lanes-3");
|
||||
}
|
||||
else
|
||||
{
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} length-pass block start");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(current, nodes, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after prefer-shortest-shortcuts-length-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repair-boundary-target-length-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadTargetApproachJoins(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-target-joins-length-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-feeder-bands-length-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeDecisionTargetEntries(current, nodes, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after finalize-decision-targets-length-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(current, nodes, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after finalize-gateway-length-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ClampBelowGraphEdges(current, nodes, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after clamp-below-graph-length-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.AvoidNodeCrossings(current, nodes, direction, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after avoid-node-crossings-length-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-boundary-length-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-source-exit-length-1");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(current, nodes, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after prefer-shortest-shortcuts-length-2");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repair-boundary-target-length-2");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadTargetApproachJoins(current, nodes, minLineClearance, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-target-joins-length-2");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeDecisionTargetEntries(current, nodes, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after finalize-decision-targets-length-2");
|
||||
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(current, nodes, focused));
|
||||
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after finalize-gateway-length-2");
|
||||
}
|
||||
|
||||
var currentHardPressure =
|
||||
ElkEdgeRoutingScoring.CountBadBoundaryAngles(candidate, nodes)
|
||||
+ ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(candidate, nodes)
|
||||
+ ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(candidate, nodes)
|
||||
+ ElkEdgeRoutingScoring.CountSharedLaneViolations(candidate, nodes)
|
||||
+ ElkEdgeRoutingScoring.CountBoundarySlotViolations(candidate, nodes)
|
||||
+ ElkEdgeRoutingScoring.CountBelowGraphViolations(candidate, nodes)
|
||||
+ ElkEdgeRoutingScoring.CountUnderNodeViolations(candidate, nodes);
|
||||
var currentLengthPressure =
|
||||
ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(candidate, nodes)
|
||||
+ ElkEdgeRoutingScoring.CountExcessiveDetourViolations(candidate, nodes);
|
||||
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes);
|
||||
var candidateRetryState = BuildRetryState(
|
||||
candidateScore,
|
||||
HighwayProcessingEnabled
|
||||
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count
|
||||
: 0);
|
||||
var improvedBoundarySlots = candidateRetryState.BoundarySlotViolations < previousRetryState.BoundarySlotViolations;
|
||||
var rejectedByRegression = improvedBoundarySlots
|
||||
? candidateScore.NodeCrossings > previousScore.NodeCrossings
|
||||
|| HasBlockingBoundarySlotPromotionRegression(candidateRetryState, previousRetryState)
|
||||
: HasHardRuleRegression(candidateRetryState, previousRetryState);
|
||||
var madeProgress = improvedBoundarySlots
|
||||
|| (previousHardPressure > 0
|
||||
? currentHardPressure < previousHardPressure
|
||||
: currentLengthPressure < previousLengthPressure);
|
||||
if (rejectedByRegression || !madeProgress)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
result = candidate;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ElkRoutedEdge[] ApplyGuardedFocusedHardRulePass(
|
||||
ElkRoutedEdge[] current,
|
||||
ElkPositionedNode[] nodes,
|
||||
Func<ElkRoutedEdge[], ElkRoutedEdge[]> pass)
|
||||
{
|
||||
var candidate = pass(current);
|
||||
return ElkEdgeRoutingScoring.CountBoundarySlotViolations(current, nodes) > 0
|
||||
? ChoosePreferredBoundarySlotRepairLayout(current, candidate, nodes)
|
||||
: ChoosePreferredHardRuleLayout(current, candidate, nodes);
|
||||
}
|
||||
|
||||
private static ElkRoutedEdge[] ChoosePreferredBoundarySlotRepairLayout(
|
||||
ElkRoutedEdge[] baseline,
|
||||
ElkRoutedEdge[] candidate,
|
||||
ElkPositionedNode[] nodes)
|
||||
{
|
||||
if (ReferenceEquals(candidate, baseline))
|
||||
{
|
||||
return baseline;
|
||||
}
|
||||
|
||||
var baselineScore = ElkEdgeRoutingScoring.ComputeScore(baseline, nodes);
|
||||
var baselineRetryState = BuildRetryState(
|
||||
baselineScore,
|
||||
HighwayProcessingEnabled
|
||||
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(baseline, nodes).Count
|
||||
: 0);
|
||||
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes);
|
||||
var candidateRetryState = BuildRetryState(
|
||||
candidateScore,
|
||||
HighwayProcessingEnabled
|
||||
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count
|
||||
: 0);
|
||||
|
||||
if (!IsBetterBoundarySlotRepairCandidate(
|
||||
candidateScore,
|
||||
candidateRetryState,
|
||||
baselineScore,
|
||||
baselineRetryState))
|
||||
{
|
||||
return baseline;
|
||||
}
|
||||
|
||||
// Boundary-slot repair is staged ahead of other soft cleanups. Once a
|
||||
// candidate legitimately reduces boundary-slot violations without
|
||||
// introducing a blocking hard regression, keep it alive so the later
|
||||
// shared-lane / detour passes can recover any temporary soft tradeoff.
|
||||
if (candidateRetryState.BoundarySlotViolations < baselineRetryState.BoundarySlotViolations)
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
var retryComparison = CompareRetryStates(candidateRetryState, baselineRetryState);
|
||||
if (retryComparison < 0)
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (retryComparison > 0)
|
||||
{
|
||||
return baseline;
|
||||
}
|
||||
|
||||
if (candidateScore.NodeCrossings != baselineScore.NodeCrossings)
|
||||
{
|
||||
return candidateScore.NodeCrossings < baselineScore.NodeCrossings
|
||||
? candidate
|
||||
: baseline;
|
||||
}
|
||||
|
||||
return candidateScore.Value > baselineScore.Value
|
||||
? candidate
|
||||
: baseline;
|
||||
}
|
||||
|
||||
private static ElkRoutedEdge[] ChoosePreferredSharedLanePolishLayout(
|
||||
ElkRoutedEdge[] baseline,
|
||||
ElkRoutedEdge[] candidate,
|
||||
ElkPositionedNode[] nodes)
|
||||
{
|
||||
if (ReferenceEquals(candidate, baseline))
|
||||
{
|
||||
return baseline;
|
||||
}
|
||||
|
||||
var baselineScore = ElkEdgeRoutingScoring.ComputeScore(baseline, nodes);
|
||||
var baselineRetryState = BuildRetryState(
|
||||
baselineScore,
|
||||
HighwayProcessingEnabled
|
||||
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(baseline, nodes).Count
|
||||
: 0);
|
||||
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes);
|
||||
var candidateRetryState = BuildRetryState(
|
||||
candidateScore,
|
||||
HighwayProcessingEnabled
|
||||
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count
|
||||
: 0);
|
||||
|
||||
if (!IsBetterSharedLanePolishCandidate(
|
||||
candidateScore,
|
||||
candidateRetryState,
|
||||
baselineScore,
|
||||
baselineRetryState))
|
||||
{
|
||||
return baseline;
|
||||
}
|
||||
|
||||
if (candidateRetryState.SharedLaneViolations < baselineRetryState.SharedLaneViolations)
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
var retryComparison = CompareRetryStates(candidateRetryState, baselineRetryState);
|
||||
if (retryComparison < 0)
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (retryComparison > 0)
|
||||
{
|
||||
return baseline;
|
||||
}
|
||||
|
||||
if (candidateScore.NodeCrossings != baselineScore.NodeCrossings)
|
||||
{
|
||||
return candidateScore.NodeCrossings < baselineScore.NodeCrossings
|
||||
? candidate
|
||||
: baseline;
|
||||
}
|
||||
|
||||
return candidateScore.Value > baselineScore.Value
|
||||
? candidate
|
||||
: baseline;
|
||||
}
|
||||
|
||||
private static bool IsBetterBoundarySlotRepairCandidate(
|
||||
EdgeRoutingScore candidateScore,
|
||||
RoutingRetryState candidateRetryState,
|
||||
EdgeRoutingScore baselineScore,
|
||||
RoutingRetryState baselineRetryState)
|
||||
{
|
||||
if (candidateRetryState.BoundarySlotViolations < baselineRetryState.BoundarySlotViolations)
|
||||
{
|
||||
return candidateScore.NodeCrossings <= baselineScore.NodeCrossings
|
||||
&& !HasBlockingBoundarySlotPromotionRegression(candidateRetryState, baselineRetryState);
|
||||
}
|
||||
|
||||
return IsBetterCandidate(candidateScore, candidateRetryState, baselineScore, baselineRetryState);
|
||||
}
|
||||
|
||||
private static bool IsBetterSharedLanePolishCandidate(
|
||||
EdgeRoutingScore candidateScore,
|
||||
RoutingRetryState candidateRetryState,
|
||||
EdgeRoutingScore baselineScore,
|
||||
RoutingRetryState baselineRetryState)
|
||||
{
|
||||
if (candidateRetryState.SharedLaneViolations < baselineRetryState.SharedLaneViolations)
|
||||
{
|
||||
return candidateScore.NodeCrossings <= baselineScore.NodeCrossings
|
||||
&& !HasBlockingSharedLanePromotionRegression(candidateRetryState, baselineRetryState);
|
||||
}
|
||||
|
||||
return IsBetterCandidate(candidateScore, candidateRetryState, baselineScore, baselineRetryState);
|
||||
}
|
||||
|
||||
private static ElkRoutedEdge[] ChoosePreferredHardRuleLayout(
|
||||
ElkRoutedEdge[] baseline,
|
||||
ElkRoutedEdge[] candidate,
|
||||
ElkPositionedNode[] nodes)
|
||||
{
|
||||
if (ReferenceEquals(candidate, baseline))
|
||||
{
|
||||
return baseline;
|
||||
}
|
||||
|
||||
var baselineScore = ElkEdgeRoutingScoring.ComputeScore(baseline, nodes);
|
||||
var baselineRetryState = BuildRetryState(
|
||||
baselineScore,
|
||||
HighwayProcessingEnabled
|
||||
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(baseline, nodes).Count
|
||||
: 0);
|
||||
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes);
|
||||
var candidateRetryState = BuildRetryState(
|
||||
candidateScore,
|
||||
HighwayProcessingEnabled
|
||||
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count
|
||||
: 0);
|
||||
|
||||
if (HasHardRuleRegression(candidateRetryState, baselineRetryState))
|
||||
{
|
||||
return baseline;
|
||||
}
|
||||
|
||||
var retryComparison = CompareRetryStates(candidateRetryState, baselineRetryState);
|
||||
if (retryComparison < 0)
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (retryComparison > 0)
|
||||
{
|
||||
return baseline;
|
||||
}
|
||||
|
||||
if (candidateScore.NodeCrossings != baselineScore.NodeCrossings)
|
||||
{
|
||||
return candidateScore.NodeCrossings < baselineScore.NodeCrossings
|
||||
? candidate
|
||||
: baseline;
|
||||
}
|
||||
|
||||
return candidateScore.Value > baselineScore.Value
|
||||
? candidate
|
||||
: baseline;
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.ElkSharp;
|
||||
|
||||
internal static partial class ElkEdgeRouterIterative
|
||||
{
|
||||
private readonly record struct CandidateSolution(
|
||||
EdgeRoutingScore Score,
|
||||
RoutingRetryState RetryState,
|
||||
ElkRoutedEdge[] Edges,
|
||||
int StrategyIndex);
|
||||
|
||||
private readonly record struct StrategyWorkItem(
|
||||
int StrategyIndex,
|
||||
string StrategyName,
|
||||
RoutingStrategy Strategy);
|
||||
|
||||
private sealed record StrategyEvaluationResult(
|
||||
int StrategyIndex,
|
||||
IReadOnlyList<CandidateSolution> FallbackSolutions,
|
||||
CandidateSolution? ValidSolution,
|
||||
ElkIterativeStrategyDiagnostics Diagnostics);
|
||||
|
||||
private readonly record struct RepairPlan(
|
||||
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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -190,6 +190,70 @@ internal static class ElkEdgeRoutingGeometry
|
||||
return "bottom";
|
||||
}
|
||||
|
||||
internal static string ResolveBoundaryApproachSide(
|
||||
ElkPoint boundaryPoint,
|
||||
ElkPoint adjacentPoint,
|
||||
ElkPositionedNode node)
|
||||
{
|
||||
if (!ElkShapeBoundaries.IsGatewayShape(node))
|
||||
{
|
||||
return ResolveBoundarySide(boundaryPoint, node);
|
||||
}
|
||||
|
||||
var deltaX = boundaryPoint.X - adjacentPoint.X;
|
||||
var deltaY = boundaryPoint.Y - adjacentPoint.Y;
|
||||
var absDx = Math.Abs(deltaX);
|
||||
var absDy = Math.Abs(deltaY);
|
||||
if (absDx <= CoordinateTolerance && absDy > CoordinateTolerance)
|
||||
{
|
||||
return deltaY >= 0d ? "top" : "bottom";
|
||||
}
|
||||
|
||||
if (absDy <= CoordinateTolerance && absDx > CoordinateTolerance)
|
||||
{
|
||||
return deltaX >= 0d ? "left" : "right";
|
||||
}
|
||||
|
||||
if (absDx > absDy * 1.25d)
|
||||
{
|
||||
return deltaX >= 0d ? "left" : "right";
|
||||
}
|
||||
|
||||
if (absDy > absDx * 1.25d)
|
||||
{
|
||||
return deltaY >= 0d ? "top" : "bottom";
|
||||
}
|
||||
|
||||
return ResolveBoundarySide(boundaryPoint, node);
|
||||
}
|
||||
|
||||
internal static double ComputeParallelOverlapLength(
|
||||
ElkPoint a1,
|
||||
ElkPoint a2,
|
||||
ElkPoint b1,
|
||||
ElkPoint b2)
|
||||
{
|
||||
if (IsHorizontal(a1, a2) && IsHorizontal(b1, b2))
|
||||
{
|
||||
return OverlapLength(
|
||||
Math.Min(a1.X, a2.X),
|
||||
Math.Max(a1.X, a2.X),
|
||||
Math.Min(b1.X, b2.X),
|
||||
Math.Max(b1.X, b2.X));
|
||||
}
|
||||
|
||||
if (IsVertical(a1, a2) && IsVertical(b1, b2))
|
||||
{
|
||||
return OverlapLength(
|
||||
Math.Min(a1.Y, a2.Y),
|
||||
Math.Max(a1.Y, a2.Y),
|
||||
Math.Min(b1.Y, b2.Y),
|
||||
Math.Max(b1.Y, b2.Y));
|
||||
}
|
||||
|
||||
return 0d;
|
||||
}
|
||||
|
||||
internal static bool AreCollinearAndOverlapping(ElkPoint a1, ElkPoint a2, ElkPoint b1, ElkPoint b2)
|
||||
{
|
||||
if (IsHorizontal(a1, a2) && IsHorizontal(b1, b2) && Math.Abs(a1.Y - b1.Y) <= CoordinateTolerance)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.ElkSharp;
|
||||
@@ -38,6 +39,7 @@ internal sealed class ElkLayoutRunDiagnostics
|
||||
public List<ElkHighwayDiagnostics> DetectedHighways { get; } = [];
|
||||
public List<string> ProgressLog { get; } = [];
|
||||
public string? ProgressLogPath { get; set; }
|
||||
public string? SnapshotPath { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ElkHighwayDiagnostics
|
||||
@@ -58,11 +60,14 @@ internal sealed class ElkIterativeStrategyDiagnostics
|
||||
public int Attempts { get; set; }
|
||||
public double TotalDurationMs { get; set; }
|
||||
public EdgeRoutingScore? BestScore { get; set; }
|
||||
public required string Outcome { get; init; }
|
||||
public required string Outcome { get; set; }
|
||||
public double BendPenalty { get; init; }
|
||||
public double DiagonalPenalty { get; init; }
|
||||
public double SoftObstacleWeight { get; init; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
internal bool RegisteredLive { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public ElkRoutedEdge[]? BestEdges { get; set; }
|
||||
|
||||
@@ -93,6 +98,8 @@ internal sealed class ElkIterativeRouteDiagnostics
|
||||
public int SoftObstacleSegments { get; init; }
|
||||
public IReadOnlyCollection<string> RepairedEdgeIds { get; init; } = [];
|
||||
public IReadOnlyCollection<string> RepairReasons { get; init; } = [];
|
||||
public string? BuilderMode { get; init; }
|
||||
public int BuilderParallelism { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class ElkIterativePhaseDiagnostics
|
||||
@@ -135,6 +142,7 @@ internal sealed class ElkDiagnosticSectionPath
|
||||
internal static class ElkLayoutDiagnostics
|
||||
{
|
||||
private static readonly AsyncLocal<ElkLayoutRunDiagnostics?> CurrentDiagnostics = new();
|
||||
private static readonly JsonSerializerOptions SnapshotJsonOptions = new() { WriteIndented = true };
|
||||
|
||||
internal static ElkLayoutRunDiagnostics? Current => CurrentDiagnostics.Value;
|
||||
|
||||
@@ -175,6 +183,8 @@ internal static class ElkLayoutDiagnostics
|
||||
{
|
||||
File.AppendAllText(diagnostics.ProgressLogPath, line + Environment.NewLine);
|
||||
}
|
||||
|
||||
WriteSnapshotLocked(diagnostics);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +199,7 @@ internal static class ElkLayoutDiagnostics
|
||||
lock (diagnostics.SyncRoot)
|
||||
{
|
||||
diagnostics.DetectedHighways.Add(diagnostic);
|
||||
WriteSnapshotLocked(diagnostics);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +214,49 @@ internal static class ElkLayoutDiagnostics
|
||||
lock (diagnostics.SyncRoot)
|
||||
{
|
||||
diagnostics.Attempts.Add(attempt);
|
||||
WriteSnapshotLocked(diagnostics);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void FlushSnapshot()
|
||||
{
|
||||
var diagnostics = CurrentDiagnostics.Value;
|
||||
if (diagnostics is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (diagnostics.SyncRoot)
|
||||
{
|
||||
WriteSnapshotLocked(diagnostics);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void FlushSnapshot(ElkLayoutRunDiagnostics diagnostics)
|
||||
{
|
||||
lock (diagnostics.SyncRoot)
|
||||
{
|
||||
WriteSnapshotLocked(diagnostics);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteSnapshotLocked(ElkLayoutRunDiagnostics diagnostics)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(diagnostics.SnapshotPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshotPath = diagnostics.SnapshotPath!;
|
||||
var snapshotDir = Path.GetDirectoryName(snapshotPath);
|
||||
if (!string.IsNullOrWhiteSpace(snapshotDir))
|
||||
{
|
||||
Directory.CreateDirectory(snapshotDir);
|
||||
}
|
||||
|
||||
var tempPath = snapshotPath + ".tmp";
|
||||
File.WriteAllText(tempPath, JsonSerializer.Serialize(diagnostics, SnapshotJsonOptions));
|
||||
File.Copy(tempPath, snapshotPath, overwrite: true);
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ internal readonly record struct GraphBounds(double MinX, double MinY, double Max
|
||||
|
||||
internal readonly record struct LayerBoundary(double MinX, double MaxX, double MinY, double MaxY);
|
||||
|
||||
internal readonly record struct NodePlacementGrid(double XStep, double YStep);
|
||||
|
||||
internal readonly record struct EdgeChannel(
|
||||
EdgeRouteMode RouteMode,
|
||||
int BackwardLane,
|
||||
@@ -52,12 +54,19 @@ internal readonly record struct EdgeRoutingScore(
|
||||
int BendCount,
|
||||
int TargetCongestion,
|
||||
int DiagonalCount,
|
||||
int BelowGraphViolations,
|
||||
int UnderNodeViolations,
|
||||
int LongDiagonalViolations,
|
||||
int EntryAngleViolations,
|
||||
int GatewaySourceExitViolations,
|
||||
int LabelProximityViolations,
|
||||
int RepeatCollectorCorridorViolations,
|
||||
int RepeatCollectorNodeClearanceViolations,
|
||||
int TargetApproachJoinViolations,
|
||||
int TargetApproachBacktrackingViolations,
|
||||
int ExcessiveDetourViolations,
|
||||
int SharedLaneViolations,
|
||||
int BoundarySlotViolations,
|
||||
int ProximityViolations,
|
||||
double TotalPathLength,
|
||||
double Value);
|
||||
@@ -65,24 +74,39 @@ internal readonly record struct EdgeRoutingScore(
|
||||
internal readonly record struct RoutingRetryState(
|
||||
int RemainingShortHighways,
|
||||
int RepeatCollectorCorridorViolations,
|
||||
int RepeatCollectorNodeClearanceViolations,
|
||||
int TargetApproachJoinViolations,
|
||||
int TargetApproachBacktrackingViolations,
|
||||
int ExcessiveDetourViolations,
|
||||
int SharedLaneViolations,
|
||||
int BoundarySlotViolations,
|
||||
int BelowGraphViolations,
|
||||
int UnderNodeViolations,
|
||||
int LongDiagonalViolations,
|
||||
int ProximityViolations,
|
||||
int EntryAngleViolations,
|
||||
int GatewaySourceExitViolations,
|
||||
int LabelProximityViolations,
|
||||
int EdgeCrossings)
|
||||
{
|
||||
internal int QualityViolationCount =>
|
||||
ProximityViolations + EntryAngleViolations + LabelProximityViolations;
|
||||
ProximityViolations + LabelProximityViolations;
|
||||
|
||||
internal bool RequiresQualityRetry => QualityViolationCount > 0;
|
||||
|
||||
internal int BlockingViolationCount =>
|
||||
RemainingShortHighways
|
||||
+ RepeatCollectorCorridorViolations
|
||||
+ RepeatCollectorNodeClearanceViolations
|
||||
+ TargetApproachJoinViolations
|
||||
+ TargetApproachBacktrackingViolations;
|
||||
+ TargetApproachBacktrackingViolations
|
||||
+ SharedLaneViolations
|
||||
+ BoundarySlotViolations
|
||||
+ BelowGraphViolations
|
||||
+ UnderNodeViolations
|
||||
+ LongDiagonalViolations
|
||||
+ EntryAngleViolations
|
||||
+ GatewaySourceExitViolations;
|
||||
|
||||
internal bool RequiresBlockingRetry => BlockingViolationCount > 0;
|
||||
|
||||
@@ -136,19 +160,29 @@ internal sealed class RoutingStrategy
|
||||
{
|
||||
var highwayPressure = Math.Min(retryState.RemainingShortHighways, 4);
|
||||
var collectorCorridorPressure = Math.Min(retryState.RepeatCollectorCorridorViolations, 4);
|
||||
var collectorClearancePressure = Math.Min(retryState.RepeatCollectorNodeClearanceViolations, 6);
|
||||
var targetJoinPressure = Math.Min(retryState.TargetApproachJoinViolations, 4);
|
||||
var backtrackingPressure = Math.Min(retryState.TargetApproachBacktrackingViolations, 4);
|
||||
var detourPressure = Math.Min(retryState.ExcessiveDetourViolations, 4);
|
||||
var sharedLanePressure = Math.Min(retryState.SharedLaneViolations, 6);
|
||||
var boundarySlotPressure = Math.Min(retryState.BoundarySlotViolations, 6);
|
||||
var underNodePressure = Math.Min(retryState.UnderNodeViolations, 6);
|
||||
var proximityPressure = Math.Min(retryState.ProximityViolations, 6);
|
||||
var entryPressure = Math.Min(retryState.EntryAngleViolations, 4);
|
||||
var gatewaySourcePressure = Math.Min(retryState.GatewaySourceExitViolations, 4);
|
||||
var labelPressure = Math.Min(retryState.LabelProximityViolations, 4);
|
||||
var crossingPressure = Math.Min(retryState.EdgeCrossings, 6);
|
||||
var clearanceStep = 4d
|
||||
+ (highwayPressure > 0 ? 8d : 0d)
|
||||
+ (collectorCorridorPressure > 0 ? 10d : 0d)
|
||||
+ (collectorClearancePressure > 0 ? 10d : 0d)
|
||||
+ (targetJoinPressure > 0 ? 12d : 0d)
|
||||
+ (backtrackingPressure > 0 ? 6d : 0d)
|
||||
+ (sharedLanePressure > 0 ? 12d : 0d)
|
||||
+ (boundarySlotPressure > 0 ? 12d : 0d)
|
||||
+ (underNodePressure > 0 ? 12d : 0d)
|
||||
+ (proximityPressure > 0 ? 10d : 0d)
|
||||
+ (gatewaySourcePressure > 0 ? 8d : 0d)
|
||||
+ (labelPressure > 0 ? 4d : 0d)
|
||||
+ (crossingPressure > 0 ? 3d : 0d);
|
||||
MinLineClearance = Math.Min(
|
||||
@@ -156,15 +190,15 @@ internal sealed class RoutingStrategy
|
||||
BaseLineClearance * 2d);
|
||||
|
||||
var bendPenalty = RoutingParams.BendPenalty;
|
||||
if (entryPressure > 0 || labelPressure > 0 || highwayPressure > 0 || collectorCorridorPressure > 0 || targetJoinPressure > 0)
|
||||
if (entryPressure > 0 || gatewaySourcePressure > 0 || labelPressure > 0 || highwayPressure > 0 || collectorCorridorPressure > 0 || collectorClearancePressure > 0 || targetJoinPressure > 0 || sharedLanePressure > 0 || boundarySlotPressure > 0 || underNodePressure > 0)
|
||||
{
|
||||
bendPenalty = Math.Min(bendPenalty + 40d, 800d);
|
||||
}
|
||||
else if (backtrackingPressure > 0 || detourPressure > 0 || proximityPressure > 0 || crossingPressure > 0)
|
||||
else if (backtrackingPressure > 0 || detourPressure > 0 || sharedLanePressure > 0 || boundarySlotPressure > 0 || underNodePressure > 0 || proximityPressure > 0 || crossingPressure > 0)
|
||||
{
|
||||
bendPenalty = Math.Max(
|
||||
80d,
|
||||
bendPenalty - (backtrackingPressure > 0 ? 80d : detourPressure > 0 ? 50d : 30d));
|
||||
bendPenalty - (backtrackingPressure > 0 ? 80d : detourPressure > 0 ? 50d : sharedLanePressure > 0 || boundarySlotPressure > 0 || underNodePressure > 0 ? 40d : 30d));
|
||||
}
|
||||
|
||||
var margin = RoutingParams.Margin;
|
||||
@@ -178,9 +212,14 @@ internal sealed class RoutingStrategy
|
||||
margin
|
||||
+ (highwayPressure > 0 ? 8d : 4d)
|
||||
+ (collectorCorridorPressure > 0 ? 8d : 0d)
|
||||
+ (collectorClearancePressure > 0 ? 8d : 0d)
|
||||
+ (targetJoinPressure > 0 ? 10d : 0d)
|
||||
+ (sharedLanePressure > 0 ? 10d : 0d)
|
||||
+ (boundarySlotPressure > 0 ? 10d : 0d)
|
||||
+ (underNodePressure > 0 ? 10d : 0d)
|
||||
+ (proximityPressure > 0 ? 6d : 0d)
|
||||
+ (entryPressure > 0 ? 3d : 0d),
|
||||
+ (entryPressure > 0 ? 3d : 0d)
|
||||
+ (gatewaySourcePressure > 0 ? 4d : 0d),
|
||||
BaseLineClearance * 2d);
|
||||
}
|
||||
|
||||
@@ -197,7 +236,9 @@ internal sealed class RoutingStrategy
|
||||
softObstacleWeight
|
||||
+ (highwayPressure > 0 ? 0.75d : 0.25d)
|
||||
+ (collectorCorridorPressure > 0 ? 0.75d : 0d)
|
||||
+ (collectorClearancePressure > 0 ? 0.75d : 0d)
|
||||
+ (targetJoinPressure > 0 ? 1.0d : 0d)
|
||||
+ (sharedLanePressure > 0 ? 1.0d : 0d)
|
||||
+ (proximityPressure > 0 ? 0.75d : 0d)
|
||||
+ (crossingPressure > 0 ? 0.5d : 0d),
|
||||
8d);
|
||||
@@ -216,7 +257,9 @@ internal sealed class RoutingStrategy
|
||||
softObstacleClearance
|
||||
+ (highwayPressure > 0 ? 8d : 4d)
|
||||
+ (collectorCorridorPressure > 0 ? 10d : 0d)
|
||||
+ (collectorClearancePressure > 0 ? 10d : 0d)
|
||||
+ (targetJoinPressure > 0 ? 16d : 0d)
|
||||
+ (sharedLanePressure > 0 ? 16d : 0d)
|
||||
+ (proximityPressure > 0 ? 10d : 0d)
|
||||
+ (labelPressure > 0 ? 4d : 0d)
|
||||
+ (crossingPressure > 0 ? 4d : 0d),
|
||||
@@ -238,6 +281,7 @@ internal sealed class RoutingStrategy
|
||||
- (highwayPressure > 0 ? 6d : 2d)
|
||||
- (collectorCorridorPressure > 0 ? 6d : 0d)
|
||||
- (targetJoinPressure > 0 ? 8d : 0d)
|
||||
- (sharedLanePressure > 0 ? 8d : 0d)
|
||||
- (proximityPressure > 0 ? 6d : 0d)
|
||||
- (entryPressure > 0 ? 4d : 0d)
|
||||
- (labelPressure > 0 ? 2d : 0d));
|
||||
|
||||
@@ -85,7 +85,7 @@ public sealed record EdgeRefinementOptions
|
||||
public sealed record IterativeRoutingOptions
|
||||
{
|
||||
public bool? Enabled { get; init; }
|
||||
public int MaxAdaptationsPerStrategy { get; init; } = 10;
|
||||
public int MaxAdaptationsPerStrategy { get; init; } = 100;
|
||||
public int RequiredValidSolutions { get; init; } = 10;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,23 @@ namespace StellaOps.ElkSharp;
|
||||
|
||||
internal static class ElkNodePlacement
|
||||
{
|
||||
internal static NodePlacementGrid ResolvePlacementGrid(IReadOnlyCollection<ElkNode> nodes)
|
||||
{
|
||||
var actualNodes = nodes
|
||||
.Where(node => node.Kind is not "Start" and not "End")
|
||||
.ToArray();
|
||||
if (actualNodes.Length == 0)
|
||||
{
|
||||
return new NodePlacementGrid(160d, 80d);
|
||||
}
|
||||
|
||||
var averageWidth = actualNodes.Average(node => node.Width);
|
||||
var averageHeight = actualNodes.Average(node => node.Height);
|
||||
return new NodePlacementGrid(
|
||||
XStep: Math.Max(64d, Math.Round(averageWidth / 8d) * 8d),
|
||||
YStep: Math.Max(48d, Math.Round(averageHeight / 8d) * 8d));
|
||||
}
|
||||
|
||||
internal static int ResolveOrderingIterationCount(
|
||||
ElkLayoutOptions options,
|
||||
int edgeCount,
|
||||
@@ -218,6 +235,7 @@ internal static class ElkNodePlacement
|
||||
sortedNodes,
|
||||
desiredCoordinates,
|
||||
nodeSpacing,
|
||||
0d,
|
||||
horizontal: direction == ElkLayoutDirection.LeftToRight);
|
||||
|
||||
for (var nodeIndex = 0; nodeIndex < sortedNodes.Length; nodeIndex++)
|
||||
@@ -231,10 +249,130 @@ internal static class ElkNodePlacement
|
||||
}
|
||||
}
|
||||
|
||||
internal static void AlignToPlacementGrid(
|
||||
Dictionary<string, ElkPositionedNode> positionedNodes,
|
||||
IReadOnlyList<ElkNode[]> layers,
|
||||
IReadOnlySet<string> dummyNodeIds,
|
||||
double nodeSpacing,
|
||||
NodePlacementGrid placementGrid,
|
||||
ElkLayoutDirection direction)
|
||||
{
|
||||
if (layers.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (direction == ElkLayoutDirection.LeftToRight)
|
||||
{
|
||||
foreach (var layer in layers)
|
||||
{
|
||||
var actualNodes = layer.Where(node => !dummyNodeIds.Contains(node.Id)).ToArray();
|
||||
if (actualNodes.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var currentX = positionedNodes[actualNodes[0].Id].X;
|
||||
var snappedX = SnapToPlacementGrid(currentX, placementGrid.XStep);
|
||||
var deltaX = snappedX - currentX;
|
||||
if (Math.Abs(deltaX) > 0.01d)
|
||||
{
|
||||
foreach (var node in layer)
|
||||
{
|
||||
var pos = positionedNodes[node.Id];
|
||||
positionedNodes[node.Id] = ElkLayoutHelpers.CreatePositionedNode(
|
||||
node,
|
||||
pos.X + deltaX,
|
||||
pos.Y,
|
||||
direction);
|
||||
}
|
||||
}
|
||||
|
||||
var desiredY = actualNodes
|
||||
.Select(node => SnapToPlacementGrid(positionedNodes[node.Id].Y, placementGrid.YStep))
|
||||
.ToArray();
|
||||
EnforceLinearSpacing(actualNodes, desiredY, nodeSpacing, placementGrid.YStep, horizontal: true);
|
||||
for (var i = 0; i < actualNodes.Length; i++)
|
||||
{
|
||||
var current = positionedNodes[actualNodes[i].Id];
|
||||
positionedNodes[actualNodes[i].Id] = ElkLayoutHelpers.CreatePositionedNode(
|
||||
actualNodes[i],
|
||||
current.X,
|
||||
desiredY[i],
|
||||
direction);
|
||||
}
|
||||
}
|
||||
|
||||
var minY = positionedNodes.Values.Min(node => node.Y);
|
||||
if (minY < -0.01d)
|
||||
{
|
||||
var shift = SnapForwardToPlacementGrid(-minY, placementGrid.YStep);
|
||||
foreach (var nodeId in positionedNodes.Keys.ToArray())
|
||||
{
|
||||
var pos = positionedNodes[nodeId];
|
||||
positionedNodes[nodeId] = pos with { Y = pos.Y + shift };
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var layer in layers)
|
||||
{
|
||||
var actualNodes = layer.Where(node => !dummyNodeIds.Contains(node.Id)).ToArray();
|
||||
if (actualNodes.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var currentY = positionedNodes[actualNodes[0].Id].Y;
|
||||
var snappedY = SnapToPlacementGrid(currentY, placementGrid.YStep);
|
||||
var deltaY = snappedY - currentY;
|
||||
if (Math.Abs(deltaY) > 0.01d)
|
||||
{
|
||||
foreach (var node in layer)
|
||||
{
|
||||
var pos = positionedNodes[node.Id];
|
||||
positionedNodes[node.Id] = ElkLayoutHelpers.CreatePositionedNode(
|
||||
node,
|
||||
pos.X,
|
||||
pos.Y + deltaY,
|
||||
direction);
|
||||
}
|
||||
}
|
||||
|
||||
var desiredX = actualNodes
|
||||
.Select(node => SnapToPlacementGrid(positionedNodes[node.Id].X, placementGrid.XStep))
|
||||
.ToArray();
|
||||
EnforceLinearSpacing(actualNodes, desiredX, nodeSpacing, placementGrid.XStep, horizontal: false);
|
||||
for (var i = 0; i < actualNodes.Length; i++)
|
||||
{
|
||||
var current = positionedNodes[actualNodes[i].Id];
|
||||
positionedNodes[actualNodes[i].Id] = ElkLayoutHelpers.CreatePositionedNode(
|
||||
actualNodes[i],
|
||||
desiredX[i],
|
||||
current.Y,
|
||||
direction);
|
||||
}
|
||||
}
|
||||
|
||||
var minX = positionedNodes.Values.Min(node => node.X);
|
||||
if (minX < -0.01d)
|
||||
{
|
||||
var shift = SnapForwardToPlacementGrid(-minX, placementGrid.XStep);
|
||||
foreach (var nodeId in positionedNodes.Keys.ToArray())
|
||||
{
|
||||
var pos = positionedNodes[nodeId];
|
||||
positionedNodes[nodeId] = pos with { X = pos.X + shift };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static void EnforceLinearSpacing(
|
||||
IReadOnlyList<ElkNode> layer,
|
||||
double[] desiredCoordinates,
|
||||
double spacing,
|
||||
double gridStep,
|
||||
bool horizontal)
|
||||
{
|
||||
for (var index = 1; index < layer.Count; index++)
|
||||
@@ -243,6 +381,7 @@ internal static class ElkNodePlacement
|
||||
desiredCoordinates[index] = Math.Max(
|
||||
desiredCoordinates[index],
|
||||
desiredCoordinates[index - 1] + extent + spacing);
|
||||
desiredCoordinates[index] = SnapForwardToPlacementGrid(desiredCoordinates[index], gridStep);
|
||||
}
|
||||
|
||||
for (var index = layer.Count - 2; index >= 0; index--)
|
||||
@@ -251,6 +390,7 @@ internal static class ElkNodePlacement
|
||||
desiredCoordinates[index] = Math.Min(
|
||||
desiredCoordinates[index],
|
||||
desiredCoordinates[index + 1] - extent - spacing);
|
||||
desiredCoordinates[index] = SnapBackwardToPlacementGrid(desiredCoordinates[index], gridStep);
|
||||
}
|
||||
|
||||
for (var index = 1; index < layer.Count; index++)
|
||||
@@ -259,6 +399,37 @@ internal static class ElkNodePlacement
|
||||
desiredCoordinates[index] = Math.Max(
|
||||
desiredCoordinates[index],
|
||||
desiredCoordinates[index - 1] + extent + spacing);
|
||||
desiredCoordinates[index] = SnapForwardToPlacementGrid(desiredCoordinates[index], gridStep);
|
||||
}
|
||||
}
|
||||
|
||||
internal static double SnapToPlacementGrid(double value, double gridStep)
|
||||
{
|
||||
if (gridStep <= 1d)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return Math.Round(value / gridStep) * gridStep;
|
||||
}
|
||||
|
||||
internal static double SnapForwardToPlacementGrid(double value, double gridStep)
|
||||
{
|
||||
if (gridStep <= 1d)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return Math.Ceiling(value / gridStep) * gridStep;
|
||||
}
|
||||
|
||||
internal static double SnapBackwardToPlacementGrid(double value, double gridStep)
|
||||
{
|
||||
if (gridStep <= 1d)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return Math.Floor(value / gridStep) * gridStep;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ internal sealed class ElkRepeatCollectorCorridorGroup
|
||||
public required bool IsAbove { get; init; }
|
||||
public required double CorridorY { get; init; }
|
||||
public required string[] EdgeIds { get; init; }
|
||||
public required int ConflictPairCount { get; init; }
|
||||
public required double MinX { get; init; }
|
||||
public required double MaxX { get; init; }
|
||||
}
|
||||
|
||||
internal static class ElkRepeatCollectorCorridors
|
||||
@@ -22,13 +25,12 @@ internal static class ElkRepeatCollectorCorridors
|
||||
var count = 0;
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var edgeCount = group.EdgeIds.Length;
|
||||
if (edgeCount < 2)
|
||||
if (group.ConflictPairCount <= 0 || group.EdgeIds.Length < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
count += edgeCount * (edgeCount - 1) / 2;
|
||||
count += group.ConflictPairCount;
|
||||
if (severityByEdgeId is null)
|
||||
{
|
||||
continue;
|
||||
@@ -37,7 +39,7 @@ internal static class ElkRepeatCollectorCorridors
|
||||
foreach (var edgeId in group.EdgeIds)
|
||||
{
|
||||
severityByEdgeId[edgeId] = severityByEdgeId.GetValueOrDefault(edgeId)
|
||||
+ ((edgeCount - 1) * severityWeight);
|
||||
+ (severityWeight * Math.Max(1, group.ConflictPairCount));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +57,10 @@ internal static class ElkRepeatCollectorCorridors
|
||||
|
||||
var graphMinY = nodes.Min(node => node.Y);
|
||||
var graphMaxY = nodes.Max(node => node.Y + node.Height);
|
||||
var serviceNodes = nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
|
||||
var minLineClearance = serviceNodes.Length > 0
|
||||
? Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d
|
||||
: 50d;
|
||||
var candidates = edges
|
||||
.Select(edge => CreateCandidate(edge, graphMinY, graphMaxY))
|
||||
.Where(candidate => candidate is not null)
|
||||
@@ -76,58 +82,38 @@ internal static class ElkRepeatCollectorCorridors
|
||||
StringComparer.Ordinal))
|
||||
{
|
||||
var bucket = groupedCandidates.ToArray();
|
||||
var visited = new bool[bucket.Length];
|
||||
var conflictPairs = 0;
|
||||
for (var i = 0; i < bucket.Length; i++)
|
||||
{
|
||||
if (visited[i])
|
||||
for (var j = i + 1; j < bucket.Length; j++)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var pending = new Queue<int>();
|
||||
var component = new List<CollectorCandidate>();
|
||||
pending.Enqueue(i);
|
||||
visited[i] = true;
|
||||
|
||||
while (pending.Count > 0)
|
||||
{
|
||||
var currentIndex = pending.Dequeue();
|
||||
var current = bucket[currentIndex];
|
||||
component.Add(current);
|
||||
|
||||
for (var j = 0; j < bucket.Length; j++)
|
||||
if (ConflictsOnOuterLane(bucket[i], bucket[j], minLineClearance))
|
||||
{
|
||||
if (visited[j] || currentIndex == j)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!SharesOuterLane(current, bucket[j]))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
visited[j] = true;
|
||||
pending.Enqueue(j);
|
||||
conflictPairs++;
|
||||
}
|
||||
}
|
||||
|
||||
if (component.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.Add(new ElkRepeatCollectorCorridorGroup
|
||||
{
|
||||
TargetNodeId = component[0].TargetNodeId,
|
||||
IsAbove = component[0].IsAbove,
|
||||
CorridorY = component.Min(member => member.CorridorY),
|
||||
EdgeIds = component
|
||||
.Select(member => member.EdgeId)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
});
|
||||
}
|
||||
|
||||
if (conflictPairs <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.Add(new ElkRepeatCollectorCorridorGroup
|
||||
{
|
||||
TargetNodeId = bucket[0].TargetNodeId,
|
||||
IsAbove = bucket[0].IsAbove,
|
||||
CorridorY = bucket[0].IsAbove
|
||||
? bucket.Min(member => member.CorridorY)
|
||||
: bucket.Max(member => member.CorridorY),
|
||||
EdgeIds = bucket
|
||||
.Select(member => member.EdgeId)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
ConflictPairCount = conflictPairs,
|
||||
MinX = bucket.Min(member => member.MinX),
|
||||
MaxX = bucket.Max(member => member.MaxX),
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
@@ -172,8 +158,15 @@ internal static class ElkRepeatCollectorCorridors
|
||||
.Select((edge, index) => new { edge, index, candidate = CreateCandidate(edge, graphMinY, graphMaxY) })
|
||||
.Where(item => item.candidate is not null
|
||||
&& group.EdgeIds.Contains(item.edge.Id, StringComparer.Ordinal))
|
||||
.Select(item => new RepairMember(item.index, item.edge.Id, item.candidate!.Value.CorridorY, item.candidate.Value.StartX))
|
||||
.OrderByDescending(member => member.StartX)
|
||||
.Select(item => new RepairMember(
|
||||
item.index,
|
||||
item.edge.Id,
|
||||
item.candidate!.Value.CorridorY,
|
||||
item.candidate.Value.StartX,
|
||||
item.candidate.Value.MinX,
|
||||
item.candidate.Value.MaxX))
|
||||
.OrderBy(member => member.CorridorY)
|
||||
.ThenByDescending(member => member.StartX)
|
||||
.ThenBy(member => member.EdgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (members.Length < 2)
|
||||
@@ -181,16 +174,30 @@ internal static class ElkRepeatCollectorCorridors
|
||||
continue;
|
||||
}
|
||||
|
||||
var baseY = group.IsAbove
|
||||
? Math.Min(members.Min(member => member.CorridorY), graphMinY - 12d)
|
||||
: Math.Max(members.Max(member => member.CorridorY), graphMaxY + 12d);
|
||||
|
||||
for (var i = 0; i < members.Length; i++)
|
||||
var forbiddenBands = BuildForbiddenCorridorBands(
|
||||
nodes,
|
||||
members.Min(member => member.MinX),
|
||||
members.Max(member => member.MaxX),
|
||||
minLineClearance);
|
||||
if (group.IsAbove)
|
||||
{
|
||||
var assignedY = group.IsAbove
|
||||
? baseY - (laneGap * i)
|
||||
: baseY + (laneGap * i);
|
||||
result[members[i].Index] = RewriteOuterLane(result[members[i].Index], members[i].CorridorY, assignedY, graphMinY, graphMaxY, group.IsAbove);
|
||||
var nextY = Math.Min(members.Max(member => member.CorridorY), graphMinY - 12d);
|
||||
nextY = ResolveSafeCorridorY(nextY, group.IsAbove, laneGap, forbiddenBands);
|
||||
for (var i = members.Length - 1; i >= 0; i--)
|
||||
{
|
||||
result[members[i].Index] = RewriteOuterLane(result[members[i].Index], members[i].CorridorY, nextY, graphMinY, graphMaxY, group.IsAbove);
|
||||
nextY = ResolveSafeCorridorY(nextY - laneGap, group.IsAbove, laneGap, forbiddenBands);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var nextY = Math.Max(members.Min(member => member.CorridorY), graphMaxY + 12d);
|
||||
nextY = ResolveSafeCorridorY(nextY, group.IsAbove, laneGap, forbiddenBands);
|
||||
for (var i = 0; i < members.Length; i++)
|
||||
{
|
||||
result[members[i].Index] = RewriteOuterLane(result[members[i].Index], members[i].CorridorY, nextY, graphMinY, graphMaxY, group.IsAbove);
|
||||
nextY = ResolveSafeCorridorY(nextY + laneGap, group.IsAbove, laneGap, forbiddenBands);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +247,10 @@ internal static class ElkRepeatCollectorCorridors
|
||||
};
|
||||
}
|
||||
|
||||
private static bool SharesOuterLane(CollectorCandidate left, CollectorCandidate right)
|
||||
private static bool ConflictsOnOuterLane(
|
||||
CollectorCandidate left,
|
||||
CollectorCandidate right,
|
||||
double minLineClearance)
|
||||
{
|
||||
if (!string.Equals(left.TargetNodeId, right.TargetNodeId, StringComparison.Ordinal)
|
||||
|| left.IsAbove != right.IsAbove)
|
||||
@@ -248,12 +258,12 @@ internal static class ElkRepeatCollectorCorridors
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Math.Abs(left.CorridorY - right.CorridorY) > CoordinateTolerance)
|
||||
if (Math.Min(left.MaxX, right.MaxX) - Math.Max(left.MinX, right.MinX) <= 1d)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Math.Min(left.MaxX, right.MaxX) - Math.Max(left.MinX, right.MinX) > 1d;
|
||||
return Math.Abs(left.CorridorY - right.CorridorY) < minLineClearance - CoordinateTolerance;
|
||||
}
|
||||
|
||||
private static CollectorCandidate? CreateCandidate(
|
||||
@@ -316,6 +326,59 @@ internal static class ElkRepeatCollectorCorridors
|
||||
return best;
|
||||
}
|
||||
|
||||
private static (double Top, double Bottom)[] BuildForbiddenCorridorBands(
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes,
|
||||
double spanMinX,
|
||||
double spanMaxX,
|
||||
double minLineClearance)
|
||||
{
|
||||
return nodes
|
||||
.Where(node => node.Kind is not "Start" and not "End")
|
||||
.Where(node => node.X + node.Width > spanMinX + CoordinateTolerance
|
||||
&& node.X < spanMaxX - CoordinateTolerance)
|
||||
.Select(node => (
|
||||
Top: node.Y - minLineClearance,
|
||||
Bottom: node.Y + node.Height + minLineClearance))
|
||||
.OrderBy(band => band.Top)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static double ResolveSafeCorridorY(
|
||||
double candidateY,
|
||||
bool isAbove,
|
||||
double laneGap,
|
||||
IReadOnlyList<(double Top, double Bottom)> forbiddenBands)
|
||||
{
|
||||
if (forbiddenBands.Count == 0)
|
||||
{
|
||||
return candidateY;
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
var shifted = false;
|
||||
foreach (var band in forbiddenBands)
|
||||
{
|
||||
if (candidateY < band.Top - CoordinateTolerance
|
||||
|| candidateY > band.Bottom + CoordinateTolerance)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
candidateY = isAbove
|
||||
? band.Top - laneGap
|
||||
: band.Bottom + laneGap;
|
||||
shifted = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!shifted)
|
||||
{
|
||||
return candidateY;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ElkPoint> ExtractPath(ElkRoutedEdge edge)
|
||||
{
|
||||
var path = new List<ElkPoint>();
|
||||
@@ -343,5 +406,11 @@ internal static class ElkRepeatCollectorCorridors
|
||||
double StartX,
|
||||
double Length);
|
||||
|
||||
private readonly record struct RepairMember(int Index, string EdgeId, double CorridorY, double StartX);
|
||||
private readonly record struct RepairMember(
|
||||
int Index,
|
||||
string EdgeId,
|
||||
double CorridorY,
|
||||
double StartX,
|
||||
double MinX,
|
||||
double MaxX);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,17 @@ namespace StellaOps.ElkSharp;
|
||||
|
||||
internal static class ElkShapeBoundaries
|
||||
{
|
||||
private const double CoordinateTolerance = 0.5d;
|
||||
private const double GatewayVertexTolerance = 3d;
|
||||
|
||||
internal static bool IsGatewayShape(ElkPositionedNode node)
|
||||
{
|
||||
return node.Kind is "Decision" or "Fork" or "Join";
|
||||
}
|
||||
|
||||
internal static ElkPoint ProjectOntoShapeBoundary(ElkPositionedNode node, ElkPoint toward)
|
||||
{
|
||||
if (node.Kind is "Decision" or "Fork" or "Join")
|
||||
if (IsGatewayShape(node))
|
||||
{
|
||||
var cx = node.X + node.Width / 2d;
|
||||
var cy = node.Y + node.Height / 2d;
|
||||
@@ -16,6 +24,150 @@ internal static class ElkShapeBoundaries
|
||||
return ProjectOntoRectBoundary(node, toward);
|
||||
}
|
||||
|
||||
internal static bool TryProjectGatewayDiagonalBoundary(
|
||||
ElkPositionedNode node,
|
||||
ElkPoint anchor,
|
||||
ElkPoint fallbackBoundary,
|
||||
out ElkPoint boundaryPoint)
|
||||
{
|
||||
boundaryPoint = default!;
|
||||
if (!IsGatewayShape(node))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidates = new List<ElkPoint>();
|
||||
var projectedAnchor = ProjectOntoShapeBoundary(node, anchor);
|
||||
AddGatewayCandidate(node, candidates, projectedAnchor);
|
||||
AddGatewayCandidate(node, candidates, fallbackBoundary);
|
||||
AddGatewayCandidate(node, candidates, ProjectOntoShapeBoundary(node, fallbackBoundary));
|
||||
|
||||
foreach (var vertex in BuildGatewayBoundaryPoints(node))
|
||||
{
|
||||
AddGatewayCandidate(node, candidates, vertex);
|
||||
}
|
||||
|
||||
var centerX = node.X + (node.Width / 2d);
|
||||
var centerY = node.Y + (node.Height / 2d);
|
||||
var directionX = Math.Sign(centerX - anchor.X);
|
||||
var directionY = Math.Sign(centerY - anchor.Y);
|
||||
var diagonalDirections = new HashSet<(int X, int Y)>();
|
||||
if (directionX != 0 && directionY != 0)
|
||||
{
|
||||
diagonalDirections.Add((directionX, directionY));
|
||||
}
|
||||
|
||||
var fallbackDirectionX = Math.Sign(fallbackBoundary.X - anchor.X);
|
||||
var fallbackDirectionY = Math.Sign(fallbackBoundary.Y - anchor.Y);
|
||||
if (fallbackDirectionX != 0 && fallbackDirectionY != 0)
|
||||
{
|
||||
diagonalDirections.Add((fallbackDirectionX, fallbackDirectionY));
|
||||
}
|
||||
|
||||
foreach (var diagonalDirection in diagonalDirections)
|
||||
{
|
||||
if (TryIntersectGatewayRay(
|
||||
node,
|
||||
anchor.X,
|
||||
anchor.Y,
|
||||
diagonalDirection.X,
|
||||
diagonalDirection.Y,
|
||||
out var rayBoundary))
|
||||
{
|
||||
AddGatewayCandidate(node, candidates, rayBoundary);
|
||||
}
|
||||
}
|
||||
|
||||
var bestCandidate = default(ElkPoint?);
|
||||
var bestScore = double.PositiveInfinity;
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var score = ScoreGatewayBoundaryCandidate(node, anchor, projectedAnchor, candidate);
|
||||
if (score >= bestScore)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bestScore = score;
|
||||
bestCandidate = candidate;
|
||||
}
|
||||
|
||||
if (bestCandidate is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
boundaryPoint = PreferGatewayEdgeInteriorBoundary(node, bestCandidate, anchor);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool HasValidGatewayBoundaryAngle(
|
||||
ElkPositionedNode node,
|
||||
ElkPoint boundaryPoint,
|
||||
ElkPoint adjacentPoint)
|
||||
{
|
||||
if (!IsGatewayShape(node))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var segDx = Math.Abs(boundaryPoint.X - adjacentPoint.X);
|
||||
var segDy = Math.Abs(boundaryPoint.Y - adjacentPoint.Y);
|
||||
if (segDx < 3d && segDy < 3d)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!IsPointOnGatewayBoundary(node, boundaryPoint, 2d))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsInsideNodeShapeInterior(node, adjacentPoint))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsDisallowedGatewayVertex(node, boundaryPoint))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsAllowedGatewayTipVertex(node, boundaryPoint))
|
||||
{
|
||||
return segDx > segDy * 3d;
|
||||
}
|
||||
|
||||
if (!TryGetGatewayBoundaryFace(node, boundaryPoint, out var faceStart, out var faceEnd))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var outwardVectorX = adjacentPoint.X - boundaryPoint.X;
|
||||
var outwardVectorY = adjacentPoint.Y - boundaryPoint.Y;
|
||||
var outwardLength = Math.Sqrt((outwardVectorX * outwardVectorX) + (outwardVectorY * outwardVectorY));
|
||||
if (outwardLength <= 0.001d)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var (normalX, normalY) = BuildGatewayFaceNormal(node, faceStart, faceEnd, boundaryPoint);
|
||||
var outwardDot = ((outwardVectorX / outwardLength) * normalX) + ((outwardVectorY / outwardLength) * normalY);
|
||||
var faceDx = Math.Abs(faceEnd.X - faceStart.X);
|
||||
var faceDy = Math.Abs(faceEnd.Y - faceStart.Y);
|
||||
var faceIsDiagonal = faceDx >= 3d && faceDy >= 3d;
|
||||
|
||||
if (faceIsDiagonal)
|
||||
{
|
||||
// Diamond-like faces can leave/arrive with a short 45-degree or orthogonal
|
||||
// stub as long as that stub moves outward from the face and does not land on
|
||||
// a corner vertex.
|
||||
return outwardDot >= 0.55d;
|
||||
}
|
||||
|
||||
return (segDx < 3d || segDy < 3d) && outwardDot >= 0.85d;
|
||||
}
|
||||
|
||||
internal static ElkPoint ProjectOntoRectBoundary(ElkPositionedNode node, ElkPoint toward)
|
||||
{
|
||||
var cx = node.X + node.Width / 2d;
|
||||
@@ -98,6 +250,22 @@ internal static class ElkShapeBoundaries
|
||||
BuildForkBoundaryPoints(node));
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<ElkPoint> BuildGatewayBoundaryPoints(ElkPositionedNode node)
|
||||
{
|
||||
if (node.Kind == "Decision")
|
||||
{
|
||||
return
|
||||
[
|
||||
new ElkPoint { X = node.X + (node.Width / 2d), Y = node.Y },
|
||||
new ElkPoint { X = node.X + node.Width, Y = node.Y + (node.Height / 2d) },
|
||||
new ElkPoint { X = node.X + (node.Width / 2d), Y = node.Y + node.Height },
|
||||
new ElkPoint { X = node.X, Y = node.Y + (node.Height / 2d) },
|
||||
];
|
||||
}
|
||||
|
||||
return BuildForkBoundaryPoints(node);
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<ElkPoint> BuildForkBoundaryPoints(ElkPositionedNode node)
|
||||
{
|
||||
var cornerInset = Math.Min(22d, Math.Max(6d, node.Width * 0.125d));
|
||||
@@ -188,4 +356,805 @@ internal static class ElkShapeBoundaries
|
||||
{
|
||||
return (ax * by) - (ay * bx);
|
||||
}
|
||||
|
||||
private static bool TryIntersectGatewayRay(
|
||||
ElkPositionedNode node,
|
||||
double originX,
|
||||
double originY,
|
||||
double deltaX,
|
||||
double deltaY,
|
||||
out ElkPoint boundaryPoint)
|
||||
{
|
||||
var polygon = BuildGatewayBoundaryPoints(node);
|
||||
var bestScale = double.PositiveInfinity;
|
||||
ElkPoint? bestPoint = null;
|
||||
for (var index = 0; index < polygon.Count; index++)
|
||||
{
|
||||
var start = polygon[index];
|
||||
var end = polygon[(index + 1) % polygon.Count];
|
||||
if (!TryIntersectRayWithSegment(originX, originY, deltaX, deltaY, start, end, out var scale, out var point))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (scale < bestScale)
|
||||
{
|
||||
bestScale = scale;
|
||||
bestPoint = point;
|
||||
}
|
||||
}
|
||||
|
||||
boundaryPoint = bestPoint ?? default!;
|
||||
return bestPoint is not null;
|
||||
}
|
||||
|
||||
private static void AddGatewayCandidate(
|
||||
ElkPositionedNode node,
|
||||
ICollection<ElkPoint> candidates,
|
||||
ElkPoint candidate)
|
||||
{
|
||||
if (!IsPointOnGatewayBoundary(node, candidate, 2d))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (candidates.Any(existing =>
|
||||
Math.Abs(existing.X - candidate.X) <= CoordinateTolerance
|
||||
&& Math.Abs(existing.Y - candidate.Y) <= CoordinateTolerance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
candidates.Add(candidate);
|
||||
}
|
||||
|
||||
private static double ScoreGatewayBoundaryCandidate(
|
||||
ElkPositionedNode node,
|
||||
ElkPoint anchor,
|
||||
ElkPoint projectedAnchor,
|
||||
ElkPoint candidate)
|
||||
{
|
||||
var towardCenterX = (node.X + (node.Width / 2d)) - anchor.X;
|
||||
var towardCenterY = (node.Y + (node.Height / 2d)) - anchor.Y;
|
||||
var candidateDeltaX = candidate.X - anchor.X;
|
||||
var candidateDeltaY = candidate.Y - anchor.Y;
|
||||
var towardDot = (candidateDeltaX * towardCenterX) + (candidateDeltaY * towardCenterY);
|
||||
if (towardDot <= 0d)
|
||||
{
|
||||
return double.PositiveInfinity;
|
||||
}
|
||||
|
||||
var absDx = Math.Abs(candidateDeltaX);
|
||||
var absDy = Math.Abs(candidateDeltaY);
|
||||
var isDiagonal = absDx >= 3d && absDy >= 3d;
|
||||
var diagonalPenalty = isDiagonal
|
||||
? Math.Abs(absDx - absDy)
|
||||
: 10_000d;
|
||||
var projectedDistance = Math.Abs(candidate.X - projectedAnchor.X) + Math.Abs(candidate.Y - projectedAnchor.Y);
|
||||
var segmentLength = Math.Sqrt((candidateDeltaX * candidateDeltaX) + (candidateDeltaY * candidateDeltaY));
|
||||
var candidateNearVertex = IsNearGatewayVertex(node, candidate, GatewayVertexTolerance);
|
||||
var projectedNearVertex = IsNearGatewayVertex(node, projectedAnchor, GatewayVertexTolerance);
|
||||
var vertexPenalty = candidateNearVertex
|
||||
? projectedNearVertex
|
||||
? 4d
|
||||
: 24d
|
||||
: 0d;
|
||||
|
||||
return diagonalPenalty + (segmentLength * 0.05d) + (projectedDistance * 0.1d) + vertexPenalty;
|
||||
}
|
||||
|
||||
private static ElkPoint InterpolateAwayFromVertex(
|
||||
ElkPoint vertexPoint,
|
||||
ElkPoint adjacentVertex,
|
||||
double? forcedOffset = null)
|
||||
{
|
||||
var deltaX = adjacentVertex.X - vertexPoint.X;
|
||||
var deltaY = adjacentVertex.Y - vertexPoint.Y;
|
||||
var length = Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY));
|
||||
if (length <= 0.001d)
|
||||
{
|
||||
return vertexPoint;
|
||||
}
|
||||
|
||||
var offset = forcedOffset ?? Math.Min(18d, Math.Max(10d, length * 0.2d));
|
||||
offset = Math.Min(Math.Max(length - 0.5d, 0.5d), offset);
|
||||
var scale = offset / length;
|
||||
return new ElkPoint
|
||||
{
|
||||
X = vertexPoint.X + (deltaX * scale),
|
||||
Y = vertexPoint.Y + (deltaY * scale),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsPointOnGatewayBoundary(ElkPositionedNode node, ElkPoint point, double tolerance)
|
||||
{
|
||||
var polygon = BuildGatewayBoundaryPoints(node);
|
||||
for (var index = 0; index < polygon.Count; index++)
|
||||
{
|
||||
var start = polygon[index];
|
||||
var end = polygon[(index + 1) % polygon.Count];
|
||||
if (DistanceToSegment(point, start, end) <= tolerance)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static bool IsNearGatewayVertex(ElkPositionedNode node, ElkPoint boundaryPoint, double tolerance = GatewayVertexTolerance)
|
||||
{
|
||||
foreach (var vertex in BuildGatewayBoundaryPoints(node))
|
||||
{
|
||||
if (Math.Abs(vertex.X - boundaryPoint.X) <= tolerance
|
||||
&& Math.Abs(vertex.Y - boundaryPoint.Y) <= tolerance)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static bool IsAllowedGatewayTipVertex(
|
||||
ElkPositionedNode node,
|
||||
ElkPoint boundaryPoint,
|
||||
double tolerance = GatewayVertexTolerance)
|
||||
{
|
||||
// Gateway tips read as visually detached "pin" exits/entries in the renderer.
|
||||
// Keep all gateway joins on a face interior instead of permitting any tip vertex.
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static bool IsInsideNodeBoundingBoxInterior(
|
||||
ElkPositionedNode node,
|
||||
ElkPoint point,
|
||||
double tolerance = CoordinateTolerance)
|
||||
{
|
||||
return point.X > node.X + tolerance
|
||||
&& point.X < node.X + node.Width - tolerance
|
||||
&& point.Y > node.Y + tolerance
|
||||
&& point.Y < node.Y + node.Height - tolerance;
|
||||
}
|
||||
|
||||
internal static bool IsInsideNodeShapeInterior(
|
||||
ElkPositionedNode node,
|
||||
ElkPoint point,
|
||||
double tolerance = CoordinateTolerance)
|
||||
{
|
||||
if (!IsGatewayShape(node))
|
||||
{
|
||||
return IsInsideNodeBoundingBoxInterior(node, point, tolerance);
|
||||
}
|
||||
|
||||
if (!IsInsideNodeBoundingBoxInterior(node, point, tolerance))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsPointOnGatewayBoundary(node, point, Math.Max(2d, tolerance * 2d)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var polygon = BuildGatewayBoundaryPoints(node);
|
||||
bool? hasPositiveSign = null;
|
||||
for (var index = 0; index < polygon.Count; index++)
|
||||
{
|
||||
var start = polygon[index];
|
||||
var end = polygon[(index + 1) % polygon.Count];
|
||||
var cross = Cross(end.X - start.X, end.Y - start.Y, point.X - start.X, point.Y - start.Y);
|
||||
if (Math.Abs(cross) <= tolerance)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var isPositive = cross > 0d;
|
||||
if (!hasPositiveSign.HasValue)
|
||||
{
|
||||
hasPositiveSign = isPositive;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasPositiveSign.Value != isPositive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return hasPositiveSign.HasValue;
|
||||
}
|
||||
|
||||
internal static ElkPoint PreferGatewayEdgeInteriorBoundary(
|
||||
ElkPositionedNode node,
|
||||
ElkPoint boundaryPoint,
|
||||
ElkPoint anchor)
|
||||
{
|
||||
if (!IsGatewayShape(node) || !IsNearGatewayVertex(node, boundaryPoint))
|
||||
{
|
||||
return boundaryPoint;
|
||||
}
|
||||
|
||||
if (IsAllowedGatewayTipVertex(node, boundaryPoint))
|
||||
{
|
||||
return boundaryPoint;
|
||||
}
|
||||
|
||||
var polygon = BuildGatewayBoundaryPoints(node);
|
||||
var nearestVertexIndex = -1;
|
||||
var nearestVertexDistance = double.PositiveInfinity;
|
||||
for (var index = 0; index < polygon.Count; index++)
|
||||
{
|
||||
var vertex = polygon[index];
|
||||
var deltaX = boundaryPoint.X - vertex.X;
|
||||
var deltaY = boundaryPoint.Y - vertex.Y;
|
||||
var distance = Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY));
|
||||
if (distance >= nearestVertexDistance)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
nearestVertexDistance = distance;
|
||||
nearestVertexIndex = index;
|
||||
}
|
||||
|
||||
if (nearestVertexIndex < 0)
|
||||
{
|
||||
return boundaryPoint;
|
||||
}
|
||||
|
||||
var vertexPoint = polygon[nearestVertexIndex];
|
||||
var previousVertex = polygon[(nearestVertexIndex - 1 + polygon.Count) % polygon.Count];
|
||||
var nextVertex = polygon[(nearestVertexIndex + 1) % polygon.Count];
|
||||
var projectedAnchor = ProjectOntoShapeBoundary(node, anchor);
|
||||
var candidates = new[]
|
||||
{
|
||||
InterpolateAwayFromVertex(vertexPoint, previousVertex),
|
||||
InterpolateAwayFromVertex(vertexPoint, nextVertex),
|
||||
};
|
||||
|
||||
var bestCandidate = boundaryPoint;
|
||||
var bestScore = double.PositiveInfinity;
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (!IsPointOnGatewayBoundary(node, candidate, 2d))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var score = ScoreGatewayBoundaryCandidate(node, anchor, projectedAnchor, candidate);
|
||||
if (score >= bestScore)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bestScore = score;
|
||||
bestCandidate = candidate;
|
||||
}
|
||||
|
||||
if (IsNearGatewayVertex(node, bestCandidate))
|
||||
{
|
||||
var forcedOffset = node.Kind == "Decision"
|
||||
? 18d
|
||||
: 14d;
|
||||
var forcedCandidates = new[]
|
||||
{
|
||||
InterpolateAwayFromVertex(vertexPoint, previousVertex, forcedOffset),
|
||||
InterpolateAwayFromVertex(vertexPoint, nextVertex, forcedOffset),
|
||||
};
|
||||
|
||||
foreach (var candidate in forcedCandidates)
|
||||
{
|
||||
if (!IsPointOnGatewayBoundary(node, candidate, 2.5d))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var score = ScoreGatewayBoundaryCandidate(node, anchor, projectedAnchor, candidate);
|
||||
if (score >= bestScore)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bestScore = score;
|
||||
bestCandidate = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return bestCandidate;
|
||||
}
|
||||
|
||||
internal static bool IsGatewayBoundaryPoint(
|
||||
ElkPositionedNode node,
|
||||
ElkPoint point,
|
||||
double tolerance = 2d)
|
||||
{
|
||||
return IsGatewayShape(node) && IsPointOnGatewayBoundary(node, point, tolerance);
|
||||
}
|
||||
|
||||
internal static bool TryProjectGatewayBoundarySlot(
|
||||
ElkPositionedNode node,
|
||||
string side,
|
||||
double slotCoordinate,
|
||||
out ElkPoint boundaryPoint)
|
||||
{
|
||||
boundaryPoint = default!;
|
||||
if (!IsGatewayShape(node))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidates = new List<ElkPoint>();
|
||||
var polygon = BuildGatewayBoundaryPoints(node);
|
||||
switch (side)
|
||||
{
|
||||
case "left":
|
||||
case "right":
|
||||
{
|
||||
var y = Math.Max(node.Y + 4d, Math.Min(node.Y + node.Height - 4d, slotCoordinate));
|
||||
for (var index = 0; index < polygon.Count; index++)
|
||||
{
|
||||
var start = polygon[index];
|
||||
var end = polygon[(index + 1) % polygon.Count];
|
||||
AddGatewaySlotIntersections(candidates, TryIntersectHorizontalSlot(start, end, y));
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
boundaryPoint = side == "left"
|
||||
? candidates.OrderBy(point => point.X).ThenBy(point => point.Y).First()
|
||||
: candidates.OrderByDescending(point => point.X).ThenBy(point => point.Y).First();
|
||||
boundaryPoint = PreferGatewayEdgeInteriorBoundary(
|
||||
node,
|
||||
boundaryPoint,
|
||||
new ElkPoint
|
||||
{
|
||||
X = side == "left" ? node.X - 32d : node.X + node.Width + 32d,
|
||||
Y = y,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case "top":
|
||||
case "bottom":
|
||||
{
|
||||
var x = Math.Max(node.X + 4d, Math.Min(node.X + node.Width - 4d, slotCoordinate));
|
||||
for (var index = 0; index < polygon.Count; index++)
|
||||
{
|
||||
var start = polygon[index];
|
||||
var end = polygon[(index + 1) % polygon.Count];
|
||||
AddGatewaySlotIntersections(candidates, TryIntersectVerticalSlot(start, end, x));
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
boundaryPoint = side == "top"
|
||||
? candidates.OrderBy(point => point.Y).ThenBy(point => point.X).First()
|
||||
: candidates.OrderByDescending(point => point.Y).ThenBy(point => point.X).First();
|
||||
boundaryPoint = PreferGatewayEdgeInteriorBoundary(
|
||||
node,
|
||||
boundaryPoint,
|
||||
new ElkPoint
|
||||
{
|
||||
X = x,
|
||||
Y = side == "top" ? node.Y - 32d : node.Y + node.Height + 32d,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static ElkPoint BuildGatewayExteriorApproachPoint(
|
||||
ElkPositionedNode node,
|
||||
ElkPoint boundaryPoint,
|
||||
double padding = 8d)
|
||||
{
|
||||
if (!IsGatewayShape(node)
|
||||
|| !TryGetGatewayBoundaryFace(node, boundaryPoint, out var faceStart, out var faceEnd))
|
||||
{
|
||||
return boundaryPoint;
|
||||
}
|
||||
|
||||
var (normalX, normalY) = BuildGatewayFaceNormal(node, faceStart, faceEnd, boundaryPoint);
|
||||
var exitDistance = ComputeRayExitDistanceFromBoundingBox(node, boundaryPoint, normalX, normalY);
|
||||
var offset = Math.Max(0.5d, exitDistance + padding);
|
||||
return new ElkPoint
|
||||
{
|
||||
X = boundaryPoint.X + (normalX * offset),
|
||||
Y = boundaryPoint.Y + (normalY * offset),
|
||||
};
|
||||
}
|
||||
|
||||
internal static ElkPoint BuildGatewayDirectionalExteriorPoint(
|
||||
ElkPositionedNode node,
|
||||
ElkPoint boundaryPoint,
|
||||
ElkPoint referencePoint,
|
||||
double padding = 8d)
|
||||
{
|
||||
if (!IsGatewayShape(node))
|
||||
{
|
||||
return boundaryPoint;
|
||||
}
|
||||
|
||||
var candidates = new List<ElkPoint>
|
||||
{
|
||||
BuildGatewayExteriorApproachPoint(node, boundaryPoint, padding),
|
||||
};
|
||||
|
||||
var horizontalDirection = Math.Sign(referencePoint.X - boundaryPoint.X);
|
||||
if (horizontalDirection != 0d)
|
||||
{
|
||||
candidates.Add(new ElkPoint
|
||||
{
|
||||
X = horizontalDirection > 0d
|
||||
? node.X + node.Width + padding
|
||||
: node.X - padding,
|
||||
Y = boundaryPoint.Y,
|
||||
});
|
||||
}
|
||||
|
||||
var verticalDirection = Math.Sign(referencePoint.Y - boundaryPoint.Y);
|
||||
if (verticalDirection != 0d)
|
||||
{
|
||||
candidates.Add(new ElkPoint
|
||||
{
|
||||
X = boundaryPoint.X,
|
||||
Y = verticalDirection > 0d
|
||||
? node.Y + node.Height + padding
|
||||
: node.Y - padding,
|
||||
});
|
||||
}
|
||||
|
||||
ElkPoint? bestCandidate = null;
|
||||
var bestScore = double.PositiveInfinity;
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (IsInsideNodeBoundingBoxInterior(node, candidate)
|
||||
|| !HasValidGatewayBoundaryAngle(node, boundaryPoint, candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var deltaX = candidate.X - boundaryPoint.X;
|
||||
var deltaY = candidate.Y - boundaryPoint.Y;
|
||||
var moveLength = Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY));
|
||||
var referenceDistance = Math.Abs(referencePoint.X - candidate.X) + Math.Abs(referencePoint.Y - candidate.Y);
|
||||
var score = moveLength + (referenceDistance * 0.1d);
|
||||
|
||||
if (Math.Abs(referencePoint.X - boundaryPoint.X) >= Math.Abs(referencePoint.Y - boundaryPoint.Y) * 1.2d)
|
||||
{
|
||||
if (Math.Sign(deltaX) != Math.Sign(referencePoint.X - boundaryPoint.X))
|
||||
{
|
||||
score += 10_000d;
|
||||
}
|
||||
|
||||
score += Math.Abs(deltaY) * 0.35d;
|
||||
}
|
||||
else if (Math.Abs(referencePoint.Y - boundaryPoint.Y) >= Math.Abs(referencePoint.X - boundaryPoint.X) * 1.2d)
|
||||
{
|
||||
if (Math.Sign(deltaY) != Math.Sign(referencePoint.Y - boundaryPoint.Y))
|
||||
{
|
||||
score += 10_000d;
|
||||
}
|
||||
|
||||
score += Math.Abs(deltaX) * 0.35d;
|
||||
}
|
||||
|
||||
if (score >= bestScore)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bestScore = score;
|
||||
bestCandidate = candidate;
|
||||
}
|
||||
|
||||
return bestCandidate ?? BuildGatewayExteriorApproachPoint(node, boundaryPoint, padding);
|
||||
}
|
||||
|
||||
internal static ElkPoint BuildPreferredGatewaySourceExteriorPoint(
|
||||
ElkPositionedNode node,
|
||||
ElkPoint boundaryPoint,
|
||||
ElkPoint referencePoint,
|
||||
double padding = 8d)
|
||||
{
|
||||
if (!IsGatewayShape(node))
|
||||
{
|
||||
return boundaryPoint;
|
||||
}
|
||||
|
||||
var deltaX = referencePoint.X - boundaryPoint.X;
|
||||
var deltaY = referencePoint.Y - boundaryPoint.Y;
|
||||
if (node.Kind == "Decision"
|
||||
&& !IsNearGatewayVertex(node, boundaryPoint, 8d)
|
||||
&& TryGetGatewayBoundaryFace(node, boundaryPoint, out var faceStart, out var faceEnd))
|
||||
{
|
||||
var faceDx = Math.Abs(faceEnd.X - faceStart.X);
|
||||
var faceDy = Math.Abs(faceEnd.Y - faceStart.Y);
|
||||
var hasMaterialHorizontal = Math.Abs(deltaX) >= 12d;
|
||||
var hasMaterialVertical = Math.Abs(deltaY) >= 12d;
|
||||
var prefersDiagonalStub = hasMaterialHorizontal
|
||||
&& hasMaterialVertical
|
||||
&& Math.Abs(Math.Abs(deltaX) - Math.Abs(deltaY)) <= Math.Max(18d, Math.Min(Math.Abs(deltaX), Math.Abs(deltaY)) * 0.75d);
|
||||
if (faceDx >= 3d && faceDy >= 3d && prefersDiagonalStub)
|
||||
{
|
||||
var faceNormalCandidate = BuildGatewayExteriorApproachPoint(node, boundaryPoint, padding);
|
||||
if (!IsInsideNodeBoundingBoxInterior(node, faceNormalCandidate)
|
||||
&& HasValidGatewayBoundaryAngle(node, boundaryPoint, faceNormalCandidate))
|
||||
{
|
||||
return faceNormalCandidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var dominantHorizontal = Math.Abs(deltaX) >= Math.Abs(deltaY) * 1.15d;
|
||||
var dominantVertical = Math.Abs(deltaY) >= Math.Abs(deltaX) * 1.15d;
|
||||
|
||||
if (dominantHorizontal && Math.Sign(deltaX) != 0)
|
||||
{
|
||||
var horizontalCandidate = new ElkPoint
|
||||
{
|
||||
X = deltaX > 0d
|
||||
? node.X + node.Width + padding
|
||||
: node.X - padding,
|
||||
Y = boundaryPoint.Y,
|
||||
};
|
||||
if (!IsInsideNodeBoundingBoxInterior(node, horizontalCandidate)
|
||||
&& HasValidGatewayBoundaryAngle(node, boundaryPoint, horizontalCandidate))
|
||||
{
|
||||
return horizontalCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (dominantVertical && Math.Sign(deltaY) != 0)
|
||||
{
|
||||
var verticalCandidate = new ElkPoint
|
||||
{
|
||||
X = boundaryPoint.X,
|
||||
Y = deltaY > 0d
|
||||
? node.Y + node.Height + padding
|
||||
: node.Y - padding,
|
||||
};
|
||||
if (!IsInsideNodeBoundingBoxInterior(node, verticalCandidate)
|
||||
&& HasValidGatewayBoundaryAngle(node, boundaryPoint, verticalCandidate))
|
||||
{
|
||||
return verticalCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
return BuildGatewayDirectionalExteriorPoint(node, boundaryPoint, referencePoint, padding);
|
||||
}
|
||||
|
||||
private static void AddGatewaySlotIntersections(
|
||||
ICollection<ElkPoint> candidates,
|
||||
IEnumerable<ElkPoint> intersections)
|
||||
{
|
||||
foreach (var candidate in intersections)
|
||||
{
|
||||
if (candidates.Any(existing =>
|
||||
Math.Abs(existing.X - candidate.X) <= CoordinateTolerance
|
||||
&& Math.Abs(existing.Y - candidate.Y) <= CoordinateTolerance))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.Add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<ElkPoint> TryIntersectHorizontalSlot(
|
||||
ElkPoint start,
|
||||
ElkPoint end,
|
||||
double y)
|
||||
{
|
||||
if (Math.Abs(start.Y - end.Y) <= CoordinateTolerance)
|
||||
{
|
||||
if (Math.Abs(y - start.Y) > CoordinateTolerance)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return new ElkPoint { X = start.X, Y = y };
|
||||
if (Math.Abs(end.X - start.X) > CoordinateTolerance)
|
||||
{
|
||||
yield return new ElkPoint { X = end.X, Y = y };
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
var minY = Math.Min(start.Y, end.Y) - CoordinateTolerance;
|
||||
var maxY = Math.Max(start.Y, end.Y) + CoordinateTolerance;
|
||||
if (y < minY || y > maxY)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var t = (y - start.Y) / (end.Y - start.Y);
|
||||
if (t < -CoordinateTolerance || t > 1d + CoordinateTolerance)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return new ElkPoint
|
||||
{
|
||||
X = start.X + ((end.X - start.X) * t),
|
||||
Y = y,
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<ElkPoint> TryIntersectVerticalSlot(
|
||||
ElkPoint start,
|
||||
ElkPoint end,
|
||||
double x)
|
||||
{
|
||||
if (Math.Abs(start.X - end.X) <= CoordinateTolerance)
|
||||
{
|
||||
if (Math.Abs(x - start.X) > CoordinateTolerance)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return new ElkPoint { X = x, Y = start.Y };
|
||||
if (Math.Abs(end.Y - start.Y) > CoordinateTolerance)
|
||||
{
|
||||
yield return new ElkPoint { X = x, Y = end.Y };
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
var minX = Math.Min(start.X, end.X) - CoordinateTolerance;
|
||||
var maxX = Math.Max(start.X, end.X) + CoordinateTolerance;
|
||||
if (x < minX || x > maxX)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var t = (x - start.X) / (end.X - start.X);
|
||||
if (t < -CoordinateTolerance || t > 1d + CoordinateTolerance)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return new ElkPoint
|
||||
{
|
||||
X = x,
|
||||
Y = start.Y + ((end.Y - start.Y) * t),
|
||||
};
|
||||
}
|
||||
|
||||
private static double DistanceToSegment(ElkPoint point, ElkPoint start, ElkPoint end)
|
||||
{
|
||||
var deltaX = end.X - start.X;
|
||||
var deltaY = end.Y - start.Y;
|
||||
var lengthSquared = (deltaX * deltaX) + (deltaY * deltaY);
|
||||
if (lengthSquared <= 0.001d)
|
||||
{
|
||||
return Math.Sqrt(((point.X - start.X) * (point.X - start.X)) + ((point.Y - start.Y) * (point.Y - start.Y)));
|
||||
}
|
||||
|
||||
var t = (((point.X - start.X) * deltaX) + ((point.Y - start.Y) * deltaY)) / lengthSquared;
|
||||
t = Math.Max(0d, Math.Min(1d, t));
|
||||
var projectionX = start.X + (t * deltaX);
|
||||
var projectionY = start.Y + (t * deltaY);
|
||||
var distanceX = point.X - projectionX;
|
||||
var distanceY = point.Y - projectionY;
|
||||
return Math.Sqrt((distanceX * distanceX) + (distanceY * distanceY));
|
||||
}
|
||||
|
||||
private static bool TryGetGatewayBoundaryFace(
|
||||
ElkPositionedNode node,
|
||||
ElkPoint boundaryPoint,
|
||||
out ElkPoint faceStart,
|
||||
out ElkPoint faceEnd)
|
||||
{
|
||||
faceStart = default!;
|
||||
faceEnd = default!;
|
||||
|
||||
var polygon = BuildGatewayBoundaryPoints(node);
|
||||
var bestDistance = double.PositiveInfinity;
|
||||
var bestIndex = -1;
|
||||
for (var index = 0; index < polygon.Count; index++)
|
||||
{
|
||||
var start = polygon[index];
|
||||
var end = polygon[(index + 1) % polygon.Count];
|
||||
var distance = DistanceToSegment(boundaryPoint, start, end);
|
||||
if (distance > 2d || distance >= bestDistance)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bestDistance = distance;
|
||||
bestIndex = index;
|
||||
}
|
||||
|
||||
if (bestIndex < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
faceStart = polygon[bestIndex];
|
||||
faceEnd = polygon[(bestIndex + 1) % polygon.Count];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsDisallowedGatewayVertex(
|
||||
ElkPositionedNode node,
|
||||
ElkPoint boundaryPoint)
|
||||
{
|
||||
return IsNearGatewayVertex(node, boundaryPoint, GatewayVertexTolerance)
|
||||
&& !IsAllowedGatewayTipVertex(node, boundaryPoint, GatewayVertexTolerance);
|
||||
}
|
||||
|
||||
private static (double X, double Y) BuildGatewayFaceNormal(
|
||||
ElkPositionedNode node,
|
||||
ElkPoint faceStart,
|
||||
ElkPoint faceEnd,
|
||||
ElkPoint boundaryPoint)
|
||||
{
|
||||
var deltaX = faceEnd.X - faceStart.X;
|
||||
var deltaY = faceEnd.Y - faceStart.Y;
|
||||
var length = Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY));
|
||||
if (length <= 0.001d)
|
||||
{
|
||||
return (0d, -1d);
|
||||
}
|
||||
|
||||
var normalAX = deltaY / length;
|
||||
var normalAY = -deltaX / length;
|
||||
var normalBX = -normalAX;
|
||||
var normalBY = -normalAY;
|
||||
var centerX = node.X + (node.Width / 2d);
|
||||
var centerY = node.Y + (node.Height / 2d);
|
||||
var centerToBoundaryX = boundaryPoint.X - centerX;
|
||||
var centerToBoundaryY = boundaryPoint.Y - centerY;
|
||||
var dotA = (normalAX * centerToBoundaryX) + (normalAY * centerToBoundaryY);
|
||||
var dotB = (normalBX * centerToBoundaryX) + (normalBY * centerToBoundaryY);
|
||||
return dotA >= dotB
|
||||
? (normalAX, normalAY)
|
||||
: (normalBX, normalBY);
|
||||
}
|
||||
|
||||
private static double ComputeRayExitDistanceFromBoundingBox(
|
||||
ElkPositionedNode node,
|
||||
ElkPoint origin,
|
||||
double directionX,
|
||||
double directionY)
|
||||
{
|
||||
const double epsilon = 0.0001d;
|
||||
var bestDistance = double.PositiveInfinity;
|
||||
|
||||
if (directionX > epsilon)
|
||||
{
|
||||
bestDistance = Math.Min(bestDistance, (node.X + node.Width - origin.X) / directionX);
|
||||
}
|
||||
else if (directionX < -epsilon)
|
||||
{
|
||||
bestDistance = Math.Min(bestDistance, (node.X - origin.X) / directionX);
|
||||
}
|
||||
|
||||
if (directionY > epsilon)
|
||||
{
|
||||
bestDistance = Math.Min(bestDistance, (node.Y + node.Height - origin.Y) / directionY);
|
||||
}
|
||||
else if (directionY < -epsilon)
|
||||
{
|
||||
bestDistance = Math.Min(bestDistance, (node.Y - origin.Y) / directionY);
|
||||
}
|
||||
|
||||
if (double.IsInfinity(bestDistance) || bestDistance < 0d)
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
|
||||
return bestDistance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
|
||||
}
|
||||
|
||||
var placementIterations = ElkNodePlacement.ResolvePlacementIterationCount(options, allNodes.Count, layers.Length);
|
||||
var placementGrid = ElkNodePlacement.ResolvePlacementGrid(graph.Nodes);
|
||||
|
||||
var positionedNodes = new Dictionary<string, ElkPositionedNode>(StringComparer.Ordinal);
|
||||
var globalNodeWidth = graph.Nodes.Max(x => x.Width);
|
||||
@@ -67,14 +68,14 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
|
||||
ElkSharpLayoutInitialPlacement.PlaceNodesLeftToRight(
|
||||
positionedNodes, layers, dummyResult, augmentedIncoming, augmentedOutgoing,
|
||||
augmentedNodesById, incomingNodeIds, outgoingNodeIds, nodesById,
|
||||
adaptiveNodeSpacing, options, placementIterations);
|
||||
adaptiveNodeSpacing, options, placementIterations, placementGrid);
|
||||
}
|
||||
else
|
||||
{
|
||||
ElkSharpLayoutInitialPlacement.PlaceNodesTopToBottom(
|
||||
positionedNodes, layers, dummyResult, augmentedIncoming, augmentedOutgoing,
|
||||
augmentedNodesById, incomingNodeIds, outgoingNodeIds, nodesById,
|
||||
globalNodeWidth, adaptiveNodeSpacing, options, placementIterations);
|
||||
globalNodeWidth, adaptiveNodeSpacing, options, placementIterations, placementGrid);
|
||||
}
|
||||
|
||||
var graphBounds = ElkGraphValidator.ComputeGraphBounds(positionedNodes.Values
|
||||
@@ -216,6 +217,7 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
|
||||
routedEdges = ElkEdgePostProcessor.SnapAnchorsToNodeBoundary(routedEdges, finalNodes);
|
||||
// 2. Iterative multi-strategy optimizer (replaces refiner + avoid crossings + diag elim + simplify + tighten)
|
||||
routedEdges = ElkEdgeRouterIterative.Optimize(routedEdges, finalNodes, options, cancellationToken);
|
||||
ElkLayoutDiagnostics.LogProgress("ElkSharp layout optimize returned");
|
||||
|
||||
return Task.FromResult(new ElkLayoutResult
|
||||
{
|
||||
|
||||
@@ -8,13 +8,16 @@ internal static class ElkSharpLayoutInitialPlacement
|
||||
Dictionary<string, List<string>> augmentedOutgoing, Dictionary<string, ElkNode> augmentedNodesById,
|
||||
Dictionary<string, List<string>> incomingNodeIds, Dictionary<string, List<string>> outgoingNodeIds,
|
||||
Dictionary<string, ElkNode> nodesById, double adaptiveNodeSpacing,
|
||||
ElkLayoutOptions options, int placementIterations)
|
||||
ElkLayoutOptions options, int placementIterations, NodePlacementGrid placementGrid)
|
||||
{
|
||||
var globalNodeHeight = augmentedNodesById.Values
|
||||
.Where(n => !dummyResult.DummyNodeIds.Contains(n.Id))
|
||||
.Max(x => x.Height);
|
||||
var gridNodeSpacing = Math.Max(adaptiveNodeSpacing, placementGrid.YStep * 0.4d);
|
||||
var edgeDensityFactor = adaptiveNodeSpacing / options.NodeSpacing;
|
||||
var adaptiveLayerSpacing = options.LayerSpacing * Math.Min(1.15d, 0.92d + (Math.Max(0d, edgeDensityFactor - 1d) * 0.35d));
|
||||
var adaptiveLayerSpacing = Math.Max(
|
||||
options.LayerSpacing * Math.Min(1.15d, 0.92d + (Math.Max(0d, edgeDensityFactor - 1d) * 0.35d)),
|
||||
placementGrid.XStep * 0.45d);
|
||||
|
||||
var layerXPositions = new double[layers.Length];
|
||||
var currentX = 0d;
|
||||
@@ -53,7 +56,7 @@ internal static class ElkSharpLayoutInitialPlacement
|
||||
}
|
||||
else
|
||||
{
|
||||
desiredY[nodeIndex] = nodeIndex * (slotHeight + adaptiveNodeSpacing);
|
||||
desiredY[nodeIndex] = nodeIndex * (slotHeight + gridNodeSpacing);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,8 +65,8 @@ internal static class ElkSharpLayoutInitialPlacement
|
||||
var prevIsDummy = dummyResult.DummyNodeIds.Contains(layer[nodeIndex - 1].Id);
|
||||
var currIsDummy = dummyResult.DummyNodeIds.Contains(layer[nodeIndex].Id);
|
||||
var pairSpacing = (prevIsDummy && currIsDummy) ? 2d
|
||||
: (prevIsDummy || currIsDummy) ? Math.Min(adaptiveNodeSpacing, options.NodeSpacing * 0.5d)
|
||||
: adaptiveNodeSpacing;
|
||||
: (prevIsDummy || currIsDummy) ? Math.Min(gridNodeSpacing, options.NodeSpacing * 0.5d)
|
||||
: gridNodeSpacing;
|
||||
var minY = desiredY[nodeIndex - 1] + layer[nodeIndex - 1].Height + pairSpacing;
|
||||
if (desiredY[nodeIndex] < minY)
|
||||
{
|
||||
@@ -91,19 +94,19 @@ internal static class ElkSharpLayoutInitialPlacement
|
||||
|
||||
ElkNodePlacement.RefineHorizontalPlacement(positionedNodes, layers,
|
||||
incomingNodeIds, outgoingNodeIds, augmentedNodesById,
|
||||
options.NodeSpacing, placementIterations, options.Direction);
|
||||
gridNodeSpacing, placementIterations, options.Direction);
|
||||
|
||||
ElkNodePlacement.SnapOriginalPrimaryAxes(positionedNodes, layers,
|
||||
dummyResult.DummyNodeIds, incomingNodeIds, outgoingNodeIds,
|
||||
nodesById, options.NodeSpacing, options.Direction);
|
||||
nodesById, gridNodeSpacing, options.Direction);
|
||||
|
||||
ElkNodePlacementAlignment.CompactTowardIncomingFlow(positionedNodes, layers,
|
||||
dummyResult.DummyNodeIds, incomingNodeIds, nodesById,
|
||||
options.NodeSpacing, options.Direction);
|
||||
gridNodeSpacing, options.Direction);
|
||||
|
||||
ElkNodePlacement.SnapOriginalPrimaryAxes(positionedNodes, layers,
|
||||
dummyResult.DummyNodeIds, incomingNodeIds, outgoingNodeIds,
|
||||
nodesById, options.NodeSpacing, options.Direction);
|
||||
nodesById, gridNodeSpacing, options.Direction);
|
||||
|
||||
ElkNodePlacementPreferredCenter.AlignDummyNodesToFlow(positionedNodes, layers,
|
||||
dummyResult.DummyNodeIds, augmentedIncoming, augmentedOutgoing,
|
||||
@@ -123,7 +126,7 @@ internal static class ElkSharpLayoutInitialPlacement
|
||||
var pos = positionedNodes[nodeId];
|
||||
positionedNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode(
|
||||
augmentedNodesById[nodeId], pos.X, pos.Y - minNodeY, options.Direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,14 +136,15 @@ internal static class ElkSharpLayoutInitialPlacement
|
||||
Dictionary<string, List<string>> augmentedOutgoing, Dictionary<string, ElkNode> augmentedNodesById,
|
||||
Dictionary<string, List<string>> incomingNodeIds, Dictionary<string, List<string>> outgoingNodeIds,
|
||||
Dictionary<string, ElkNode> nodesById, double globalNodeWidth,
|
||||
double adaptiveNodeSpacing, ElkLayoutOptions options, int placementIterations)
|
||||
double adaptiveNodeSpacing, ElkLayoutOptions options, int placementIterations, NodePlacementGrid placementGrid)
|
||||
{
|
||||
var gridNodeSpacing = Math.Max(adaptiveNodeSpacing, placementGrid.XStep * 0.4d);
|
||||
var layerYPositions = new double[layers.Length];
|
||||
var currentY = 0d;
|
||||
for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++)
|
||||
{
|
||||
layerYPositions[layerIndex] = currentY;
|
||||
currentY += layers[layerIndex].Max(x => x.Height) + options.LayerSpacing;
|
||||
currentY += layers[layerIndex].Max(x => x.Height) + Math.Max(options.LayerSpacing, placementGrid.YStep * 0.45d);
|
||||
}
|
||||
|
||||
var slotWidth = globalNodeWidth;
|
||||
@@ -172,7 +176,7 @@ internal static class ElkSharpLayoutInitialPlacement
|
||||
}
|
||||
else
|
||||
{
|
||||
desiredX[nodeIndex] = nodeIndex * (slotWidth + adaptiveNodeSpacing);
|
||||
desiredX[nodeIndex] = nodeIndex * (slotWidth + gridNodeSpacing);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,8 +185,8 @@ internal static class ElkSharpLayoutInitialPlacement
|
||||
var prevIsDummyX = dummyResult.DummyNodeIds.Contains(layer[nodeIndex - 1].Id);
|
||||
var currIsDummyX = dummyResult.DummyNodeIds.Contains(layer[nodeIndex].Id);
|
||||
var pairSpacingX = (prevIsDummyX && currIsDummyX) ? 2d
|
||||
: (prevIsDummyX || currIsDummyX) ? Math.Min(adaptiveNodeSpacing, options.NodeSpacing * 0.5d)
|
||||
: adaptiveNodeSpacing;
|
||||
: (prevIsDummyX || currIsDummyX) ? Math.Min(gridNodeSpacing, options.NodeSpacing * 0.5d)
|
||||
: gridNodeSpacing;
|
||||
var minX = desiredX[nodeIndex - 1] + layer[nodeIndex - 1].Width + pairSpacingX;
|
||||
if (desiredX[nodeIndex] < minX)
|
||||
{
|
||||
@@ -210,19 +214,19 @@ internal static class ElkSharpLayoutInitialPlacement
|
||||
|
||||
ElkNodePlacement.RefineVerticalPlacement(positionedNodes, layers,
|
||||
incomingNodeIds, outgoingNodeIds, augmentedNodesById,
|
||||
options.NodeSpacing, placementIterations, options.Direction);
|
||||
gridNodeSpacing, placementIterations, options.Direction);
|
||||
|
||||
ElkNodePlacement.SnapOriginalPrimaryAxes(positionedNodes, layers,
|
||||
dummyResult.DummyNodeIds, incomingNodeIds, outgoingNodeIds,
|
||||
nodesById, options.NodeSpacing, options.Direction);
|
||||
nodesById, gridNodeSpacing, options.Direction);
|
||||
|
||||
ElkNodePlacementAlignment.CompactTowardIncomingFlow(positionedNodes, layers,
|
||||
dummyResult.DummyNodeIds, incomingNodeIds, nodesById,
|
||||
options.NodeSpacing, options.Direction);
|
||||
gridNodeSpacing, options.Direction);
|
||||
|
||||
ElkNodePlacement.SnapOriginalPrimaryAxes(positionedNodes, layers,
|
||||
dummyResult.DummyNodeIds, incomingNodeIds, outgoingNodeIds,
|
||||
nodesById, options.NodeSpacing, options.Direction);
|
||||
nodesById, gridNodeSpacing, options.Direction);
|
||||
|
||||
ElkNodePlacementPreferredCenter.AlignDummyNodesToFlow(positionedNodes, layers,
|
||||
dummyResult.DummyNodeIds, augmentedIncoming, augmentedOutgoing,
|
||||
@@ -242,7 +246,7 @@ internal static class ElkSharpLayoutInitialPlacement
|
||||
var pos = positionedNodes[nodeId];
|
||||
positionedNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode(
|
||||
augmentedNodesById[nodeId], pos.X - minNodeX, pos.Y, options.Direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user