Refactor ElkSharp routing sources into partial modules

This commit is contained in:
master
2026-03-28 11:56:35 +02:00
parent 7be4e855d6
commit 7057819f4d
34 changed files with 33377 additions and 21402 deletions

View File

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

View File

@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Reflection;
using NUnit.Framework;
using StellaOps.ElkSharp;
@@ -9,7 +10,7 @@ using StellaOps.Workflow.Renderer.Svg;
namespace StellaOps.Workflow.Renderer.Tests;
[TestFixture]
public class DocumentProcessingWorkflowRenderingTests
public partial class DocumentProcessingWorkflowRenderingTests
{
private static WorkflowRenderGraph BuildDocumentProcessingWorkflowGraph()
{
@@ -85,451 +86,46 @@ public class DocumentProcessingWorkflowRenderingTests
};
}
[Test]
public async Task DocumentProcessingWorkflow_WhenLayoutOnly_ShouldProduceFinitePositions()
private sealed class ElkPointComparer : IEqualityComparer<ElkPoint>
{
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
internal static readonly ElkPointComparer Instance = new();
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
public bool Equals(ElkPoint? x, ElkPoint? y)
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
Assert.That(layout.Nodes.Count, Is.EqualTo(24));
Assert.That(layout.Edges.Count, Is.EqualTo(36));
Assert.That(layout.Nodes.All(n => double.IsFinite(n.X) && double.IsFinite(n.Y)), Is.True);
var serviceNodes = graph.Nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
var expectedGridX = Math.Max(64d, Math.Round(serviceNodes.Average(node => node.Width) / 8d) * 8d);
var expectedGridY = Math.Max(48d, Math.Round(serviceNodes.Average(node => node.Height) / 8d) * 8d);
var edgeDensityFactor = Math.Min(1.8d, 1d + (Math.Max(0, graph.Edges.Count - 15) * 0.02d));
var expectedNodeSpacing = Math.Max(40d * edgeDensityFactor, expectedGridY * 0.4d);
var edgeDensitySpacingFactor = 0.92d + (Math.Max(0d, edgeDensityFactor - 1d) * 0.35d);
var expectedLayerSpacing = Math.Max(60d * Math.Min(1.15d, edgeDensitySpacingFactor), expectedGridX * 0.45d);
var visibleNodes = layout.Nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
var distinctLayerXs = visibleNodes
.Select(node => node.X)
.Distinct()
.OrderBy(x => x)
.ToArray();
var minLayerGap = distinctLayerXs.Zip(distinctLayerXs.Skip(1), (left, right) => right - left).DefaultIfEmpty(double.MaxValue).Min();
var minInLayerGap = visibleNodes
.GroupBy(node => node.X)
.Select(group =>
if (ReferenceEquals(x, y))
{
var ordered = group.OrderBy(node => node.Y).ToArray();
if (ordered.Length < 2)
{
return double.MaxValue;
}
return ordered
.Zip(ordered.Skip(1), (upper, lower) => lower.Y - (upper.Y + upper.Height))
.Min();
})
.DefaultIfEmpty(double.MaxValue)
.Min();
Assert.That(
minLayerGap,
Is.GreaterThanOrEqualTo(expectedLayerSpacing - 1d),
$"Layer spacing should honor the placement grid scale (~{expectedGridX:F0}px average width).");
Assert.That(
minInLayerGap,
Is.GreaterThanOrEqualTo(expectedNodeSpacing - 1d),
$"In-layer node spacing should honor the placement grid scale (~{expectedGridY:F0}px average height).");
}
[Test]
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotBacktrackIntoCheckResult()
{
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var targetNode = layout.Nodes.Single(node => node.Id == "start/2/branch-1/1/body/5");
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/7");
Assert.That(
HasTargetApproachBacktracking(edge, targetNode),
Is.False,
"Execute Batch -> Check Result must not overshoot the target side and curl back near the final approach.");
}
[Test]
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldUseHorizontalSidesBetweenConfigParametersAndEvaluateConditions()
{
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/36");
var sourceNode = layout.Nodes.Single(node => node.Id == edge.SourceNodeId);
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
var path = FlattenPath(edge);
Assert.That(path.Count, Is.GreaterThanOrEqualTo(2));
Assert.That(
ResolveBoundarySide(path[0], sourceNode),
Is.EqualTo("right"),
"Setting configParameters should leave from its east side toward Evaluate Conditions.");
Assert.That(
ResolveBoundarySide(path[^1], targetNode),
Is.EqualTo("left"),
"Evaluate Conditions should be reached from its west side when the horizontal shortcut is clear.");
var shortestDirectLength = 0d;
for (var i = 1; i < path.Count; i++)
{
shortestDirectLength += Math.Abs(path[i].X - path[i - 1].X) + Math.Abs(path[i].Y - path[i - 1].Y);
}
var boundaryDirectLength = Math.Abs(path[^1].X - path[0].X) + Math.Abs(path[^1].Y - path[0].Y);
Assert.That(
path.Min(point => point.Y),
Is.GreaterThanOrEqualTo(Math.Min(sourceNode.Y, targetNode.Y) - 24d),
"Setting configParameters -> Evaluate Conditions must not detour north when a direct east-to-west shortcut is clear.");
Assert.That(
shortestDirectLength,
Is.LessThanOrEqualTo(boundaryDirectLength + 48d),
"Setting configParameters -> Evaluate Conditions must stay close to the direct boundary-to-boundary shortcut.");
}
[Test]
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotFakeBottomEntryIntoSettingConfigParameters()
{
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var edge = layout.Edges.Single(routedEdge =>
routedEdge.SourceNodeId == "start/3"
&& routedEdge.TargetNodeId == "start/4/batched");
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
var path = FlattenPath(edge);
Assert.That(
HasTargetApproachBacktracking(edge, targetNode),
Is.False,
"Load Configuration -> Setting configParameters must not use a tiny orthogonal hook to fake a bottom-side entry.");
Assert.That(path.Count, Is.GreaterThanOrEqualTo(2));
Assert.That(
ResolveBoundarySide(path[^1], targetNode),
Is.EqualTo("left"),
"Load Configuration -> Setting configParameters should enter from the west side once short fake bottom hooks are forbidden.");
}
[Test]
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepLocalRepeatReturnsAboveTheNodeField()
{
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/35");
var sourceNode = layout.Nodes.Single(node => node.Id == edge.SourceNodeId);
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
var path = FlattenPath(edge);
var maxAllowedY = Math.Max(sourceNode.Y + sourceNode.Height, targetNode.Y + targetNode.Height) + 40d;
Assert.That(
path.Max(point => point.Y),
Is.LessThanOrEqualTo(maxAllowedY),
"Local repeat-return lanes must not drop into a lower detour band when an upper return is available.");
}
[Test]
[Category("RenderingArtifacts")]
public async Task DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings()
{
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var outputDir = Path.Combine(
Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!,
"TestResults", "workflow-renderings", DateTime.Today.ToString("yyyyMMdd"), "DocumentProcessingWorkflow");
Directory.CreateDirectory(outputDir);
using var diagnosticsCapture = ElkLayoutDiagnostics.BeginCapture();
var progressLogPath = Path.Combine(outputDir, "elksharp.progress.log");
if (File.Exists(progressLogPath))
{
File.Delete(progressLogPath);
}
var diagnosticsPath = Path.Combine(outputDir, "elksharp.refinement-diagnostics.json");
if (File.Exists(diagnosticsPath))
{
File.Delete(diagnosticsPath);
}
diagnosticsCapture.Diagnostics.ProgressLogPath = progressLogPath;
diagnosticsCapture.Diagnostics.SnapshotPath = diagnosticsPath;
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var svgRenderer = new WorkflowRenderSvgRenderer();
var svgDoc = svgRenderer.Render(layout, "DocumentProcessingWorkflow [ElkSharp]");
var svgPath = Path.Combine(outputDir, "elksharp.svg");
await File.WriteAllTextAsync(svgPath, svgDoc.Svg);
var jsonPath = Path.Combine(outputDir, "elksharp.json");
await File.WriteAllTextAsync(jsonPath, JsonSerializer.Serialize(layout, new JsonSerializerOptions { WriteIndented = true }));
await File.WriteAllTextAsync(
diagnosticsPath,
JsonSerializer.Serialize(diagnosticsCapture.Diagnostics, new JsonSerializerOptions { WriteIndented = true }));
WorkflowRenderPngExporter? pngExporter = null;
string? pngPath = null;
try
{
pngPath = Path.Combine(outputDir, "elksharp.png");
pngExporter = new WorkflowRenderPngExporter();
await pngExporter.ExportAsync(svgDoc, pngPath, scale: 2f);
TestContext.Out.WriteLine($"PNG generated at: {pngPath}");
}
catch (Exception ex)
{
TestContext.Out.WriteLine($"PNG export failed (non-fatal): {ex.Message}");
TestContext.Out.WriteLine($"SVG available at: {svgPath}");
}
TestContext.Out.WriteLine($"SVG: {svgPath}");
TestContext.Out.WriteLine($"JSON: {jsonPath}");
TestContext.Out.WriteLine($"Diagnostics: {diagnosticsPath}");
TestContext.Out.WriteLine($"Progress log: {progressLogPath}");
// Render every iteration of every strategy as SVG only
var variantsDir = Path.Combine(outputDir, "strategy-variants");
Directory.CreateDirectory(variantsDir);
foreach (var stratDiag in diagnosticsCapture.Diagnostics.IterativeStrategies)
{
foreach (var attemptDiag in stratDiag.AttemptDetails)
{
if (attemptDiag.Edges is null)
{
continue;
}
var attemptLayout = BuildVariantLayout(layout, attemptDiag.Edges);
var sc = attemptDiag.Score;
var attemptLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} att{attemptDiag.Attempt} [{attemptDiag.Outcome}] " +
$"nc={sc.NodeCrossings} ec={sc.EdgeCrossings} bends={sc.BendCount} diag={sc.DiagonalCount} " +
$"bg={sc.BelowGraphViolations} un={sc.UnderNodeViolations} ld={sc.LongDiagonalViolations} " +
$"ea={sc.EntryAngleViolations} lbl={sc.LabelProximityViolations} tj={sc.TargetApproachJoinViolations} " +
$"sl={sc.SharedLaneViolations} " +
$"tb={sc.TargetApproachBacktrackingViolations} det={sc.ExcessiveDetourViolations} score={sc.Value:F0}";
var attemptSvg = svgRenderer.Render(attemptLayout, attemptLabel);
await File.WriteAllTextAsync(
Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-att{attemptDiag.Attempt:D2}.svg"),
attemptSvg.Svg);
return true;
}
if (stratDiag.BestEdges is not null)
if (x is null || y is null)
{
var bestLayout = BuildVariantLayout(layout, stratDiag.BestEdges);
var bestSc = stratDiag.BestScore;
var bestLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} BEST [{stratDiag.Outcome}] " +
$"nc={bestSc?.NodeCrossings} ec={bestSc?.EdgeCrossings} bends={bestSc?.BendCount} diag={bestSc?.DiagonalCount} " +
$"bg={bestSc?.BelowGraphViolations} un={bestSc?.UnderNodeViolations} ld={bestSc?.LongDiagonalViolations} " +
$"ea={bestSc?.EntryAngleViolations} lbl={bestSc?.LabelProximityViolations} tj={bestSc?.TargetApproachJoinViolations} " +
$"sl={bestSc?.SharedLaneViolations} " +
$"tb={bestSc?.TargetApproachBacktrackingViolations} det={bestSc?.ExcessiveDetourViolations} score={bestSc?.Value:F0}";
var bestSvg = svgRenderer.Render(bestLayout, bestLabel);
await File.WriteAllTextAsync(
Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-BEST.svg"),
bestSvg.Svg);
return false;
}
return Math.Abs(x.X - y.X) <= 0.5d && Math.Abs(x.Y - y.Y) <= 0.5d;
}
TestContext.Out.WriteLine($"Strategy variants: {variantsDir}");
var localRepairAttempts = diagnosticsCapture.Diagnostics.IterativeStrategies
.SelectMany(strategy => strategy.AttemptDetails)
.Where(attempt => attempt.Attempt > 1
&& string.Equals(attempt.RouteDiagnostics?.Mode, "local-repair", StringComparison.Ordinal))
.ToArray();
Assert.That(localRepairAttempts, Is.Not.Empty, "Expected later attempts to use targeted local repair.");
Assert.That(
localRepairAttempts.All(attempt => attempt.RouteDiagnostics!.RoutedEdges < attempt.RouteDiagnostics.TotalEdges),
Is.True,
"Local repair attempts must reroute only the penalized subset of edges.");
Assert.That(diagnosticsCapture.Diagnostics.FinalBrokenShortHighwayCount, Is.EqualTo(0), "Final selected layout must not keep broken short highways.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.RepeatCollectorCorridorViolations, Is.EqualTo(0), "Repeat collector outer lanes must remain separated.");
var boundaryAngleOffenders = layout.Edges
.SelectMany(edge => GetBoundaryAngleViolations(edge, layout.Nodes))
.ToArray();
TestContext.Out.WriteLine($"Boundary angle offenders: {(boundaryAngleOffenders.Length == 0 ? "<none>" : string.Join(", ", boundaryAngleOffenders))}");
var targetJoinOffenders = GetTargetApproachJoinOffenders(layout.Edges, layout.Nodes).ToArray();
TestContext.Out.WriteLine($"Target join offenders: {(targetJoinOffenders.Length == 0 ? "<none>" : string.Join(", ", targetJoinOffenders))}");
var elkNodes = layout.Nodes.Select(node => new ElkPositionedNode
public int GetHashCode(ElkPoint obj)
{
Id = node.Id,
Label = node.Label,
Kind = node.Kind,
X = node.X,
Y = node.Y,
Width = node.Width,
Height = node.Height,
}).ToArray();
var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge
{
Id = edge.Id,
SourceNodeId = edge.SourceNodeId,
TargetNodeId = edge.TargetNodeId,
Kind = edge.Kind,
Label = edge.Label,
Sections = edge.Sections.Select(section => new ElkEdgeSection
{
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
}).ToArray(),
}).ToArray();
var sharedLaneOffenders = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
.Select(conflict => $"{conflict.LeftEdgeId}+{conflict.RightEdgeId}")
.Distinct(StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"Shared lane offenders: {(sharedLaneOffenders.Length == 0 ? "<none>" : string.Join(", ", sharedLaneOffenders))}");
var belowGraphOffenders = GetBelowGraphOffenders(layout.Edges, layout.Nodes).ToArray();
TestContext.Out.WriteLine($"Below-graph offenders: {(belowGraphOffenders.Length == 0 ? "<none>" : string.Join(", ", belowGraphOffenders))}");
var underNodeOffenders = GetUnderNodeOffenders(layout.Edges, layout.Nodes).ToArray();
TestContext.Out.WriteLine($"Under-node offenders: {(underNodeOffenders.Length == 0 ? "<none>" : string.Join(", ", underNodeOffenders))}");
var longDiagonalOffenders = GetLongDiagonalOffenders(layout.Edges, layout.Nodes).ToArray();
TestContext.Out.WriteLine($"Long-diagonal offenders: {(longDiagonalOffenders.Length == 0 ? "<none>" : string.Join(", ", longDiagonalOffenders))}");
var gatewaySourceScoringOffenders = layout.Edges
.Where(edge => HasGatewaySourceScoringIssue(edge, layout.Nodes))
.Select(edge => edge.Id)
.ToArray();
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.EntryAngleViolations, Is.EqualTo(0), "Selected layout must satisfy the node-side entry/exit angle rule.");
Assert.That(targetJoinOffenders, Is.Empty, "Selected layout must not leave visually collapsed target-side approach joins.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachJoinViolations, Is.EqualTo(0), "Selected layout must not keep disallowed target-side joins.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.SharedLaneViolations, Is.EqualTo(0), "Selected layout must not keep same-lane occupancy outside explicit corridor/highway exceptions.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.BelowGraphViolations, Is.EqualTo(0), "Selected layout must not route any lane below the node field.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.UnderNodeViolations, Is.EqualTo(0), "Selected layout must not keep horizontal lanes tucked underneath other nodes.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.LongDiagonalViolations, Is.EqualTo(0), "Selected layout must not keep overlong 45-degree segments.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachBacktrackingViolations, Is.EqualTo(0), "Selected layout must not overshoot a target side and curl back near the final approach.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.ExcessiveDetourViolations, Is.EqualTo(0), "Selected layout must not keep shortest-path violations after the retry budget is exhausted.");
var gatewayCornerDiagonalCount = layout.Edges.Count(edge =>
HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true)
|| HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false));
Assert.That(gatewayCornerDiagonalCount, Is.EqualTo(0), "Gateway diagonal stubs may land on side faces, not on gateway corner vertices.");
var gatewayInteriorAdjacentCount = layout.Edges.Count(edge =>
HasGatewayInteriorAdjacentPoint(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true)
|| HasGatewayInteriorAdjacentPoint(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false));
Assert.That(gatewayInteriorAdjacentCount, Is.EqualTo(0), "Gateway joins must use an exterior face-approach point instead of gluing the boundary back to an interior rectangular anchor.");
var gatewaySourceCurlCount = layout.Edges.Count(edge =>
HasGatewaySourceExitCurl(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)));
var gatewaySourceCurlOffenders = layout.Edges
.Where(edge => HasGatewaySourceExitCurl(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)))
.Select(edge => edge.Id)
.ToArray();
Assert.That(gatewaySourceCurlCount, Is.EqualTo(0), "Gateway source exits must leave from the downstream-facing side without curling away and back.");
var gatewaySourceFaceMismatchCount = layout.Edges.Count(edge =>
HasGatewaySourcePreferredFaceMismatch(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes));
var gatewaySourceFaceMismatchOffenders = layout.Edges
.Where(edge => HasGatewaySourcePreferredFaceMismatch(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes))
.Select(edge => edge.Id)
.ToArray();
Assert.That(gatewaySourceFaceMismatchCount, Is.EqualTo(0), "Gateway source exits must leave from the dominant downstream-facing face instead of drifting onto an upper or lower face.");
var gatewaySourceDetourCount = layout.Edges.Count(edge =>
HasGatewaySourceDominantAxisDetour(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes));
var gatewaySourceDetourOffenders = layout.Edges
.Where(edge => HasGatewaySourceDominantAxisDetour(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes))
.Select(edge => edge.Id)
.ToArray();
Assert.That(gatewaySourceDetourCount, Is.EqualTo(0), "Gateway source exits must not leave on the non-dominant axis when a direct dominant-axis exit is available.");
var gatewaySourceVertexExitCount = layout.Edges.Count(edge =>
HasGatewaySourceVertexExit(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)));
var gatewaySourceVertexExitOffenders = layout.Edges
.Where(edge => HasGatewaySourceVertexExit(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)))
.Select(edge => edge.Id)
.ToArray();
Assert.That(gatewaySourceVertexExitCount, Is.EqualTo(0), "Gateway source exits must leave from a face interior, not from a gateway tip/corner.");
Assert.That(gatewaySourceScoringOffenders, Is.Empty, "Gateway source exits must not leave a shorter clean downstream-facing repair opportunity unused.");
var loadConfigurationNode = layout.Nodes.Single(node => node.Id == "start/3");
var processBatchLoops = layout.Edges
.Where(edge => edge.TargetNodeId == "start/2/branch-1/1"
&& edge.Label.StartsWith("repeat while", StringComparison.Ordinal))
.ToArray();
Assert.That(
processBatchLoops.All(edge => !HasNearNodeClearanceViolation(edge, loadConfigurationNode, 40d)),
Is.True,
"Repeat-return lanes into Process Batch must stay outside the Load Configuration clearance band.");
static WorkflowRenderLayoutResult BuildVariantLayout(WorkflowRenderLayoutResult baseLayout, ElkRoutedEdge[] edges)
{
return new WorkflowRenderLayoutResult
{
GraphId = baseLayout.GraphId,
Nodes = baseLayout.Nodes,
Edges = edges.Select(e => new WorkflowRenderRoutedEdge
{
Id = e.Id,
SourceNodeId = e.SourceNodeId,
TargetNodeId = e.TargetNodeId,
Kind = e.Kind,
Label = e.Label,
Sections = e.Sections.Select(s => new WorkflowRenderEdgeSection
{
StartPoint = new WorkflowRenderPoint { X = s.StartPoint.X, Y = s.StartPoint.Y },
EndPoint = new WorkflowRenderPoint { X = s.EndPoint.X, Y = s.EndPoint.Y },
BendPoints = s.BendPoints.Select(p => new WorkflowRenderPoint { X = p.X, Y = p.Y }).ToArray(),
}).ToArray(),
}).ToArray(),
};
return HashCode.Combine(Math.Round(obj.X, 3), Math.Round(obj.Y, 3));
}
}
// Verify zero edge-node crossings
var crossings = 0;
foreach (var node in layout.Nodes)
private static List<ElkPoint> ExtractElkPath(ElkRoutedEdge edge)
{
var path = new List<ElkPoint>();
foreach (var section in edge.Sections)
{
foreach (var edge in layout.Edges)
if (path.Count == 0)
{
if (edge.SourceNodeId == node.Id || edge.TargetNodeId == node.Id) continue;
foreach (var section in edge.Sections)
{
var pts = new List<WorkflowRenderPoint> { section.StartPoint };
pts.AddRange(section.BendPoints);
pts.Add(section.EndPoint);
for (var i = 0; i < pts.Count - 1; i++)
{
var p1 = pts[i];
var p2 = pts[i + 1];
if (Math.Abs(p1.Y - p2.Y) < 2 && p1.Y > node.Y && p1.Y < node.Y + node.Height)
{
if (Math.Max(p1.X, p2.X) > node.X && Math.Min(p1.X, p2.X) < node.X + node.Width)
crossings++;
}
else if (Math.Abs(p1.X - p2.X) < 2 && p1.X > node.X && p1.X < node.X + node.Width)
{
if (Math.Max(p1.Y, p2.Y) > node.Y && Math.Min(p1.Y, p2.Y) < node.Y + node.Height)
crossings++;
}
}
}
path.Add(section.StartPoint);
}
path.AddRange(section.BendPoints);
path.Add(section.EndPoint);
}
TestContext.Out.WriteLine($"Edge-node crossings: {crossings}");
Assert.That(crossings, Is.EqualTo(0), "No edges should cross through node shapes");
return path;
}
private static List<WorkflowRenderPoint> FlattenPath(WorkflowRenderRoutedEdge edge)
@@ -560,7 +156,7 @@ public class DocumentProcessingWorkflowRenderingTests
if (targetNode.Kind is "Decision" or "Fork" or "Join")
{
return HasGatewayTargetApproachBacktracking(path);
return HasGatewayTargetApproachBacktracking(path, targetNode);
}
var side = ResolveBoundarySide(path[^1], targetNode);
@@ -689,13 +285,20 @@ public class DocumentProcessingWorkflowRenderingTests
};
}
private static bool HasGatewayTargetApproachBacktracking(IReadOnlyList<WorkflowRenderPoint> path)
private static bool HasGatewayTargetApproachBacktracking(
IReadOnlyList<WorkflowRenderPoint> path,
WorkflowRenderPositionedNode targetNode)
{
if (path.Count < 4)
{
return false;
}
if (HasShortGatewayTargetOrthogonalHook(path, targetNode))
{
return true;
}
const double tolerance = 0.5d;
var startIndex = Math.Max(0, path.Count - 4);
var nearEnd = path.Skip(startIndex).ToArray();
@@ -757,6 +360,39 @@ public class DocumentProcessingWorkflowRenderingTests
return false;
}
private static bool HasShortGatewayTargetOrthogonalHook(
IReadOnlyList<WorkflowRenderPoint> path,
WorkflowRenderPositionedNode targetNode)
{
if (path.Count < 3)
{
return false;
}
const double tolerance = 0.5d;
var boundaryPoint = path[^1];
var exteriorPoint = path[^2];
var finalDx = Math.Abs(boundaryPoint.X - exteriorPoint.X);
var finalDy = Math.Abs(boundaryPoint.Y - exteriorPoint.Y);
var finalIsHorizontal = finalDx > tolerance && finalDy <= tolerance;
var finalIsVertical = finalDy > tolerance && finalDx <= tolerance;
if (!finalIsHorizontal && !finalIsVertical)
{
return false;
}
var finalStubLength = finalIsHorizontal ? finalDx : finalDy;
var requiredDepth = Math.Min(targetNode.Width, targetNode.Height);
var predecessor = path[^3];
var predecessorDx = Math.Abs(exteriorPoint.X - predecessor.X);
var predecessorDy = Math.Abs(exteriorPoint.Y - predecessor.Y);
const double minimumApproachSpan = 24d;
return finalStubLength + tolerance < requiredDepth
&& (finalIsHorizontal
? predecessorDy >= minimumApproachSpan && predecessorDy > predecessorDx * 3d
: predecessorDx >= minimumApproachSpan && predecessorDx > predecessorDy * 3d);
}
private static IEnumerable<string> GetTargetApproachJoinOffenders(
IReadOnlyCollection<WorkflowRenderRoutedEdge> edges,
IReadOnlyCollection<WorkflowRenderPositionedNode> nodes)
@@ -775,36 +411,51 @@ public class DocumentProcessingWorkflowRenderingTests
continue;
}
var targetEdges = group.ToArray();
for (var i = 0; i < targetEdges.Length; i++)
var elkTargetNode = new ElkPositionedNode
{
var leftPath = ExtractPath(targetEdges[i]);
if (leftPath.Count < 2)
Id = targetNode.Id,
Label = targetNode.Label,
Kind = targetNode.Kind,
X = targetNode.X,
Y = targetNode.Y,
Width = targetNode.Width,
Height = targetNode.Height,
};
var sideGroups = group
.Select(edge => new
{
continue;
}
var leftSide = ResolveTargetApproachJoinSide(leftPath, targetNode);
for (var j = i + 1; j < targetEdges.Length; j++)
Edge = edge,
Path = ExtractPath(edge),
})
.Where(entry => entry.Path.Count >= 2)
.Select(entry => new
{
var rightPath = ExtractPath(targetEdges[j]);
if (rightPath.Count < 2)
{
continue;
}
entry.Edge,
entry.Path,
Side = ResolveTargetApproachJoinSide(entry.Path, targetNode),
})
.GroupBy(entry => entry.Side, StringComparer.Ordinal);
var rightSide = ResolveTargetApproachJoinSide(rightPath, targetNode);
if (!string.Equals(leftSide, rightSide, StringComparison.Ordinal))
{
continue;
}
foreach (var sideGroup in sideGroups)
{
var sideEntries = sideGroup.ToArray();
var requiredGap = ElkBoundarySlots.ResolveRequiredBoundarySlotGap(
elkTargetNode,
sideGroup.Key,
sideEntries.Length,
minLineClearance);
if (!HasTargetApproachJoin(leftPath, rightPath, minLineClearance, 3))
for (var i = 0; i < sideEntries.Length; i++)
{
for (var j = i + 1; j < sideEntries.Length; j++)
{
continue;
}
if (!HasTargetApproachJoin(sideEntries[i].Path, sideEntries[j].Path, requiredGap, 3))
{
continue;
}
yield return $"{targetEdges[i].Id}+{targetEdges[j].Id}@{targetNode.Id}/{leftSide}";
yield return $"{sideEntries[i].Edge.Id}+{sideEntries[j].Edge.Id}@{targetNode.Id}/{sideGroup.Key}";
}
}
}
}
@@ -833,6 +484,7 @@ public class DocumentProcessingWorkflowRenderingTests
double minLineClearance,
int maxSegmentsFromEnd)
{
var effectiveClearance = Math.Max(0d, minLineClearance - 0.5d);
var leftSegments = FlattenSegmentsNearEnd(leftPath, maxSegmentsFromEnd);
var rightSegments = FlattenSegmentsNearEnd(rightPath, maxSegmentsFromEnd);
@@ -840,7 +492,7 @@ public class DocumentProcessingWorkflowRenderingTests
{
foreach (var rightSegment in rightSegments)
{
if (!AreParallelAndClose(leftSegment.Start, leftSegment.End, rightSegment.Start, rightSegment.End, minLineClearance))
if (!AreParallelAndClose(leftSegment.Start, leftSegment.End, rightSegment.Start, rightSegment.End, effectiveClearance))
{
continue;
}
@@ -1555,7 +1207,7 @@ public class DocumentProcessingWorkflowRenderingTests
}
var averageShapeSize = (serviceNodes.Average(node => node.Width) + serviceNodes.Average(node => node.Height)) / 2d;
var maxDiagonalLength = Math.Max(96d, averageShapeSize * 2d);
var maxDiagonalLength = averageShapeSize;
foreach (var edge in edges)
{
@@ -1659,4 +1311,4 @@ public class DocumentProcessingWorkflowRenderingTests
return ResolveBoundarySide(boundaryPoint, node);
}
}
}

