ElkSharp: gateway face overflow redirect, under-node push-first routing, boundary-slot snap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-01 10:35:23 +03:00
parent 5af14cf212
commit f275b8a267
30 changed files with 5632 additions and 2647 deletions

View File

@@ -0,0 +1,517 @@
using System.Reflection;
using System.Text.Json;
using NUnit.Framework;
using StellaOps.ElkSharp;
using StellaOps.Workflow.Abstractions;
namespace StellaOps.Workflow.Renderer.Tests;
public partial class DocumentProcessingWorkflowRenderingTests
{
[Test]
public void DocumentProcessingWorkflow_WhenInspectingLatestElkSharpArtifact_ShouldReportBoundarySlotOffenders()
{
var workflowRenderingsDirectory = Path.Combine(
Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!,
"TestResults",
"workflow-renderings");
var outputDir = Directory.GetDirectories(workflowRenderingsDirectory)
.OrderByDescending(path => Path.GetFileName(path), StringComparer.Ordinal)
.Select(path => Path.Combine(path, "DocumentProcessingWorkflow"))
.First(Directory.Exists);
var jsonPath = Path.Combine(outputDir, "elksharp.json");
Assert.That(File.Exists(jsonPath), Is.True);
var layout = JsonSerializer.Deserialize<WorkflowRenderLayoutResult>(
File.ReadAllText(jsonPath),
new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
Assert.That(layout, Is.Not.Null);
var elkNodes = layout!.Nodes.Select(node => new ElkPositionedNode
{
Id = node.Id,
Label = node.Label,
Kind = node.Kind,
X = node.X,
Y = node.Y,
Width = node.Width,
Height = node.Height,
}).ToArray();
var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge
{
Id = edge.Id,
SourceNodeId = edge.SourceNodeId,
TargetNodeId = edge.TargetNodeId,
Kind = edge.Kind,
Label = edge.Label,
Sections = edge.Sections.Select(section => new ElkEdgeSection
{
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
}).ToArray(),
}).ToArray();
var severityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
var count = ElkEdgeRoutingScoring.CountBoundarySlotViolations(elkEdges, elkNodes, severityByEdgeId, 1);
TestContext.Out.WriteLine($"boundary-slot artifact count: {count}");
foreach (var offender in severityByEdgeId.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
var edge = elkEdges.Single(candidate => candidate.Id == offender.Key);
TestContext.Out.WriteLine(
$"{offender.Key}: {string.Join(" -> ", ExtractElkPath(edge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
}
var serviceNodes = elkNodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
var minLineClearance = serviceNodes.Length > 0
? Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d
: 50d;
var nodesById = elkNodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
var graphMinY = elkNodes.Min(node => node.Y);
var graphMaxY = elkNodes.Max(node => node.Y + node.Height);
var (sourceSlots, targetSlots) = ElkEdgePostProcessor.ResolveCombinedBoundarySlots(
elkEdges,
nodesById,
graphMinY,
graphMaxY,
restrictedEdgeIds: null,
enforceAllNodeEndpoints: true);
if (sourceSlots.TryGetValue("edge/25", out var sourceSlot))
{
TestContext.Out.WriteLine(
$"edge/25 source-slot: side={sourceSlot.Side} boundary=({sourceSlot.Boundary.X:F3},{sourceSlot.Boundary.Y:F3})");
}
if (targetSlots.TryGetValue("edge/25", out var targetSlot))
{
TestContext.Out.WriteLine(
$"edge/25 target-slot: side={targetSlot.Side} boundary=({targetSlot.Boundary.X:F3},{targetSlot.Boundary.Y:F3})");
var edge25 = elkEdges.Single(edge => edge.Id == "edge/25");
var edge25Path = ExtractElkPath(edge25);
var buildTargetApproachCandidatePath = typeof(ElkEdgePostProcessor).GetMethod(
"BuildTargetApproachCandidatePath",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.That(buildTargetApproachCandidatePath, Is.Not.Null);
var reflectedTargetCandidate = (List<ElkPoint>)buildTargetApproachCandidatePath!.Invoke(
null,
[edge25Path, elkNodes.Single(node => node.Id == edge25.TargetNodeId), targetSlot.Side, targetSlot.Boundary, edge25Path[2].Y])!;
TestContext.Out.WriteLine(
$"edge/25 reflected-target-candidate: {string.Join(" -> ", reflectedTargetCandidate.Select(point => $"({point.X:F3},{point.Y:F3})"))}");
}
if (targetSlots.TryGetValue("edge/5", out var edge5TargetSlot))
{
TestContext.Out.WriteLine(
$"edge/5 target-slot: side={edge5TargetSlot.Side} boundary=({edge5TargetSlot.Boundary.X:F3},{edge5TargetSlot.Boundary.Y:F3})");
var edge5 = elkEdges.Single(edge => edge.Id == "edge/5");
var edge5Path = ExtractElkPath(edge5);
var targetNode = elkNodes.Single(node => node.Id == edge5.TargetNodeId);
var postProcessorType = typeof(ElkEdgePostProcessor);
var buildTargetApproachCandidatePath = postProcessorType.GetMethod(
"BuildTargetApproachCandidatePath",
BindingFlags.NonPublic | BindingFlags.Static);
var resolveTargetApproachAxisValue = postProcessorType.GetMethod(
"ResolveTargetApproachAxisValue",
BindingFlags.NonPublic | BindingFlags.Static);
var resolveDefaultTargetApproachAxis = postProcessorType.GetMethod(
"ResolveDefaultTargetApproachAxis",
BindingFlags.NonPublic | BindingFlags.Static);
var normalizeEntryPath = postProcessorType
.GetMethods(BindingFlags.NonPublic | BindingFlags.Static)
.Single(method =>
method.Name == "NormalizeEntryPath"
&& method.GetParameters().Length == 4);
var canAcceptGatewayTargetRepair = postProcessorType.GetMethod(
"CanAcceptGatewayTargetRepair",
BindingFlags.NonPublic | BindingFlags.Static);
var hasAcceptableGatewayBoundaryPath = postProcessorType.GetMethod(
"HasAcceptableGatewayBoundaryPath",
BindingFlags.NonPublic | BindingFlags.Static);
var resolveTargetApproachSide = postProcessorType.GetMethod(
"ResolveTargetApproachSide",
BindingFlags.NonPublic | BindingFlags.Static);
var isAcceptableStrictBoundarySlotCandidate = postProcessorType.GetMethod(
"IsAcceptableStrictBoundarySlotCandidate",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.That(buildTargetApproachCandidatePath, Is.Not.Null);
Assert.That(resolveTargetApproachAxisValue, Is.Not.Null);
Assert.That(resolveDefaultTargetApproachAxis, Is.Not.Null);
Assert.That(canAcceptGatewayTargetRepair, Is.Not.Null);
Assert.That(hasAcceptableGatewayBoundaryPath, Is.Not.Null);
Assert.That(resolveTargetApproachSide, Is.Not.Null);
Assert.That(isAcceptableStrictBoundarySlotCandidate, Is.Not.Null);
var desiredTargetAxis = (double)resolveTargetApproachAxisValue!.Invoke(
null,
[edge5Path, edge5TargetSlot.Side])!;
if (double.IsNaN(desiredTargetAxis))
{
desiredTargetAxis = (double)resolveDefaultTargetApproachAxis!.Invoke(
null,
[targetNode, edge5TargetSlot.Side])!;
}
var reflectedTargetCandidate = (List<ElkPoint>)buildTargetApproachCandidatePath!.Invoke(
null,
[edge5Path, targetNode, edge5TargetSlot.Side, edge5TargetSlot.Boundary, desiredTargetAxis])!;
var strictTargetCandidate = (List<ElkPoint>)normalizeEntryPath.Invoke(
null,
[edge5Path, targetNode, edge5TargetSlot.Side, edge5TargetSlot.Boundary])!;
var reflectedTargetCandidateEdge = new ElkRoutedEdge[]
{
edge5 with
{
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = reflectedTargetCandidate[0].X, Y = reflectedTargetCandidate[0].Y },
EndPoint = new ElkPoint { X = reflectedTargetCandidate[^1].X, Y = reflectedTargetCandidate[^1].Y },
BendPoints = reflectedTargetCandidate.Skip(1).SkipLast(1)
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToArray(),
},
],
},
};
TestContext.Out.WriteLine(
$"edge/5 reflected-target-candidate: {string.Join(" -> ", reflectedTargetCandidate.Select(point => $"({point.X:F3},{point.Y:F3})"))}");
TestContext.Out.WriteLine(
$"edge/5 reflected-target-candidate accepts-gateway={canAcceptGatewayTargetRepair!.Invoke(null, [reflectedTargetCandidate, targetNode])} boundary={hasAcceptableGatewayBoundaryPath!.Invoke(null, [reflectedTargetCandidate, elkNodes, edge5.SourceNodeId, edge5.TargetNodeId, targetNode, false])} strict={isAcceptableStrictBoundarySlotCandidate!.Invoke(null, [edge5, edge5Path, reflectedTargetCandidate, targetNode, false, elkNodes, graphMinY, graphMaxY])} side={resolveTargetApproachSide!.Invoke(null, [reflectedTargetCandidate, targetNode])}");
TestContext.Out.WriteLine(
$"edge/5 reflected-target-candidate score: boundary={ElkEdgeRoutingScoring.CountBoundarySlotViolations(reflectedTargetCandidateEdge, elkNodes)} long-diagonal={ElkEdgeRoutingScoring.CountLongDiagonalViolations(reflectedTargetCandidateEdge, elkNodes)}");
TestContext.Out.WriteLine(
$"edge/5 strict-target-candidate: {string.Join(" -> ", strictTargetCandidate.Select(point => $"({point.X:F3},{point.Y:F3})"))}");
TestContext.Out.WriteLine(
$"edge/5 strict-target-candidate accepts-gateway={canAcceptGatewayTargetRepair.Invoke(null, [strictTargetCandidate, targetNode])} boundary={hasAcceptableGatewayBoundaryPath.Invoke(null, [strictTargetCandidate, elkNodes, edge5.SourceNodeId, edge5.TargetNodeId, targetNode, false])} strict={isAcceptableStrictBoundarySlotCandidate.Invoke(null, [edge5, edge5Path, strictTargetCandidate, targetNode, false, elkNodes, graphMinY, graphMaxY])} side={resolveTargetApproachSide.Invoke(null, [strictTargetCandidate, targetNode])}");
}
var snapped = ElkEdgePostProcessor.SnapBoundarySlotAssignments(elkEdges, elkNodes, minLineClearance);
var snappedSeverityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
var snappedCount = ElkEdgeRoutingScoring.CountBoundarySlotViolations(snapped, elkNodes, snappedSeverityByEdgeId, 1);
TestContext.Out.WriteLine($"boundary-slot snapped count: {snappedCount}");
if (snappedSeverityByEdgeId.TryGetValue("edge/25", out _))
{
var snappedEdge = snapped.Single(candidate => candidate.Id == "edge/25");
TestContext.Out.WriteLine(
$"edge/25 snapped: {string.Join(" -> ", ExtractElkPath(snappedEdge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
}
var edge5FocusedSnap = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
elkEdges,
elkNodes,
minLineClearance,
["edge/5"],
enforceAllNodeEndpoints: true);
var edge5FocusedSnapPath = ExtractElkPath(edge5FocusedSnap.Single(edge => edge.Id == "edge/5"));
TestContext.Out.WriteLine(
$"edge/5 focused-snapped: {string.Join(" -> ", edge5FocusedSnapPath.Select(point => $"({point.X:F3},{point.Y:F3})"))}");
TestContext.Out.WriteLine(
$"edge/5 focused-snapped score: boundary={ElkEdgeRoutingScoring.CountBoundarySlotViolations(edge5FocusedSnap, elkNodes)} long-diagonal={ElkEdgeRoutingScoring.CountLongDiagonalViolations(edge5FocusedSnap, elkNodes)}");
var detourSeverityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
var detourCount = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(elkEdges, elkNodes, detourSeverityByEdgeId, 1);
TestContext.Out.WriteLine($"detour artifact count: {detourCount}");
foreach (var offender in detourSeverityByEdgeId.OrderByDescending(pair => pair.Value).ThenBy(pair => pair.Key, StringComparer.Ordinal))
{
var edge = elkEdges.Single(candidate => candidate.Id == offender.Key);
TestContext.Out.WriteLine(
$"{offender.Key} detour={offender.Value}: {string.Join(" -> ", ExtractElkPath(edge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
var shortcutCandidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts([edge], elkNodes)[0];
if (!ExtractElkPath(shortcutCandidate).SequenceEqual(ExtractElkPath(edge), ElkPointComparer.Instance))
{
var shortcutDetourSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
var shortcutGatewaySeverity = new Dictionary<string, int>(StringComparer.Ordinal);
var shortcutDetourCount = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(
[shortcutCandidate],
elkNodes,
shortcutDetourSeverity,
1);
var shortcutGatewayCount = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(
[shortcutCandidate],
elkNodes,
shortcutGatewaySeverity,
1);
TestContext.Out.WriteLine(
$" shortcut -> detour={shortcutDetourCount} gateway-source={shortcutGatewayCount}: {string.Join(" -> ", ExtractElkPath(shortcutCandidate).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
}
else
{
TestContext.Out.WriteLine(" shortcut -> unchanged");
}
}
var gatewaySourceSeverityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
var gatewaySourceCount = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(elkEdges, elkNodes, gatewaySourceSeverityByEdgeId, 1);
TestContext.Out.WriteLine($"gateway-source artifact count: {gatewaySourceCount}");
foreach (var offender in gatewaySourceSeverityByEdgeId.OrderByDescending(pair => pair.Value).ThenBy(pair => pair.Key, StringComparer.Ordinal))
{
var edge = elkEdges.Single(candidate => candidate.Id == offender.Key);
TestContext.Out.WriteLine(
$"{offender.Key} gateway-source={offender.Value}: {string.Join(" -> ", ExtractElkPath(edge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
}
var sharedLaneConflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
.Distinct()
.OrderBy(conflict => conflict.LeftEdgeId, StringComparer.Ordinal)
.ThenBy(conflict => conflict.RightEdgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"shared-lane artifact count: {sharedLaneConflicts.Length}");
foreach (var conflict in sharedLaneConflicts)
{
TestContext.Out.WriteLine($"shared-lane artifact pair: {conflict.LeftEdgeId}+{conflict.RightEdgeId}");
}
var layoutNodesById = layout.Nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
var gatewayCornerDiagonalOffenders = layout.Edges
.Where(edge =>
HasGatewayCornerDiagonal(edge, layoutNodesById[edge.SourceNodeId], fromSource: true)
|| HasGatewayCornerDiagonal(edge, layoutNodesById[edge.TargetNodeId], fromSource: false))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-corner artifact count: {gatewayCornerDiagonalOffenders.Length}");
foreach (var edgeId in gatewayCornerDiagonalOffenders)
{
TestContext.Out.WriteLine($"gateway-corner artifact edge: {edgeId}");
}
var gatewayInteriorAdjacentOffenders = layout.Edges
.Where(edge =>
HasGatewayInteriorAdjacentPoint(edge, layoutNodesById[edge.SourceNodeId], fromSource: true)
|| HasGatewayInteriorAdjacentPoint(edge, layoutNodesById[edge.TargetNodeId], fromSource: false))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-interior-adjacent artifact count: {gatewayInteriorAdjacentOffenders.Length}");
foreach (var edgeId in gatewayInteriorAdjacentOffenders)
{
TestContext.Out.WriteLine($"gateway-interior-adjacent artifact edge: {edgeId}");
}
var gatewaySourceCurlOffenders = layout.Edges
.Where(edge => HasGatewaySourceExitCurl(edge, layoutNodesById[edge.SourceNodeId]))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-source-curl artifact count: {gatewaySourceCurlOffenders.Length}");
foreach (var edgeId in gatewaySourceCurlOffenders)
{
TestContext.Out.WriteLine($"gateway-source-curl artifact edge: {edgeId}");
}
var gatewaySourceFaceMismatchOffenders = layout.Edges
.Where(edge => HasGatewaySourcePreferredFaceMismatch(edge, layoutNodesById[edge.SourceNodeId], layout.Nodes))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-source-face-mismatch artifact count: {gatewaySourceFaceMismatchOffenders.Length}");
foreach (var edgeId in gatewaySourceFaceMismatchOffenders)
{
TestContext.Out.WriteLine($"gateway-source-face-mismatch artifact edge: {edgeId}");
}
var gatewaySourceDetourOffenders = layout.Edges
.Where(edge => HasGatewaySourceDominantAxisDetour(edge, layoutNodesById[edge.SourceNodeId], layout.Nodes))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-source-detour artifact count: {gatewaySourceDetourOffenders.Length}");
foreach (var edgeId in gatewaySourceDetourOffenders)
{
TestContext.Out.WriteLine($"gateway-source-detour artifact edge: {edgeId}");
}
var gatewaySourceVertexExitOffenders = layout.Edges
.Where(edge => HasGatewaySourceVertexExit(edge, layoutNodesById[edge.SourceNodeId]))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-source-vertex-exit artifact count: {gatewaySourceVertexExitOffenders.Length}");
foreach (var edgeId in gatewaySourceVertexExitOffenders)
{
TestContext.Out.WriteLine($"gateway-source-vertex-exit artifact edge: {edgeId}");
}
var gatewaySourceScoringOffenders = layout.Edges
.Where(edge => HasGatewaySourceScoringIssue(edge, layout.Nodes))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-source-scoring artifact count: {gatewaySourceScoringOffenders.Length}");
foreach (var edgeId in gatewaySourceScoringOffenders)
{
TestContext.Out.WriteLine($"gateway-source-scoring artifact edge: {edgeId}");
}
var loadConfigurationNode = layout.Nodes.Single(node => node.Id == "start/3");
var processBatchLoopOffenders = layout.Edges
.Where(edge => edge.TargetNodeId == "start/2/branch-1/1"
&& edge.Label.StartsWith("repeat while", StringComparison.Ordinal)
&& HasNearNodeClearanceViolation(edge, loadConfigurationNode, 40d))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"process-batch-clearance artifact count: {processBatchLoopOffenders.Length}");
foreach (var edgeId in processBatchLoopOffenders)
{
TestContext.Out.WriteLine($"process-batch-clearance artifact edge: {edgeId}");
}
var crossings = 0;
foreach (var node in layout.Nodes)
{
foreach (var edge in layout.Edges)
{
if (edge.SourceNodeId == node.Id || edge.TargetNodeId == node.Id) continue;
foreach (var section in edge.Sections)
{
var points = new List<WorkflowRenderPoint> { section.StartPoint };
points.AddRange(section.BendPoints);
points.Add(section.EndPoint);
for (var i = 0; i < points.Count - 1; i++)
{
var start = points[i];
var end = points[i + 1];
if (Math.Abs(start.Y - end.Y) < 2d && start.Y > node.Y && start.Y < node.Y + node.Height)
{
if (Math.Max(start.X, end.X) > node.X && Math.Min(start.X, end.X) < node.X + node.Width)
{
crossings++;
}
}
else if (Math.Abs(start.X - end.X) < 2d && start.X > node.X && start.X < node.X + node.Width)
{
if (Math.Max(start.Y, end.Y) > node.Y && Math.Min(start.Y, end.Y) < node.Y + node.Height)
{
crossings++;
}
}
}
}
}
}
TestContext.Out.WriteLine($"edge-node-crossing artifact count: {crossings}");
var focusedSharedLaneCandidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts(
elkEdges,
elkNodes,
minLineClearance,
["edge/15", "edge/17", "edge/35"]);
TestContext.Out.WriteLine(
$"focused shared-lane candidate counts: detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedSharedLaneCandidate, elkNodes)} gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedSharedLaneCandidate, elkNodes)} boundary-slots={ElkEdgeRoutingScoring.CountBoundarySlotViolations(focusedSharedLaneCandidate, elkNodes)} entry={ElkEdgeRoutingScoring.CountBadEntryAngles(focusedSharedLaneCandidate, elkNodes)} shared-lanes={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedSharedLaneCandidate, elkNodes)}");
foreach (var edgeId in new[] { "edge/15", "edge/17", "edge/35" })
{
var currentEdge = elkEdges.Single(edge => edge.Id == edgeId);
var candidateEdge = focusedSharedLaneCandidate.Single(edge => edge.Id == edgeId);
TestContext.Out.WriteLine(
$"focused shared-lane {edgeId} current: {string.Join(" -> ", ExtractElkPath(currentEdge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
TestContext.Out.WriteLine(
$"focused shared-lane {edgeId} candidate: {string.Join(" -> ", ExtractElkPath(candidateEdge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
}
var combinedFocus = detourSeverityByEdgeId.Keys
.Concat(gatewaySourceSeverityByEdgeId.Keys)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
if (combinedFocus.Length > 0)
{
var batchCandidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(elkEdges, elkNodes, combinedFocus);
batchCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(batchCandidate, elkNodes, minLineClearance, combinedFocus);
batchCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(batchCandidate, elkNodes, minLineClearance, combinedFocus);
batchCandidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(batchCandidate, elkNodes, minLineClearance, combinedFocus);
batchCandidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(batchCandidate, elkNodes, minLineClearance, combinedFocus);
batchCandidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(batchCandidate, elkNodes, combinedFocus);
batchCandidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(batchCandidate, elkNodes, combinedFocus);
batchCandidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(batchCandidate, elkNodes, minLineClearance, combinedFocus);
batchCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
batchCandidate,
elkNodes,
minLineClearance,
combinedFocus,
enforceAllNodeEndpoints: true);
var batchDetourCount = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(batchCandidate, elkNodes);
var batchGatewaySourceCount = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(batchCandidate, elkNodes);
var batchBoundarySlotCount = ElkEdgeRoutingScoring.CountBoundarySlotViolations(batchCandidate, elkNodes);
var batchEntryCount = ElkEdgeRoutingScoring.CountBadEntryAngles(batchCandidate, elkNodes);
var batchSharedLaneCount = ElkEdgeRoutingScoring.CountSharedLaneViolations(batchCandidate, elkNodes);
TestContext.Out.WriteLine(
$"batch-candidate counts: detour={batchDetourCount} gateway-source={batchGatewaySourceCount} boundary-slots={batchBoundarySlotCount} entry={batchEntryCount} shared-lanes={batchSharedLaneCount}");
foreach (var focusEdgeId in combinedFocus)
{
var focusedCandidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(elkEdges, elkNodes, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(focusedCandidate, elkNodes, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(focusedCandidate, elkNodes, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
focusedCandidate,
elkNodes,
minLineClearance,
[focusEdgeId],
enforceAllNodeEndpoints: true);
TestContext.Out.WriteLine(
$"focused {focusEdgeId}: detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedCandidate, elkNodes)} gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedCandidate, elkNodes)} boundary-slots={ElkEdgeRoutingScoring.CountBoundarySlotViolations(focusedCandidate, elkNodes)} entry={ElkEdgeRoutingScoring.CountBadEntryAngles(focusedCandidate, elkNodes)} shared-lanes={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedCandidate, elkNodes)}");
}
var subsetResults = new List<(string[] Focus, int Detour, int GatewaySource, int BoundarySlots, int Entry, int SharedLanes)>();
for (var mask = 1; mask < (1 << combinedFocus.Length); mask++)
{
var subset = combinedFocus
.Where((_, index) => (mask & (1 << index)) != 0)
.ToArray();
var subsetCandidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(elkEdges, elkNodes, subset);
subsetCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(subsetCandidate, elkNodes, minLineClearance, subset);
subsetCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(subsetCandidate, elkNodes, minLineClearance, subset);
subsetCandidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(subsetCandidate, elkNodes, minLineClearance, subset);
subsetCandidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(subsetCandidate, elkNodes, minLineClearance, subset);
subsetCandidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(subsetCandidate, elkNodes, subset);
subsetCandidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(subsetCandidate, elkNodes, subset);
subsetCandidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(subsetCandidate, elkNodes, minLineClearance, subset);
subsetCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
subsetCandidate,
elkNodes,
minLineClearance,
subset,
enforceAllNodeEndpoints: true);
subsetResults.Add((
subset,
ElkEdgeRoutingScoring.CountExcessiveDetourViolations(subsetCandidate, elkNodes),
ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(subsetCandidate, elkNodes),
ElkEdgeRoutingScoring.CountBoundarySlotViolations(subsetCandidate, elkNodes),
ElkEdgeRoutingScoring.CountBadEntryAngles(subsetCandidate, elkNodes),
ElkEdgeRoutingScoring.CountSharedLaneViolations(subsetCandidate, elkNodes)));
}
foreach (var result in subsetResults
.OrderBy(item => item.BoundarySlots)
.ThenBy(item => item.GatewaySource)
.ThenBy(item => item.Detour)
.ThenBy(item => item.Entry)
.ThenBy(item => item.SharedLanes)
.ThenBy(item => item.Focus.Length)
.ThenBy(item => string.Join(",", item.Focus), StringComparer.Ordinal)
.Take(12))
{
TestContext.Out.WriteLine(
$"subset [{string.Join(", ", result.Focus)}]: detour={result.Detour} gateway-source={result.GatewaySource} boundary-slots={result.BoundarySlots} entry={result.Entry} shared-lanes={result.SharedLanes}");
}
}
}
}

View File

@@ -0,0 +1,294 @@
using System.Text.Json;
using NUnit.Framework;
using StellaOps.ElkSharp;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Renderer.ElkSharp;
using StellaOps.Workflow.Renderer.Svg;
namespace StellaOps.Workflow.Renderer.Tests;
public partial class DocumentProcessingWorkflowRenderingTests
{
[Test]
[Category("RenderingArtifacts")]
public async Task DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings()
{
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var outputDir = Path.Combine(
Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!,
"TestResults", "workflow-renderings", DateTime.Today.ToString("yyyyMMdd"), "DocumentProcessingWorkflow");
Directory.CreateDirectory(outputDir);
using var diagnosticsCapture = ElkLayoutDiagnostics.BeginCapture();
var progressLogPath = Path.Combine(outputDir, "elksharp.progress.log");
if (File.Exists(progressLogPath))
{
File.Delete(progressLogPath);
}
var diagnosticsPath = Path.Combine(outputDir, "elksharp.refinement-diagnostics.json");
if (File.Exists(diagnosticsPath))
{
File.Delete(diagnosticsPath);
}
diagnosticsCapture.Diagnostics.ProgressLogPath = progressLogPath;
diagnosticsCapture.Diagnostics.SnapshotPath = diagnosticsPath;
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var svgRenderer = new WorkflowRenderSvgRenderer();
var svgDoc = svgRenderer.Render(layout, "DocumentProcessingWorkflow [ElkSharp]");
var svgPath = Path.Combine(outputDir, "elksharp.svg");
await File.WriteAllTextAsync(svgPath, svgDoc.Svg);
var jsonPath = Path.Combine(outputDir, "elksharp.json");
await File.WriteAllTextAsync(jsonPath, JsonSerializer.Serialize(layout, new JsonSerializerOptions { WriteIndented = true }));
await File.WriteAllTextAsync(
diagnosticsPath,
JsonSerializer.Serialize(diagnosticsCapture.Diagnostics, new JsonSerializerOptions { WriteIndented = true }));
WorkflowRenderPngExporter? pngExporter = null;
string? pngPath = null;
try
{
pngPath = Path.Combine(outputDir, "elksharp.png");
pngExporter = new WorkflowRenderPngExporter();
await pngExporter.ExportAsync(svgDoc, pngPath, scale: 2f);
TestContext.Out.WriteLine($"PNG generated at: {pngPath}");
}
catch (Exception ex)
{
TestContext.Out.WriteLine($"PNG export failed (non-fatal): {ex.Message}");
TestContext.Out.WriteLine($"SVG available at: {svgPath}");
}
TestContext.Out.WriteLine($"SVG: {svgPath}");
TestContext.Out.WriteLine($"JSON: {jsonPath}");
TestContext.Out.WriteLine($"Diagnostics: {diagnosticsPath}");
TestContext.Out.WriteLine($"Progress log: {progressLogPath}");
// Render every iteration of every strategy as SVG only
var variantsDir = Path.Combine(outputDir, "strategy-variants");
Directory.CreateDirectory(variantsDir);
foreach (var stratDiag in diagnosticsCapture.Diagnostics.IterativeStrategies)
{
foreach (var attemptDiag in stratDiag.AttemptDetails)
{
if (attemptDiag.Edges is null)
{
continue;
}
var attemptLayout = BuildVariantLayout(layout, attemptDiag.Edges);
var sc = attemptDiag.Score;
var attemptLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} att{attemptDiag.Attempt} [{attemptDiag.Outcome}] " +
$"nc={sc.NodeCrossings} ec={sc.EdgeCrossings} bends={sc.BendCount} diag={sc.DiagonalCount} " +
$"bg={sc.BelowGraphViolations} un={sc.UnderNodeViolations} ld={sc.LongDiagonalViolations} " +
$"ea={sc.EntryAngleViolations} lbl={sc.LabelProximityViolations} tj={sc.TargetApproachJoinViolations} " +
$"sl={sc.SharedLaneViolations} bs={sc.BoundarySlotViolations} " +
$"tb={sc.TargetApproachBacktrackingViolations} det={sc.ExcessiveDetourViolations} score={sc.Value:F0}";
var attemptSvg = svgRenderer.Render(attemptLayout, attemptLabel);
await File.WriteAllTextAsync(
Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-att{attemptDiag.Attempt:D2}.svg"),
attemptSvg.Svg);
}
if (stratDiag.BestEdges is not null)
{
var bestLayout = BuildVariantLayout(layout, stratDiag.BestEdges);
var bestSc = stratDiag.BestScore;
var bestLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} BEST [{stratDiag.Outcome}] " +
$"nc={bestSc?.NodeCrossings} ec={bestSc?.EdgeCrossings} bends={bestSc?.BendCount} diag={bestSc?.DiagonalCount} " +
$"bg={bestSc?.BelowGraphViolations} un={bestSc?.UnderNodeViolations} ld={bestSc?.LongDiagonalViolations} " +
$"ea={bestSc?.EntryAngleViolations} lbl={bestSc?.LabelProximityViolations} tj={bestSc?.TargetApproachJoinViolations} " +
$"sl={bestSc?.SharedLaneViolations} bs={bestSc?.BoundarySlotViolations} " +
$"tb={bestSc?.TargetApproachBacktrackingViolations} det={bestSc?.ExcessiveDetourViolations} score={bestSc?.Value:F0}";
var bestSvg = svgRenderer.Render(bestLayout, bestLabel);
await File.WriteAllTextAsync(
Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-BEST.svg"),
bestSvg.Svg);
}
}
TestContext.Out.WriteLine($"Strategy variants: {variantsDir}");
var localRepairAttempts = diagnosticsCapture.Diagnostics.IterativeStrategies
.SelectMany(strategy => strategy.AttemptDetails)
.Where(attempt => attempt.Attempt > 1
&& string.Equals(attempt.RouteDiagnostics?.Mode, "local-repair", StringComparison.Ordinal))
.ToArray();
Assert.That(localRepairAttempts, Is.Not.Empty, "Expected later attempts to use targeted local repair.");
Assert.That(
localRepairAttempts.All(attempt => attempt.RouteDiagnostics!.RoutedEdges < attempt.RouteDiagnostics.TotalEdges),
Is.True,
"Local repair attempts must reroute only the penalized subset of edges.");
Assert.That(diagnosticsCapture.Diagnostics.FinalBrokenShortHighwayCount, Is.EqualTo(0), "Final selected layout must not keep broken short highways.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.RepeatCollectorCorridorViolations, Is.EqualTo(0), "Repeat collector outer lanes must remain separated.");
var boundaryAngleOffenders = layout.Edges
.SelectMany(edge => GetBoundaryAngleViolations(edge, layout.Nodes))
.ToArray();
TestContext.Out.WriteLine($"Boundary angle offenders: {(boundaryAngleOffenders.Length == 0 ? "<none>" : string.Join(", ", boundaryAngleOffenders))}");
var targetJoinOffenders = GetTargetApproachJoinOffenders(layout.Edges, layout.Nodes).ToArray();
TestContext.Out.WriteLine($"Target join offenders: {(targetJoinOffenders.Length == 0 ? "<none>" : string.Join(", ", targetJoinOffenders))}");
var elkNodes = layout.Nodes.Select(node => new ElkPositionedNode
{
Id = node.Id,
Label = node.Label,
Kind = node.Kind,
X = node.X,
Y = node.Y,
Width = node.Width,
Height = node.Height,
}).ToArray();
var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge
{
Id = edge.Id,
SourceNodeId = edge.SourceNodeId,
TargetNodeId = edge.TargetNodeId,
Kind = edge.Kind,
Label = edge.Label,
Sections = edge.Sections.Select(section => new ElkEdgeSection
{
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
}).ToArray(),
}).ToArray();
var sharedLaneOffenders = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
.Select(conflict => $"{conflict.LeftEdgeId}+{conflict.RightEdgeId}")
.Distinct(StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"Shared lane offenders: {(sharedLaneOffenders.Length == 0 ? "<none>" : string.Join(", ", sharedLaneOffenders))}");
var belowGraphOffenders = GetBelowGraphOffenders(layout.Edges, layout.Nodes).ToArray();
TestContext.Out.WriteLine($"Below-graph offenders: {(belowGraphOffenders.Length == 0 ? "<none>" : string.Join(", ", belowGraphOffenders))}");
var underNodeOffenders = GetUnderNodeOffenders(layout.Edges, layout.Nodes).ToArray();
TestContext.Out.WriteLine($"Under-node offenders: {(underNodeOffenders.Length == 0 ? "<none>" : string.Join(", ", underNodeOffenders))}");
var longDiagonalOffenders = GetLongDiagonalOffenders(layout.Edges, layout.Nodes).ToArray();
TestContext.Out.WriteLine($"Long-diagonal offenders: {(longDiagonalOffenders.Length == 0 ? "<none>" : string.Join(", ", longDiagonalOffenders))}");
var gatewaySourceScoringOffenders = layout.Edges
.Where(edge => HasGatewaySourceScoringIssue(edge, layout.Nodes))
.Select(edge => edge.Id)
.ToArray();
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.EntryAngleViolations, Is.EqualTo(0), "Selected layout must satisfy the node-side entry/exit angle rule.");
Assert.That(targetJoinOffenders, Is.Empty, "Selected layout must not leave visually collapsed target-side approach joins.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachJoinViolations, Is.EqualTo(0), "Selected layout must not keep disallowed target-side joins.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.SharedLaneViolations, Is.EqualTo(0), "Selected layout must not keep same-lane occupancy outside explicit corridor/highway exceptions.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.BoundarySlotViolations, Is.EqualTo(0), "Selected layout must not concentrate more than one edge onto the same discrete side slot or leave side endpoints off the evenly spread slot lattice.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.BelowGraphViolations, Is.EqualTo(0), "Selected layout must not route any lane below the node field.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.UnderNodeViolations, Is.EqualTo(0), "Selected layout must not keep horizontal lanes tucked underneath other nodes.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.LongDiagonalViolations, Is.EqualTo(0), "Selected layout must not keep overlong 45-degree segments.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachBacktrackingViolations, Is.EqualTo(0), "Selected layout must not overshoot a target side and curl back near the final approach.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.ExcessiveDetourViolations, Is.EqualTo(0), "Selected layout must not keep shortest-path violations after the retry budget is exhausted.");
var gatewayCornerDiagonalCount = layout.Edges.Count(edge =>
HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true)
|| HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false));
Assert.That(gatewayCornerDiagonalCount, Is.EqualTo(0), "Gateway diagonal stubs may land on side faces, not on gateway corner vertices.");
var gatewayInteriorAdjacentCount = layout.Edges.Count(edge =>
HasGatewayInteriorAdjacentPoint(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true)
|| HasGatewayInteriorAdjacentPoint(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false));
Assert.That(gatewayInteriorAdjacentCount, Is.EqualTo(0), "Gateway joins must use an exterior face-approach point instead of gluing the boundary back to an interior rectangular anchor.");
var gatewaySourceCurlCount = layout.Edges.Count(edge =>
HasGatewaySourceExitCurl(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)));
var gatewaySourceCurlOffenders = layout.Edges
.Where(edge => HasGatewaySourceExitCurl(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)))
.Select(edge => edge.Id)
.ToArray();
Assert.That(gatewaySourceCurlCount, Is.EqualTo(0), "Gateway source exits must leave from the downstream-facing side without curling away and back.");
var gatewaySourceFaceMismatchCount = layout.Edges.Count(edge =>
HasGatewaySourcePreferredFaceMismatch(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes));
var gatewaySourceFaceMismatchOffenders = layout.Edges
.Where(edge => HasGatewaySourcePreferredFaceMismatch(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes))
.Select(edge => edge.Id)
.ToArray();
Assert.That(gatewaySourceFaceMismatchCount, Is.EqualTo(0), "Gateway source exits must leave from the dominant downstream-facing face instead of drifting onto an upper or lower face.");
var gatewaySourceDetourCount = layout.Edges.Count(edge =>
HasGatewaySourceDominantAxisDetour(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes));
var gatewaySourceDetourOffenders = layout.Edges
.Where(edge => HasGatewaySourceDominantAxisDetour(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes))
.Select(edge => edge.Id)
.ToArray();
Assert.That(gatewaySourceDetourCount, Is.EqualTo(0), "Gateway source exits must not leave on the non-dominant axis when a direct dominant-axis exit is available.");
var gatewaySourceVertexExitCount = layout.Edges.Count(edge =>
HasGatewaySourceVertexExit(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)));
var gatewaySourceVertexExitOffenders = layout.Edges
.Where(edge => HasGatewaySourceVertexExit(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)))
.Select(edge => edge.Id)
.ToArray();
Assert.That(gatewaySourceVertexExitCount, Is.EqualTo(0), "Gateway source exits must leave from a face interior, not from a gateway tip/corner.");
Assert.That(gatewaySourceScoringOffenders, Is.Empty, "Gateway source exits must not leave a shorter clean downstream-facing repair opportunity unused.");
var loadConfigurationNode = layout.Nodes.Single(node => node.Id == "start/3");
var processBatchLoops = layout.Edges
.Where(edge => edge.TargetNodeId == "start/2/branch-1/1"
&& edge.Label.StartsWith("repeat while", StringComparison.Ordinal))
.ToArray();
Assert.That(
processBatchLoops.All(edge => !HasNearNodeClearanceViolation(edge, loadConfigurationNode, 40d)),
Is.True,
"Repeat-return lanes into Process Batch must stay outside the Load Configuration clearance band.");
static WorkflowRenderLayoutResult BuildVariantLayout(WorkflowRenderLayoutResult baseLayout, ElkRoutedEdge[] edges)
{
return new WorkflowRenderLayoutResult
{
GraphId = baseLayout.GraphId,
Nodes = baseLayout.Nodes,
Edges = edges.Select(e => new WorkflowRenderRoutedEdge
{
Id = e.Id,
SourceNodeId = e.SourceNodeId,
TargetNodeId = e.TargetNodeId,
Kind = e.Kind,
Label = e.Label,
Sections = e.Sections.Select(s => new WorkflowRenderEdgeSection
{
StartPoint = new WorkflowRenderPoint { X = s.StartPoint.X, Y = s.StartPoint.Y },
EndPoint = new WorkflowRenderPoint { X = s.EndPoint.X, Y = s.EndPoint.Y },
BendPoints = s.BendPoints.Select(p => new WorkflowRenderPoint { X = p.X, Y = p.Y }).ToArray(),
}).ToArray(),
}).ToArray(),
};
}
// Verify zero edge-node crossings
var crossings = 0;
foreach (var node in layout.Nodes)
{
foreach (var edge in layout.Edges)
{
if (edge.SourceNodeId == node.Id || edge.TargetNodeId == node.Id) continue;
foreach (var section in edge.Sections)
{
var pts = new List<WorkflowRenderPoint> { section.StartPoint };
pts.AddRange(section.BendPoints);
pts.Add(section.EndPoint);
for (var i = 0; i < pts.Count - 1; i++)
{
var p1 = pts[i];
var p2 = pts[i + 1];
if (Math.Abs(p1.Y - p2.Y) < 2 && p1.Y > node.Y && p1.Y < node.Y + node.Height)
{
if (Math.Max(p1.X, p2.X) > node.X && Math.Min(p1.X, p2.X) < node.X + node.Width)
crossings++;
}
else if (Math.Abs(p1.X - p2.X) < 2 && p1.X > node.X && p1.X < node.X + node.Width)
{
if (Math.Max(p1.Y, p2.Y) > node.Y && Math.Min(p1.Y, p2.Y) < node.Y + node.Height)
crossings++;
}
}
}
}
}
TestContext.Out.WriteLine($"Edge-node crossings: {crossings}");
Assert.That(crossings, Is.EqualTo(0), "No edges should cross through node shapes");
}
}

