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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
// 5–6. 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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)}");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user