View File

@@ -1,6 +1,7 @@
using FluentAssertions;
using NUnit.Framework;
using StellaOps.ElkSharp;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Renderer.ElkSharp;
@@ -149,6 +150,174 @@ public class ElkSharpWorkflowRenderLayoutEngineTests
lowerEdge.StartPoint.Y.Should().BeGreaterThanOrEqualTo(sourceCenterY);
}
[Test]
public async Task LayoutAsync_WhenDecisionSourceExitsTowardLowerBranch_ShouldUseDiagonalGatewayExit()
{
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var graph = new WorkflowRenderGraph
{
Id = "decision-exit-diagonal",
Nodes =
[
new WorkflowRenderNode
{
Id = "source",
Label = "Source",
Kind = "Decision",
Width = 144,
Height = 120,
},
new WorkflowRenderNode
{
Id = "upper",
Label = "Upper",
Kind = "SetState",
Width = 180,
Height = 84,
},
new WorkflowRenderNode
{
Id = "lower",
Label = "Lower",
Kind = "SetState",
Width = 180,
Height = 84,
},
],
Edges =
[
new WorkflowRenderEdge
{
Id = "e-upper",
SourceNodeId = "source",
TargetNodeId = "upper",
Label = "when true",
},
new WorkflowRenderEdge
{
Id = "e-lower",
SourceNodeId = "source",
TargetNodeId = "lower",
Label = "otherwise",
},
],
};
var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var source = result.Nodes.Single(x => x.Id == "source");
var lowerSection = result.Edges.Single(x => x.Id == "e-lower").Sections.Single();
var lowerPath = BuildPath(lowerSection);
var exitPoint = lowerPath[0];
var nextPoint = lowerPath[1];
Math.Abs(nextPoint.X - exitPoint.X).Should().BeGreaterThan(3d);
Math.Abs(nextPoint.Y - exitPoint.Y).Should().BeGreaterThan(3d);
AssertDiamondBoundaryPoint(exitPoint, source);
ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(ToElkNode(source), new ElkPoint { X = nextPoint.X, Y = nextPoint.Y })
.Should()
.BeFalse();
}
[Test]
public async Task LayoutAsync_WhenDecisionTargetIsReachedOffAxis_ShouldUseDiagonalGatewayEntry()
{
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var graph = new WorkflowRenderGraph
{
Id = "decision-entry-diagonal",
Nodes =
[
new WorkflowRenderNode
{
Id = "upper",
Label = "Upper",
Kind = "SetState",
Width = 180,
Height = 84,
},
new WorkflowRenderNode
{
Id = "lower",
Label = "Lower",
Kind = "SetState",
Width = 180,
Height = 84,
},
new WorkflowRenderNode
{
Id = "gate",
Label = "Gate",
Kind = "Decision",
Width = 156,
Height = 132,
},
new WorkflowRenderNode
{
Id = "end",
Label = "End",
Kind = "End",
Width = 80,
Height = 40,
},
],
Edges =
[
new WorkflowRenderEdge
{
Id = "upper-gate",
SourceNodeId = "upper",
TargetNodeId = "gate",
},
new WorkflowRenderEdge
{
Id = "lower-gate",
SourceNodeId = "lower",
TargetNodeId = "gate",
},
new WorkflowRenderEdge
{
Id = "gate-end",
SourceNodeId = "gate",
TargetNodeId = "end",
},
],
};
var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var gate = result.Nodes.Single(x => x.Id == "gate");
var incomingSections = result.Edges
.Where(x => x.TargetNodeId == "gate")
.Select(x => x.Sections.Single())
.ToArray();
var incomingPaths = incomingSections
.Select(BuildPath)
.ToArray();
incomingPaths.All(path => IsPointOnDiamondBoundary(path[^1], gate)).Should().BeTrue();
incomingPaths.Any(path =>
{
var prev = path[^2];
var end = path[^1];
return Math.Abs(end.X - prev.X) > 3d
&& Math.Abs(end.Y - prev.Y) > 3d;
}).Should().BeTrue();
incomingPaths.All(path =>
!ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(
ToElkNode(gate),
new ElkPoint { X = path[^2].X, Y = path[^2].Y }))
.Should()
.BeTrue();
}
[Test]
public async Task LayoutAsync_WhenSameLaneStateBoxesConnected_ShouldAnchorToBoxBorders()
{
@@ -940,22 +1109,22 @@ public class ElkSharpWorkflowRenderLayoutEngineTests
.ToArray();
var outerLoopEdges = loopEdges
.Where(section => section.BendPoints.Min(point => point.Y) < section.EndPoint.Y - 1d)
.Where(section => BuildPath(section).Any(point => point.Y < section.EndPoint.Y - 1d))
.ToArray();
outerLoopEdges.Should().HaveCountGreaterOrEqualTo(2);
outerLoopEdges.Should().OnlyContain(section => section.BendPoints.Count >= 3);
outerLoopEdges.Should().OnlyContain(section => BuildPath(section).Count >= 4);
var directLoopEdges = loopEdges.Except(outerLoopEdges).ToArray();
directLoopEdges.Should().OnlyContain(section => section.BendPoints.Count >= 2);
directLoopEdges.Should().OnlyContain(section => BuildPath(section).Count >= 2);
var collectorX = outerLoopEdges
.Select(section => section.BendPoints.Max(point => point.X))
.Select(section => BuildPath(section).Max(point => point.X))
.DistinctBy(x => Math.Round(x, 2))
.ToArray();
collectorX.Should().HaveCount(1);
collectorX.Should().HaveCountLessOrEqualTo(2);
var outerLaneYs = outerLoopEdges
.Select(section => section.BendPoints.Min(point => point.Y))
.Select(section => BuildPath(section).Min(point => point.Y))
.OrderBy(y => y)
.ToArray();
outerLaneYs.Should().OnlyHaveUniqueItems();
@@ -977,4 +1146,40 @@ public class ElkSharpWorkflowRenderLayoutEngineTests
return bundlePoint?.Y ?? section.BendPoints.Last().Y;
}
private static IReadOnlyList<WorkflowRenderPoint> BuildPath(WorkflowRenderEdgeSection section)
{
var path = new List<WorkflowRenderPoint> { section.StartPoint };
path.AddRange(section.BendPoints);
path.Add(section.EndPoint);
return path;
}
private static void AssertDiamondBoundaryPoint(WorkflowRenderPoint point, WorkflowRenderPositionedNode node)
{
IsPointOnDiamondBoundary(point, node).Should().BeTrue();
}
private static bool IsPointOnDiamondBoundary(WorkflowRenderPoint point, WorkflowRenderPositionedNode node)
{
var centerX = node.X + (node.Width / 2d);
var centerY = node.Y + (node.Height / 2d);
var normalized = (Math.Abs(point.X - centerX) / Math.Max(node.Width / 2d, 0.001d))
+ (Math.Abs(point.Y - centerY) / Math.Max(node.Height / 2d, 0.001d));
return Math.Abs(normalized - 1d) <= 0.08d;
}
private static ElkPositionedNode ToElkNode(WorkflowRenderPositionedNode node)
{
return new ElkPositionedNode
{
Id = node.Id,
Label = node.Label,
Kind = node.Kind,
X = node.X,
Y = node.Y,
Width = node.Width,
Height = node.Height,
};
}
}

View File

@@ -1,4 +1,4 @@
# AGENTS.md · StellaOps.ElkSharp
# AGENTS.md - StellaOps.ElkSharp
## Scope
- Working directory: `src/__Libraries/StellaOps.ElkSharp/`
@@ -22,13 +22,21 @@
- Keep iterative diagnostics detailed enough to prove progress. The document-processing artifact test should emit a live progress log that shows baseline state, strategy starts, per-attempt scores, and adaptation decisions.
- Iterative optimization work should focus on penalized edge or edge-cluster fixes, not whole-graph reroutes. Use whole-graph retries only as a fallback once diagnostics show the local repair path is unavailable.
- Keep attempt 1 as the only full-strategy reroute. Attempt 2+ must target only the failed lanes or failed edge clusters, with shortest-path detours prioritized before broader quality cleanup.
- Do not pad a local-repair iteration with generic high-severity edges that are not part of the currently failing rule set. The repair plan may add exact conflict peers for the same join/shared-lane/corridor problem, but it must not drift back toward a whole-graph reroute.
- Local-repair iterations may build reroute candidates in parallel, but the merge back into the route must stay deterministic and keep the final per-edge apply order stable.
- The selected layout must not backtrack inside the final target-approach window. Attempt 2+ shortest-path repair should try a direct orthogonal shortcut first and only fall back to a low-penalty 45-degree A* candidate when the orthogonal repair is blocked by other rules.
- Keep small or protected graphs on the baseline route when the iterative sweep would risk established geometry contracts; reserve the multi-strategy path for larger congested graphs where it materially improves routing quality.
- Keep per-attempt diagnostics granular enough to expose routing versus post-processing cost. Phase timings and route-pass counts are required evidence before widening the retry budget again.
- Use cheap local geometry repair after routing to clean boundary-angle, target-side arrival-slot, and repeat-collector return-lane defects before escalating to more A* work. The selected layout must satisfy the node-side 90° entry/exit rule and must not leave repeat-collector lanes collapsed onto the same outer return lane.
- Use cheap local geometry repair after routing to clean boundary-angle, target-side arrival-slot, and repeat-collector return-lane defects before escalating to more A* work. The selected layout must satisfy the node-side 90-degree entry/exit rule and must not leave repeat-collector lanes collapsed onto the same outer return lane.
- Boundary joins must use a discrete side-slot lattice, not ad-hoc clustering. Rectangular nodes may use at most `3` evenly spread slots on `left`/`right` faces and at most `5` evenly spread slots on `top`/`bottom` faces; gateway faces may use only `1` or `2` centered face slots. Never allow more than one input/output to occupy the same resolved slot, and do not exempt singleton entries or preserved repeat/corridor exits from the lattice: a lone endpoint still has to land on its centered slot. Make scoring and final repair share the same realizable slot coordinates, and end winner refinement with a restabilization pass so late shared-lane or under-node cleanup cannot drift decision/branch exits back into mid-face clustering.
- When shortest-path local repair evaluates obstacle-skirt candidates, include usable interior axes from the current path and a raw-clearance fallback before preserving a wider overshoot; otherwise the repair can miss an already-safe local lane and keep an unnecessary detour alive.
- When local repair is restricted to a penalized subset, target-slot spacing must still be computed against the full peer set for that target/side so one repaired edge does not collapse back onto the unchanged arrivals beside it.
- Decision/Fork/Join gateway nodes are polygonal, not rectangular. Keep their final landing logic gateway-specific: land on the real polygon boundary, derive target slots from polygon-face intersections instead of rectangular side slots, prefer short 45-degree diagonal stubs only on gateway side faces, never on gateway corner vertices, and do not apply rectangle-side highway or target-backtracking heuristics to gateway targets.
- Selected layouts must not keep any lane below the node field, and any retained 45-degree segment must stay within one average node-shape length. Gateway tips are not valid final join points: source exits must leave from a face interior, and gateway-target join spreading/scoring must group arrivals by the landed boundary band rather than by the final diagonal direction alone.
- Repeat-collector edges with preserved outer corridors are still subject to node-crossing repair. If a pre-corridor prefix crosses a node, reroute only that prefix into the preserved corridor instead of skipping the edge outright.
- Do not replace corridor and backward-route behavior with generic rerouting unless the sprint explicitly changes that contract.
- When touching proximity/highway logic, keep long applicable shared corridors distinct from short shared segments that must be spread apart.
- Future A* performance work must precompute occupied grid cells or blocked segment masks and avoid expanding through cells already owned by non-terminal nodes or previously committed lanes. Derive intermediate grid spacing from approximately one third of the average service-task size instead of keeping a fixed dense lattice.
- The A* router now precomputes node-obstacle blocked step masks per route so neighbor expansion does not rescan every node obstacle. Future performance work should extend that to precomputed lane-occupancy masks for previously committed edge lanes, so the router can skip already-owned space instead of only penalizing it after expansion. Derive intermediate grid spacing from approximately one third of the average service-task size instead of keeping a fixed dense lattice.
- Keep `TopToBottom` behavior stable unless the sprint explicitly includes it.
## Testing

View File