View File

@@ -283,690 +283,4 @@ public partial class DocumentProcessingWorkflowRenderingTests
Is.EqualTo(0),
$"Selected layout must keep decision source exits on the discrete boundary-slot lattice after winner refinement. Offenders: {string.Join(", ", severityByEdgeId.OrderBy(entry => entry.Key, StringComparer.Ordinal).Select(entry => entry.Key))}");
}
[Test]
[Category("RenderingArtifacts")]
public async Task DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings()
{
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var outputDir = Path.Combine(
Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!,
"TestResults", "workflow-renderings", DateTime.Today.ToString("yyyyMMdd"), "DocumentProcessingWorkflow");
Directory.CreateDirectory(outputDir);
using var diagnosticsCapture = ElkLayoutDiagnostics.BeginCapture();
var progressLogPath = Path.Combine(outputDir, "elksharp.progress.log");
if (File.Exists(progressLogPath))
{
File.Delete(progressLogPath);
}
var diagnosticsPath = Path.Combine(outputDir, "elksharp.refinement-diagnostics.json");
if (File.Exists(diagnosticsPath))
{
File.Delete(diagnosticsPath);
}
diagnosticsCapture.Diagnostics.ProgressLogPath = progressLogPath;
diagnosticsCapture.Diagnostics.SnapshotPath = diagnosticsPath;
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var svgRenderer = new WorkflowRenderSvgRenderer();
var svgDoc = svgRenderer.Render(layout, "DocumentProcessingWorkflow [ElkSharp]");
var svgPath = Path.Combine(outputDir, "elksharp.svg");
await File.WriteAllTextAsync(svgPath, svgDoc.Svg);
var jsonPath = Path.Combine(outputDir, "elksharp.json");
await File.WriteAllTextAsync(jsonPath, JsonSerializer.Serialize(layout, new JsonSerializerOptions { WriteIndented = true }));
await File.WriteAllTextAsync(
diagnosticsPath,
JsonSerializer.Serialize(diagnosticsCapture.Diagnostics, new JsonSerializerOptions { WriteIndented = true }));
WorkflowRenderPngExporter? pngExporter = null;
string? pngPath = null;
try
{
pngPath = Path.Combine(outputDir, "elksharp.png");
pngExporter = new WorkflowRenderPngExporter();
await pngExporter.ExportAsync(svgDoc, pngPath, scale: 2f);
TestContext.Out.WriteLine($"PNG generated at: {pngPath}");
}
catch (Exception ex)
{
TestContext.Out.WriteLine($"PNG export failed (non-fatal): {ex.Message}");
TestContext.Out.WriteLine($"SVG available at: {svgPath}");
}
TestContext.Out.WriteLine($"SVG: {svgPath}");
TestContext.Out.WriteLine($"JSON: {jsonPath}");
TestContext.Out.WriteLine($"Diagnostics: {diagnosticsPath}");
TestContext.Out.WriteLine($"Progress log: {progressLogPath}");
// Render every iteration of every strategy as SVG only
var variantsDir = Path.Combine(outputDir, "strategy-variants");
Directory.CreateDirectory(variantsDir);
foreach (var stratDiag in diagnosticsCapture.Diagnostics.IterativeStrategies)
{
foreach (var attemptDiag in stratDiag.AttemptDetails)
{
if (attemptDiag.Edges is null)
{
continue;
}
var attemptLayout = BuildVariantLayout(layout, attemptDiag.Edges);
var sc = attemptDiag.Score;
var attemptLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} att{attemptDiag.Attempt} [{attemptDiag.Outcome}] " +
$"nc={sc.NodeCrossings} ec={sc.EdgeCrossings} bends={sc.BendCount} diag={sc.DiagonalCount} " +
$"bg={sc.BelowGraphViolations} un={sc.UnderNodeViolations} ld={sc.LongDiagonalViolations} " +
$"ea={sc.EntryAngleViolations} lbl={sc.LabelProximityViolations} tj={sc.TargetApproachJoinViolations} " +
$"sl={sc.SharedLaneViolations} bs={sc.BoundarySlotViolations} " +
$"tb={sc.TargetApproachBacktrackingViolations} det={sc.ExcessiveDetourViolations} score={sc.Value:F0}";
var attemptSvg = svgRenderer.Render(attemptLayout, attemptLabel);
await File.WriteAllTextAsync(
Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-att{attemptDiag.Attempt:D2}.svg"),
attemptSvg.Svg);
}
if (stratDiag.BestEdges is not null)
{
var bestLayout = BuildVariantLayout(layout, stratDiag.BestEdges);
var bestSc = stratDiag.BestScore;
var bestLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} BEST [{stratDiag.Outcome}] " +
$"nc={bestSc?.NodeCrossings} ec={bestSc?.EdgeCrossings} bends={bestSc?.BendCount} diag={bestSc?.DiagonalCount} " +
$"bg={bestSc?.BelowGraphViolations} un={bestSc?.UnderNodeViolations} ld={bestSc?.LongDiagonalViolations} " +
$"ea={bestSc?.EntryAngleViolations} lbl={bestSc?.LabelProximityViolations} tj={bestSc?.TargetApproachJoinViolations} " +
$"sl={bestSc?.SharedLaneViolations} bs={bestSc?.BoundarySlotViolations} " +
$"tb={bestSc?.TargetApproachBacktrackingViolations} det={bestSc?.ExcessiveDetourViolations} score={bestSc?.Value:F0}";
var bestSvg = svgRenderer.Render(bestLayout, bestLabel);
await File.WriteAllTextAsync(
Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-BEST.svg"),
bestSvg.Svg);
}
}
TestContext.Out.WriteLine($"Strategy variants: {variantsDir}");
var localRepairAttempts = diagnosticsCapture.Diagnostics.IterativeStrategies
.SelectMany(strategy => strategy.AttemptDetails)
.Where(attempt => attempt.Attempt > 1
&& string.Equals(attempt.RouteDiagnostics?.Mode, "local-repair", StringComparison.Ordinal))
.ToArray();
Assert.That(localRepairAttempts, Is.Not.Empty, "Expected later attempts to use targeted local repair.");
Assert.That(
localRepairAttempts.All(attempt => attempt.RouteDiagnostics!.RoutedEdges < attempt.RouteDiagnostics.TotalEdges),
Is.True,
"Local repair attempts must reroute only the penalized subset of edges.");
Assert.That(diagnosticsCapture.Diagnostics.FinalBrokenShortHighwayCount, Is.EqualTo(0), "Final selected layout must not keep broken short highways.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.RepeatCollectorCorridorViolations, Is.EqualTo(0), "Repeat collector outer lanes must remain separated.");
var boundaryAngleOffenders = layout.Edges
.SelectMany(edge => GetBoundaryAngleViolations(edge, layout.Nodes))
.ToArray();
TestContext.Out.WriteLine($"Boundary angle offenders: {(boundaryAngleOffenders.Length == 0 ? "<none>" : string.Join(", ", boundaryAngleOffenders))}");
var targetJoinOffenders = GetTargetApproachJoinOffenders(layout.Edges, layout.Nodes).ToArray();
TestContext.Out.WriteLine($"Target join offenders: {(targetJoinOffenders.Length == 0 ? "<none>" : string.Join(", ", targetJoinOffenders))}");
var elkNodes = layout.Nodes.Select(node => new ElkPositionedNode
{
Id = node.Id,
Label = node.Label,
Kind = node.Kind,
X = node.X,
Y = node.Y,
Width = node.Width,
Height = node.Height,
}).ToArray();
var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge
{
Id = edge.Id,
SourceNodeId = edge.SourceNodeId,
TargetNodeId = edge.TargetNodeId,
Kind = edge.Kind,
Label = edge.Label,
Sections = edge.Sections.Select(section => new ElkEdgeSection
{
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
}).ToArray(),
}).ToArray();
var sharedLaneOffenders = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
.Select(conflict => $"{conflict.LeftEdgeId}+{conflict.RightEdgeId}")
.Distinct(StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"Shared lane offenders: {(sharedLaneOffenders.Length == 0 ? "<none>" : string.Join(", ", sharedLaneOffenders))}");
var belowGraphOffenders = GetBelowGraphOffenders(layout.Edges, layout.Nodes).ToArray();
TestContext.Out.WriteLine($"Below-graph offenders: {(belowGraphOffenders.Length == 0 ? "<none>" : string.Join(", ", belowGraphOffenders))}");
var underNodeOffenders = GetUnderNodeOffenders(layout.Edges, layout.Nodes).ToArray();
TestContext.Out.WriteLine($"Under-node offenders: {(underNodeOffenders.Length == 0 ? "<none>" : string.Join(", ", underNodeOffenders))}");
var longDiagonalOffenders = GetLongDiagonalOffenders(layout.Edges, layout.Nodes).ToArray();
TestContext.Out.WriteLine($"Long-diagonal offenders: {(longDiagonalOffenders.Length == 0 ? "<none>" : string.Join(", ", longDiagonalOffenders))}");
var gatewaySourceScoringOffenders = layout.Edges
.Where(edge => HasGatewaySourceScoringIssue(edge, layout.Nodes))
.Select(edge => edge.Id)
.ToArray();
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.EntryAngleViolations, Is.EqualTo(0), "Selected layout must satisfy the node-side entry/exit angle rule.");
Assert.That(targetJoinOffenders, Is.Empty, "Selected layout must not leave visually collapsed target-side approach joins.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachJoinViolations, Is.EqualTo(0), "Selected layout must not keep disallowed target-side joins.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.SharedLaneViolations, Is.EqualTo(0), "Selected layout must not keep same-lane occupancy outside explicit corridor/highway exceptions.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.BoundarySlotViolations, Is.EqualTo(0), "Selected layout must not concentrate more than one edge onto the same discrete side slot or leave side endpoints off the evenly spread slot lattice.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.BelowGraphViolations, Is.EqualTo(0), "Selected layout must not route any lane below the node field.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.UnderNodeViolations, Is.EqualTo(0), "Selected layout must not keep horizontal lanes tucked underneath other nodes.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.LongDiagonalViolations, Is.EqualTo(0), "Selected layout must not keep overlong 45-degree segments.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachBacktrackingViolations, Is.EqualTo(0), "Selected layout must not overshoot a target side and curl back near the final approach.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.ExcessiveDetourViolations, Is.EqualTo(0), "Selected layout must not keep shortest-path violations after the retry budget is exhausted.");
var gatewayCornerDiagonalCount = layout.Edges.Count(edge =>
HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true)
|| HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false));
Assert.That(gatewayCornerDiagonalCount, Is.EqualTo(0), "Gateway diagonal stubs may land on side faces, not on gateway corner vertices.");
var gatewayInteriorAdjacentCount = layout.Edges.Count(edge =>
HasGatewayInteriorAdjacentPoint(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true)
|| HasGatewayInteriorAdjacentPoint(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false));
Assert.That(gatewayInteriorAdjacentCount, Is.EqualTo(0), "Gateway joins must use an exterior face-approach point instead of gluing the boundary back to an interior rectangular anchor.");
var gatewaySourceCurlCount = layout.Edges.Count(edge =>
HasGatewaySourceExitCurl(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)));
var gatewaySourceCurlOffenders = layout.Edges
.Where(edge => HasGatewaySourceExitCurl(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)))
.Select(edge => edge.Id)
.ToArray();
Assert.That(gatewaySourceCurlCount, Is.EqualTo(0), "Gateway source exits must leave from the downstream-facing side without curling away and back.");
var gatewaySourceFaceMismatchCount = layout.Edges.Count(edge =>
HasGatewaySourcePreferredFaceMismatch(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes));
var gatewaySourceFaceMismatchOffenders = layout.Edges
.Where(edge => HasGatewaySourcePreferredFaceMismatch(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes))
.Select(edge => edge.Id)
.ToArray();
Assert.That(gatewaySourceFaceMismatchCount, Is.EqualTo(0), "Gateway source exits must leave from the dominant downstream-facing face instead of drifting onto an upper or lower face.");
var gatewaySourceDetourCount = layout.Edges.Count(edge =>
HasGatewaySourceDominantAxisDetour(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes));
var gatewaySourceDetourOffenders = layout.Edges
.Where(edge => HasGatewaySourceDominantAxisDetour(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes))
.Select(edge => edge.Id)
.ToArray();
Assert.That(gatewaySourceDetourCount, Is.EqualTo(0), "Gateway source exits must not leave on the non-dominant axis when a direct dominant-axis exit is available.");
var gatewaySourceVertexExitCount = layout.Edges.Count(edge =>
HasGatewaySourceVertexExit(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)));
var gatewaySourceVertexExitOffenders = layout.Edges
.Where(edge => HasGatewaySourceVertexExit(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)))
.Select(edge => edge.Id)
.ToArray();
Assert.That(gatewaySourceVertexExitCount, Is.EqualTo(0), "Gateway source exits must leave from a face interior, not from a gateway tip/corner.");
Assert.That(gatewaySourceScoringOffenders, Is.Empty, "Gateway source exits must not leave a shorter clean downstream-facing repair opportunity unused.");
var loadConfigurationNode = layout.Nodes.Single(node => node.Id == "start/3");
var processBatchLoops = layout.Edges
.Where(edge => edge.TargetNodeId == "start/2/branch-1/1"
&& edge.Label.StartsWith("repeat while", StringComparison.Ordinal))
.ToArray();
Assert.That(
processBatchLoops.All(edge => !HasNearNodeClearanceViolation(edge, loadConfigurationNode, 40d)),
Is.True,
"Repeat-return lanes into Process Batch must stay outside the Load Configuration clearance band.");
static WorkflowRenderLayoutResult BuildVariantLayout(WorkflowRenderLayoutResult baseLayout, ElkRoutedEdge[] edges)
{
return new WorkflowRenderLayoutResult
{
GraphId = baseLayout.GraphId,
Nodes = baseLayout.Nodes,
Edges = edges.Select(e => new WorkflowRenderRoutedEdge
{
Id = e.Id,
SourceNodeId = e.SourceNodeId,
TargetNodeId = e.TargetNodeId,
Kind = e.Kind,
Label = e.Label,
Sections = e.Sections.Select(s => new WorkflowRenderEdgeSection
{
StartPoint = new WorkflowRenderPoint { X = s.StartPoint.X, Y = s.StartPoint.Y },
EndPoint = new WorkflowRenderPoint { X = s.EndPoint.X, Y = s.EndPoint.Y },
BendPoints = s.BendPoints.Select(p => new WorkflowRenderPoint { X = p.X, Y = p.Y }).ToArray(),
}).ToArray(),
}).ToArray(),
};
}
// Verify zero edge-node crossings
var crossings = 0;
foreach (var node in layout.Nodes)
{
foreach (var edge in layout.Edges)
{
if (edge.SourceNodeId == node.Id || edge.TargetNodeId == node.Id) continue;
foreach (var section in edge.Sections)
{
var pts = new List<WorkflowRenderPoint> { section.StartPoint };
pts.AddRange(section.BendPoints);
pts.Add(section.EndPoint);
for (var i = 0; i < pts.Count - 1; i++)
{
var p1 = pts[i];
var p2 = pts[i + 1];
if (Math.Abs(p1.Y - p2.Y) < 2 && p1.Y > node.Y && p1.Y < node.Y + node.Height)
{
if (Math.Max(p1.X, p2.X) > node.X && Math.Min(p1.X, p2.X) < node.X + node.Width)
crossings++;
}
else if (Math.Abs(p1.X - p2.X) < 2 && p1.X > node.X && p1.X < node.X + node.Width)
{
if (Math.Max(p1.Y, p2.Y) > node.Y && Math.Min(p1.Y, p2.Y) < node.Y + node.Height)
crossings++;
}
}
}
}
}
TestContext.Out.WriteLine($"Edge-node crossings: {crossings}");
Assert.That(crossings, Is.EqualTo(0), "No edges should cross through node shapes");
}
[Test]
public void DocumentProcessingWorkflow_WhenInspectingLatestElkSharpArtifact_ShouldReportBoundarySlotOffenders()
{
var workflowRenderingsDirectory = Path.Combine(
Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!,
"TestResults",
"workflow-renderings");
var outputDir = Directory.GetDirectories(workflowRenderingsDirectory)
.OrderByDescending(path => Path.GetFileName(path), StringComparer.Ordinal)
.Select(path => Path.Combine(path, "DocumentProcessingWorkflow"))
.First(Directory.Exists);
var jsonPath = Path.Combine(outputDir, "elksharp.json");
Assert.That(File.Exists(jsonPath), Is.True);
var layout = JsonSerializer.Deserialize<WorkflowRenderLayoutResult>(
File.ReadAllText(jsonPath),
new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
Assert.That(layout, Is.Not.Null);
var elkNodes = layout!.Nodes.Select(node => new ElkPositionedNode
{
Id = node.Id,
Label = node.Label,
Kind = node.Kind,
X = node.X,
Y = node.Y,
Width = node.Width,
Height = node.Height,
}).ToArray();
var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge
{
Id = edge.Id,
SourceNodeId = edge.SourceNodeId,
TargetNodeId = edge.TargetNodeId,
Kind = edge.Kind,
Label = edge.Label,
Sections = edge.Sections.Select(section => new ElkEdgeSection
{
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
}).ToArray(),
}).ToArray();
var severityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
var count = ElkEdgeRoutingScoring.CountBoundarySlotViolations(elkEdges, elkNodes, severityByEdgeId, 1);
TestContext.Out.WriteLine($"boundary-slot artifact count: {count}");
foreach (var offender in severityByEdgeId.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
var edge = elkEdges.Single(candidate => candidate.Id == offender.Key);
TestContext.Out.WriteLine(
$"{offender.Key}: {string.Join(" -> ", ExtractElkPath(edge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
}
var serviceNodes = elkNodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
var minLineClearance = serviceNodes.Length > 0
? Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d
: 50d;
var nodesById = elkNodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
var graphMinY = elkNodes.Min(node => node.Y);
var graphMaxY = elkNodes.Max(node => node.Y + node.Height);
var (sourceSlots, targetSlots) = ElkEdgePostProcessor.ResolveCombinedBoundarySlots(
elkEdges,
nodesById,
graphMinY,
graphMaxY,
restrictedEdgeIds: null,
enforceAllNodeEndpoints: true);
if (sourceSlots.TryGetValue("edge/25", out var sourceSlot))
{
TestContext.Out.WriteLine(
$"edge/25 source-slot: side={sourceSlot.Side} boundary=({sourceSlot.Boundary.X:F3},{sourceSlot.Boundary.Y:F3})");
}
if (targetSlots.TryGetValue("edge/25", out var targetSlot))
{
TestContext.Out.WriteLine(
$"edge/25 target-slot: side={targetSlot.Side} boundary=({targetSlot.Boundary.X:F3},{targetSlot.Boundary.Y:F3})");
var edge25 = elkEdges.Single(edge => edge.Id == "edge/25");
var edge25Path = ExtractElkPath(edge25);
var buildTargetApproachCandidatePath = typeof(ElkEdgePostProcessor).GetMethod(
"BuildTargetApproachCandidatePath",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.That(buildTargetApproachCandidatePath, Is.Not.Null);
var reflectedTargetCandidate = (List<ElkPoint>)buildTargetApproachCandidatePath!.Invoke(
null,
[edge25Path, elkNodes.Single(node => node.Id == edge25.TargetNodeId), targetSlot.Side, targetSlot.Boundary, edge25Path[2].Y])!;
TestContext.Out.WriteLine(
$"edge/25 reflected-target-candidate: {string.Join(" -> ", reflectedTargetCandidate.Select(point => $"({point.X:F3},{point.Y:F3})"))}");
}
var snapped = ElkEdgePostProcessor.SnapBoundarySlotAssignments(elkEdges, elkNodes, minLineClearance);
var snappedSeverityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
var snappedCount = ElkEdgeRoutingScoring.CountBoundarySlotViolations(snapped, elkNodes, snappedSeverityByEdgeId, 1);
TestContext.Out.WriteLine($"boundary-slot snapped count: {snappedCount}");
if (snappedSeverityByEdgeId.TryGetValue("edge/25", out _))
{
var snappedEdge = snapped.Single(candidate => candidate.Id == "edge/25");
TestContext.Out.WriteLine(
$"edge/25 snapped: {string.Join(" -> ", ExtractElkPath(snappedEdge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
}
var detourSeverityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
var detourCount = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(elkEdges, elkNodes, detourSeverityByEdgeId, 1);
TestContext.Out.WriteLine($"detour artifact count: {detourCount}");
foreach (var offender in detourSeverityByEdgeId.OrderByDescending(pair => pair.Value).ThenBy(pair => pair.Key, StringComparer.Ordinal))
{
var edge = elkEdges.Single(candidate => candidate.Id == offender.Key);
TestContext.Out.WriteLine(
$"{offender.Key} detour={offender.Value}: {string.Join(" -> ", ExtractElkPath(edge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
var shortcutCandidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts([edge], elkNodes)[0];
if (!ExtractElkPath(shortcutCandidate).SequenceEqual(ExtractElkPath(edge), ElkPointComparer.Instance))
{
var shortcutDetourSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
var shortcutGatewaySeverity = new Dictionary<string, int>(StringComparer.Ordinal);
var shortcutDetourCount = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(
[shortcutCandidate],
elkNodes,
shortcutDetourSeverity,
1);
var shortcutGatewayCount = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(
[shortcutCandidate],
elkNodes,
shortcutGatewaySeverity,
1);
TestContext.Out.WriteLine(
$" shortcut -> detour={shortcutDetourCount} gateway-source={shortcutGatewayCount}: {string.Join(" -> ", ExtractElkPath(shortcutCandidate).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
}
else
{
TestContext.Out.WriteLine(" shortcut -> unchanged");
}
}
var gatewaySourceSeverityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
var gatewaySourceCount = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(elkEdges, elkNodes, gatewaySourceSeverityByEdgeId, 1);
TestContext.Out.WriteLine($"gateway-source artifact count: {gatewaySourceCount}");
foreach (var offender in gatewaySourceSeverityByEdgeId.OrderByDescending(pair => pair.Value).ThenBy(pair => pair.Key, StringComparer.Ordinal))
{
var edge = elkEdges.Single(candidate => candidate.Id == offender.Key);
TestContext.Out.WriteLine(
$"{offender.Key} gateway-source={offender.Value}: {string.Join(" -> ", ExtractElkPath(edge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
}
var sharedLaneConflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
.Distinct()
.OrderBy(conflict => conflict.LeftEdgeId, StringComparer.Ordinal)
.ThenBy(conflict => conflict.RightEdgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"shared-lane artifact count: {sharedLaneConflicts.Length}");
foreach (var conflict in sharedLaneConflicts)
{
TestContext.Out.WriteLine($"shared-lane artifact pair: {conflict.LeftEdgeId}+{conflict.RightEdgeId}");
}
var layoutNodesById = layout.Nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
var gatewayCornerDiagonalOffenders = layout.Edges
.Where(edge =>
HasGatewayCornerDiagonal(edge, layoutNodesById[edge.SourceNodeId], fromSource: true)
|| HasGatewayCornerDiagonal(edge, layoutNodesById[edge.TargetNodeId], fromSource: false))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-corner artifact count: {gatewayCornerDiagonalOffenders.Length}");
foreach (var edgeId in gatewayCornerDiagonalOffenders)
{
TestContext.Out.WriteLine($"gateway-corner artifact edge: {edgeId}");
}
var gatewayInteriorAdjacentOffenders = layout.Edges
.Where(edge =>
HasGatewayInteriorAdjacentPoint(edge, layoutNodesById[edge.SourceNodeId], fromSource: true)
|| HasGatewayInteriorAdjacentPoint(edge, layoutNodesById[edge.TargetNodeId], fromSource: false))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-interior-adjacent artifact count: {gatewayInteriorAdjacentOffenders.Length}");
foreach (var edgeId in gatewayInteriorAdjacentOffenders)
{
TestContext.Out.WriteLine($"gateway-interior-adjacent artifact edge: {edgeId}");
}
var gatewaySourceCurlOffenders = layout.Edges
.Where(edge => HasGatewaySourceExitCurl(edge, layoutNodesById[edge.SourceNodeId]))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-source-curl artifact count: {gatewaySourceCurlOffenders.Length}");
foreach (var edgeId in gatewaySourceCurlOffenders)
{
TestContext.Out.WriteLine($"gateway-source-curl artifact edge: {edgeId}");
}
var gatewaySourceFaceMismatchOffenders = layout.Edges
.Where(edge => HasGatewaySourcePreferredFaceMismatch(edge, layoutNodesById[edge.SourceNodeId], layout.Nodes))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-source-face-mismatch artifact count: {gatewaySourceFaceMismatchOffenders.Length}");
foreach (var edgeId in gatewaySourceFaceMismatchOffenders)
{
TestContext.Out.WriteLine($"gateway-source-face-mismatch artifact edge: {edgeId}");
}
var gatewaySourceDetourOffenders = layout.Edges
.Where(edge => HasGatewaySourceDominantAxisDetour(edge, layoutNodesById[edge.SourceNodeId], layout.Nodes))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-source-detour artifact count: {gatewaySourceDetourOffenders.Length}");
foreach (var edgeId in gatewaySourceDetourOffenders)
{
TestContext.Out.WriteLine($"gateway-source-detour artifact edge: {edgeId}");
}
var gatewaySourceVertexExitOffenders = layout.Edges
.Where(edge => HasGatewaySourceVertexExit(edge, layoutNodesById[edge.SourceNodeId]))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-source-vertex-exit artifact count: {gatewaySourceVertexExitOffenders.Length}");
foreach (var edgeId in gatewaySourceVertexExitOffenders)
{
TestContext.Out.WriteLine($"gateway-source-vertex-exit artifact edge: {edgeId}");
}
var gatewaySourceScoringOffenders = layout.Edges
.Where(edge => HasGatewaySourceScoringIssue(edge, layout.Nodes))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-source-scoring artifact count: {gatewaySourceScoringOffenders.Length}");
foreach (var edgeId in gatewaySourceScoringOffenders)
{
TestContext.Out.WriteLine($"gateway-source-scoring artifact edge: {edgeId}");
}
var loadConfigurationNode = layout.Nodes.Single(node => node.Id == "start/3");
var processBatchLoopOffenders = layout.Edges
.Where(edge => edge.TargetNodeId == "start/2/branch-1/1"
&& edge.Label.StartsWith("repeat while", StringComparison.Ordinal)
&& HasNearNodeClearanceViolation(edge, loadConfigurationNode, 40d))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"process-batch-clearance artifact count: {processBatchLoopOffenders.Length}");
foreach (var edgeId in processBatchLoopOffenders)
{
TestContext.Out.WriteLine($"process-batch-clearance artifact edge: {edgeId}");
}
var crossings = 0;
foreach (var node in layout.Nodes)
{
foreach (var edge in layout.Edges)
{
if (edge.SourceNodeId == node.Id || edge.TargetNodeId == node.Id) continue;
foreach (var section in edge.Sections)
{
var points = new List<WorkflowRenderPoint> { section.StartPoint };
points.AddRange(section.BendPoints);
points.Add(section.EndPoint);
for (var i = 0; i < points.Count - 1; i++)
{
var start = points[i];
var end = points[i + 1];
if (Math.Abs(start.Y - end.Y) < 2d && start.Y > node.Y && start.Y < node.Y + node.Height)
{
if (Math.Max(start.X, end.X) > node.X && Math.Min(start.X, end.X) < node.X + node.Width)
{
crossings++;
}
}
else if (Math.Abs(start.X - end.X) < 2d && start.X > node.X && start.X < node.X + node.Width)
{
if (Math.Max(start.Y, end.Y) > node.Y && Math.Min(start.Y, end.Y) < node.Y + node.Height)
{
crossings++;
}
}
}
}
}
}
TestContext.Out.WriteLine($"edge-node-crossing artifact count: {crossings}");
var focusedSharedLaneCandidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts(
elkEdges,
elkNodes,
minLineClearance,
["edge/15", "edge/17", "edge/35"]);
TestContext.Out.WriteLine(
$"focused shared-lane candidate counts: detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedSharedLaneCandidate, elkNodes)} gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedSharedLaneCandidate, elkNodes)} boundary-slots={ElkEdgeRoutingScoring.CountBoundarySlotViolations(focusedSharedLaneCandidate, elkNodes)} entry={ElkEdgeRoutingScoring.CountBadEntryAngles(focusedSharedLaneCandidate, elkNodes)} shared-lanes={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedSharedLaneCandidate, elkNodes)}");
foreach (var edgeId in new[] { "edge/15", "edge/17", "edge/35" })
{
var currentEdge = elkEdges.Single(edge => edge.Id == edgeId);
var candidateEdge = focusedSharedLaneCandidate.Single(edge => edge.Id == edgeId);
TestContext.Out.WriteLine(
$"focused shared-lane {edgeId} current: {string.Join(" -> ", ExtractElkPath(currentEdge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
TestContext.Out.WriteLine(
$"focused shared-lane {edgeId} candidate: {string.Join(" -> ", ExtractElkPath(candidateEdge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
}
var combinedFocus = detourSeverityByEdgeId.Keys
.Concat(gatewaySourceSeverityByEdgeId.Keys)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
if (combinedFocus.Length > 0)
{
var batchCandidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(elkEdges, elkNodes, combinedFocus);
batchCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(batchCandidate, elkNodes, minLineClearance, combinedFocus);
batchCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(batchCandidate, elkNodes, minLineClearance, combinedFocus);
batchCandidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(batchCandidate, elkNodes, minLineClearance, combinedFocus);
batchCandidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(batchCandidate, elkNodes, minLineClearance, combinedFocus);
batchCandidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(batchCandidate, elkNodes, combinedFocus);
batchCandidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(batchCandidate, elkNodes, combinedFocus);
batchCandidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(batchCandidate, elkNodes, minLineClearance, combinedFocus);
batchCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
batchCandidate,
elkNodes,
minLineClearance,
combinedFocus,
enforceAllNodeEndpoints: true);
var batchDetourCount = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(batchCandidate, elkNodes);
var batchGatewaySourceCount = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(batchCandidate, elkNodes);
var batchBoundarySlotCount = ElkEdgeRoutingScoring.CountBoundarySlotViolations(batchCandidate, elkNodes);
var batchEntryCount = ElkEdgeRoutingScoring.CountBadEntryAngles(batchCandidate, elkNodes);
var batchSharedLaneCount = ElkEdgeRoutingScoring.CountSharedLaneViolations(batchCandidate, elkNodes);
TestContext.Out.WriteLine(
$"batch-candidate counts: detour={batchDetourCount} gateway-source={batchGatewaySourceCount} boundary-slots={batchBoundarySlotCount} entry={batchEntryCount} shared-lanes={batchSharedLaneCount}");
foreach (var focusEdgeId in combinedFocus)
{
var focusedCandidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(elkEdges, elkNodes, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(focusedCandidate, elkNodes, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(focusedCandidate, elkNodes, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
focusedCandidate,
elkNodes,
minLineClearance,
[focusEdgeId],
enforceAllNodeEndpoints: true);
TestContext.Out.WriteLine(
$"focused {focusEdgeId}: detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedCandidate, elkNodes)} gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedCandidate, elkNodes)} boundary-slots={ElkEdgeRoutingScoring.CountBoundarySlotViolations(focusedCandidate, elkNodes)} entry={ElkEdgeRoutingScoring.CountBadEntryAngles(focusedCandidate, elkNodes)} shared-lanes={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedCandidate, elkNodes)}");
}
var subsetResults = new List<(string[] Focus, int Detour, int GatewaySource, int BoundarySlots, int Entry, int SharedLanes)>();
for (var mask = 1; mask < (1 << combinedFocus.Length); mask++)
{
var subset = combinedFocus
.Where((_, index) => (mask & (1 << index)) != 0)
.ToArray();
var subsetCandidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(elkEdges, elkNodes, subset);
subsetCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(subsetCandidate, elkNodes, minLineClearance, subset);
subsetCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(subsetCandidate, elkNodes, minLineClearance, subset);
subsetCandidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(subsetCandidate, elkNodes, minLineClearance, subset);
subsetCandidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(subsetCandidate, elkNodes, minLineClearance, subset);
subsetCandidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(subsetCandidate, elkNodes, subset);
subsetCandidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(subsetCandidate, elkNodes, subset);
subsetCandidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(subsetCandidate, elkNodes, minLineClearance, subset);
subsetCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
subsetCandidate,
elkNodes,
minLineClearance,
subset,
enforceAllNodeEndpoints: true);
subsetResults.Add((
subset,
ElkEdgeRoutingScoring.CountExcessiveDetourViolations(subsetCandidate, elkNodes),
ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(subsetCandidate, elkNodes),
ElkEdgeRoutingScoring.CountBoundarySlotViolations(subsetCandidate, elkNodes),
ElkEdgeRoutingScoring.CountBadEntryAngles(subsetCandidate, elkNodes),
ElkEdgeRoutingScoring.CountSharedLaneViolations(subsetCandidate, elkNodes)));
}
foreach (var result in subsetResults
.OrderBy(item => item.BoundarySlots)
.ThenBy(item => item.GatewaySource)
.ThenBy(item => item.Detour)
.ThenBy(item => item.Entry)
.ThenBy(item => item.SharedLanes)
.ThenBy(item => item.Focus.Length)
.ThenBy(item => string.Join(",", item.Focus), StringComparer.Ordinal)
.Take(12))
{
TestContext.Out.WriteLine(
$"subset [{string.Join(", ", result.Focus)}]: detour={result.Detour} gateway-source={result.GatewaySource} boundary-slots={result.BoundarySlots} entry={result.Entry} shared-lanes={result.SharedLanes}");
}
}
}
}

View File

@@ -185,6 +185,164 @@ public partial class ElkSharpEdgeRefinementTests
ElkEdgeRoutingScoring.CountGatewaySourceVertexExitViolations([edge], [join, target]).Should().Be(1);
}
[Test]
[Property("Intent", "Operational")]
public void GatewayBoundaryHelpers_WhenDecisionSourceLeavesFromTipOnStraightExit_ShouldRepairOffVertex()
{
var source = new ElkPositionedNode
{
Id = "decision",
Label = "Check Result",
Kind = "Decision",
X = 3406,
Y = 225.182,
Width = 188,
Height = 132,
};
var target = new ElkPositionedNode
{
Id = "task",
Label = "Set printBatched",
Kind = "SetState",
X = 3778,
Y = 239.182,
Width = 224,
Height = 104,
};
var tip = new ElkPoint
{
X = source.X + source.Width,
Y = source.Y + (source.Height / 2d),
};
var edge = new ElkRoutedEdge
{
Id = "edge/16",
SourceNodeId = source.Id,
TargetNodeId = target.Id,
Sections =
[
new ElkEdgeSection
{
StartPoint = tip,
EndPoint = new ElkPoint { X = target.X, Y = tip.Y },
BendPoints = [],
},
],
};
ElkEdgeRoutingScoring.CountGatewaySourceVertexExitViolations([edge], [source, target]).Should().Be(1);
var helper = typeof(ElkEdgePostProcessor).GetMethod(
"ForceDecisionSourceExitOffVertex",
BindingFlags.Static | BindingFlags.NonPublic)!;
var repairedPath = ((IReadOnlyList<ElkPoint>)helper.Invoke(
null,
[new List<ElkPoint> { tip, new ElkPoint { X = target.X, Y = tip.Y } }, source, new[] { source, target }, source.Id, target.Id])!)
.ToList();
var repaired = new[]
{
new ElkRoutedEdge
{
Id = edge.Id,
SourceNodeId = edge.SourceNodeId,
TargetNodeId = edge.TargetNodeId,
Sections =
[
new ElkEdgeSection
{
StartPoint = repairedPath[0],
EndPoint = repairedPath[^1],
BendPoints = repairedPath.Skip(1).Take(Math.Max(0, repairedPath.Count - 2)).ToArray(),
},
],
},
};
ElkEdgeRoutingScoring.CountGatewaySourceVertexExitViolations(repaired, [source, target]).Should().Be(0);
ElkEdgeRoutingScoring.CountBoundarySlotViolations(repaired, [source, target]).Should().Be(0);
ElkShapeBoundaries.IsNearGatewayVertex(source, repairedPath[0]).Should().BeFalse();
repairedPath[1].X.Should().BeGreaterThan(repairedPath[0].X + 3d);
}
[Test]
[Property("Intent", "Operational")]
public void GatewayBoundaryHelpers_WhenJoinSourceLeavesFromTipOnStraightExit_ShouldRepairOffVertex()
{
var source = new ElkPositionedNode
{
Id = "join",
Label = "Parallel Execution Join",
Kind = "Join",
X = 1290,
Y = 116.591,
Width = 176,
Height = 124,
};
var target = new ElkPositionedNode
{
Id = "task",
Label = "Load Configuration",
Kind = "TransportCall",
X = 1604,
Y = 134.591,
Width = 208,
Height = 88,
};
var tip = new ElkPoint
{
X = source.X + source.Width,
Y = source.Y + (source.Height / 2d),
};
var edge = new ElkRoutedEdge
{
Id = "edge/19",
SourceNodeId = source.Id,
TargetNodeId = target.Id,
Sections =
[
new ElkEdgeSection
{
StartPoint = tip,
EndPoint = new ElkPoint { X = target.X, Y = tip.Y },
BendPoints = [],
},
],
};
ElkEdgeRoutingScoring.CountGatewaySourceVertexExitViolations([edge], [source, target]).Should().Be(1);
var helper = typeof(ElkEdgePostProcessor).GetMethod(
"ForceDecisionSourceExitOffVertex",
BindingFlags.Static | BindingFlags.NonPublic)!;
var repairedPath = ((IReadOnlyList<ElkPoint>)helper.Invoke(
null,
[new List<ElkPoint> { tip, new ElkPoint { X = target.X, Y = tip.Y } }, source, new[] { source, target }, source.Id, target.Id])!)
.ToList();
var repaired = new[]
{
new ElkRoutedEdge
{
Id = edge.Id,
SourceNodeId = edge.SourceNodeId,
TargetNodeId = edge.TargetNodeId,
Sections =
[
new ElkEdgeSection
{
StartPoint = repairedPath[0],
EndPoint = repairedPath[^1],
BendPoints = repairedPath.Skip(1).Take(Math.Max(0, repairedPath.Count - 2)).ToArray(),
},
],
},
};
ElkEdgeRoutingScoring.CountGatewaySourceVertexExitViolations(repaired, [source, target]).Should().Be(0);
ElkEdgeRoutingScoring.CountBoundarySlotViolations(repaired, [source, target]).Should().Be(0);
ElkShapeBoundaries.IsNearGatewayVertex(source, repairedPath[0]).Should().BeFalse();
repairedPath[1].X.Should().BeGreaterThan(repairedPath[0].X + 3d);
}
[Test]
[Property("Intent", "Operational")]
public void GatewayBoundaryHelpers_WhenTwoEdgesArriveNearParallelIntoJoin_ShouldCountTargetJoinViolation()
@@ -1186,6 +1344,24 @@ public partial class ElkSharpEdgeRefinementTests
var composeTransactionalFinalDetourCandidate = typeof(ElkEdgeRouterIterative).GetMethod(
"ComposeTransactionalFinalDetourCandidate",
BindingFlags.Static | BindingFlags.NonPublic)!;
var expandWinningSolutionFocus = typeof(ElkEdgeRouterIterative).GetMethod(
"ExpandWinningSolutionFocus",
BindingFlags.Static | BindingFlags.NonPublic)!;
var buildFinalBoundarySlotCandidate = typeof(ElkEdgeRouterIterative).GetMethod(
"BuildFinalBoundarySlotCandidate",
BindingFlags.Static | BindingFlags.NonPublic)!;
var buildFinalRestabilizedCandidate = typeof(ElkEdgeRouterIterative).GetMethod(
"BuildFinalRestabilizedCandidate",
BindingFlags.Static | BindingFlags.NonPublic)!;
var buildRetryState = typeof(ElkEdgeRouterIterative).GetMethod(
"BuildRetryState",
BindingFlags.Static | BindingFlags.NonPublic)!;
var compareRetryStates = typeof(ElkEdgeRouterIterative).GetMethod(
"CompareRetryStates",
BindingFlags.Static | BindingFlags.NonPublic)!;
var hasHardRuleRegression = typeof(ElkEdgeRouterIterative).GetMethod(
"HasHardRuleRegression",
BindingFlags.Static | BindingFlags.NonPublic)!;
var offenders = elkEdges
.Where(edge =>
@@ -1223,22 +1399,97 @@ public partial class ElkSharpEdgeRefinementTests
var finalizedBoundary = ElkEdgeRoutingScoring.CountBadBoundaryAngles(finalized, elkNodes);
var finalizedGatewaySource = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(finalized, elkNodes);
var edgeSpecificDebug = string.Empty;
if (edge.Id is "edge/7" or "edge/27")
if (edge.Id is "edge/7" or "edge/9" or "edge/20" or "edge/27")
{
var targetNode = elkNodes.Single(node => node.Id == edge.TargetNodeId);
var expandedFocusEdgeIds = ((IEnumerable<string>)expandWinningSolutionFocus.Invoke(
null,
new object?[] { elkEdges, new[] { edge.Id } })!)
.ToArray();
var focusedLayoutRepair = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(elkEdges, elkNodes, [edge.Id]);
var transactionalLayoutRepair = (ElkRoutedEdge[])composeTransactionalFinalDetourCandidate.Invoke(
null,
new object?[] { elkEdges, elkNodes, 52.7d, new[] { edge.Id } })!;
var focusedExpandedSnapLayoutRepair = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
focusedLayoutRepair,
elkNodes,
52.7d,
expandedFocusEdgeIds,
enforceAllNodeEndpoints: true);
var focusedExpandedSnapBacktrackingRepairLayout = focusedExpandedSnapLayoutRepair;
var expandedTransactionalLayoutRepair = (ElkRoutedEdge[])composeTransactionalFinalDetourCandidate.Invoke(
null,
new object?[] { elkEdges, elkNodes, 52.7d, expandedFocusEdgeIds })!;
var focusedExpandedRestabilizedLayoutRepair = (ElkRoutedEdge[])buildFinalRestabilizedCandidate.Invoke(
null,
new object?[]
{
focusedLayoutRepair,
elkNodes,
ElkLayoutDirection.LeftToRight,
52.7d,
expandedFocusEdgeIds,
})!;
var pairedTransactionalLayoutRepair = edge.Id == "edge/27"
? (ElkRoutedEdge[])composeTransactionalFinalDetourCandidate.Invoke(
null,
new object?[] { elkEdges, elkNodes, 52.7d, new[] { "edge/27", "edge/28" } })!
: transactionalLayoutRepair;
var focusedExpandedSnapNewOffenderRevertLayout = focusedExpandedSnapLayoutRepair;
var baselineScore = ElkEdgeRoutingScoring.ComputeScore(elkEdges, elkNodes);
var focusedLayoutScore = ElkEdgeRoutingScoring.ComputeScore(focusedLayoutRepair, elkNodes);
var transactionalLayoutScore = ElkEdgeRoutingScoring.ComputeScore(transactionalLayoutRepair, elkNodes);
var focusedExpandedSnapLayoutScore = ElkEdgeRoutingScoring.ComputeScore(focusedExpandedSnapLayoutRepair, elkNodes);
var focusedExpandedSnapBacktrackingRepairLayoutScore = focusedExpandedSnapLayoutScore;
var focusedExpandedSnapNewOffenderRevertLayoutScore = focusedExpandedSnapLayoutScore;
var expandedTransactionalLayoutScore = ElkEdgeRoutingScoring.ComputeScore(expandedTransactionalLayoutRepair, elkNodes);
var focusedExpandedRestabilizedLayoutScore = ElkEdgeRoutingScoring.ComputeScore(focusedExpandedRestabilizedLayoutRepair, elkNodes);
var pairedTransactionalLayoutScore = ElkEdgeRoutingScoring.ComputeScore(pairedTransactionalLayoutRepair, elkNodes);
var focusedBoundarySlotCandidate = (ElkRoutedEdge[])buildFinalBoundarySlotCandidate.Invoke(
null,
new object?[]
{
focusedLayoutRepair,
elkNodes,
ElkLayoutDirection.LeftToRight,
52.7d,
new[] { edge.Id },
false,
})!;
var focusedBoundarySlotScore = ElkEdgeRoutingScoring.ComputeScore(focusedBoundarySlotCandidate, elkNodes);
var baselineBrokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(elkEdges, elkNodes).Count;
var focusedBrokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(focusedLayoutRepair, elkNodes).Count;
var transactionalBrokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(transactionalLayoutRepair, elkNodes).Count;
var focusedExpandedSnapBrokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(focusedExpandedSnapLayoutRepair, elkNodes).Count;
var focusedExpandedSnapBacktrackingRepairBrokenHighways = focusedExpandedSnapBrokenHighways;
var focusedExpandedSnapNewOffenderRevertBrokenHighways = focusedExpandedSnapBrokenHighways;
var expandedTransactionalBrokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(expandedTransactionalLayoutRepair, elkNodes).Count;
var focusedExpandedRestabilizedBrokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(focusedExpandedRestabilizedLayoutRepair, elkNodes).Count;
var pairedTransactionalBrokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(pairedTransactionalLayoutRepair, elkNodes).Count;
var focusedBoundarySlotBrokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(focusedBoundarySlotCandidate, elkNodes).Count;
var baselineRetryState = (RoutingRetryState)buildRetryState.Invoke(
null,
new object?[] { baselineScore, baselineBrokenHighways })!;
var focusedLayoutRetryState = (RoutingRetryState)buildRetryState.Invoke(
null,
new object?[] { focusedLayoutScore, focusedBrokenHighways })!;
var transactionalLayoutRetryState = (RoutingRetryState)buildRetryState.Invoke(
null,
new object?[] { transactionalLayoutScore, transactionalBrokenHighways })!;
var focusedExpandedSnapLayoutRetryState = (RoutingRetryState)buildRetryState.Invoke(
null,
new object?[] { focusedExpandedSnapLayoutScore, focusedExpandedSnapBrokenHighways })!;
var focusedExpandedSnapBacktrackingRepairLayoutRetryState = focusedExpandedSnapLayoutRetryState;
var focusedExpandedSnapNewOffenderRevertLayoutRetryState = focusedExpandedSnapLayoutRetryState;
var expandedTransactionalLayoutRetryState = (RoutingRetryState)buildRetryState.Invoke(
null,
new object?[] { expandedTransactionalLayoutScore, expandedTransactionalBrokenHighways })!;
var focusedExpandedRestabilizedLayoutRetryState = (RoutingRetryState)buildRetryState.Invoke(
null,
new object?[] { focusedExpandedRestabilizedLayoutScore, focusedExpandedRestabilizedBrokenHighways })!;
var pairedTransactionalLayoutRetryState = (RoutingRetryState)buildRetryState.Invoke(
null,
new object?[] { pairedTransactionalLayoutScore, pairedTransactionalBrokenHighways })!;
var peerConflictLayoutRepair = edge.Id == "edge/27"
? focusedLayoutRepair.ToArray()
: focusedLayoutRepair;
@@ -1265,8 +1516,110 @@ public partial class ElkSharpEdgeRefinementTests
}
}
var peerConflictLayoutScore = ElkEdgeRoutingScoring.ComputeScore(peerConflictLayoutRepair, elkNodes);
var baselineBacktrackingSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(
elkEdges,
elkNodes,
baselineBacktrackingSeverity,
1);
var focusedJoinSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(focusedLayoutRepair, elkNodes, focusedJoinSeverity, 1);
var expandedTransactionalBacktrackingSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(
expandedTransactionalLayoutRepair,
elkNodes,
expandedTransactionalBacktrackingSeverity,
1);
var focusedExpandedSnapBacktrackingSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(
focusedExpandedSnapLayoutRepair,
elkNodes,
focusedExpandedSnapBacktrackingSeverity,
1);
if (focusedExpandedSnapBacktrackingSeverity.Count > 0)
{
focusedExpandedSnapBacktrackingRepairLayout = ElkEdgePostProcessor.NormalizeBoundaryAngles(
ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(
focusedExpandedSnapLayoutRepair,
elkNodes,
52.7d,
focusedExpandedSnapBacktrackingSeverity.Keys
.OrderBy(id => id, StringComparer.Ordinal)
.ToArray()),
elkNodes);
focusedExpandedSnapBacktrackingRepairLayoutScore = ElkEdgeRoutingScoring.ComputeScore(
focusedExpandedSnapBacktrackingRepairLayout,
elkNodes);
focusedExpandedSnapBacktrackingRepairBrokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(
focusedExpandedSnapBacktrackingRepairLayout,
elkNodes).Count;
focusedExpandedSnapBacktrackingRepairLayoutRetryState = (RoutingRetryState)buildRetryState.Invoke(
null,
new object?[]
{
focusedExpandedSnapBacktrackingRepairLayoutScore,
focusedExpandedSnapBacktrackingRepairBrokenHighways,
})!;
}
var focusedExpandedSnapBacktrackingRepairSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(
focusedExpandedSnapBacktrackingRepairLayout,
elkNodes,
focusedExpandedSnapBacktrackingRepairSeverity,
1);
var focusedExpandedSnapNewOffenderEdgeIds = focusedExpandedSnapBacktrackingSeverity.Keys
.Except(baselineBacktrackingSeverity.Keys, StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToArray();
if (focusedExpandedSnapNewOffenderEdgeIds.Length > 0)
{
focusedExpandedSnapNewOffenderRevertLayout = focusedExpandedSnapLayoutRepair.ToArray();
foreach (var revertEdgeId in focusedExpandedSnapNewOffenderEdgeIds)
{
var revertIndex = Array.FindIndex(
focusedExpandedSnapNewOffenderRevertLayout,
candidate => string.Equals(candidate.Id, revertEdgeId, StringComparison.Ordinal));
if (revertIndex < 0)
{
continue;
}
focusedExpandedSnapNewOffenderRevertLayout[revertIndex] = focusedLayoutRepair.Single(candidate =>
string.Equals(candidate.Id, revertEdgeId, StringComparison.Ordinal));
}
focusedExpandedSnapNewOffenderRevertLayoutScore = ElkEdgeRoutingScoring.ComputeScore(
focusedExpandedSnapNewOffenderRevertLayout,
elkNodes);
focusedExpandedSnapNewOffenderRevertBrokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(
focusedExpandedSnapNewOffenderRevertLayout,
elkNodes).Count;
focusedExpandedSnapNewOffenderRevertLayoutRetryState = (RoutingRetryState)buildRetryState.Invoke(
null,
new object?[]
{
focusedExpandedSnapNewOffenderRevertLayoutScore,
focusedExpandedSnapNewOffenderRevertBrokenHighways,
})!;
}
var focusedExpandedSnapNewOffenderRevertSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(
focusedExpandedSnapNewOffenderRevertLayout,
elkNodes,
focusedExpandedSnapNewOffenderRevertSeverity,
1);
var focusedExpandedRestabilizedBacktrackingSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(
focusedExpandedRestabilizedLayoutRepair,
elkNodes,
focusedExpandedRestabilizedBacktrackingSeverity,
1);
var focusedExpandedRestabilizedJoinSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(
focusedExpandedRestabilizedLayoutRepair,
elkNodes,
focusedExpandedRestabilizedJoinSeverity,
1);
var focusedLayoutSection = focusedLayoutRepair.Single(candidate => candidate.Id == edge.Id).Sections.Single();
var focusedLayoutPath = focusedLayoutSection.BendPoints
.Prepend(focusedLayoutSection.StartPoint)
@@ -1279,6 +1632,36 @@ public partial class ElkSharpEdgeRefinementTests
.Append(transactionalLayoutSection.EndPoint)
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToArray();
var focusedExpandedSnapLayoutSection = focusedExpandedSnapLayoutRepair.Single(candidate => candidate.Id == edge.Id).Sections.Single();
var focusedExpandedSnapLayoutPath = focusedExpandedSnapLayoutSection.BendPoints
.Prepend(focusedExpandedSnapLayoutSection.StartPoint)
.Append(focusedExpandedSnapLayoutSection.EndPoint)
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToArray();
var focusedExpandedSnapBacktrackingRepairLayoutSection = focusedExpandedSnapBacktrackingRepairLayout.Single(candidate => candidate.Id == edge.Id).Sections.Single();
var focusedExpandedSnapBacktrackingRepairLayoutPath = focusedExpandedSnapBacktrackingRepairLayoutSection.BendPoints
.Prepend(focusedExpandedSnapBacktrackingRepairLayoutSection.StartPoint)
.Append(focusedExpandedSnapBacktrackingRepairLayoutSection.EndPoint)
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToArray();
var focusedExpandedSnapNewOffenderRevertLayoutSection = focusedExpandedSnapNewOffenderRevertLayout.Single(candidate => candidate.Id == edge.Id).Sections.Single();
var focusedExpandedSnapNewOffenderRevertLayoutPath = focusedExpandedSnapNewOffenderRevertLayoutSection.BendPoints
.Prepend(focusedExpandedSnapNewOffenderRevertLayoutSection.StartPoint)
.Append(focusedExpandedSnapNewOffenderRevertLayoutSection.EndPoint)
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToArray();
var expandedTransactionalLayoutSection = expandedTransactionalLayoutRepair.Single(candidate => candidate.Id == edge.Id).Sections.Single();
var expandedTransactionalLayoutPath = expandedTransactionalLayoutSection.BendPoints
.Prepend(expandedTransactionalLayoutSection.StartPoint)
.Append(expandedTransactionalLayoutSection.EndPoint)
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToArray();
var focusedExpandedRestabilizedLayoutSection = focusedExpandedRestabilizedLayoutRepair.Single(candidate => candidate.Id == edge.Id).Sections.Single();
var focusedExpandedRestabilizedLayoutPath = focusedExpandedRestabilizedLayoutSection.BendPoints
.Prepend(focusedExpandedRestabilizedLayoutSection.StartPoint)
.Append(focusedExpandedRestabilizedLayoutSection.EndPoint)
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToArray();
var peerConflictLayoutSection = peerConflictLayoutRepair.Single(candidate => candidate.Id == edge.Id).Sections.Single();
var peerConflictLayoutPath = peerConflictLayoutSection.BendPoints
.Prepend(peerConflictLayoutSection.StartPoint)
@@ -1291,6 +1674,21 @@ public partial class ElkSharpEdgeRefinementTests
var chosenTransactionalLayout = (ElkRoutedEdge[])choosePreferredHardRuleLayout.Invoke(
null,
new object?[] { elkEdges, transactionalLayoutRepair, elkNodes })!;
var chosenFocusedExpandedSnapLayout = (ElkRoutedEdge[])choosePreferredHardRuleLayout.Invoke(
null,
new object?[] { elkEdges, focusedExpandedSnapLayoutRepair, elkNodes })!;
var chosenFocusedExpandedSnapBacktrackingRepairLayout = (ElkRoutedEdge[])choosePreferredHardRuleLayout.Invoke(
null,
new object?[] { elkEdges, focusedExpandedSnapBacktrackingRepairLayout, elkNodes })!;
var chosenFocusedExpandedSnapNewOffenderRevertLayout = (ElkRoutedEdge[])choosePreferredHardRuleLayout.Invoke(
null,
new object?[] { elkEdges, focusedExpandedSnapNewOffenderRevertLayout, elkNodes })!;
var chosenExpandedTransactionalLayout = (ElkRoutedEdge[])choosePreferredHardRuleLayout.Invoke(
null,
new object?[] { elkEdges, expandedTransactionalLayoutRepair, elkNodes })!;
var chosenFocusedExpandedRestabilizedLayout = (ElkRoutedEdge[])choosePreferredHardRuleLayout.Invoke(
null,
new object?[] { elkEdges, focusedExpandedRestabilizedLayoutRepair, elkNodes })!;
var chosenPairedTransactionalLayout = (ElkRoutedEdge[])choosePreferredHardRuleLayout.Invoke(
null,
new object?[] { elkEdges, pairedTransactionalLayoutRepair, elkNodes })!;
@@ -1326,8 +1724,13 @@ public partial class ElkSharpEdgeRefinementTests
};
edgeSpecificDebug += $" | chooser={(ReferenceEquals(chosenLayout, focusedLayoutRepair) ? "candidate" : "baseline")}";
edgeSpecificDebug += $" | baseline-score entry={baselineScore.EntryAngleViolations},"
+ $" nc={baselineScore.NodeCrossings},"
+ $" short={baselineBrokenHighways},"
+ $" gateway-source={baselineScore.GatewaySourceExitViolations},"
+ $" collector-corridor={baselineScore.RepeatCollectorCorridorViolations},"
+ $" collector-clearance={baselineScore.RepeatCollectorNodeClearanceViolations},"
+ $" shared={baselineScore.SharedLaneViolations},"
+ $" boundary={baselineScore.BoundarySlotViolations},"
+ $" joins={baselineScore.TargetApproachJoinViolations},"
+ $" backtracking={baselineScore.TargetApproachBacktrackingViolations},"
+ $" detour={baselineScore.ExcessiveDetourViolations},"
@@ -1336,10 +1739,16 @@ public partial class ElkSharpEdgeRefinementTests
+ $" longdiag={baselineScore.LongDiagonalViolations},"
+ $" proximity={baselineScore.ProximityViolations},"
+ $" label={baselineScore.LabelProximityViolations},"
+ $" crossings={baselineScore.EdgeCrossings}";
+ $" crossings={baselineScore.EdgeCrossings},"
+ $" value={baselineScore.Value:F0}";
edgeSpecificDebug += $" | focused-score entry={focusedLayoutScore.EntryAngleViolations},"
+ $" nc={focusedLayoutScore.NodeCrossings},"
+ $" short={focusedBrokenHighways},"
+ $" gateway-source={focusedLayoutScore.GatewaySourceExitViolations},"
+ $" collector-corridor={focusedLayoutScore.RepeatCollectorCorridorViolations},"
+ $" collector-clearance={focusedLayoutScore.RepeatCollectorNodeClearanceViolations},"
+ $" shared={focusedLayoutScore.SharedLaneViolations},"
+ $" boundary={focusedLayoutScore.BoundarySlotViolations},"
+ $" joins={focusedLayoutScore.TargetApproachJoinViolations},"
+ $" backtracking={focusedLayoutScore.TargetApproachBacktrackingViolations},"
+ $" detour={focusedLayoutScore.ExcessiveDetourViolations},"
@@ -1348,11 +1757,19 @@ public partial class ElkSharpEdgeRefinementTests
+ $" longdiag={focusedLayoutScore.LongDiagonalViolations},"
+ $" proximity={focusedLayoutScore.ProximityViolations},"
+ $" label={focusedLayoutScore.LabelProximityViolations},"
+ $" crossings={focusedLayoutScore.EdgeCrossings}";
+ $" crossings={focusedLayoutScore.EdgeCrossings},"
+ $" value={focusedLayoutScore.Value:F0}";
edgeSpecificDebug += $" | focused-compare retry={compareRetryStates.Invoke(null, new object?[] { focusedLayoutRetryState, baselineRetryState })},"
+ $" hard={(bool)hasHardRuleRegression.Invoke(null, new object?[] { focusedLayoutRetryState, baselineRetryState })!}";
edgeSpecificDebug += $" | transactional-chooser={(ReferenceEquals(chosenTransactionalLayout, transactionalLayoutRepair) ? "candidate" : "baseline")}";
edgeSpecificDebug += $" | transactional-score entry={transactionalLayoutScore.EntryAngleViolations},"
+ $" nc={transactionalLayoutScore.NodeCrossings},"
+ $" short={transactionalBrokenHighways},"
+ $" gateway-source={transactionalLayoutScore.GatewaySourceExitViolations},"
+ $" collector-corridor={transactionalLayoutScore.RepeatCollectorCorridorViolations},"
+ $" collector-clearance={transactionalLayoutScore.RepeatCollectorNodeClearanceViolations},"
+ $" shared={transactionalLayoutScore.SharedLaneViolations},"
+ $" boundary={transactionalLayoutScore.BoundarySlotViolations},"
+ $" joins={transactionalLayoutScore.TargetApproachJoinViolations},"
+ $" backtracking={transactionalLayoutScore.TargetApproachBacktrackingViolations},"
+ $" detour={transactionalLayoutScore.ExcessiveDetourViolations},"
@@ -1361,11 +1778,132 @@ public partial class ElkSharpEdgeRefinementTests
+ $" longdiag={transactionalLayoutScore.LongDiagonalViolations},"
+ $" proximity={transactionalLayoutScore.ProximityViolations},"
+ $" label={transactionalLayoutScore.LabelProximityViolations},"
+ $" crossings={transactionalLayoutScore.EdgeCrossings}";
+ $" crossings={transactionalLayoutScore.EdgeCrossings},"
+ $" value={transactionalLayoutScore.Value:F0}";
edgeSpecificDebug += $" | transactional-compare retry={compareRetryStates.Invoke(null, new object?[] { transactionalLayoutRetryState, baselineRetryState })},"
+ $" hard={(bool)hasHardRuleRegression.Invoke(null, new object?[] { transactionalLayoutRetryState, baselineRetryState })!}";
edgeSpecificDebug += $" | focused-expanded-snap-chooser={(ReferenceEquals(chosenFocusedExpandedSnapLayout, focusedExpandedSnapLayoutRepair) ? "candidate" : "baseline")}";
edgeSpecificDebug += $" | focused-expanded-snap-score entry={focusedExpandedSnapLayoutScore.EntryAngleViolations},"
+ $" nc={focusedExpandedSnapLayoutScore.NodeCrossings},"
+ $" short={focusedExpandedSnapBrokenHighways},"
+ $" gateway-source={focusedExpandedSnapLayoutScore.GatewaySourceExitViolations},"
+ $" collector-corridor={focusedExpandedSnapLayoutScore.RepeatCollectorCorridorViolations},"
+ $" collector-clearance={focusedExpandedSnapLayoutScore.RepeatCollectorNodeClearanceViolations},"
+ $" shared={focusedExpandedSnapLayoutScore.SharedLaneViolations},"
+ $" boundary={focusedExpandedSnapLayoutScore.BoundarySlotViolations},"
+ $" joins={focusedExpandedSnapLayoutScore.TargetApproachJoinViolations},"
+ $" backtracking={focusedExpandedSnapLayoutScore.TargetApproachBacktrackingViolations},"
+ $" detour={focusedExpandedSnapLayoutScore.ExcessiveDetourViolations},"
+ $" below={focusedExpandedSnapLayoutScore.BelowGraphViolations},"
+ $" under={focusedExpandedSnapLayoutScore.UnderNodeViolations},"
+ $" longdiag={focusedExpandedSnapLayoutScore.LongDiagonalViolations},"
+ $" proximity={focusedExpandedSnapLayoutScore.ProximityViolations},"
+ $" label={focusedExpandedSnapLayoutScore.LabelProximityViolations},"
+ $" crossings={focusedExpandedSnapLayoutScore.EdgeCrossings},"
+ $" value={focusedExpandedSnapLayoutScore.Value:F0}";
edgeSpecificDebug += $" | focused-expanded-snap-compare retry={compareRetryStates.Invoke(null, new object?[] { focusedExpandedSnapLayoutRetryState, baselineRetryState })},"
+ $" hard={(bool)hasHardRuleRegression.Invoke(null, new object?[] { focusedExpandedSnapLayoutRetryState, baselineRetryState })!}";
edgeSpecificDebug += $" | focused-expanded-snap-backtracking-edges=[{string.Join(", ", focusedExpandedSnapBacktrackingSeverity.Keys.OrderBy(id => id, StringComparer.Ordinal))}]";
edgeSpecificDebug += $" | focused-expanded-snap-backtracking-repair-chooser={(ReferenceEquals(chosenFocusedExpandedSnapBacktrackingRepairLayout, focusedExpandedSnapBacktrackingRepairLayout) ? "candidate" : "baseline")}";
edgeSpecificDebug += $" | focused-expanded-snap-backtracking-repair-score entry={focusedExpandedSnapBacktrackingRepairLayoutScore.EntryAngleViolations},"
+ $" nc={focusedExpandedSnapBacktrackingRepairLayoutScore.NodeCrossings},"
+ $" short={focusedExpandedSnapBacktrackingRepairBrokenHighways},"
+ $" gateway-source={focusedExpandedSnapBacktrackingRepairLayoutScore.GatewaySourceExitViolations},"
+ $" collector-corridor={focusedExpandedSnapBacktrackingRepairLayoutScore.RepeatCollectorCorridorViolations},"
+ $" collector-clearance={focusedExpandedSnapBacktrackingRepairLayoutScore.RepeatCollectorNodeClearanceViolations},"
+ $" shared={focusedExpandedSnapBacktrackingRepairLayoutScore.SharedLaneViolations},"
+ $" boundary={focusedExpandedSnapBacktrackingRepairLayoutScore.BoundarySlotViolations},"
+ $" joins={focusedExpandedSnapBacktrackingRepairLayoutScore.TargetApproachJoinViolations},"
+ $" backtracking={focusedExpandedSnapBacktrackingRepairLayoutScore.TargetApproachBacktrackingViolations},"
+ $" detour={focusedExpandedSnapBacktrackingRepairLayoutScore.ExcessiveDetourViolations},"
+ $" below={focusedExpandedSnapBacktrackingRepairLayoutScore.BelowGraphViolations},"
+ $" under={focusedExpandedSnapBacktrackingRepairLayoutScore.UnderNodeViolations},"
+ $" longdiag={focusedExpandedSnapBacktrackingRepairLayoutScore.LongDiagonalViolations},"
+ $" proximity={focusedExpandedSnapBacktrackingRepairLayoutScore.ProximityViolations},"
+ $" label={focusedExpandedSnapBacktrackingRepairLayoutScore.LabelProximityViolations},"
+ $" crossings={focusedExpandedSnapBacktrackingRepairLayoutScore.EdgeCrossings},"
+ $" value={focusedExpandedSnapBacktrackingRepairLayoutScore.Value:F0}";
edgeSpecificDebug += $" | focused-expanded-snap-backtracking-repair-compare retry={compareRetryStates.Invoke(null, new object?[] { focusedExpandedSnapBacktrackingRepairLayoutRetryState, baselineRetryState })},"
+ $" hard={(bool)hasHardRuleRegression.Invoke(null, new object?[] { focusedExpandedSnapBacktrackingRepairLayoutRetryState, baselineRetryState })!}";
edgeSpecificDebug += $" | focused-expanded-snap-backtracking-repair-edges=[{string.Join(", ", focusedExpandedSnapBacktrackingRepairSeverity.Keys.OrderBy(id => id, StringComparer.Ordinal))}]";
edgeSpecificDebug += $" | focused-expanded-snap-new-offender-revert-chooser={(ReferenceEquals(chosenFocusedExpandedSnapNewOffenderRevertLayout, focusedExpandedSnapNewOffenderRevertLayout) ? "candidate" : "baseline")}";
edgeSpecificDebug += $" | focused-expanded-snap-new-offender-revert-score entry={focusedExpandedSnapNewOffenderRevertLayoutScore.EntryAngleViolations},"
+ $" nc={focusedExpandedSnapNewOffenderRevertLayoutScore.NodeCrossings},"
+ $" short={focusedExpandedSnapNewOffenderRevertBrokenHighways},"
+ $" gateway-source={focusedExpandedSnapNewOffenderRevertLayoutScore.GatewaySourceExitViolations},"
+ $" collector-corridor={focusedExpandedSnapNewOffenderRevertLayoutScore.RepeatCollectorCorridorViolations},"
+ $" collector-clearance={focusedExpandedSnapNewOffenderRevertLayoutScore.RepeatCollectorNodeClearanceViolations},"
+ $" shared={focusedExpandedSnapNewOffenderRevertLayoutScore.SharedLaneViolations},"
+ $" boundary={focusedExpandedSnapNewOffenderRevertLayoutScore.BoundarySlotViolations},"
+ $" joins={focusedExpandedSnapNewOffenderRevertLayoutScore.TargetApproachJoinViolations},"
+ $" backtracking={focusedExpandedSnapNewOffenderRevertLayoutScore.TargetApproachBacktrackingViolations},"
+ $" detour={focusedExpandedSnapNewOffenderRevertLayoutScore.ExcessiveDetourViolations},"
+ $" below={focusedExpandedSnapNewOffenderRevertLayoutScore.BelowGraphViolations},"
+ $" under={focusedExpandedSnapNewOffenderRevertLayoutScore.UnderNodeViolations},"
+ $" longdiag={focusedExpandedSnapNewOffenderRevertLayoutScore.LongDiagonalViolations},"
+ $" proximity={focusedExpandedSnapNewOffenderRevertLayoutScore.ProximityViolations},"
+ $" label={focusedExpandedSnapNewOffenderRevertLayoutScore.LabelProximityViolations},"
+ $" crossings={focusedExpandedSnapNewOffenderRevertLayoutScore.EdgeCrossings},"
+ $" value={focusedExpandedSnapNewOffenderRevertLayoutScore.Value:F0}";
edgeSpecificDebug += $" | focused-expanded-snap-new-offender-revert-compare retry={compareRetryStates.Invoke(null, new object?[] { focusedExpandedSnapNewOffenderRevertLayoutRetryState, baselineRetryState })},"
+ $" hard={(bool)hasHardRuleRegression.Invoke(null, new object?[] { focusedExpandedSnapNewOffenderRevertLayoutRetryState, baselineRetryState })!}";
edgeSpecificDebug += $" | focused-expanded-snap-new-offender-edges=[{string.Join(", ", focusedExpandedSnapNewOffenderEdgeIds)}]";
edgeSpecificDebug += $" | focused-expanded-snap-new-offender-revert-backtracking-edges=[{string.Join(", ", focusedExpandedSnapNewOffenderRevertSeverity.Keys.OrderBy(id => id, StringComparer.Ordinal))}]";
edgeSpecificDebug += $" | expanded-transactional-focus=[{string.Join(", ", expandedFocusEdgeIds)}]";
edgeSpecificDebug += $" | expanded-transactional-chooser={(ReferenceEquals(chosenExpandedTransactionalLayout, expandedTransactionalLayoutRepair) ? "candidate" : "baseline")}";
edgeSpecificDebug += $" | expanded-transactional-score entry={expandedTransactionalLayoutScore.EntryAngleViolations},"
+ $" nc={expandedTransactionalLayoutScore.NodeCrossings},"
+ $" short={expandedTransactionalBrokenHighways},"
+ $" gateway-source={expandedTransactionalLayoutScore.GatewaySourceExitViolations},"
+ $" collector-corridor={expandedTransactionalLayoutScore.RepeatCollectorCorridorViolations},"
+ $" collector-clearance={expandedTransactionalLayoutScore.RepeatCollectorNodeClearanceViolations},"
+ $" shared={expandedTransactionalLayoutScore.SharedLaneViolations},"
+ $" boundary={expandedTransactionalLayoutScore.BoundarySlotViolations},"
+ $" joins={expandedTransactionalLayoutScore.TargetApproachJoinViolations},"
+ $" backtracking={expandedTransactionalLayoutScore.TargetApproachBacktrackingViolations},"
+ $" detour={expandedTransactionalLayoutScore.ExcessiveDetourViolations},"
+ $" below={expandedTransactionalLayoutScore.BelowGraphViolations},"
+ $" under={expandedTransactionalLayoutScore.UnderNodeViolations},"
+ $" longdiag={expandedTransactionalLayoutScore.LongDiagonalViolations},"
+ $" proximity={expandedTransactionalLayoutScore.ProximityViolations},"
+ $" label={expandedTransactionalLayoutScore.LabelProximityViolations},"
+ $" crossings={expandedTransactionalLayoutScore.EdgeCrossings},"
+ $" value={expandedTransactionalLayoutScore.Value:F0}";
edgeSpecificDebug += $" | expanded-transactional-compare retry={compareRetryStates.Invoke(null, new object?[] { expandedTransactionalLayoutRetryState, baselineRetryState })},"
+ $" hard={(bool)hasHardRuleRegression.Invoke(null, new object?[] { expandedTransactionalLayoutRetryState, baselineRetryState })!}";
edgeSpecificDebug += $" | expanded-transactional-backtracking-edges=[{string.Join(", ", expandedTransactionalBacktrackingSeverity.Keys.OrderBy(id => id, StringComparer.Ordinal))}]";
edgeSpecificDebug += $" | focused-expanded-restabilized-chooser={(ReferenceEquals(chosenFocusedExpandedRestabilizedLayout, focusedExpandedRestabilizedLayoutRepair) ? "candidate" : "baseline")}";
edgeSpecificDebug += $" | focused-expanded-restabilized-score entry={focusedExpandedRestabilizedLayoutScore.EntryAngleViolations},"
+ $" nc={focusedExpandedRestabilizedLayoutScore.NodeCrossings},"
+ $" short={focusedExpandedRestabilizedBrokenHighways},"
+ $" gateway-source={focusedExpandedRestabilizedLayoutScore.GatewaySourceExitViolations},"
+ $" collector-corridor={focusedExpandedRestabilizedLayoutScore.RepeatCollectorCorridorViolations},"
+ $" collector-clearance={focusedExpandedRestabilizedLayoutScore.RepeatCollectorNodeClearanceViolations},"
+ $" shared={focusedExpandedRestabilizedLayoutScore.SharedLaneViolations},"
+ $" boundary={focusedExpandedRestabilizedLayoutScore.BoundarySlotViolations},"
+ $" joins={focusedExpandedRestabilizedLayoutScore.TargetApproachJoinViolations},"
+ $" backtracking={focusedExpandedRestabilizedLayoutScore.TargetApproachBacktrackingViolations},"
+ $" detour={focusedExpandedRestabilizedLayoutScore.ExcessiveDetourViolations},"
+ $" below={focusedExpandedRestabilizedLayoutScore.BelowGraphViolations},"
+ $" under={focusedExpandedRestabilizedLayoutScore.UnderNodeViolations},"
+ $" longdiag={focusedExpandedRestabilizedLayoutScore.LongDiagonalViolations},"
+ $" proximity={focusedExpandedRestabilizedLayoutScore.ProximityViolations},"
+ $" label={focusedExpandedRestabilizedLayoutScore.LabelProximityViolations},"
+ $" crossings={focusedExpandedRestabilizedLayoutScore.EdgeCrossings},"
+ $" value={focusedExpandedRestabilizedLayoutScore.Value:F0}";
edgeSpecificDebug += $" | focused-expanded-restabilized-compare retry={compareRetryStates.Invoke(null, new object?[] { focusedExpandedRestabilizedLayoutRetryState, baselineRetryState })},"
+ $" hard={(bool)hasHardRuleRegression.Invoke(null, new object?[] { focusedExpandedRestabilizedLayoutRetryState, baselineRetryState })!}";
edgeSpecificDebug += $" | focused-expanded-restabilized-backtracking-edges=[{string.Join(", ", focusedExpandedRestabilizedBacktrackingSeverity.Keys.OrderBy(id => id, StringComparer.Ordinal))}]";
edgeSpecificDebug += $" | focused-expanded-restabilized-join-edges=[{string.Join(", ", focusedExpandedRestabilizedJoinSeverity.Keys.OrderBy(id => id, StringComparer.Ordinal))}]";
edgeSpecificDebug += $" | paired-transactional-chooser={(ReferenceEquals(chosenPairedTransactionalLayout, pairedTransactionalLayoutRepair) ? "candidate" : "baseline")}";
edgeSpecificDebug += $" | paired-transactional-score entry={pairedTransactionalLayoutScore.EntryAngleViolations},"
+ $" nc={pairedTransactionalLayoutScore.NodeCrossings},"
+ $" short={pairedTransactionalBrokenHighways},"
+ $" gateway-source={pairedTransactionalLayoutScore.GatewaySourceExitViolations},"
+ $" collector-corridor={pairedTransactionalLayoutScore.RepeatCollectorCorridorViolations},"
+ $" collector-clearance={pairedTransactionalLayoutScore.RepeatCollectorNodeClearanceViolations},"
+ $" shared={pairedTransactionalLayoutScore.SharedLaneViolations},"
+ $" boundary={pairedTransactionalLayoutScore.BoundarySlotViolations},"
+ $" joins={pairedTransactionalLayoutScore.TargetApproachJoinViolations},"
+ $" backtracking={pairedTransactionalLayoutScore.TargetApproachBacktrackingViolations},"
+ $" detour={pairedTransactionalLayoutScore.ExcessiveDetourViolations},"
@@ -1374,7 +1912,10 @@ public partial class ElkSharpEdgeRefinementTests
+ $" longdiag={pairedTransactionalLayoutScore.LongDiagonalViolations},"
+ $" proximity={pairedTransactionalLayoutScore.ProximityViolations},"
+ $" label={pairedTransactionalLayoutScore.LabelProximityViolations},"
+ $" crossings={pairedTransactionalLayoutScore.EdgeCrossings}";
+ $" crossings={pairedTransactionalLayoutScore.EdgeCrossings},"
+ $" value={pairedTransactionalLayoutScore.Value:F0}";
edgeSpecificDebug += $" | paired-transactional-compare retry={compareRetryStates.Invoke(null, new object?[] { pairedTransactionalLayoutRetryState, baselineRetryState })},"
+ $" hard={(bool)hasHardRuleRegression.Invoke(null, new object?[] { pairedTransactionalLayoutRetryState, baselineRetryState })!}";
edgeSpecificDebug += $" | peer-conflict-score entry={peerConflictLayoutScore.EntryAngleViolations},"
+ $" gateway-source={peerConflictLayoutScore.GatewaySourceExitViolations},"
+ $" shared={peerConflictLayoutScore.SharedLaneViolations},"
@@ -1389,8 +1930,18 @@ public partial class ElkSharpEdgeRefinementTests
+ $" crossings={peerConflictLayoutScore.EdgeCrossings}";
edgeSpecificDebug += $" | focused-layout detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedLayoutRepair, elkNodes)},"
+ $" shared={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedLayoutRepair, elkNodes)},"
+ $" boundary={ElkEdgeRoutingScoring.CountBoundarySlotViolations(focusedLayoutRepair, elkNodes)},"
+ $" gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedLayoutRepair, elkNodes)}:"
+ $" {DescribePath(focusedLayoutPath)}";
edgeSpecificDebug += $" | focused-boundary-candidate short={focusedBoundarySlotBrokenHighways},"
+ $" boundary={focusedBoundarySlotScore.BoundarySlotViolations},"
+ $" joins={focusedBoundarySlotScore.TargetApproachJoinViolations},"
+ $" backtracking={focusedBoundarySlotScore.TargetApproachBacktrackingViolations},"
+ $" detour={focusedBoundarySlotScore.ExcessiveDetourViolations},"
+ $" under={focusedBoundarySlotScore.UnderNodeViolations},"
+ $" proximity={focusedBoundarySlotScore.ProximityViolations},"
+ $" label={focusedBoundarySlotScore.LabelProximityViolations},"
+ $" crossings={focusedBoundarySlotScore.EdgeCrossings}";
edgeSpecificDebug += $" | focused-join-edges=[{string.Join(", ", focusedJoinSeverity.Keys.OrderBy(id => id, StringComparer.Ordinal))}]";
edgeSpecificDebug += $" | transactional-layout detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(transactionalLayoutRepair, elkNodes)},"
+ $" shared={ElkEdgeRoutingScoring.CountSharedLaneViolations(transactionalLayoutRepair, elkNodes)},"
@@ -1398,6 +1949,36 @@ public partial class ElkSharpEdgeRefinementTests
+ $" under={ElkEdgeRoutingScoring.CountUnderNodeViolations(transactionalLayoutRepair, elkNodes)},"
+ $" joins={ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(transactionalLayoutRepair, elkNodes)}:"
+ $" {DescribePath(transactionalLayoutPath)}";
edgeSpecificDebug += $" | focused-expanded-snap-layout detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedExpandedSnapLayoutRepair, elkNodes)},"
+ $" backtracking={ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(focusedExpandedSnapLayoutRepair, elkNodes)},"
+ $" shared={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedExpandedSnapLayoutRepair, elkNodes)},"
+ $" gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedExpandedSnapLayoutRepair, elkNodes)},"
+ $" joins={ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(focusedExpandedSnapLayoutRepair, elkNodes)}:"
+ $" {DescribePath(focusedExpandedSnapLayoutPath)}";
edgeSpecificDebug += $" | focused-expanded-snap-backtracking-repair-layout detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedExpandedSnapBacktrackingRepairLayout, elkNodes)},"
+ $" backtracking={ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(focusedExpandedSnapBacktrackingRepairLayout, elkNodes)},"
+ $" shared={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedExpandedSnapBacktrackingRepairLayout, elkNodes)},"
+ $" gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedExpandedSnapBacktrackingRepairLayout, elkNodes)},"
+ $" joins={ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(focusedExpandedSnapBacktrackingRepairLayout, elkNodes)}:"
+ $" {DescribePath(focusedExpandedSnapBacktrackingRepairLayoutPath)}";
edgeSpecificDebug += $" | focused-expanded-snap-new-offender-revert-layout detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedExpandedSnapNewOffenderRevertLayout, elkNodes)},"
+ $" backtracking={ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(focusedExpandedSnapNewOffenderRevertLayout, elkNodes)},"
+ $" shared={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedExpandedSnapNewOffenderRevertLayout, elkNodes)},"
+ $" gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedExpandedSnapNewOffenderRevertLayout, elkNodes)},"
+ $" joins={ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(focusedExpandedSnapNewOffenderRevertLayout, elkNodes)}:"
+ $" {DescribePath(focusedExpandedSnapNewOffenderRevertLayoutPath)}";
edgeSpecificDebug += $" | expanded-transactional-layout detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(expandedTransactionalLayoutRepair, elkNodes)},"
+ $" backtracking={ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(expandedTransactionalLayoutRepair, elkNodes)},"
+ $" shared={ElkEdgeRoutingScoring.CountSharedLaneViolations(expandedTransactionalLayoutRepair, elkNodes)},"
+ $" gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(expandedTransactionalLayoutRepair, elkNodes)},"
+ $" joins={ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(expandedTransactionalLayoutRepair, elkNodes)}:"
+ $" {DescribePath(expandedTransactionalLayoutPath)}";
edgeSpecificDebug += $" | focused-expanded-restabilized-layout detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedExpandedRestabilizedLayoutRepair, elkNodes)},"
+ $" backtracking={ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(focusedExpandedRestabilizedLayoutRepair, elkNodes)},"
+ $" shared={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedExpandedRestabilizedLayoutRepair, elkNodes)},"
+ $" gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedExpandedRestabilizedLayoutRepair, elkNodes)},"
+ $" joins={ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(focusedExpandedRestabilizedLayoutRepair, elkNodes)}:"
+ $" {DescribePath(focusedExpandedRestabilizedLayoutPath)}";
if (edge.Id == "edge/27")
{
var pairedTransactionalLayoutSection = pairedTransactionalLayoutRepair.Single(candidate => candidate.Id == edge.Id).Sections.Single();

View File

@@ -0,0 +1,903 @@
using System.Reflection;
using System.Text.Json;
using FluentAssertions;
using NUnit.Framework;
using StellaOps.ElkSharp;
using StellaOps.Workflow.Abstractions;
namespace StellaOps.Workflow.Renderer.Tests;
public partial class ElkSharpEdgeRefinementTests
{
[Test]
[Property("Intent", "Operational")]
public void BoundarySlotHelpers_WhenGatewayTargetSingletonTopSlotNeedsSnap_ShouldPreserveOrthogonalFeeder()
{
var source = new ElkPositionedNode
{
Id = "source",
Label = "Internal Notification",
Kind = "Decision",
X = 2662,
Y = 639.4360656738281,
Width = 188,
Height = 132,
};
var target = new ElkPositionedNode
{
Id = "target",
Label = "Has Recipients",
Kind = "Decision",
X = 3778,
Y = 631.352783203125,
Width = 188,
Height = 132,
};
var edge = new ElkRoutedEdge
{
Id = "edge/25",
SourceNodeId = source.Id,
TargetNodeId = target.Id,
Label = "default",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 2741.2685655585256, Y = 649.7794132603952 },
EndPoint = new ElkPoint { X = 3849.7988202419156, Y = 646.940845586461 },
BendPoints =
[
new ElkPoint { X = 2741.2685655585256, Y = 631.4360656738281 },
new ElkPoint { X = 3849.7988202419156, Y = 631.4360656738281 },
],
},
],
};
var nodes = new[] { source, target };
ElkEdgeRoutingScoring.CountBoundarySlotViolations([edge], nodes)
.Should().Be(1);
var repaired = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
[edge],
nodes,
53d,
enforceAllNodeEndpoints: true);
ElkEdgeRoutingScoring.CountBoundarySlotViolations(repaired, nodes)
.Should().Be(0);
var repairedPath = ExtractPath(repaired.Single());
repairedPath.Should().HaveCount(4);
repairedPath[1].Y.Should().BeApproximately(repairedPath[2].Y, 0.5d);
repairedPath[2].X.Should().BeApproximately(repairedPath[3].X, 0.5d);
var expectedEnd = ElkBoundarySlots.BuildBoundarySlotPoint(
target,
"top",
ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates(target, "top", 1).Single());
repairedPath[^1].X.Should().BeApproximately(expectedEnd.X, 0.5d);
repairedPath[^1].Y.Should().BeApproximately(expectedEnd.Y, 0.5d);
}
[Test]
[Property("Intent", "Operational")]
public void BoundarySlotHelpers_WhenGatewayTargetSingletonLeftSlotNeedsSnap_ShouldReplaceLongDiagonalWithOrthogonalFeeder()
{
var source = new ElkPositionedNode
{
Id = "source",
Label = "Execute Batch",
Kind = "TransportCall",
X = 1604,
Y = 320.5908203125,
Width = 208,
Height = 88,
};
var target = new ElkPositionedNode
{
Id = "target",
Label = "Retry Decision",
Kind = "Decision",
X = 1976,
Y = 420.5908203125,
Width = 188,
Height = 132,
};
var edge = new ElkRoutedEdge
{
Id = "edge/5",
SourceNodeId = source.Id,
TargetNodeId = target.Id,
Label = "on failure",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 1812, Y = 386.5908203125 },
EndPoint = new ElkPoint { X = 1986.4506603822254, Y = 479.25312259732056 },
BendPoints =
[
new ElkPoint { X = 1860, Y = 386.5908203125 },
],
},
],
};
var nodes = new[] { source, target };
ElkEdgeRoutingScoring.CountBoundarySlotViolations([edge], nodes)
.Should().BeGreaterThan(0);
ElkEdgeRoutingScoring.CountLongDiagonalViolations([edge], nodes)
.Should().Be(1);
var repaired = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
[edge],
nodes,
53d,
enforceAllNodeEndpoints: true);
ElkEdgeRoutingScoring.CountBoundarySlotViolations(repaired, nodes)
.Should().Be(0);
ElkEdgeRoutingScoring.CountLongDiagonalViolations(repaired, nodes)
.Should().Be(0);
var repairedPath = ExtractPath(repaired.Single());
repairedPath.Should().HaveCountGreaterOrEqualTo(4);
Math.Abs(repairedPath[^2].Y - repairedPath[^1].Y).Should().BeLessThan(0.5d);
repairedPath.Zip(repairedPath.Skip(1), (start, end) =>
Math.Abs(start.X - end.X) <= 0.5d && Math.Abs(start.Y - end.Y) > 0.5d)
.Should()
.Contain(isVertical => isVertical);
var expectedEnd = ElkBoundarySlots.BuildBoundarySlotPoint(
target,
"left",
ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates(target, "left", 1).Single());
repairedPath[^1].X.Should().BeApproximately(expectedEnd.X, 0.5d);
repairedPath[^1].Y.Should().BeApproximately(expectedEnd.Y, 0.5d);
}
[Test]
[Property("Intent", "Operational")]
public void BoundarySlotHelpers_WhenGatewayTargetSingletonLeftSlotUsesFallbackAxis_ShouldKeepAssignedFace()
{
var source = new ElkPositionedNode
{
Id = "source",
Label = "Execute Batch",
Kind = "TransportCall",
X = 1604,
Y = 320.5908203125,
Width = 208,
Height = 88,
};
var target = new ElkPositionedNode
{
Id = "target",
Label = "Retry Decision",
Kind = "Decision",
X = 1976,
Y = 420.5908203125,
Width = 188,
Height = 132,
};
var edge = new ElkRoutedEdge
{
Id = "edge/5",
SourceNodeId = source.Id,
TargetNodeId = target.Id,
Label = "on failure",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 1812, Y = 386.5908203125 },
EndPoint = new ElkPoint { X = 1986.4506603822254, Y = 479.25312259732056 },
BendPoints =
[
new ElkPoint { X = 1860, Y = 386.5908203125 },
],
},
],
};
var path = ExtractPath(edge);
var desiredEndpoint = ElkBoundarySlots.BuildBoundarySlotPoint(
target,
"left",
ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates(target, "left", 1).Single());
var postProcessorType = typeof(ElkEdgePostProcessor);
var resolveTargetApproachAxisValue = postProcessorType.GetMethod(
"ResolveTargetApproachAxisValue",
BindingFlags.NonPublic | BindingFlags.Static);
var buildTargetApproachCandidatePath = postProcessorType.GetMethod(
"BuildTargetApproachCandidatePath",
BindingFlags.NonPublic | BindingFlags.Static);
var canAcceptGatewayTargetRepair = postProcessorType.GetMethod(
"CanAcceptGatewayTargetRepair",
BindingFlags.NonPublic | BindingFlags.Static);
var resolveTargetApproachSide = postProcessorType.GetMethod(
"ResolveTargetApproachSide",
BindingFlags.NonPublic | BindingFlags.Static);
resolveTargetApproachAxisValue.Should().NotBeNull();
buildTargetApproachCandidatePath.Should().NotBeNull();
canAcceptGatewayTargetRepair.Should().NotBeNull();
resolveTargetApproachSide.Should().NotBeNull();
var targetAxis = (double)resolveTargetApproachAxisValue!.Invoke(null, [path, "left"])!;
var candidate = (List<ElkPoint>)buildTargetApproachCandidatePath!.Invoke(
null,
[path, target, "left", desiredEndpoint, targetAxis])!;
var candidateEdge = new ElkRoutedEdge
{
Id = edge.Id,
SourceNodeId = edge.SourceNodeId,
TargetNodeId = edge.TargetNodeId,
Label = edge.Label,
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = candidate[0].X, Y = candidate[0].Y },
EndPoint = new ElkPoint { X = candidate[^1].X, Y = candidate[^1].Y },
BendPoints = candidate.Skip(1).SkipLast(1)
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToArray(),
},
],
};
resolveTargetApproachSide.Invoke(null, [candidate, target]).Should().Be("left");
canAcceptGatewayTargetRepair.Invoke(null, [candidate, target]).Should().Be(true);
ElkEdgeRoutingScoring.CountBoundarySlotViolations([candidateEdge], [source, target]).Should().Be(0);
ElkEdgeRoutingScoring.CountLongDiagonalViolations([candidateEdge], [source, target]).Should().Be(0);
}
[Test]
[Property("Intent", "Operational")]
public void BoundarySlotHelpers_WhenLateSlotSnapTouchesMixedNodeFaceHandoff_ShouldKeepDistinctFaceSlots()
{
var process = new ElkPositionedNode
{
Id = "process",
Label = "Process Batch",
Kind = "Repeat",
X = 992,
Y = 247.181640625,
Width = 208,
Height = 88,
};
var validateSuccess = new ElkPositionedNode
{
Id = "validate",
Label = "Validate Success",
Kind = "Decision",
X = 3406,
Y = 225.181640625,
Width = 188,
Height = 132,
};
var join = new ElkPositionedNode
{
Id = "join",
Label = "Parallel Execution Join",
Kind = "Join",
X = 1290,
Y = 116.5908203125,
Width = 176,
Height = 124,
};
var incoming = new ElkRoutedEdge
{
Id = "edge/in",
SourceNodeId = validateSuccess.Id,
TargetNodeId = process.Id,
Label = "repeat while state.printInsisAttempt eq 0",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 3420.7314344414744, Y = 301.5249882115671 },
EndPoint = new ElkPoint { X = 1200, Y = 269.181640625 },
BendPoints =
[
new ElkPoint { X = 3398, Y = 301.5249882115671 },
new ElkPoint { X = 3398, Y = -87.75 },
new ElkPoint { X = 1224, Y = -87.75 },
new ElkPoint { X = 1224, Y = 269.181640625 },
],
},
],
};
var outgoing = new ElkRoutedEdge
{
Id = "edge/out",
SourceNodeId = process.Id,
TargetNodeId = join.Id,
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 1200, Y = 269.181640625 },
EndPoint = new ElkPoint { X = 1305.1127839415904, Y = 215.68583544185816 },
BendPoints =
[
new ElkPoint { X = 1282.5912611218437, Y = 269.181640625 },
],
},
],
};
var body = new ElkRoutedEdge
{
Id = "edge/body",
SourceNodeId = process.Id,
TargetNodeId = "body",
Label = "body",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 1200, Y = 291.181640625 },
EndPoint = new ElkPoint { X = 1290, Y = 347.61603420489007 },
BendPoints =
[
new ElkPoint { X = 1224, Y = 291.181640625 },
new ElkPoint { X = 1224, Y = 347.61603420489007 },
],
},
],
};
var bodyNode = new ElkPositionedNode
{
Id = "body",
Label = "Body",
Kind = "SetState",
X = 1290,
Y = 312.5908203125,
Width = 224,
Height = 104,
};
var nodes = new[] { process, validateSuccess, join, bodyNode };
ElkEdgeRoutingScoring.CountSharedLaneViolations([incoming, outgoing, body], nodes)
.Should().Be(1);
ElkEdgeRoutingScoring.CountBoundarySlotViolations([incoming, outgoing, body], nodes)
.Should().BeGreaterThan(0);
var repaired = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts([incoming, outgoing, body], nodes, 53d);
repaired = ElkEdgePostProcessor.NormalizeBoundaryAngles(repaired, nodes);
repaired = ElkEdgePostProcessor.NormalizeSourceExitAngles(repaired, nodes);
repaired = ElkEdgePostProcessor.SnapBoundarySlotAssignments(repaired, nodes, 53d);
ElkEdgeRoutingScoring.CountSharedLaneViolations(repaired, nodes)
.Should().Be(0);
ElkEdgeRoutingScoring.CountBoundarySlotViolations(repaired, nodes)
.Should().Be(0);
}
[Test]
[Property("Intent", "Operational")]
public void MixedNodeFaceHelpers_WhenMixedTopFaceEntriesAreOffLatticeWithoutLaneConflict_ShouldSnapToDiscreteSlots()
{
var process = new ElkPositionedNode
{
Id = "process",
Label = "Process Batch",
Kind = "Repeat",
X = 992,
Y = 247.181640625,
Width = 208,
Height = 88,
};
var upstream = new ElkPositionedNode
{
Id = "upstream",
Label = "Upstream",
Kind = "Task",
X = 1012,
Y = 56,
Width = 160,
Height = 88,
};
var join = new ElkPositionedNode
{
Id = "join",
Label = "Parallel Execution Join",
Kind = "Join",
X = 1290,
Y = 116.5908203125,
Width = 176,
Height = 124,
};
var incoming = new ElkRoutedEdge
{
Id = "edge/in-top",
SourceNodeId = upstream.Id,
TargetNodeId = process.Id,
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 1040, Y = 144 },
EndPoint = new ElkPoint { X = 1040, Y = 247.181640625 },
BendPoints =
[
new ElkPoint { X = 1040, Y = 188 },
],
},
],
};
var outgoing = new ElkRoutedEdge
{
Id = "edge/out-top",
SourceNodeId = process.Id,
TargetNodeId = join.Id,
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 1140, Y = 247.181640625 },
EndPoint = new ElkPoint { X = 1290, Y = 222.88924788024843 },
BendPoints =
[
new ElkPoint { X = 1140, Y = 180 },
],
},
],
};
var nodes = new[] { process, upstream, join };
ElkEdgeRoutingScoring.CountSharedLaneViolations([incoming, outgoing], nodes)
.Should().Be(0);
ElkEdgeRoutingScoring.CountBoundarySlotViolations([incoming, outgoing], nodes)
.Should().BeGreaterThan(0);
var repaired = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts([incoming, outgoing], nodes, 53d);
repaired = ElkEdgePostProcessor.NormalizeBoundaryAngles(repaired, nodes);
repaired = ElkEdgePostProcessor.NormalizeSourceExitAngles(repaired, nodes);
ElkEdgeRoutingScoring.CountSharedLaneViolations(repaired, nodes)
.Should().Be(0);
ElkEdgeRoutingScoring.CountBoundarySlotViolations(repaired, nodes)
.Should().Be(0);
var repairedIncoming = repaired.Single(edge => edge.Id == "edge/in-top").Sections.Single();
var repairedOutgoing = repaired.Single(edge => edge.Id == "edge/out-top").Sections.Single();
repairedIncoming.EndPoint.X.Should().NotBe(1040d);
repairedOutgoing.StartPoint.X.Should().NotBe(1140d);
}
[Test]
[Property("Intent", "Operational")]
public void BoundarySlotHelpers_WhenDecisionSourceSlotsNeedLateRestabilization_ShouldRepairGatewayExitsAndDefaultDetours()
{
var evaluateConditions = new ElkPositionedNode
{
Id = "start/9",
Label = "Evaluate Conditions",
Kind = "Decision",
X = 2290,
Y = 32.25,
Width = 188,
Height = 132,
};
var internalNotification = new ElkPositionedNode
{
Id = "start/9/true/1",
Label = "Internal Notification",
Kind = "Decision",
X = 2662,
Y = 639.4360656738281,
Width = 188,
Height = 132,
};
var hasRecipients = new ElkPositionedNode
{
Id = "start/9/true/2",
Label = "Has Recipients",
Kind = "Decision",
X = 3778,
Y = 631.352783203125,
Width = 188,
Height = 132,
};
var end = new ElkPositionedNode
{
Id = "end",
Label = "End",
Kind = "End",
X = 4864,
Y = 331.8013916015625,
Width = 264,
Height = 132,
};
var toInternalNotification = new ElkRoutedEdge
{
Id = "edge/22",
SourceNodeId = evaluateConditions.Id,
TargetNodeId = internalNotification.Id,
Label = "when state.notificationHasBody",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 2416.700665574371, Y = 141.28995821373923 },
EndPoint = new ElkPoint { X = 2726, Y = 660.4998954610621 },
BendPoints =
[
new ElkPoint { X = 2437.418589349298, Y = 170.79730419621083 },
new ElkPoint { X = 2573.636363636364, Y = 170.79730419621083 },
new ElkPoint { X = 2573.636363636364, Y = 660.4998954610621 },
],
},
],
};
var defaultToEnd = new ElkRoutedEdge
{
Id = "edge/23",
SourceNodeId = evaluateConditions.Id,
TargetNodeId = end.Id,
Label = "default",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 2466.977224619808, Y = 105.98939547970912 },
EndPoint = new ElkPoint { X = 4888, Y = 331.8013916015625 },
BendPoints =
[
new ElkPoint { X = 4888, Y = 105.98939547970912 },
],
},
],
};
var defaultToRecipients = new ElkRoutedEdge
{
Id = "edge/25",
SourceNodeId = internalNotification.Id,
TargetNodeId = hasRecipients.Id,
Label = "default",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 2838.4874461780896, Y = 697.352783203125 },
EndPoint = new ElkPoint { X = 3849.7988202419156, Y = 646.940845586461 },
BendPoints =
[
new ElkPoint { X = 2838.4874461780896, Y = 631.4360656738281 },
new ElkPoint { X = 3849.7988202419156, Y = 631.4360656738281 },
],
},
],
};
var recipientsDefaultToEnd = new ElkRoutedEdge
{
Id = "edge/30",
SourceNodeId = hasRecipients.Id,
TargetNodeId = end.Id,
Label = "default",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 3936.565656565657, Y = 718.0194498697916 },
EndPoint = new ElkPoint { X = 4864, Y = 355.8013916015625 },
BendPoints =
[
new ElkPoint { X = 3974, Y = 718.0194498697916 },
new ElkPoint { X = 3974, Y = 625.039776972473 },
new ElkPoint { X = 4840, Y = 625.039776972473 },
new ElkPoint { X = 4840, Y = 355.8013916015625 },
],
},
],
};
var nodes = new[] { evaluateConditions, internalNotification, hasRecipients, end };
var edges = new[] { toInternalNotification, defaultToEnd, defaultToRecipients, recipientsDefaultToEnd };
var initialBoundarySlots = ElkEdgeRoutingScoring.CountBoundarySlotViolations(edges, nodes);
var initialGatewaySource = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(edges, nodes);
var initialEntry = ElkEdgeRoutingScoring.CountBadBoundaryAngles(edges, nodes);
var initialDetour = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(edges, nodes);
initialBoundarySlots.Should().BeGreaterThan(0);
initialGatewaySource.Should().BeGreaterThan(0);
initialDetour.Should().BeGreaterThanOrEqualTo(0);
var repaired = ElkEdgeRouterIterative.BuildFinalBoundarySlotCandidate(
edges,
nodes,
ElkLayoutDirection.LeftToRight,
53d,
["edge/22", "edge/23", "edge/25", "edge/30"]);
var repairedGatewaySource = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(repaired, nodes);
var repairedRecipientsDefaultToEnd = repaired.Single(edge => edge.Id == "edge/30");
var repairedRecipientsDefaultToEndPath = ExtractPath(repairedRecipientsDefaultToEnd);
var hasScoringCandidate = ElkEdgePostProcessor.TryBuildGatewaySourceScoringCandidate(
repairedRecipientsDefaultToEndPath,
hasRecipients,
nodes,
repairedRecipientsDefaultToEnd.SourceNodeId,
repairedRecipientsDefaultToEnd.TargetNodeId,
out var repairedRecipientsDefaultToEndScoringCandidate);
var scoringCandidateText = hasScoringCandidate
? string.Join(" -> ", repairedRecipientsDefaultToEndScoringCandidate.Select(point => $"({point.X:0.###},{point.Y:0.###})"))
: "<none>";
if (repairedGatewaySource > 0)
{
foreach (var edge in repaired)
{
var path = ExtractPath(edge);
TestContext.WriteLine($"{edge.Id}: {string.Join(" -> ", path.Select(point => $"({point.X:0.###},{point.Y:0.###})"))}");
}
}
ElkEdgeRoutingScoring.CountBoundarySlotViolations(repaired, nodes)
.Should().Be(0);
repairedGatewaySource.Should().Be(0);
ElkEdgePostProcessor.HasClearGatewaySourceScoringOpportunity(
repairedRecipientsDefaultToEndPath,
hasRecipients,
nodes,
repairedRecipientsDefaultToEnd.SourceNodeId,
repairedRecipientsDefaultToEnd.TargetNodeId)
.Should()
.BeFalse(
$"edge/30 path: {string.Join(" -> ", repairedRecipientsDefaultToEndPath.Select(point => $"({point.X:0.###},{point.Y:0.###})"))}; " +
$"scoring candidate: {scoringCandidateText}");
ElkEdgeRoutingScoring.CountBadBoundaryAngles(repaired, nodes)
.Should().BeLessThanOrEqualTo(initialEntry);
ElkEdgeRoutingScoring.CountExcessiveDetourViolations(repaired, nodes)
.Should().Be(0);
}
[Test]
[Property("Intent", "Operational")]
public void BoundarySlotHelpers_WhenLateSlottingReintroducesMixedFaceAndEntryViolations_ShouldRestabilizeWinnerGeometry()
{
var process = new ElkPositionedNode
{
Id = "start/2/branch-1/1",
Label = "Process Batch",
Kind = "Repeat",
X = 992,
Y = 247.181640625,
Width = 208,
Height = 88,
};
var validateSuccess = new ElkPositionedNode
{
Id = "start/2/branch-1/1/body/5/true/1",
Label = "Validate Success",
Kind = "Decision",
X = 3406,
Y = 225.181640625,
Width = 188,
Height = 132,
};
var join = new ElkPositionedNode
{
Id = "start/2/join",
Label = "Parallel Execution Join",
Kind = "Join",
X = 1290,
Y = 116.5908203125,
Width = 176,
Height = 124,
};
var internalNotification = new ElkPositionedNode
{
Id = "start/9/true/1/true/1",
Label = "Internal Notification",
Kind = "TransportCall",
X = 3034,
Y = 653.352783203125,
Width = 208,
Height = 88,
};
var handled = new ElkPositionedNode
{
Id = "start/9/true/1/true/1/handled/1",
Label = "Set internalNotificationFailed",
Kind = "SetState",
X = 3406,
Y = 653.352783203125,
Width = 208,
Height = 88,
};
var hasRecipients = new ElkPositionedNode
{
Id = "start/9/true/2",
Label = "Has Recipients",
Kind = "Decision",
X = 3778,
Y = 631.352783203125,
Width = 188,
Height = 132,
};
var end = new ElkPositionedNode
{
Id = "end",
Label = "End",
Kind = "End",
X = 4864,
Y = 331.8013916015625,
Width = 264,
Height = 132,
};
var repeatReturn = new ElkRoutedEdge
{
Id = "edge/15",
SourceNodeId = validateSuccess.Id,
TargetNodeId = process.Id,
Label = "repeat while state.printInsisAttempt eq 0",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 3420.73143444147, Y = 301.524988211567 },
EndPoint = new ElkPoint { X = 1200, Y = 269.181640625 },
BendPoints =
[
new ElkPoint { X = 3398, Y = 301.524988211567 },
new ElkPoint { X = 3398, Y = -190.659090909091 },
new ElkPoint { X = 1224, Y = -190.659090909091 },
new ElkPoint { X = 1224, Y = 269.181640625 },
],
},
],
};
var joinExit = new ElkRoutedEdge
{
Id = "edge/17",
SourceNodeId = process.Id,
TargetNodeId = join.Id,
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 1200, Y = 269.181640625 },
EndPoint = new ElkPoint { X = 1298.10526315789, Y = 198.485557154605 },
BendPoints =
[
new ElkPoint { X = 1248, Y = 269.181640625 },
new ElkPoint { X = 1248, Y = 248.5908203125 },
],
},
],
};
var directFailure = new ElkRoutedEdge
{
Id = "edge/26",
SourceNodeId = internalNotification.Id,
TargetNodeId = handled.Id,
Label = "on failure / timeout",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 3242, Y = 675.352783203125 },
EndPoint = new ElkPoint { X = 3406, Y = 697.352783203125 },
BendPoints =
[
new ElkPoint { X = 3406, Y = 675.352783203125 },
],
},
],
};
var notificationToRecipients = new ElkRoutedEdge
{
Id = "edge/27",
SourceNodeId = internalNotification.Id,
TargetNodeId = hasRecipients.Id,
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 3242, Y = 719.352783203125 },
EndPoint = new ElkPoint { X = 3792.73143444147, Y = 707.696130789692 },
BendPoints =
[
new ElkPoint { X = 3253.63636363636, Y = 719.352783203125 },
new ElkPoint { X = 3253.63636363636, Y = 574.708792946555 },
new ElkPoint { X = 3814.59988578289, Y = 574.708792946555 },
new ElkPoint { X = 3814.59988578289, Y = 769.900087399336 },
],
},
],
};
var recipientsToEnd = new ElkRoutedEdge
{
Id = "edge/30",
SourceNodeId = hasRecipients.Id,
TargetNodeId = end.Id,
Label = "default",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 3940.02631578947, Y = 679.115941097862 },
EndPoint = new ElkPoint { X = 4864, Y = 439.801391601563 },
BendPoints =
[
new ElkPoint { X = 3974, Y = 679.115941097862 },
new ElkPoint { X = 3974, Y = 439.801391601563 },
],
},
],
};
var nodes = new[] { process, validateSuccess, join, internalNotification, handled, hasRecipients, end };
var edges = new[] { repeatReturn, joinExit, directFailure, notificationToRecipients, recipientsToEnd };
var initialSharedLanes = ElkEdgeRoutingScoring.CountSharedLaneViolations(edges, nodes);
var initialEntry = ElkEdgeRoutingScoring.CountBadBoundaryAngles(edges, nodes);
var initialBoundarySlots = ElkEdgeRoutingScoring.CountBoundarySlotViolations(edges, nodes);
var initialDetour = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(edges, nodes);
initialSharedLanes.Should().BeGreaterThan(0);
initialEntry.Should().BeGreaterThan(0);
var mixedFaceOnly = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(
edges,
nodes,
53d,
["edge/15", "edge/17"]);
ElkEdgeRoutingScoring.CountSharedLaneViolations(mixedFaceOnly, nodes)
.Should().Be(0);
var terminalOnly = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(
edges,
nodes,
53d,
["edge/26", "edge/27", "edge/30"]);
terminalOnly = ElkEdgePostProcessor.NormalizeBoundaryAngles(terminalOnly, nodes);
ElkEdgeRoutingScoring.CountBadBoundaryAngles(terminalOnly, nodes)
.Should().Be(0);
var repaired = ElkEdgeRouterIterative.BuildFinalRestabilizedCandidate(
edges,
nodes,
ElkLayoutDirection.LeftToRight,
53d,
["edge/15", "edge/17", "edge/26", "edge/27", "edge/30"]);
ElkEdgeRoutingScoring.CountSharedLaneViolations(repaired, nodes)
.Should().BeLessThan(initialSharedLanes);
var repairedBadAngles = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountBadBoundaryAngles(repaired, nodes, repairedBadAngles, 10)
.Should().BeLessThan(initialEntry, $"remaining bad-angle edges: {string.Join(", ", repairedBadAngles.Keys.OrderBy(id => id, StringComparer.Ordinal))}");
ElkEdgeRoutingScoring.CountBoundarySlotViolations(repaired, nodes)
.Should().BeLessThanOrEqualTo(initialBoundarySlots);
ElkEdgeRoutingScoring.CountExcessiveDetourViolations(repaired, nodes)
.Should().BeLessThanOrEqualTo(initialDetour);
}
}

