elksharp stabilization

This commit is contained in:
master
2026-03-24 08:38:09 +02:00
parent d788ee757e
commit 71edccd485
18 changed files with 6083 additions and 36 deletions

View File

@@ -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 |

View File

@@ -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";
}
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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))

View 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);
}
}
}
}

View 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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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);

View File

@@ -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;

View 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);
}
}
}

View File

@@ -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);

View File

@@ -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; }

View File

@@ -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);
}

View File

@@ -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
{

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Workflow.Renderer.Tests")]