@@ -0,0 +1,219 @@
namespace StellaOps.ElkSharp;
internal static class ElkBoundarySlots
{
private const double GatewayBoundaryInset = 4d;
internal static int ResolveBoundarySlotCapacity(ElkPositionedNode node, string side)
{
if (ElkShapeBoundaries.IsGatewayShape(node))
{
return side is "left" or "right" or "top" or "bottom" ? 2 : 1;
}
return side switch
{
"left" or "right" => 3,
"top" or "bottom" => 5,
_ => 1,
};
}
internal static double[] BuildUniqueBoundarySlotCoordinates(
ElkPositionedNode node,
string side,
int endpointCount)
{
var slotCount = Math.Max(1, Math.Min(Math.Max(1, endpointCount), ResolveBoundarySlotCapacity(node, side)));
var (axisMin, axisMax) = ResolveBoundarySlotAxisRange(node, side);
if (axisMax <= axisMin)
{
var midpoint = (axisMin + axisMax) / 2d;
return Enumerable.Repeat(midpoint, slotCount).ToArray();
}
if (slotCount == 1)
{
return [(axisMin + axisMax) / 2d];
}
var axisSpan = axisMax - axisMin;
if (ElkShapeBoundaries.IsGatewayShape(node))
{
return Enumerable.Range(0, slotCount)
.Select(index => axisMin + (((index + 1d) * axisSpan) / (slotCount + 1d)))
.ToArray();
}
var axisStep = axisSpan / (slotCount - 1d);
return Enumerable.Range(0, slotCount)
.Select(index => axisMin + (index * axisStep))
.ToArray();
}
internal static double[] BuildAssignedBoundarySlotCoordinates(
ElkPositionedNode node,
string side,
int endpointCount)
{
if (endpointCount <= 0)
{
return [];
}
var uniqueCoordinates = BuildUniqueBoundarySlotCoordinates(node, side, endpointCount);
return Enumerable.Range(0, endpointCount)
.Select(index => uniqueCoordinates[ResolveOrderedSlotIndex(index, endpointCount, uniqueCoordinates.Length)])
.ToArray();
}
internal static double[] BuildAssignedBoundarySlotCoordinates(
ElkPositionedNode node,
string side,
IReadOnlyList<double> orderedActualCoordinates)
{
if (orderedActualCoordinates.Count == 0)
{
return [];
}
return BuildAssignedBoundarySlotCoordinates(node, side, orderedActualCoordinates.Count);
}
internal static double[] BuildAssignedBoundarySlotAxisCoordinates(
ElkPositionedNode node,
string side,
int endpointCount)
{
var assignedCoordinates = BuildAssignedBoundarySlotCoordinates(node, side, endpointCount);
if (assignedCoordinates.Length == 0)
{
return [];
}
return assignedCoordinates
.Select(coordinate =>
{
var boundaryPoint = BuildBoundarySlotPoint(node, side, coordinate);
return side is "left" or "right"
? boundaryPoint.Y
: boundaryPoint.X;
})
.ToArray();
}
internal static double[] BuildAssignedBoundarySlotAxisCoordinates(
ElkPositionedNode node,
string side,
IReadOnlyList<double> orderedActualCoordinates)
{
var assignedCoordinates = BuildAssignedBoundarySlotCoordinates(node, side, orderedActualCoordinates);
if (assignedCoordinates.Length == 0)
{
return [];
}
return assignedCoordinates
.Select(coordinate =>
{
var boundaryPoint = BuildBoundarySlotPoint(node, side, coordinate);
return side is "left" or "right"
? boundaryPoint.Y
: boundaryPoint.X;
})
.ToArray();
}
internal static double ResolveRequiredBoundarySlotGap(
ElkPositionedNode node,
string side,
int endpointCount,
double defaultGap)
{
var assignedCoordinates = BuildAssignedBoundarySlotAxisCoordinates(node, side, endpointCount);
if (assignedCoordinates.Length < 2)
{
return defaultGap;
}
var minPositiveGap = double.PositiveInfinity;
var ordered = assignedCoordinates.OrderBy(value => value).ToArray();
for (var i = 1; i < ordered.Length; i++)
{
var gap = ordered[i] - ordered[i - 1];
if (gap > 0.5d)
{
minPositiveGap = Math.Min(minPositiveGap, gap);
}
}
if (double.IsInfinity(minPositiveGap))
{
return defaultGap;
}
return Math.Min(defaultGap, minPositiveGap);
}
internal static int ResolveOrderedSlotIndex(int orderedIndex, int entryCount, int slotCount)
{
if (slotCount <= 1 || entryCount <= 1)
{
return 0;
}
if (orderedIndex <= 0)
{
return 0;
}
if (orderedIndex >= entryCount - 1)
{
return slotCount - 1;
}
return Math.Min(slotCount - 1, (int)Math.Floor((orderedIndex * (double)slotCount) / entryCount));
}
internal static ElkPoint BuildBoundarySlotPoint(
ElkPositionedNode node,
string side,
double slotCoordinate)
{
if (ElkShapeBoundaries.TryProjectGatewayBoundarySlot(node, side, slotCoordinate, out var gatewaySlot))
{
return gatewaySlot;
}
return side switch
{
"left" => new ElkPoint { X = node.X, Y = slotCoordinate },
"right" => new ElkPoint { X = node.X + node.Width, Y = slotCoordinate },
"top" => new ElkPoint { X = slotCoordinate, Y = node.Y },
"bottom" => new ElkPoint { X = slotCoordinate, Y = node.Y + node.Height },
_ => new ElkPoint
{
X = node.X + (node.Width / 2d),
Y = node.Y + (node.Height / 2d),
},
};
}
private static (double Min, double Max) ResolveBoundarySlotAxisRange(
ElkPositionedNode node,
string side)
{
if (ElkShapeBoundaries.IsGatewayShape(node))
{
return side is "left" or "right"
? (node.Y + GatewayBoundaryInset, node.Y + node.Height - GatewayBoundaryInset)
: (node.X + GatewayBoundaryInset, node.X + node.Width - GatewayBoundaryInset);
}
var inset = side is "left" or "right"
? Math.Min(24d, Math.Max(8d, node.Height / 4d))
: Math.Min(24d, Math.Max(8d, node.Width / 4d));
return side is "left" or "right"
? (node.Y + inset, node.Y + node.Height - inset)
: (node.X + inset, node.X + node.Width - inset);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -34,27 +34,60 @@ internal static class ElkEdgePostProcessorCorridor
var corridorY = pts[firstCorridorIndex].Y;
var isAboveCorridor = corridorY < graphMinY - 8d;
var clearanceMargin = Math.Max(margin, 40d);
var result = new List<ElkPoint>();
if (firstCorridorIndex > 0)
{
if (isAboveCorridor)
{
var preCorridorHasCrossing = false;
for (var i = 0; i < firstCorridorIndex; i++)
{
var last = result.Count > 0 ? result[^1] : (ElkPoint?)null;
if (last is null || Math.Abs(last.X - pts[i].X) > 0.01d || Math.Abs(last.Y - pts[i].Y) > 0.01d)
if (!ElkEdgePostProcessor.SegmentCrossesObstacle(pts[i], pts[i + 1], obstacles, sourceId, targetId)
&& !SegmentViolatesObstacleClearance(pts[i], pts[i + 1], obstacles, sourceId, targetId, clearanceMargin))
{
result.Add(pts[i]);
continue;
}
preCorridorHasCrossing = true;
break;
}
if (preCorridorHasCrossing)
{
var entryTarget = pts[firstCorridorIndex];
var entryPath = ElkEdgePostProcessorAStar.RerouteWithGridAStar(
section.StartPoint,
entryTarget,
obstacles,
sourceId,
targetId,
margin);
if (entryPath is not null && entryPath.Count >= 2)
{
result.AddRange(entryPath);
}
}
var entryX = result.Count > 0 ? result[^1].X : section.StartPoint.X;
var entryY = result.Count > 0 ? result[^1].Y : section.StartPoint.Y;
var safeEntryX = FindSafeVerticalX(entryX, entryY, corridorY, obstacles, sourceId, targetId);
if (Math.Abs(safeEntryX - entryX) > 1d)
if (result.Count == 0)
{
result.Add(new ElkPoint { X = safeEntryX, Y = corridorY });
for (var i = 0; i < firstCorridorIndex; i++)
{
var last = result.Count > 0 ? result[^1] : (ElkPoint?)null;
if (last is null || Math.Abs(last.X - pts[i].X) > 0.01d || Math.Abs(last.Y - pts[i].Y) > 0.01d)
{
result.Add(pts[i]);
}
}
var entryX = result.Count > 0 ? result[^1].X : section.StartPoint.X;
var entryY = result.Count > 0 ? result[^1].Y : section.StartPoint.Y;
var safeEntryX = FindSafeVerticalX(entryX, entryY, corridorY, obstacles, sourceId, targetId, clearanceMargin);
if (Math.Abs(safeEntryX - entryX) > 1d)
{
result.Add(new ElkPoint { X = safeEntryX, Y = corridorY });
}
}
}
else
@@ -140,7 +173,8 @@ internal static class ElkEdgePostProcessorCorridor
internal static double FindSafeVerticalX(
double anchorX, double anchorY, double corridorY,
(double Left, double Top, double Right, double Bottom, string Id)[] obstacles,
string sourceId, string targetId)
string sourceId, string targetId,
double clearanceMargin = 0d)
{
var minY = Math.Min(anchorY, corridorY);
var maxY = Math.Max(anchorY, corridorY);
@@ -153,7 +187,10 @@ internal static class ElkEdgePostProcessorCorridor
continue;
}
if (anchorX > ob.Left && anchorX < ob.Right && maxY > ob.Top && minY < ob.Bottom)
if (anchorX > ob.Left - clearanceMargin
&& anchorX < ob.Right + clearanceMargin
&& maxY > ob.Top - clearanceMargin
&& minY < ob.Bottom + clearanceMargin)
{
blocked = true;
break;
@@ -167,16 +204,17 @@ internal static class ElkEdgePostProcessorCorridor
var candidateRight = anchorX;
var candidateLeft = anchorX;
var searchStep = Math.Max(24d, clearanceMargin * 0.5d);
for (var attempt = 0; attempt < 20; attempt++)
{
candidateRight += 24d;
if (!IsVerticalBlocked(candidateRight, minY, maxY, obstacles, sourceId, targetId))
candidateRight += searchStep;
if (!IsVerticalBlocked(candidateRight, minY, maxY, obstacles, sourceId, targetId, clearanceMargin))
{
return candidateRight;
}
candidateLeft -= 24d;
if (!IsVerticalBlocked(candidateLeft, minY, maxY, obstacles, sourceId, targetId))
candidateLeft -= searchStep;
if (!IsVerticalBlocked(candidateLeft, minY, maxY, obstacles, sourceId, targetId, clearanceMargin))
{
return candidateLeft;
}
@@ -188,7 +226,8 @@ internal static class ElkEdgePostProcessorCorridor
private static bool IsVerticalBlocked(
double x, double minY, double maxY,
(double Left, double Top, double Right, double Bottom, string Id)[] obstacles,
string sourceId, string targetId)
string sourceId, string targetId,
double clearanceMargin)
{
foreach (var ob in obstacles)
{
@@ -197,7 +236,66 @@ internal static class ElkEdgePostProcessorCorridor
continue;
}
if (x > ob.Left && x < ob.Right && maxY > ob.Top && minY < ob.Bottom)
if (x > ob.Left - clearanceMargin
&& x < ob.Right + clearanceMargin
&& maxY > ob.Top - clearanceMargin
&& minY < ob.Bottom + clearanceMargin)
{
return true;
}
}
return false;
}
private static bool SegmentViolatesObstacleClearance(
ElkPoint start,
ElkPoint end,
(double Left, double Top, double Right, double Bottom, string Id)[] obstacles,
string sourceId,
string targetId,
double clearanceMargin)
{
if (clearanceMargin <= 0d)
{
return false;
}
var horizontal = Math.Abs(start.Y - end.Y) <= 0.5d;
var vertical = Math.Abs(start.X - end.X) <= 0.5d;
if (!horizontal && !vertical)
{
return false;
}
foreach (var ob in obstacles)
{
if (ob.Id == sourceId || ob.Id == targetId)
{
continue;
}
if (horizontal)
{
var minX = Math.Min(start.X, end.X);
var maxX = Math.Max(start.X, end.X);
if (start.Y > ob.Top - clearanceMargin
&& start.Y < ob.Bottom + clearanceMargin
&& maxX > ob.Left - clearanceMargin
&& minX < ob.Right + clearanceMargin)
{
return true;
}
continue;
}
var minY = Math.Min(start.Y, end.Y);
var maxY = Math.Max(start.Y, end.Y);
if (start.X > ob.Left - clearanceMargin
&& start.X < ob.Right + clearanceMargin
&& maxY > ob.Top - clearanceMargin
&& minY < ob.Bottom + clearanceMargin)
{
return true;
}

View File

@@ -48,6 +48,15 @@ internal static class ElkEdgeRouterAStar8Dir
return null;
}
var graphMaxY = obstacles.Length > 0
? obstacles.Max(obstacle => obstacle.Bottom)
: double.MaxValue;
var disallowedBottomY = graphMaxY + 4d;
var maxDiagonalStepLength = ResolveMaxDiagonalStepLength(obstacles);
var blockedSegments = BuildBlockedSegments(xArr, yArr, obstacles, sourceId, targetId);
var softObstacleInfos = BuildSoftObstacleInfos(softObstacles);
var startIx = Array.BinarySearch(xArr, start.X);
var startIy = Array.BinarySearch(yArr, start.Y);
var endIx = Array.BinarySearch(xArr, end.X);
@@ -59,41 +68,34 @@ internal static class ElkEdgeRouterAStar8Dir
bool IsBlockedOrthogonal(int ix1, int iy1, int ix2, int iy2)
{
var x1 = xArr[ix1];
var y1 = yArr[iy1];
var x2 = xArr[ix2];
var y2 = yArr[iy2];
foreach (var ob in obstacles)
if (ix1 == ix2)
{
if (ob.Id == sourceId || ob.Id == targetId)
var minIy = Math.Min(iy1, iy2);
var maxIy = Math.Max(iy1, iy2);
for (var iy = minIy; iy < maxIy; iy++)
{
continue;
if (blockedSegments.IsVerticalBlocked(ix1, iy))
{
return true;
}
}
if (ix1 == ix2)
return false;
}
if (iy1 == iy2)
{
var minIx = Math.Min(ix1, ix2);
var maxIx = Math.Max(ix1, ix2);
for (var ix = minIx; ix < maxIx; ix++)
{
if (x1 > ob.Left && x1 < ob.Right)
if (blockedSegments.IsHorizontalBlocked(ix, iy1))
{
var minY = Math.Min(y1, y2);
var maxY = Math.Max(y1, y2);
if (maxY > ob.Top && minY < ob.Bottom)
{
return true;
}
}
}
else if (iy1 == iy2)
{
if (y1 > ob.Top && y1 < ob.Bottom)
{
var minX = Math.Min(x1, x2);
var maxX = Math.Max(x1, x2);
if (maxX > ob.Left && minX < ob.Right)
{
return true;
}
return true;
}
}
return false;
}
return false;
@@ -152,18 +154,20 @@ internal static class ElkEdgeRouterAStar8Dir
var maxIterations = xCount * yCount * 12;
var iterations = 0;
var closed = new HashSet<int>();
var closed = new bool[stateCount];
while (openSet.Count > 0 && iterations++ < maxIterations)
{
cancellationToken.ThrowIfCancellationRequested();
var current = openSet.Dequeue();
if (!closed.Add(current))
if (closed[current])
{
continue;
}
closed[current] = true;
var curDir = current % dirCount;
var curIy = (current / dirCount) % yCount;
var curIx = (current / dirCount) / yCount;
@@ -182,6 +186,11 @@ internal static class ElkEdgeRouterAStar8Dir
continue;
}
if (yArr[curIy] > disallowedBottomY || yArr[ny] > disallowedBottomY)
{
continue;
}
var isDiagonal = Dx[d] != 0 && Dy[d] != 0;
if (isDiagonal)
{
@@ -214,7 +223,12 @@ internal static class ElkEdgeRouterAStar8Dir
{
var ddx = xArr[nx] - xArr[curIx];
var ddy = yArr[ny] - yArr[curIy];
dist = Math.Sqrt(ddx * ddx + ddy * ddy) + routingParams.DiagonalPenalty;
var diagonalStepLength = Math.Sqrt(ddx * ddx + ddy * ddy);
if (diagonalStepLength > maxDiagonalStepLength)
{
continue;
}
dist = diagonalStepLength + routingParams.DiagonalPenalty;
}
else
{
@@ -223,7 +237,7 @@ internal static class ElkEdgeRouterAStar8Dir
var softCost = ComputeSoftObstacleCost(
xArr[curIx], yArr[curIy], xArr[nx], yArr[ny],
softObstacles, routingParams);
softObstacleInfos, routingParams);
var tentativeG = gScore[current] + dist + bend + softCost;
var neighborState = StateId(nx, ny, newDir);
@@ -240,6 +254,20 @@ internal static class ElkEdgeRouterAStar8Dir
return null;
}
private static double ResolveMaxDiagonalStepLength(
IReadOnlyCollection<(double Left, double Top, double Right, double Bottom, string Id)> obstacles)
{
if (obstacles.Count == 0)
{
return 256d;
}
var averageWidth = obstacles.Average(obstacle => obstacle.Right - obstacle.Left);
var averageHeight = obstacles.Average(obstacle => obstacle.Bottom - obstacle.Top);
var averageShapeSize = (averageWidth + averageHeight) / 2d;
return Math.Max(96d, averageShapeSize * 2d);
}
private static double ComputeBendPenalty(int curDir, int newDir, double bendPenalty)
{
if (curDir == 0 || curDir == newDir)
@@ -259,10 +287,10 @@ internal static class ElkEdgeRouterAStar8Dir
private static double ComputeSoftObstacleCost(
double x1, double y1, double x2, double y2,
IReadOnlyList<OrthogonalSoftObstacle> softObstacles,
SoftObstacleInfo[] softObstacles,
AStarRoutingParams routingParams)
{
if (routingParams.SoftObstacleWeight <= 0d || softObstacles.Count == 0)
if (routingParams.SoftObstacleWeight <= 0d || softObstacles.Length == 0)
{
return 0d;
}
@@ -271,10 +299,26 @@ internal static class ElkEdgeRouterAStar8Dir
var candidateEnd = new ElkPoint { X = x2, Y = y2 };
var candidateIsH = Math.Abs(y2 - y1) < 2d;
var candidateIsV = Math.Abs(x2 - x1) < 2d;
var candidateMinX = Math.Min(x1, x2);
var candidateMaxX = Math.Max(x1, x2);
var candidateMinY = Math.Min(y1, y2);
var candidateMaxY = Math.Max(y1, y2);
var expandedMinX = candidateMinX - routingParams.SoftObstacleClearance;
var expandedMaxX = candidateMaxX + routingParams.SoftObstacleClearance;
var expandedMinY = candidateMinY - routingParams.SoftObstacleClearance;
var expandedMaxY = candidateMaxY + routingParams.SoftObstacleClearance;
var cost = 0d;
foreach (var obstacle in softObstacles)
{
if (expandedMaxX < obstacle.MinX
|| expandedMinX > obstacle.MaxX
|| expandedMaxY < obstacle.MinY
|| expandedMinY > obstacle.MaxY)
{
continue;
}
if (ElkEdgeRoutingGeometry.SegmentsIntersect(candidateStart, candidateEnd, obstacle.Start, obstacle.End))
{
cost += 120d * routingParams.SoftObstacleWeight;
@@ -284,7 +328,7 @@ internal static class ElkEdgeRouterAStar8Dir
// Graduated proximity: closer = exponentially more expensive
var dist = ComputeParallelDistance(
x1, y1, x2, y2, candidateIsH, candidateIsV,
obstacle.Start, obstacle.End,
obstacle,
routingParams.SoftObstacleClearance);
if (dist >= 0d)
@@ -300,41 +344,202 @@ internal static class ElkEdgeRouterAStar8Dir
private static double ComputeParallelDistance(
double x1, double y1, double x2, double y2,
bool candidateIsH, bool candidateIsV,
ElkPoint obStart, ElkPoint obEnd,
SoftObstacleInfo obstacle,
double clearance)
{
var obIsH = Math.Abs(obStart.Y - obEnd.Y) < 2d;
var obIsV = Math.Abs(obStart.X - obEnd.X) < 2d;
if (candidateIsH && obIsH)
if (candidateIsH && obstacle.IsHorizontal)
{
var dist = Math.Abs(y1 - obStart.Y);
var dist = Math.Abs(y1 - obstacle.Start.Y);
if (dist >= clearance)
{
return -1d;
}
var overlapMin = Math.Max(Math.Min(x1, x2), Math.Min(obStart.X, obEnd.X));
var overlapMax = Math.Min(Math.Max(x1, x2), Math.Max(obStart.X, obEnd.X));
var overlapMin = Math.Max(Math.Min(x1, x2), obstacle.MinX);
var overlapMax = Math.Min(Math.Max(x1, x2), obstacle.MaxX);
return overlapMax > overlapMin + 1d ? dist : -1d;
}
if (candidateIsV && obIsV)
if (candidateIsV && obstacle.IsVertical)
{
var dist = Math.Abs(x1 - obStart.X);
var dist = Math.Abs(x1 - obstacle.Start.X);
if (dist >= clearance)
{
return -1d;
}
var overlapMin = Math.Max(Math.Min(y1, y2), Math.Min(obStart.Y, obEnd.Y));
var overlapMax = Math.Min(Math.Max(y1, y2), Math.Max(obStart.Y, obEnd.Y));
var overlapMin = Math.Max(Math.Min(y1, y2), obstacle.MinY);
var overlapMax = Math.Min(Math.Max(y1, y2), obstacle.MaxY);
return overlapMax > overlapMin + 1d ? dist : -1d;
}
return -1d;
}
private static BlockedSegments BuildBlockedSegments(
double[] xArr,
double[] yArr,
(double Left, double Top, double Right, double Bottom, string Id)[] obstacles,
string sourceId,
string targetId)
{
var xCount = xArr.Length;
var yCount = yArr.Length;
var verticalBlocked = new bool[xCount * Math.Max(0, yCount - 1)];
var horizontalBlocked = new bool[Math.Max(0, xCount - 1) * yCount];
foreach (var obstacle in obstacles)
{
if (obstacle.Id == sourceId || obstacle.Id == targetId)
{
continue;
}
var verticalXStart = Math.Max(0, LowerBoundExclusive(xArr, obstacle.Left));
var verticalXEnd = Math.Min(xCount - 1, UpperBoundExclusive(xArr, obstacle.Right) - 1);
if (verticalXStart <= verticalXEnd)
{
var verticalYStart = Math.Max(0, LowerBound(yArr, obstacle.Top) - 1);
var verticalYEnd = Math.Min(yCount - 2, UpperBound(yArr, obstacle.Bottom) - 1);
for (var ix = verticalXStart; ix <= verticalXEnd; ix++)
{
for (var iy = verticalYStart; iy <= verticalYEnd; iy++)
{
if (yArr[iy + 1] > obstacle.Top && yArr[iy] < obstacle.Bottom)
{
verticalBlocked[(ix * (yCount - 1)) + iy] = true;
}
}
}
}
var horizontalYStart = Math.Max(0, LowerBoundExclusive(yArr, obstacle.Top));
var horizontalYEnd = Math.Min(yCount - 1, UpperBoundExclusive(yArr, obstacle.Bottom) - 1);
if (horizontalYStart <= horizontalYEnd)
{
var horizontalXStart = Math.Max(0, LowerBound(xArr, obstacle.Left) - 1);
var horizontalXEnd = Math.Min(xCount - 2, UpperBound(xArr, obstacle.Right) - 1);
for (var iy = horizontalYStart; iy <= horizontalYEnd; iy++)
{
for (var ix = horizontalXStart; ix <= horizontalXEnd; ix++)
{
if (xArr[ix + 1] > obstacle.Left && xArr[ix] < obstacle.Right)
{
horizontalBlocked[(ix * yCount) + iy] = true;
}
}
}
}
}
return new BlockedSegments(xCount, yCount, verticalBlocked, horizontalBlocked);
}
private static SoftObstacleInfo[] BuildSoftObstacleInfos(IReadOnlyList<OrthogonalSoftObstacle> softObstacles)
{
if (softObstacles.Count == 0)
{
return [];
}
var infos = new SoftObstacleInfo[softObstacles.Count];
for (var i = 0; i < softObstacles.Count; i++)
{
var obstacle = softObstacles[i];
infos[i] = new SoftObstacleInfo(
obstacle.Start,
obstacle.End,
Math.Min(obstacle.Start.X, obstacle.End.X),
Math.Max(obstacle.Start.X, obstacle.End.X),
Math.Min(obstacle.Start.Y, obstacle.End.Y),
Math.Max(obstacle.Start.Y, obstacle.End.Y),
Math.Abs(obstacle.Start.Y - obstacle.End.Y) < 2d,
Math.Abs(obstacle.Start.X - obstacle.End.X) < 2d);
}
return infos;
}
private static int LowerBound(double[] values, double target)
{
var low = 0;
var high = values.Length;
while (low < high)
{
var mid = low + ((high - low) / 2);
if (values[mid] < target)
{
low = mid + 1;
}
else
{
high = mid;
}
}
return low;
}
private static int UpperBound(double[] values, double target)
{
var low = 0;
var high = values.Length;
while (low < high)
{
var mid = low + ((high - low) / 2);
if (values[mid] <= target)
{
low = mid + 1;
}
else
{
high = mid;
}
}
return low;
}
private static int LowerBoundExclusive(double[] values, double target)
{
var low = 0;
var high = values.Length;
while (low < high)
{
var mid = low + ((high - low) / 2);
if (values[mid] <= target)
{
low = mid + 1;
}
else
{
high = mid;
}
}
return low;
}
private static int UpperBoundExclusive(double[] values, double target)
{
var low = 0;
var high = values.Length;
while (low < high)
{
var mid = low + ((high - low) / 2);
if (values[mid] < target)
{
low = mid + 1;
}
else
{
high = mid;
}
}
return low;
}
private static List<ElkPoint> ReconstructPath(
int endState, int[] cameFrom,
double[] xArr, double[] yArr,
@@ -391,4 +596,41 @@ internal static class ElkEdgeRouterAStar8Dir
}
}
}
private readonly record struct SoftObstacleInfo(
ElkPoint Start,
ElkPoint End,
double MinX,
double MaxX,
double MinY,
double MaxY,
bool IsHorizontal,
bool IsVertical);
private readonly record struct BlockedSegments(
int XCount,
int YCount,
bool[] VerticalBlocked,
bool[] HorizontalBlocked)
{
internal bool IsVerticalBlocked(int ix, int iy)
{
if (ix < 0 || ix >= XCount || iy < 0 || iy >= YCount - 1)
{
return true;
}
return VerticalBlocked[(ix * (YCount - 1)) + iy];
}
internal bool IsHorizontalBlocked(int ix, int iy)
{
if (ix < 0 || ix >= XCount - 1 || iy < 0 || iy >= YCount)
{
return true;
}
return HorizontalBlocked[(ix * YCount) + iy];
}
}
}

View File

@@ -164,6 +164,11 @@ internal static class ElkEdgeRouterHighway
continue;
}
if (ElkShapeBoundaries.IsGatewayShape(targetNode))
{
continue;
}
var path = ExtractFullPath(edge);
if (path.Count < 2)
{
@@ -203,7 +208,12 @@ internal static class ElkEdgeRouterHighway
var pairMetrics = ComputePairMetrics(members);
var actualGap = ComputeMinEndpointGap(members, side);
var requiresSpread = (actualGap + CoordinateTolerance) < minLineClearance
var requiredGap = ElkBoundarySlots.ResolveRequiredBoundarySlotGap(
targetNode,
side,
members.Count,
minLineClearance);
var requiresSpread = (actualGap + CoordinateTolerance) < requiredGap
&& !pairMetrics.AllPairsApplicable;
if (!requiresSpread && pairMetrics.ShortestSharedRatio < MinHighwayRatio)
{
@@ -223,10 +233,10 @@ internal static class ElkEdgeRouterHighway
Reason = requiresSpread
? pairMetrics.HasSharedSegment && pairMetrics.ShortestSharedRatio < MinHighwayRatio
? $"shared ratio {pairMetrics.ShortestSharedRatio:F2} < {MinHighwayRatio:F2}"
: $"gap {actualGap:F0}px < clearance {minLineClearance:F0}px"
: $"gap {actualGap:F0}px < required gap {requiredGap:F0}px"
: pairMetrics.AllPairsApplicable
? $"shared ratio {pairMetrics.ShortestSharedRatio:F2} >= {MinHighwayRatio:F2}"
: $"gap {actualGap:F0}px >= clearance {minLineClearance:F0}px",
: $"gap {actualGap:F0}px >= required gap {requiredGap:F0}px",
};
return new GroupEvaluation(members, diagnostic);
@@ -300,33 +310,7 @@ internal static class ElkEdgeRouterHighway
int count,
double minLineClearance)
{
if (count <= 1)
{
return
[
side is "left" or "right"
? targetNode.Y + (targetNode.Height / 2d)
: targetNode.X + (targetNode.Width / 2d),
];
}
var axisMin = side is "left" or "right"
? targetNode.Y + BoundaryInset
: targetNode.X + BoundaryInset;
var axisMax = side is "left" or "right"
? targetNode.Y + targetNode.Height - BoundaryInset
: targetNode.X + targetNode.Width - BoundaryInset;
var axisLength = Math.Max(8d, axisMax - axisMin);
var spacing = Math.Max(
MinimumSpreadSpacing,
Math.Min(minLineClearance, axisLength / (count - 1)));
var totalSpan = (count - 1) * spacing;
var center = (axisMin + axisMax) / 2d;
var start = Math.Max(axisMin, Math.Min(center - (totalSpan / 2d), axisMax - totalSpan));
return Enumerable.Range(0, count)
.Select(index => Math.Min(axisMax, start + (index * spacing)))
.ToArray();
return ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates(targetNode, side, count);
}
private static List<ElkPoint> AdjustPathToTargetSlot(

View File

@@ -0,0 +1,924 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
namespace StellaOps.ElkSharp;
internal static partial class ElkEdgeRouterIterative
{
private static ElkRoutedEdge[] ApplyPostProcessing(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
ElkLayoutOptions layoutOptions)
{
var result = ElkEdgePostProcessor.AvoidNodeCrossings(edges, nodes, layoutOptions.Direction);
result = ElkEdgePostProcessor.EliminateDiagonalSegments(result, nodes);
result = ElkEdgePostProcessorSimplify.SimplifyEdgePaths(result, nodes);
result = ElkEdgePostProcessorSimplify.TightenOuterCorridors(result, nodes);
if (HighwayProcessingEnabled)
{
result = ElkEdgeRouterHighway.BreakShortHighways(result, nodes);
}
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction);
result = ElkEdgePostProcessorSimplify.SimplifyEdgePaths(result, nodes);
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
var serviceNodes = nodes.Where(n => n.Kind is not "Start" and not "End").ToArray();
var minLineClearance = serviceNodes.Length > 0
? Math.Min(serviceNodes.Average(n => n.Width), serviceNodes.Average(n => n.Height)) / 2d
: 50d;
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance);
result = RestoreProtectedRepeatCollectorCorridors(result, edges, nodes);
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction);
result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes);
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction);
result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes);
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction);
result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes);
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction);
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction);
result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction);
result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes);
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction);
result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes);
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes);
result = ClampBelowGraphEdges(result, nodes);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(result, nodes);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes);
result = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(result, nodes);
result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(result, nodes, minLineClearance);
result = ClampBelowGraphEdges(result, nodes);
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, layoutOptions.Direction);
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(result, nodes, minLineClearance);
result = ElkRepeatCollectorCorridors.SeparateSharedLanes(result, nodes);
result = ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(result, nodes);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
// The final hard-rule closure must end on lane separation so later
// boundary slot normalizers cannot collapse a repaired handoff strip
// back onto the same effective rail.
result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance);
result = ClampBelowGraphEdges(result, nodes);
result = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
result,
nodes,
minLineClearance,
enforceAllNodeEndpoints: true);
result = ApplyPostSlotDetourClosure(result, nodes, minLineClearance);
result = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
result,
nodes,
minLineClearance,
enforceAllNodeEndpoints: true);
var score = ElkEdgeRoutingScoring.ComputeScore(result, nodes);
var remainingBrokenHighways = HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(result, nodes).Count
: 0;
var retryState = BuildRetryState(score, remainingBrokenHighways);
if (retryState.RequiresBlockingRetry || retryState.RequiresLengthRetry)
{
var stabilized = ApplyTerminalRuleCleanupRound(
result,
nodes,
layoutOptions.Direction,
minLineClearance);
var stabilizedScore = ElkEdgeRoutingScoring.ComputeScore(stabilized, nodes);
var stabilizedBrokenHighways = HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(stabilized, nodes).Count
: 0;
var stabilizedRetryState = BuildRetryState(stabilizedScore, stabilizedBrokenHighways);
if (IsBetterBoundarySlotRepairCandidate(
stabilizedScore,
stabilizedRetryState,
score,
retryState))
{
result = stabilized;
}
}
return result;
}
private static ElkRoutedEdge[] ApplyTerminalRuleCleanupRound(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
ElkLayoutDirection direction,
double minLineClearance,
IReadOnlyCollection<string>? restrictedEdgeIds = null)
{
var result = edges;
result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(result, nodes, restrictedEdgeIds);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
result = ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkRepeatCollectorCorridors.SeparateSharedLanes(result, nodes, restrictedEdgeIds);
result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(result, nodes, restrictedEdgeIds);
result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes, restrictedEdgeIds);
result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds);
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes, restrictedEdgeIds);
result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds);
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
// Final late-stage verification: source/target boundary normalization can collapse
// lanes back onto the same node face, so restabilize the local geometry once more.
result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds);
result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds);
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
result = ElkEdgePostProcessor.SpreadSourceDepartureJoins(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SeparateSharedLaneConflicts(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(result, nodes, restrictedEdgeIds);
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(result, nodes, restrictedEdgeIds);
result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds);
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
// Final hard-rule restabilization after the last normalize pass: the final
// boundary normalization can still pull target slots and horizontal lanes back
// into a bad state, so re-apply the local rule fixers once more before scoring.
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
result = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SpreadTargetApproachJoins(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.ElevateUnderNodeViolations(result, nodes, minLineClearance, restrictedEdgeIds);
result = ClampBelowGraphEdges(result, nodes, restrictedEdgeIds);
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction, restrictedEdgeIds);
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = CloseRemainingTerminalViolations(result, nodes, direction, minLineClearance, restrictedEdgeIds);
var lateDetourShortcuts = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(result, nodes, restrictedEdgeIds);
result = ElkEdgeRoutingScoring.CountBoundarySlotViolations(result, nodes) > 0
? ChoosePreferredBoundarySlotRepairLayout(result, lateDetourShortcuts, nodes)
: ChoosePreferredHardRuleLayout(result, lateDetourShortcuts, nodes);
result = ApplyFinalDetourPolish(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
result,
nodes,
minLineClearance,
restrictedEdgeIds,
enforceAllNodeEndpoints: true);
result = ApplyPostSlotDetourClosure(result, nodes, minLineClearance, restrictedEdgeIds);
result = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
result,
nodes,
minLineClearance,
restrictedEdgeIds,
enforceAllNodeEndpoints: true);
return result;
}
private static ElkRoutedEdge[] ApplyFinalDetourPolish(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
double minLineClearance,
IReadOnlyCollection<string>? restrictedEdgeIds)
{
var restrictedSet = restrictedEdgeIds is null
? null
: restrictedEdgeIds.ToHashSet(StringComparer.Ordinal);
var result = edges;
for (var round = 0; round < 3; round++)
{
var detourSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountExcessiveDetourViolations(result, nodes, detourSeverity, 10);
if (detourSeverity.Count == 0)
{
break;
}
var currentScore = ElkEdgeRoutingScoring.ComputeScore(result, nodes);
var currentRetryState = BuildRetryState(
currentScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(result, nodes).Count
: 0);
var improved = false;
foreach (var edgeId in detourSeverity
.OrderByDescending(pair => pair.Value)
.ThenBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => pair.Key))
{
if (restrictedSet is not null && !restrictedSet.Contains(edgeId))
{
continue;
}
var focused = (IReadOnlyCollection<string>)[edgeId];
var candidateEdges = ComposeTransactionalFinalDetourCandidate(
result,
nodes,
minLineClearance,
focused);
candidateEdges = ChoosePreferredHardRuleLayout(result, candidateEdges, nodes);
if (ReferenceEquals(candidateEdges, result))
{
continue;
}
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes);
var candidateRetryState = BuildRetryState(
candidateScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count
: 0);
var improvedDetours = candidateRetryState.ExcessiveDetourViolations < currentRetryState.ExcessiveDetourViolations;
if (HasHardRuleRegression(candidateRetryState, currentRetryState)
|| (!improvedDetours
&& !IsBetterBoundarySlotRepairCandidate(
candidateScore,
candidateRetryState,
currentScore,
currentRetryState)))
{
continue;
}
result = candidateEdges;
improved = true;
break;
}
if (!improved)
{
break;
}
}
return result;
}
private static bool TryPromoteFinalDetourCandidate(
ElkRoutedEdge[] baselineEdges,
ElkRoutedEdge[] candidateEdges,
ElkPositionedNode[] nodes,
EdgeRoutingScore baselineScore,
RoutingRetryState baselineRetryState,
out ElkRoutedEdge[] promotedEdges)
{
promotedEdges = baselineEdges;
if (ReferenceEquals(candidateEdges, baselineEdges))
{
return false;
}
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes);
var candidateRetryState = BuildRetryState(
candidateScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count
: 0);
var improvedDetours = candidateRetryState.ExcessiveDetourViolations < baselineRetryState.ExcessiveDetourViolations;
var improvedGatewaySource = candidateRetryState.GatewaySourceExitViolations < baselineRetryState.GatewaySourceExitViolations;
if (HasHardRuleRegression(candidateRetryState, baselineRetryState)
|| (!(improvedDetours || improvedGatewaySource)
&& !IsBetterBoundarySlotRepairCandidate(
candidateScore,
candidateRetryState,
baselineScore,
baselineRetryState)))
{
return false;
}
promotedEdges = candidateEdges;
return true;
}
private static ElkRoutedEdge[] ComposeTransactionalFinalDetourCandidate(
ElkRoutedEdge[] baseline,
ElkPositionedNode[] nodes,
double minLineClearance,
IReadOnlyCollection<string> focusedEdgeIds)
{
var candidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(baseline, nodes, focusedEdgeIds);
if (ReferenceEquals(candidate, baseline))
{
return baseline;
}
candidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(candidate, nodes, minLineClearance, focusedEdgeIds);
candidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(candidate, nodes, minLineClearance, focusedEdgeIds);
candidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(candidate, nodes, minLineClearance, focusedEdgeIds);
candidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(candidate, nodes, minLineClearance, focusedEdgeIds);
candidate = ClampBelowGraphEdges(candidate, nodes, focusedEdgeIds);
candidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(candidate, nodes, minLineClearance, focusedEdgeIds);
candidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(candidate, nodes, minLineClearance, focusedEdgeIds);
candidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(candidate, nodes, minLineClearance, focusedEdgeIds);
candidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(candidate, nodes, focusedEdgeIds);
candidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(candidate, nodes, focusedEdgeIds);
candidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(candidate, nodes, minLineClearance, focusedEdgeIds);
candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
candidate,
nodes,
minLineClearance,
focusedEdgeIds,
enforceAllNodeEndpoints: true);
candidate = ApplyPostSlotDetourClosure(candidate, nodes, minLineClearance, focusedEdgeIds);
candidate = ApplyLateBoundarySlotRestabilization(candidate, nodes, minLineClearance, focusedEdgeIds);
candidate = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(candidate, nodes, minLineClearance, focusedEdgeIds);
candidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts(candidate, nodes, minLineClearance, focusedEdgeIds);
candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
candidate,
nodes,
minLineClearance,
focusedEdgeIds,
enforceAllNodeEndpoints: true);
candidate = ApplyPostSlotDetourClosure(candidate, nodes, minLineClearance, focusedEdgeIds);
return candidate;
}
internal static ElkRoutedEdge[] ApplyPostSlotDetourClosure(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
double minLineClearance,
IReadOnlyCollection<string>? restrictedEdgeIds = null)
{
var candidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(edges, nodes, restrictedEdgeIds);
if (ReferenceEquals(candidate, edges))
{
return edges;
}
candidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes);
candidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes);
candidate = ElkEdgePostProcessor.SpreadSourceDepartureJoins(candidate, nodes, minLineClearance, restrictedEdgeIds);
candidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(candidate, nodes, minLineClearance, restrictedEdgeIds);
candidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(candidate, nodes, minLineClearance, restrictedEdgeIds);
candidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(candidate, nodes, minLineClearance, restrictedEdgeIds);
candidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(candidate, nodes, restrictedEdgeIds);
candidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(candidate, nodes, restrictedEdgeIds);
candidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(candidate, nodes, minLineClearance, restrictedEdgeIds);
candidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(candidate, nodes, minLineClearance, restrictedEdgeIds);
candidate = ClampBelowGraphEdges(candidate, nodes, restrictedEdgeIds);
candidate = ElkEdgePostProcessor.NormalizeBoundaryAngles(candidate, nodes);
candidate = ElkEdgePostProcessor.NormalizeSourceExitAngles(candidate, nodes);
candidate = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(candidate, nodes, minLineClearance, restrictedEdgeIds);
candidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts(candidate, nodes, minLineClearance, restrictedEdgeIds);
candidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
candidate,
nodes,
minLineClearance,
restrictedEdgeIds,
enforceAllNodeEndpoints: true);
return ElkEdgeRoutingScoring.CountBoundarySlotViolations(edges, nodes) > 0
? ChoosePreferredBoundarySlotRepairLayout(edges, candidate, nodes)
: ChoosePreferredHardRuleLayout(edges, candidate, nodes);
}
private static ElkRoutedEdge[] CloseRemainingTerminalViolations(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
ElkLayoutDirection direction,
double minLineClearance,
IReadOnlyCollection<string>? restrictedEdgeIds)
{
var result = edges;
var restrictedSet = restrictedEdgeIds is null
? null
: restrictedEdgeIds.ToHashSet(StringComparer.Ordinal);
ElkLayoutDiagnostics.LogProgress(
$"Terminal closure start: restricted={restrictedEdgeIds?.Count ?? 0}");
for (var round = 0; round < 4; round++)
{
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} pressure scan start");
var severityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
var previousHardPressure =
ElkEdgeRoutingScoring.CountBadBoundaryAngles(result, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(result, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(result, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountSharedLaneViolations(result, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountBoundarySlotViolations(result, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountBelowGraphViolations(result, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountUnderNodeViolations(result, nodes, severityByEdgeId, 10);
var previousLengthPressure = 0;
if (previousHardPressure == 0)
{
previousLengthPressure =
ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(result, nodes, severityByEdgeId, 10)
+ ElkEdgeRoutingScoring.CountExcessiveDetourViolations(result, nodes, severityByEdgeId, 10);
}
ElkLayoutDiagnostics.LogProgress(
$"Terminal closure round {round + 1} pressure scan done: hard={previousHardPressure} length={previousLengthPressure} severity={severityByEdgeId.Count}");
var previousScore = ElkEdgeRoutingScoring.ComputeScore(result, nodes);
var previousRetryState = BuildRetryState(
previousScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(result, nodes).Count
: 0);
ElkLayoutDiagnostics.LogProgress(
$"Terminal closure round {round + 1} retry state ready: {DescribeRetryState(previousRetryState)}");
if (previousHardPressure == 0 && previousLengthPressure == 0)
{
break;
}
var focusEdgeIds = severityByEdgeId.Keys
.Where(edgeId => restrictedSet is null || restrictedSet.Contains(edgeId))
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
if (focusEdgeIds.Length == 0)
{
break;
}
ElkLayoutDiagnostics.LogProgress(
$"Terminal closure round {round + 1} focus ready: count={focusEdgeIds.Length}");
var focused = (IReadOnlyCollection<string>)focusEdgeIds;
var candidate = result;
if (previousHardPressure > 0
&& ShouldPreferCompactFocusedTerminalClosure(previousRetryState, focusEdgeIds.Length))
{
ElkLayoutDiagnostics.LogProgress(
$"Terminal closure round {round + 1} compact hard-pass start");
candidate = ApplyCompactFocusedTerminalClosure(
candidate,
nodes,
direction,
minLineClearance,
focused);
ElkLayoutDiagnostics.LogProgress(
$"Terminal closure round {round + 1} compact hard-pass complete");
}
else if (previousHardPressure > 0)
{
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} hard-pass block start");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-boundary");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-source-exit");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateSharedLaneConflicts(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-shared-lanes-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-repeat-collector-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkRepeatCollectorCorridors.SeparateSharedLanes(current, nodes, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repeat-collector-shared-lanes-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadSourceDepartureJoins(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-source-joins-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-mixed-faces-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.ElevateUnderNodeViolations(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after elevate-under-node-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.ElevateRepeatCollectorNodeClearanceViolations(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after elevate-repeat-collector-clearance-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(current, nodes, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after prefer-shortest-shortcuts-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ClampBelowGraphEdges(current, nodes, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after clamp-below-graph-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repair-boundary-target-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadTargetApproachJoins(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-target-joins-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-feeder-bands-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(current, nodes, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after finalize-gateway-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.AvoidNodeCrossings(current, nodes, direction, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after avoid-node-crossings-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.ElevateUnderNodeViolations(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after elevate-under-node-2");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-boundary-2");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-source-exit-2");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateSharedLaneConflicts(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-shared-lanes-2");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateRepeatCollectorLocalLaneConflicts(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-repeat-collector-2");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkRepeatCollectorCorridors.SeparateSharedLanes(current, nodes, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repeat-collector-shared-lanes-2");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-boundary-3");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-source-exit-3");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadSourceDepartureJoins(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-source-joins-2");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-mixed-faces-2");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repair-boundary-target-2");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadTargetApproachJoins(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-target-joins-2");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-feeder-bands-2");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ClampBelowGraphEdges(current, nodes, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after clamp-below-graph-2");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.AvoidNodeCrossings(current, nodes, direction, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after avoid-node-crossings-2");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.ElevateUnderNodeViolations(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after elevate-under-node-3");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-boundary-4");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-source-exit-4");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadSourceDepartureJoins(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-source-joins-3");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-mixed-faces-3");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SeparateSharedLaneConflicts(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after separate-shared-lanes-3");
}
else
{
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} length-pass block start");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(current, nodes, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after prefer-shortest-shortcuts-length-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repair-boundary-target-length-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadTargetApproachJoins(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-target-joins-length-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-feeder-bands-length-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeDecisionTargetEntries(current, nodes, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after finalize-decision-targets-length-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(current, nodes, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after finalize-gateway-length-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ClampBelowGraphEdges(current, nodes, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after clamp-below-graph-length-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.AvoidNodeCrossings(current, nodes, direction, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after avoid-node-crossings-length-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeBoundaryAngles(current, nodes));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-boundary-length-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.NormalizeSourceExitAngles(current, nodes));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after normalize-source-exit-length-1");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(current, nodes, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after prefer-shortest-shortcuts-length-2");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after repair-boundary-target-length-2");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.SpreadTargetApproachJoins(current, nodes, minLineClearance, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after spread-target-joins-length-2");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeDecisionTargetEntries(current, nodes, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after finalize-decision-targets-length-2");
candidate = ApplyGuardedFocusedHardRulePass(candidate, nodes, current => ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(current, nodes, focused));
ElkLayoutDiagnostics.LogProgress($"Terminal closure round {round + 1} after finalize-gateway-length-2");
}
var currentHardPressure =
ElkEdgeRoutingScoring.CountBadBoundaryAngles(candidate, nodes)
+ ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(candidate, nodes)
+ ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(candidate, nodes)
+ ElkEdgeRoutingScoring.CountSharedLaneViolations(candidate, nodes)
+ ElkEdgeRoutingScoring.CountBoundarySlotViolations(candidate, nodes)
+ ElkEdgeRoutingScoring.CountBelowGraphViolations(candidate, nodes)
+ ElkEdgeRoutingScoring.CountUnderNodeViolations(candidate, nodes);
var currentLengthPressure =
ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(candidate, nodes)
+ ElkEdgeRoutingScoring.CountExcessiveDetourViolations(candidate, nodes);
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes);
var candidateRetryState = BuildRetryState(
candidateScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count
: 0);
var improvedBoundarySlots = candidateRetryState.BoundarySlotViolations < previousRetryState.BoundarySlotViolations;
var rejectedByRegression = improvedBoundarySlots
? candidateScore.NodeCrossings > previousScore.NodeCrossings
|| HasBlockingBoundarySlotPromotionRegression(candidateRetryState, previousRetryState)
: HasHardRuleRegression(candidateRetryState, previousRetryState);
var madeProgress = improvedBoundarySlots
|| (previousHardPressure > 0
? currentHardPressure < previousHardPressure
: currentLengthPressure < previousLengthPressure);
if (rejectedByRegression || !madeProgress)
{
break;
}
result = candidate;
}
return result;
}
private static ElkRoutedEdge[] ApplyGuardedFocusedHardRulePass(
ElkRoutedEdge[] current,
ElkPositionedNode[] nodes,
Func<ElkRoutedEdge[], ElkRoutedEdge[]> pass)
{
var candidate = pass(current);
return ElkEdgeRoutingScoring.CountBoundarySlotViolations(current, nodes) > 0
? ChoosePreferredBoundarySlotRepairLayout(current, candidate, nodes)
: ChoosePreferredHardRuleLayout(current, candidate, nodes);
}
private static ElkRoutedEdge[] ChoosePreferredBoundarySlotRepairLayout(
ElkRoutedEdge[] baseline,
ElkRoutedEdge[] candidate,
ElkPositionedNode[] nodes)
{
if (ReferenceEquals(candidate, baseline))
{
return baseline;
}
var baselineScore = ElkEdgeRoutingScoring.ComputeScore(baseline, nodes);
var baselineRetryState = BuildRetryState(
baselineScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(baseline, nodes).Count
: 0);
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes);
var candidateRetryState = BuildRetryState(
candidateScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count
: 0);
if (!IsBetterBoundarySlotRepairCandidate(
candidateScore,
candidateRetryState,
baselineScore,
baselineRetryState))
{
return baseline;
}
// Boundary-slot repair is staged ahead of other soft cleanups. Once a
// candidate legitimately reduces boundary-slot violations without
// introducing a blocking hard regression, keep it alive so the later
// shared-lane / detour passes can recover any temporary soft tradeoff.
if (candidateRetryState.BoundarySlotViolations < baselineRetryState.BoundarySlotViolations)
{
return candidate;
}
var retryComparison = CompareRetryStates(candidateRetryState, baselineRetryState);
if (retryComparison < 0)
{
return candidate;
}
if (retryComparison > 0)
{
return baseline;
}
if (candidateScore.NodeCrossings != baselineScore.NodeCrossings)
{
return candidateScore.NodeCrossings < baselineScore.NodeCrossings
? candidate
: baseline;
}
return candidateScore.Value > baselineScore.Value
? candidate
: baseline;
}
private static ElkRoutedEdge[] ChoosePreferredSharedLanePolishLayout(
ElkRoutedEdge[] baseline,
ElkRoutedEdge[] candidate,
ElkPositionedNode[] nodes)
{
if (ReferenceEquals(candidate, baseline))
{
return baseline;
}
var baselineScore = ElkEdgeRoutingScoring.ComputeScore(baseline, nodes);
var baselineRetryState = BuildRetryState(
baselineScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(baseline, nodes).Count
: 0);
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes);
var candidateRetryState = BuildRetryState(
candidateScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count
: 0);
if (!IsBetterSharedLanePolishCandidate(
candidateScore,
candidateRetryState,
baselineScore,
baselineRetryState))
{
return baseline;
}
if (candidateRetryState.SharedLaneViolations < baselineRetryState.SharedLaneViolations)
{
return candidate;
}
var retryComparison = CompareRetryStates(candidateRetryState, baselineRetryState);
if (retryComparison < 0)
{
return candidate;
}
if (retryComparison > 0)
{
return baseline;
}
if (candidateScore.NodeCrossings != baselineScore.NodeCrossings)
{
return candidateScore.NodeCrossings < baselineScore.NodeCrossings
? candidate
: baseline;
}
return candidateScore.Value > baselineScore.Value
? candidate
: baseline;
}
private static bool IsBetterBoundarySlotRepairCandidate(
EdgeRoutingScore candidateScore,
RoutingRetryState candidateRetryState,
EdgeRoutingScore baselineScore,
RoutingRetryState baselineRetryState)
{
if (candidateRetryState.BoundarySlotViolations < baselineRetryState.BoundarySlotViolations)
{
return candidateScore.NodeCrossings <= baselineScore.NodeCrossings
&& !HasBlockingBoundarySlotPromotionRegression(candidateRetryState, baselineRetryState);
}
return IsBetterCandidate(candidateScore, candidateRetryState, baselineScore, baselineRetryState);
}
private static bool IsBetterSharedLanePolishCandidate(
EdgeRoutingScore candidateScore,
RoutingRetryState candidateRetryState,
EdgeRoutingScore baselineScore,
RoutingRetryState baselineRetryState)
{
if (candidateRetryState.SharedLaneViolations < baselineRetryState.SharedLaneViolations)
{
return candidateScore.NodeCrossings <= baselineScore.NodeCrossings
&& !HasBlockingSharedLanePromotionRegression(candidateRetryState, baselineRetryState);
}
return IsBetterCandidate(candidateScore, candidateRetryState, baselineScore, baselineRetryState);
}
private static ElkRoutedEdge[] ChoosePreferredHardRuleLayout(
ElkRoutedEdge[] baseline,
ElkRoutedEdge[] candidate,
ElkPositionedNode[] nodes)
{
if (ReferenceEquals(candidate, baseline))
{
return baseline;
}
var baselineScore = ElkEdgeRoutingScoring.ComputeScore(baseline, nodes);
var baselineRetryState = BuildRetryState(
baselineScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(baseline, nodes).Count
: 0);
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes);
var candidateRetryState = BuildRetryState(
candidateScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidate, nodes).Count
: 0);
if (HasHardRuleRegression(candidateRetryState, baselineRetryState))
{
return baseline;
}
var retryComparison = CompareRetryStates(candidateRetryState, baselineRetryState);
if (retryComparison < 0)
{
return candidate;
}
if (retryComparison > 0)
{
return baseline;
}
if (candidateScore.NodeCrossings != baselineScore.NodeCrossings)
{
return candidateScore.NodeCrossings < baselineScore.NodeCrossings
? candidate
: baseline;
}
return candidateScore.Value > baselineScore.Value
? candidate
: baseline;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
namespace StellaOps.ElkSharp;
internal static partial class ElkEdgeRouterIterative
{
private readonly record struct CandidateSolution(
EdgeRoutingScore Score,
RoutingRetryState RetryState,
ElkRoutedEdge[] Edges,
int StrategyIndex);
private readonly record struct StrategyWorkItem(
int StrategyIndex,
string StrategyName,
RoutingStrategy Strategy);
private sealed record StrategyEvaluationResult(
int StrategyIndex,
IReadOnlyList<CandidateSolution> FallbackSolutions,
CandidateSolution? ValidSolution,
ElkIterativeStrategyDiagnostics Diagnostics);
private readonly record struct RepairPlan(
int[] EdgeIndices,
string[] EdgeIds,
string[] PreferredShortestEdgeIds,
string[] RouteRepairEdgeIds,
string[] Reasons);
private sealed record RouteAllEdgesResult(
ElkRoutedEdge[] Edges,
ElkIterativeRouteDiagnostics Diagnostics);
private sealed record RepairEdgeBuildResult(
ElkRoutedEdge Edge,
int RoutedSections,
int FallbackSections,
bool WasSkipped);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -190,6 +190,70 @@ internal static class ElkEdgeRoutingGeometry
return "bottom";
}
internal static string ResolveBoundaryApproachSide(
ElkPoint boundaryPoint,
ElkPoint adjacentPoint,
ElkPositionedNode node)
{
if (!ElkShapeBoundaries.IsGatewayShape(node))
{
return ResolveBoundarySide(boundaryPoint, node);
}
var deltaX = boundaryPoint.X - adjacentPoint.X;
var deltaY = boundaryPoint.Y - adjacentPoint.Y;
var absDx = Math.Abs(deltaX);
var absDy = Math.Abs(deltaY);
if (absDx <= CoordinateTolerance && absDy > CoordinateTolerance)
{
return deltaY >= 0d ? "top" : "bottom";
}
if (absDy <= CoordinateTolerance && absDx > CoordinateTolerance)
{
return deltaX >= 0d ? "left" : "right";
}
if (absDx > absDy * 1.25d)
{
return deltaX >= 0d ? "left" : "right";
}
if (absDy > absDx * 1.25d)
{
return deltaY >= 0d ? "top" : "bottom";
}
return ResolveBoundarySide(boundaryPoint, node);
}
internal static double ComputeParallelOverlapLength(
ElkPoint a1,
ElkPoint a2,
ElkPoint b1,
ElkPoint b2)
{
if (IsHorizontal(a1, a2) && IsHorizontal(b1, b2))
{
return OverlapLength(
Math.Min(a1.X, a2.X),
Math.Max(a1.X, a2.X),
Math.Min(b1.X, b2.X),
Math.Max(b1.X, b2.X));
}
if (IsVertical(a1, a2) && IsVertical(b1, b2))
{
return OverlapLength(
Math.Min(a1.Y, a2.Y),
Math.Max(a1.Y, a2.Y),
Math.Min(b1.Y, b2.Y),
Math.Max(b1.Y, b2.Y));
}
return 0d;
}
internal static bool AreCollinearAndOverlapping(ElkPoint a1, ElkPoint a2, ElkPoint b1, ElkPoint b2)
{
if (IsHorizontal(a1, a2) && IsHorizontal(b1, b2) && Math.Abs(a1.Y - b1.Y) <= CoordinateTolerance)

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading;
namespace StellaOps.ElkSharp;
@@ -38,6 +39,7 @@ internal sealed class ElkLayoutRunDiagnostics
public List<ElkHighwayDiagnostics> DetectedHighways { get; } = [];
public List<string> ProgressLog { get; } = [];
public string? ProgressLogPath { get; set; }
public string? SnapshotPath { get; set; }
}
internal sealed class ElkHighwayDiagnostics
@@ -58,11 +60,14 @@ internal sealed class ElkIterativeStrategyDiagnostics
public int Attempts { get; set; }
public double TotalDurationMs { get; set; }
public EdgeRoutingScore? BestScore { get; set; }
public required string Outcome { get; init; }
public required string Outcome { get; set; }
public double BendPenalty { get; init; }
public double DiagonalPenalty { get; init; }
public double SoftObstacleWeight { get; init; }
[System.Text.Json.Serialization.JsonIgnore]
internal bool RegisteredLive { get; set; }
[System.Text.Json.Serialization.JsonIgnore]
public ElkRoutedEdge[]? BestEdges { get; set; }
@@ -93,6 +98,8 @@ internal sealed class ElkIterativeRouteDiagnostics
public int SoftObstacleSegments { get; init; }
public IReadOnlyCollection<string> RepairedEdgeIds { get; init; } = [];
public IReadOnlyCollection<string> RepairReasons { get; init; } = [];
public string? BuilderMode { get; init; }
public int BuilderParallelism { get; init; }
}
internal sealed class ElkIterativePhaseDiagnostics
@@ -135,6 +142,7 @@ internal sealed class ElkDiagnosticSectionPath
internal static class ElkLayoutDiagnostics
{
private static readonly AsyncLocal<ElkLayoutRunDiagnostics?> CurrentDiagnostics = new();
private static readonly JsonSerializerOptions SnapshotJsonOptions = new() { WriteIndented = true };
internal static ElkLayoutRunDiagnostics? Current => CurrentDiagnostics.Value;
@@ -175,6 +183,8 @@ internal static class ElkLayoutDiagnostics
{
File.AppendAllText(diagnostics.ProgressLogPath, line + Environment.NewLine);
}
WriteSnapshotLocked(diagnostics);
}
}
@@ -189,6 +199,7 @@ internal static class ElkLayoutDiagnostics
lock (diagnostics.SyncRoot)
{
diagnostics.DetectedHighways.Add(diagnostic);
WriteSnapshotLocked(diagnostics);
}
}
@@ -203,6 +214,49 @@ internal static class ElkLayoutDiagnostics
lock (diagnostics.SyncRoot)
{
diagnostics.Attempts.Add(attempt);
WriteSnapshotLocked(diagnostics);
}
}
internal static void FlushSnapshot()
{
var diagnostics = CurrentDiagnostics.Value;
if (diagnostics is null)
{
return;
}
lock (diagnostics.SyncRoot)
{
WriteSnapshotLocked(diagnostics);
}
}
internal static void FlushSnapshot(ElkLayoutRunDiagnostics diagnostics)
{
lock (diagnostics.SyncRoot)
{
WriteSnapshotLocked(diagnostics);
}
}
private static void WriteSnapshotLocked(ElkLayoutRunDiagnostics diagnostics)
{
if (string.IsNullOrWhiteSpace(diagnostics.SnapshotPath))
{
return;
}
var snapshotPath = diagnostics.SnapshotPath!;
var snapshotDir = Path.GetDirectoryName(snapshotPath);
if (!string.IsNullOrWhiteSpace(snapshotDir))
{
Directory.CreateDirectory(snapshotDir);
}
var tempPath = snapshotPath + ".tmp";
File.WriteAllText(tempPath, JsonSerializer.Serialize(diagnostics, SnapshotJsonOptions));
File.Copy(tempPath, snapshotPath, overwrite: true);
File.Delete(tempPath);
}
}