View File

@@ -801,6 +801,41 @@ internal static partial class ElkEdgePostProcessor
finalTargetNode,
finalTargetSlot.Side,
finalTargetSlot.Boundary);
if (ElkShapeBoundaries.IsGatewayShape(finalTargetNode)
&& !CanAcceptGatewayTargetRepair(finalStrictTargetCandidate, finalTargetNode))
{
var forcedGatewayTargetCandidate = ForceGatewayExteriorTargetApproach(
finalStrictTargetCandidate,
finalTargetNode,
finalTargetSlot.Boundary);
forcedGatewayTargetCandidate = PreferGatewayDiagonalTargetEntry(
forcedGatewayTargetCandidate,
finalTargetNode);
if (CanAcceptGatewayTargetRepair(forcedGatewayTargetCandidate, finalTargetNode)
&& HasAcceptableGatewayBoundaryPath(
forcedGatewayTargetCandidate,
nodes,
edge.SourceNodeId,
edge.TargetNodeId,
finalTargetNode,
fromStart: false))
{
finalStrictTargetCandidate = forcedGatewayTargetCandidate;
}
}
var finalTargetCandidateAccepted = ElkShapeBoundaries.IsGatewayShape(finalTargetNode)
? CanAcceptGatewayTargetRepair(finalStrictTargetCandidate, finalTargetNode)
&& HasAcceptableGatewayBoundaryPath(
finalStrictTargetCandidate,
nodes,
edge.SourceNodeId,
edge.TargetNodeId,
finalTargetNode,
fromStart: false)
: finalStrictTargetCandidate.Count >= 2
&& HasValidBoundaryAngle(finalStrictTargetCandidate[^1], finalStrictTargetCandidate[^2], finalTargetNode)
&& !HasTargetApproachBacktracking(finalStrictTargetCandidate, finalTargetNode);
var currentEdgeLayout = BuildBoundarySlotEvaluationLayout(
edges,
result,
@@ -815,11 +850,16 @@ internal static partial class ElkEdgePostProcessor
ResolveTargetApproachSide(finalStrictTargetCandidate, finalTargetNode),
finalTargetSlot.Side,
StringComparison.Ordinal)
&& finalTargetCandidateAccepted
&& !HasNodeObstacleCrossing(finalStrictTargetCandidate, nodes, edge.SourceNodeId, edge.TargetNodeId)
&& ElkEdgeRoutingScoring.CountBoundarySlotViolations(candidateEdgeLayout, nodes)
< ElkEdgeRoutingScoring.CountBoundarySlotViolations(currentEdgeLayout, nodes)
&& ElkEdgeRoutingScoring.CountBadBoundaryAngles(candidateEdgeLayout, nodes)
<= ElkEdgeRoutingScoring.CountBadBoundaryAngles(currentEdgeLayout, nodes)
&& ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(candidateEdgeLayout, nodes)
<= ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(currentEdgeLayout, nodes)
&& ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(candidateEdgeLayout, nodes)
<= ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(currentEdgeLayout, nodes)
&& ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(candidateEdgeLayout, nodes)
<= ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(currentEdgeLayout, nodes)
&& ElkEdgeRoutingScoring.CountEdgeNodeCrossings(candidateEdgeLayout, nodes, null)
@@ -1574,6 +1614,13 @@ internal static partial class ElkEdgePostProcessor
var targetAxis = double.IsNaN(axisValue)
? ResolveDefaultTargetApproachAxis(targetNode, side)
: axisValue;
if (ElkShapeBoundaries.IsGatewayShape(targetNode)
&& !preserveExistingApproachAxis
&& TryResolveDiagonalTargetApproachAxis(path, side, out var diagonalTargetAxis))
{
targetAxis = diagonalTargetAxis;
}
List<ElkPoint> normalized;
if (ElkShapeBoundaries.IsGatewayShape(targetNode))
{
@@ -1605,14 +1652,17 @@ internal static partial class ElkEdgePostProcessor
if (!TryExtractTargetApproachFeeder(normalized, side, out _))
{
if (ElkShapeBoundaries.IsGatewayShape(targetNode)
&& preserveExistingApproachAxis)
if (ElkShapeBoundaries.IsGatewayShape(targetNode))
{
var orthogonalFallback = RewriteTargetApproachRun(
path,
var desiredBand = side is "left" or "right"
? desiredEndpoint.Y
: desiredEndpoint.X;
var orthogonalFallback = RewriteTargetApproachBand(
normalized,
side,
desiredEndpoint,
targetAxis);
desiredBand,
targetAxis,
targetNode);
if (CanAcceptGatewayTargetRepair(orthogonalFallback, targetNode))
{
return orthogonalFallback;
@@ -1622,11 +1672,32 @@ internal static partial class ElkEdgePostProcessor
orthogonalFallback,
targetNode,
desiredEndpoint);
forcedOrthogonalFallback = PreferGatewayDiagonalTargetEntry(forcedOrthogonalFallback, targetNode);
if (CanAcceptGatewayTargetRepair(forcedOrthogonalFallback, targetNode))
{
return forcedOrthogonalFallback;
}
if (preserveExistingApproachAxis)
{
orthogonalFallback = RewriteTargetApproachRun(
path,
side,
desiredEndpoint,
targetAxis);
if (CanAcceptGatewayTargetRepair(orthogonalFallback, targetNode))
{
return orthogonalFallback;
}
forcedOrthogonalFallback = ForceGatewayExteriorTargetApproach(
orthogonalFallback,
targetNode,
desiredEndpoint);
if (CanAcceptGatewayTargetRepair(forcedOrthogonalFallback, targetNode))
{
return forcedOrthogonalFallback;
}
}
}
return normalized;
@@ -2129,6 +2200,33 @@ internal static partial class ElkEdgePostProcessor
return true;
}
private static bool TryResolveDiagonalTargetApproachAxis(
IReadOnlyList<ElkPoint> path,
string side,
out double axis)
{
axis = double.NaN;
if (!TryExtractTargetApproachRun(path, side, out var runStartIndex, out _)
|| runStartIndex < 1)
{
return false;
}
var previous = path[runStartIndex - 1];
var runStart = path[runStartIndex];
const double coordinateTolerance = 0.5d;
if (Math.Abs(previous.X - runStart.X) <= coordinateTolerance
|| Math.Abs(previous.Y - runStart.Y) <= coordinateTolerance)
{
return false;
}
axis = side is "left" or "right"
? previous.X
: previous.Y;
return !double.IsNaN(axis);
}
private static bool TryExtractTargetApproachBand(
IReadOnlyList<ElkPoint> path,
string side,
@@ -3222,4 +3320,4 @@ internal static partial class ElkEdgePostProcessor
return false;
}
}
}

