elksharp stabilization
This commit is contained in:
@@ -920,7 +920,7 @@ The engine can render workflow definitions as visual diagrams.
|
||||
|
||||
| Engine | Description |
|
||||
|--------|-------------|
|
||||
| **ElkSharp** | Port of Eclipse Layout Kernel (default). In `Best` effort mode it now runs a bounded deterministic orthogonal edge-refinement pass after base routing; `Draft` and `Balanced` keep the base route unless library callers opt in through `ElkLayoutOptions.EdgeRefinement`. |
|
||||
| **ElkSharp** | Port of Eclipse Layout Kernel (default). In `Best` effort mode it runs a deterministic iterative multi-strategy orthogonal router after base routing, scoring candidate layouts across crossings, proximity, labels, target-approach joins, detours, target-approach backtracking, and entry geometry before selecting the best valid result. Attempt 1 remains the only full-strategy reroute; later attempts repair only the penalized lanes or edge clusters, with shortest-path detours prioritized first, a direct orthogonal shortcut tried before broader rerouting, and corridor-like overshoots only eligible when a clean orthogonal shortcut actually exists. Small or protected graphs keep the baseline route to preserve established sink-corridor, backward-edge, and port-anchor contracts, while larger congested graphs use the iterative sweep. Final strategy acceptance re-validates post-processed output so remaining broken short highways and non-applicable target-side approach joins are retried instead of being selected, while other soft-rule regressions get bounded multi-attempt retries and a wider but finite strategy sweep before fallback selection. A final cheap geometry-repair pass cleans node-side entry/exit angles, target-slot spacing, repeat-collector return lanes, and target-side backtracking without re-running whole-graph A*. The document-processing artifact test emits both a live progress log and per-attempt phase timings/route-pass counts alongside the SVG/PNG/JSON diagnostics so long-running strategy searches can be inspected while they are still running and profiled after completion. `Draft` and `Balanced` keep the base route unless library callers opt in through ElkSharp layout options. |
|
||||
| **ElkJS** | JavaScript-based ELK via Node.js |
|
||||
| **MSAGL** | Microsoft Automatic Graph Layout |
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using NUnit.Framework;
|
||||
|
||||
using StellaOps.ElkSharp;
|
||||
using StellaOps.Workflow.Abstractions;
|
||||
using StellaOps.Workflow.Renderer.ElkSharp;
|
||||
using StellaOps.Workflow.Renderer.Svg;
|
||||
@@ -101,8 +102,7 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("RenderingArtifacts")]
|
||||
public async Task DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings()
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotBacktrackIntoCheckResult()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
@@ -112,20 +112,53 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var svgRenderer = new WorkflowRenderSvgRenderer();
|
||||
var svgDoc = svgRenderer.Render(layout, "DocumentProcessingWorkflow [ElkSharp]");
|
||||
var targetNode = layout.Nodes.Single(node => node.Id == "start/2/branch-1/1/body/5");
|
||||
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/7");
|
||||
|
||||
Assert.That(
|
||||
HasTargetApproachBacktracking(edge, targetNode),
|
||||
Is.False,
|
||||
"Execute Batch -> Check Result must not overshoot the target side and curl back near the final approach.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("RenderingArtifacts")]
|
||||
public async Task DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var outputDir = Path.Combine(
|
||||
Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!,
|
||||
"TestResults", "workflow-renderings", DateTime.Today.ToString("yyyyMMdd"), "DocumentProcessingWorkflow");
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
using var diagnosticsCapture = ElkLayoutDiagnostics.BeginCapture();
|
||||
var progressLogPath = Path.Combine(outputDir, "elksharp.progress.log");
|
||||
if (File.Exists(progressLogPath))
|
||||
{
|
||||
File.Delete(progressLogPath);
|
||||
}
|
||||
|
||||
diagnosticsCapture.Diagnostics.ProgressLogPath = progressLogPath;
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var svgRenderer = new WorkflowRenderSvgRenderer();
|
||||
var svgDoc = svgRenderer.Render(layout, "DocumentProcessingWorkflow [ElkSharp]");
|
||||
|
||||
var svgPath = Path.Combine(outputDir, "elksharp.svg");
|
||||
await File.WriteAllTextAsync(svgPath, svgDoc.Svg);
|
||||
|
||||
var jsonPath = Path.Combine(outputDir, "elksharp.json");
|
||||
await File.WriteAllTextAsync(jsonPath, JsonSerializer.Serialize(layout, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
var diagnosticsPath = Path.Combine(outputDir, "elksharp.refinement-diagnostics.json");
|
||||
await File.WriteAllTextAsync(
|
||||
diagnosticsPath,
|
||||
JsonSerializer.Serialize(diagnosticsCapture.Diagnostics, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
WorkflowRenderPngExporter? pngExporter = null;
|
||||
string? pngPath = null;
|
||||
try
|
||||
@@ -143,6 +176,88 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
|
||||
TestContext.Out.WriteLine($"SVG: {svgPath}");
|
||||
TestContext.Out.WriteLine($"JSON: {jsonPath}");
|
||||
TestContext.Out.WriteLine($"Diagnostics: {diagnosticsPath}");
|
||||
TestContext.Out.WriteLine($"Progress log: {progressLogPath}");
|
||||
|
||||
// Render every iteration of every strategy as SVG only
|
||||
var variantsDir = Path.Combine(outputDir, "strategy-variants");
|
||||
Directory.CreateDirectory(variantsDir);
|
||||
foreach (var stratDiag in diagnosticsCapture.Diagnostics.IterativeStrategies)
|
||||
{
|
||||
foreach (var attemptDiag in stratDiag.AttemptDetails)
|
||||
{
|
||||
if (attemptDiag.Edges is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var attemptLayout = BuildVariantLayout(layout, attemptDiag.Edges);
|
||||
var sc = attemptDiag.Score;
|
||||
var attemptLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} att{attemptDiag.Attempt} [{attemptDiag.Outcome}] " +
|
||||
$"nc={sc.NodeCrossings} ec={sc.EdgeCrossings} bends={sc.BendCount} diag={sc.DiagonalCount} " +
|
||||
$"ea={sc.EntryAngleViolations} lbl={sc.LabelProximityViolations} tj={sc.TargetApproachJoinViolations} " +
|
||||
$"tb={sc.TargetApproachBacktrackingViolations} det={sc.ExcessiveDetourViolations} score={sc.Value:F0}";
|
||||
var attemptSvg = svgRenderer.Render(attemptLayout, attemptLabel);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-att{attemptDiag.Attempt:D2}.svg"),
|
||||
attemptSvg.Svg);
|
||||
}
|
||||
|
||||
if (stratDiag.BestEdges is not null)
|
||||
{
|
||||
var bestLayout = BuildVariantLayout(layout, stratDiag.BestEdges);
|
||||
var bestSc = stratDiag.BestScore;
|
||||
var bestLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} BEST [{stratDiag.Outcome}] " +
|
||||
$"nc={bestSc?.NodeCrossings} ec={bestSc?.EdgeCrossings} bends={bestSc?.BendCount} diag={bestSc?.DiagonalCount} " +
|
||||
$"ea={bestSc?.EntryAngleViolations} lbl={bestSc?.LabelProximityViolations} tj={bestSc?.TargetApproachJoinViolations} " +
|
||||
$"tb={bestSc?.TargetApproachBacktrackingViolations} det={bestSc?.ExcessiveDetourViolations} score={bestSc?.Value:F0}";
|
||||
var bestSvg = svgRenderer.Render(bestLayout, bestLabel);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-BEST.svg"),
|
||||
bestSvg.Svg);
|
||||
}
|
||||
}
|
||||
|
||||
TestContext.Out.WriteLine($"Strategy variants: {variantsDir}");
|
||||
|
||||
var localRepairAttempts = diagnosticsCapture.Diagnostics.IterativeStrategies
|
||||
.SelectMany(strategy => strategy.AttemptDetails)
|
||||
.Where(attempt => attempt.Attempt > 1
|
||||
&& string.Equals(attempt.RouteDiagnostics?.Mode, "local-repair", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
Assert.That(localRepairAttempts, Is.Not.Empty, "Expected later attempts to use targeted local repair.");
|
||||
Assert.That(
|
||||
localRepairAttempts.All(attempt => attempt.RouteDiagnostics!.RoutedEdges < attempt.RouteDiagnostics.TotalEdges),
|
||||
Is.True,
|
||||
"Local repair attempts must reroute only the penalized subset of edges.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalBrokenShortHighwayCount, Is.EqualTo(0), "Final selected layout must not keep broken short highways.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.RepeatCollectorCorridorViolations, Is.EqualTo(0), "Repeat collector outer lanes must remain separated.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.EntryAngleViolations, Is.EqualTo(0), "Selected layout must satisfy the node-side entry/exit angle rule.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachJoinViolations, Is.EqualTo(0), "Selected layout must not keep disallowed target-side joins.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachBacktrackingViolations, Is.EqualTo(0), "Selected layout must not overshoot a target side and curl back near the final approach.");
|
||||
|
||||
static WorkflowRenderLayoutResult BuildVariantLayout(WorkflowRenderLayoutResult baseLayout, ElkRoutedEdge[] edges)
|
||||
{
|
||||
return new WorkflowRenderLayoutResult
|
||||
{
|
||||
GraphId = baseLayout.GraphId,
|
||||
Nodes = baseLayout.Nodes,
|
||||
Edges = edges.Select(e => new WorkflowRenderRoutedEdge
|
||||
{
|
||||
Id = e.Id,
|
||||
SourceNodeId = e.SourceNodeId,
|
||||
TargetNodeId = e.TargetNodeId,
|
||||
Kind = e.Kind,
|
||||
Label = e.Label,
|
||||
Sections = e.Sections.Select(s => new WorkflowRenderEdgeSection
|
||||
{
|
||||
StartPoint = new WorkflowRenderPoint { X = s.StartPoint.X, Y = s.StartPoint.Y },
|
||||
EndPoint = new WorkflowRenderPoint { X = s.EndPoint.X, Y = s.EndPoint.Y },
|
||||
BendPoints = s.BendPoints.Select(p => new WorkflowRenderPoint { X = p.X, Y = p.Y }).ToArray(),
|
||||
}).ToArray(),
|
||||
}).ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
// Verify zero edge-node crossings
|
||||
var crossings = 0;
|
||||
@@ -178,4 +293,90 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
TestContext.Out.WriteLine($"Edge-node crossings: {crossings}");
|
||||
Assert.That(crossings, Is.EqualTo(0), "No edges should cross through node shapes");
|
||||
}
|
||||
|
||||
private static bool HasTargetApproachBacktracking(WorkflowRenderRoutedEdge edge, WorkflowRenderPositionedNode targetNode)
|
||||
{
|
||||
var path = new List<WorkflowRenderPoint>();
|
||||
foreach (var section in edge.Sections)
|
||||
{
|
||||
if (path.Count == 0)
|
||||
{
|
||||
path.Add(section.StartPoint);
|
||||
}
|
||||
|
||||
path.AddRange(section.BendPoints);
|
||||
path.Add(section.EndPoint);
|
||||
}
|
||||
|
||||
if (path.Count < 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var side = ResolveBoundarySide(path[^1], targetNode);
|
||||
if (side is not "left" and not "right" and not "top" and not "bottom")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const double tolerance = 0.5d;
|
||||
var startIndex = Math.Max(0, path.Count - 5);
|
||||
var axisValues = new List<double>(path.Count - startIndex);
|
||||
for (var i = startIndex; i < path.Count; i++)
|
||||
{
|
||||
var value = side is "left" or "right"
|
||||
? path[i].X
|
||||
: path[i].Y;
|
||||
if (axisValues.Count == 0 || Math.Abs(axisValues[^1] - value) > tolerance)
|
||||
{
|
||||
axisValues.Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (axisValues.Count < 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var targetAxis = side switch
|
||||
{
|
||||
"left" => targetNode.X,
|
||||
"right" => targetNode.X + targetNode.Width,
|
||||
"top" => targetNode.Y,
|
||||
"bottom" => targetNode.Y + targetNode.Height,
|
||||
_ => double.NaN,
|
||||
};
|
||||
|
||||
return side switch
|
||||
{
|
||||
"left" or "top" => axisValues.Any(value => value > targetAxis + tolerance),
|
||||
"right" or "bottom" => axisValues.Any(value => value < targetAxis - tolerance),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveBoundarySide(WorkflowRenderPoint point, WorkflowRenderPositionedNode node)
|
||||
{
|
||||
var left = Math.Abs(point.X - node.X);
|
||||
var right = Math.Abs(point.X - (node.X + node.Width));
|
||||
var top = Math.Abs(point.Y - node.Y);
|
||||
var bottom = Math.Abs(point.Y - (node.Y + node.Height));
|
||||
var min = Math.Min(Math.Min(left, right), Math.Min(top, bottom));
|
||||
if (Math.Abs(min - left) < 0.5d)
|
||||
{
|
||||
return "left";
|
||||
}
|
||||
|
||||
if (Math.Abs(min - right) < 0.5d)
|
||||
{
|
||||
return "right";
|
||||
}
|
||||
|
||||
if (Math.Abs(min - top) < 0.5d)
|
||||
{
|
||||
return "top";
|
||||
}
|
||||
|
||||
return "bottom";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -714,12 +714,14 @@ public class ElkSharpWorkflowRenderLayoutEngineTests
|
||||
var successTwo = result.Edges.Single(edge => edge.Id == "success-2").Sections.Single();
|
||||
var failure = result.Edges.Single(edge => edge.Id == "failure-1").Sections.Single();
|
||||
|
||||
// All three edges should reach the end node with distinct approach Y values
|
||||
var successBundleYOne = ResolvePreTargetBundleY(successOne);
|
||||
var successBundleYTwo = ResolvePreTargetBundleY(successTwo);
|
||||
var failureBundleY = ResolvePreTargetBundleY(failure);
|
||||
|
||||
successBundleYOne.Should().BeApproximately(successBundleYTwo, 0.01d);
|
||||
failureBundleY.Should().NotBeApproximately(successBundleYOne, 0.01d);
|
||||
var allApproachYs = new[] { successBundleYOne, successBundleYTwo, failureBundleY };
|
||||
allApproachYs.Should().OnlyHaveUniqueItems(
|
||||
"each edge should approach the target at a distinct Y coordinate");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -841,7 +843,7 @@ public class ElkSharpWorkflowRenderLayoutEngineTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenBackwardFamilySharesTarget_ShouldUseSharedSourceCollectorColumn()
|
||||
public async Task LayoutAsync_WhenBackwardFamilySharesTarget_ShouldStackOuterCollectorLanes()
|
||||
{
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var graph = new WorkflowRenderGraph
|
||||
@@ -937,22 +939,42 @@ public class ElkSharpWorkflowRenderLayoutEngineTests
|
||||
.Select(edge => edge.Sections.Single())
|
||||
.ToArray();
|
||||
|
||||
loopEdges.Should().OnlyContain(section => section.BendPoints.Count >= 3);
|
||||
var sharedCollectorX = loopEdges[0].BendPoints.ElementAt(0).X;
|
||||
loopEdges.Should().OnlyContain(section => Math.Abs(section.BendPoints.ElementAt(0).X - sharedCollectorX) <= 0.01d);
|
||||
loopEdges.Should().OnlyContain(section => Math.Abs(section.BendPoints.ElementAt(1).X - sharedCollectorX) <= 0.01d);
|
||||
var sharedCorridorY = loopEdges[0].BendPoints.ElementAt(1).Y;
|
||||
loopEdges.Should().OnlyContain(section => Math.Abs(section.BendPoints.ElementAt(1).Y - sharedCorridorY) <= 0.01d);
|
||||
var outerLoopEdges = loopEdges
|
||||
.Where(section => section.BendPoints.Min(point => point.Y) < section.EndPoint.Y - 1d)
|
||||
.ToArray();
|
||||
outerLoopEdges.Should().HaveCountGreaterOrEqualTo(2);
|
||||
outerLoopEdges.Should().OnlyContain(section => section.BendPoints.Count >= 3);
|
||||
|
||||
var directLoopEdges = loopEdges.Except(outerLoopEdges).ToArray();
|
||||
directLoopEdges.Should().OnlyContain(section => section.BendPoints.Count >= 2);
|
||||
|
||||
var collectorX = outerLoopEdges
|
||||
.Select(section => section.BendPoints.Max(point => point.X))
|
||||
.DistinctBy(x => Math.Round(x, 2))
|
||||
.ToArray();
|
||||
collectorX.Should().HaveCount(1);
|
||||
|
||||
var outerLaneYs = outerLoopEdges
|
||||
.Select(section => section.BendPoints.Min(point => point.Y))
|
||||
.OrderBy(y => y)
|
||||
.ToArray();
|
||||
outerLaneYs.Should().OnlyHaveUniqueItems();
|
||||
outerLaneYs.Should().BeInAscendingOrder();
|
||||
}
|
||||
|
||||
private static double ResolvePreTargetBundleY(WorkflowRenderEdgeSection section)
|
||||
{
|
||||
if (section.BendPoints.Count == 0)
|
||||
{
|
||||
return section.StartPoint.Y;
|
||||
}
|
||||
|
||||
var preTargetX = section.BendPoints.Max(point => point.X);
|
||||
var bundlePoint = section.BendPoints
|
||||
.Where(point => Math.Abs(point.X - preTargetX) <= 0.01d && Math.Abs(point.Y - section.EndPoint.Y) > 0.01d)
|
||||
.OrderBy(point => point.Y)
|
||||
.First();
|
||||
.FirstOrDefault();
|
||||
|
||||
return bundlePoint.Y;
|
||||
return bundlePoint?.Y ?? section.BendPoints.Last().Y;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,20 @@
|
||||
- Preserve deterministic output for the same graph and options. Do not introduce random tie-breaking.
|
||||
- Keep orthogonal routing as the default contract unless a sprint explicitly broadens it.
|
||||
- Treat channel assignment, dummy-edge reconstruction, and anchor selection as authoritative upstream inputs.
|
||||
- The current `Best`-effort path uses deterministic multi-strategy iterative routing after the baseline channel route. Keep strategy ordering stable and keep the seeded-random strategy family reproducible for the same graph.
|
||||
- A strategy attempt is only valid after final post-processing if it leaves no remaining broken short highways; detection alone is not enough.
|
||||
- Treat near-end target-side approach joins that collapse multiple edges into the same arrival rail as blocking violations. Valid highways may still remain when they satisfy the shared-length rule; non-applicable joins must not be selected silently.
|
||||
- When proximity, entry-angle, label, detour, or crossing quality is poor, use bounded multi-attempt retries and a broader but still finite strategy sweep when baseline artifacts remain; keep the final node-crossing cleanup at the end of post-processing.
|
||||
- Keep iterative diagnostics detailed enough to prove progress. The document-processing artifact test should emit a live progress log that shows baseline state, strategy starts, per-attempt scores, and adaptation decisions.
|
||||
- Iterative optimization work should focus on penalized edge or edge-cluster fixes, not whole-graph reroutes. Use whole-graph retries only as a fallback once diagnostics show the local repair path is unavailable.
|
||||
- Keep attempt 1 as the only full-strategy reroute. Attempt 2+ must target only the failed lanes or failed edge clusters, with shortest-path detours prioritized before broader quality cleanup.
|
||||
- The selected layout must not backtrack inside the final target-approach window. Attempt 2+ shortest-path repair should try a direct orthogonal shortcut first and only fall back to a low-penalty 45-degree A* candidate when the orthogonal repair is blocked by other rules.
|
||||
- Keep small or protected graphs on the baseline route when the iterative sweep would risk established geometry contracts; reserve the multi-strategy path for larger congested graphs where it materially improves routing quality.
|
||||
- Keep per-attempt diagnostics granular enough to expose routing versus post-processing cost. Phase timings and route-pass counts are required evidence before widening the retry budget again.
|
||||
- Use cheap local geometry repair after routing to clean boundary-angle, target-side arrival-slot, and repeat-collector return-lane defects before escalating to more A* work. The selected layout must satisfy the node-side 90° entry/exit rule and must not leave repeat-collector lanes collapsed onto the same outer return lane.
|
||||
- Do not replace corridor and backward-route behavior with generic rerouting unless the sprint explicitly changes that contract.
|
||||
- When touching proximity/highway logic, keep long applicable shared corridors distinct from short shared segments that must be spread apart.
|
||||
- Future A* performance work must precompute occupied grid cells or blocked segment masks and avoid expanding through cells already owned by non-terminal nodes or previously committed lanes. Derive intermediate grid spacing from approximately one third of the average service-task size instead of keeping a fixed dense lattice.
|
||||
- Keep `TopToBottom` behavior stable unless the sprint explicitly includes it.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -170,6 +170,7 @@ internal static class ElkEdgePostProcessor
|
||||
{
|
||||
var graphMinY = nodes.Length > 0 ? nodes.Min(n => n.Y) : 0d;
|
||||
var graphMaxY = nodes.Length > 0 ? nodes.Max(n => n.Y + n.Height) : 0d;
|
||||
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
|
||||
var obstacles = nodes.Select(n => (L: n.X - 4d, T: n.Y - 4d, R: n.X + n.Width + 4d, B: n.Y + n.Height + 4d, Id: n.Id)).ToArray();
|
||||
var result = new ElkRoutedEdge[edges.Length];
|
||||
for (var i = 0; i < edges.Length; i++)
|
||||
@@ -177,9 +178,12 @@ internal static class ElkEdgePostProcessor
|
||||
var edge = edges[i];
|
||||
var anyFixed = false;
|
||||
var newSections = new List<ElkEdgeSection>();
|
||||
var sectionList = edge.Sections.ToList();
|
||||
|
||||
foreach (var section in edge.Sections)
|
||||
for (var sIdx = 0; sIdx < sectionList.Count; sIdx++)
|
||||
{
|
||||
var section = sectionList[sIdx];
|
||||
var isLastSection = sIdx == sectionList.Count - 1;
|
||||
var pts = new List<ElkPoint> { section.StartPoint };
|
||||
pts.AddRange(section.BendPoints);
|
||||
pts.Add(section.EndPoint);
|
||||
@@ -205,6 +209,26 @@ internal static class ElkEdgePostProcessor
|
||||
{
|
||||
// Preserve diagonal for backward collector edges
|
||||
}
|
||||
else if (isLastSection && j == pts.Count - 1 && !isBackwardSection)
|
||||
{
|
||||
// Target approach: L-corner must be perpendicular to the entry side.
|
||||
// Vertical side (left/right) → last segment horizontal (default).
|
||||
// Horizontal side (top/bottom) → last segment vertical (flipped).
|
||||
var targetNode = nodesById.GetValueOrDefault(edge.TargetNodeId ?? "");
|
||||
var onHorizontalSide = targetNode is not null
|
||||
&& (Math.Abs(curr.Y - targetNode.Y) < 2d
|
||||
|| Math.Abs(curr.Y - (targetNode.Y + targetNode.Height)) < 2d);
|
||||
if (onHorizontalSide)
|
||||
{
|
||||
fixedPts.Add(new ElkPoint { X = curr.X, Y = prev.Y });
|
||||
}
|
||||
else
|
||||
{
|
||||
fixedPts.Add(new ElkPoint { X = prev.X, Y = curr.Y });
|
||||
}
|
||||
|
||||
anyFixed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
fixedPts.Add(new ElkPoint { X = prev.X, Y = curr.Y });
|
||||
@@ -229,6 +253,275 @@ internal static class ElkEdgePostProcessor
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static ElkRoutedEdge[] NormalizeBoundaryAngles(
|
||||
ElkRoutedEdge[] edges,
|
||||
ElkPositionedNode[] nodes)
|
||||
{
|
||||
if (edges.Length == 0 || nodes.Length == 0)
|
||||
{
|
||||
return edges;
|
||||
}
|
||||
|
||||
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
var graphMinY = nodes.Min(node => node.Y);
|
||||
var graphMaxY = nodes.Max(node => node.Y + node.Height);
|
||||
var result = new ElkRoutedEdge[edges.Length];
|
||||
for (var i = 0; i < edges.Length; i++)
|
||||
{
|
||||
var edge = edges[i];
|
||||
var path = new List<ElkPoint>();
|
||||
foreach (var section in edge.Sections)
|
||||
{
|
||||
if (path.Count == 0)
|
||||
{
|
||||
path.Add(section.StartPoint);
|
||||
}
|
||||
|
||||
path.AddRange(section.BendPoints);
|
||||
path.Add(section.EndPoint);
|
||||
}
|
||||
|
||||
if (path.Count < 2)
|
||||
{
|
||||
result[i] = edge;
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = path;
|
||||
var preserveSourceExit = ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label)
|
||||
|| ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY);
|
||||
if (!preserveSourceExit
|
||||
&& nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode))
|
||||
{
|
||||
var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[0], sourceNode);
|
||||
var sourceNormalized = NormalizeExitPath(normalized, sourceNode, sourceSide);
|
||||
if (HasClearSourceExitSegment(sourceNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId))
|
||||
{
|
||||
normalized = sourceNormalized;
|
||||
}
|
||||
}
|
||||
|
||||
if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode))
|
||||
{
|
||||
var targetSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[^1], targetNode);
|
||||
normalized = NormalizeEntryPath(normalized, targetNode, targetSide);
|
||||
}
|
||||
|
||||
if (normalized.Count == path.Count
|
||||
&& normalized.Zip(path, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal))
|
||||
{
|
||||
result[i] = edge;
|
||||
continue;
|
||||
}
|
||||
|
||||
result[i] = new ElkRoutedEdge
|
||||
{
|
||||
Id = edge.Id,
|
||||
SourceNodeId = edge.SourceNodeId,
|
||||
TargetNodeId = edge.TargetNodeId,
|
||||
SourcePortId = edge.SourcePortId,
|
||||
TargetPortId = edge.TargetPortId,
|
||||
Kind = edge.Kind,
|
||||
Label = edge.Label,
|
||||
Sections =
|
||||
[
|
||||
new ElkEdgeSection
|
||||
{
|
||||
StartPoint = normalized[0],
|
||||
EndPoint = normalized[^1],
|
||||
BendPoints = normalized.Count > 2
|
||||
? normalized.Skip(1).Take(normalized.Count - 2).ToArray()
|
||||
: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static ElkRoutedEdge[] NormalizeTargetEntryAngles(
|
||||
ElkRoutedEdge[] edges,
|
||||
ElkPositionedNode[] nodes)
|
||||
{
|
||||
return NormalizeBoundaryAngles(edges, nodes);
|
||||
}
|
||||
|
||||
internal static ElkRoutedEdge[] NormalizeSourceExitAngles(
|
||||
ElkRoutedEdge[] edges,
|
||||
ElkPositionedNode[] nodes)
|
||||
{
|
||||
if (edges.Length == 0 || nodes.Length == 0)
|
||||
{
|
||||
return edges;
|
||||
}
|
||||
|
||||
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
var graphMinY = nodes.Min(node => node.Y);
|
||||
var graphMaxY = nodes.Max(node => node.Y + node.Height);
|
||||
var result = new ElkRoutedEdge[edges.Length];
|
||||
for (var i = 0; i < edges.Length; i++)
|
||||
{
|
||||
var edge = edges[i];
|
||||
var preserveSourceExit = ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label)
|
||||
|| ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY);
|
||||
if (preserveSourceExit
|
||||
|| !nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode))
|
||||
{
|
||||
result[i] = edge;
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = new List<ElkPoint>();
|
||||
foreach (var section in edge.Sections)
|
||||
{
|
||||
if (path.Count == 0)
|
||||
{
|
||||
path.Add(section.StartPoint);
|
||||
}
|
||||
|
||||
path.AddRange(section.BendPoints);
|
||||
path.Add(section.EndPoint);
|
||||
}
|
||||
|
||||
if (path.Count < 2)
|
||||
{
|
||||
result[i] = edge;
|
||||
continue;
|
||||
}
|
||||
|
||||
var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(path[0], sourceNode);
|
||||
var normalized = NormalizeExitPath(path, sourceNode, sourceSide);
|
||||
if (!HasClearSourceExitSegment(normalized, nodes, edge.SourceNodeId, edge.TargetNodeId))
|
||||
{
|
||||
result[i] = edge;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalized.Count == path.Count
|
||||
&& normalized.Zip(path, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal))
|
||||
{
|
||||
result[i] = edge;
|
||||
continue;
|
||||
}
|
||||
|
||||
result[i] = new ElkRoutedEdge
|
||||
{
|
||||
Id = edge.Id,
|
||||
SourceNodeId = edge.SourceNodeId,
|
||||
TargetNodeId = edge.TargetNodeId,
|
||||
SourcePortId = edge.SourcePortId,
|
||||
TargetPortId = edge.TargetPortId,
|
||||
Kind = edge.Kind,
|
||||
Label = edge.Label,
|
||||
Sections =
|
||||
[
|
||||
new ElkEdgeSection
|
||||
{
|
||||
StartPoint = normalized[0],
|
||||
EndPoint = normalized[^1],
|
||||
BendPoints = normalized.Count > 2
|
||||
? normalized.Skip(1).Take(normalized.Count - 2).ToArray()
|
||||
: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static ElkRoutedEdge[] RepairBoundaryAnglesAndTargetApproaches(
|
||||
ElkRoutedEdge[] edges,
|
||||
ElkPositionedNode[] nodes,
|
||||
double minLineClearance,
|
||||
IReadOnlyCollection<string>? restrictedEdgeIds = null)
|
||||
{
|
||||
if (edges.Length == 0 || nodes.Length == 0)
|
||||
{
|
||||
return edges;
|
||||
}
|
||||
|
||||
var restrictedSet = restrictedEdgeIds is null
|
||||
? null
|
||||
: restrictedEdgeIds.ToHashSet(StringComparer.Ordinal);
|
||||
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
var graphMinY = nodes.Min(node => node.Y);
|
||||
var graphMaxY = nodes.Max(node => node.Y + node.Height);
|
||||
var targetSlots = ResolveTargetApproachSlots(edges, nodesById, graphMinY, graphMaxY, minLineClearance, restrictedSet);
|
||||
var result = new ElkRoutedEdge[edges.Length];
|
||||
|
||||
for (var i = 0; i < edges.Length; i++)
|
||||
{
|
||||
var edge = edges[i];
|
||||
if (restrictedSet is not null && !restrictedSet.Contains(edge.Id))
|
||||
{
|
||||
result[i] = edge;
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = ExtractFullPath(edge);
|
||||
if (path.Count < 2)
|
||||
{
|
||||
result[i] = edge;
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = path
|
||||
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
|
||||
.ToList();
|
||||
|
||||
if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)
|
||||
&& !HasValidBoundaryAngle(normalized[0], normalized[1], sourceNode))
|
||||
{
|
||||
var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[0], sourceNode);
|
||||
var sourceNormalized = NormalizeExitPath(normalized, sourceNode, sourceSide);
|
||||
if (HasClearBoundarySegments(sourceNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId, true, 3))
|
||||
{
|
||||
normalized = sourceNormalized;
|
||||
}
|
||||
}
|
||||
|
||||
if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode))
|
||||
{
|
||||
var assignedEndpoint = targetSlots.TryGetValue(edge.Id, out var slot)
|
||||
? slot
|
||||
: normalized[^1];
|
||||
var targetSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(assignedEndpoint, targetNode);
|
||||
if (!ElkEdgeRoutingGeometry.PointsEqual(assignedEndpoint, normalized[^1])
|
||||
|| !HasValidBoundaryAngle(normalized[^1], normalized[^2], targetNode))
|
||||
{
|
||||
var targetNormalized = NormalizeEntryPath(normalized, targetNode, targetSide, assignedEndpoint);
|
||||
if (HasClearBoundarySegments(targetNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId, false, 3))
|
||||
{
|
||||
normalized = targetNormalized;
|
||||
}
|
||||
}
|
||||
|
||||
var shortenedApproach = TrimTargetApproachBacktracking(normalized, targetNode, targetSide, assignedEndpoint);
|
||||
if (shortenedApproach.Count != normalized.Count
|
||||
|| !shortenedApproach.Zip(normalized, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal))
|
||||
{
|
||||
if (HasClearBoundarySegments(shortenedApproach, nodes, edge.SourceNodeId, edge.TargetNodeId, false, 3))
|
||||
{
|
||||
normalized = shortenedApproach;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.Count == path.Count
|
||||
&& normalized.Zip(path, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal))
|
||||
{
|
||||
result[i] = edge;
|
||||
continue;
|
||||
}
|
||||
|
||||
result[i] = BuildSingleSectionEdge(edge, normalized);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static bool IsRepeatCollectorLabel(string? label)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(label))
|
||||
@@ -268,10 +561,8 @@ internal static class ElkEdgePostProcessor
|
||||
(double Left, double Top, double Right, double Bottom, string Id)[] obstacles,
|
||||
string sourceId, string targetId)
|
||||
{
|
||||
var segLen = Math.Sqrt(Math.Pow(p1.X - p2.X, 2) + Math.Pow(p1.Y - p2.Y, 2));
|
||||
var isH = Math.Abs(p1.Y - p2.Y) < 2d;
|
||||
var isV = Math.Abs(p1.X - p2.X) < 2d;
|
||||
if (!isH && !isV) return segLen > 15d;
|
||||
|
||||
foreach (var ob in obstacles)
|
||||
{
|
||||
@@ -288,8 +579,555 @@ internal static class ElkEdgePostProcessor
|
||||
var maxY = Math.Max(p1.Y, p2.Y);
|
||||
if (maxY > ob.Top && minY < ob.Bottom) return true;
|
||||
}
|
||||
else if (!isH && !isV)
|
||||
{
|
||||
// Diagonal segment: check actual intersection with obstacle rectangle
|
||||
if (ElkEdgeRoutingGeometry.SegmentsIntersect(p1, p2,
|
||||
new ElkPoint { X = ob.Left, Y = ob.Top }, new ElkPoint { X = ob.Right, Y = ob.Top })
|
||||
|| ElkEdgeRoutingGeometry.SegmentsIntersect(p1, p2,
|
||||
new ElkPoint { X = ob.Right, Y = ob.Top }, new ElkPoint { X = ob.Right, Y = ob.Bottom })
|
||||
|| ElkEdgeRoutingGeometry.SegmentsIntersect(p1, p2,
|
||||
new ElkPoint { X = ob.Right, Y = ob.Bottom }, new ElkPoint { X = ob.Left, Y = ob.Bottom })
|
||||
|| ElkEdgeRoutingGeometry.SegmentsIntersect(p1, p2,
|
||||
new ElkPoint { X = ob.Left, Y = ob.Bottom }, new ElkPoint { X = ob.Left, Y = ob.Top }))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasClearSourceExitSegment(
|
||||
IReadOnlyList<ElkPoint> path,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes,
|
||||
string? sourceNodeId,
|
||||
string? targetNodeId)
|
||||
{
|
||||
return HasClearBoundarySegments(path, nodes, sourceNodeId, targetNodeId, true, 2);
|
||||
}
|
||||
|
||||
private static List<ElkPoint> NormalizeExitPath(
|
||||
IReadOnlyList<ElkPoint> sourcePath,
|
||||
ElkPositionedNode sourceNode,
|
||||
string side)
|
||||
{
|
||||
const double coordinateTolerance = 0.5d;
|
||||
var path = sourcePath
|
||||
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
|
||||
.ToList();
|
||||
if (path.Count < 2)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
if (side is "left" or "right")
|
||||
{
|
||||
var sourceX = side == "left"
|
||||
? sourceNode.X
|
||||
: sourceNode.X + sourceNode.Width;
|
||||
while (path.Count >= 3 && Math.Abs(path[1].X - sourceX) <= coordinateTolerance)
|
||||
{
|
||||
path.RemoveAt(1);
|
||||
}
|
||||
|
||||
var rebuilt = new List<ElkPoint>
|
||||
{
|
||||
new() { X = sourceX, Y = path[0].Y },
|
||||
};
|
||||
var anchor = path[1];
|
||||
var stubX = side == "left"
|
||||
? sourceX - 24d
|
||||
: sourceX + 24d;
|
||||
if (Math.Abs(stubX - sourceX) > coordinateTolerance)
|
||||
{
|
||||
rebuilt.Add(new ElkPoint
|
||||
{
|
||||
X = stubX,
|
||||
Y = path[0].Y,
|
||||
});
|
||||
}
|
||||
|
||||
if (Math.Abs(anchor.Y - path[0].Y) > coordinateTolerance)
|
||||
{
|
||||
rebuilt.Add(new ElkPoint { X = stubX, Y = anchor.Y });
|
||||
}
|
||||
|
||||
if (Math.Abs(anchor.X - stubX) > coordinateTolerance)
|
||||
{
|
||||
rebuilt.Add(new ElkPoint { X = anchor.X, Y = anchor.Y });
|
||||
}
|
||||
|
||||
rebuilt.AddRange(path.Skip(2));
|
||||
return NormalizePathPoints(rebuilt);
|
||||
}
|
||||
|
||||
var sourceY = side == "top"
|
||||
? sourceNode.Y
|
||||
: sourceNode.Y + sourceNode.Height;
|
||||
while (path.Count >= 3 && Math.Abs(path[1].Y - sourceY) <= coordinateTolerance)
|
||||
{
|
||||
path.RemoveAt(1);
|
||||
}
|
||||
|
||||
var verticalRebuilt = new List<ElkPoint>
|
||||
{
|
||||
new() { X = path[0].X, Y = sourceY },
|
||||
};
|
||||
var verticalAnchor = path[1];
|
||||
var stubY = side == "top"
|
||||
? sourceY - 24d
|
||||
: sourceY + 24d;
|
||||
if (Math.Abs(stubY - sourceY) > coordinateTolerance)
|
||||
{
|
||||
verticalRebuilt.Add(new ElkPoint
|
||||
{
|
||||
X = path[0].X,
|
||||
Y = stubY,
|
||||
});
|
||||
}
|
||||
|
||||
if (Math.Abs(verticalAnchor.X - path[0].X) > coordinateTolerance)
|
||||
{
|
||||
verticalRebuilt.Add(new ElkPoint { X = verticalAnchor.X, Y = stubY });
|
||||
}
|
||||
|
||||
if (Math.Abs(verticalAnchor.Y - stubY) > coordinateTolerance)
|
||||
{
|
||||
verticalRebuilt.Add(new ElkPoint { X = verticalAnchor.X, Y = verticalAnchor.Y });
|
||||
}
|
||||
|
||||
verticalRebuilt.AddRange(path.Skip(2));
|
||||
return NormalizePathPoints(verticalRebuilt);
|
||||
}
|
||||
|
||||
private static List<ElkPoint> NormalizeEntryPath(
|
||||
IReadOnlyList<ElkPoint> sourcePath,
|
||||
ElkPositionedNode targetNode,
|
||||
string side)
|
||||
{
|
||||
return NormalizeEntryPath(sourcePath, targetNode, side, null);
|
||||
}
|
||||
|
||||
private static List<ElkPoint> NormalizeEntryPath(
|
||||
IReadOnlyList<ElkPoint> sourcePath,
|
||||
ElkPositionedNode targetNode,
|
||||
string side,
|
||||
ElkPoint? explicitEndpoint)
|
||||
{
|
||||
const double coordinateTolerance = 0.5d;
|
||||
var path = sourcePath
|
||||
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
|
||||
.ToList();
|
||||
if (path.Count < 2)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
if (side is "left" or "right")
|
||||
{
|
||||
var targetX = side == "left"
|
||||
? targetNode.X
|
||||
: targetNode.X + targetNode.Width;
|
||||
var endpoint = explicitEndpoint ?? new ElkPoint { X = targetX, Y = path[^1].Y };
|
||||
while (path.Count >= 3 && Math.Abs(path[^2].X - targetX) <= coordinateTolerance)
|
||||
{
|
||||
path.RemoveAt(path.Count - 2);
|
||||
}
|
||||
|
||||
var anchor = path[^2];
|
||||
var rebuilt = path.Take(path.Count - 2).ToList();
|
||||
if (rebuilt.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], anchor))
|
||||
{
|
||||
rebuilt.Add(anchor);
|
||||
}
|
||||
|
||||
var stubX = side == "left"
|
||||
? targetX - 24d
|
||||
: targetX + 24d;
|
||||
if (Math.Abs(anchor.X - stubX) > coordinateTolerance)
|
||||
{
|
||||
rebuilt.Add(new ElkPoint { X = stubX, Y = anchor.Y });
|
||||
}
|
||||
|
||||
if (Math.Abs(anchor.Y - endpoint.Y) > coordinateTolerance)
|
||||
{
|
||||
rebuilt.Add(new ElkPoint { X = stubX, Y = endpoint.Y });
|
||||
}
|
||||
|
||||
rebuilt.Add(endpoint);
|
||||
return NormalizePathPoints(rebuilt);
|
||||
}
|
||||
|
||||
var targetY = side == "top"
|
||||
? targetNode.Y
|
||||
: targetNode.Y + targetNode.Height;
|
||||
var verticalEndpoint = explicitEndpoint ?? new ElkPoint { X = path[^1].X, Y = targetY };
|
||||
while (path.Count >= 3 && Math.Abs(path[^2].Y - targetY) <= coordinateTolerance)
|
||||
{
|
||||
path.RemoveAt(path.Count - 2);
|
||||
}
|
||||
|
||||
var verticalAnchor = path[^2];
|
||||
var verticalRebuilt = path.Take(path.Count - 2).ToList();
|
||||
if (verticalRebuilt.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(verticalRebuilt[^1], verticalAnchor))
|
||||
{
|
||||
verticalRebuilt.Add(verticalAnchor);
|
||||
}
|
||||
|
||||
var stubY = side == "top"
|
||||
? targetY - 24d
|
||||
: targetY + 24d;
|
||||
if (Math.Abs(verticalAnchor.X - verticalEndpoint.X) > coordinateTolerance)
|
||||
{
|
||||
verticalRebuilt.Add(new ElkPoint { X = verticalEndpoint.X, Y = verticalAnchor.Y });
|
||||
}
|
||||
|
||||
if (Math.Abs(verticalAnchor.Y - stubY) > coordinateTolerance)
|
||||
{
|
||||
verticalRebuilt.Add(new ElkPoint { X = verticalEndpoint.X, Y = stubY });
|
||||
}
|
||||
|
||||
verticalRebuilt.Add(verticalEndpoint);
|
||||
return NormalizePathPoints(verticalRebuilt);
|
||||
}
|
||||
|
||||
private static List<ElkPoint> TrimTargetApproachBacktracking(
|
||||
IReadOnlyList<ElkPoint> sourcePath,
|
||||
ElkPositionedNode targetNode,
|
||||
string side,
|
||||
ElkPoint explicitEndpoint)
|
||||
{
|
||||
if (sourcePath.Count < 4)
|
||||
{
|
||||
return sourcePath
|
||||
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
|
||||
.ToList();
|
||||
}
|
||||
|
||||
const double tolerance = 0.5d;
|
||||
var startIndex = Math.Max(0, sourcePath.Count - 5);
|
||||
var firstOffendingIndex = -1;
|
||||
for (var i = startIndex; i < sourcePath.Count - 1; i++)
|
||||
{
|
||||
if (IsOnWrongSideOfTarget(sourcePath[i], targetNode, side, tolerance))
|
||||
{
|
||||
firstOffendingIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstOffendingIndex < 0)
|
||||
{
|
||||
return sourcePath
|
||||
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var trimmed = sourcePath
|
||||
.Take(Math.Max(1, firstOffendingIndex))
|
||||
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
|
||||
.ToList();
|
||||
if (trimmed.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(trimmed[^1], explicitEndpoint))
|
||||
{
|
||||
trimmed.Add(explicitEndpoint);
|
||||
}
|
||||
|
||||
return NormalizeEntryPath(trimmed, targetNode, side, explicitEndpoint);
|
||||
}
|
||||
|
||||
private static bool IsOnWrongSideOfTarget(
|
||||
ElkPoint point,
|
||||
ElkPositionedNode targetNode,
|
||||
string side,
|
||||
double tolerance)
|
||||
{
|
||||
return side switch
|
||||
{
|
||||
"left" => point.X > targetNode.X + tolerance,
|
||||
"right" => point.X < (targetNode.X + targetNode.Width) - tolerance,
|
||||
"top" => point.Y > targetNode.Y + tolerance,
|
||||
"bottom" => point.Y < (targetNode.Y + targetNode.Height) - tolerance,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, ElkPoint> ResolveTargetApproachSlots(
|
||||
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
|
||||
double graphMinY,
|
||||
double graphMaxY,
|
||||
double minLineClearance,
|
||||
IReadOnlySet<string>? restrictedEdgeIds)
|
||||
{
|
||||
var result = new Dictionary<string, ElkPoint>(StringComparer.Ordinal);
|
||||
var groups = new Dictionary<string, List<(string EdgeId, ElkPoint Endpoint)>>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (restrictedEdgeIds is not null && !restrictedEdgeIds.Contains(edge.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ShouldSpreadTargetApproach(edge, graphMinY, graphMaxY)
|
||||
|| !nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = ExtractFullPath(edge);
|
||||
if (path.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var endpoint = path[^1];
|
||||
var side = ElkEdgeRoutingGeometry.ResolveBoundarySide(endpoint, targetNode);
|
||||
var key = $"{targetNode.Id}|{side}";
|
||||
if (!groups.TryGetValue(key, out var group))
|
||||
{
|
||||
group = [];
|
||||
groups[key] = group;
|
||||
}
|
||||
|
||||
group.Add((edge.Id, endpoint));
|
||||
}
|
||||
|
||||
foreach (var (key, group) in groups)
|
||||
{
|
||||
if (group.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var separator = key.IndexOf('|', StringComparison.Ordinal);
|
||||
var targetId = key[..separator];
|
||||
var side = key[(separator + 1)..];
|
||||
if (!nodesById.TryGetValue(targetId, out var targetNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var sideLength = side is "left" or "right"
|
||||
? Math.Max(8d, targetNode.Height - 8d)
|
||||
: Math.Max(8d, targetNode.Width - 8d);
|
||||
var slotSpacing = group.Count > 1
|
||||
? Math.Max(12d, Math.Min(minLineClearance, sideLength / (group.Count - 1)))
|
||||
: 0d;
|
||||
var totalSpan = (group.Count - 1) * slotSpacing;
|
||||
|
||||
if (side is "left" or "right")
|
||||
{
|
||||
var centerY = targetNode.Y + (targetNode.Height / 2d);
|
||||
var startY = Math.Max(targetNode.Y + 4d, centerY - (totalSpan / 2d));
|
||||
var sorted = group.OrderBy(item => item.Endpoint.Y).ToArray();
|
||||
for (var i = 0; i < sorted.Length; i++)
|
||||
{
|
||||
var slotY = Math.Min(targetNode.Y + targetNode.Height - 4d, startY + (i * slotSpacing));
|
||||
result[sorted[i].EdgeId] = new ElkPoint
|
||||
{
|
||||
X = side == "left" ? targetNode.X : targetNode.X + targetNode.Width,
|
||||
Y = slotY,
|
||||
};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var centerX = targetNode.X + (targetNode.Width / 2d);
|
||||
var startX = Math.Max(targetNode.X + 4d, centerX - (totalSpan / 2d));
|
||||
var sorted = group.OrderBy(item => item.Endpoint.X).ToArray();
|
||||
for (var i = 0; i < sorted.Length; i++)
|
||||
{
|
||||
var slotX = Math.Min(targetNode.X + targetNode.Width - 4d, startX + (i * slotSpacing));
|
||||
result[sorted[i].EdgeId] = new ElkPoint
|
||||
{
|
||||
X = slotX,
|
||||
Y = side == "top" ? targetNode.Y : targetNode.Y + targetNode.Height,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool ShouldSpreadTargetApproach(
|
||||
ElkRoutedEdge edge,
|
||||
double graphMinY,
|
||||
double graphMaxY)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(edge.SourcePortId) || !string.IsNullOrWhiteSpace(edge.TargetPortId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(edge.Kind)
|
||||
&& edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsRepeatCollectorLabel(edge.Label))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (HasCorridorBendPoints(edge, graphMinY, graphMaxY))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasClearBoundarySegments(
|
||||
IReadOnlyList<ElkPoint> path,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes,
|
||||
string? sourceNodeId,
|
||||
string? targetNodeId,
|
||||
bool fromStart,
|
||||
int segmentCount)
|
||||
{
|
||||
if (path.Count < 2)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var obstacles = nodes.Select(node => (
|
||||
Left: node.X,
|
||||
Top: node.Y,
|
||||
Right: node.X + node.Width,
|
||||
Bottom: node.Y + node.Height,
|
||||
Id: node.Id)).ToArray();
|
||||
if (fromStart)
|
||||
{
|
||||
var maxIndex = Math.Min(path.Count - 1, segmentCount);
|
||||
for (var i = 0; i < maxIndex; i++)
|
||||
{
|
||||
if (SegmentCrossesObstacle(path[i], path[i + 1], obstacles, sourceNodeId ?? string.Empty, targetNodeId ?? string.Empty))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
var startIndex = Math.Max(0, path.Count - 1 - segmentCount);
|
||||
for (var i = startIndex; i < path.Count - 1; i++)
|
||||
{
|
||||
if (SegmentCrossesObstacle(path[i], path[i + 1], obstacles, sourceNodeId ?? string.Empty, targetNodeId ?? string.Empty))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasValidBoundaryAngle(
|
||||
ElkPoint boundaryPoint,
|
||||
ElkPoint adjacentPoint,
|
||||
ElkPositionedNode node)
|
||||
{
|
||||
var segDx = Math.Abs(boundaryPoint.X - adjacentPoint.X);
|
||||
var segDy = Math.Abs(boundaryPoint.Y - adjacentPoint.Y);
|
||||
if (segDx < 3d && segDy < 3d)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var side = ElkEdgeRoutingGeometry.ResolveBoundarySide(boundaryPoint, node);
|
||||
var validForVerticalSide = segDx > segDy * 3d;
|
||||
var validForHorizontalSide = segDy > segDx * 3d;
|
||||
return side switch
|
||||
{
|
||||
"left" or "right" => validForVerticalSide,
|
||||
"top" or "bottom" => validForHorizontalSide,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<ElkPoint> ExtractFullPath(ElkRoutedEdge edge)
|
||||
{
|
||||
var path = new List<ElkPoint>();
|
||||
foreach (var section in edge.Sections)
|
||||
{
|
||||
if (path.Count == 0)
|
||||
{
|
||||
path.Add(section.StartPoint);
|
||||
}
|
||||
|
||||
path.AddRange(section.BendPoints);
|
||||
path.Add(section.EndPoint);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private static ElkRoutedEdge BuildSingleSectionEdge(
|
||||
ElkRoutedEdge edge,
|
||||
IReadOnlyList<ElkPoint> path)
|
||||
{
|
||||
return new ElkRoutedEdge
|
||||
{
|
||||
Id = edge.Id,
|
||||
SourceNodeId = edge.SourceNodeId,
|
||||
TargetNodeId = edge.TargetNodeId,
|
||||
SourcePortId = edge.SourcePortId,
|
||||
TargetPortId = edge.TargetPortId,
|
||||
Kind = edge.Kind,
|
||||
Label = edge.Label,
|
||||
Sections =
|
||||
[
|
||||
new ElkEdgeSection
|
||||
{
|
||||
StartPoint = path[0],
|
||||
EndPoint = path[^1],
|
||||
BendPoints = path.Count > 2
|
||||
? path.Skip(1).Take(path.Count - 2).ToArray()
|
||||
: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static List<ElkPoint> NormalizePathPoints(IReadOnlyList<ElkPoint> points)
|
||||
{
|
||||
const double coordinateTolerance = 0.5d;
|
||||
var deduped = new List<ElkPoint>();
|
||||
foreach (var point in points)
|
||||
{
|
||||
if (deduped.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(deduped[^1], point))
|
||||
{
|
||||
deduped.Add(point);
|
||||
}
|
||||
}
|
||||
|
||||
if (deduped.Count <= 2)
|
||||
{
|
||||
return deduped;
|
||||
}
|
||||
|
||||
var simplified = new List<ElkPoint> { deduped[0] };
|
||||
for (var i = 1; i < deduped.Count - 1; i++)
|
||||
{
|
||||
var previous = simplified[^1];
|
||||
var current = deduped[i];
|
||||
var next = deduped[i + 1];
|
||||
var sameX = Math.Abs(previous.X - current.X) <= coordinateTolerance
|
||||
&& Math.Abs(current.X - next.X) <= coordinateTolerance;
|
||||
var sameY = Math.Abs(previous.Y - current.Y) <= coordinateTolerance
|
||||
&& Math.Abs(current.Y - next.Y) <= coordinateTolerance;
|
||||
if (!sameX && !sameY)
|
||||
{
|
||||
simplified.Add(current);
|
||||
}
|
||||
}
|
||||
|
||||
simplified.Add(deduped[^1]);
|
||||
return simplified;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,12 @@ internal static class ElkEdgePostProcessorSimplify
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed && TryApplyOrthogonalShortcut(cleaned, obstacles, excludeIds))
|
||||
{
|
||||
changed = true;
|
||||
anyChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove trailing duplicates (bend point == endpoint)
|
||||
@@ -110,8 +116,12 @@ internal static class ElkEdgePostProcessorSimplify
|
||||
|
||||
var graphMinY = nodes.Min(n => n.Y);
|
||||
var graphMaxY = nodes.Max(n => n.Y + n.Height);
|
||||
const double minMargin = 12d;
|
||||
const double laneGap = 8d;
|
||||
var serviceNodes = nodes.Where(n => n.Kind is not "Start" and not "End").ToArray();
|
||||
var minLineClearance = serviceNodes.Length > 0
|
||||
? Math.Min(serviceNodes.Average(n => n.Width), serviceNodes.Average(n => n.Height)) / 2d
|
||||
: 50d;
|
||||
var minMargin = Math.Max(12d, minLineClearance + 4d);
|
||||
var laneGap = Math.Max(8d, minLineClearance + 4d);
|
||||
|
||||
var outerEdges = new List<(int Index, double CorridorY, bool IsAbove)>();
|
||||
for (var i = 0; i < edges.Length; i++)
|
||||
@@ -250,6 +260,110 @@ internal static class ElkEdgePostProcessorSimplify
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryApplyOrthogonalShortcut(
|
||||
List<ElkPoint> points,
|
||||
(double L, double T, double R, double B, string Id)[] obstacles,
|
||||
HashSet<string> excludeIds)
|
||||
{
|
||||
if (points.Count < 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var startIndex = 0; startIndex < points.Count - 2; startIndex++)
|
||||
{
|
||||
for (var endIndex = points.Count - 1; endIndex >= startIndex + 2; endIndex--)
|
||||
{
|
||||
var start = points[startIndex];
|
||||
var end = points[endIndex];
|
||||
var existingLength = ComputeSubpathLength(points, startIndex, endIndex);
|
||||
|
||||
foreach (var shortcut in BuildShortcutCandidates(start, end))
|
||||
{
|
||||
if (shortcut.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ShortcutClearsObstacles(shortcut, obstacles, excludeIds))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var shortcutLength = ComputePathLength(shortcut);
|
||||
if (shortcutLength >= existingLength - 8d)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
points.RemoveRange(startIndex + 1, endIndex - startIndex - 1);
|
||||
if (shortcut.Count > 2)
|
||||
{
|
||||
points.InsertRange(startIndex + 1, shortcut.Skip(1).Take(shortcut.Count - 2));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<List<ElkPoint>> BuildShortcutCandidates(ElkPoint start, ElkPoint end)
|
||||
{
|
||||
var candidates = new List<List<ElkPoint>>();
|
||||
if (Math.Abs(start.X - end.X) < 1d || Math.Abs(start.Y - end.Y) < 1d)
|
||||
{
|
||||
candidates.Add([start, end]);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var corner1 = new ElkPoint { X = start.X, Y = end.Y };
|
||||
var corner2 = new ElkPoint { X = end.X, Y = start.Y };
|
||||
candidates.Add([start, corner1, end]);
|
||||
candidates.Add([start, corner2, end]);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private static bool ShortcutClearsObstacles(
|
||||
IReadOnlyList<ElkPoint> shortcut,
|
||||
(double L, double T, double R, double B, string Id)[] obstacles,
|
||||
HashSet<string> excludeIds)
|
||||
{
|
||||
for (var i = 0; i < shortcut.Count - 1; i++)
|
||||
{
|
||||
if (!SegmentClearsObstacles(shortcut[i], shortcut[i + 1], obstacles, excludeIds))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static double ComputeSubpathLength(IReadOnlyList<ElkPoint> points, int startIndex, int endIndex)
|
||||
{
|
||||
var length = 0d;
|
||||
for (var i = startIndex; i < endIndex; i++)
|
||||
{
|
||||
length += ElkEdgeRoutingGeometry.ComputeSegmentLength(points[i], points[i + 1]);
|
||||
}
|
||||
|
||||
return length;
|
||||
}
|
||||
|
||||
private static double ComputePathLength(IReadOnlyList<ElkPoint> points)
|
||||
{
|
||||
var length = 0d;
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
length += ElkEdgeRoutingGeometry.ComputeSegmentLength(points[i], points[i + 1]);
|
||||
}
|
||||
|
||||
return length;
|
||||
}
|
||||
|
||||
private static void NormalizeCorridorYValues(
|
||||
List<(int Index, double CorridorY, bool IsAbove)> outerEdges,
|
||||
ElkRoutedEdge[] edges,
|
||||
|
||||
@@ -25,9 +25,15 @@ internal static class ElkEdgeRouteRefiner
|
||||
return edges;
|
||||
}
|
||||
|
||||
var diagnostics = ElkLayoutDiagnostics.Current;
|
||||
var bestEdges = edges;
|
||||
var bestScore = ElkEdgeRoutingScoring.ComputeScore(bestEdges, nodes);
|
||||
var bestNodeCrossings = bestScore.NodeCrossings;
|
||||
if (diagnostics is not null)
|
||||
{
|
||||
diagnostics.InitialScore = bestScore;
|
||||
diagnostics.FinalScore = bestScore;
|
||||
}
|
||||
|
||||
for (var passIndex = 0; passIndex < options.MaxGlobalPasses; passIndex++)
|
||||
{
|
||||
@@ -46,7 +52,7 @@ internal static class ElkEdgeRouteRefiner
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!TryImproveEdge(bestEdges, nodes, issue.EdgeId, bestScore, options, cancellationToken, out var improvedEdges, out var improvedScore))
|
||||
if (!TryImproveEdge(bestEdges, nodes, passIndex + 1, issue, bestScore, options, cancellationToken, out var improvedEdges, out var improvedScore))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -73,13 +79,23 @@ internal static class ElkEdgeRouteRefiner
|
||||
}
|
||||
}
|
||||
|
||||
if (diagnostics is not null)
|
||||
{
|
||||
diagnostics.CompletedPasses = diagnostics.Attempts
|
||||
.Select(attempt => attempt.PassIndex)
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
diagnostics.FinalScore = bestScore;
|
||||
}
|
||||
|
||||
return bestEdges;
|
||||
}
|
||||
|
||||
private static bool TryImproveEdge(
|
||||
ElkRoutedEdge[] edges,
|
||||
ElkPositionedNode[] nodes,
|
||||
string edgeId,
|
||||
int passIndex,
|
||||
EdgeRoutingIssue issue,
|
||||
EdgeRoutingScore baselineScore,
|
||||
EdgeRefinementOptions options,
|
||||
CancellationToken cancellationToken,
|
||||
@@ -89,7 +105,7 @@ internal static class ElkEdgeRouteRefiner
|
||||
improvedEdges = edges;
|
||||
improvedScore = baselineScore;
|
||||
|
||||
var edgeIndex = Array.FindIndex(edges, edge => string.Equals(edge.Id, edgeId, StringComparison.Ordinal));
|
||||
var edgeIndex = Array.FindIndex(edges, edge => string.Equals(edge.Id, issue.EdgeId, StringComparison.Ordinal));
|
||||
if (edgeIndex < 0)
|
||||
{
|
||||
return false;
|
||||
@@ -107,15 +123,28 @@ internal static class ElkEdgeRouteRefiner
|
||||
Right: node.X + node.Width,
|
||||
Bottom: node.Y + node.Height,
|
||||
Id: node.Id)).ToArray();
|
||||
var softObstacles = BuildSoftObstacles(edges, edgeId);
|
||||
var softObstacles = BuildSoftObstacles(edges, issue.EdgeId);
|
||||
|
||||
var attemptDiagnostics = ElkLayoutDiagnostics.Current is null
|
||||
? null
|
||||
: new ElkEdgeRefinementAttemptDiagnostics
|
||||
{
|
||||
PassIndex = passIndex,
|
||||
EdgeId = issue.EdgeId,
|
||||
Severity = issue.Severity,
|
||||
BaselineScore = baselineScore,
|
||||
};
|
||||
|
||||
var bestLocalEdges = edges;
|
||||
var bestLocalScore = baselineScore;
|
||||
var trials = BuildTrials(options).Take(options.MaxTrialsPerProblemEdge);
|
||||
var bestTrialIndex = -1;
|
||||
var trialCounter = 0;
|
||||
|
||||
foreach (var trial in trials)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
trialCounter++;
|
||||
|
||||
var reroutedSections = new List<ElkEdgeSection>(edge.Sections.Count);
|
||||
var rerouteFailed = false;
|
||||
@@ -146,6 +175,14 @@ internal static class ElkEdgeRouteRefiner
|
||||
|
||||
if (rerouteFailed)
|
||||
{
|
||||
attemptDiagnostics?.Trials.Add(new ElkEdgeRefinementTrialDiagnostics
|
||||
{
|
||||
TrialIndex = trialCounter,
|
||||
Margin = trial.Margin,
|
||||
BendPenalty = trial.BendPenalty,
|
||||
SoftObstacleWeight = trial.SoftObstacleWeight,
|
||||
Outcome = "no-path",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -163,6 +200,21 @@ internal static class ElkEdgeRouteRefiner
|
||||
};
|
||||
|
||||
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes);
|
||||
attemptDiagnostics?.Trials.Add(new ElkEdgeRefinementTrialDiagnostics
|
||||
{
|
||||
TrialIndex = trialCounter,
|
||||
Margin = trial.Margin,
|
||||
BendPenalty = trial.BendPenalty,
|
||||
SoftObstacleWeight = trial.SoftObstacleWeight,
|
||||
Outcome = "scored",
|
||||
CandidateScore = candidateScore,
|
||||
Sections = reroutedSections.Select(section => new ElkDiagnosticSectionPath
|
||||
{
|
||||
StartPoint = section.StartPoint,
|
||||
BendPoints = section.BendPoints.ToArray(),
|
||||
EndPoint = section.EndPoint,
|
||||
}).ToArray(),
|
||||
});
|
||||
if (!IsBetterCandidate(candidateScore, bestLocalScore))
|
||||
{
|
||||
continue;
|
||||
@@ -170,6 +222,24 @@ internal static class ElkEdgeRouteRefiner
|
||||
|
||||
bestLocalEdges = candidateEdges;
|
||||
bestLocalScore = candidateScore;
|
||||
bestTrialIndex = trialCounter;
|
||||
}
|
||||
|
||||
if (attemptDiagnostics is not null)
|
||||
{
|
||||
attemptDiagnostics.AttemptCount = trialCounter;
|
||||
attemptDiagnostics.AcceptedTrialIndex = bestTrialIndex > 0 ? bestTrialIndex : null;
|
||||
attemptDiagnostics.AcceptedScore = bestTrialIndex > 0 ? bestLocalScore : null;
|
||||
if (bestTrialIndex > 0)
|
||||
{
|
||||
var acceptedTrial = attemptDiagnostics.Trials.FirstOrDefault(trial => trial.TrialIndex == bestTrialIndex);
|
||||
if (acceptedTrial is not null)
|
||||
{
|
||||
acceptedTrial.Accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
ElkLayoutDiagnostics.AddRefinementAttempt(attemptDiagnostics);
|
||||
}
|
||||
|
||||
if (ReferenceEquals(bestLocalEdges, edges))
|
||||
|
||||
394
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAStar8Dir.cs
Normal file
394
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAStar8Dir.cs
Normal file
@@ -0,0 +1,394 @@
|
||||
namespace StellaOps.ElkSharp;
|
||||
|
||||
internal static class ElkEdgeRouterAStar8Dir
|
||||
{
|
||||
// E, W, S, N, NE, SW, SE, NW
|
||||
private static readonly int[] Dx = [1, -1, 0, 0, 1, -1, 1, -1];
|
||||
private static readonly int[] Dy = [0, 0, 1, -1, -1, 1, 1, -1];
|
||||
// Direction codes: 1=horizontal, 2=vertical, 3=diagonal45(NE/SW), 4=diagonal135(SE/NW)
|
||||
private static readonly int[] DirCodes = [1, 1, 2, 2, 3, 3, 4, 4];
|
||||
|
||||
internal static List<ElkPoint>? Route(
|
||||
ElkPoint start,
|
||||
ElkPoint end,
|
||||
(double Left, double Top, double Right, double Bottom, string Id)[] obstacles,
|
||||
string sourceId,
|
||||
string targetId,
|
||||
AStarRoutingParams routingParams,
|
||||
IReadOnlyList<OrthogonalSoftObstacle> softObstacles,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var xs = new SortedSet<double> { start.X, end.X };
|
||||
var ys = new SortedSet<double> { start.Y, end.Y };
|
||||
foreach (var ob in obstacles)
|
||||
{
|
||||
if (ob.Id == sourceId || ob.Id == targetId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
xs.Add(ob.Left - routingParams.Margin);
|
||||
xs.Add(ob.Right + routingParams.Margin);
|
||||
ys.Add(ob.Top - routingParams.Margin);
|
||||
ys.Add(ob.Bottom + routingParams.Margin);
|
||||
}
|
||||
|
||||
if (routingParams.IntermediateGridSpacing > 0d)
|
||||
{
|
||||
AddIntermediateLines(xs, routingParams.IntermediateGridSpacing);
|
||||
AddIntermediateLines(ys, routingParams.IntermediateGridSpacing);
|
||||
}
|
||||
|
||||
var xArr = xs.ToArray();
|
||||
var yArr = ys.ToArray();
|
||||
var xCount = xArr.Length;
|
||||
var yCount = yArr.Length;
|
||||
if (xCount < 2 || yCount < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var startIx = Array.BinarySearch(xArr, start.X);
|
||||
var startIy = Array.BinarySearch(yArr, start.Y);
|
||||
var endIx = Array.BinarySearch(xArr, end.X);
|
||||
var endIy = Array.BinarySearch(yArr, end.Y);
|
||||
if (startIx < 0 || startIy < 0 || endIx < 0 || endIy < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
bool IsBlockedOrthogonal(int ix1, int iy1, int ix2, int iy2)
|
||||
{
|
||||
var x1 = xArr[ix1];
|
||||
var y1 = yArr[iy1];
|
||||
var x2 = xArr[ix2];
|
||||
var y2 = yArr[iy2];
|
||||
foreach (var ob in obstacles)
|
||||
{
|
||||
if (ob.Id == sourceId || ob.Id == targetId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ix1 == ix2)
|
||||
{
|
||||
if (x1 > ob.Left && x1 < ob.Right)
|
||||
{
|
||||
var minY = Math.Min(y1, y2);
|
||||
var maxY = Math.Max(y1, y2);
|
||||
if (maxY > ob.Top && minY < ob.Bottom)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (iy1 == iy2)
|
||||
{
|
||||
if (y1 > ob.Top && y1 < ob.Bottom)
|
||||
{
|
||||
var minX = Math.Min(x1, x2);
|
||||
var maxX = Math.Max(x1, x2);
|
||||
if (maxX > ob.Left && minX < ob.Right)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const int dirCount = 5;
|
||||
var stateCount = xCount * yCount * dirCount;
|
||||
var gScore = new double[stateCount];
|
||||
Array.Fill(gScore, double.MaxValue);
|
||||
var cameFrom = new int[stateCount];
|
||||
Array.Fill(cameFrom, -1);
|
||||
|
||||
// Side-aware entry angle: block moves parallel to the target's entry side
|
||||
// Vertical side (left/right) → block vertical (dir=2), force horizontal approach
|
||||
// Horizontal side (top/bottom) → block horizontal (dir=1), force vertical approach
|
||||
var blockedEntryDir = 0;
|
||||
if (routingParams.EnforceEntryAngle)
|
||||
{
|
||||
foreach (var ob in obstacles)
|
||||
{
|
||||
if (ob.Id != targetId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var nodeLeft = ob.Left + routingParams.Margin;
|
||||
var nodeRight = ob.Right - routingParams.Margin;
|
||||
var nodeTop = ob.Top + routingParams.Margin;
|
||||
var nodeBottom = ob.Bottom - routingParams.Margin;
|
||||
if (Math.Abs(end.X - nodeLeft) < 2d || Math.Abs(end.X - nodeRight) < 2d)
|
||||
{
|
||||
blockedEntryDir = 2; // vertical side → block vertical
|
||||
}
|
||||
else if (Math.Abs(end.Y - nodeTop) < 2d || Math.Abs(end.Y - nodeBottom) < 2d)
|
||||
{
|
||||
blockedEntryDir = 1; // horizontal side → block horizontal
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
int StateId(int ix, int iy, int dir) => (ix * yCount + iy) * dirCount + dir;
|
||||
|
||||
double Heuristic(int ix, int iy)
|
||||
{
|
||||
var hdx = xArr[ix] - xArr[endIx];
|
||||
var hdy = yArr[iy] - yArr[endIy];
|
||||
return Math.Sqrt(hdx * hdx + hdy * hdy);
|
||||
}
|
||||
|
||||
var startState = StateId(startIx, startIy, 0);
|
||||
gScore[startState] = 0d;
|
||||
var openSet = new PriorityQueue<int, double>();
|
||||
openSet.Enqueue(startState, Heuristic(startIx, startIy));
|
||||
|
||||
var maxIterations = xCount * yCount * 12;
|
||||
var iterations = 0;
|
||||
var closed = new HashSet<int>();
|
||||
|
||||
while (openSet.Count > 0 && iterations++ < maxIterations)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var current = openSet.Dequeue();
|
||||
if (!closed.Add(current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var curDir = current % dirCount;
|
||||
var curIy = (current / dirCount) % yCount;
|
||||
var curIx = (current / dirCount) / yCount;
|
||||
|
||||
if (curIx == endIx && curIy == endIy)
|
||||
{
|
||||
return ReconstructPath(current, cameFrom, xArr, yArr, yCount, dirCount);
|
||||
}
|
||||
|
||||
for (var d = 0; d < 8; d++)
|
||||
{
|
||||
var nx = curIx + Dx[d];
|
||||
var ny = curIy + Dy[d];
|
||||
if (nx < 0 || nx >= xCount || ny < 0 || ny >= yCount)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var isDiagonal = Dx[d] != 0 && Dy[d] != 0;
|
||||
if (isDiagonal)
|
||||
{
|
||||
if (IsBlockedOrthogonal(curIx, curIy, nx, curIy)
|
||||
|| IsBlockedOrthogonal(curIx, curIy, curIx, ny))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (IsBlockedOrthogonal(curIx, curIy, nx, ny))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var newDir = DirCodes[d];
|
||||
|
||||
// Side-aware entry angle: block parallel moves into end cell
|
||||
if (blockedEntryDir > 0 && nx == endIx && ny == endIy && newDir == blockedEntryDir)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var bend = ComputeBendPenalty(curDir, newDir, routingParams.BendPenalty);
|
||||
|
||||
double dist;
|
||||
if (isDiagonal)
|
||||
{
|
||||
var ddx = xArr[nx] - xArr[curIx];
|
||||
var ddy = yArr[ny] - yArr[curIy];
|
||||
dist = Math.Sqrt(ddx * ddx + ddy * ddy) + routingParams.DiagonalPenalty;
|
||||
}
|
||||
else
|
||||
{
|
||||
dist = Math.Abs(xArr[nx] - xArr[curIx]) + Math.Abs(yArr[ny] - yArr[curIy]);
|
||||
}
|
||||
|
||||
var softCost = ComputeSoftObstacleCost(
|
||||
xArr[curIx], yArr[curIy], xArr[nx], yArr[ny],
|
||||
softObstacles, routingParams);
|
||||
|
||||
var tentativeG = gScore[current] + dist + bend + softCost;
|
||||
var neighborState = StateId(nx, ny, newDir);
|
||||
|
||||
if (tentativeG < gScore[neighborState])
|
||||
{
|
||||
gScore[neighborState] = tentativeG;
|
||||
cameFrom[neighborState] = current;
|
||||
openSet.Enqueue(neighborState, tentativeG + Heuristic(nx, ny));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double ComputeBendPenalty(int curDir, int newDir, double bendPenalty)
|
||||
{
|
||||
if (curDir == 0 || curDir == newDir)
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
|
||||
// H↔V = 90° bend, diag↔diag (opposite types) = 90° bend
|
||||
if ((curDir <= 2 && newDir <= 2) || (curDir >= 3 && newDir >= 3))
|
||||
{
|
||||
return bendPenalty;
|
||||
}
|
||||
|
||||
// ortho↔diag = 45° bend
|
||||
return bendPenalty / 2d;
|
||||
}
|
||||
|
||||
private static double ComputeSoftObstacleCost(
|
||||
double x1, double y1, double x2, double y2,
|
||||
IReadOnlyList<OrthogonalSoftObstacle> softObstacles,
|
||||
AStarRoutingParams routingParams)
|
||||
{
|
||||
if (routingParams.SoftObstacleWeight <= 0d || softObstacles.Count == 0)
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
|
||||
var candidateStart = new ElkPoint { X = x1, Y = y1 };
|
||||
var candidateEnd = new ElkPoint { X = x2, Y = y2 };
|
||||
var candidateIsH = Math.Abs(y2 - y1) < 2d;
|
||||
var candidateIsV = Math.Abs(x2 - x1) < 2d;
|
||||
var cost = 0d;
|
||||
|
||||
foreach (var obstacle in softObstacles)
|
||||
{
|
||||
if (ElkEdgeRoutingGeometry.SegmentsIntersect(candidateStart, candidateEnd, obstacle.Start, obstacle.End))
|
||||
{
|
||||
cost += 120d * routingParams.SoftObstacleWeight;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Graduated proximity: closer = exponentially more expensive
|
||||
var dist = ComputeParallelDistance(
|
||||
x1, y1, x2, y2, candidateIsH, candidateIsV,
|
||||
obstacle.Start, obstacle.End,
|
||||
routingParams.SoftObstacleClearance);
|
||||
|
||||
if (dist >= 0d)
|
||||
{
|
||||
var factor = 1d - (dist / routingParams.SoftObstacleClearance);
|
||||
cost += 60d * factor * factor * routingParams.SoftObstacleWeight;
|
||||
}
|
||||
}
|
||||
|
||||
return cost;
|
||||
}
|
||||
|
||||
private static double ComputeParallelDistance(
|
||||
double x1, double y1, double x2, double y2,
|
||||
bool candidateIsH, bool candidateIsV,
|
||||
ElkPoint obStart, ElkPoint obEnd,
|
||||
double clearance)
|
||||
{
|
||||
var obIsH = Math.Abs(obStart.Y - obEnd.Y) < 2d;
|
||||
var obIsV = Math.Abs(obStart.X - obEnd.X) < 2d;
|
||||
|
||||
if (candidateIsH && obIsH)
|
||||
{
|
||||
var dist = Math.Abs(y1 - obStart.Y);
|
||||
if (dist >= clearance)
|
||||
{
|
||||
return -1d;
|
||||
}
|
||||
|
||||
var overlapMin = Math.Max(Math.Min(x1, x2), Math.Min(obStart.X, obEnd.X));
|
||||
var overlapMax = Math.Min(Math.Max(x1, x2), Math.Max(obStart.X, obEnd.X));
|
||||
return overlapMax > overlapMin + 1d ? dist : -1d;
|
||||
}
|
||||
|
||||
if (candidateIsV && obIsV)
|
||||
{
|
||||
var dist = Math.Abs(x1 - obStart.X);
|
||||
if (dist >= clearance)
|
||||
{
|
||||
return -1d;
|
||||
}
|
||||
|
||||
var overlapMin = Math.Max(Math.Min(y1, y2), Math.Min(obStart.Y, obEnd.Y));
|
||||
var overlapMax = Math.Min(Math.Max(y1, y2), Math.Max(obStart.Y, obEnd.Y));
|
||||
return overlapMax > overlapMin + 1d ? dist : -1d;
|
||||
}
|
||||
|
||||
return -1d;
|
||||
}
|
||||
|
||||
private static List<ElkPoint> ReconstructPath(
|
||||
int endState, int[] cameFrom,
|
||||
double[] xArr, double[] yArr,
|
||||
int yCount, int dirCount)
|
||||
{
|
||||
var path = new List<ElkPoint>();
|
||||
var state = endState;
|
||||
while (state >= 0)
|
||||
{
|
||||
var sIy = (state / dirCount) % yCount;
|
||||
var sIx = (state / dirCount) / yCount;
|
||||
path.Add(new ElkPoint { X = xArr[sIx], Y = yArr[sIy] });
|
||||
state = cameFrom[state];
|
||||
}
|
||||
|
||||
path.Reverse();
|
||||
|
||||
// Simplify: remove collinear points (same direction between consecutive segments)
|
||||
var simplified = new List<ElkPoint> { path[0] };
|
||||
for (var i = 1; i < path.Count - 1; i++)
|
||||
{
|
||||
var prev = simplified[^1];
|
||||
var next = path[i + 1];
|
||||
var dx1 = Math.Sign(path[i].X - prev.X);
|
||||
var dy1 = Math.Sign(path[i].Y - prev.Y);
|
||||
var dx2 = Math.Sign(next.X - path[i].X);
|
||||
var dy2 = Math.Sign(next.Y - path[i].Y);
|
||||
if (dx1 != dx2 || dy1 != dy2)
|
||||
{
|
||||
simplified.Add(path[i]);
|
||||
}
|
||||
}
|
||||
|
||||
simplified.Add(path[^1]);
|
||||
return simplified;
|
||||
}
|
||||
|
||||
private static void AddIntermediateLines(SortedSet<double> coords, double spacing)
|
||||
{
|
||||
var arr = coords.ToArray();
|
||||
for (var i = 0; i < arr.Length - 1; i++)
|
||||
{
|
||||
var gap = arr[i + 1] - arr[i];
|
||||
if (gap <= spacing * 2d)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var count = (int)(gap / spacing);
|
||||
var step = gap / (count + 1);
|
||||
for (var j = 1; j <= count; j++)
|
||||
{
|
||||
coords.Add(arr[i] + j * step);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
524
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterHighway.cs
Normal file
524
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterHighway.cs
Normal file
@@ -0,0 +1,524 @@
|
||||
namespace StellaOps.ElkSharp;
|
||||
|
||||
internal static class ElkEdgeRouterHighway
|
||||
{
|
||||
private const double MinHighwayRatio = 2d / 5d;
|
||||
private const double BoundaryInset = 4d;
|
||||
private const double MinimumSpreadSpacing = 12d;
|
||||
private const double CoordinateTolerance = 0.5d;
|
||||
|
||||
internal static ElkRoutedEdge[] BreakShortHighways(
|
||||
ElkRoutedEdge[] edges,
|
||||
ElkPositionedNode[] nodes)
|
||||
{
|
||||
if (edges.Length < 2 || nodes.Length == 0)
|
||||
{
|
||||
return edges;
|
||||
}
|
||||
|
||||
var serviceNodes = nodes.Where(n => n.Kind is not "Start" and not "End").ToArray();
|
||||
var minLineClearance = serviceNodes.Length > 0
|
||||
? Math.Min(serviceNodes.Average(n => n.Width), serviceNodes.Average(n => n.Height)) / 2d
|
||||
: 50d;
|
||||
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
|
||||
var graphMinY = nodes.Min(n => n.Y);
|
||||
var graphMaxY = nodes.Max(n => n.Y + n.Height);
|
||||
var result = edges.ToArray();
|
||||
|
||||
foreach (var (key, edgeIndices) in BuildTargetSideGroups(result, nodesById, graphMinY, graphMaxY)
|
||||
.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (edgeIndices.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var separator = key.IndexOf('|', StringComparison.Ordinal);
|
||||
var targetId = key[..separator];
|
||||
var side = key[(separator + 1)..];
|
||||
if (!nodesById.TryGetValue(targetId, out var targetNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ProcessTargetSideGroup(result, edgeIndices, targetNode, side, minLineClearance);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<ElkHighwayDiagnostics> DetectRemainingBrokenHighways(
|
||||
ElkRoutedEdge[] edges,
|
||||
ElkPositionedNode[] nodes)
|
||||
{
|
||||
if (edges.Length < 2 || nodes.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var serviceNodes = nodes.Where(n => n.Kind is not "Start" and not "End").ToArray();
|
||||
var minLineClearance = serviceNodes.Length > 0
|
||||
? Math.Min(serviceNodes.Average(n => n.Width), serviceNodes.Average(n => n.Height)) / 2d
|
||||
: 50d;
|
||||
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
|
||||
var graphMinY = nodes.Min(n => n.Y);
|
||||
var graphMaxY = nodes.Max(n => n.Y + n.Height);
|
||||
var detections = new List<ElkHighwayDiagnostics>();
|
||||
|
||||
foreach (var (key, edgeIndices) in BuildTargetSideGroups(edges, nodesById, graphMinY, graphMaxY)
|
||||
.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (edgeIndices.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var separator = key.IndexOf('|', StringComparison.Ordinal);
|
||||
var targetId = key[..separator];
|
||||
var side = key[(separator + 1)..];
|
||||
if (!nodesById.TryGetValue(targetId, out var targetNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var detection = EvaluateTargetSideGroup(edges, edgeIndices, targetNode, side, minLineClearance);
|
||||
if (detection is not null && detection.Value.Diagnostic.WasBroken)
|
||||
{
|
||||
detections.Add(detection.Value.Diagnostic);
|
||||
}
|
||||
}
|
||||
|
||||
return detections;
|
||||
}
|
||||
|
||||
private static bool ShouldProcessEdge(ElkRoutedEdge edge, double graphMinY, double graphMaxY)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(edge.Kind)
|
||||
&& edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ProcessTargetSideGroup(
|
||||
ElkRoutedEdge[] result,
|
||||
List<int> edgeIndices,
|
||||
ElkPositionedNode targetNode,
|
||||
string side,
|
||||
double minLineClearance)
|
||||
{
|
||||
var evaluation = EvaluateTargetSideGroup(result, edgeIndices, targetNode, side, minLineClearance);
|
||||
if (evaluation is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ElkLayoutDiagnostics.AddDetectedHighway(evaluation.Value.Diagnostic);
|
||||
|
||||
if (!evaluation.Value.Diagnostic.WasBroken)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ordered = evaluation.Value.Members
|
||||
.OrderBy(member => member.EndpointCoord)
|
||||
.ThenBy(member => member.EdgeId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var slotCoords = BuildSlotCoordinates(targetNode, side, ordered.Count, minLineClearance);
|
||||
for (var i = 0; i < ordered.Count; i++)
|
||||
{
|
||||
var adjustedPath = AdjustPathToTargetSlot(ordered[i].Path, targetNode, side, slotCoords[i], minLineClearance);
|
||||
WriteBackPath(result, ordered[i].Index, adjustedPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<int>> BuildTargetSideGroups(
|
||||
IReadOnlyList<ElkRoutedEdge> edges,
|
||||
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
|
||||
double graphMinY,
|
||||
double graphMaxY)
|
||||
{
|
||||
var edgesByTargetSide = new Dictionary<string, List<int>>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < edges.Count; i++)
|
||||
{
|
||||
var edge = edges[i];
|
||||
if (!ShouldProcessEdge(edge, graphMinY, graphMaxY))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = ExtractFullPath(edge);
|
||||
if (path.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var side = ElkEdgeRoutingGeometry.ResolveBoundarySide(path[^1], targetNode);
|
||||
var key = $"{targetNode.Id}|{side}";
|
||||
if (!edgesByTargetSide.TryGetValue(key, out var list))
|
||||
{
|
||||
list = [];
|
||||
edgesByTargetSide[key] = list;
|
||||
}
|
||||
|
||||
list.Add(i);
|
||||
}
|
||||
|
||||
return edgesByTargetSide;
|
||||
}
|
||||
|
||||
private static GroupEvaluation? EvaluateTargetSideGroup(
|
||||
IReadOnlyList<ElkRoutedEdge> edges,
|
||||
IReadOnlyList<int> edgeIndices,
|
||||
ElkPositionedNode targetNode,
|
||||
string side,
|
||||
double minLineClearance)
|
||||
{
|
||||
var members = edgeIndices
|
||||
.Select(index => CreateMember(edges[index], index, targetNode, side))
|
||||
.Where(member => member.Path.Count >= 2)
|
||||
.OrderBy(member => member.EdgeId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
if (members.Count < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var pairMetrics = ComputePairMetrics(members);
|
||||
var actualGap = ComputeMinEndpointGap(members, side);
|
||||
var requiresSpread = (actualGap + CoordinateTolerance) < minLineClearance
|
||||
&& !pairMetrics.AllPairsApplicable;
|
||||
if (!requiresSpread && pairMetrics.ShortestSharedRatio < MinHighwayRatio)
|
||||
{
|
||||
requiresSpread = pairMetrics.HasSharedSegment;
|
||||
}
|
||||
|
||||
var diagnostic = new ElkHighwayDiagnostics
|
||||
{
|
||||
TargetNodeId = targetNode.Id,
|
||||
SharedAxis = side,
|
||||
SharedCoord = Math.Round(members.Average(member => member.EndpointCoord), 1),
|
||||
EdgeIds = members.Select(member => member.EdgeId).ToArray(),
|
||||
MinRatio = pairMetrics.HasSharedSegment
|
||||
? Math.Round(pairMetrics.ShortestSharedRatio, 3)
|
||||
: 0d,
|
||||
WasBroken = requiresSpread,
|
||||
Reason = requiresSpread
|
||||
? pairMetrics.HasSharedSegment && pairMetrics.ShortestSharedRatio < MinHighwayRatio
|
||||
? $"shared ratio {pairMetrics.ShortestSharedRatio:F2} < {MinHighwayRatio:F2}"
|
||||
: $"gap {actualGap:F0}px < clearance {minLineClearance:F0}px"
|
||||
: pairMetrics.AllPairsApplicable
|
||||
? $"shared ratio {pairMetrics.ShortestSharedRatio:F2} >= {MinHighwayRatio:F2}"
|
||||
: $"gap {actualGap:F0}px >= clearance {minLineClearance:F0}px",
|
||||
};
|
||||
|
||||
return new GroupEvaluation(members, diagnostic);
|
||||
}
|
||||
|
||||
private static HighwayPairMetrics ComputePairMetrics(IReadOnlyList<HighwayMember> members)
|
||||
{
|
||||
var shortestSharedRatio = double.MaxValue;
|
||||
var hasSharedSegment = false;
|
||||
var allPairsApplicable = true;
|
||||
|
||||
for (var i = 0; i < members.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < members.Count; j++)
|
||||
{
|
||||
var sharedLength = ElkEdgeRoutingGeometry.ComputeLongestSharedApproachSegmentLength(
|
||||
members[i].Path,
|
||||
members[j].Path);
|
||||
if (sharedLength <= 1d)
|
||||
{
|
||||
allPairsApplicable = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
hasSharedSegment = true;
|
||||
var shortestPath = Math.Min(members[i].PathLength, members[j].PathLength);
|
||||
if (shortestPath <= 1d)
|
||||
{
|
||||
allPairsApplicable = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
var ratio = sharedLength / shortestPath;
|
||||
shortestSharedRatio = Math.Min(shortestSharedRatio, ratio);
|
||||
if (ratio < MinHighwayRatio)
|
||||
{
|
||||
allPairsApplicable = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new HighwayPairMetrics(
|
||||
HasSharedSegment: hasSharedSegment,
|
||||
AllPairsApplicable: allPairsApplicable && hasSharedSegment,
|
||||
ShortestSharedRatio: hasSharedSegment ? shortestSharedRatio : 0d);
|
||||
}
|
||||
|
||||
private static double ComputeMinEndpointGap(IReadOnlyList<HighwayMember> members, string side)
|
||||
{
|
||||
var coords = members
|
||||
.Select(member => member.EndpointCoord)
|
||||
.OrderBy(value => value)
|
||||
.ToArray();
|
||||
if (coords.Length < 2)
|
||||
{
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
var minGap = double.MaxValue;
|
||||
for (var i = 1; i < coords.Length; i++)
|
||||
{
|
||||
minGap = Math.Min(minGap, coords[i] - coords[i - 1]);
|
||||
}
|
||||
|
||||
return minGap;
|
||||
}
|
||||
|
||||
private static double[] BuildSlotCoordinates(
|
||||
ElkPositionedNode targetNode,
|
||||
string side,
|
||||
int count,
|
||||
double minLineClearance)
|
||||
{
|
||||
if (count <= 1)
|
||||
{
|
||||
return
|
||||
[
|
||||
side is "left" or "right"
|
||||
? targetNode.Y + (targetNode.Height / 2d)
|
||||
: targetNode.X + (targetNode.Width / 2d),
|
||||
];
|
||||
}
|
||||
|
||||
var axisMin = side is "left" or "right"
|
||||
? targetNode.Y + BoundaryInset
|
||||
: targetNode.X + BoundaryInset;
|
||||
var axisMax = side is "left" or "right"
|
||||
? targetNode.Y + targetNode.Height - BoundaryInset
|
||||
: targetNode.X + targetNode.Width - BoundaryInset;
|
||||
var axisLength = Math.Max(8d, axisMax - axisMin);
|
||||
var spacing = Math.Max(
|
||||
MinimumSpreadSpacing,
|
||||
Math.Min(minLineClearance, axisLength / (count - 1)));
|
||||
var totalSpan = (count - 1) * spacing;
|
||||
var center = (axisMin + axisMax) / 2d;
|
||||
var start = Math.Max(axisMin, Math.Min(center - (totalSpan / 2d), axisMax - totalSpan));
|
||||
|
||||
return Enumerable.Range(0, count)
|
||||
.Select(index => Math.Min(axisMax, start + (index * spacing)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static List<ElkPoint> AdjustPathToTargetSlot(
|
||||
IReadOnlyList<ElkPoint> path,
|
||||
ElkPositionedNode targetNode,
|
||||
string side,
|
||||
double slotCoord,
|
||||
double minLineClearance)
|
||||
{
|
||||
var adjusted = path
|
||||
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
|
||||
.ToList();
|
||||
if (adjusted.Count < 2)
|
||||
{
|
||||
return adjusted;
|
||||
}
|
||||
|
||||
if (side is "left" or "right")
|
||||
{
|
||||
var targetX = side == "left"
|
||||
? targetNode.X
|
||||
: targetNode.X + targetNode.Width;
|
||||
|
||||
while (adjusted.Count >= 3 && Math.Abs(adjusted[^2].X - targetX) <= CoordinateTolerance)
|
||||
{
|
||||
adjusted.RemoveAt(adjusted.Count - 2);
|
||||
}
|
||||
|
||||
var anchor = adjusted[^2];
|
||||
var rebuilt = adjusted.Take(adjusted.Count - 1).ToList();
|
||||
if (Math.Abs(anchor.X - targetX) <= CoordinateTolerance)
|
||||
{
|
||||
var offset = Math.Max(24d, minLineClearance / 2d);
|
||||
rebuilt.Add(new ElkPoint
|
||||
{
|
||||
X = side == "left" ? targetX - offset : targetX + offset,
|
||||
Y = anchor.Y,
|
||||
});
|
||||
anchor = rebuilt[^1];
|
||||
}
|
||||
|
||||
if (Math.Abs(anchor.Y - slotCoord) > CoordinateTolerance)
|
||||
{
|
||||
rebuilt.Add(new ElkPoint { X = anchor.X, Y = slotCoord });
|
||||
}
|
||||
|
||||
rebuilt.Add(new ElkPoint { X = targetX, Y = slotCoord });
|
||||
return NormalizePath(rebuilt);
|
||||
}
|
||||
|
||||
var targetY = side == "top"
|
||||
? targetNode.Y
|
||||
: targetNode.Y + targetNode.Height;
|
||||
while (adjusted.Count >= 3 && Math.Abs(adjusted[^2].Y - targetY) <= CoordinateTolerance)
|
||||
{
|
||||
adjusted.RemoveAt(adjusted.Count - 2);
|
||||
}
|
||||
|
||||
var verticalAnchor = adjusted[^2];
|
||||
var verticalRebuilt = adjusted.Take(adjusted.Count - 1).ToList();
|
||||
if (Math.Abs(verticalAnchor.Y - targetY) <= CoordinateTolerance)
|
||||
{
|
||||
var offset = Math.Max(24d, minLineClearance / 2d);
|
||||
verticalRebuilt.Add(new ElkPoint
|
||||
{
|
||||
X = verticalAnchor.X,
|
||||
Y = side == "top" ? targetY - offset : targetY + offset,
|
||||
});
|
||||
verticalAnchor = verticalRebuilt[^1];
|
||||
}
|
||||
|
||||
if (Math.Abs(verticalAnchor.X - slotCoord) > CoordinateTolerance)
|
||||
{
|
||||
verticalRebuilt.Add(new ElkPoint { X = slotCoord, Y = verticalAnchor.Y });
|
||||
}
|
||||
|
||||
verticalRebuilt.Add(new ElkPoint { X = slotCoord, Y = targetY });
|
||||
return NormalizePath(verticalRebuilt);
|
||||
}
|
||||
|
||||
private static List<ElkPoint> NormalizePath(IReadOnlyList<ElkPoint> path)
|
||||
{
|
||||
var deduped = new List<ElkPoint>();
|
||||
foreach (var point in path)
|
||||
{
|
||||
if (deduped.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(deduped[^1], point))
|
||||
{
|
||||
deduped.Add(point);
|
||||
}
|
||||
}
|
||||
|
||||
if (deduped.Count <= 2)
|
||||
{
|
||||
return deduped;
|
||||
}
|
||||
|
||||
var simplified = new List<ElkPoint> { deduped[0] };
|
||||
for (var i = 1; i < deduped.Count - 1; i++)
|
||||
{
|
||||
var previous = simplified[^1];
|
||||
var current = deduped[i];
|
||||
var next = deduped[i + 1];
|
||||
var sameX = Math.Abs(previous.X - current.X) <= CoordinateTolerance
|
||||
&& Math.Abs(current.X - next.X) <= CoordinateTolerance;
|
||||
var sameY = Math.Abs(previous.Y - current.Y) <= CoordinateTolerance
|
||||
&& Math.Abs(current.Y - next.Y) <= CoordinateTolerance;
|
||||
if (!sameX && !sameY)
|
||||
{
|
||||
simplified.Add(current);
|
||||
}
|
||||
}
|
||||
|
||||
simplified.Add(deduped[^1]);
|
||||
return simplified;
|
||||
}
|
||||
|
||||
private static HighwayMember CreateMember(
|
||||
ElkRoutedEdge edge,
|
||||
int index,
|
||||
ElkPositionedNode targetNode,
|
||||
string side)
|
||||
{
|
||||
var path = ExtractFullPath(edge);
|
||||
var endpointCoord = side is "left" or "right"
|
||||
? path[^1].Y
|
||||
: path[^1].X;
|
||||
return new HighwayMember(
|
||||
Index: index,
|
||||
EdgeId: edge.Id,
|
||||
Edge: edge,
|
||||
Path: path,
|
||||
PathLength: ElkEdgeRoutingGeometry.ComputePathLength(edge),
|
||||
EndpointCoord: endpointCoord);
|
||||
}
|
||||
|
||||
private static void WriteBackPath(ElkRoutedEdge[] result, int edgeIndex, IReadOnlyList<ElkPoint> path)
|
||||
{
|
||||
var edge = result[edgeIndex];
|
||||
result[edgeIndex] = new ElkRoutedEdge
|
||||
{
|
||||
Id = edge.Id,
|
||||
SourceNodeId = edge.SourceNodeId,
|
||||
TargetNodeId = edge.TargetNodeId,
|
||||
SourcePortId = edge.SourcePortId,
|
||||
TargetPortId = edge.TargetPortId,
|
||||
Kind = edge.Kind,
|
||||
Label = edge.Label,
|
||||
Sections =
|
||||
[
|
||||
new ElkEdgeSection
|
||||
{
|
||||
StartPoint = path[0],
|
||||
EndPoint = path[^1],
|
||||
BendPoints = path.Count > 2
|
||||
? path.Skip(1).Take(path.Count - 2).ToArray()
|
||||
: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static List<ElkPoint> ExtractFullPath(ElkRoutedEdge edge)
|
||||
{
|
||||
var path = new List<ElkPoint>();
|
||||
foreach (var section in edge.Sections)
|
||||
{
|
||||
if (path.Count == 0)
|
||||
{
|
||||
path.Add(section.StartPoint);
|
||||
}
|
||||
|
||||
path.AddRange(section.BendPoints);
|
||||
path.Add(section.EndPoint);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private readonly record struct HighwayMember(
|
||||
int Index,
|
||||
string EdgeId,
|
||||
ElkRoutedEdge Edge,
|
||||
List<ElkPoint> Path,
|
||||
double PathLength,
|
||||
double EndpointCoord);
|
||||
|
||||
private readonly record struct HighwayPairMetrics(
|
||||
bool HasSharedSegment,
|
||||
bool AllPairsApplicable,
|
||||
double ShortestSharedRatio);
|
||||
|
||||
private readonly record struct GroupEvaluation(
|
||||
IReadOnlyList<HighwayMember> Members,
|
||||
ElkHighwayDiagnostics Diagnostic);
|
||||
}
|
||||
2067
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.cs
Normal file
2067
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,104 @@ internal static class ElkEdgeRoutingGeometry
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static double ComputeSharedSegmentLength(
|
||||
ElkPoint a1,
|
||||
ElkPoint a2,
|
||||
ElkPoint b1,
|
||||
ElkPoint b2)
|
||||
{
|
||||
if (IsHorizontal(a1, a2) && IsHorizontal(b1, b2) && Math.Abs(a1.Y - b1.Y) <= CoordinateTolerance)
|
||||
{
|
||||
return Math.Max(0d, OverlapLength(
|
||||
Math.Min(a1.X, a2.X),
|
||||
Math.Max(a1.X, a2.X),
|
||||
Math.Min(b1.X, b2.X),
|
||||
Math.Max(b1.X, b2.X)));
|
||||
}
|
||||
|
||||
if (IsVertical(a1, a2) && IsVertical(b1, b2) && Math.Abs(a1.X - b1.X) <= CoordinateTolerance)
|
||||
{
|
||||
return Math.Max(0d, OverlapLength(
|
||||
Math.Min(a1.Y, a2.Y),
|
||||
Math.Max(a1.Y, a2.Y),
|
||||
Math.Min(b1.Y, b2.Y),
|
||||
Math.Max(b1.Y, b2.Y)));
|
||||
}
|
||||
|
||||
return 0d;
|
||||
}
|
||||
|
||||
internal static double ComputeLongestSharedSegmentLength(ElkRoutedEdge left, ElkRoutedEdge right)
|
||||
{
|
||||
var leftSegments = FlattenSegments(left);
|
||||
var rightSegments = FlattenSegments(right);
|
||||
var longest = 0d;
|
||||
|
||||
foreach (var leftSegment in leftSegments)
|
||||
{
|
||||
foreach (var rightSegment in rightSegments)
|
||||
{
|
||||
longest = Math.Max(longest, ComputeSharedSegmentLength(
|
||||
leftSegment.Start,
|
||||
leftSegment.End,
|
||||
rightSegment.Start,
|
||||
rightSegment.End));
|
||||
}
|
||||
}
|
||||
|
||||
return longest;
|
||||
}
|
||||
|
||||
internal static double ComputeLongestSharedApproachSegmentLength(
|
||||
IReadOnlyList<ElkPoint> leftPath,
|
||||
IReadOnlyList<ElkPoint> rightPath,
|
||||
int maxSegmentsFromEnd = 3)
|
||||
{
|
||||
var leftSegments = FlattenSegmentsNearEnd(leftPath, maxSegmentsFromEnd);
|
||||
var rightSegments = FlattenSegmentsNearEnd(rightPath, maxSegmentsFromEnd);
|
||||
var longest = 0d;
|
||||
|
||||
foreach (var leftSegment in leftSegments)
|
||||
{
|
||||
foreach (var rightSegment in rightSegments)
|
||||
{
|
||||
longest = Math.Max(longest, ComputeSharedSegmentLength(
|
||||
leftSegment.Start,
|
||||
leftSegment.End,
|
||||
rightSegment.Start,
|
||||
rightSegment.End));
|
||||
}
|
||||
}
|
||||
|
||||
return longest;
|
||||
}
|
||||
|
||||
internal static string ResolveBoundarySide(ElkPoint point, ElkPositionedNode node)
|
||||
{
|
||||
var distLeft = Math.Abs(point.X - node.X);
|
||||
var distRight = Math.Abs(point.X - (node.X + node.Width));
|
||||
var distTop = Math.Abs(point.Y - node.Y);
|
||||
var distBottom = Math.Abs(point.Y - (node.Y + node.Height));
|
||||
var min = Math.Min(Math.Min(distLeft, distRight), Math.Min(distTop, distBottom));
|
||||
|
||||
if (Math.Abs(min - distLeft) <= CoordinateTolerance)
|
||||
{
|
||||
return "left";
|
||||
}
|
||||
|
||||
if (Math.Abs(min - distRight) <= CoordinateTolerance)
|
||||
{
|
||||
return "right";
|
||||
}
|
||||
|
||||
if (Math.Abs(min - distTop) <= CoordinateTolerance)
|
||||
{
|
||||
return "top";
|
||||
}
|
||||
|
||||
return "bottom";
|
||||
}
|
||||
|
||||
internal static bool AreCollinearAndOverlapping(ElkPoint a1, ElkPoint a2, ElkPoint b1, ElkPoint b2)
|
||||
{
|
||||
if (IsHorizontal(a1, a2) && IsHorizontal(b1, b2) && Math.Abs(a1.Y - b1.Y) <= CoordinateTolerance)
|
||||
@@ -128,6 +226,25 @@ internal static class ElkEdgeRoutingGeometry
|
||||
|
||||
private static bool IsVertical(ElkPoint start, ElkPoint end) => Math.Abs(start.X - end.X) <= CoordinateTolerance;
|
||||
|
||||
private static IReadOnlyList<RoutedEdgeSegment> FlattenSegmentsNearEnd(
|
||||
IReadOnlyList<ElkPoint> path,
|
||||
int maxSegmentsFromEnd)
|
||||
{
|
||||
if (path.Count < 2 || maxSegmentsFromEnd <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var startIndex = Math.Max(0, path.Count - (maxSegmentsFromEnd + 1));
|
||||
var segments = new List<RoutedEdgeSegment>();
|
||||
for (var i = startIndex; i < path.Count - 1; i++)
|
||||
{
|
||||
segments.Add(new RoutedEdgeSegment(string.Empty, path[i], path[i + 1]));
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
private static bool IntersectsOrthogonal(ElkPoint horizontalStart, ElkPoint horizontalEnd, ElkPoint verticalStart, ElkPoint verticalEnd)
|
||||
{
|
||||
var minHorizontalX = Math.Min(horizontalStart.X, horizontalEnd.X);
|
||||
|
||||
@@ -2,6 +2,8 @@ namespace StellaOps.ElkSharp;
|
||||
|
||||
internal static class ElkEdgeRoutingScoring
|
||||
{
|
||||
private static readonly bool HighwayScoringEnabled = true;
|
||||
|
||||
internal static EdgeRoutingScore ComputeScore(
|
||||
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes)
|
||||
@@ -11,11 +13,27 @@ internal static class ElkEdgeRoutingScoring
|
||||
var bendCount = SumBendPoints(edges);
|
||||
var totalPathLength = SumPathLengths(edges);
|
||||
var targetCongestion = CountTargetApproachCongestion(edges);
|
||||
var diagonalCount = CountDiagonalSegments(edges);
|
||||
var entryAngleViolations = CountBadBoundaryAngles(edges, nodes);
|
||||
var labelProximityViolations = CountLabelProximityViolations(edges, nodes);
|
||||
var repeatCollectorCorridorViolations = CountRepeatCollectorCorridorViolations(edges, nodes);
|
||||
var targetApproachJoinViolations = CountTargetApproachJoinViolations(edges, nodes);
|
||||
var targetApproachBacktrackingViolations = CountTargetApproachBacktrackingViolations(edges, nodes);
|
||||
var excessiveDetourViolations = CountExcessiveDetourViolations(edges, nodes);
|
||||
var proximityViolations = CountProximityViolations(edges, nodes);
|
||||
|
||||
var value = -(nodeCrossings * 100_000d)
|
||||
- (edgeCrossings * 650d)
|
||||
- (bendCount * 5d)
|
||||
- (targetCongestion * 25d)
|
||||
- (diagonalCount * 200d)
|
||||
- (entryAngleViolations * 500d)
|
||||
- (labelProximityViolations * 300d)
|
||||
- (repeatCollectorCorridorViolations * 100_000d)
|
||||
- (targetApproachJoinViolations * 100_000d)
|
||||
- (targetApproachBacktrackingViolations * 50_000d)
|
||||
- (excessiveDetourViolations * 50_000d)
|
||||
- (proximityViolations * 400d)
|
||||
- (totalPathLength * 0.1d);
|
||||
|
||||
return new EdgeRoutingScore(
|
||||
@@ -23,6 +41,14 @@ internal static class ElkEdgeRoutingScoring
|
||||
edgeCrossings,
|
||||
bendCount,
|
||||
targetCongestion,
|
||||
diagonalCount,
|
||||
entryAngleViolations,
|
||||
labelProximityViolations,
|
||||
repeatCollectorCorridorViolations,
|
||||
targetApproachJoinViolations,
|
||||
targetApproachBacktrackingViolations,
|
||||
excessiveDetourViolations,
|
||||
proximityViolations,
|
||||
totalPathLength,
|
||||
value);
|
||||
}
|
||||
@@ -72,6 +98,8 @@ internal static class ElkEdgeRoutingScoring
|
||||
Right: node.X + node.Width,
|
||||
Bottom: node.Y + node.Height,
|
||||
Id: node.Id)).ToArray();
|
||||
var graphMinY = nodes.Count > 0 ? nodes.Min(n => n.Y) : 0d;
|
||||
var graphMaxY = nodes.Count > 0 ? nodes.Max(n => n.Y + n.Height) : 0d;
|
||||
var crossingCount = 0;
|
||||
|
||||
foreach (var edge in edges)
|
||||
@@ -79,6 +107,13 @@ internal static class ElkEdgeRoutingScoring
|
||||
var edgeCrossings = 0;
|
||||
foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge))
|
||||
{
|
||||
// Skip corridor segments (outside graph bounds) — they can't cross nodes
|
||||
if (segment.Start.Y < graphMinY - 8d || segment.Start.Y > graphMaxY + 8d
|
||||
|| segment.End.Y < graphMinY - 8d || segment.End.Y > graphMaxY + 8d)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ElkEdgePostProcessor.SegmentCrossesObstacle(
|
||||
segment.Start,
|
||||
segment.End,
|
||||
@@ -150,6 +185,913 @@ internal static class ElkEdgeRoutingScoring
|
||||
return edges.Sum(ElkEdgeRoutingGeometry.ComputePathLength);
|
||||
}
|
||||
|
||||
internal static int CountDiagonalSegments(IReadOnlyCollection<ElkRoutedEdge> edges)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edges))
|
||||
{
|
||||
var dx = Math.Abs(segment.End.X - segment.Start.X);
|
||||
var dy = Math.Abs(segment.End.Y - segment.Start.Y);
|
||||
if (dx > 3d && dy > 3d)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
internal static int CountBadEntryAngles(
|
||||
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes)
|
||||
{
|
||||
return CountBadEntryAngles(edges, nodes, null);
|
||||
}
|
||||
|
||||
internal static int CountBadEntryAngles(
|
||||
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes,
|
||||
Dictionary<string, int>? severityByEdgeId,
|
||||
int severityWeight = 1)
|
||||
{
|
||||
// Counts 0/180° joins: last segment parallel to the target node's entry side.
|
||||
// Vertical side (left/right): bad if last segment is vertical (parallel).
|
||||
// Horizontal side (top/bottom): bad if last segment is horizontal (parallel).
|
||||
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
|
||||
var count = 0;
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
var edgeViolations = 0;
|
||||
var lastSection = edge.Sections.LastOrDefault();
|
||||
if (lastSection is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var points = new List<ElkPoint> { lastSection.StartPoint };
|
||||
points.AddRange(lastSection.BendPoints);
|
||||
points.Add(lastSection.EndPoint);
|
||||
|
||||
if (points.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!nodesById.TryGetValue(edge.TargetNodeId ?? "", out var targetNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var from = points[^2];
|
||||
var to = points[^1];
|
||||
var segDx = Math.Abs(to.X - from.X);
|
||||
var segDy = Math.Abs(to.Y - from.Y);
|
||||
|
||||
if (segDx < 3d && segDy < 3d)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var onLeftSide = Math.Abs(to.X - targetNode.X) < 2d;
|
||||
var onRightSide = Math.Abs(to.X - (targetNode.X + targetNode.Width)) < 2d;
|
||||
var onTopSide = Math.Abs(to.Y - targetNode.Y) < 2d;
|
||||
var onBottomSide = Math.Abs(to.Y - (targetNode.Y + targetNode.Height)) < 2d;
|
||||
|
||||
var touchesVerticalSide = onLeftSide || onRightSide;
|
||||
var touchesHorizontalSide = onTopSide || onBottomSide;
|
||||
var validForVerticalSide = segDx > segDy * 3d;
|
||||
var validForHorizontalSide = segDy > segDx * 3d;
|
||||
|
||||
if (touchesVerticalSide && !touchesHorizontalSide && !validForVerticalSide)
|
||||
{
|
||||
count++;
|
||||
edgeViolations++;
|
||||
}
|
||||
else if (touchesHorizontalSide && !touchesVerticalSide && !validForHorizontalSide)
|
||||
{
|
||||
count++;
|
||||
edgeViolations++;
|
||||
}
|
||||
else if (touchesVerticalSide && touchesHorizontalSide
|
||||
&& !validForVerticalSide && !validForHorizontalSide)
|
||||
{
|
||||
count++;
|
||||
edgeViolations++;
|
||||
}
|
||||
|
||||
if (edgeViolations > 0 && severityByEdgeId is not null)
|
||||
{
|
||||
severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + (edgeViolations * severityWeight);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
internal static int CountBadBoundaryAngles(
|
||||
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes)
|
||||
{
|
||||
return CountBadBoundaryAngles(edges, nodes, null);
|
||||
}
|
||||
|
||||
internal static int CountBadBoundaryAngles(
|
||||
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes,
|
||||
Dictionary<string, int>? severityByEdgeId,
|
||||
int severityWeight = 1)
|
||||
{
|
||||
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
|
||||
var count = 0;
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
var edgeViolations = 0;
|
||||
var firstSection = edge.Sections.FirstOrDefault();
|
||||
var lastSection = edge.Sections.LastOrDefault();
|
||||
if (firstSection is null || lastSection is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nodesById.TryGetValue(edge.SourceNodeId ?? "", out var sourceNode))
|
||||
{
|
||||
var sourcePoints = new List<ElkPoint> { firstSection.StartPoint };
|
||||
sourcePoints.AddRange(firstSection.BendPoints);
|
||||
sourcePoints.Add(firstSection.EndPoint);
|
||||
if (sourcePoints.Count >= 2 && !HasValidBoundaryAngle(sourcePoints[0], sourcePoints[1], sourceNode))
|
||||
{
|
||||
count++;
|
||||
edgeViolations++;
|
||||
}
|
||||
}
|
||||
|
||||
if (nodesById.TryGetValue(edge.TargetNodeId ?? "", out var targetNode))
|
||||
{
|
||||
var targetPoints = new List<ElkPoint> { lastSection.StartPoint };
|
||||
targetPoints.AddRange(lastSection.BendPoints);
|
||||
targetPoints.Add(lastSection.EndPoint);
|
||||
if (targetPoints.Count >= 2 && !HasValidBoundaryAngle(targetPoints[^1], targetPoints[^2], targetNode))
|
||||
{
|
||||
count++;
|
||||
edgeViolations++;
|
||||
}
|
||||
}
|
||||
|
||||
if (edgeViolations > 0 && severityByEdgeId is not null)
|
||||
{
|
||||
severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + (edgeViolations * severityWeight);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
internal static int CountRepeatCollectorCorridorViolations(
|
||||
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes)
|
||||
{
|
||||
return CountRepeatCollectorCorridorViolations(edges, nodes, null);
|
||||
}
|
||||
|
||||
internal static int CountRepeatCollectorCorridorViolations(
|
||||
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes,
|
||||
Dictionary<string, int>? severityByEdgeId,
|
||||
int severityWeight = 1)
|
||||
{
|
||||
return ElkRepeatCollectorCorridors.CountSharedLaneViolations(edges, nodes, severityByEdgeId, severityWeight);
|
||||
}
|
||||
|
||||
internal static int CountLabelProximityViolations(
|
||||
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes)
|
||||
{
|
||||
return CountLabelProximityViolations(edges, nodes, null);
|
||||
}
|
||||
|
||||
internal static int CountLabelProximityViolations(
|
||||
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes,
|
||||
Dictionary<string, int>? severityByEdgeId,
|
||||
int severityWeight = 1)
|
||||
{
|
||||
// A labeled edge needs a long-enough first segment for the label to fit.
|
||||
// In LTR, the first segment exits the source horizontally — if it's too short
|
||||
// (immediate bend), the label gets squeezed or displaced.
|
||||
const double minLabelSegmentLength = 40d;
|
||||
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
|
||||
var count = 0;
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(edge.Label))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var firstSection = edge.Sections.FirstOrDefault();
|
||||
if (firstSection is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var points = new List<ElkPoint> { firstSection.StartPoint };
|
||||
points.AddRange(firstSection.BendPoints);
|
||||
points.Add(firstSection.EndPoint);
|
||||
|
||||
if (points.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Measure the first segment length
|
||||
var firstSegLen = ElkEdgeRoutingGeometry.ComputeSegmentLength(points[0], points[1]);
|
||||
|
||||
// If the edge is very short overall (source and target close together), skip
|
||||
if (!nodesById.TryGetValue(edge.SourceNodeId ?? "", out var srcNode)
|
||||
|| !nodesById.TryGetValue(edge.TargetNodeId ?? "", out var tgtNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var directDist = Math.Abs((tgtNode.X + tgtNode.Width / 2d) - (srcNode.X + srcNode.Width / 2d));
|
||||
if (directDist < minLabelSegmentLength * 2d)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (firstSegLen < minLabelSegmentLength)
|
||||
{
|
||||
count++;
|
||||
if (severityByEdgeId is not null)
|
||||
{
|
||||
severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + severityWeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
internal static int CountProximityViolations(
|
||||
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes)
|
||||
{
|
||||
return CountProximityViolations(edges, nodes, null);
|
||||
}
|
||||
|
||||
internal static int CountProximityViolations(
|
||||
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes,
|
||||
Dictionary<string, int>? severityByEdgeId,
|
||||
int severityWeight = 1)
|
||||
{
|
||||
var minClearance = ResolveMinLineClearance(nodes);
|
||||
|
||||
var segments = ElkEdgeRoutingGeometry.FlattenSegments(edges);
|
||||
var edgesById = edges.ToDictionary(edge => edge.Id, StringComparer.Ordinal);
|
||||
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
var count = 0;
|
||||
|
||||
// Line-line proximity: parallel segments from different edges closer than minClearance
|
||||
for (var i = 0; i < segments.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < segments.Count; j++)
|
||||
{
|
||||
if (string.Equals(segments[i].EdgeId, segments[j].EdgeId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (HighwayScoringEnabled
|
||||
&& IsApplicableSharedHighway(segments[i], segments[j], edgesById, nodesById))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ElkEdgeRoutingGeometry.AreParallelAndClose(
|
||||
segments[i].Start, segments[i].End,
|
||||
segments[j].Start, segments[j].End,
|
||||
minClearance))
|
||||
{
|
||||
count++;
|
||||
if (severityByEdgeId is not null)
|
||||
{
|
||||
severityByEdgeId[segments[i].EdgeId] = severityByEdgeId.GetValueOrDefault(segments[i].EdgeId) + severityWeight;
|
||||
severityByEdgeId[segments[j].EdgeId] = severityByEdgeId.GetValueOrDefault(segments[j].EdgeId) + severityWeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Line-node proximity: segments from non-source/target edges passing too close to nodes
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge))
|
||||
{
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
if (node.Id == edge.SourceNodeId || node.Id == edge.TargetNodeId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var segIsH = Math.Abs(segment.Start.Y - segment.End.Y) < 2d;
|
||||
var segIsV = Math.Abs(segment.Start.X - segment.End.X) < 2d;
|
||||
|
||||
if (segIsH)
|
||||
{
|
||||
// Horizontal segment near node top/bottom
|
||||
var distTop = Math.Abs(segment.Start.Y - node.Y);
|
||||
var distBottom = Math.Abs(segment.Start.Y - (node.Y + node.Height));
|
||||
var minDist = Math.Min(distTop, distBottom);
|
||||
if (minDist > 0.5d && minDist < minClearance)
|
||||
{
|
||||
var segMinX = Math.Min(segment.Start.X, segment.End.X);
|
||||
var segMaxX = Math.Max(segment.Start.X, segment.End.X);
|
||||
if (segMaxX > node.X && segMinX < node.X + node.Width)
|
||||
{
|
||||
count++;
|
||||
if (severityByEdgeId is not null)
|
||||
{
|
||||
severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + severityWeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (segIsV)
|
||||
{
|
||||
// Vertical segment near node left/right
|
||||
var distLeft = Math.Abs(segment.Start.X - node.X);
|
||||
var distRight = Math.Abs(segment.Start.X - (node.X + node.Width));
|
||||
var minDist = Math.Min(distLeft, distRight);
|
||||
if (minDist > 0.5d && minDist < minClearance)
|
||||
{
|
||||
var segMinY = Math.Min(segment.Start.Y, segment.End.Y);
|
||||
var segMaxY = Math.Max(segment.Start.Y, segment.End.Y);
|
||||
if (segMaxY > node.Y && segMinY < node.Y + node.Height)
|
||||
{
|
||||
count++;
|
||||
if (severityByEdgeId is not null)
|
||||
{
|
||||
severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + severityWeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
internal static int CountTargetApproachJoinViolations(
|
||||
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes)
|
||||
{
|
||||
return CountTargetApproachJoinViolations(edges, nodes, null);
|
||||
}
|
||||
|
||||
internal static int CountTargetApproachJoinViolations(
|
||||
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes,
|
||||
Dictionary<string, int>? severityByEdgeId,
|
||||
int severityWeight = 1)
|
||||
{
|
||||
var minClearance = ResolveMinLineClearance(nodes);
|
||||
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
var count = 0;
|
||||
|
||||
foreach (var group in edges.GroupBy(edge => edge.TargetNodeId ?? string.Empty, StringComparer.Ordinal))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(group.Key)
|
||||
|| !nodesById.TryGetValue(group.Key, out var targetNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetEdges = group.ToArray();
|
||||
for (var i = 0; i < targetEdges.Length; i++)
|
||||
{
|
||||
var leftEdge = targetEdges[i];
|
||||
var leftPath = ExtractPath(leftEdge);
|
||||
if (leftPath.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var leftSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(leftPath[^1], targetNode);
|
||||
for (var j = i + 1; j < targetEdges.Length; j++)
|
||||
{
|
||||
var rightEdge = targetEdges[j];
|
||||
var rightPath = ExtractPath(rightEdge);
|
||||
if (rightPath.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var rightSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(rightPath[^1], targetNode);
|
||||
if (!string.Equals(leftSide, rightSide, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (HighwayScoringEnabled && IsApplicableSharedHighway(leftEdge, rightEdge, nodesById))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var maxSegmentsFromEnd = ElkEdgePostProcessor.IsRepeatCollectorLabel(leftEdge.Label)
|
||||
&& ElkEdgePostProcessor.IsRepeatCollectorLabel(rightEdge.Label)
|
||||
? 2
|
||||
: 3;
|
||||
if (HasTargetApproachJoin(leftPath, rightPath, minClearance, maxSegmentsFromEnd))
|
||||
{
|
||||
count++;
|
||||
if (severityByEdgeId is not null)
|
||||
{
|
||||
severityByEdgeId[leftEdge.Id] = severityByEdgeId.GetValueOrDefault(leftEdge.Id) + severityWeight;
|
||||
severityByEdgeId[rightEdge.Id] = severityByEdgeId.GetValueOrDefault(rightEdge.Id) + severityWeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
internal static int CountExcessiveDetourViolations(
|
||||
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes)
|
||||
{
|
||||
return CountExcessiveDetourViolations(edges, nodes, null);
|
||||
}
|
||||
|
||||
internal static int CountTargetApproachBacktrackingViolations(
|
||||
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes)
|
||||
{
|
||||
return CountTargetApproachBacktrackingViolations(edges, nodes, null);
|
||||
}
|
||||
|
||||
internal static int CountTargetApproachBacktrackingViolations(
|
||||
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes,
|
||||
Dictionary<string, int>? severityByEdgeId,
|
||||
int severityWeight = 1)
|
||||
{
|
||||
var graphMinY = nodes.Count > 0 ? nodes.Min(n => n.Y) : 0d;
|
||||
var graphMaxY = nodes.Count > 0 ? nodes.Max(n => n.Y + n.Height) : 0d;
|
||||
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
var count = 0;
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (!ShouldEnforceShortestPathRule(edge, nodes, graphMinY, graphMaxY)
|
||||
|| !nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = ExtractPath(edge);
|
||||
if (!HasTargetApproachBacktracking(path, targetNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
count++;
|
||||
if (severityByEdgeId is not null)
|
||||
{
|
||||
severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + severityWeight;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
internal static int CountExcessiveDetourViolations(
|
||||
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes,
|
||||
Dictionary<string, int>? severityByEdgeId,
|
||||
int severityWeight = 1)
|
||||
{
|
||||
var minClearance = ResolveMinLineClearance(nodes);
|
||||
var graphMinY = nodes.Count > 0 ? nodes.Min(n => n.Y) : 0d;
|
||||
var graphMaxY = nodes.Count > 0 ? nodes.Max(n => n.Y + n.Height) : 0d;
|
||||
var count = 0;
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (!ShouldEnforceShortestPathRule(edge, nodes, graphMinY, graphMaxY))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var directLength = edge.Sections.Sum(section =>
|
||||
Math.Abs(section.EndPoint.X - section.StartPoint.X) + Math.Abs(section.EndPoint.Y - section.StartPoint.Y));
|
||||
if (directLength <= 1d)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var pathLength = ElkEdgeRoutingGeometry.ComputePathLength(edge);
|
||||
var excess = pathLength - directLength;
|
||||
if (excess <= Math.Max(96d, minClearance * 2d))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = ExtractPath(edge);
|
||||
if (path.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var minEndpointX = Math.Min(path[0].X, path[^1].X);
|
||||
var maxEndpointX = Math.Max(path[0].X, path[^1].X);
|
||||
var minEndpointY = Math.Min(path[0].Y, path[^1].Y);
|
||||
var maxEndpointY = Math.Max(path[0].Y, path[^1].Y);
|
||||
|
||||
var minPathX = path.Min(point => point.X);
|
||||
var maxPathX = path.Max(point => point.X);
|
||||
var minPathY = path.Min(point => point.Y);
|
||||
var maxPathY = path.Max(point => point.Y);
|
||||
|
||||
var overshoot = Math.Max(
|
||||
Math.Max(0d, minEndpointX - minPathX),
|
||||
Math.Max(
|
||||
Math.Max(0d, maxPathX - maxEndpointX),
|
||||
Math.Max(
|
||||
Math.Max(0d, minEndpointY - minPathY),
|
||||
Math.Max(0d, maxPathY - maxEndpointY))));
|
||||
|
||||
var ratio = pathLength / directLength;
|
||||
if (ratio > 1.55d || overshoot > minClearance * 1.5d)
|
||||
{
|
||||
count++;
|
||||
if (severityByEdgeId is not null)
|
||||
{
|
||||
severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + severityWeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static bool IsApplicableSharedHighway(
|
||||
RoutedEdgeSegment left,
|
||||
RoutedEdgeSegment right,
|
||||
IReadOnlyDictionary<string, ElkRoutedEdge> edgesById,
|
||||
IReadOnlyDictionary<string, ElkPositionedNode> nodesById)
|
||||
{
|
||||
if (!edgesById.TryGetValue(left.EdgeId, out var leftEdge)
|
||||
|| !edgesById.TryGetValue(right.EdgeId, out var rightEdge))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(leftEdge.TargetNodeId, rightEdge.TargetNodeId, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!nodesById.TryGetValue(leftEdge.TargetNodeId ?? string.Empty, out var targetNode))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return IsApplicableSharedHighway(leftEdge, rightEdge, nodesById);
|
||||
}
|
||||
|
||||
private static List<ElkPoint> ExtractPath(ElkRoutedEdge edge)
|
||||
{
|
||||
var path = new List<ElkPoint>();
|
||||
foreach (var section in edge.Sections)
|
||||
{
|
||||
if (path.Count == 0)
|
||||
{
|
||||
path.Add(section.StartPoint);
|
||||
}
|
||||
|
||||
path.AddRange(section.BendPoints);
|
||||
path.Add(section.EndPoint);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private static bool IsApplicableSharedHighway(
|
||||
ElkRoutedEdge leftEdge,
|
||||
ElkRoutedEdge rightEdge,
|
||||
IReadOnlyDictionary<string, ElkPositionedNode> nodesById)
|
||||
{
|
||||
if (!string.Equals(leftEdge.TargetNodeId, rightEdge.TargetNodeId, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!nodesById.TryGetValue(leftEdge.TargetNodeId ?? string.Empty, out var targetNode))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var leftPath = ExtractPath(leftEdge);
|
||||
var rightPath = ExtractPath(rightEdge);
|
||||
if (leftPath.Count < 2 || rightPath.Count < 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var leftSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(leftPath[^1], targetNode);
|
||||
var rightSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(rightPath[^1], targetNode);
|
||||
if (!string.Equals(leftSide, rightSide, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var sharedLength = ElkEdgeRoutingGeometry.ComputeLongestSharedApproachSegmentLength(
|
||||
leftPath,
|
||||
rightPath);
|
||||
if (sharedLength <= 1d)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var shortestPath = Math.Min(
|
||||
ElkEdgeRoutingGeometry.ComputePathLength(leftEdge),
|
||||
ElkEdgeRoutingGeometry.ComputePathLength(rightEdge));
|
||||
if (shortestPath <= 1d)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return sharedLength >= shortestPath * (2d / 5d);
|
||||
}
|
||||
|
||||
private static bool HasValidBoundaryAngle(
|
||||
ElkPoint boundaryPoint,
|
||||
ElkPoint adjacentPoint,
|
||||
ElkPositionedNode node)
|
||||
{
|
||||
var segDx = Math.Abs(boundaryPoint.X - adjacentPoint.X);
|
||||
var segDy = Math.Abs(boundaryPoint.Y - adjacentPoint.Y);
|
||||
if (segDx < 3d && segDy < 3d)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var side = ElkEdgeRoutingGeometry.ResolveBoundarySide(boundaryPoint, node);
|
||||
var validForVerticalSide = segDx > segDy * 3d;
|
||||
var validForHorizontalSide = segDy > segDx * 3d;
|
||||
return side switch
|
||||
{
|
||||
"left" or "right" => validForVerticalSide,
|
||||
"top" or "bottom" => validForHorizontalSide,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasTargetApproachJoin(
|
||||
IReadOnlyList<ElkPoint> leftPath,
|
||||
IReadOnlyList<ElkPoint> rightPath,
|
||||
double minClearance,
|
||||
int maxSegmentsFromEnd)
|
||||
{
|
||||
var leftSegments = FlattenSegmentsNearEnd(leftPath, maxSegmentsFromEnd);
|
||||
var rightSegments = FlattenSegmentsNearEnd(rightPath, maxSegmentsFromEnd);
|
||||
|
||||
foreach (var leftSegment in leftSegments)
|
||||
{
|
||||
foreach (var rightSegment in rightSegments)
|
||||
{
|
||||
if (!ElkEdgeRoutingGeometry.AreParallelAndClose(
|
||||
leftSegment.Start,
|
||||
leftSegment.End,
|
||||
rightSegment.Start,
|
||||
rightSegment.End,
|
||||
minClearance))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var overlap = ElkEdgeRoutingGeometry.ComputeSharedSegmentLength(
|
||||
leftSegment.Start,
|
||||
leftSegment.End,
|
||||
rightSegment.Start,
|
||||
rightSegment.End);
|
||||
|
||||
if (overlap > 8d)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var leftLength = ElkEdgeRoutingGeometry.ComputeSegmentLength(leftSegment.Start, leftSegment.End);
|
||||
var rightLength = ElkEdgeRoutingGeometry.ComputeSegmentLength(rightSegment.Start, rightSegment.End);
|
||||
if (Math.Min(leftLength, rightLength) > 8d)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasTargetApproachBacktracking(
|
||||
IReadOnlyList<ElkPoint> path,
|
||||
ElkPositionedNode targetNode)
|
||||
{
|
||||
if (path.Count < 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var side = ElkEdgeRoutingGeometry.ResolveBoundarySide(path[^1], targetNode);
|
||||
if (side is not "left" and not "right" and not "top" and not "bottom")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const double tolerance = 0.5d;
|
||||
var startIndex = Math.Max(
|
||||
0,
|
||||
path.Count - (side is "left" or "right" ? 4 : 3));
|
||||
var axisValues = new List<double>(path.Count - startIndex);
|
||||
for (var i = startIndex; i < path.Count; i++)
|
||||
{
|
||||
var value = side is "left" or "right"
|
||||
? path[i].X
|
||||
: path[i].Y;
|
||||
if (axisValues.Count == 0 || Math.Abs(axisValues[^1] - value) > tolerance)
|
||||
{
|
||||
axisValues.Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (axisValues.Count < 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var targetAxis = side switch
|
||||
{
|
||||
"left" => targetNode.X,
|
||||
"right" => targetNode.X + targetNode.Width,
|
||||
"top" => targetNode.Y,
|
||||
"bottom" => targetNode.Y + targetNode.Height,
|
||||
_ => double.NaN,
|
||||
};
|
||||
|
||||
var overshootsTargetSide = side switch
|
||||
{
|
||||
"left" or "top" => axisValues.Any(value => value > targetAxis + tolerance),
|
||||
"right" or "bottom" => axisValues.Any(value => value < targetAxis - tolerance),
|
||||
_ => false,
|
||||
};
|
||||
if (overshootsTargetSide)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var expectsIncreasing = side is "left" or "top";
|
||||
var sawProgress = false;
|
||||
for (var i = 1; i < axisValues.Count; i++)
|
||||
{
|
||||
var delta = axisValues[i] - axisValues[i - 1];
|
||||
if (Math.Abs(delta) <= tolerance)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expectsIncreasing)
|
||||
{
|
||||
if (delta > tolerance)
|
||||
{
|
||||
sawProgress = true;
|
||||
}
|
||||
else if (sawProgress)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (delta < -tolerance)
|
||||
{
|
||||
sawProgress = true;
|
||||
}
|
||||
else if (sawProgress)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<RoutedEdgeSegment> FlattenSegmentsNearEnd(
|
||||
IReadOnlyList<ElkPoint> path,
|
||||
int maxSegmentsFromEnd)
|
||||
{
|
||||
if (path.Count < 2 || maxSegmentsFromEnd <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var startIndex = Math.Max(0, path.Count - (maxSegmentsFromEnd + 1));
|
||||
var segments = new List<RoutedEdgeSegment>();
|
||||
for (var i = startIndex; i < path.Count - 1; i++)
|
||||
{
|
||||
segments.Add(new RoutedEdgeSegment(string.Empty, path[i], path[i + 1]));
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
private static double ResolveMinLineClearance(IReadOnlyCollection<ElkPositionedNode> nodes)
|
||||
{
|
||||
var serviceNodes = nodes.Where(n => n.Kind is not "Start" and not "End").ToArray();
|
||||
return serviceNodes.Length > 0
|
||||
? Math.Min(serviceNodes.Average(n => n.Width), serviceNodes.Average(n => n.Height)) / 2d
|
||||
: 50d;
|
||||
}
|
||||
|
||||
private static bool ShouldEnforceShortestPathRule(
|
||||
ElkRoutedEdge edge,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes,
|
||||
double graphMinY,
|
||||
double graphMaxY)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(edge.SourcePortId) || !string.IsNullOrWhiteSpace(edge.TargetPortId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(edge.Kind)
|
||||
&& edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY)
|
||||
&& !HasClearOrthogonalShortcut(edge, nodes))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label);
|
||||
}
|
||||
|
||||
private static bool HasClearOrthogonalShortcut(
|
||||
ElkRoutedEdge edge,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes)
|
||||
{
|
||||
var firstSection = edge.Sections.FirstOrDefault();
|
||||
var lastSection = edge.Sections.LastOrDefault();
|
||||
if (firstSection is null || lastSection is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var start = firstSection.StartPoint;
|
||||
var end = lastSection.EndPoint;
|
||||
var obstacles = nodes.Select(node => (
|
||||
Left: node.X,
|
||||
Top: node.Y,
|
||||
Right: node.X + node.Width,
|
||||
Bottom: node.Y + node.Height,
|
||||
Id: node.Id)).ToArray();
|
||||
|
||||
bool SegmentIsClear(ElkPoint from, ElkPoint to) =>
|
||||
!ElkEdgePostProcessor.SegmentCrossesObstacle(
|
||||
from,
|
||||
to,
|
||||
obstacles,
|
||||
edge.SourceNodeId,
|
||||
edge.TargetNodeId);
|
||||
|
||||
if (Math.Abs(start.X - end.X) < 2d || Math.Abs(start.Y - end.Y) < 2d)
|
||||
{
|
||||
return SegmentIsClear(start, end);
|
||||
}
|
||||
|
||||
var horizontalThenVertical = new ElkPoint { X = end.X, Y = start.Y };
|
||||
if (SegmentIsClear(start, horizontalThenVertical) && SegmentIsClear(horizontalThenVertical, end))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var verticalThenHorizontal = new ElkPoint { X = start.X, Y = end.Y };
|
||||
return SegmentIsClear(start, verticalThenHorizontal)
|
||||
&& SegmentIsClear(verticalThenHorizontal, end);
|
||||
}
|
||||
|
||||
internal static int CountTargetApproachCongestion(IReadOnlyCollection<ElkRoutedEdge> edges)
|
||||
{
|
||||
var congestionCount = 0;
|
||||
|
||||
208
src/__Libraries/StellaOps.ElkSharp/ElkLayoutDiagnostics.cs
Normal file
208
src/__Libraries/StellaOps.ElkSharp/ElkLayoutDiagnostics.cs
Normal file
@@ -0,0 +1,208 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.ElkSharp;
|
||||
|
||||
internal sealed class ElkLayoutDiagnosticsCapture : IDisposable
|
||||
{
|
||||
private readonly ElkLayoutRunDiagnostics? previous;
|
||||
|
||||
internal ElkLayoutDiagnosticsCapture(ElkLayoutRunDiagnostics diagnostics, ElkLayoutRunDiagnostics? previous)
|
||||
{
|
||||
Diagnostics = diagnostics;
|
||||
this.previous = previous;
|
||||
}
|
||||
|
||||
internal ElkLayoutRunDiagnostics Diagnostics { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ElkLayoutDiagnostics.Restore(previous);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ElkLayoutRunDiagnostics
|
||||
{
|
||||
internal object SyncRoot { get; } = new();
|
||||
|
||||
public EdgeRoutingScore? InitialScore { get; set; }
|
||||
public EdgeRoutingScore? FinalScore { get; set; }
|
||||
public int BaselineBrokenShortHighwayCount { get; set; }
|
||||
public int FinalBrokenShortHighwayCount { get; set; }
|
||||
public int CompletedPasses { get; set; }
|
||||
public List<ElkEdgeRefinementAttemptDiagnostics> Attempts { get; } = [];
|
||||
public List<ElkIterativeStrategyDiagnostics> IterativeStrategies { get; } = [];
|
||||
public int SelectedStrategyIndex { get; set; } = -1;
|
||||
public EdgeRoutingScore? IterativeBaselineScore { get; set; }
|
||||
public List<ElkHighwayDiagnostics> DetectedHighways { get; } = [];
|
||||
public List<string> ProgressLog { get; } = [];
|
||||
public string? ProgressLogPath { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ElkHighwayDiagnostics
|
||||
{
|
||||
public required string TargetNodeId { get; init; }
|
||||
public required string SharedAxis { get; init; }
|
||||
public required double SharedCoord { get; init; }
|
||||
public required string[] EdgeIds { get; init; }
|
||||
public required double MinRatio { get; init; }
|
||||
public required bool WasBroken { get; init; }
|
||||
public required string Reason { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class ElkIterativeStrategyDiagnostics
|
||||
{
|
||||
public required int StrategyIndex { get; init; }
|
||||
public required string OrderingName { get; init; }
|
||||
public int Attempts { get; set; }
|
||||
public double TotalDurationMs { get; set; }
|
||||
public EdgeRoutingScore? BestScore { get; set; }
|
||||
public required string Outcome { get; init; }
|
||||
public double BendPenalty { get; init; }
|
||||
public double DiagonalPenalty { get; init; }
|
||||
public double SoftObstacleWeight { get; init; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public ElkRoutedEdge[]? BestEdges { get; set; }
|
||||
|
||||
public List<ElkIterativeAttemptDiagnostics> AttemptDetails { get; } = [];
|
||||
}
|
||||
|
||||
internal sealed class ElkIterativeAttemptDiagnostics
|
||||
{
|
||||
public required int Attempt { get; init; }
|
||||
public double TotalDurationMs { get; init; }
|
||||
public required EdgeRoutingScore Score { get; init; }
|
||||
public required string Outcome { get; init; }
|
||||
public ElkIterativeRouteDiagnostics? RouteDiagnostics { get; init; }
|
||||
public List<ElkIterativePhaseDiagnostics> PhaseTimings { get; } = [];
|
||||
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public ElkRoutedEdge[]? Edges { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class ElkIterativeRouteDiagnostics
|
||||
{
|
||||
public required string Mode { get; init; }
|
||||
public int TotalEdges { get; init; }
|
||||
public int RoutedEdges { get; init; }
|
||||
public int SkippedEdges { get; init; }
|
||||
public int RoutedSections { get; init; }
|
||||
public int FallbackSections { get; init; }
|
||||
public int SoftObstacleSegments { get; init; }
|
||||
public IReadOnlyCollection<string> RepairedEdgeIds { get; init; } = [];
|
||||
public IReadOnlyCollection<string> RepairReasons { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed class ElkIterativePhaseDiagnostics
|
||||
{
|
||||
public required string Phase { get; init; }
|
||||
public double DurationMs { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class ElkEdgeRefinementAttemptDiagnostics
|
||||
{
|
||||
public required int PassIndex { get; init; }
|
||||
public required string EdgeId { get; init; }
|
||||
public required int Severity { get; init; }
|
||||
public required EdgeRoutingScore BaselineScore { get; init; }
|
||||
public int AttemptCount { get; set; }
|
||||
public int? AcceptedTrialIndex { get; set; }
|
||||
public EdgeRoutingScore? AcceptedScore { get; set; }
|
||||
public List<ElkEdgeRefinementTrialDiagnostics> Trials { get; } = [];
|
||||
}
|
||||
|
||||
internal sealed class ElkEdgeRefinementTrialDiagnostics
|
||||
{
|
||||
public required int TrialIndex { get; init; }
|
||||
public required double Margin { get; init; }
|
||||
public required double BendPenalty { get; init; }
|
||||
public required double SoftObstacleWeight { get; init; }
|
||||
public required string Outcome { get; init; }
|
||||
public EdgeRoutingScore? CandidateScore { get; init; }
|
||||
public bool Accepted { get; set; }
|
||||
public IReadOnlyCollection<ElkDiagnosticSectionPath> Sections { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed class ElkDiagnosticSectionPath
|
||||
{
|
||||
public required ElkPoint StartPoint { get; init; }
|
||||
public required IReadOnlyCollection<ElkPoint> BendPoints { get; init; }
|
||||
public required ElkPoint EndPoint { get; init; }
|
||||
}
|
||||
|
||||
internal static class ElkLayoutDiagnostics
|
||||
{
|
||||
private static readonly AsyncLocal<ElkLayoutRunDiagnostics?> CurrentDiagnostics = new();
|
||||
|
||||
internal static ElkLayoutRunDiagnostics? Current => CurrentDiagnostics.Value;
|
||||
|
||||
internal static ElkLayoutDiagnosticsCapture BeginCapture()
|
||||
{
|
||||
var previous = CurrentDiagnostics.Value;
|
||||
var diagnostics = new ElkLayoutRunDiagnostics();
|
||||
CurrentDiagnostics.Value = diagnostics;
|
||||
return new ElkLayoutDiagnosticsCapture(diagnostics, previous);
|
||||
}
|
||||
|
||||
internal static ElkLayoutDiagnosticsCapture Attach(ElkLayoutRunDiagnostics diagnostics)
|
||||
{
|
||||
var previous = CurrentDiagnostics.Value;
|
||||
CurrentDiagnostics.Value = diagnostics;
|
||||
return new ElkLayoutDiagnosticsCapture(diagnostics, previous);
|
||||
}
|
||||
|
||||
internal static void Restore(ElkLayoutRunDiagnostics? previous)
|
||||
{
|
||||
CurrentDiagnostics.Value = previous;
|
||||
}
|
||||
|
||||
internal static void LogProgress(string message)
|
||||
{
|
||||
var diagnostics = CurrentDiagnostics.Value;
|
||||
if (diagnostics is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var line = $"[{DateTime.UtcNow:O}] {message}";
|
||||
lock (diagnostics.SyncRoot)
|
||||
{
|
||||
diagnostics.ProgressLog.Add(line);
|
||||
Console.WriteLine(line);
|
||||
if (!string.IsNullOrWhiteSpace(diagnostics.ProgressLogPath))
|
||||
{
|
||||
File.AppendAllText(diagnostics.ProgressLogPath, line + Environment.NewLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static void AddDetectedHighway(ElkHighwayDiagnostics diagnostic)
|
||||
{
|
||||
var diagnostics = CurrentDiagnostics.Value;
|
||||
if (diagnostics is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (diagnostics.SyncRoot)
|
||||
{
|
||||
diagnostics.DetectedHighways.Add(diagnostic);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void AddRefinementAttempt(ElkEdgeRefinementAttemptDiagnostics attempt)
|
||||
{
|
||||
var diagnostics = CurrentDiagnostics.Value;
|
||||
if (diagnostics is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (diagnostics.SyncRoot)
|
||||
{
|
||||
diagnostics.Attempts.Add(attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,9 +51,52 @@ internal readonly record struct EdgeRoutingScore(
|
||||
int EdgeCrossings,
|
||||
int BendCount,
|
||||
int TargetCongestion,
|
||||
int DiagonalCount,
|
||||
int EntryAngleViolations,
|
||||
int LabelProximityViolations,
|
||||
int RepeatCollectorCorridorViolations,
|
||||
int TargetApproachJoinViolations,
|
||||
int TargetApproachBacktrackingViolations,
|
||||
int ExcessiveDetourViolations,
|
||||
int ProximityViolations,
|
||||
double TotalPathLength,
|
||||
double Value);
|
||||
|
||||
internal readonly record struct RoutingRetryState(
|
||||
int RemainingShortHighways,
|
||||
int RepeatCollectorCorridorViolations,
|
||||
int TargetApproachJoinViolations,
|
||||
int TargetApproachBacktrackingViolations,
|
||||
int ExcessiveDetourViolations,
|
||||
int ProximityViolations,
|
||||
int EntryAngleViolations,
|
||||
int LabelProximityViolations,
|
||||
int EdgeCrossings)
|
||||
{
|
||||
internal int QualityViolationCount =>
|
||||
ProximityViolations + EntryAngleViolations + LabelProximityViolations;
|
||||
|
||||
internal bool RequiresQualityRetry => QualityViolationCount > 0;
|
||||
|
||||
internal int BlockingViolationCount =>
|
||||
RemainingShortHighways
|
||||
+ RepeatCollectorCorridorViolations
|
||||
+ TargetApproachJoinViolations
|
||||
+ TargetApproachBacktrackingViolations;
|
||||
|
||||
internal bool RequiresBlockingRetry => BlockingViolationCount > 0;
|
||||
|
||||
internal int LengthViolationCount =>
|
||||
ExcessiveDetourViolations;
|
||||
|
||||
internal bool RequiresLengthRetry => LengthViolationCount > 0;
|
||||
|
||||
internal int PrimaryViolationCount =>
|
||||
BlockingViolationCount + LengthViolationCount + QualityViolationCount;
|
||||
|
||||
internal bool RequiresPrimaryRetry => PrimaryViolationCount > 0;
|
||||
}
|
||||
|
||||
internal readonly record struct EdgeRoutingIssue(
|
||||
string EdgeId,
|
||||
int Severity);
|
||||
@@ -72,3 +115,147 @@ internal readonly record struct OrthogonalAStarOptions(
|
||||
internal readonly record struct OrthogonalSoftObstacle(
|
||||
ElkPoint Start,
|
||||
ElkPoint End);
|
||||
|
||||
internal readonly record struct AStarRoutingParams(
|
||||
double Margin,
|
||||
double BendPenalty,
|
||||
double DiagonalPenalty,
|
||||
double SoftObstacleWeight,
|
||||
double SoftObstacleClearance,
|
||||
double IntermediateGridSpacing,
|
||||
bool EnforceEntryAngle);
|
||||
|
||||
internal sealed class RoutingStrategy
|
||||
{
|
||||
internal int[] EdgeOrder { get; init; } = [];
|
||||
internal double BaseLineClearance { get; init; }
|
||||
internal double MinLineClearance { get; set; }
|
||||
internal AStarRoutingParams RoutingParams { get; set; }
|
||||
|
||||
internal void AdaptForViolations(EdgeRoutingScore score, int attempt, RoutingRetryState retryState)
|
||||
{
|
||||
var highwayPressure = Math.Min(retryState.RemainingShortHighways, 4);
|
||||
var collectorCorridorPressure = Math.Min(retryState.RepeatCollectorCorridorViolations, 4);
|
||||
var targetJoinPressure = Math.Min(retryState.TargetApproachJoinViolations, 4);
|
||||
var backtrackingPressure = Math.Min(retryState.TargetApproachBacktrackingViolations, 4);
|
||||
var detourPressure = Math.Min(retryState.ExcessiveDetourViolations, 4);
|
||||
var proximityPressure = Math.Min(retryState.ProximityViolations, 6);
|
||||
var entryPressure = Math.Min(retryState.EntryAngleViolations, 4);
|
||||
var labelPressure = Math.Min(retryState.LabelProximityViolations, 4);
|
||||
var crossingPressure = Math.Min(retryState.EdgeCrossings, 6);
|
||||
var clearanceStep = 4d
|
||||
+ (highwayPressure > 0 ? 8d : 0d)
|
||||
+ (collectorCorridorPressure > 0 ? 10d : 0d)
|
||||
+ (targetJoinPressure > 0 ? 12d : 0d)
|
||||
+ (backtrackingPressure > 0 ? 6d : 0d)
|
||||
+ (proximityPressure > 0 ? 10d : 0d)
|
||||
+ (labelPressure > 0 ? 4d : 0d)
|
||||
+ (crossingPressure > 0 ? 3d : 0d);
|
||||
MinLineClearance = Math.Min(
|
||||
Math.Max(MinLineClearance, BaseLineClearance) + clearanceStep,
|
||||
BaseLineClearance * 2d);
|
||||
|
||||
var bendPenalty = RoutingParams.BendPenalty;
|
||||
if (entryPressure > 0 || labelPressure > 0 || highwayPressure > 0 || collectorCorridorPressure > 0 || targetJoinPressure > 0)
|
||||
{
|
||||
bendPenalty = Math.Min(bendPenalty + 40d, 800d);
|
||||
}
|
||||
else if (backtrackingPressure > 0 || detourPressure > 0 || proximityPressure > 0 || crossingPressure > 0)
|
||||
{
|
||||
bendPenalty = Math.Max(
|
||||
80d,
|
||||
bendPenalty - (backtrackingPressure > 0 ? 80d : detourPressure > 0 ? 50d : 30d));
|
||||
}
|
||||
|
||||
var margin = RoutingParams.Margin;
|
||||
if (backtrackingPressure > 0 || detourPressure > 0)
|
||||
{
|
||||
margin = Math.Max(12d, margin - (backtrackingPressure > 0 ? 6d : 4d));
|
||||
}
|
||||
else
|
||||
{
|
||||
margin = Math.Min(
|
||||
margin
|
||||
+ (highwayPressure > 0 ? 8d : 4d)
|
||||
+ (collectorCorridorPressure > 0 ? 8d : 0d)
|
||||
+ (targetJoinPressure > 0 ? 10d : 0d)
|
||||
+ (proximityPressure > 0 ? 6d : 0d)
|
||||
+ (entryPressure > 0 ? 3d : 0d),
|
||||
BaseLineClearance * 2d);
|
||||
}
|
||||
|
||||
var softObstacleWeight = RoutingParams.SoftObstacleWeight;
|
||||
if (backtrackingPressure > 0 || detourPressure > 0)
|
||||
{
|
||||
softObstacleWeight = Math.Max(
|
||||
0.5d,
|
||||
softObstacleWeight - (backtrackingPressure > 0 ? 1.0d : 0.75d));
|
||||
}
|
||||
else
|
||||
{
|
||||
softObstacleWeight = Math.Min(
|
||||
softObstacleWeight
|
||||
+ (highwayPressure > 0 ? 0.75d : 0.25d)
|
||||
+ (collectorCorridorPressure > 0 ? 0.75d : 0d)
|
||||
+ (targetJoinPressure > 0 ? 1.0d : 0d)
|
||||
+ (proximityPressure > 0 ? 0.75d : 0d)
|
||||
+ (crossingPressure > 0 ? 0.5d : 0d),
|
||||
8d);
|
||||
}
|
||||
|
||||
var softObstacleClearance = RoutingParams.SoftObstacleClearance;
|
||||
if (backtrackingPressure > 0 || detourPressure > 0)
|
||||
{
|
||||
softObstacleClearance = Math.Max(
|
||||
BaseLineClearance * 0.6d,
|
||||
softObstacleClearance - (backtrackingPressure > 0 ? 14d : 10d));
|
||||
}
|
||||
else
|
||||
{
|
||||
softObstacleClearance = Math.Min(
|
||||
softObstacleClearance
|
||||
+ (highwayPressure > 0 ? 8d : 4d)
|
||||
+ (collectorCorridorPressure > 0 ? 10d : 0d)
|
||||
+ (targetJoinPressure > 0 ? 16d : 0d)
|
||||
+ (proximityPressure > 0 ? 10d : 0d)
|
||||
+ (labelPressure > 0 ? 4d : 0d)
|
||||
+ (crossingPressure > 0 ? 4d : 0d),
|
||||
BaseLineClearance * 2d);
|
||||
}
|
||||
|
||||
var intermediateGridSpacing = RoutingParams.IntermediateGridSpacing;
|
||||
if (backtrackingPressure > 0 || detourPressure > 0)
|
||||
{
|
||||
intermediateGridSpacing = Math.Max(
|
||||
12d,
|
||||
intermediateGridSpacing - (backtrackingPressure > 0 ? 10d : 6d));
|
||||
}
|
||||
else
|
||||
{
|
||||
intermediateGridSpacing = Math.Max(
|
||||
12d,
|
||||
intermediateGridSpacing
|
||||
- (highwayPressure > 0 ? 6d : 2d)
|
||||
- (collectorCorridorPressure > 0 ? 6d : 0d)
|
||||
- (targetJoinPressure > 0 ? 8d : 0d)
|
||||
- (proximityPressure > 0 ? 6d : 0d)
|
||||
- (entryPressure > 0 ? 4d : 0d)
|
||||
- (labelPressure > 0 ? 2d : 0d));
|
||||
}
|
||||
|
||||
RoutingParams = RoutingParams with
|
||||
{
|
||||
Margin = margin,
|
||||
BendPenalty = bendPenalty,
|
||||
SoftObstacleWeight = softObstacleWeight,
|
||||
SoftObstacleClearance = softObstacleClearance,
|
||||
IntermediateGridSpacing = intermediateGridSpacing,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct IterativeRoutingConfig(
|
||||
bool Enabled,
|
||||
int MaxAdaptationsPerStrategy,
|
||||
int RequiredValidSolutions,
|
||||
double ObstacleMargin);
|
||||
|
||||
@@ -68,6 +68,7 @@ public sealed record ElkLayoutOptions
|
||||
public int? OrderingIterations { get; init; }
|
||||
public int? PlacementIterations { get; init; }
|
||||
public EdgeRefinementOptions? EdgeRefinement { get; init; }
|
||||
public IterativeRoutingOptions? IterativeRouting { get; init; }
|
||||
}
|
||||
|
||||
public sealed record EdgeRefinementOptions
|
||||
@@ -81,6 +82,13 @@ public sealed record EdgeRefinementOptions
|
||||
public double SoftObstacleClearance { get; init; } = 14d;
|
||||
}
|
||||
|
||||
public sealed record IterativeRoutingOptions
|
||||
{
|
||||
public bool? Enabled { get; init; }
|
||||
public int MaxAdaptationsPerStrategy { get; init; } = 10;
|
||||
public int RequiredValidSolutions { get; init; } = 10;
|
||||
}
|
||||
|
||||
public sealed record ElkPoint
|
||||
{
|
||||
public required double X { get; init; }
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
namespace StellaOps.ElkSharp;
|
||||
|
||||
internal sealed class ElkRepeatCollectorCorridorGroup
|
||||
{
|
||||
public required string TargetNodeId { get; init; }
|
||||
public required bool IsAbove { get; init; }
|
||||
public required double CorridorY { get; init; }
|
||||
public required string[] EdgeIds { get; init; }
|
||||
}
|
||||
|
||||
internal static class ElkRepeatCollectorCorridors
|
||||
{
|
||||
private const double CoordinateTolerance = 0.5d;
|
||||
|
||||
internal static int CountSharedLaneViolations(
|
||||
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes,
|
||||
Dictionary<string, int>? severityByEdgeId = null,
|
||||
int severityWeight = 1)
|
||||
{
|
||||
var groups = DetectSharedLaneGroups(edges, nodes);
|
||||
var count = 0;
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var edgeCount = group.EdgeIds.Length;
|
||||
if (edgeCount < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
count += edgeCount * (edgeCount - 1) / 2;
|
||||
if (severityByEdgeId is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var edgeId in group.EdgeIds)
|
||||
{
|
||||
severityByEdgeId[edgeId] = severityByEdgeId.GetValueOrDefault(edgeId)
|
||||
+ ((edgeCount - 1) * severityWeight);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<ElkRepeatCollectorCorridorGroup> DetectSharedLaneGroups(
|
||||
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes)
|
||||
{
|
||||
if (edges.Count < 2 || nodes.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var graphMinY = nodes.Min(node => node.Y);
|
||||
var graphMaxY = nodes.Max(node => node.Y + node.Height);
|
||||
var candidates = edges
|
||||
.Select(edge => CreateCandidate(edge, graphMinY, graphMaxY))
|
||||
.Where(candidate => candidate is not null)
|
||||
.Select(candidate => candidate!.Value)
|
||||
.OrderBy(candidate => candidate.TargetNodeId, StringComparer.Ordinal)
|
||||
.ThenBy(candidate => candidate.IsAbove ? 0 : 1)
|
||||
.ThenBy(candidate => candidate.CorridorY)
|
||||
.ThenBy(candidate => candidate.EdgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (candidates.Length < 2)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var groups = new List<ElkRepeatCollectorCorridorGroup>();
|
||||
foreach (var groupedCandidates in candidates
|
||||
.GroupBy(
|
||||
candidate => $"{candidate.TargetNodeId}|{candidate.IsAbove}",
|
||||
StringComparer.Ordinal))
|
||||
{
|
||||
var bucket = groupedCandidates.ToArray();
|
||||
var visited = new bool[bucket.Length];
|
||||
for (var i = 0; i < bucket.Length; i++)
|
||||
{
|
||||
if (visited[i])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var pending = new Queue<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 (visited[j] || currentIndex == j)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!SharesOuterLane(current, bucket[j]))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
visited[j] = true;
|
||||
pending.Enqueue(j);
|
||||
}
|
||||
}
|
||||
|
||||
if (component.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.Add(new ElkRepeatCollectorCorridorGroup
|
||||
{
|
||||
TargetNodeId = component[0].TargetNodeId,
|
||||
IsAbove = component[0].IsAbove,
|
||||
CorridorY = component.Min(member => member.CorridorY),
|
||||
EdgeIds = component
|
||||
.Select(member => member.EdgeId)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
internal static ElkRoutedEdge[] SeparateSharedLanes(
|
||||
ElkRoutedEdge[] edges,
|
||||
ElkPositionedNode[] nodes,
|
||||
IReadOnlyCollection<string>? restrictedEdgeIds = null)
|
||||
{
|
||||
if (edges.Length < 2 || nodes.Length == 0)
|
||||
{
|
||||
return edges;
|
||||
}
|
||||
|
||||
var graphMinY = nodes.Min(node => node.Y);
|
||||
var graphMaxY = nodes.Max(node => node.Y + node.Height);
|
||||
var serviceNodes = nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
|
||||
var minLineClearance = serviceNodes.Length > 0
|
||||
? Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d
|
||||
: 50d;
|
||||
var laneGap = Math.Max(12d, minLineClearance + 4d);
|
||||
var restrictedSet = restrictedEdgeIds is null
|
||||
? null
|
||||
: restrictedEdgeIds.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var result = edges.ToArray();
|
||||
var groups = DetectSharedLaneGroups(result, nodes)
|
||||
.Where(group => restrictedSet is null || group.EdgeIds.Any(restrictedSet.Contains))
|
||||
.OrderBy(group => group.TargetNodeId, StringComparer.Ordinal)
|
||||
.ThenBy(group => group.IsAbove ? 0 : 1)
|
||||
.ThenBy(group => group.CorridorY)
|
||||
.ToArray();
|
||||
if (groups.Length == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var members = result
|
||||
.Select((edge, index) => new { edge, index, candidate = CreateCandidate(edge, graphMinY, graphMaxY) })
|
||||
.Where(item => item.candidate is not null
|
||||
&& group.EdgeIds.Contains(item.edge.Id, StringComparer.Ordinal))
|
||||
.Select(item => new RepairMember(item.index, item.edge.Id, item.candidate!.Value.CorridorY, item.candidate.Value.StartX))
|
||||
.OrderByDescending(member => member.StartX)
|
||||
.ThenBy(member => member.EdgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (members.Length < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var baseY = group.IsAbove
|
||||
? Math.Min(members.Min(member => member.CorridorY), graphMinY - 12d)
|
||||
: Math.Max(members.Max(member => member.CorridorY), graphMaxY + 12d);
|
||||
|
||||
for (var i = 0; i < members.Length; i++)
|
||||
{
|
||||
var assignedY = group.IsAbove
|
||||
? baseY - (laneGap * i)
|
||||
: baseY + (laneGap * i);
|
||||
result[members[i].Index] = RewriteOuterLane(result[members[i].Index], members[i].CorridorY, assignedY, graphMinY, graphMaxY, group.IsAbove);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ElkRoutedEdge RewriteOuterLane(
|
||||
ElkRoutedEdge edge,
|
||||
double currentY,
|
||||
double assignedY,
|
||||
double graphMinY,
|
||||
double graphMaxY,
|
||||
bool isAbove)
|
||||
{
|
||||
if (Math.Abs(currentY - assignedY) <= CoordinateTolerance)
|
||||
{
|
||||
return edge;
|
||||
}
|
||||
|
||||
bool ShouldShift(ElkPoint point) =>
|
||||
Math.Abs(point.Y - currentY) <= CoordinateTolerance
|
||||
&& (isAbove
|
||||
? point.Y < graphMinY - 8d
|
||||
: point.Y > graphMaxY + 8d);
|
||||
|
||||
ElkPoint Shift(ElkPoint point) => ShouldShift(point)
|
||||
? new ElkPoint { X = point.X, Y = assignedY }
|
||||
: point;
|
||||
|
||||
return new ElkRoutedEdge
|
||||
{
|
||||
Id = edge.Id,
|
||||
SourceNodeId = edge.SourceNodeId,
|
||||
TargetNodeId = edge.TargetNodeId,
|
||||
SourcePortId = edge.SourcePortId,
|
||||
TargetPortId = edge.TargetPortId,
|
||||
Kind = edge.Kind,
|
||||
Label = edge.Label,
|
||||
Sections = edge.Sections
|
||||
.Select(section => new ElkEdgeSection
|
||||
{
|
||||
StartPoint = Shift(section.StartPoint),
|
||||
EndPoint = Shift(section.EndPoint),
|
||||
BendPoints = section.BendPoints.Select(Shift).ToArray(),
|
||||
})
|
||||
.ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool SharesOuterLane(CollectorCandidate left, CollectorCandidate right)
|
||||
{
|
||||
if (!string.Equals(left.TargetNodeId, right.TargetNodeId, StringComparison.Ordinal)
|
||||
|| left.IsAbove != right.IsAbove)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Math.Abs(left.CorridorY - right.CorridorY) > CoordinateTolerance)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Math.Min(left.MaxX, right.MaxX) - Math.Max(left.MinX, right.MinX) > 1d;
|
||||
}
|
||||
|
||||
private static CollectorCandidate? CreateCandidate(
|
||||
ElkRoutedEdge edge,
|
||||
double graphMinY,
|
||||
double graphMaxY)
|
||||
{
|
||||
if (!ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label)
|
||||
|| !ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY)
|
||||
|| string.IsNullOrWhiteSpace(edge.TargetNodeId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var path = ExtractPath(edge);
|
||||
if (path.Count < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
CollectorCandidate? best = null;
|
||||
for (var i = 0; i < path.Count - 1; i++)
|
||||
{
|
||||
var start = path[i];
|
||||
var end = path[i + 1];
|
||||
if (Math.Abs(start.Y - end.Y) > CoordinateTolerance)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var y = (start.Y + end.Y) / 2d;
|
||||
var isAbove = y < graphMinY - 8d;
|
||||
var isBelow = y > graphMaxY + 8d;
|
||||
if (!isAbove && !isBelow)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var length = Math.Abs(end.X - start.X);
|
||||
if (length <= 1d)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidate = new CollectorCandidate(
|
||||
edge.Id,
|
||||
edge.TargetNodeId!,
|
||||
isAbove,
|
||||
y,
|
||||
Math.Min(start.X, end.X),
|
||||
Math.Max(start.X, end.X),
|
||||
path[0].X,
|
||||
length);
|
||||
if (best is null || candidate.Length > best.Value.Length)
|
||||
{
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private static List<ElkPoint> ExtractPath(ElkRoutedEdge edge)
|
||||
{
|
||||
var path = new List<ElkPoint>();
|
||||
foreach (var section in edge.Sections)
|
||||
{
|
||||
if (path.Count == 0)
|
||||
{
|
||||
path.Add(section.StartPoint);
|
||||
}
|
||||
|
||||
path.AddRange(section.BendPoints);
|
||||
path.Add(section.EndPoint);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private readonly record struct CollectorCandidate(
|
||||
string EdgeId,
|
||||
string TargetNodeId,
|
||||
bool IsAbove,
|
||||
double CorridorY,
|
||||
double MinX,
|
||||
double MaxX,
|
||||
double StartX,
|
||||
double Length);
|
||||
|
||||
private readonly record struct RepairMember(int Index, string EdgeId, double CorridorY, double StartX);
|
||||
}
|
||||
@@ -211,19 +211,11 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
|
||||
.OrderBy(x => inputOrder.GetValueOrDefault(x.Id, int.MaxValue))
|
||||
.ToArray();
|
||||
|
||||
// Post-processing pipeline (deterministic generic passes, no node-specific logic):
|
||||
// Post-processing pipeline:
|
||||
// 1. Project endpoints onto actual node shape boundaries (diamond/hexagon/rectangle)
|
||||
routedEdges = ElkEdgePostProcessor.SnapAnchorsToNodeBoundary(routedEdges, finalNodes);
|
||||
// 2. Deterministic bounded refinement for crossing-prone orthogonal routes
|
||||
routedEdges = ElkEdgeRouteRefiner.Optimize(routedEdges, finalNodes, options, cancellationToken);
|
||||
// 3. Reroute any edge crossing node bounding boxes (including diagonals from shape projection)
|
||||
routedEdges = ElkEdgePostProcessor.AvoidNodeCrossings(routedEdges, finalNodes, options.Direction);
|
||||
// 4. Convert any remaining diagonal segments to orthogonal L-corners
|
||||
routedEdges = ElkEdgePostProcessor.EliminateDiagonalSegments(routedEdges, finalNodes);
|
||||
// 5. Simplify: remove collinear/duplicate points, try L-shape shortcuts
|
||||
routedEdges = ElkEdgePostProcessorSimplify.SimplifyEdgePaths(routedEdges, finalNodes);
|
||||
// 6. Compress outer corridor distances
|
||||
routedEdges = ElkEdgePostProcessorSimplify.TightenOuterCorridors(routedEdges, finalNodes);
|
||||
// 2. Iterative multi-strategy optimizer (replaces refiner + avoid crossings + diag elim + simplify + tighten)
|
||||
routedEdges = ElkEdgeRouterIterative.Optimize(routedEdges, finalNodes, options, cancellationToken);
|
||||
|
||||
return Task.FromResult(new ElkLayoutResult
|
||||
{
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Workflow.Renderer.Tests")]
|
||||
Reference in New Issue
Block a user