View File

@@ -12,6 +12,8 @@ internal readonly record struct GraphBounds(double MinX, double MinY, double Max
internal readonly record struct LayerBoundary(double MinX, double MaxX, double MinY, double MaxY);
internal readonly record struct NodePlacementGrid(double XStep, double YStep);
internal readonly record struct EdgeChannel(
EdgeRouteMode RouteMode,
int BackwardLane,
@@ -52,12 +54,19 @@ internal readonly record struct EdgeRoutingScore(
int BendCount,
int TargetCongestion,
int DiagonalCount,
int BelowGraphViolations,
int UnderNodeViolations,
int LongDiagonalViolations,
int EntryAngleViolations,
int GatewaySourceExitViolations,
int LabelProximityViolations,
int RepeatCollectorCorridorViolations,
int RepeatCollectorNodeClearanceViolations,
int TargetApproachJoinViolations,
int TargetApproachBacktrackingViolations,
int ExcessiveDetourViolations,
int SharedLaneViolations,
int BoundarySlotViolations,
int ProximityViolations,
double TotalPathLength,
double Value);
@@ -65,24 +74,39 @@ internal readonly record struct EdgeRoutingScore(
internal readonly record struct RoutingRetryState(
int RemainingShortHighways,
int RepeatCollectorCorridorViolations,
int RepeatCollectorNodeClearanceViolations,
int TargetApproachJoinViolations,
int TargetApproachBacktrackingViolations,
int ExcessiveDetourViolations,
int SharedLaneViolations,
int BoundarySlotViolations,
int BelowGraphViolations,
int UnderNodeViolations,
int LongDiagonalViolations,
int ProximityViolations,
int EntryAngleViolations,
int GatewaySourceExitViolations,
int LabelProximityViolations,
int EdgeCrossings)
{
internal int QualityViolationCount =>
ProximityViolations + EntryAngleViolations + LabelProximityViolations;
ProximityViolations + LabelProximityViolations;
internal bool RequiresQualityRetry => QualityViolationCount > 0;
internal int BlockingViolationCount =>
RemainingShortHighways
+ RepeatCollectorCorridorViolations
+ RepeatCollectorNodeClearanceViolations
+ TargetApproachJoinViolations
+ TargetApproachBacktrackingViolations;
+ TargetApproachBacktrackingViolations
+ SharedLaneViolations
+ BoundarySlotViolations
+ BelowGraphViolations
+ UnderNodeViolations
+ LongDiagonalViolations
+ EntryAngleViolations
+ GatewaySourceExitViolations;
internal bool RequiresBlockingRetry => BlockingViolationCount > 0;
@@ -136,19 +160,29 @@ internal sealed class RoutingStrategy
{
var highwayPressure = Math.Min(retryState.RemainingShortHighways, 4);
var collectorCorridorPressure = Math.Min(retryState.RepeatCollectorCorridorViolations, 4);
var collectorClearancePressure = Math.Min(retryState.RepeatCollectorNodeClearanceViolations, 6);
var targetJoinPressure = Math.Min(retryState.TargetApproachJoinViolations, 4);
var backtrackingPressure = Math.Min(retryState.TargetApproachBacktrackingViolations, 4);
var detourPressure = Math.Min(retryState.ExcessiveDetourViolations, 4);
var sharedLanePressure = Math.Min(retryState.SharedLaneViolations, 6);
var boundarySlotPressure = Math.Min(retryState.BoundarySlotViolations, 6);
var underNodePressure = Math.Min(retryState.UnderNodeViolations, 6);
var proximityPressure = Math.Min(retryState.ProximityViolations, 6);
var entryPressure = Math.Min(retryState.EntryAngleViolations, 4);
var gatewaySourcePressure = Math.Min(retryState.GatewaySourceExitViolations, 4);
var labelPressure = Math.Min(retryState.LabelProximityViolations, 4);
var crossingPressure = Math.Min(retryState.EdgeCrossings, 6);
var clearanceStep = 4d
+ (highwayPressure > 0 ? 8d : 0d)
+ (collectorCorridorPressure > 0 ? 10d : 0d)
+ (collectorClearancePressure > 0 ? 10d : 0d)
+ (targetJoinPressure > 0 ? 12d : 0d)
+ (backtrackingPressure > 0 ? 6d : 0d)
+ (sharedLanePressure > 0 ? 12d : 0d)
+ (boundarySlotPressure > 0 ? 12d : 0d)
+ (underNodePressure > 0 ? 12d : 0d)
+ (proximityPressure > 0 ? 10d : 0d)
+ (gatewaySourcePressure > 0 ? 8d : 0d)
+ (labelPressure > 0 ? 4d : 0d)
+ (crossingPressure > 0 ? 3d : 0d);
MinLineClearance = Math.Min(
@@ -156,15 +190,15 @@ internal sealed class RoutingStrategy
BaseLineClearance * 2d);
var bendPenalty = RoutingParams.BendPenalty;
if (entryPressure > 0 || labelPressure > 0 || highwayPressure > 0 || collectorCorridorPressure > 0 || targetJoinPressure > 0)
if (entryPressure > 0 || gatewaySourcePressure > 0 || labelPressure > 0 || highwayPressure > 0 || collectorCorridorPressure > 0 || collectorClearancePressure > 0 || targetJoinPressure > 0 || sharedLanePressure > 0 || boundarySlotPressure > 0 || underNodePressure > 0)
{
bendPenalty = Math.Min(bendPenalty + 40d, 800d);
}
else if (backtrackingPressure > 0 || detourPressure > 0 || proximityPressure > 0 || crossingPressure > 0)
else if (backtrackingPressure > 0 || detourPressure > 0 || sharedLanePressure > 0 || boundarySlotPressure > 0 || underNodePressure > 0 || proximityPressure > 0 || crossingPressure > 0)
{
bendPenalty = Math.Max(
80d,
bendPenalty - (backtrackingPressure > 0 ? 80d : detourPressure > 0 ? 50d : 30d));
bendPenalty - (backtrackingPressure > 0 ? 80d : detourPressure > 0 ? 50d : sharedLanePressure > 0 || boundarySlotPressure > 0 || underNodePressure > 0 ? 40d : 30d));
}
var margin = RoutingParams.Margin;
@@ -178,9 +212,14 @@ internal sealed class RoutingStrategy
margin
+ (highwayPressure > 0 ? 8d : 4d)
+ (collectorCorridorPressure > 0 ? 8d : 0d)
+ (collectorClearancePressure > 0 ? 8d : 0d)
+ (targetJoinPressure > 0 ? 10d : 0d)
+ (sharedLanePressure > 0 ? 10d : 0d)
+ (boundarySlotPressure > 0 ? 10d : 0d)
+ (underNodePressure > 0 ? 10d : 0d)
+ (proximityPressure > 0 ? 6d : 0d)
+ (entryPressure > 0 ? 3d : 0d),
+ (entryPressure > 0 ? 3d : 0d)
+ (gatewaySourcePressure > 0 ? 4d : 0d),
BaseLineClearance * 2d);
}
@@ -197,7 +236,9 @@ internal sealed class RoutingStrategy
softObstacleWeight
+ (highwayPressure > 0 ? 0.75d : 0.25d)
+ (collectorCorridorPressure > 0 ? 0.75d : 0d)
+ (collectorClearancePressure > 0 ? 0.75d : 0d)
+ (targetJoinPressure > 0 ? 1.0d : 0d)
+ (sharedLanePressure > 0 ? 1.0d : 0d)
+ (proximityPressure > 0 ? 0.75d : 0d)
+ (crossingPressure > 0 ? 0.5d : 0d),
8d);
@@ -216,7 +257,9 @@ internal sealed class RoutingStrategy
softObstacleClearance
+ (highwayPressure > 0 ? 8d : 4d)
+ (collectorCorridorPressure > 0 ? 10d : 0d)
+ (collectorClearancePressure > 0 ? 10d : 0d)
+ (targetJoinPressure > 0 ? 16d : 0d)
+ (sharedLanePressure > 0 ? 16d : 0d)
+ (proximityPressure > 0 ? 10d : 0d)
+ (labelPressure > 0 ? 4d : 0d)
+ (crossingPressure > 0 ? 4d : 0d),
@@ -238,6 +281,7 @@ internal sealed class RoutingStrategy
- (highwayPressure > 0 ? 6d : 2d)
- (collectorCorridorPressure > 0 ? 6d : 0d)
- (targetJoinPressure > 0 ? 8d : 0d)
- (sharedLanePressure > 0 ? 8d : 0d)
- (proximityPressure > 0 ? 6d : 0d)
- (entryPressure > 0 ? 4d : 0d)
- (labelPressure > 0 ? 2d : 0d));