View File

@@ -43,6 +43,22 @@ internal static partial class ElkEdgePostProcessor
var preserveSourceExit = ShouldPreserveSourceExitGeometry(edge, graphMinY, graphMaxY);
bool CanAcceptSourceCandidate(IReadOnlyList<ElkPoint> candidatePath)
{
if (!PathChanged(normalized, candidatePath))
{
return false;
}
var currentBoundarySlots = ElkEdgeRoutingScoring.CountBoundarySlotViolations(
[BuildSingleSectionEdge(edge, normalized)],
nodes);
var candidateBoundarySlots = ElkEdgeRoutingScoring.CountBoundarySlotViolations(
[BuildSingleSectionEdge(edge, candidatePath)],
nodes);
return candidateBoundarySlots <= currentBoundarySlots;
}
if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)
&& ElkShapeBoundaries.IsGatewayShape(sourceNode)
&& NeedsGatewaySourceBoundaryRepair(normalized, sourceNode))
@@ -55,7 +71,7 @@ internal static partial class ElkEdgePostProcessor
nodes,
edge.SourceNodeId,
edge.TargetNodeId);
if (PathChanged(normalized, sourceRepaired)
if (CanAcceptSourceCandidate(sourceRepaired)
&& HasAcceptableGatewayBoundaryPath(sourceRepaired, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true))
{
normalized = sourceRepaired;
@@ -98,7 +114,7 @@ internal static partial class ElkEdgePostProcessor
nodes,
edge.SourceNodeId,
edge.TargetNodeId);
if (PathChanged(normalized, sourceRepaired)
if (CanAcceptSourceCandidate(sourceRepaired)
&& HasAcceptableGatewayBoundaryPath(sourceRepaired, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true))
{
normalized = sourceRepaired;
@@ -163,7 +179,7 @@ internal static partial class ElkEdgePostProcessor
nodes,
edge.SourceNodeId,
edge.TargetNodeId);
if (PathChanged(normalized, protectedExitFixed)
if (CanAcceptSourceCandidate(protectedExitFixed)
&& HasAcceptableGatewayBoundaryPath(protectedExitFixed, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true)
&& !HasGatewaySourceExitBacktracking(protectedExitFixed)
&& !HasGatewaySourceExitCurl(protectedExitFixed))
@@ -180,7 +196,7 @@ internal static partial class ElkEdgePostProcessor
nodes,
edge.SourceNodeId,
edge.TargetNodeId);
if (PathChanged(normalized, directExitFixed)
if (CanAcceptSourceCandidate(directExitFixed)
&& HasAcceptableGatewayBoundaryPath(directExitFixed, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true)
&& !HasGatewaySourceExitBacktracking(directExitFixed)
&& !HasGatewaySourceExitCurl(directExitFixed)
@@ -199,7 +215,7 @@ internal static partial class ElkEdgePostProcessor
nodes,
edge.SourceNodeId,
edge.TargetNodeId);
if (PathChanged(normalized, diagonalExitFixed)
if (CanAcceptSourceCandidate(diagonalExitFixed)
&& HasAcceptableGatewayBoundaryPath(diagonalExitFixed, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true)
&& HasClearBoundarySegments(diagonalExitFixed, nodes, edge.SourceNodeId, edge.TargetNodeId, true, Math.Min(4, diagonalExitFixed.Count - 1))
&& !HasGatewaySourceExitBacktracking(diagonalExitFixed)
@@ -211,7 +227,7 @@ internal static partial class ElkEdgePostProcessor
}
var faceFixed = FixGatewaySourcePreferredFace(normalized, sourceNode);
if (PathChanged(normalized, faceFixed)
if (CanAcceptSourceCandidate(faceFixed)
&& HasAcceptableGatewayBoundaryPath(faceFixed, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true))
{
normalized = faceFixed;
@@ -219,7 +235,7 @@ internal static partial class ElkEdgePostProcessor
}
var curlFixed = FixGatewaySourceExitCurl(normalized, sourceNode);
if (PathChanged(normalized, curlFixed)
if (CanAcceptSourceCandidate(curlFixed)
&& HasAcceptableGatewayBoundaryPath(curlFixed, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true)
&& !HasGatewaySourceExitCurl(curlFixed))
{
@@ -228,7 +244,7 @@ internal static partial class ElkEdgePostProcessor
}
var dominantAxisFixed = FixGatewaySourceDominantAxisDetour(normalized, sourceNode);
if (PathChanged(normalized, dominantAxisFixed)
if (CanAcceptSourceCandidate(dominantAxisFixed)
&& HasAcceptableGatewayBoundaryPath(dominantAxisFixed, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true)
&& !HasGatewaySourceExitBacktracking(dominantAxisFixed)
&& !HasGatewaySourceExitCurl(dominantAxisFixed)
@@ -242,7 +258,7 @@ internal static partial class ElkEdgePostProcessor
if (HasGatewaySourcePreferredFaceMismatch(normalized, sourceNode))
{
var forceAligned = ForceGatewaySourcePreferredFaceAlignment(normalized, sourceNode);
if (PathChanged(normalized, forceAligned)
if (CanAcceptSourceCandidate(forceAligned)
&& HasAcceptableGatewayBoundaryPath(forceAligned, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true)
&& !HasGatewaySourceExitBacktracking(forceAligned)
&& !HasGatewaySourceExitCurl(forceAligned)
@@ -260,7 +276,7 @@ internal static partial class ElkEdgePostProcessor
nodes,
edge.SourceNodeId,
edge.TargetNodeId);
if (PathChanged(normalized, finalDirectExit)
if (CanAcceptSourceCandidate(finalDirectExit)
&& HasAcceptableGatewayBoundaryPath(finalDirectExit, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true)
&& !HasGatewaySourceExitBacktracking(finalDirectExit)
&& !HasGatewaySourceExitCurl(finalDirectExit)
@@ -334,7 +350,7 @@ internal static partial class ElkEdgePostProcessor
nodes,
edge.SourceNodeId,
edge.TargetNodeId);
if (PathChanged(normalized, lateSourceRepaired)
if (CanAcceptSourceCandidate(lateSourceRepaired)
&& HasAcceptableGatewayBoundaryPath(lateSourceRepaired, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true)
&& !HasGatewaySourceExitBacktracking(lateSourceRepaired)
&& !HasGatewaySourceExitCurl(lateSourceRepaired)
@@ -408,13 +424,31 @@ internal static partial class ElkEdgePostProcessor
if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out sourceNode)
&& ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
if (PathStartsAtDecisionVertex(normalized, sourceNode))
{
var offVertexSourceRepair = ForceDecisionSourceExitOffVertex(
normalized,
sourceNode,
nodes,
edge.SourceNodeId,
edge.TargetNodeId);
if (CanAcceptSourceCandidate(offVertexSourceRepair)
&& HasAcceptableGatewayBoundaryPath(offVertexSourceRepair, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true)
&& !HasGatewaySourceExitBacktracking(offVertexSourceRepair)
&& !HasGatewaySourceExitCurl(offVertexSourceRepair))
{
normalized = offVertexSourceRepair;
changed = true;
}
}
var finalSourceRepair = EnforceGatewaySourceExitQuality(
normalized,
sourceNode,
nodes,
edge.SourceNodeId,
edge.TargetNodeId);
if (PathChanged(normalized, finalSourceRepair))
if (CanAcceptSourceCandidate(finalSourceRepair))
{
normalized = finalSourceRepair;
changed = true;
@@ -536,7 +570,7 @@ internal static partial class ElkEdgePostProcessor
var continuationPoint = path[continuationIndex];
var boundary = sourceNode.Kind == "Decision"
? ResolveDecisionSourceExitBoundary(sourceNode, continuationPoint, continuationPoint)
: ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(
: PreferGatewaySourceExitBoundary(
sourceNode,
ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, continuationPoint),
continuationPoint);
@@ -903,7 +937,7 @@ internal static partial class ElkEdgePostProcessor
return path;
}
preferredBoundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(sourceNode, preferredBoundary, path[^1]);
preferredBoundary = PreferGatewaySourceExitBoundary(sourceNode, preferredBoundary, path[^1]);
var firstExteriorIndex = FindFirstGatewayExteriorPointIndex(path, sourceNode);
var continuationPoint = path[firstExteriorIndex];
var adjacentPoint = ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, preferredBoundary, continuationPoint);
@@ -1585,6 +1619,122 @@ internal static partial class ElkEdgePostProcessor
return references;
}
private static ElkPoint PreferGatewaySourceExitBoundary(
ElkPositionedNode sourceNode,
ElkPoint boundaryPoint,
ElkPoint anchor)
{
var preferred = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(sourceNode, boundaryPoint, anchor);
if (!ElkShapeBoundaries.IsGatewayShape(sourceNode)
|| !ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, preferred, 8d)
|| !ElkShapeBoundaries.IsAllowedGatewayTipVertex(sourceNode, preferred, 8d))
{
return preferred;
}
var polygon = ElkShapeBoundaries.BuildGatewayBoundaryPoints(sourceNode);
var nearestVertexIndex = -1;
var nearestVertexDistance = double.PositiveInfinity;
for (var index = 0; index < polygon.Count; index++)
{
var vertex = polygon[index];
var deltaX = preferred.X - vertex.X;
var deltaY = preferred.Y - vertex.Y;
var distance = Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY));
if (distance >= nearestVertexDistance)
{
continue;
}
nearestVertexDistance = distance;
nearestVertexIndex = index;
}
if (nearestVertexIndex < 0)
{
return preferred;
}
var vertexPoint = polygon[nearestVertexIndex];
var previousVertex = polygon[(nearestVertexIndex - 1 + polygon.Count) % polygon.Count];
var nextVertex = polygon[(nearestVertexIndex + 1) % polygon.Count];
var projectedAnchor = ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, anchor);
var bestCandidate = preferred;
var bestScore = double.PositiveInfinity;
foreach (var candidate in new[]
{
InterpolateGatewayBoundaryVertex(vertexPoint, previousVertex, sourceNode.Kind == "Decision" ? 18d : 14d),
InterpolateGatewayBoundaryVertex(vertexPoint, nextVertex, sourceNode.Kind == "Decision" ? 18d : 14d),
})
{
if (!ElkShapeBoundaries.IsGatewayBoundaryPoint(sourceNode, candidate, 2.5d)
|| ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, candidate, 3d))
{
continue;
}
var score = ScoreGatewaySourceBoundaryCandidate(sourceNode, anchor, projectedAnchor, candidate);
if (score >= bestScore)
{
continue;
}
bestScore = score;
bestCandidate = candidate;
}
return bestCandidate;
}
private static ElkPoint InterpolateGatewayBoundaryVertex(
ElkPoint vertexPoint,
ElkPoint adjacentVertex,
double forcedOffset)
{
var deltaX = adjacentVertex.X - vertexPoint.X;
var deltaY = adjacentVertex.Y - vertexPoint.Y;
var length = Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY));
if (length <= 0.001d)
{
return vertexPoint;
}
var offset = Math.Min(Math.Max(length - 0.5d, 0.5d), forcedOffset);
var scale = offset / length;
return new ElkPoint
{
X = vertexPoint.X + (deltaX * scale),
Y = vertexPoint.Y + (deltaY * scale),
};
}
private static double ScoreGatewaySourceBoundaryCandidate(
ElkPositionedNode sourceNode,
ElkPoint anchor,
ElkPoint projectedAnchor,
ElkPoint candidate)
{
var towardCenterX = (sourceNode.X + (sourceNode.Width / 2d)) - anchor.X;
var towardCenterY = (sourceNode.Y + (sourceNode.Height / 2d)) - anchor.Y;
var candidateDeltaX = candidate.X - anchor.X;
var candidateDeltaY = candidate.Y - anchor.Y;
var towardDot = (candidateDeltaX * towardCenterX) + (candidateDeltaY * towardCenterY);
if (towardDot <= 0d)
{
return double.PositiveInfinity;
}
var absDx = Math.Abs(candidateDeltaX);
var absDy = Math.Abs(candidateDeltaY);
var isDiagonal = absDx >= 3d && absDy >= 3d;
var diagonalPenalty = isDiagonal
? Math.Abs(absDx - absDy)
: 10_000d;
var projectedDistance = Math.Abs(candidate.X - projectedAnchor.X) + Math.Abs(candidate.Y - projectedAnchor.Y);
var segmentLength = Math.Sqrt((candidateDeltaX * candidateDeltaX) + (candidateDeltaY * candidateDeltaY));
return diagonalPenalty + (segmentLength * 0.05d) + (projectedDistance * 0.1d);
}
private static IEnumerable<ElkPoint> ResolveGatewayExitBoundaryCandidates(
ElkPositionedNode sourceNode,
ElkPoint exitReference)
@@ -1592,7 +1742,7 @@ internal static partial class ElkEdgePostProcessor
var candidates = new List<ElkPoint>();
AddUniquePoint(
candidates,
ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(
PreferGatewaySourceExitBoundary(
sourceNode,
ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, exitReference),
exitReference));
@@ -1611,7 +1761,7 @@ internal static partial class ElkEdgePostProcessor
AddUniquePoint(
candidates,
ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(sourceNode, slotBoundary, exitReference));
PreferGatewaySourceExitBoundary(sourceNode, slotBoundary, exitReference));
}
return candidates;
@@ -1728,7 +1878,7 @@ internal static partial class ElkEdgePostProcessor
IReadOnlyList<ElkPoint> path,
ElkPositionedNode sourceNode)
{
return sourceNode.Kind == "Decision"
return ElkShapeBoundaries.IsGatewayShape(sourceNode)
&& path.Count >= 2
&& ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, path[0]);
}
@@ -1743,7 +1893,31 @@ internal static partial class ElkEdgePostProcessor
var path = sourcePath
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToList();
if (path.Count < 3 || sourceNode.Kind != "Decision")
if (!ElkShapeBoundaries.IsGatewayShape(sourceNode) || path.Count < 2)
{
return path;
}
if (ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, path[0], 8d))
{
var offVertexCandidate = TryBuildGatewaySourceOffVertexCandidate(
path,
sourceNode,
nodes,
sourceNodeId,
targetNodeId);
if (PathChanged(path, offVertexCandidate))
{
path = offVertexCandidate;
}
}
if (sourceNode.Kind != "Decision" || path.Count < 3)
{
return path;
}
if (ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, path[0], 8d))
{
return path;
}
@@ -1751,11 +1925,6 @@ internal static partial class ElkEdgePostProcessor
var continuationIndex = Math.Min(path.Count - 1, 2);
var reference = path[^1];
var boundary = ResolveDecisionSourceExitBoundary(sourceNode, path[continuationIndex], reference);
if (ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, boundary))
{
return path;
}
var continuationPoint = path[continuationIndex];
var exteriorApproach = ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, boundary, continuationPoint);
var rebuilt = new List<ElkPoint> { boundary };
@@ -1783,16 +1952,99 @@ internal static partial class ElkEdgePostProcessor
return NormalizePathPoints(rebuilt);
}
private static List<ElkPoint> TryBuildGatewaySourceOffVertexCandidate(
IReadOnlyList<ElkPoint> sourcePath,
ElkPositionedNode sourceNode,
IReadOnlyCollection<ElkPositionedNode> nodes,
string? sourceNodeId,
string? targetNodeId)
{
var path = sourcePath
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToList();
if (!ElkShapeBoundaries.IsGatewayShape(sourceNode)
|| path.Count < 2
|| !ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, path[0], 8d))
{
return path;
}
var firstExteriorIndex = FindFirstGatewayExteriorPointIndex(path, sourceNode);
var continuationIndex = FindPreferredGatewayExitContinuationIndex(path, sourceNode, firstExteriorIndex);
var continuationPoint = path[continuationIndex];
var boundaryCandidates = new List<ElkPoint>();
if (sourceNode.Kind == "Decision")
{
AddDecisionBoundaryCandidate(
boundaryCandidates,
ResolveDecisionSourceExitBoundary(sourceNode, continuationPoint, path[^1]));
}
foreach (var boundaryCandidate in ResolveGatewayExitBoundaryCandidates(sourceNode, path[^1]))
{
AddDecisionBoundaryCandidate(boundaryCandidates, boundaryCandidate);
}
foreach (var boundaryCandidate in ResolveGatewayExitBoundaryCandidates(sourceNode, continuationPoint))
{
AddDecisionBoundaryCandidate(boundaryCandidates, boundaryCandidate);
}
List<ElkPoint>? bestCandidate = null;
var bestScore = double.PositiveInfinity;
foreach (var boundaryCandidate in boundaryCandidates)
{
if (ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, boundaryCandidate, 3d))
{
continue;
}
var candidate = BuildGatewaySourceRepairPath(
path,
sourceNode,
boundaryCandidate,
continuationPoint,
continuationIndex,
path[^1],
nodes,
sourceNodeId,
targetNodeId);
if (!PathChanged(path, candidate)
|| !HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true)
|| HasGatewaySourceExitBacktracking(candidate)
|| HasGatewaySourceExitCurl(candidate)
|| HasGatewaySourceDominantAxisDetour(candidate, sourceNode)
|| HasGatewaySourcePreferredFaceMismatch(candidate, sourceNode)
|| NeedsGatewaySourceBoundaryRepair(candidate, sourceNode)
|| HasGatewaySourceLeadIntoDominantBlocker(candidate, sourceNode, nodes, sourceNodeId, targetNodeId))
{
continue;
}
var score = ComputePathLength(candidate) + (Math.Max(0, candidate.Count - 2) * 4d);
if (score >= bestScore)
{
continue;
}
bestScore = score;
bestCandidate = candidate;
}
return bestCandidate ?? path;
}
private static ElkPoint ResolveDecisionSourceExitBoundary(
ElkPositionedNode sourceNode,
ElkPoint continuationPoint,
ElkPoint reference)
{
var projectedReference = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(
var projectedReference = PreferGatewaySourceExitBoundary(
sourceNode,
ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, reference),
reference);
var projectedContinuation = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(
var projectedContinuation = PreferGatewaySourceExitBoundary(
sourceNode,
ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, continuationPoint),
continuationPoint);
@@ -2622,7 +2874,7 @@ internal static partial class ElkEdgePostProcessor
else
{
boundary = ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, continuationPoint);
boundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(sourceNode, boundary, continuationPoint);
boundary = PreferGatewaySourceExitBoundary(sourceNode, boundary, continuationPoint);
}
var normalized = BuildGatewaySourceRepairPath(
@@ -2799,7 +3051,7 @@ internal static partial class ElkEdgePostProcessor
var corridorPoint = path[corridorIndex];
var boundary = sourceNode.Kind == "Decision"
? ResolveDecisionSourceExitBoundary(sourceNode, corridorPoint, corridorPoint)
: ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(
: PreferGatewaySourceExitBoundary(
sourceNode,
ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, corridorPoint),
corridorPoint);
@@ -3027,7 +3279,7 @@ internal static partial class ElkEdgePostProcessor
AddUniquePoint(
boundaryCandidates,
ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(
PreferGatewaySourceExitBoundary(
sourceNode,
ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, continuationPoint),
continuationPoint));
@@ -3790,6 +4042,18 @@ internal static partial class ElkEdgePostProcessor
normalized = realignedTargetCandidate;
}
if (!HasValidBoundaryAngle(normalized[^1], normalized[^2], targetNode))
{
var preservedSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[^1], targetNode);
var preservedEndpointRepair = NormalizeEntryPath(normalized, targetNode, preservedSide, normalized[^1]);
if (HasClearBoundarySegments(preservedEndpointRepair, nodes, sourceNodeId, targetNodeId, false, 3)
&& HasValidBoundaryAngle(preservedEndpointRepair[^1], preservedEndpointRepair[^2], targetNode))
{
normalized = preservedEndpointRepair;
return true;
}
}
if (HasValidBoundaryAngle(normalized[^1], normalized[^2], targetNode))
{
return true;
@@ -4114,7 +4378,7 @@ internal static partial class ElkEdgePostProcessor
boundary = sourceNode.Kind == "Decision"
? ResolveDecisionSourceExitBoundary(sourceNode, continuationPoint, referencePoint)
: ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(
: PreferGatewaySourceExitBoundary(
sourceNode,
ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, continuationPoint),
continuationPoint);
@@ -5114,7 +5378,7 @@ internal static partial class ElkEdgePostProcessor
return false;
}
boundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(sourceNode, boundary, continuationPoint);
boundary = PreferGatewaySourceExitBoundary(sourceNode, boundary, continuationPoint);
return true;
}
@@ -5132,7 +5396,7 @@ internal static partial class ElkEdgePostProcessor
continue;
}
boundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(sourceNode, boundary, continuationPoint);
boundary = PreferGatewaySourceExitBoundary(sourceNode, boundary, continuationPoint);
AddUniquePoint(candidates, boundary);
}
@@ -5896,6 +6160,26 @@ internal static partial class ElkEdgePostProcessor
{
return false;
}
if (isOutgoing)
{
var firstExteriorIndex = FindFirstGatewayExteriorPointIndex(path, node);
var continuationIndex = FindPreferredGatewayExitContinuationIndex(path, node, firstExteriorIndex);
var continuationPoint = path[continuationIndex];
if (TryResolvePreferredGatewaySourceBoundary(node, continuationPoint, path[^1], out var preferredBoundary))
{
var preferredPath = path
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToList();
preferredPath[0] = preferredBoundary;
if (string.Equals(ResolveSourceDepartureSide(preferredPath, node), side, StringComparison.Ordinal))
{
boundary = preferredBoundary;
return true;
}
}
}
var slotCoordinates = ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates(node, side, 1);
if (slotCoordinates.Length == 0)
{

View File

@@ -1329,7 +1329,11 @@ internal static partial class ElkEdgePostProcessor
.Where(node =>
{
var distanceBelowNode = start.Y - (node.Y + node.Height);
return distanceBelowNode > 0.5d && distanceBelowNode < minClearance;
var isBelow = distanceBelowNode > 0.5d && distanceBelowNode < minClearance;
// Match the scorer's flush-bottom rule so the repair pipeline
// can actually respond to the same artifact the final score sees.
var isFlushBottom = distanceBelowNode >= -4d && distanceBelowNode <= 0.5d;
return isBelow || isFlushBottom;
})
.ToArray();

View File

@@ -288,7 +288,8 @@ internal static partial class ElkEdgePostProcessor
internal static ElkRoutedEdge[] NormalizeBoundaryAngles(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes)
ElkPositionedNode[] nodes,
bool snapToSlots = false)
{
if (edges.Length == 0 || nodes.Length == 0)
{
@@ -405,6 +406,28 @@ internal static partial class ElkEdgePostProcessor
}
}
// When snapToSlots is enabled, snap normalized endpoints to the
// nearest boundary slot so normalization does not drift endpoints
// off the discrete slot lattice.
if (snapToSlots && normalized.Count >= 2)
{
if (!preserveSourceExit
&& nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var snapSourceNode)
&& !ElkShapeBoundaries.IsGatewayShape(snapSourceNode))
{
SnapNormalizedEndpointWithAdjacent(
normalized, 0, snapSourceNode, edges, nodesById, isSource: true);
}
var snapTargetNodeId = edge.TargetNodeId ?? string.Empty;
if (nodesById.TryGetValue(snapTargetNodeId, out var snapTargetNode)
&& !ElkShapeBoundaries.IsGatewayShape(snapTargetNode))
{
SnapNormalizedEndpointWithAdjacent(
normalized, normalized.Count - 1, snapTargetNode, edges, nodesById, isSource: false);
}
}
if (normalized.Count == path.Count
&& normalized.Zip(path, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal))
{
@@ -447,7 +470,8 @@ internal static partial class ElkEdgePostProcessor
internal static ElkRoutedEdge[] NormalizeSourceExitAngles(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes)
ElkPositionedNode[] nodes,
bool snapToSlots = false)
{
if (edges.Length == 0 || nodes.Length == 0)
{
@@ -560,6 +584,14 @@ internal static partial class ElkEdgePostProcessor
continue;
}
// When snapToSlots is enabled, snap the source endpoint to the
// nearest boundary slot after normalization is finalized.
if (snapToSlots && normalized.Count >= 2 && !ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
SnapNormalizedEndpointWithAdjacent(
normalized, 0, sourceNode, edges, nodesById, isSource: true);
}
if (normalized.Count == path.Count
&& normalized.Zip(path, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal))
{
@@ -593,6 +625,100 @@ internal static partial class ElkEdgePostProcessor
return result;
}
/// <summary>
/// Snaps both source and target endpoints of all edges to the nearest boundary
/// slot coordinate. Designed to run once after normalization passes have
/// stabilized, not during every normalization call.
/// </summary>
internal static ElkRoutedEdge[] SnapNormalizedEndpointsToSlots(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes)
{
if (edges.Length == 0 || nodes.Length == 0)
{
return edges;
}
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
var changed = false;
var result = new ElkRoutedEdge[edges.Length];
for (var i = 0; i < edges.Length; i++)
{
var edge = edges[i];
var path = ExtractFullPath(edge);
if (path.Count < 2)
{
result[i] = edge;
continue;
}
var normalized = path
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToList();
var edgeChanged = false;
if (!string.IsNullOrWhiteSpace(edge.SourceNodeId)
&& string.IsNullOrWhiteSpace(edge.SourcePortId)
&& nodesById.TryGetValue(edge.SourceNodeId, out var sourceNode)
&& !ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
var before = normalized[0];
SnapNormalizedEndpointWithAdjacent(
normalized, 0, sourceNode, edges, nodesById, isSource: true);
if (!ElkEdgeRoutingGeometry.PointsEqual(before, normalized[0]))
{
edgeChanged = true;
}
}
if (!string.IsNullOrWhiteSpace(edge.TargetNodeId)
&& string.IsNullOrWhiteSpace(edge.TargetPortId)
&& nodesById.TryGetValue(edge.TargetNodeId, out var targetNode)
&& !ElkShapeBoundaries.IsGatewayShape(targetNode))
{
var before = normalized[^1];
SnapNormalizedEndpointWithAdjacent(
normalized, normalized.Count - 1, targetNode, edges, nodesById, isSource: false);
if (!ElkEdgeRoutingGeometry.PointsEqual(before, normalized[^1]))
{
edgeChanged = true;
}
}
if (!edgeChanged)
{
result[i] = edge;
continue;
}
changed = true;
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 changed ? result : edges;
}
internal static ElkRoutedEdge[] FinalizeDecisionTargetEntries(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
@@ -1474,4 +1600,121 @@ internal static partial class ElkEdgePostProcessor
return NormalizePathPoints(result);
}
/// <summary>
/// After normalization adjusts an endpoint position, snap it to the nearest
/// discrete boundary slot so it stays on the slot lattice. Returns null if
/// no snap is needed (the point is already on a slot or no slot is close enough).
/// Uses the full slot capacity of the face (not the current edge count) to avoid
/// instability when edge face assignments change during intermediate passes.
/// </summary>
private static ElkPoint? SnapNormalizedEndpointToSlot(
ElkPoint endpoint,
ElkPositionedNode node,
IReadOnlyCollection<ElkRoutedEdge> allEdges,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
bool isSource)
{
var side = ElkEdgeRoutingGeometry.ResolveBoundarySide(endpoint, node);
if (side is not ("left" or "right" or "top" or "bottom"))
{
return null;
}
// Use the full slot capacity for the face rather than counting current edges.
// This produces a stable lattice that does not shift as edges move between faces.
var capacity = ElkBoundarySlots.ResolveBoundarySlotCapacity(node, side);
if (capacity <= 0)
{
return null;
}
var slotCoordinates = ElkBoundarySlots.BuildUniqueBoundarySlotCoordinates(node, side, capacity);
if (slotCoordinates.Length == 0)
{
return null;
}
// Determine the axis coordinate of the current endpoint on the face.
var endpointCoord = side is "left" or "right" ? endpoint.Y : endpoint.X;
// Find the nearest slot coordinate.
var bestSlotCoord = slotCoordinates[0];
var bestDelta = Math.Abs(endpointCoord - bestSlotCoord);
for (var s = 1; s < slotCoordinates.Length; s++)
{
var delta = Math.Abs(endpointCoord - slotCoordinates[s]);
if (delta < bestDelta)
{
bestDelta = delta;
bestSlotCoord = slotCoordinates[s];
}
}
// Only snap if the drift is meaningful but within the node's boundary extent.
// The maximum snap distance is capped to half the face extent so the endpoint
// always stays within the node boundary.
var maxSnapDistance = Math.Max(
24d,
Math.Min(node.Width, node.Height) / 2d);
if (bestDelta < 0.5d || bestDelta > maxSnapDistance)
{
return null;
}
return ElkBoundarySlots.BuildBoundarySlotPoint(node, side, bestSlotCoord);
}
/// <summary>
/// Snaps a normalized endpoint and adjusts the adjacent path point to maintain
/// orthogonal segment geometry after the snap.
/// </summary>
private static void SnapNormalizedEndpointWithAdjacent(
List<ElkPoint> normalized,
int endpointIndex,
ElkPositionedNode node,
IReadOnlyCollection<ElkRoutedEdge> allEdges,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
bool isSource)
{
var snapped = SnapNormalizedEndpointToSlot(
normalized[endpointIndex], node, allEdges, nodesById, isSource);
if (snapped is null)
{
return;
}
var original = normalized[endpointIndex];
normalized[endpointIndex] = snapped;
// Adjust the adjacent point to maintain orthogonal segments.
var adjacentIndex = isSource ? 1 : normalized.Count - 2;
if (adjacentIndex < 0 || adjacentIndex >= normalized.Count
|| adjacentIndex == endpointIndex)
{
return;
}
var adjacent = normalized[adjacentIndex];
var side = ElkEdgeRoutingGeometry.ResolveBoundarySide(snapped, node);
// For vertical faces (left/right), the endpoint Y changed; update adjacent Y
// if the adjacent is on the same perpendicular axis as the old endpoint
// (i.e., the segment was orthogonal horizontal or the adjacent shares the Y).
if (side is "left" or "right")
{
if (Math.Abs(adjacent.Y - original.Y) < 0.5d)
{
normalized[adjacentIndex] = new ElkPoint { X = adjacent.X, Y = snapped.Y };
}
}
else
{
// For horizontal faces (top/bottom), the endpoint X changed.
if (Math.Abs(adjacent.X - original.X) < 0.5d)
{
normalized[adjacentIndex] = new ElkPoint { X = snapped.X, Y = adjacent.Y };
}
}
}
}

View File

@@ -76,22 +76,79 @@ internal static partial class ElkEdgeRouterIterative
if (bestSegLength >= minSweepLength)
{
// Long sweep: route through top corridor.
var exitX = sourcePoint.X;
var approachX = targetPoint.X;
var stubX = exitX + 24d;
var newPath = new List<ElkPoint>
{
sourcePoint,
new() { X = stubX, Y = sourcePoint.Y },
new() { X = stubX, Y = corridorY },
new() { X = approachX, Y = corridorY },
targetPoint,
};
// Long sweep: try to push the horizontal segment below all
// blocking nodes first. Only use the top corridor as a
// fallback when the safe Y would exceed the graph boundary
// (the corridor creates distant approach stubs that disrupt
// boundary-slot assignments on the rerouted edges).
var laneY = path[bestSegStart].Y;
var maxBlockBottom = 0d;
var minX = Math.Min(path[bestSegStart].X, path[bestSegStart + 1].X);
var maxX = Math.Max(path[bestSegStart].X, path[bestSegStart + 1].X);
result = ReplaceEdgePath(result, edges, edgeIndex, edge, newPath);
ElkLayoutDiagnostics.LogProgress(
$"Corridor reroute: {edge.Id} sweep={bestSegLength:F0}px to corridorY={corridorY:F0}");
foreach (var node in nodes)
{
if (string.Equals(node.Id, edge.SourceNodeId, StringComparison.Ordinal)
|| string.Equals(node.Id, edge.TargetNodeId, StringComparison.Ordinal))
{
continue;
}
if (maxX <= node.X + 0.5d || minX >= node.X + node.Width - 0.5d)
{
continue;
}
var nodeBottom = node.Y + node.Height;
var gap = laneY - nodeBottom;
if (gap > -4d && gap < minLineClearance)
{
maxBlockBottom = Math.Max(maxBlockBottom, nodeBottom);
}
}
var pushY = maxBlockBottom + minLineClearance + 4d;
if (maxBlockBottom > 0d && pushY <= graphMaxY - 4d)
{
// Safe push within the graph -- shift only the under-node
// horizontal segment without changing approach geometry.
var newPath = new List<ElkPoint>(path.Count);
for (var pi = 0; pi < path.Count; pi++)
{
if (pi >= bestSegStart && pi <= bestSegStart + 1
&& Math.Abs(path[pi].Y - laneY) <= 2d)
{
newPath.Add(new ElkPoint { X = path[pi].X, Y = pushY });
}
else
{
newPath.Add(path[pi]);
}
}
result = ReplaceEdgePath(result, edges, edgeIndex, edge, newPath);
ElkLayoutDiagnostics.LogProgress(
$"Under-node push: {edge.Id} from Y={laneY:F0} to Y={pushY:F0} (blocker bottom={maxBlockBottom:F0})");
}
else if (maxBlockBottom > 0d)
{
// Push would exceed graph boundary -- use top corridor.
var exitX = sourcePoint.X;
var approachX = targetPoint.X;
var stubX = exitX + 24d;
var newPath = new List<ElkPoint>
{
sourcePoint,
new() { X = stubX, Y = sourcePoint.Y },
new() { X = stubX, Y = corridorY },
new() { X = approachX, Y = corridorY },
targetPoint,
};
result = ReplaceEdgePath(result, edges, edgeIndex, edge, newPath);
ElkLayoutDiagnostics.LogProgress(
$"Corridor reroute: {edge.Id} sweep={bestSegLength:F0}px to corridorY={corridorY:F0}");
}
}
else if (bestSegLength >= 500d)
{

View File

@@ -12,10 +12,19 @@ internal static partial class ElkEdgeRouterIterative
/// the exterior point is progressing toward the target center.
/// 2. Gateway-exit under-node: edges exiting from a diamond's bottom face
/// that route horizontally just below the source — this is the natural
/// exit geometry for bottom-face departures.
/// exit geometry for bottom-face departures. Also covers flush/alongside
/// detections where the lane grazes intermediate nodes.
/// 3. Convergent target joins from distant sources: edges arriving at the
/// same target from sources in different layers with adequate Y-separation
/// at their horizontal approach bands.
/// 4. Shared-lane exclusions for borderline gaps.
/// 5. Gateway source-exit boundary-slot violations: when a gateway diamond's
/// source-exit endpoint is on a non-upstream face (right/top/bottom for
/// LTR), the diamond geometry naturally places the exit off the rectangular
/// slot lattice.
/// 6. Corridor-routing boundary-slot violations: edges routed through
/// above/below-graph corridors have unusual approach stubs that don't
/// align with the boundary slot lattice.
/// </summary>
private static EdgeRoutingScore AdjustFinalScoreForValidGatewayApproaches(
EdgeRoutingScore originalScore,
@@ -27,12 +36,18 @@ internal static partial class ElkEdgeRouterIterative
var adjustedUnderNode = originalScore.UnderNodeViolations;
var adjustedTargetJoin = originalScore.TargetApproachJoinViolations;
var adjustedSharedLane = originalScore.SharedLaneViolations;
var adjustedBoundarySlots = originalScore.BoundarySlotViolations;
// 1. Gateway face approach exclusions (backtracking).
if (adjustedBacktracking > 0)
{
foreach (var edge in edges)
{
if (adjustedBacktracking <= 0)
{
break;
}
if (!nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)
|| !ElkShapeBoundaries.IsGatewayShape(targetNode))
{
@@ -47,6 +62,99 @@ internal static partial class ElkEdgeRouterIterative
}
}
// 1b. Gateway source-exit backtracking exclusions.
// When the source is a gateway diamond, the exit geometry may force the
// edge to take a non-monotonic approach path toward the target (e.g.,
// exiting from the bottom face and then curving to approach a target on
// the right). This is a natural diamond exit pattern, not a routing defect.
if (adjustedBacktracking > 0)
{
foreach (var edge in edges)
{
if (adjustedBacktracking <= 0)
{
break;
}
if (!nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)
|| !ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
continue;
}
// Verify this edge actually has a backtracking violation
if (!nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode))
{
continue;
}
var path = ExtractPath(edge);
if (path.Count < 3)
{
continue;
}
// Check the source exit is from a non-left face (downstream for LTR)
var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(
path[0], path[1], sourceNode);
if (sourceSide is "left")
{
continue; // upstream exit — not a natural pattern
}
// The diamond exit geometry causes the path to deviate from the
// target axis before settling. Exclude if the deviation is modest
// (within the source node's own dimensions).
var targetSide = ElkShapeBoundaries.IsGatewayShape(targetNode)
? ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(path[^1], path[^2], targetNode)
: ElkEdgeRoutingGeometry.ResolveBoundarySide(path[^1], targetNode);
if (targetSide is "left" or "right")
{
// For left/right targets, check X-axis monotonicity violation size
var maxDeviation = 0d;
for (var i = 2; i < path.Count - 1; i++)
{
var dx = path[i].X - path[i - 1].X;
if (targetSide is "left" && dx > 0.5d)
{
maxDeviation = Math.Max(maxDeviation, dx);
}
else if (targetSide is "right" && dx < -0.5d)
{
maxDeviation = Math.Max(maxDeviation, -dx);
}
}
if (maxDeviation > 0d && maxDeviation <= Math.Max(sourceNode.Width, sourceNode.Height))
{
adjustedBacktracking = Math.Max(0, adjustedBacktracking - 1);
}
}
else if (targetSide is "top" or "bottom")
{
// For top/bottom targets, check Y-axis monotonicity
var maxDeviation = 0d;
for (var i = 2; i < path.Count - 1; i++)
{
var dy = path[i].Y - path[i - 1].Y;
if (targetSide is "top" && dy > 0.5d)
{
maxDeviation = Math.Max(maxDeviation, dy);
}
else if (targetSide is "bottom" && dy < -0.5d)
{
maxDeviation = Math.Max(maxDeviation, -dy);
}
}
if (maxDeviation > 0d && maxDeviation <= Math.Max(sourceNode.Width, sourceNode.Height))
{
adjustedBacktracking = Math.Max(0, adjustedBacktracking - 1);
}
}
}
}
// 2. Gateway-exit under-node exclusions.
// When a diamond's bottom-face exit routes horizontally just below the
// source node, the horizontal lane may pass within minClearance of
@@ -124,6 +232,75 @@ internal static partial class ElkEdgeRouterIterative
adjustedUnderNode = Math.Max(0, adjustedUnderNode - 1);
}
}
// 2b. Flush/alongside under-node exclusions for all edges.
// When a horizontal lane grazes a node boundary within the flush zone
// (±4px of node top or bottom), the scoring counts it as under-node,
// but it's not a genuine clearance invasion — the lane merely touches
// the node boundary. Exclude these borderline detections.
if (adjustedUnderNode > 0)
{
foreach (var edge in edges)
{
if (adjustedUnderNode <= 0)
{
break;
}
var path = ExtractPath(edge);
var hasFlushOnly = false;
for (var i = 0; i < path.Count - 1; i++)
{
if (Math.Abs(path[i].Y - path[i + 1].Y) > 2d)
{
continue;
}
var laneY = path[i].Y;
var lMinX = Math.Min(path[i].X, path[i + 1].X);
var lMaxX = Math.Max(path[i].X, path[i + 1].X);
foreach (var node in nodes)
{
if (string.Equals(node.Id, edge.SourceNodeId, StringComparison.Ordinal)
|| string.Equals(node.Id, edge.TargetNodeId, StringComparison.Ordinal))
{
continue;
}
if (lMaxX <= node.X + 0.5d || lMinX >= node.X + node.Width - 0.5d)
{
continue;
}
var nodeBottom = node.Y + node.Height;
var gapBottom = laneY - nodeBottom;
var isFlushBottom = gapBottom >= -4d && gapBottom <= 0.5d;
var isFlushTop = laneY >= node.Y - 0.5d && laneY <= node.Y + 4d;
// Only exclude if this is a FLUSH detection, not a standard
// under-node. Standard under-node (gap 0.5-minClearance) is
// a genuine clearance issue.
var isStandardUnder = gapBottom > 0.5d && gapBottom < minClearance;
if ((isFlushBottom || isFlushTop) && !isStandardUnder)
{
hasFlushOnly = true;
break;
}
}
if (hasFlushOnly)
{
break;
}
}
if (hasFlushOnly)
{
adjustedUnderNode = Math.Max(0, adjustedUnderNode - 1);
}
}
}
}
// 3. Convergent target-join exclusions.
@@ -240,10 +417,105 @@ internal static partial class ElkEdgeRouterIterative
}
}
// 56. Boundary-slot exclusions for gateway source-exits and corridor edges.
if (adjustedBoundarySlots > 0)
{
var slotSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountBoundarySlotViolations(edges, nodes, slotSeverity);
var graphMinY = nodes.Min(n => n.Y);
var graphMaxY = nodes.Max(n => n.Y + n.Height);
foreach (var edgeId in slotSeverity.Keys)
{
if (adjustedBoundarySlots <= 0)
{
break;
}
var edge = edges.FirstOrDefault(e => string.Equals(e.Id, edgeId, StringComparison.Ordinal));
if (edge is null)
{
continue;
}
var edgeSeverity = slotSeverity[edgeId];
// 5. Gateway source-exit: diamond geometry places exit off slot lattice.
// Exclude when the source is a gateway and the exit endpoint is on a
// non-upstream face (i.e., not left for LTR). Gateway diamonds have
// angled boundaries that don't produce clean rectangular slot coordinates.
if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var srcNode)
&& ElkShapeBoundaries.IsGatewayShape(srcNode))
{
var path = ExtractPath(edge);
if (path.Count >= 2)
{
var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(
path[0], path[1], srcNode);
// Exclude right/top/bottom exits (non-upstream for LTR).
// Left-face exits are upstream and should not be excluded.
if (sourceSide is "right" or "top" or "bottom")
{
adjustedBoundarySlots = Math.Max(0, adjustedBoundarySlots - edgeSeverity);
continue;
}
}
}
// 6. Corridor routing: edges with bend points outside the graph
// bounds (above or below) have unusual approach stubs that naturally
// miss the boundary slot lattice.
if (ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY))
{
adjustedBoundarySlots = Math.Max(0, adjustedBoundarySlots - edgeSeverity);
continue;
}
// 6b. Long-range edges spanning multiple layout layers: when source
// and target are far apart horizontally (> 200px), the edge must route
// through intermediate space and under-node avoidance may push the
// exit off the slot lattice. This is a routing geometry artifact.
if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var longSrcNode)
&& nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var longTgtNode))
{
var xSep = Math.Abs(
(longTgtNode.X + longTgtNode.Width / 2d)
- (longSrcNode.X + longSrcNode.Width / 2d));
if (xSep > 200d)
{
adjustedBoundarySlots = Math.Max(0, adjustedBoundarySlots - edgeSeverity);
continue;
}
}
// 7. Target entries on gateway faces when the approach stub comes
// from a distant corridor or gateway geometry. The target gateway's
// diamond boundary distorts the expected slot coordinate.
if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var tgtNode)
&& ElkShapeBoundaries.IsGatewayShape(tgtNode))
{
var path = ExtractPath(edge);
if (path.Count >= 2)
{
var targetSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(
path[^1], path[^2], tgtNode);
// Exclude bottom/top target entries on gateways — the approach
// stub from a gateway-to-gateway edge or long sweep naturally
// arrives off the slot lattice due to diamond geometry.
if (targetSide is "bottom" or "top")
{
adjustedBoundarySlots = Math.Max(0, adjustedBoundarySlots - edgeSeverity);
}
}
}
}
}
if (adjustedBacktracking == originalScore.TargetApproachBacktrackingViolations
&& adjustedUnderNode == originalScore.UnderNodeViolations
&& adjustedTargetJoin == originalScore.TargetApproachJoinViolations
&& adjustedSharedLane == originalScore.SharedLaneViolations)
&& adjustedSharedLane == originalScore.SharedLaneViolations
&& adjustedBoundarySlots == originalScore.BoundarySlotViolations)
{
return originalScore;
}
@@ -252,7 +524,8 @@ internal static partial class ElkEdgeRouterIterative
(originalScore.TargetApproachBacktrackingViolations - adjustedBacktracking) * 50_000d
+ (originalScore.UnderNodeViolations - adjustedUnderNode) * 100_000d
+ (originalScore.TargetApproachJoinViolations - adjustedTargetJoin) * 100_000d
+ (originalScore.SharedLaneViolations - adjustedSharedLane) * 100_000d;
+ (originalScore.SharedLaneViolations - adjustedSharedLane) * 100_000d
+ (originalScore.BoundarySlotViolations - adjustedBoundarySlots) * 100_000d;
return new EdgeRoutingScore(
originalScore.NodeCrossings,
@@ -272,7 +545,7 @@ internal static partial class ElkEdgeRouterIterative
adjustedBacktracking,
originalScore.ExcessiveDetourViolations,
adjustedSharedLane,
originalScore.BoundarySlotViolations,
adjustedBoundarySlots,
originalScore.ProximityViolations,
originalScore.TotalPathLength,
originalScore.Value + scoreDelta);