View File

@@ -85,7 +85,7 @@ public sealed record EdgeRefinementOptions
public sealed record IterativeRoutingOptions
{
public bool? Enabled { get; init; }
public int MaxAdaptationsPerStrategy { get; init; } = 10;
public int MaxAdaptationsPerStrategy { get; init; } = 100;
public int RequiredValidSolutions { get; init; } = 10;
}

View File

@@ -2,6 +2,23 @@ namespace StellaOps.ElkSharp;
internal static class ElkNodePlacement
{
internal static NodePlacementGrid ResolvePlacementGrid(IReadOnlyCollection<ElkNode> nodes)
{
var actualNodes = nodes
.Where(node => node.Kind is not "Start" and not "End")
.ToArray();
if (actualNodes.Length == 0)
{
return new NodePlacementGrid(160d, 80d);
}
var averageWidth = actualNodes.Average(node => node.Width);
var averageHeight = actualNodes.Average(node => node.Height);
return new NodePlacementGrid(
XStep: Math.Max(64d, Math.Round(averageWidth / 8d) * 8d),
YStep: Math.Max(48d, Math.Round(averageHeight / 8d) * 8d));
}
internal static int ResolveOrderingIterationCount(
ElkLayoutOptions options,
int edgeCount,
@@ -218,6 +235,7 @@ internal static class ElkNodePlacement
sortedNodes,
desiredCoordinates,
nodeSpacing,
0d,
horizontal: direction == ElkLayoutDirection.LeftToRight);
for (var nodeIndex = 0; nodeIndex < sortedNodes.Length; nodeIndex++)
@@ -231,10 +249,130 @@ internal static class ElkNodePlacement
}
}
internal static void AlignToPlacementGrid(
Dictionary<string, ElkPositionedNode> positionedNodes,
IReadOnlyList<ElkNode[]> layers,
IReadOnlySet<string> dummyNodeIds,
double nodeSpacing,
NodePlacementGrid placementGrid,
ElkLayoutDirection direction)
{
if (layers.Count == 0)
{
return;
}
if (direction == ElkLayoutDirection.LeftToRight)
{
foreach (var layer in layers)
{
var actualNodes = layer.Where(node => !dummyNodeIds.Contains(node.Id)).ToArray();
if (actualNodes.Length == 0)
{
continue;
}
var currentX = positionedNodes[actualNodes[0].Id].X;
var snappedX = SnapToPlacementGrid(currentX, placementGrid.XStep);
var deltaX = snappedX - currentX;
if (Math.Abs(deltaX) > 0.01d)
{
foreach (var node in layer)
{
var pos = positionedNodes[node.Id];
positionedNodes[node.Id] = ElkLayoutHelpers.CreatePositionedNode(
node,
pos.X + deltaX,
pos.Y,
direction);
}
}
var desiredY = actualNodes
.Select(node => SnapToPlacementGrid(positionedNodes[node.Id].Y, placementGrid.YStep))
.ToArray();
EnforceLinearSpacing(actualNodes, desiredY, nodeSpacing, placementGrid.YStep, horizontal: true);
for (var i = 0; i < actualNodes.Length; i++)
{
var current = positionedNodes[actualNodes[i].Id];
positionedNodes[actualNodes[i].Id] = ElkLayoutHelpers.CreatePositionedNode(
actualNodes[i],
current.X,
desiredY[i],
direction);
}
}
var minY = positionedNodes.Values.Min(node => node.Y);
if (minY < -0.01d)
{
var shift = SnapForwardToPlacementGrid(-minY, placementGrid.YStep);
foreach (var nodeId in positionedNodes.Keys.ToArray())
{
var pos = positionedNodes[nodeId];
positionedNodes[nodeId] = pos with { Y = pos.Y + shift };
}
}
return;
}
foreach (var layer in layers)
{
var actualNodes = layer.Where(node => !dummyNodeIds.Contains(node.Id)).ToArray();
if (actualNodes.Length == 0)
{
continue;
}
var currentY = positionedNodes[actualNodes[0].Id].Y;
var snappedY = SnapToPlacementGrid(currentY, placementGrid.YStep);
var deltaY = snappedY - currentY;
if (Math.Abs(deltaY) > 0.01d)
{
foreach (var node in layer)
{
var pos = positionedNodes[node.Id];
positionedNodes[node.Id] = ElkLayoutHelpers.CreatePositionedNode(
node,
pos.X,
pos.Y + deltaY,
direction);
}
}
var desiredX = actualNodes
.Select(node => SnapToPlacementGrid(positionedNodes[node.Id].X, placementGrid.XStep))
.ToArray();
EnforceLinearSpacing(actualNodes, desiredX, nodeSpacing, placementGrid.XStep, horizontal: false);
for (var i = 0; i < actualNodes.Length; i++)
{
var current = positionedNodes[actualNodes[i].Id];
positionedNodes[actualNodes[i].Id] = ElkLayoutHelpers.CreatePositionedNode(
actualNodes[i],
desiredX[i],
current.Y,
direction);
}
}
var minX = positionedNodes.Values.Min(node => node.X);
if (minX < -0.01d)
{
var shift = SnapForwardToPlacementGrid(-minX, placementGrid.XStep);
foreach (var nodeId in positionedNodes.Keys.ToArray())
{
var pos = positionedNodes[nodeId];
positionedNodes[nodeId] = pos with { X = pos.X + shift };
}
}
}
internal static void EnforceLinearSpacing(
IReadOnlyList<ElkNode> layer,
double[] desiredCoordinates,
double spacing,
double gridStep,
bool horizontal)
{
for (var index = 1; index < layer.Count; index++)
@@ -243,6 +381,7 @@ internal static class ElkNodePlacement
desiredCoordinates[index] = Math.Max(
desiredCoordinates[index],
desiredCoordinates[index - 1] + extent + spacing);
desiredCoordinates[index] = SnapForwardToPlacementGrid(desiredCoordinates[index], gridStep);
}
for (var index = layer.Count - 2; index >= 0; index--)
@@ -251,6 +390,7 @@ internal static class ElkNodePlacement
desiredCoordinates[index] = Math.Min(
desiredCoordinates[index],
desiredCoordinates[index + 1] - extent - spacing);
desiredCoordinates[index] = SnapBackwardToPlacementGrid(desiredCoordinates[index], gridStep);
}
for (var index = 1; index < layer.Count; index++)
@@ -259,6 +399,37 @@ internal static class ElkNodePlacement
desiredCoordinates[index] = Math.Max(
desiredCoordinates[index],
desiredCoordinates[index - 1] + extent + spacing);
desiredCoordinates[index] = SnapForwardToPlacementGrid(desiredCoordinates[index], gridStep);
}
}
internal static double SnapToPlacementGrid(double value, double gridStep)
{
if (gridStep <= 1d)
{
return value;
}
return Math.Round(value / gridStep) * gridStep;
}
internal static double SnapForwardToPlacementGrid(double value, double gridStep)
{
if (gridStep <= 1d)
{
return value;
}
return Math.Ceiling(value / gridStep) * gridStep;
}
internal static double SnapBackwardToPlacementGrid(double value, double gridStep)
{
if (gridStep <= 1d)
{
return value;
}
return Math.Floor(value / gridStep) * gridStep;
}
}

View File

@@ -6,6 +6,9 @@ internal sealed class ElkRepeatCollectorCorridorGroup
public required bool IsAbove { get; init; }
public required double CorridorY { get; init; }
public required string[] EdgeIds { get; init; }
public required int ConflictPairCount { get; init; }
public required double MinX { get; init; }
public required double MaxX { get; init; }
}
internal static class ElkRepeatCollectorCorridors
@@ -22,13 +25,12 @@ internal static class ElkRepeatCollectorCorridors
var count = 0;
foreach (var group in groups)
{
var edgeCount = group.EdgeIds.Length;
if (edgeCount < 2)
if (group.ConflictPairCount <= 0 || group.EdgeIds.Length < 2)
{
continue;
}
count += edgeCount * (edgeCount - 1) / 2;
count += group.ConflictPairCount;
if (severityByEdgeId is null)
{
continue;
@@ -37,7 +39,7 @@ internal static class ElkRepeatCollectorCorridors
foreach (var edgeId in group.EdgeIds)
{
severityByEdgeId[edgeId] = severityByEdgeId.GetValueOrDefault(edgeId)
+ ((edgeCount - 1) * severityWeight);
+ (severityWeight * Math.Max(1, group.ConflictPairCount));
}
}
@@ -55,6 +57,10 @@ internal static class ElkRepeatCollectorCorridors
var graphMinY = nodes.Min(node => node.Y);
var graphMaxY = nodes.Max(node => node.Y + node.Height);
var serviceNodes = nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
var minLineClearance = serviceNodes.Length > 0
? Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d
: 50d;
var candidates = edges
.Select(edge => CreateCandidate(edge, graphMinY, graphMaxY))
.Where(candidate => candidate is not null)
@@ -76,58 +82,38 @@ internal static class ElkRepeatCollectorCorridors
StringComparer.Ordinal))
{
var bucket = groupedCandidates.ToArray();
var visited = new bool[bucket.Length];
var conflictPairs = 0;
for (var i = 0; i < bucket.Length; i++)
{
if (visited[i])
for (var j = i + 1; j < bucket.Length; j++)
{
continue;
}
var pending = new Queue<int>();
var component = new List<CollectorCandidate>();
pending.Enqueue(i);
visited[i] = true;
while (pending.Count > 0)
{
var currentIndex = pending.Dequeue();
var current = bucket[currentIndex];
component.Add(current);
for (var j = 0; j < bucket.Length; j++)
if (ConflictsOnOuterLane(bucket[i], bucket[j], minLineClearance))
{
if (visited[j] || currentIndex == j)
{
continue;
}
if (!SharesOuterLane(current, bucket[j]))
{
continue;
}
visited[j] = true;
pending.Enqueue(j);
conflictPairs++;
}
}
if (component.Count < 2)
{
continue;
}
groups.Add(new ElkRepeatCollectorCorridorGroup
{
TargetNodeId = component[0].TargetNodeId,
IsAbove = component[0].IsAbove,
CorridorY = component.Min(member => member.CorridorY),
EdgeIds = component
.Select(member => member.EdgeId)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray(),
});
}
if (conflictPairs <= 0)
{
continue;
}
groups.Add(new ElkRepeatCollectorCorridorGroup
{
TargetNodeId = bucket[0].TargetNodeId,
IsAbove = bucket[0].IsAbove,
CorridorY = bucket[0].IsAbove
? bucket.Min(member => member.CorridorY)
: bucket.Max(member => member.CorridorY),
EdgeIds = bucket
.Select(member => member.EdgeId)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray(),
ConflictPairCount = conflictPairs,
MinX = bucket.Min(member => member.MinX),
MaxX = bucket.Max(member => member.MaxX),
});
}
return groups;
@@ -172,8 +158,15 @@ internal static class ElkRepeatCollectorCorridors
.Select((edge, index) => new { edge, index, candidate = CreateCandidate(edge, graphMinY, graphMaxY) })
.Where(item => item.candidate is not null
&& group.EdgeIds.Contains(item.edge.Id, StringComparer.Ordinal))
.Select(item => new RepairMember(item.index, item.edge.Id, item.candidate!.Value.CorridorY, item.candidate.Value.StartX))
.OrderByDescending(member => member.StartX)
.Select(item => new RepairMember(
item.index,
item.edge.Id,
item.candidate!.Value.CorridorY,
item.candidate.Value.StartX,
item.candidate.Value.MinX,
item.candidate.Value.MaxX))
.OrderBy(member => member.CorridorY)
.ThenByDescending(member => member.StartX)
.ThenBy(member => member.EdgeId, StringComparer.Ordinal)
.ToArray();
if (members.Length < 2)
@@ -181,16 +174,30 @@ internal static class ElkRepeatCollectorCorridors
continue;
}
var baseY = group.IsAbove
? Math.Min(members.Min(member => member.CorridorY), graphMinY - 12d)
: Math.Max(members.Max(member => member.CorridorY), graphMaxY + 12d);
for (var i = 0; i < members.Length; i++)
var forbiddenBands = BuildForbiddenCorridorBands(
nodes,
members.Min(member => member.MinX),
members.Max(member => member.MaxX),
minLineClearance);
if (group.IsAbove)
{
var assignedY = group.IsAbove
? baseY - (laneGap * i)
: baseY + (laneGap * i);
result[members[i].Index] = RewriteOuterLane(result[members[i].Index], members[i].CorridorY, assignedY, graphMinY, graphMaxY, group.IsAbove);
var nextY = Math.Min(members.Max(member => member.CorridorY), graphMinY - 12d);
nextY = ResolveSafeCorridorY(nextY, group.IsAbove, laneGap, forbiddenBands);
for (var i = members.Length - 1; i >= 0; i--)
{
result[members[i].Index] = RewriteOuterLane(result[members[i].Index], members[i].CorridorY, nextY, graphMinY, graphMaxY, group.IsAbove);
nextY = ResolveSafeCorridorY(nextY - laneGap, group.IsAbove, laneGap, forbiddenBands);
}
}
else
{
var nextY = Math.Max(members.Min(member => member.CorridorY), graphMaxY + 12d);
nextY = ResolveSafeCorridorY(nextY, group.IsAbove, laneGap, forbiddenBands);
for (var i = 0; i < members.Length; i++)
{
result[members[i].Index] = RewriteOuterLane(result[members[i].Index], members[i].CorridorY, nextY, graphMinY, graphMaxY, group.IsAbove);
nextY = ResolveSafeCorridorY(nextY + laneGap, group.IsAbove, laneGap, forbiddenBands);
}
}
}
@@ -240,7 +247,10 @@ internal static class ElkRepeatCollectorCorridors
};
}
private static bool SharesOuterLane(CollectorCandidate left, CollectorCandidate right)
private static bool ConflictsOnOuterLane(
CollectorCandidate left,
CollectorCandidate right,
double minLineClearance)
{
if (!string.Equals(left.TargetNodeId, right.TargetNodeId, StringComparison.Ordinal)
|| left.IsAbove != right.IsAbove)
@@ -248,12 +258,12 @@ internal static class ElkRepeatCollectorCorridors
return false;
}
if (Math.Abs(left.CorridorY - right.CorridorY) > CoordinateTolerance)
if (Math.Min(left.MaxX, right.MaxX) - Math.Max(left.MinX, right.MinX) <= 1d)
{
return false;
}
return Math.Min(left.MaxX, right.MaxX) - Math.Max(left.MinX, right.MinX) > 1d;
return Math.Abs(left.CorridorY - right.CorridorY) < minLineClearance - CoordinateTolerance;
}
private static CollectorCandidate? CreateCandidate(
@@ -316,6 +326,59 @@ internal static class ElkRepeatCollectorCorridors
return best;
}
private static (double Top, double Bottom)[] BuildForbiddenCorridorBands(
IReadOnlyCollection<ElkPositionedNode> nodes,
double spanMinX,
double spanMaxX,
double minLineClearance)
{
return nodes
.Where(node => node.Kind is not "Start" and not "End")
.Where(node => node.X + node.Width > spanMinX + CoordinateTolerance
&& node.X < spanMaxX - CoordinateTolerance)
.Select(node => (
Top: node.Y - minLineClearance,
Bottom: node.Y + node.Height + minLineClearance))
.OrderBy(band => band.Top)
.ToArray();
}
private static double ResolveSafeCorridorY(
double candidateY,
bool isAbove,
double laneGap,
IReadOnlyList<(double Top, double Bottom)> forbiddenBands)
{
if (forbiddenBands.Count == 0)
{
return candidateY;
}
while (true)
{
var shifted = false;
foreach (var band in forbiddenBands)
{
if (candidateY < band.Top - CoordinateTolerance
|| candidateY > band.Bottom + CoordinateTolerance)
{
continue;
}
candidateY = isAbove
? band.Top - laneGap
: band.Bottom + laneGap;
shifted = true;
break;
}
if (!shifted)
{
return candidateY;
}
}
}
private static List<ElkPoint> ExtractPath(ElkRoutedEdge edge)
{
var path = new List<ElkPoint>();
@@ -343,5 +406,11 @@ internal static class ElkRepeatCollectorCorridors
double StartX,
double Length);
private readonly record struct RepairMember(int Index, string EdgeId, double CorridorY, double StartX);
private readonly record struct RepairMember(
int Index,
string EdgeId,
double CorridorY,
double StartX,
double MinX,
double MaxX);
}