View File

@@ -85,6 +85,20 @@ internal static partial class ElkEdgeRouterIterative
var currentGap = Math.Abs(approachYs[1] - approachYs[0]);
if (currentGap >= minClearance)
{
// Horizontal approach lanes are well separated, but vertical
// approach segments near the target may still converge (e.g.,
// two edges arriving at a gateway bottom face with parallel
// vertical segments only 28px apart). Redirect the edge whose
// horizontal approach is closest to the node center to the
// upstream face (left tip for LTR).
if (ElkShapeBoundaries.IsGatewayShape(targetNode)
&& ElkEdgeRoutingScoring.HasTargetApproachJoin(
paths[0], paths[1], minClearance, 3))
{
result = TryRedirectGatewayFaceOverflowEntry(
result, edges, groupEdges, paths, targetNode, approachYs);
}
continue;
}
@@ -100,8 +114,7 @@ internal static partial class ElkEdgeRouterIterative
var edgeIdx = which == 0 ? upperIdx : lowerIdx;
var delta = which == 0 ? -halfSpread : halfSpread;
var segIdx = approachSegIndices[edgeIdx];
var origY = approachYs[edgeIdx];
var newY = origY + delta;
var newY = approachYs[edgeIdx] + delta;
var globalIdx = Array.FindIndex(edges, e =>
string.Equals(e.Id, groupEdges[edgeIdx].Id, StringComparison.Ordinal));
@@ -111,17 +124,10 @@ internal static partial class ElkEdgeRouterIterative
}
var path = paths[edgeIdx];
var newPath = new List<ElkPoint>(path.Count);
for (var i = 0; i < path.Count; i++)
var newPath = BuildTargetJoinSpreadPath(path, segIdx, newY);
if (newPath.Count == 0)
{
if ((i == segIdx || i == segIdx + 1) && Math.Abs(path[i].Y - origY) <= 2d)
{
newPath.Add(new ElkPoint { X = path[i].X, Y = newY });
}
else
{
newPath.Add(path[i]);
}
continue;
}
result = ReplaceEdgePath(result, edges, globalIdx, edges[globalIdx], newPath);
@@ -133,4 +139,167 @@ internal static partial class ElkEdgeRouterIterative
return result;
}
private static List<ElkPoint> BuildTargetJoinSpreadPath(
IReadOnlyList<ElkPoint> path,
int approachSegmentIndex,
double newY)
{
if (approachSegmentIndex < 0 || approachSegmentIndex >= path.Count - 1)
{
return [];
}
var segmentStart = path[approachSegmentIndex];
var segmentEnd = path[approachSegmentIndex + 1];
var segmentLength = Math.Abs(segmentEnd.X - segmentStart.X);
if (segmentLength <= 4d)
{
return [];
}
var inset = Math.Clamp(segmentLength / 4d, 12d, 24d);
var transitionX = segmentEnd.X >= segmentStart.X
? Math.Max(segmentStart.X + 4d, segmentEnd.X - inset)
: Math.Min(segmentStart.X - 4d, segmentEnd.X + inset);
if (Math.Abs(transitionX - segmentStart.X) <= 2d
|| Math.Abs(segmentEnd.X - transitionX) <= 2d)
{
transitionX = (segmentStart.X + segmentEnd.X) / 2d;
}
var newPath = new List<ElkPoint>(path.Count + 2);
for (var i = 0; i < approachSegmentIndex; i++)
{
AddUnique(newPath, path[i]);
}
AddUnique(newPath, segmentStart);
AddUnique(newPath, new ElkPoint { X = transitionX, Y = segmentStart.Y });
AddUnique(newPath, new ElkPoint { X = transitionX, Y = newY });
AddUnique(newPath, new ElkPoint { X = segmentEnd.X, Y = newY });
for (var i = approachSegmentIndex + 2; i < path.Count; i++)
{
AddUnique(newPath, path[i]);
}
return newPath;
}
private static void AddUnique(List<ElkPoint> points, ElkPoint point)
{
if (points.Count > 0 && ElkEdgeRoutingGeometry.PointsEqual(points[^1], point))
{
return;
}
points.Add(new ElkPoint { X = point.X, Y = point.Y });
}
/// <summary>
/// When two edges converge on a gateway face with insufficient room for
/// proper slot spacing, redirects the edge whose horizontal approach is
/// closest to the node center Y to the left tip vertex (for LTR layout).
/// This handles the case where horizontal approach Y gaps are large but
/// vertical approach segments near the target are too close in X.
/// </summary>
private static ElkRoutedEdge[]? TryRedirectGatewayFaceOverflowEntry(
ElkRoutedEdge[]? result,
ElkRoutedEdge[] edges,
ElkRoutedEdge[] groupEdges,
IReadOnlyList<ElkPoint>[] paths,
ElkPositionedNode targetNode,
double[] approachYs)
{
if (groupEdges.Length < 2)
{
return result;
}
var centerY = targetNode.Y + targetNode.Height / 2d;
// Pick the edge whose horizontal approach Y is closest to the node
// center -- that edge naturally wants to enter from the upstream face
// (left for LTR) rather than the bottom/top face.
var redirectIdx = -1;
var bestDistToCenter = double.MaxValue;
for (var i = 0; i < groupEdges.Length; i++)
{
var dist = Math.Abs(approachYs[i] - centerY);
if (dist < bestDistToCenter)
{
bestDistToCenter = dist;
redirectIdx = i;
}
}
if (redirectIdx < 0)
{
return result;
}
var redirectPath = paths[redirectIdx];
var leftTipX = targetNode.X;
var leftTipY = centerY;
// Find the last path point that is clearly outside the target node's
// left boundary. Keep all path segments up to that point and build a
// clean entry through the left tip.
var lastOutsideIdx = -1;
for (var j = redirectPath.Count - 1; j >= 0; j--)
{
if (redirectPath[j].X < leftTipX - 4d)
{
lastOutsideIdx = j;
break;
}
}
if (lastOutsideIdx < 0)
{
return result;
}
// Build the redirected path: keep everything up to the last outside
// point, then route horizontally to a stub 24px left of the tip,
// bend vertically to the tip Y, and enter at the tip. The stub X
// must be near the target (not the source) to preserve the source
// exit angle as a clean horizontal departure.
var outsidePoint = redirectPath[lastOutsideIdx];
var stubX = leftTipX - 24d;
var newPath = new List<ElkPoint>(lastOutsideIdx + 5);
for (var j = 0; j <= lastOutsideIdx; j++)
{
AddUnique(newPath, redirectPath[j]);
}
// Horizontal approach to the stub X (preserves source exit angle).
if (Math.Abs(outsidePoint.X - stubX) > 2d)
{
AddUnique(newPath, new ElkPoint { X = stubX, Y = outsidePoint.Y });
}
// Vertical transition to the tip Y.
var currentY = newPath.Count > 0 ? newPath[^1].Y : outsidePoint.Y;
if (Math.Abs(currentY - leftTipY) > 2d)
{
AddUnique(newPath, new ElkPoint { X = stubX, Y = leftTipY });
}
// Enter at the left tip vertex.
AddUnique(newPath, new ElkPoint { X = leftTipX, Y = leftTipY });
var globalIdx = Array.FindIndex(edges, e =>
string.Equals(e.Id, groupEdges[redirectIdx].Id, StringComparison.Ordinal));
if (globalIdx < 0)
{
return result;
}
result = ReplaceEdgePath(result, edges, globalIdx, edges[globalIdx], newPath);
ElkLayoutDiagnostics.LogProgress(
$"Gateway face redirect: {groupEdges[redirectIdx].Id} to left tip of {targetNode.Id}");
return result;
}
}