View File

@@ -2,9 +2,17 @@ namespace StellaOps.ElkSharp;
internal static class ElkShapeBoundaries
{
private const double CoordinateTolerance = 0.5d;
private const double GatewayVertexTolerance = 3d;
internal static bool IsGatewayShape(ElkPositionedNode node)
{
return node.Kind is "Decision" or "Fork" or "Join";
}
internal static ElkPoint ProjectOntoShapeBoundary(ElkPositionedNode node, ElkPoint toward)
{
if (node.Kind is "Decision" or "Fork" or "Join")
if (IsGatewayShape(node))
{
var cx = node.X + node.Width / 2d;
var cy = node.Y + node.Height / 2d;
@@ -16,6 +24,150 @@ internal static class ElkShapeBoundaries
return ProjectOntoRectBoundary(node, toward);
}
internal static bool TryProjectGatewayDiagonalBoundary(
ElkPositionedNode node,
ElkPoint anchor,
ElkPoint fallbackBoundary,
out ElkPoint boundaryPoint)
{
boundaryPoint = default!;
if (!IsGatewayShape(node))
{
return false;
}
var candidates = new List<ElkPoint>();
var projectedAnchor = ProjectOntoShapeBoundary(node, anchor);
AddGatewayCandidate(node, candidates, projectedAnchor);
AddGatewayCandidate(node, candidates, fallbackBoundary);
AddGatewayCandidate(node, candidates, ProjectOntoShapeBoundary(node, fallbackBoundary));
foreach (var vertex in BuildGatewayBoundaryPoints(node))
{
AddGatewayCandidate(node, candidates, vertex);
}
var centerX = node.X + (node.Width / 2d);
var centerY = node.Y + (node.Height / 2d);
var directionX = Math.Sign(centerX - anchor.X);
var directionY = Math.Sign(centerY - anchor.Y);
var diagonalDirections = new HashSet<(int X, int Y)>();
if (directionX != 0 && directionY != 0)
{
diagonalDirections.Add((directionX, directionY));
}
var fallbackDirectionX = Math.Sign(fallbackBoundary.X - anchor.X);
var fallbackDirectionY = Math.Sign(fallbackBoundary.Y - anchor.Y);
if (fallbackDirectionX != 0 && fallbackDirectionY != 0)
{
diagonalDirections.Add((fallbackDirectionX, fallbackDirectionY));
}
foreach (var diagonalDirection in diagonalDirections)
{
if (TryIntersectGatewayRay(
node,
anchor.X,
anchor.Y,
diagonalDirection.X,
diagonalDirection.Y,
out var rayBoundary))
{
AddGatewayCandidate(node, candidates, rayBoundary);
}
}
var bestCandidate = default(ElkPoint?);
var bestScore = double.PositiveInfinity;
foreach (var candidate in candidates)
{
var score = ScoreGatewayBoundaryCandidate(node, anchor, projectedAnchor, candidate);
if (score >= bestScore)
{
continue;
}
bestScore = score;
bestCandidate = candidate;
}
if (bestCandidate is null)
{
return false;
}
boundaryPoint = PreferGatewayEdgeInteriorBoundary(node, bestCandidate, anchor);
return true;
}
internal static bool HasValidGatewayBoundaryAngle(
ElkPositionedNode node,
ElkPoint boundaryPoint,
ElkPoint adjacentPoint)
{
if (!IsGatewayShape(node))
{
return false;
}
var segDx = Math.Abs(boundaryPoint.X - adjacentPoint.X);
var segDy = Math.Abs(boundaryPoint.Y - adjacentPoint.Y);
if (segDx < 3d && segDy < 3d)
{
return true;
}
if (!IsPointOnGatewayBoundary(node, boundaryPoint, 2d))
{
return false;
}
if (IsInsideNodeShapeInterior(node, adjacentPoint))
{
return false;
}
if (IsDisallowedGatewayVertex(node, boundaryPoint))
{
return false;
}
if (IsAllowedGatewayTipVertex(node, boundaryPoint))
{
return segDx > segDy * 3d;
}
if (!TryGetGatewayBoundaryFace(node, boundaryPoint, out var faceStart, out var faceEnd))
{
return false;
}
var outwardVectorX = adjacentPoint.X - boundaryPoint.X;
var outwardVectorY = adjacentPoint.Y - boundaryPoint.Y;
var outwardLength = Math.Sqrt((outwardVectorX * outwardVectorX) + (outwardVectorY * outwardVectorY));
if (outwardLength <= 0.001d)
{
return true;
}
var (normalX, normalY) = BuildGatewayFaceNormal(node, faceStart, faceEnd, boundaryPoint);
var outwardDot = ((outwardVectorX / outwardLength) * normalX) + ((outwardVectorY / outwardLength) * normalY);
var faceDx = Math.Abs(faceEnd.X - faceStart.X);
var faceDy = Math.Abs(faceEnd.Y - faceStart.Y);
var faceIsDiagonal = faceDx >= 3d && faceDy >= 3d;
if (faceIsDiagonal)
{
// Diamond-like faces can leave/arrive with a short 45-degree or orthogonal
// stub as long as that stub moves outward from the face and does not land on
// a corner vertex.
return outwardDot >= 0.55d;
}
return (segDx < 3d || segDy < 3d) && outwardDot >= 0.85d;
}
internal static ElkPoint ProjectOntoRectBoundary(ElkPositionedNode node, ElkPoint toward)
{
var cx = node.X + node.Width / 2d;
@@ -98,6 +250,22 @@ internal static class ElkShapeBoundaries
BuildForkBoundaryPoints(node));
}
internal static IReadOnlyList<ElkPoint> BuildGatewayBoundaryPoints(ElkPositionedNode node)
{
if (node.Kind == "Decision")
{
return
[
new ElkPoint { X = node.X + (node.Width / 2d), Y = node.Y },
new ElkPoint { X = node.X + node.Width, Y = node.Y + (node.Height / 2d) },
new ElkPoint { X = node.X + (node.Width / 2d), Y = node.Y + node.Height },
new ElkPoint { X = node.X, Y = node.Y + (node.Height / 2d) },
];
}
return BuildForkBoundaryPoints(node);
}
internal static IReadOnlyList<ElkPoint> BuildForkBoundaryPoints(ElkPositionedNode node)
{
var cornerInset = Math.Min(22d, Math.Max(6d, node.Width * 0.125d));
@@ -188,4 +356,805 @@ internal static class ElkShapeBoundaries
{
return (ax * by) - (ay * bx);
}
private static bool TryIntersectGatewayRay(
ElkPositionedNode node,
double originX,
double originY,
double deltaX,
double deltaY,
out ElkPoint boundaryPoint)
{
var polygon = BuildGatewayBoundaryPoints(node);
var bestScale = double.PositiveInfinity;
ElkPoint? bestPoint = null;
for (var index = 0; index < polygon.Count; index++)
{
var start = polygon[index];
var end = polygon[(index + 1) % polygon.Count];
if (!TryIntersectRayWithSegment(originX, originY, deltaX, deltaY, start, end, out var scale, out var point))
{
continue;
}
if (scale < bestScale)
{
bestScale = scale;
bestPoint = point;
}
}
boundaryPoint = bestPoint ?? default!;
return bestPoint is not null;
}
private static void AddGatewayCandidate(
ElkPositionedNode node,
ICollection<ElkPoint> candidates,
ElkPoint candidate)
{
if (!IsPointOnGatewayBoundary(node, candidate, 2d))
{
return;
}
if (candidates.Any(existing =>
Math.Abs(existing.X - candidate.X) <= CoordinateTolerance
&& Math.Abs(existing.Y - candidate.Y) <= CoordinateTolerance))
{
return;
}
candidates.Add(candidate);
}
private static double ScoreGatewayBoundaryCandidate(
ElkPositionedNode node,
ElkPoint anchor,
ElkPoint projectedAnchor,
ElkPoint candidate)
{
var towardCenterX = (node.X + (node.Width / 2d)) - anchor.X;
var towardCenterY = (node.Y + (node.Height / 2d)) - anchor.Y;
var candidateDeltaX = candidate.X - anchor.X;
var candidateDeltaY = candidate.Y - anchor.Y;
var towardDot = (candidateDeltaX * towardCenterX) + (candidateDeltaY * towardCenterY);
if (towardDot <= 0d)
{
return double.PositiveInfinity;
}
var absDx = Math.Abs(candidateDeltaX);
var absDy = Math.Abs(candidateDeltaY);
var isDiagonal = absDx >= 3d && absDy >= 3d;
var diagonalPenalty = isDiagonal
? Math.Abs(absDx - absDy)
: 10_000d;
var projectedDistance = Math.Abs(candidate.X - projectedAnchor.X) + Math.Abs(candidate.Y - projectedAnchor.Y);
var segmentLength = Math.Sqrt((candidateDeltaX * candidateDeltaX) + (candidateDeltaY * candidateDeltaY));
var candidateNearVertex = IsNearGatewayVertex(node, candidate, GatewayVertexTolerance);
var projectedNearVertex = IsNearGatewayVertex(node, projectedAnchor, GatewayVertexTolerance);
var vertexPenalty = candidateNearVertex
? projectedNearVertex
? 4d
: 24d
: 0d;
return diagonalPenalty + (segmentLength * 0.05d) + (projectedDistance * 0.1d) + vertexPenalty;
}
private static ElkPoint InterpolateAwayFromVertex(
ElkPoint vertexPoint,
ElkPoint adjacentVertex,
double? forcedOffset = null)
{
var deltaX = adjacentVertex.X - vertexPoint.X;
var deltaY = adjacentVertex.Y - vertexPoint.Y;
var length = Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY));
if (length <= 0.001d)
{
return vertexPoint;
}
var offset = forcedOffset ?? Math.Min(18d, Math.Max(10d, length * 0.2d));
offset = Math.Min(Math.Max(length - 0.5d, 0.5d), offset);
var scale = offset / length;
return new ElkPoint
{
X = vertexPoint.X + (deltaX * scale),
Y = vertexPoint.Y + (deltaY * scale),
};
}
private static bool IsPointOnGatewayBoundary(ElkPositionedNode node, ElkPoint point, double tolerance)
{
var polygon = BuildGatewayBoundaryPoints(node);
for (var index = 0; index < polygon.Count; index++)
{
var start = polygon[index];
var end = polygon[(index + 1) % polygon.Count];
if (DistanceToSegment(point, start, end) <= tolerance)
{
return true;
}
}
return false;
}
internal static bool IsNearGatewayVertex(ElkPositionedNode node, ElkPoint boundaryPoint, double tolerance = GatewayVertexTolerance)
{
foreach (var vertex in BuildGatewayBoundaryPoints(node))
{
if (Math.Abs(vertex.X - boundaryPoint.X) <= tolerance
&& Math.Abs(vertex.Y - boundaryPoint.Y) <= tolerance)
{
return true;
}
}
return false;
}
internal static bool IsAllowedGatewayTipVertex(
ElkPositionedNode node,
ElkPoint boundaryPoint,
double tolerance = GatewayVertexTolerance)
{
// Gateway tips read as visually detached "pin" exits/entries in the renderer.
// Keep all gateway joins on a face interior instead of permitting any tip vertex.
return false;
}
internal static bool IsInsideNodeBoundingBoxInterior(
ElkPositionedNode node,
ElkPoint point,
double tolerance = CoordinateTolerance)
{
return point.X > node.X + tolerance
&& point.X < node.X + node.Width - tolerance
&& point.Y > node.Y + tolerance
&& point.Y < node.Y + node.Height - tolerance;
}
internal static bool IsInsideNodeShapeInterior(
ElkPositionedNode node,
ElkPoint point,
double tolerance = CoordinateTolerance)
{
if (!IsGatewayShape(node))
{
return IsInsideNodeBoundingBoxInterior(node, point, tolerance);
}
if (!IsInsideNodeBoundingBoxInterior(node, point, tolerance))
{
return false;
}
if (IsPointOnGatewayBoundary(node, point, Math.Max(2d, tolerance * 2d)))
{
return false;
}
var polygon = BuildGatewayBoundaryPoints(node);
bool? hasPositiveSign = null;
for (var index = 0; index < polygon.Count; index++)
{
var start = polygon[index];
var end = polygon[(index + 1) % polygon.Count];
var cross = Cross(end.X - start.X, end.Y - start.Y, point.X - start.X, point.Y - start.Y);
if (Math.Abs(cross) <= tolerance)
{
continue;
}
var isPositive = cross > 0d;
if (!hasPositiveSign.HasValue)
{
hasPositiveSign = isPositive;
continue;
}
if (hasPositiveSign.Value != isPositive)
{
return false;
}
}
return hasPositiveSign.HasValue;
}
internal static ElkPoint PreferGatewayEdgeInteriorBoundary(
ElkPositionedNode node,
ElkPoint boundaryPoint,
ElkPoint anchor)
{
if (!IsGatewayShape(node) || !IsNearGatewayVertex(node, boundaryPoint))
{
return boundaryPoint;
}
if (IsAllowedGatewayTipVertex(node, boundaryPoint))
{
return boundaryPoint;
}
var polygon = BuildGatewayBoundaryPoints(node);
var nearestVertexIndex = -1;
var nearestVertexDistance = double.PositiveInfinity;
for (var index = 0; index < polygon.Count; index++)
{
var vertex = polygon[index];
var deltaX = boundaryPoint.X - vertex.X;
var deltaY = boundaryPoint.Y - vertex.Y;
var distance = Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY));
if (distance >= nearestVertexDistance)
{
continue;
}
nearestVertexDistance = distance;
nearestVertexIndex = index;
}
if (nearestVertexIndex < 0)
{
return boundaryPoint;
}
var vertexPoint = polygon[nearestVertexIndex];
var previousVertex = polygon[(nearestVertexIndex - 1 + polygon.Count) % polygon.Count];
var nextVertex = polygon[(nearestVertexIndex + 1) % polygon.Count];
var projectedAnchor = ProjectOntoShapeBoundary(node, anchor);
var candidates = new[]
{
InterpolateAwayFromVertex(vertexPoint, previousVertex),
InterpolateAwayFromVertex(vertexPoint, nextVertex),
};
var bestCandidate = boundaryPoint;
var bestScore = double.PositiveInfinity;
foreach (var candidate in candidates)
{
if (!IsPointOnGatewayBoundary(node, candidate, 2d))
{
continue;
}
var score = ScoreGatewayBoundaryCandidate(node, anchor, projectedAnchor, candidate);
if (score >= bestScore)
{
continue;
}
bestScore = score;
bestCandidate = candidate;
}
if (IsNearGatewayVertex(node, bestCandidate))
{
var forcedOffset = node.Kind == "Decision"
? 18d
: 14d;
var forcedCandidates = new[]
{
InterpolateAwayFromVertex(vertexPoint, previousVertex, forcedOffset),
InterpolateAwayFromVertex(vertexPoint, nextVertex, forcedOffset),
};
foreach (var candidate in forcedCandidates)
{
if (!IsPointOnGatewayBoundary(node, candidate, 2.5d))
{
continue;
}
var score = ScoreGatewayBoundaryCandidate(node, anchor, projectedAnchor, candidate);
if (score >= bestScore)
{
continue;
}
bestScore = score;
bestCandidate = candidate;
}
}
return bestCandidate;
}
internal static bool IsGatewayBoundaryPoint(
ElkPositionedNode node,
ElkPoint point,
double tolerance = 2d)
{
return IsGatewayShape(node) && IsPointOnGatewayBoundary(node, point, tolerance);
}
internal static bool TryProjectGatewayBoundarySlot(
ElkPositionedNode node,
string side,
double slotCoordinate,
out ElkPoint boundaryPoint)
{
boundaryPoint = default!;
if (!IsGatewayShape(node))
{
return false;
}
var candidates = new List<ElkPoint>();
var polygon = BuildGatewayBoundaryPoints(node);
switch (side)
{
case "left":
case "right":
{
var y = Math.Max(node.Y + 4d, Math.Min(node.Y + node.Height - 4d, slotCoordinate));
for (var index = 0; index < polygon.Count; index++)
{
var start = polygon[index];
var end = polygon[(index + 1) % polygon.Count];
AddGatewaySlotIntersections(candidates, TryIntersectHorizontalSlot(start, end, y));
}
if (candidates.Count == 0)
{
return false;
}
boundaryPoint = side == "left"
? candidates.OrderBy(point => point.X).ThenBy(point => point.Y).First()
: candidates.OrderByDescending(point => point.X).ThenBy(point => point.Y).First();
boundaryPoint = PreferGatewayEdgeInteriorBoundary(
node,
boundaryPoint,
new ElkPoint
{
X = side == "left" ? node.X - 32d : node.X + node.Width + 32d,
Y = y,
});
return true;
}
case "top":
case "bottom":
{
var x = Math.Max(node.X + 4d, Math.Min(node.X + node.Width - 4d, slotCoordinate));
for (var index = 0; index < polygon.Count; index++)
{
var start = polygon[index];
var end = polygon[(index + 1) % polygon.Count];
AddGatewaySlotIntersections(candidates, TryIntersectVerticalSlot(start, end, x));
}
if (candidates.Count == 0)
{
return false;
}
boundaryPoint = side == "top"
? candidates.OrderBy(point => point.Y).ThenBy(point => point.X).First()
: candidates.OrderByDescending(point => point.Y).ThenBy(point => point.X).First();
boundaryPoint = PreferGatewayEdgeInteriorBoundary(
node,
boundaryPoint,
new ElkPoint
{
X = x,
Y = side == "top" ? node.Y - 32d : node.Y + node.Height + 32d,
});
return true;
}
default:
return false;
}
}
internal static ElkPoint BuildGatewayExteriorApproachPoint(
ElkPositionedNode node,
ElkPoint boundaryPoint,
double padding = 8d)
{
if (!IsGatewayShape(node)
|| !TryGetGatewayBoundaryFace(node, boundaryPoint, out var faceStart, out var faceEnd))
{
return boundaryPoint;
}
var (normalX, normalY) = BuildGatewayFaceNormal(node, faceStart, faceEnd, boundaryPoint);
var exitDistance = ComputeRayExitDistanceFromBoundingBox(node, boundaryPoint, normalX, normalY);
var offset = Math.Max(0.5d, exitDistance + padding);
return new ElkPoint
{
X = boundaryPoint.X + (normalX * offset),
Y = boundaryPoint.Y + (normalY * offset),
};
}
internal static ElkPoint BuildGatewayDirectionalExteriorPoint(
ElkPositionedNode node,
ElkPoint boundaryPoint,
ElkPoint referencePoint,
double padding = 8d)
{
if (!IsGatewayShape(node))
{
return boundaryPoint;
}
var candidates = new List<ElkPoint>
{
BuildGatewayExteriorApproachPoint(node, boundaryPoint, padding),
};
var horizontalDirection = Math.Sign(referencePoint.X - boundaryPoint.X);
if (horizontalDirection != 0d)
{
candidates.Add(new ElkPoint
{
X = horizontalDirection > 0d
? node.X + node.Width + padding
: node.X - padding,
Y = boundaryPoint.Y,
});
}
var verticalDirection = Math.Sign(referencePoint.Y - boundaryPoint.Y);
if (verticalDirection != 0d)
{
candidates.Add(new ElkPoint
{
X = boundaryPoint.X,
Y = verticalDirection > 0d
? node.Y + node.Height + padding
: node.Y - padding,
});
}
ElkPoint? bestCandidate = null;
var bestScore = double.PositiveInfinity;
foreach (var candidate in candidates)
{
if (IsInsideNodeBoundingBoxInterior(node, candidate)
|| !HasValidGatewayBoundaryAngle(node, boundaryPoint, candidate))
{
continue;
}
var deltaX = candidate.X - boundaryPoint.X;
var deltaY = candidate.Y - boundaryPoint.Y;
var moveLength = Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY));
var referenceDistance = Math.Abs(referencePoint.X - candidate.X) + Math.Abs(referencePoint.Y - candidate.Y);
var score = moveLength + (referenceDistance * 0.1d);
if (Math.Abs(referencePoint.X - boundaryPoint.X) >= Math.Abs(referencePoint.Y - boundaryPoint.Y) * 1.2d)
{
if (Math.Sign(deltaX) != Math.Sign(referencePoint.X - boundaryPoint.X))
{
score += 10_000d;
}
score += Math.Abs(deltaY) * 0.35d;
}
else if (Math.Abs(referencePoint.Y - boundaryPoint.Y) >= Math.Abs(referencePoint.X - boundaryPoint.X) * 1.2d)
{
if (Math.Sign(deltaY) != Math.Sign(referencePoint.Y - boundaryPoint.Y))
{
score += 10_000d;
}
score += Math.Abs(deltaX) * 0.35d;
}
if (score >= bestScore)
{
continue;
}
bestScore = score;
bestCandidate = candidate;
}
return bestCandidate ?? BuildGatewayExteriorApproachPoint(node, boundaryPoint, padding);
}
internal static ElkPoint BuildPreferredGatewaySourceExteriorPoint(
ElkPositionedNode node,
ElkPoint boundaryPoint,
ElkPoint referencePoint,
double padding = 8d)
{
if (!IsGatewayShape(node))
{
return boundaryPoint;
}
var deltaX = referencePoint.X - boundaryPoint.X;
var deltaY = referencePoint.Y - boundaryPoint.Y;
if (node.Kind == "Decision"
&& !IsNearGatewayVertex(node, boundaryPoint, 8d)
&& TryGetGatewayBoundaryFace(node, boundaryPoint, out var faceStart, out var faceEnd))
{
var faceDx = Math.Abs(faceEnd.X - faceStart.X);
var faceDy = Math.Abs(faceEnd.Y - faceStart.Y);
var hasMaterialHorizontal = Math.Abs(deltaX) >= 12d;
var hasMaterialVertical = Math.Abs(deltaY) >= 12d;
var prefersDiagonalStub = hasMaterialHorizontal
&& hasMaterialVertical
&& Math.Abs(Math.Abs(deltaX) - Math.Abs(deltaY)) <= Math.Max(18d, Math.Min(Math.Abs(deltaX), Math.Abs(deltaY)) * 0.75d);
if (faceDx >= 3d && faceDy >= 3d && prefersDiagonalStub)
{
var faceNormalCandidate = BuildGatewayExteriorApproachPoint(node, boundaryPoint, padding);
if (!IsInsideNodeBoundingBoxInterior(node, faceNormalCandidate)
&& HasValidGatewayBoundaryAngle(node, boundaryPoint, faceNormalCandidate))
{
return faceNormalCandidate;
}
}
}
var dominantHorizontal = Math.Abs(deltaX) >= Math.Abs(deltaY) * 1.15d;
var dominantVertical = Math.Abs(deltaY) >= Math.Abs(deltaX) * 1.15d;
if (dominantHorizontal && Math.Sign(deltaX) != 0)
{
var horizontalCandidate = new ElkPoint
{
X = deltaX > 0d
? node.X + node.Width + padding
: node.X - padding,
Y = boundaryPoint.Y,
};
if (!IsInsideNodeBoundingBoxInterior(node, horizontalCandidate)
&& HasValidGatewayBoundaryAngle(node, boundaryPoint, horizontalCandidate))
{
return horizontalCandidate;
}
}
if (dominantVertical && Math.Sign(deltaY) != 0)
{
var verticalCandidate = new ElkPoint
{
X = boundaryPoint.X,
Y = deltaY > 0d
? node.Y + node.Height + padding
: node.Y - padding,
};
if (!IsInsideNodeBoundingBoxInterior(node, verticalCandidate)
&& HasValidGatewayBoundaryAngle(node, boundaryPoint, verticalCandidate))
{
return verticalCandidate;
}
}
return BuildGatewayDirectionalExteriorPoint(node, boundaryPoint, referencePoint, padding);
}
private static void AddGatewaySlotIntersections(
ICollection<ElkPoint> candidates,
IEnumerable<ElkPoint> intersections)
{
foreach (var candidate in intersections)
{
if (candidates.Any(existing =>
Math.Abs(existing.X - candidate.X) <= CoordinateTolerance
&& Math.Abs(existing.Y - candidate.Y) <= CoordinateTolerance))
{
continue;
}
candidates.Add(candidate);
}
}
private static IEnumerable<ElkPoint> TryIntersectHorizontalSlot(
ElkPoint start,
ElkPoint end,
double y)
{
if (Math.Abs(start.Y - end.Y) <= CoordinateTolerance)
{
if (Math.Abs(y - start.Y) > CoordinateTolerance)
{
yield break;
}
yield return new ElkPoint { X = start.X, Y = y };
if (Math.Abs(end.X - start.X) > CoordinateTolerance)
{
yield return new ElkPoint { X = end.X, Y = y };
}
yield break;
}
var minY = Math.Min(start.Y, end.Y) - CoordinateTolerance;
var maxY = Math.Max(start.Y, end.Y) + CoordinateTolerance;
if (y < minY || y > maxY)
{
yield break;
}
var t = (y - start.Y) / (end.Y - start.Y);
if (t < -CoordinateTolerance || t > 1d + CoordinateTolerance)
{
yield break;
}
yield return new ElkPoint
{
X = start.X + ((end.X - start.X) * t),
Y = y,
};
}
private static IEnumerable<ElkPoint> TryIntersectVerticalSlot(
ElkPoint start,
ElkPoint end,
double x)
{
if (Math.Abs(start.X - end.X) <= CoordinateTolerance)
{
if (Math.Abs(x - start.X) > CoordinateTolerance)
{
yield break;
}
yield return new ElkPoint { X = x, Y = start.Y };
if (Math.Abs(end.Y - start.Y) > CoordinateTolerance)
{
yield return new ElkPoint { X = x, Y = end.Y };
}
yield break;
}
var minX = Math.Min(start.X, end.X) - CoordinateTolerance;
var maxX = Math.Max(start.X, end.X) + CoordinateTolerance;
if (x < minX || x > maxX)
{
yield break;
}
var t = (x - start.X) / (end.X - start.X);
if (t < -CoordinateTolerance || t > 1d + CoordinateTolerance)
{
yield break;
}
yield return new ElkPoint
{
X = x,
Y = start.Y + ((end.Y - start.Y) * t),
};
}
private static double DistanceToSegment(ElkPoint point, ElkPoint start, ElkPoint end)
{
var deltaX = end.X - start.X;
var deltaY = end.Y - start.Y;
var lengthSquared = (deltaX * deltaX) + (deltaY * deltaY);
if (lengthSquared <= 0.001d)
{
return Math.Sqrt(((point.X - start.X) * (point.X - start.X)) + ((point.Y - start.Y) * (point.Y - start.Y)));
}
var t = (((point.X - start.X) * deltaX) + ((point.Y - start.Y) * deltaY)) / lengthSquared;
t = Math.Max(0d, Math.Min(1d, t));
var projectionX = start.X + (t * deltaX);
var projectionY = start.Y + (t * deltaY);
var distanceX = point.X - projectionX;
var distanceY = point.Y - projectionY;
return Math.Sqrt((distanceX * distanceX) + (distanceY * distanceY));
}
private static bool TryGetGatewayBoundaryFace(
ElkPositionedNode node,
ElkPoint boundaryPoint,
out ElkPoint faceStart,
out ElkPoint faceEnd)
{
faceStart = default!;
faceEnd = default!;
var polygon = BuildGatewayBoundaryPoints(node);
var bestDistance = double.PositiveInfinity;
var bestIndex = -1;
for (var index = 0; index < polygon.Count; index++)
{
var start = polygon[index];
var end = polygon[(index + 1) % polygon.Count];
var distance = DistanceToSegment(boundaryPoint, start, end);
if (distance > 2d || distance >= bestDistance)
{
continue;
}
bestDistance = distance;
bestIndex = index;
}
if (bestIndex < 0)
{
return false;
}
faceStart = polygon[bestIndex];
faceEnd = polygon[(bestIndex + 1) % polygon.Count];
return true;
}
private static bool IsDisallowedGatewayVertex(
ElkPositionedNode node,
ElkPoint boundaryPoint)
{
return IsNearGatewayVertex(node, boundaryPoint, GatewayVertexTolerance)
&& !IsAllowedGatewayTipVertex(node, boundaryPoint, GatewayVertexTolerance);
}
private static (double X, double Y) BuildGatewayFaceNormal(
ElkPositionedNode node,
ElkPoint faceStart,
ElkPoint faceEnd,
ElkPoint boundaryPoint)
{
var deltaX = faceEnd.X - faceStart.X;
var deltaY = faceEnd.Y - faceStart.Y;
var length = Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY));
if (length <= 0.001d)
{
return (0d, -1d);
}
var normalAX = deltaY / length;
var normalAY = -deltaX / length;
var normalBX = -normalAX;
var normalBY = -normalAY;
var centerX = node.X + (node.Width / 2d);
var centerY = node.Y + (node.Height / 2d);
var centerToBoundaryX = boundaryPoint.X - centerX;
var centerToBoundaryY = boundaryPoint.Y - centerY;
var dotA = (normalAX * centerToBoundaryX) + (normalAY * centerToBoundaryY);
var dotB = (normalBX * centerToBoundaryX) + (normalBY * centerToBoundaryY);
return dotA >= dotB
? (normalAX, normalAY)
: (normalBX, normalBY);
}
private static double ComputeRayExitDistanceFromBoundingBox(
ElkPositionedNode node,
ElkPoint origin,
double directionX,
double directionY)
{
const double epsilon = 0.0001d;
var bestDistance = double.PositiveInfinity;
if (directionX > epsilon)
{
bestDistance = Math.Min(bestDistance, (node.X + node.Width - origin.X) / directionX);
}
else if (directionX < -epsilon)
{
bestDistance = Math.Min(bestDistance, (node.X - origin.X) / directionX);
}
if (directionY > epsilon)
{
bestDistance = Math.Min(bestDistance, (node.Y + node.Height - origin.Y) / directionY);
}
else if (directionY < -epsilon)
{
bestDistance = Math.Min(bestDistance, (node.Y - origin.Y) / directionY);
}
if (double.IsInfinity(bestDistance) || bestDistance < 0d)
{
return 0d;
}
return bestDistance;
}
}