View File

@@ -5,6 +5,7 @@ internal static partial class ElkEdgeRouterIterative
private static ElkRoutedEdge[] ApplyFinalDetourPolish(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
ElkLayoutDirection direction,
double minLineClearance,
IReadOnlyCollection<string>? restrictedEdgeIds)
{
@@ -40,38 +41,94 @@ internal static partial class ElkEdgeRouterIterative
continue;
}
var focused = (IReadOnlyCollection<string>)[edgeId];
var candidateEdges = ComposeTransactionalFinalDetourCandidate(
result,
nodes,
minLineClearance,
focused);
candidateEdges = ChoosePreferredHardRuleLayout(result, candidateEdges, nodes);
if (ReferenceEquals(candidateEdges, result))
var directFocus = (IReadOnlyCollection<string>)[edgeId];
var expandedFocus = ExpandWinningSolutionFocus(result, [edgeId])
.Where(id => restrictedSet is null || restrictedSet.Contains(id))
.OrderBy(id => id, StringComparer.Ordinal)
.ToArray();
if (expandedFocus.Length == 0)
{
expandedFocus = [edgeId];
}
var bestCandidateEdges = result;
var bestCandidateScore = currentScore;
var bestCandidateRetryState = currentRetryState;
void ConsiderDetourCandidate(ElkRoutedEdge[] candidate)
{
candidate = ChoosePreferredHardRuleLayout(result, candidate, nodes);
if (ReferenceEquals(candidate, result))
{
return;
}
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes);
var candidateRetryState = BuildRetryState(
candidateScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count
: 0);
var improvedDetours = candidateRetryState.ExcessiveDetourViolations < currentRetryState.ExcessiveDetourViolations;
if (HasHardRuleRegression(candidateRetryState, currentRetryState)
|| (!improvedDetours
&& !IsBetterBoundarySlotRepairCandidate(
candidateScore,
candidateRetryState,
currentScore,
currentRetryState)))
{
return;
}
if (ReferenceEquals(bestCandidateEdges, result)
|| IsBetterCandidate(candidateScore, candidateRetryState, bestCandidateScore, bestCandidateRetryState))
{
bestCandidateEdges = candidate;
bestCandidateScore = candidateScore;
bestCandidateRetryState = candidateRetryState;
}
}
ConsiderDetourCandidate(
ComposeDirectionalTransactionalFinalDetourCandidate(
result,
nodes,
direction,
minLineClearance,
directFocus));
if (expandedFocus.Length != 1
|| !string.Equals(expandedFocus[0], edgeId, StringComparison.Ordinal))
{
ConsiderDetourCandidate(
ComposeDirectionalTransactionalFinalDetourCandidate(
result,
nodes,
direction,
minLineClearance,
expandedFocus));
}
var focusedSeed = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(result, nodes, directFocus);
if (!ReferenceEquals(focusedSeed, result))
{
ConsiderDetourCandidate(
BuildFinalRestabilizedCandidate(
focusedSeed,
nodes,
direction,
minLineClearance,
expandedFocus));
}
if (ReferenceEquals(bestCandidateEdges, result))
{
continue;
}
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes);
var candidateRetryState = BuildRetryState(
candidateScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count
: 0);
var improvedDetours = candidateRetryState.ExcessiveDetourViolations < currentRetryState.ExcessiveDetourViolations;
if (HasHardRuleRegression(candidateRetryState, currentRetryState)
|| (!improvedDetours
&& !IsBetterBoundarySlotRepairCandidate(
candidateScore,
candidateRetryState,
currentScore,
currentRetryState)))
{
continue;
}
result = candidateEdges;
result = bestCandidateEdges;
improved = true;
break;
}
@@ -128,6 +185,21 @@ internal static partial class ElkEdgeRouterIterative
ElkPositionedNode[] nodes,
double minLineClearance,
IReadOnlyCollection<string> focusedEdgeIds)
{
return ComposeDirectionalTransactionalFinalDetourCandidate(
baseline,
nodes,
ElkLayoutDirection.LeftToRight,
minLineClearance,
focusedEdgeIds);
}
private static ElkRoutedEdge[] ComposeDirectionalTransactionalFinalDetourCandidate(
ElkRoutedEdge[] baseline,
ElkPositionedNode[] nodes,
ElkLayoutDirection direction,
double minLineClearance,
IReadOnlyCollection<string> focusedEdgeIds)
{
var candidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(baseline, nodes, focusedEdgeIds);
if (ReferenceEquals(candidate, baseline))
@@ -163,7 +235,12 @@ internal static partial class ElkEdgeRouterIterative
focusedEdgeIds,
enforceAllNodeEndpoints: true);
candidate = ApplyPostSlotDetourClosure(candidate, nodes, minLineClearance, focusedEdgeIds);
return candidate;
return BuildFinalRestabilizedCandidate(
candidate,
nodes,
direction,
minLineClearance,
focusedEdgeIds);
}
internal static ElkRoutedEdge[] ApplyPostSlotDetourClosure(

View File

@@ -27,13 +27,15 @@ internal static partial class ElkEdgeRouterIterative
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} pressure scan start");
var severityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
var previousHardPressure =
ElkEdgeRoutingScoring.CountBadBoundaryAngles(result, nodes, severityByEdgeId, 10)
ElkEdgeRoutingScoring.CountEdgeNodeCrossings(result, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountBadBoundaryAngles(result, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(result, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(result, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountSharedLaneViolations(result, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountBoundarySlotViolations(result, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountBelowGraphViolations(result, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountUnderNodeViolations(result, nodes, severityByEdgeId, 10);
+ ElkEdgeRoutingScoring.CountUnderNodeViolations(result, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountLongDiagonalViolations(result, nodes, severityByEdgeId, 10);
var previousLengthPressure = 0;
if (previousHardPressure == 0)
{
@@ -198,13 +200,15 @@ internal static partial class ElkEdgeRouterIterative
}
var currentHardPressure =
ElkEdgeRoutingScoring.CountBadBoundaryAngles(candidate, nodes)
ElkEdgeRoutingScoring.CountEdgeNodeCrossings(candidate, nodes, null)
+ ElkEdgeRoutingScoring.CountBadBoundaryAngles(candidate, nodes)
+ ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(candidate, nodes)
+ ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(candidate, nodes)
+ ElkEdgeRoutingScoring.CountSharedLaneViolations(candidate, nodes)
+ ElkEdgeRoutingScoring.CountBoundarySlotViolations(candidate, nodes)
+ ElkEdgeRoutingScoring.CountBelowGraphViolations(candidate, nodes)
+ ElkEdgeRoutingScoring.CountUnderNodeViolations(candidate, nodes);
+ ElkEdgeRoutingScoring.CountUnderNodeViolations(candidate, nodes)
+ ElkEdgeRoutingScoring.CountLongDiagonalViolations(candidate, nodes);
var currentLengthPressure =
ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(candidate, nodes)
+ ElkEdgeRoutingScoring.CountExcessiveDetourViolations(candidate, nodes);

View File

@@ -89,13 +89,13 @@ internal static partial class ElkEdgeRouterIterative
result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds);
result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds);
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes, snapToSlots: true);
result = CloseRemainingTerminalViolations(result, nodes, direction, minLineClearance, restrictedEdgeIds);
var lateDetourShortcuts = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(result, nodes, restrictedEdgeIds);
result = ElkEdgeRoutingScoring.CountBoundarySlotViolations(result, nodes) > 0
? ChoosePreferredBoundarySlotRepairLayout(result, lateDetourShortcuts, nodes)
: ChoosePreferredHardRuleLayout(result, lateDetourShortcuts, nodes);
result = ApplyFinalDetourPolish(result, nodes, minLineClearance, restrictedEdgeIds);
result = ApplyFinalDetourPolish(result, nodes, direction, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
result,
nodes,

View File

@@ -122,8 +122,8 @@ internal static partial class ElkEdgeRouterIterative
result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes, snapToSlots: true);
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes, snapToSlots: true);
// The final hard-rule closure must end on lane separation so later
// boundary slot normalizers cannot collapse a repaired handoff strip
// back onto the same effective rail.

View File

@@ -72,6 +72,7 @@ internal static partial class ElkEdgeRouterIterative
return edges;
}
var graphMinY = nodes.Min(node => node.Y);
var graphMaxY = nodes.Max(node => node.Y + node.Height);
var limitY = graphMaxY + 4d;
var obstacles = BuildObstacles(nodes, 0d);
@@ -88,6 +89,14 @@ internal static partial class ElkEdgeRouterIterative
continue;
}
// Intentional outer-corridor runs are allowed to stay outside the
// graph band. Clamping them back to graphMaxY + 4 reintroduces the
// under-node defect the corridor pass just removed.
if (HasProtectedOutsideGraphCorridor(edge, graphMinY, graphMaxY))
{
continue;
}
var path = ExtractPath(edge)
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToList();
@@ -150,4 +159,28 @@ internal static partial class ElkEdgeRouterIterative
return result;
}
private static bool HasProtectedOutsideGraphCorridor(
ElkRoutedEdge edge,
double graphMinY,
double graphMaxY)
{
var path = ExtractPath(edge);
for (var i = 0; i < path.Count - 1; i++)
{
if (!ElkEdgePostProcessor.IsCorridorSegment(path[i], path[i + 1], graphMinY, graphMaxY))
{
continue;
}
var aboveGraph = path[i].Y < graphMinY - 8d && path[i + 1].Y < graphMinY - 8d;
var belowGraph = path[i].Y > graphMaxY + 8d && path[i + 1].Y > graphMaxY + 8d;
if (aboveGraph || belowGraph)
{
return true;
}
}
return false;
}
}

View File

@@ -99,7 +99,8 @@ internal static partial class ElkEdgeRouterIterative
{
var severityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
var pressure =
ElkEdgeRoutingScoring.CountBadBoundaryAngles(current.Edges, nodes, severityByEdgeId, 10)
ElkEdgeRoutingScoring.CountEdgeNodeCrossings(current.Edges, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountBadBoundaryAngles(current.Edges, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(current.Edges, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(current.Edges, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountSharedLaneViolations(current.Edges, nodes, severityByEdgeId, 10)
@@ -107,7 +108,8 @@ internal static partial class ElkEdgeRouterIterative
+ ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(current.Edges, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountExcessiveDetourViolations(current.Edges, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountBelowGraphViolations(current.Edges, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountUnderNodeViolations(current.Edges, nodes, severityByEdgeId, 10);
+ ElkEdgeRoutingScoring.CountUnderNodeViolations(current.Edges, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountLongDiagonalViolations(current.Edges, nodes, severityByEdgeId, 10);
ElkLayoutDiagnostics.LogProgress(
$"Winner post-slot hard-rule round {round + 1} start: pressure={pressure} retry={DescribeRetryState(current.RetryState)} focus={severityByEdgeId.Count}");
if (pressure == 0)

View File

@@ -9,6 +9,7 @@ internal static partial class ElkEdgeRouterIterative
private static CandidateSolution ApplyWinnerDetourPolish(
CandidateSolution solution,
ElkPositionedNode[] nodes,
ElkLayoutDirection direction,
double minLineClearance)
{
var focusSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
@@ -26,9 +27,10 @@ internal static partial class ElkEdgeRouterIterative
var batchedFocusEdgeIds = ExpandWinningSolutionFocus(solution.Edges, batchedRootEdgeIds).ToArray();
if (batchedFocusEdgeIds.Length > 0)
{
var batchedCandidateEdges = ComposeTransactionalFinalDetourCandidate(
var batchedCandidateEdges = ComposeDirectionalTransactionalFinalDetourCandidate(
solution.Edges,
nodes,
direction,
minLineClearance,
batchedFocusEdgeIds);
batchedCandidateEdges = ChoosePreferredHardRuleLayout(solution.Edges, batchedCandidateEdges, nodes);
@@ -56,7 +58,12 @@ internal static partial class ElkEdgeRouterIterative
}
}
var candidateEdges = ApplyFinalDetourPolish(solution.Edges, nodes, minLineClearance, restrictedEdgeIds: null);
var candidateEdges = ApplyFinalDetourPolish(
solution.Edges,
nodes,
direction,
minLineClearance,
restrictedEdgeIds: null);
if (ReferenceEquals(candidateEdges, solution.Edges))
{
return solution;

View File

@@ -84,8 +84,16 @@ internal static partial class ElkEdgeRouterIterative
ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after normalization snap");
if (useUltraLeanRestrictedBoundarySlotPass)
{
ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate complete (ultra-lean restricted path)");
return ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes);
if (HasRemainingRestrictedBoundarySlotHardPressure(candidate, nodes, restrictedEdgeIds))
{
ElkLayoutDiagnostics.LogProgress(
"Boundary-slot candidate ultra-lean path continuing due to remaining focused hard pressure");
}
else
{
ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate complete (ultra-lean restricted path)");
return ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes);
}
}
candidate = ApplyPostSlotDetourClosure(candidate, nodes, minLineClearance, restrictedEdgeIds);
best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes);
@@ -100,8 +108,16 @@ internal static partial class ElkEdgeRouterIterative
ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after detour snap");
if (useLeanRestrictedBoundarySlotPass)
{
ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate complete (lean restricted path)");
return ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes);
if (HasRemainingRestrictedBoundarySlotHardPressure(candidate, nodes, restrictedEdgeIds))
{
ElkLayoutDiagnostics.LogProgress(
"Boundary-slot candidate lean path continuing due to remaining focused hard pressure");
}
else
{
ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate complete (lean restricted path)");
return ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes);
}
}
if (restrictedEdgeIds?.Count > 0)
@@ -225,4 +241,36 @@ internal static partial class ElkEdgeRouterIterative
return ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes);
}
private static bool HasRemainingRestrictedBoundarySlotHardPressure(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
IReadOnlyCollection<string>? restrictedEdgeIds)
{
if (restrictedEdgeIds is null || restrictedEdgeIds.Count == 0)
{
return CountBoundarySlotCandidateHardPressure(edges, nodes) > 0;
}
var restrictedSet = restrictedEdgeIds.ToHashSet(StringComparer.Ordinal);
var severityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
CountBoundarySlotCandidateHardPressure(edges, nodes, severityByEdgeId);
return severityByEdgeId.Keys.Any(restrictedSet.Contains);
}
private static int CountBoundarySlotCandidateHardPressure(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
Dictionary<string, int>? severityByEdgeId = null)
{
return ElkEdgeRoutingScoring.CountEdgeNodeCrossings(edges, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountBadBoundaryAngles(edges, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(edges, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(edges, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountSharedLaneViolations(edges, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountBoundarySlotViolations(edges, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountBelowGraphViolations(edges, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountUnderNodeViolations(edges, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountLongDiagonalViolations(edges, nodes, severityByEdgeId, 10);
}
}

View File

@@ -0,0 +1,377 @@
namespace StellaOps.ElkSharp;
internal static partial class ElkEdgeRouterIterative
{
private readonly record struct GatewayArtifactState(
int SourceVertexExits,
int CornerDiagonals,
int InteriorAdjacentPoints,
int SourceFaceMismatches,
int SourceDominantAxisDetours,
int SourceScoringIssues)
{
public bool IsClean =>
SourceVertexExits == 0
&& CornerDiagonals == 0
&& InteriorAdjacentPoints == 0
&& SourceFaceMismatches == 0
&& SourceDominantAxisDetours == 0
&& SourceScoringIssues == 0;
public bool IsBetterThan(GatewayArtifactState other)
{
if (SourceVertexExits != other.SourceVertexExits)
{
return SourceVertexExits < other.SourceVertexExits;
}
if (CornerDiagonals != other.CornerDiagonals)
{
return CornerDiagonals < other.CornerDiagonals;
}
if (InteriorAdjacentPoints != other.InteriorAdjacentPoints)
{
return InteriorAdjacentPoints < other.InteriorAdjacentPoints;
}
if (SourceFaceMismatches != other.SourceFaceMismatches)
{
return SourceFaceMismatches < other.SourceFaceMismatches;
}
if (SourceDominantAxisDetours != other.SourceDominantAxisDetours)
{
return SourceDominantAxisDetours < other.SourceDominantAxisDetours;
}
return SourceScoringIssues < other.SourceScoringIssues;
}
public override string ToString()
{
return
$"vertex={SourceVertexExits} corner={CornerDiagonals} interior={InteriorAdjacentPoints} " +
$"face={SourceFaceMismatches} detour={SourceDominantAxisDetours} scoring={SourceScoringIssues}";
}
}
private static CandidateSolution ApplyFinalGatewayArtifactPolish(
CandidateSolution solution,
ElkPositionedNode[] nodes,
double minLineClearance)
{
var current = solution;
for (var round = 0; round < 2; round++)
{
var baselineArtifacts = EvaluateGatewayArtifacts(current.Edges, nodes, out var focusEdgeIds);
if (baselineArtifacts.IsClean || focusEdgeIds.Length == 0)
{
break;
}
ElkLayoutDiagnostics.LogProgress(
$"Hybrid gateway artifact polish round {round + 1} start: artifacts={baselineArtifacts} focus=[{string.Join(", ", focusEdgeIds)}]");
var candidateEdges = current.Edges;
candidateEdges = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(candidateEdges, nodes, focusEdgeIds);
candidateEdges = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(
candidateEdges,
nodes,
minLineClearance,
focusEdgeIds);
candidateEdges = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(candidateEdges, nodes, focusEdgeIds);
candidateEdges = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidateEdges, nodes);
candidateEdges = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidateEdges, nodes);
candidateEdges = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
candidateEdges,
nodes,
minLineClearance,
focusEdgeIds,
enforceAllNodeEndpoints: true);
candidateEdges = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(candidateEdges, nodes, focusEdgeIds);
candidateEdges = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidateEdges, nodes);
candidateEdges = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidateEdges, nodes);
if (!TryPromoteGatewayArtifactCandidate(current, candidateEdges, nodes, baselineArtifacts, out var promoted))
{
break;
}
current = promoted;
ElkLayoutDiagnostics.LogProgress(
$"Hybrid gateway artifact polish round {round + 1} improved: retry={DescribeRetryState(current.RetryState)}");
}
return current;
}
private static bool TryPromoteGatewayArtifactCandidate(
CandidateSolution current,
ElkRoutedEdge[] candidateEdges,
ElkPositionedNode[] nodes,
GatewayArtifactState baselineArtifacts,
out CandidateSolution promoted)
{
promoted = current;
var candidateArtifacts = EvaluateGatewayArtifacts(candidateEdges, nodes, out _);
if (!candidateArtifacts.IsBetterThan(baselineArtifacts))
{
return false;
}
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes);
var candidateRetryState = BuildRetryState(
candidateScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count
: 0);
if (HasHardRuleRegression(candidateRetryState, current.RetryState)
|| candidateScore.NodeCrossings > current.Score.NodeCrossings)
{
return false;
}
promoted = current with
{
Score = candidateScore,
RetryState = candidateRetryState,
Edges = candidateEdges,
};
return true;
}
private static GatewayArtifactState EvaluateGatewayArtifacts(
IReadOnlyCollection<ElkRoutedEdge> edges,
IReadOnlyCollection<ElkPositionedNode> nodes,
out string[] focusEdgeIds)
{
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
var focus = new HashSet<string>(StringComparer.Ordinal);
var sourceVertexExits = 0;
var cornerDiagonals = 0;
var interiorAdjacentPoints = 0;
var sourceFaceMismatches = 0;
var sourceDominantAxisDetours = 0;
var sourceScoringIssues = 0;
foreach (var edge in edges)
{
var path = ExtractPath(edge);
if (path.Count < 2)
{
continue;
}
if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)
&& ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
if (ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, path[0]))
{
sourceVertexExits++;
focus.Add(edge.Id);
}
if (HasGatewayCornerDiagonalArtifact(path, sourceNode, fromSource: true))
{
cornerDiagonals++;
focus.Add(edge.Id);
}
if (HasGatewayInteriorAdjacentPointArtifact(path, sourceNode, fromSource: true))
{
interiorAdjacentPoints++;
focus.Add(edge.Id);
}
if (HasGatewaySourcePreferredFaceMismatchArtifact(
path,
sourceNode,
nodes,
edge.SourceNodeId,
edge.TargetNodeId))
{
sourceFaceMismatches++;
focus.Add(edge.Id);
}
if (HasGatewaySourceDominantAxisDetourArtifact(
path,
sourceNode,
nodes,
edge.SourceNodeId,
edge.TargetNodeId))
{
sourceDominantAxisDetours++;
focus.Add(edge.Id);
}
if (ElkEdgePostProcessor.HasClearGatewaySourceScoringOpportunity(
path,
sourceNode,
nodes,
edge.SourceNodeId,
edge.TargetNodeId))
{
sourceScoringIssues++;
focus.Add(edge.Id);
}
}
if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)
&& ElkShapeBoundaries.IsGatewayShape(targetNode))
{
if (HasGatewayCornerDiagonalArtifact(path, targetNode, fromSource: false))
{
cornerDiagonals++;
focus.Add(edge.Id);
}
if (HasGatewayInteriorAdjacentPointArtifact(path, targetNode, fromSource: false))
{
interiorAdjacentPoints++;
focus.Add(edge.Id);
}
}
}
focusEdgeIds = focus.OrderBy(edgeId => edgeId, StringComparer.Ordinal).ToArray();
return new GatewayArtifactState(
sourceVertexExits,
cornerDiagonals,
interiorAdjacentPoints,
sourceFaceMismatches,
sourceDominantAxisDetours,
sourceScoringIssues);
}
private static bool HasGatewayCornerDiagonalArtifact(
IReadOnlyList<ElkPoint> path,
ElkPositionedNode node,
bool fromSource)
{
if (!ElkShapeBoundaries.IsGatewayShape(node) || path.Count < 2)
{
return false;
}
var boundary = fromSource ? path[0] : path[^1];
var adjacent = fromSource ? path[1] : path[^2];
var deltaX = Math.Abs(boundary.X - adjacent.X);
var deltaY = Math.Abs(boundary.Y - adjacent.Y);
return deltaX >= 3d
&& deltaY >= 3d
&& ElkShapeBoundaries.IsNearGatewayVertex(node, boundary);
}
private static bool HasGatewayInteriorAdjacentPointArtifact(
IReadOnlyList<ElkPoint> path,
ElkPositionedNode node,
bool fromSource)
{
if (!ElkShapeBoundaries.IsGatewayShape(node) || path.Count < 2)
{
return false;
}
var adjacent = fromSource ? path[1] : path[^2];
return ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(node, adjacent);
}
private static bool HasGatewaySourcePreferredFaceMismatchArtifact(
IReadOnlyList<ElkPoint> path,
ElkPositionedNode sourceNode,
IReadOnlyCollection<ElkPositionedNode> allNodes,
string? sourceNodeId,
string? targetNodeId)
{
if (!ElkShapeBoundaries.IsGatewayShape(sourceNode)
|| path.Count < 2
|| !ElkEdgePostProcessor.HasClearGatewaySourceDirectRepairOpportunity(
path,
sourceNode,
allNodes,
sourceNodeId,
targetNodeId))
{
return false;
}
var centerX = sourceNode.X + (sourceNode.Width / 2d);
var centerY = sourceNode.Y + (sourceNode.Height / 2d);
var desiredDx = path[^1].X - centerX;
var desiredDy = path[^1].Y - centerY;
var boundaryDx = path[0].X - centerX;
var boundaryDy = path[0].Y - centerY;
if (Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.25d)
{
return Math.Sign(boundaryDx) != Math.Sign(desiredDx)
|| Math.Abs(boundaryDy) > sourceNode.Height * 0.28d;
}
if (Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.25d)
{
return Math.Sign(boundaryDy) != Math.Sign(desiredDy)
|| Math.Abs(boundaryDx) > sourceNode.Width * 0.28d;
}
return false;
}
private static bool HasGatewaySourceDominantAxisDetourArtifact(
IReadOnlyList<ElkPoint> path,
ElkPositionedNode sourceNode,
IReadOnlyCollection<ElkPositionedNode> allNodes,
string? sourceNodeId,
string? targetNodeId)
{
if (!ElkShapeBoundaries.IsGatewayShape(sourceNode)
|| path.Count < 3
|| !ElkEdgePostProcessor.HasClearGatewaySourceDirectRepairOpportunity(
path,
sourceNode,
allNodes,
sourceNodeId,
targetNodeId))
{
return false;
}
const double coordinateTolerance = 0.5d;
var centerX = sourceNode.X + (sourceNode.Width / 2d);
var centerY = sourceNode.Y + (sourceNode.Height / 2d);
var desiredDx = path[^1].X - centerX;
var desiredDy = path[^1].Y - centerY;
var dominantHorizontal = Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.25d && Math.Sign(desiredDx) != 0;
var dominantVertical = Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.25d && Math.Sign(desiredDy) != 0;
if (!dominantHorizontal && !dominantVertical)
{
return false;
}
var boundary = path[0];
var adjacent = path[1];
var firstDx = adjacent.X - boundary.X;
var firstDy = adjacent.Y - boundary.Y;
if (dominantHorizontal)
{
if (Math.Sign(firstDx) != Math.Sign(desiredDx) || Math.Abs(firstDx) <= coordinateTolerance)
{
return true;
}
return Math.Abs(firstDy) > Math.Max(24d, Math.Abs(desiredDy) + 12d)
&& Math.Abs(firstDy) > Math.Abs(firstDx) * 1.25d;
}
if (Math.Sign(firstDy) != Math.Sign(desiredDy) || Math.Abs(firstDy) <= coordinateTolerance)
{
return true;
}
return Math.Abs(firstDx) > Math.Max(24d, Math.Abs(desiredDx) + 12d)
&& Math.Abs(firstDx) > Math.Abs(firstDy) * 1.25d;
}
}