View File

@@ -57,6 +57,7 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
}
var placementIterations = ElkNodePlacement.ResolvePlacementIterationCount(options, allNodes.Count, layers.Length);
var placementGrid = ElkNodePlacement.ResolvePlacementGrid(graph.Nodes);
var positionedNodes = new Dictionary<string, ElkPositionedNode>(StringComparer.Ordinal);
var globalNodeWidth = graph.Nodes.Max(x => x.Width);
@@ -67,14 +68,14 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
ElkSharpLayoutInitialPlacement.PlaceNodesLeftToRight(
positionedNodes, layers, dummyResult, augmentedIncoming, augmentedOutgoing,
augmentedNodesById, incomingNodeIds, outgoingNodeIds, nodesById,
adaptiveNodeSpacing, options, placementIterations);
adaptiveNodeSpacing, options, placementIterations, placementGrid);
}
else
{
ElkSharpLayoutInitialPlacement.PlaceNodesTopToBottom(
positionedNodes, layers, dummyResult, augmentedIncoming, augmentedOutgoing,
augmentedNodesById, incomingNodeIds, outgoingNodeIds, nodesById,
globalNodeWidth, adaptiveNodeSpacing, options, placementIterations);
globalNodeWidth, adaptiveNodeSpacing, options, placementIterations, placementGrid);
}
var graphBounds = ElkGraphValidator.ComputeGraphBounds(positionedNodes.Values
@@ -216,6 +217,7 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
routedEdges = ElkEdgePostProcessor.SnapAnchorsToNodeBoundary(routedEdges, finalNodes);
// 2. Iterative multi-strategy optimizer (replaces refiner + avoid crossings + diag elim + simplify + tighten)
routedEdges = ElkEdgeRouterIterative.Optimize(routedEdges, finalNodes, options, cancellationToken);
ElkLayoutDiagnostics.LogProgress("ElkSharp layout optimize returned");
return Task.FromResult(new ElkLayoutResult
{

View File

@@ -8,13 +8,16 @@ internal static class ElkSharpLayoutInitialPlacement
Dictionary<string, List<string>> augmentedOutgoing, Dictionary<string, ElkNode> augmentedNodesById,
Dictionary<string, List<string>> incomingNodeIds, Dictionary<string, List<string>> outgoingNodeIds,
Dictionary<string, ElkNode> nodesById, double adaptiveNodeSpacing,
ElkLayoutOptions options, int placementIterations)
ElkLayoutOptions options, int placementIterations, NodePlacementGrid placementGrid)
{
var globalNodeHeight = augmentedNodesById.Values
.Where(n => !dummyResult.DummyNodeIds.Contains(n.Id))
.Max(x => x.Height);
var gridNodeSpacing = Math.Max(adaptiveNodeSpacing, placementGrid.YStep * 0.4d);
var edgeDensityFactor = adaptiveNodeSpacing / options.NodeSpacing;
var adaptiveLayerSpacing = options.LayerSpacing * Math.Min(1.15d, 0.92d + (Math.Max(0d, edgeDensityFactor - 1d) * 0.35d));
var adaptiveLayerSpacing = Math.Max(
options.LayerSpacing * Math.Min(1.15d, 0.92d + (Math.Max(0d, edgeDensityFactor - 1d) * 0.35d)),
placementGrid.XStep * 0.45d);
var layerXPositions = new double[layers.Length];
var currentX = 0d;
@@ -53,7 +56,7 @@ internal static class ElkSharpLayoutInitialPlacement
}
else
{
desiredY[nodeIndex] = nodeIndex * (slotHeight + adaptiveNodeSpacing);
desiredY[nodeIndex] = nodeIndex * (slotHeight + gridNodeSpacing);
}
}
@@ -62,8 +65,8 @@ internal static class ElkSharpLayoutInitialPlacement
var prevIsDummy = dummyResult.DummyNodeIds.Contains(layer[nodeIndex - 1].Id);
var currIsDummy = dummyResult.DummyNodeIds.Contains(layer[nodeIndex].Id);
var pairSpacing = (prevIsDummy && currIsDummy) ? 2d
: (prevIsDummy || currIsDummy) ? Math.Min(adaptiveNodeSpacing, options.NodeSpacing * 0.5d)
: adaptiveNodeSpacing;
: (prevIsDummy || currIsDummy) ? Math.Min(gridNodeSpacing, options.NodeSpacing * 0.5d)
: gridNodeSpacing;
var minY = desiredY[nodeIndex - 1] + layer[nodeIndex - 1].Height + pairSpacing;
if (desiredY[nodeIndex] < minY)
{
@@ -91,19 +94,19 @@ internal static class ElkSharpLayoutInitialPlacement
ElkNodePlacement.RefineHorizontalPlacement(positionedNodes, layers,
incomingNodeIds, outgoingNodeIds, augmentedNodesById,
options.NodeSpacing, placementIterations, options.Direction);
gridNodeSpacing, placementIterations, options.Direction);
ElkNodePlacement.SnapOriginalPrimaryAxes(positionedNodes, layers,
dummyResult.DummyNodeIds, incomingNodeIds, outgoingNodeIds,
nodesById, options.NodeSpacing, options.Direction);
nodesById, gridNodeSpacing, options.Direction);
ElkNodePlacementAlignment.CompactTowardIncomingFlow(positionedNodes, layers,
dummyResult.DummyNodeIds, incomingNodeIds, nodesById,
options.NodeSpacing, options.Direction);
gridNodeSpacing, options.Direction);
ElkNodePlacement.SnapOriginalPrimaryAxes(positionedNodes, layers,
dummyResult.DummyNodeIds, incomingNodeIds, outgoingNodeIds,
nodesById, options.NodeSpacing, options.Direction);
nodesById, gridNodeSpacing, options.Direction);
ElkNodePlacementPreferredCenter.AlignDummyNodesToFlow(positionedNodes, layers,
dummyResult.DummyNodeIds, augmentedIncoming, augmentedOutgoing,
@@ -123,7 +126,7 @@ internal static class ElkSharpLayoutInitialPlacement
var pos = positionedNodes[nodeId];
positionedNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode(
augmentedNodesById[nodeId], pos.X, pos.Y - minNodeY, options.Direction);
}
}
}
}
@@ -133,14 +136,15 @@ internal static class ElkSharpLayoutInitialPlacement
Dictionary<string, List<string>> augmentedOutgoing, Dictionary<string, ElkNode> augmentedNodesById,
Dictionary<string, List<string>> incomingNodeIds, Dictionary<string, List<string>> outgoingNodeIds,
Dictionary<string, ElkNode> nodesById, double globalNodeWidth,
double adaptiveNodeSpacing, ElkLayoutOptions options, int placementIterations)
double adaptiveNodeSpacing, ElkLayoutOptions options, int placementIterations, NodePlacementGrid placementGrid)
{
var gridNodeSpacing = Math.Max(adaptiveNodeSpacing, placementGrid.XStep * 0.4d);
var layerYPositions = new double[layers.Length];
var currentY = 0d;
for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++)
{
layerYPositions[layerIndex] = currentY;
currentY += layers[layerIndex].Max(x => x.Height) + options.LayerSpacing;
currentY += layers[layerIndex].Max(x => x.Height) + Math.Max(options.LayerSpacing, placementGrid.YStep * 0.45d);
}
var slotWidth = globalNodeWidth;
@@ -172,7 +176,7 @@ internal static class ElkSharpLayoutInitialPlacement
}
else
{
desiredX[nodeIndex] = nodeIndex * (slotWidth + adaptiveNodeSpacing);
desiredX[nodeIndex] = nodeIndex * (slotWidth + gridNodeSpacing);
}
}
@@ -181,8 +185,8 @@ internal static class ElkSharpLayoutInitialPlacement
var prevIsDummyX = dummyResult.DummyNodeIds.Contains(layer[nodeIndex - 1].Id);
var currIsDummyX = dummyResult.DummyNodeIds.Contains(layer[nodeIndex].Id);
var pairSpacingX = (prevIsDummyX && currIsDummyX) ? 2d
: (prevIsDummyX || currIsDummyX) ? Math.Min(adaptiveNodeSpacing, options.NodeSpacing * 0.5d)
: adaptiveNodeSpacing;
: (prevIsDummyX || currIsDummyX) ? Math.Min(gridNodeSpacing, options.NodeSpacing * 0.5d)
: gridNodeSpacing;
var minX = desiredX[nodeIndex - 1] + layer[nodeIndex - 1].Width + pairSpacingX;
if (desiredX[nodeIndex] < minX)
{
@@ -210,19 +214,19 @@ internal static class ElkSharpLayoutInitialPlacement
ElkNodePlacement.RefineVerticalPlacement(positionedNodes, layers,
incomingNodeIds, outgoingNodeIds, augmentedNodesById,
options.NodeSpacing, placementIterations, options.Direction);
gridNodeSpacing, placementIterations, options.Direction);
ElkNodePlacement.SnapOriginalPrimaryAxes(positionedNodes, layers,
dummyResult.DummyNodeIds, incomingNodeIds, outgoingNodeIds,
nodesById, options.NodeSpacing, options.Direction);
nodesById, gridNodeSpacing, options.Direction);
ElkNodePlacementAlignment.CompactTowardIncomingFlow(positionedNodes, layers,
dummyResult.DummyNodeIds, incomingNodeIds, nodesById,
options.NodeSpacing, options.Direction);
gridNodeSpacing, options.Direction);
ElkNodePlacement.SnapOriginalPrimaryAxes(positionedNodes, layers,
dummyResult.DummyNodeIds, incomingNodeIds, outgoingNodeIds,
nodesById, options.NodeSpacing, options.Direction);
nodesById, gridNodeSpacing, options.Direction);
ElkNodePlacementPreferredCenter.AlignDummyNodesToFlow(positionedNodes, layers,
dummyResult.DummyNodeIds, augmentedIncoming, augmentedOutgoing,
@@ -242,7 +246,7 @@ internal static class ElkSharpLayoutInitialPlacement
var pos = positionedNodes[nodeId];
positionedNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode(
augmentedNodesById[nodeId], pos.X - minNodeX, pos.Y, options.Direction);
}
}
}
}
}