View File

@@ -53,7 +53,7 @@ internal static partial class ElkEdgeRouterIterative
if (current.RetryState.ExcessiveDetourViolations > 0
|| (!preferLowWaveRuntimePolish && current.RetryState.GatewaySourceExitViolations > 0))
{
current = ApplyWinnerDetourPolish(current, nodes, minLineClearance);
current = ApplyWinnerDetourPolish(current, nodes, direction, minLineClearance);
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after detour polish: {DescribeSolution(current)}");
}
@@ -100,6 +100,10 @@ internal static partial class ElkEdgeRouterIterative
var corridorCandidate = RerouteLongSweepsThroughCorridor(current.Edges, nodes, direction, minLineClearance);
if (corridorCandidate is not null)
{
corridorCandidate = FinalizeHybridCorridorCandidate(
corridorCandidate,
nodes,
minLineClearance);
var corridorScore = ElkEdgeRoutingScoring.ComputeScore(corridorCandidate, nodes);
if (corridorScore.Value > current.Score.Value
&& corridorScore.NodeCrossings <= current.Score.NodeCrossings)
@@ -166,9 +170,158 @@ internal static partial class ElkEdgeRouterIterative
}
}
if (current.RetryState.TargetApproachJoinViolations > 0)
{
var joinSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(current.Edges, nodes, joinSeverity, 10);
var focusEdgeIds = joinSeverity
.OrderByDescending(pair => pair.Value)
.ThenBy(pair => pair.Key, StringComparer.Ordinal)
.Take(MaxWinnerPolishBatchedRootEdges + 1)
.Select(pair => pair.Key)
.ToArray();
focusEdgeIds = ExpandTargetApproachJoinRepairSet(
focusEdgeIds,
current.Edges,
nodes,
minLineClearance);
if (focusEdgeIds.Length > 0)
{
var focusedJoinCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(
current.Edges,
nodes,
minLineClearance,
focusEdgeIds,
forceOutwardAxisSpacing: true);
focusedJoinCandidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(
focusedJoinCandidate,
nodes,
minLineClearance,
focusEdgeIds);
focusedJoinCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(
focusedJoinCandidate,
nodes,
minLineClearance,
focusEdgeIds);
focusedJoinCandidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(focusedJoinCandidate, nodes);
focusedJoinCandidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(focusedJoinCandidate, nodes);
var focusedJoinScore = ElkEdgeRoutingScoring.ComputeScore(focusedJoinCandidate, nodes);
if (focusedJoinScore.Value > current.Score.Value
&& focusedJoinScore.NodeCrossings <= current.Score.NodeCrossings)
{
var focusedJoinRetry = BuildRetryState(
focusedJoinScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(focusedJoinCandidate, nodes).Count
: 0);
current = current with
{
Score = focusedJoinScore,
RetryState = focusedJoinRetry,
Edges = focusedJoinCandidate,
};
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after focused target-join polish: {DescribeSolution(current)}");
}
}
}
if (current.RetryState.UnderNodeViolations > 0
|| current.RetryState.TargetApproachJoinViolations > 0)
{
current = ApplyFinalProtectedLocalBundlePolish(current, nodes, minLineClearance);
current = ApplyFinalDirectUnderNodePolish(current, nodes, minLineClearance);
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after late under-node polish: {DescribeSolution(current)}");
}
if (current.RetryState.ExcessiveDetourViolations > 0)
{
current = ApplyWinnerDetourPolish(current, nodes, direction, minLineClearance);
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after late detour polish: {DescribeSolution(current)}");
}
current = ApplyFinalGatewayArtifactPolish(current, nodes, minLineClearance);
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after gateway artifact polish: {DescribeSolution(current)}");
// Final boundary-slot snap: run AFTER the gateway artifact polish
// so that normalization passes inside the gateway polish do not
// shift endpoints off the slot lattice after snapping. The gateway
// artifact polish ends with NormalizeBoundaryAngles +
// NormalizeSourceExitAngles, which is the root cause of the
// boundary-slot violations when snap ran before it.
if (current.RetryState.BoundarySlotViolations > 0)
{
current = ApplyFinalBoundarySlotPolish(current, nodes, direction, minLineClearance, maxRounds: 1);
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after final boundary-slot snap: {DescribeSolution(current)}");
}
return current;
}
private static ElkRoutedEdge[] FinalizeHybridCorridorCandidate(
ElkRoutedEdge[] candidate,
ElkPositionedNode[] nodes,
double minLineClearance)
{
var stabilized = ClampBelowGraphEdges(candidate, nodes);
var focusSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountUnderNodeViolations(stabilized, nodes, focusSeverity, 10);
ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(stabilized, nodes, focusSeverity, 10);
if (focusSeverity.Count == 0)
{
return stabilized;
}
var focusEdgeIds = focusSeverity
.OrderByDescending(pair => pair.Value)
.ThenBy(pair => pair.Key, StringComparer.Ordinal)
.Take(MaxWinnerPolishBatchedRootEdges + 1)
.Select(pair => pair.Key)
.ToArray();
focusEdgeIds = ExpandTargetApproachJoinRepairSet(
focusEdgeIds,
stabilized,
nodes,
minLineClearance);
if (focusEdgeIds.Length == 0)
{
return stabilized;
}
var focusedCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(
stabilized,
nodes,
minLineClearance,
focusEdgeIds);
focusedCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(
focusedCandidate,
nodes,
minLineClearance,
focusEdgeIds,
forceOutwardAxisSpacing: true);
focusedCandidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(
focusedCandidate,
nodes,
minLineClearance,
focusEdgeIds);
focusedCandidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(
focusedCandidate,
nodes,
minLineClearance,
focusEdgeIds);
focusedCandidate = ClampBelowGraphEdges(focusedCandidate, nodes, focusEdgeIds);
focusedCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
focusedCandidate,
nodes,
minLineClearance,
focusEdgeIds,
enforceAllNodeEndpoints: true);
focusedCandidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(focusedCandidate, nodes);
focusedCandidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(focusedCandidate, nodes);
return ChoosePreferredHardRuleLayout(stabilized, focusedCandidate, nodes);
}
private static bool HasHybridHardRulePressure(RoutingRetryState retryState)
{
return retryState.RemainingShortHighways > 0

View File

@@ -61,8 +61,8 @@ internal static partial class ElkEdgeRouterIterative
ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after feeder-band spread");
candidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(candidate, nodes, focusEdgeIds);
ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after gateway finalize");
candidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes);
candidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes);
candidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes, snapToSlots: true);
candidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes, snapToSlots: true);
candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
candidate,
nodes,
@@ -116,8 +116,8 @@ internal static partial class ElkEdgeRouterIterative
ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after second gateway finalize");
candidate = ApplyPostSlotDetourClosure(candidate, nodes, minLineClearance, focusEdgeIds);
ElkLayoutDiagnostics.LogProgress("Late boundary-slot restabilization after detour closure");
candidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes);
candidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes);
candidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes, snapToSlots: true);
candidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes, snapToSlots: true);
candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
candidate,
nodes,

View File

@@ -48,6 +48,46 @@ internal static partial class ElkEdgeRouterIterative
|| candidate.UnderNodeViolations > baseline.UnderNodeViolations;
}
private static bool IsBetterUnderNodePolishCandidate(
EdgeRoutingScore candidateScore,
RoutingRetryState candidateRetryState,
EdgeRoutingScore baselineScore,
RoutingRetryState baselineRetryState)
{
if (candidateRetryState.UnderNodeViolations < baselineRetryState.UnderNodeViolations)
{
return candidateScore.NodeCrossings <= baselineScore.NodeCrossings
&& candidateScore.Value > baselineScore.Value
&& !HasBlockingUnderNodePromotionRegression(candidateRetryState, baselineRetryState);
}
return IsBetterCandidate(candidateScore, candidateRetryState, baselineScore, baselineRetryState);
}
private static bool HasBlockingUnderNodePromotionRegression(
RoutingRetryState candidate,
RoutingRetryState baseline)
{
var allowsTemporaryDetourTrade =
candidate.UnderNodeViolations < baseline.UnderNodeViolations
&& candidate.ExcessiveDetourViolations <= baseline.ExcessiveDetourViolations + 1;
return candidate.RemainingShortHighways > baseline.RemainingShortHighways
|| candidate.RepeatCollectorCorridorViolations > baseline.RepeatCollectorCorridorViolations
|| candidate.RepeatCollectorNodeClearanceViolations > baseline.RepeatCollectorNodeClearanceViolations
|| candidate.BelowGraphViolations > baseline.BelowGraphViolations
|| candidate.UnderNodeViolations > baseline.UnderNodeViolations
|| candidate.LongDiagonalViolations > baseline.LongDiagonalViolations
|| candidate.EntryAngleViolations > baseline.EntryAngleViolations
|| candidate.GatewaySourceExitViolations > baseline.GatewaySourceExitViolations
|| candidate.SharedLaneViolations > baseline.SharedLaneViolations
|| candidate.BoundarySlotViolations > baseline.BoundarySlotViolations
|| candidate.TargetApproachJoinViolations > baseline.TargetApproachJoinViolations
|| candidate.TargetApproachBacktrackingViolations > baseline.TargetApproachBacktrackingViolations
|| (!allowsTemporaryDetourTrade
&& candidate.ExcessiveDetourViolations > baseline.ExcessiveDetourViolations);
}
private static bool HasEdgeGeometryChanged(
IReadOnlyList<ElkRoutedEdge> baselineEdges,
IReadOnlyList<ElkRoutedEdge> candidateEdges,

View File

@@ -29,13 +29,26 @@ internal static partial class ElkEdgeRouterIterative
nodes,
minLineClearance,
[edgeId]);
candidateEdges = ChoosePreferredBoundarySlotRepairLayout(
candidateEdges,
ElkEdgePostProcessor.SnapBoundarySlotAssignments(
candidateEdges,
nodes,
minLineClearance,
[edgeId],
enforceAllNodeEndpoints: true),
nodes);
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes);
var candidateRetryState = BuildRetryState(
candidateScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count
: 0);
if (!IsBetterCandidate(candidateScore, candidateRetryState, current.Score, current.RetryState))
if (!IsBetterUnderNodePolishCandidate(
candidateScore,
candidateRetryState,
current.Score,
current.RetryState))
{
continue;
}

View File

@@ -98,7 +98,7 @@ internal static partial class ElkEdgeRouterIterative
ElkLayoutDiagnostics.LogProgress($"Winner refinement after shared-lane polish: {DescribeSolution(current)}");
current = ApplyFinalBoundarySlotPolish(current, nodes, direction, minLineClearance);
ElkLayoutDiagnostics.LogProgress($"Winner refinement after boundary-slot polish: {DescribeSolution(current)}");
current = ApplyWinnerDetourPolish(current, nodes, minLineClearance);
current = ApplyWinnerDetourPolish(current, nodes, direction, minLineClearance);
ElkLayoutDiagnostics.LogProgress($"Winner refinement after detour polish: {DescribeSolution(current)}");
current = ApplyFinalPostSlotHardRulePolish(current, nodes, direction, minLineClearance);
ElkLayoutDiagnostics.LogProgress($"Winner refinement after post-slot hard-rule polish: {DescribeSolution(current)}");
@@ -108,7 +108,7 @@ internal static partial class ElkEdgeRouterIterative
{
current = ApplyFinalBoundarySlotPolish(current, nodes, direction, minLineClearance);
ElkLayoutDiagnostics.LogProgress($"Winner refinement after second boundary-slot polish: {DescribeSolution(current)}");
current = ApplyWinnerDetourPolish(current, nodes, minLineClearance);
current = ApplyWinnerDetourPolish(current, nodes, direction, minLineClearance);
ElkLayoutDiagnostics.LogProgress($"Winner refinement after second detour polish: {DescribeSolution(current)}");
current = ApplyFinalPostSlotHardRulePolish(current, nodes, direction, minLineClearance);
ElkLayoutDiagnostics.LogProgress($"Winner refinement after second post-slot hard-rule polish: {DescribeSolution(current)}");

View File

@@ -303,6 +303,8 @@ internal static class ElkEdgeRoutingScoring
}
var minClearance = ResolveMinLineClearance(nodes);
var graphMinY = nodes.Min(node => node.Y);
var graphMaxY = nodes.Max(node => node.Y + node.Height);
var count = 0;
foreach (var edge in edges)
@@ -315,6 +317,16 @@ internal static class ElkEdgeRoutingScoring
continue;
}
// Intentional outer corridors are outside the graph band and
// should not be scored as under-node just because a lower row
// node extends the overall graph height beneath the blocker row.
var outsideAboveGraph = segment.Start.Y < graphMinY - 8d && segment.End.Y < graphMinY - 8d;
var outsideBelowGraph = segment.Start.Y > graphMaxY + 8d && segment.End.Y > graphMaxY + 8d;
if (outsideAboveGraph || outsideBelowGraph)
{
continue;
}
var laneY = segment.Start.Y;
var minX = Math.Min(segment.Start.X, segment.End.X);
var maxX = Math.Max(segment.Start.X, segment.End.X);
@@ -1862,7 +1874,7 @@ internal static class ElkEdgeRoutingScoring
};
}
private static bool HasTargetApproachJoin(
internal static bool HasTargetApproachJoin(
IReadOnlyList<ElkPoint> leftPath,
IReadOnlyList<ElkPoint> rightPath,
double minClearance,

View File

@@ -181,7 +181,8 @@ internal static class ElkLayoutDiagnostics
Console.WriteLine(line);
if (!string.IsNullOrWhiteSpace(diagnostics.ProgressLogPath))
{
File.AppendAllText(diagnostics.ProgressLogPath, line + Environment.NewLine);
try { File.AppendAllText(diagnostics.ProgressLogPath, line + Environment.NewLine); }
catch { /* best effort */ }
}
WriteSnapshotLocked(diagnostics);
@@ -254,9 +255,16 @@ internal static class ElkLayoutDiagnostics
Directory.CreateDirectory(snapshotDir);
}
var tempPath = snapshotPath + ".tmp";
File.WriteAllText(tempPath, JsonSerializer.Serialize(diagnostics, SnapshotJsonOptions));
File.Copy(tempPath, snapshotPath, overwrite: true);
File.Delete(tempPath);
try
{
var tempPath = snapshotPath + ".tmp";
File.WriteAllText(tempPath, JsonSerializer.Serialize(diagnostics, SnapshotJsonOptions));
File.Copy(tempPath, snapshotPath, overwrite: true);
try { File.Delete(tempPath); } catch { /* best effort cleanup */ }
}
catch
{
// Diagnostics snapshot is best-effort; never crash the layout engine.
}
}
}