ElkSharp edge routing: boundary slots, gateway repairs, corridor spacing

Major edge routing improvements including corridor spacing, crossing reduction,
focused gateway boundary repairs, setter families, and advanced restabilization.
Adds workflow renderer tests for document-processing and artifact inspection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-06 08:52:02 +03:00
parent e3e87942c7
commit 5d6435fdb2
41 changed files with 8334 additions and 246 deletions

View File

@@ -509,6 +509,7 @@ public partial class DocumentProcessingWorkflowRenderingTests
$"subset [{string.Join(", ", result.Focus)}]: detour={result.Detour} gateway-source={result.GatewaySource} boundary-slots={result.BoundarySlots} entry={result.Entry} shared-lanes={result.SharedLanes}");
}
}
}
private static string RenderLatestElkSharpArtifactForInspection()

View File

@@ -908,8 +908,9 @@ public partial class DocumentProcessingWorkflowRenderingTests
return false;
}
var boundary = new ElkPoint { X = path[0].X, Y = path[0].Y };
return ElkShapeBoundaries.IsNearGatewayVertex(ToElkNode(sourceNode), boundary);
return ElkEdgePostProcessor.HasProblematicGatewaySourceVertexExit(
path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
ToElkNode(sourceNode));
}
private static bool HasGatewaySourceScoringIssue(
@@ -1315,4 +1316,4 @@ public partial class DocumentProcessingWorkflowRenderingTests
return ResolveBoundarySide(boundaryPoint, node);
}
}
}

View File

@@ -136,6 +136,68 @@ public partial class ElkSharpEdgeRefinementTests
ElkShapeBoundaries.IsGatewayBoundaryPoint(join, shifted).Should().BeTrue();
}
[Test]
[Property("Intent", "Operational")]
public void GatewayBoundaryHelpers_WhenJoinTargetUsesBottomHookIntoDiagonalFace_ShouldRebuildBottomEntry()
{
var source = new ElkPositionedNode
{
Id = "source",
Label = "Process Batch",
Kind = "Repeat",
X = 992,
Y = 268.311,
Width = 208,
Height = 88,
};
var target = new ElkPositionedNode
{
Id = "join",
Label = "Parallel Execution Join",
Kind = "Join",
X = 1290,
Y = 188.733,
Width = 176,
Height = 124,
};
var edge = new ElkRoutedEdge
{
Id = "edge/17",
SourceNodeId = source.Id,
TargetNodeId = target.Id,
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 1200, Y = 290.311 },
EndPoint = new ElkPoint { X = 1302.152, Y = 280.561 },
BendPoints =
[
new ElkPoint { X = 1302.152, Y = 290.311 },
],
},
],
};
var originalSection = edge.Sections.Single();
ElkShapeBoundaries.HasValidGatewayBoundaryAngle(
target,
originalSection.EndPoint,
originalSection.BendPoints.Single()).Should().BeFalse();
var repaired = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry([edge], [source, target]);
var section = repaired[0].Sections.Single();
var path = new List<ElkPoint> { section.StartPoint };
path.AddRange(section.BendPoints);
path.Add(section.EndPoint);
ElkShapeBoundaries.HasValidGatewayBoundaryAngle(target, path[^1], path[^2]).Should().BeTrue();
ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(target, path[^2]).Should().BeFalse();
ElkShapeBoundaries.IsGatewayBoundaryPoint(target, path[^1]).Should().BeTrue();
}
[Test]
[Property("Intent", "Operational")]
public void GatewayBoundaryHelpers_WhenJoinSourceStartsAtTip_ShouldCountVertexExitViolation()

View File

@@ -1603,4 +1603,228 @@ public partial class ElkSharpEdgeRefinementTests
.Should()
.BeFalse();
}
[Test]
[Property("Intent", "Operational")]
public void EndTerminalFamilyHelpers_WhenRepeatRoofFamilyOccupiesOuterBands_ShouldKeepEndRoofFamilyAboveIt()
{
var repeatSource = new ElkPositionedNode
{
Id = "start/2/branch-1/1/body/5",
Label = "Check Result",
Kind = "Decision",
X = 3034,
Y = 325.88,
Width = 188,
Height = 132,
};
var repeatTarget = new ElkPositionedNode
{
Id = "start/2/branch-1/1",
Label = "Process Batch",
Kind = "Repeat",
X = 992,
Y = 268.31,
Width = 208,
Height = 88,
};
var sourceFailure = new ElkPositionedNode
{
Id = "start/3",
Label = "Load Configuration",
Kind = "TransportCall",
X = 1604,
Y = 145.16,
Width = 208,
Height = 88,
};
var sourceDefault = new ElkPositionedNode
{
Id = "start/9",
Label = "Evaluate Conditions",
Kind = "Decision",
X = 2290,
Y = 34.75,
Width = 188,
Height = 132,
};
var end = new ElkPositionedNode
{
Id = "end",
Label = "End",
Kind = "End",
X = 4864,
Y = 364.87,
Width = 264,
Height = 132,
};
var repeatReturn = new ElkRoutedEdge
{
Id = "edge/14",
SourceNodeId = repeatSource.Id,
TargetNodeId = repeatTarget.Id,
Label = "repeat while state.printInsisAttempt eq 0",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 3098, Y = 346.95 },
EndPoint = new ElkPoint { X = 1069.33, Y = 268.31 },
BendPoints =
[
new ElkPoint { X = 3098, Y = -202.7 },
new ElkPoint { X = 1069.33, Y = -202.7 },
],
},
],
};
var failureArrival = new ElkRoutedEdge
{
Id = "edge/20",
SourceNodeId = sourceFailure.Id,
TargetNodeId = end.Id,
Label = "on failure / timeout",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 1812, Y = 211.1552734375 },
EndPoint = new ElkPoint { X = 4864, Y = 376.8660888671875 },
BendPoints =
[
new ElkPoint { X = 1836, Y = 211.1552734375 },
new ElkPoint { X = 1836, Y = -30.25 },
new ElkPoint { X = 4759, Y = -30.25 },
new ElkPoint { X = 4759, Y = 376.8660888671875 },
],
},
],
};
var defaultArrival = new ElkRoutedEdge
{
Id = "edge/23",
SourceNodeId = sourceDefault.Id,
TargetNodeId = end.Id,
Label = "default",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 2464.06, Y = 110.54 },
EndPoint = new ElkPoint { X = 4864, Y = 403.87 },
BendPoints =
[
new ElkPoint { X = 2502, Y = 110.54 },
new ElkPoint { X = 2502, Y = 16.75 },
new ElkPoint { X = 4820, Y = 16.75 },
new ElkPoint { X = 4820, Y = 403.87 },
],
},
],
};
static double FindAboveGraphLaneY(ElkRoutedEdge edge, double graphMinY)
{
var path = ExtractPath(edge);
var bestLength = double.NegativeInfinity;
var bestY = double.NaN;
for (var i = 0; i < path.Count - 1; i++)
{
if (Math.Abs(path[i].Y - path[i + 1].Y) > 0.5d
|| path[i].Y >= graphMinY - 8d)
{
continue;
}
var length = Math.Abs(path[i + 1].X - path[i].X);
if (length <= bestLength)
{
continue;
}
bestLength = length;
bestY = path[i].Y;
}
double.IsNaN(bestY).Should().BeFalse();
return bestY;
}
var nodes = new[] { repeatSource, repeatTarget, sourceFailure, sourceDefault, end };
var repaired = ElkEdgePostProcessor.DistributeEndTerminalLeftFaceTrunks(
[repeatReturn, failureArrival, defaultArrival],
nodes,
53d);
var graphMinY = nodes.Min(node => node.Y);
var repeatY = FindAboveGraphLaneY(repaired.Single(edge => edge.Id == "edge/14"), graphMinY);
var repairedFailureY = FindAboveGraphLaneY(repaired.Single(edge => edge.Id == "edge/20"), graphMinY);
var repairedDefaultY = FindAboveGraphLaneY(repaired.Single(edge => edge.Id == "edge/23"), graphMinY);
repairedFailureY.Should().BeLessThan(repeatY);
repairedDefaultY.Should().BeLessThan(repeatY);
// The End edges' vertical exits at X within the repeat return's horizontal span
// create 2 topologically unavoidable crossings. The repair must eliminate the
// remaining approach-area crossings (baseline has 4).
var repairedCrossings = ElkEdgeRoutingScoring.ComputeScore(repaired, nodes).EdgeCrossings;
repairedCrossings.Should().BeLessThanOrEqualTo(2);
}
[Test]
[Property("Intent", "Operational")]
public void LabelProximityScoring_WhenLongestAnchorSegmentIsLongEnough_ShouldIgnoreShortFirstStub()
{
var source = new ElkPositionedNode
{
Id = "source",
Label = "Source",
Kind = "TransportCall",
X = 0,
Y = 0,
Width = 160,
Height = 80,
};
var target = new ElkPositionedNode
{
Id = "target",
Label = "Target",
Kind = "SetState",
X = 420,
Y = 260,
Width = 176,
Height = 88,
};
var edge = new ElkRoutedEdge
{
Id = "edge/labeled",
SourceNodeId = source.Id,
TargetNodeId = target.Id,
Label = "on failure / timeout",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 160, Y = 40 },
EndPoint = new ElkPoint { X = 420, Y = 304 },
BendPoints =
[
new ElkPoint { X = 184, Y = 40 },
new ElkPoint { X = 184, Y = 304 },
],
},
],
};
ElkEdgeRoutingScoring.CountLabelProximityViolations([edge], [source, target]).Should().Be(0);
}
}

View File

@@ -11,6 +11,12 @@ internal static class ElkBoundarySlots
return side is "left" or "right" or "top" or "bottom" ? 2 : 1;
}
if (string.Equals(node.Kind, "End", StringComparison.Ordinal)
&& side is "left" or "right")
{
return 5;
}
return side switch
{
"left" or "right" => 3,
@@ -209,7 +215,10 @@ internal static class ElkBoundarySlots
: (node.X + GatewayBoundaryInset, node.X + node.Width - GatewayBoundaryInset);
}
var inset = side is "left" or "right"
var inset = string.Equals(node.Kind, "End", StringComparison.Ordinal)
&& side is "left" or "right"
? 12d
: 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"

View File

@@ -287,12 +287,16 @@ internal static partial class ElkCompoundLayout
}
routedEdges = InsertCompoundBoundaryCrossings(routedEdges, compoundNodes, hierarchy);
var finalNodes = graph.Nodes.Select(node => compoundNodes[node.Id]).ToArray();
routedEdges = ElkEdgePostProcessor.SpreadOuterCorridors(routedEdges, finalNodes);
ElkLayoutDiagnostics.LogProgress("ElkSharp compound layout optimize returned");
return new ElkLayoutResult
{
GraphId = graph.Id,
Nodes = graph.Nodes.Select(node => compoundNodes[node.Id]).ToArray(),
Nodes = finalNodes,
Edges = routedEdges,
};
}

View File

@@ -24,6 +24,159 @@ internal static partial class ElkEdgePostProcessor
return ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(path[^1], path[^2], targetNode);
}
private static string ResolveSemanticTargetApproachSide(
ElkRoutedEdge edge,
IReadOnlyList<ElkPoint> path,
ElkPositionedNode targetNode,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
double graphMinY,
double graphMaxY)
{
if (ShouldDockForkBranchOnRepeatHeader(edge, targetNode, nodesById))
{
return "top";
}
if (ShouldDockTerminalArrivalOnEndLeftFace(edge, targetNode, nodesById, graphMinY, graphMaxY))
{
return "left";
}
var rawSide = ResolveTargetApproachSide(path, targetNode);
return TryResolvePreferredRectLateralTargetApproachSide(
edge,
path,
targetNode,
nodesById,
rawSide,
out var preferredSide)
? preferredSide
: rawSide;
}
internal static string ResolveSemanticTargetApproachFamily(
ElkRoutedEdge edge,
IReadOnlyList<ElkPoint> path,
ElkPositionedNode targetNode,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
double graphMinY,
double graphMaxY)
{
if (string.Equals(targetNode.Kind, "End", StringComparison.Ordinal)
&& ShouldDockTerminalArrivalOnEndLeftFace(edge, targetNode, nodesById, graphMinY, graphMaxY))
{
return "end-left";
}
if (string.Equals(targetNode.Kind, "SetState", StringComparison.Ordinal)
&& !string.IsNullOrWhiteSpace(edge.SourceNodeId)
&& nodesById.TryGetValue(edge.SourceNodeId, out var sourceNode)
&& sourceNode.Kind is "Decision" or "Timer")
{
return "decision-timer-setter";
}
return string.Empty;
}
private static bool ShouldDockForkBranchOnRepeatHeader(
ElkRoutedEdge edge,
ElkPositionedNode targetNode,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById)
{
if (!string.Equals(targetNode.Kind, "Repeat", StringComparison.Ordinal)
|| string.IsNullOrWhiteSpace(edge.SourceNodeId)
|| !nodesById.TryGetValue(edge.SourceNodeId, out var sourceNode))
{
return false;
}
return string.Equals(sourceNode.Kind, "Fork", StringComparison.Ordinal)
&& !IsRepeatCollectorLabel(edge.Label)
&& !string.IsNullOrWhiteSpace(edge.Label)
&& edge.Label.StartsWith("branch", StringComparison.OrdinalIgnoreCase);
}
private static bool ShouldDockTerminalArrivalOnEndLeftFace(
ElkRoutedEdge edge,
ElkPositionedNode targetNode,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
double graphMinY,
double graphMaxY)
{
if (!string.Equals(targetNode.Kind, "End", StringComparison.Ordinal)
|| string.IsNullOrWhiteSpace(edge.SourceNodeId)
|| !nodesById.TryGetValue(edge.SourceNodeId, out var sourceNode))
{
return false;
}
var sourceCenterX = sourceNode.X + (sourceNode.Width / 2d);
var targetCenterX = targetNode.X + (targetNode.Width / 2d);
return sourceCenterX <= targetCenterX;
}
private static bool HasAboveGraphSemanticCorridor(
IReadOnlyList<ElkPoint> path,
double graphMinY)
{
for (var i = 0; i < path.Count - 1; i++)
{
if (Math.Abs(path[i].Y - path[i + 1].Y) <= 0.5d
&& path[i].Y < graphMinY - 8d)
{
return true;
}
}
return false;
}
private static bool TryResolvePreferredRectLateralTargetApproachSide(
ElkRoutedEdge edge,
IReadOnlyList<ElkPoint> path,
ElkPositionedNode targetNode,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
string rawSide,
out string preferredSide)
{
preferredSide = string.Empty;
if (path.Count == 0
|| rawSide is not ("top" or "bottom")
|| ElkShapeBoundaries.IsGatewayShape(targetNode)
|| string.IsNullOrWhiteSpace(edge.SourceNodeId)
|| !nodesById.TryGetValue(edge.SourceNodeId, out var sourceNode))
{
return false;
}
const double coordinateTolerance = 1d;
var endpoint = path[^1];
var targetCenterX = targetNode.X + (targetNode.Width / 2d);
var sourceCenterX = sourceNode.X + (sourceNode.Width / 2d);
var verticalSlack = Math.Max(48d, targetNode.Height / 2d);
if (Math.Abs(endpoint.X - targetNode.X) <= coordinateTolerance
&& endpoint.Y >= targetNode.Y - verticalSlack
&& endpoint.Y <= targetNode.Y + targetNode.Height + verticalSlack
&& sourceCenterX <= targetCenterX)
{
preferredSide = "left";
return true;
}
var rightBoundaryX = targetNode.X + targetNode.Width;
if (Math.Abs(endpoint.X - rightBoundaryX) <= coordinateTolerance
&& endpoint.Y >= targetNode.Y - verticalSlack
&& endpoint.Y <= targetNode.Y + targetNode.Height + verticalSlack
&& sourceCenterX >= targetCenterX)
{
preferredSide = "right";
return true;
}
return false;
}
private static double ResolveTargetApproachAxisValue(
IReadOnlyList<ElkPoint> path,
string side)

View File

@@ -6,7 +6,8 @@ internal static partial class ElkEdgePostProcessor
IReadOnlyList<ElkPoint> path,
string side,
ElkPoint endpoint,
double desiredAxis)
double desiredAxis,
bool preserveDiagonalLeadIn = false)
{
if (!TryExtractTargetApproachRun(path, side, out var runStartIndex, out _))
{
@@ -16,9 +17,12 @@ internal static partial class ElkEdgePostProcessor
}
var prefixEndExclusive = runStartIndex;
if (runStartIndex > 0 && !IsOrthogonal(path[runStartIndex - 1], path[runStartIndex]))
var hasDiagonalLeadIn = runStartIndex > 0 && !IsOrthogonal(path[runStartIndex - 1], path[runStartIndex]);
if (hasDiagonalLeadIn)
{
prefixEndExclusive = runStartIndex + 1;
prefixEndExclusive = preserveDiagonalLeadIn
? runStartIndex + 1
: runStartIndex;
}
else if (prefixEndExclusive < 2 && path.Count > 2)
{
@@ -331,6 +335,82 @@ internal static partial class ElkEdgePostProcessor
return NormalizePathPoints(prefix);
}
private static List<ElkPoint> RewriteTargetApproachBandFromLocalPivot(
IReadOnlyList<ElkPoint> path,
string side,
double desiredBand,
ElkPoint endpoint,
double desiredApproachAxis)
{
if (!TryExtractTargetApproachRun(path, side, out var runStartIndex, out _)
|| runStartIndex < 3)
{
return RewriteTargetApproachRun(path, side, endpoint, desiredApproachAxis);
}
var pivotIndex = runStartIndex - 3;
var prefix = path.Take(pivotIndex + 1)
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToList();
if (prefix.Count == 0)
{
prefix.Add(new ElkPoint { X = path[0].X, Y = path[0].Y });
}
const double coordinateTolerance = 0.5d;
if (side is "left" or "right")
{
if (Math.Abs(prefix[^1].Y - desiredBand) > coordinateTolerance)
{
prefix.Add(new ElkPoint { X = prefix[^1].X, Y = desiredBand });
}
if (Math.Abs(prefix[^1].X - desiredApproachAxis) > coordinateTolerance)
{
prefix.Add(new ElkPoint { X = desiredApproachAxis, Y = desiredBand });
}
if (Math.Abs(prefix[^1].Y - endpoint.Y) > coordinateTolerance)
{
prefix.Add(new ElkPoint { X = desiredApproachAxis, Y = endpoint.Y });
}
if (Math.Abs(prefix[^1].X - endpoint.X) > coordinateTolerance)
{
prefix.Add(new ElkPoint { X = endpoint.X, Y = endpoint.Y });
}
}
else
{
if (Math.Abs(prefix[^1].X - desiredBand) > coordinateTolerance)
{
prefix.Add(new ElkPoint { X = desiredBand, Y = prefix[^1].Y });
}
if (Math.Abs(prefix[^1].Y - desiredApproachAxis) > coordinateTolerance)
{
prefix.Add(new ElkPoint { X = desiredBand, Y = desiredApproachAxis });
}
if (Math.Abs(prefix[^1].X - endpoint.X) > coordinateTolerance)
{
prefix.Add(new ElkPoint { X = endpoint.X, Y = desiredApproachAxis });
}
if (Math.Abs(prefix[^1].Y - endpoint.Y) > coordinateTolerance)
{
prefix.Add(new ElkPoint { X = endpoint.X, Y = endpoint.Y });
}
}
if (!ElkEdgeRoutingGeometry.PointsEqual(prefix[^1], endpoint))
{
prefix.Add(new ElkPoint { X = endpoint.X, Y = endpoint.Y });
}
return NormalizePathPoints(prefix);
}
private static List<ElkPoint> RewriteTargetApproachFeederBand(
IReadOnlyList<ElkPoint> path,
string side,

View File

@@ -2,6 +2,45 @@ namespace StellaOps.ElkSharp;
internal static partial class ElkEdgePostProcessor
{
private static List<ElkPoint> BuildRepeatHeaderTargetDockPath(
IReadOnlyList<ElkPoint> path,
ElkPositionedNode targetNode,
ElkPoint desiredEndpoint)
{
const double coordinateTolerance = 0.5d;
if (path.Count < 2)
{
return path
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToList();
}
var sourcePoint = path[0];
var rebuilt = new List<ElkPoint>
{
new() { X = sourcePoint.X, Y = sourcePoint.Y },
};
var dropX = desiredEndpoint.X;
var stubY = Math.Min(sourcePoint.Y, targetNode.Y - 24d);
if (Math.Abs(rebuilt[^1].X - dropX) > coordinateTolerance)
{
rebuilt.Add(new ElkPoint { X = dropX, Y = rebuilt[^1].Y });
}
if (Math.Abs(rebuilt[^1].Y - stubY) > coordinateTolerance)
{
rebuilt.Add(new ElkPoint { X = dropX, Y = stubY });
}
if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], desiredEndpoint))
{
rebuilt.Add(new ElkPoint { X = desiredEndpoint.X, Y = desiredEndpoint.Y });
}
return NormalizePathPoints(rebuilt);
}
private static List<ElkPoint> BuildTargetApproachCandidatePath(
IReadOnlyList<ElkPoint> path,
ElkPositionedNode targetNode,
@@ -10,6 +49,7 @@ internal static partial class ElkEdgePostProcessor
double axisValue)
{
var preserveExistingApproachAxis = TryExtractTargetApproachFeeder(path, side, out _);
var hasDiagonalLeadIn = HasDiagonalLeadInToTargetRun(path, side);
var targetAxis = double.IsNaN(axisValue)
? ResolveDefaultTargetApproachAxis(targetNode, side)
: axisValue;
@@ -20,6 +60,34 @@ internal static partial class ElkEdgePostProcessor
targetAxis = diagonalTargetAxis;
}
if (!ElkShapeBoundaries.IsGatewayShape(targetNode)
&& !preserveExistingApproachAxis
&& hasDiagonalLeadIn)
{
return RewriteTargetApproachRun(
path,
side,
desiredEndpoint,
targetAxis);
}
if (!ElkShapeBoundaries.IsGatewayShape(targetNode)
&& TryExtractTargetApproachBand(path, side, out var existingBand))
{
var desiredBand = side is "left" or "right"
? desiredEndpoint.Y
: desiredEndpoint.X;
if (Math.Abs(existingBand.BandCoordinate - desiredBand) > 0.5d)
{
return RewriteTargetApproachBandFromLocalPivot(
path,
side,
desiredBand,
desiredEndpoint,
targetAxis);
}
}
List<ElkPoint> normalized;
if (ElkShapeBoundaries.IsGatewayShape(targetNode))
{
@@ -82,7 +150,8 @@ internal static partial class ElkEdgePostProcessor
path,
side,
desiredEndpoint,
targetAxis);
targetAxis,
preserveDiagonalLeadIn: true);
if (CanAcceptGatewayTargetRepair(orthogonalFallback, targetNode))
{
return orthogonalFallback;
@@ -115,7 +184,8 @@ internal static partial class ElkEdgePostProcessor
normalized,
side,
desiredEndpoint,
targetAxis);
targetAxis,
preserveDiagonalLeadIn: ElkShapeBoundaries.IsGatewayShape(targetNode));
if (!PathChanged(normalized, rewritten))
{
return normalized;
@@ -134,6 +204,15 @@ internal static partial class ElkEdgePostProcessor
return rewritten;
}
private static bool HasDiagonalLeadInToTargetRun(
IReadOnlyList<ElkPoint> path,
string side)
{
return TryExtractTargetApproachRun(path, side, out var runStartIndex, out _)
&& runStartIndex > 0
&& !IsOrthogonal(path[runStartIndex - 1], path[runStartIndex]);
}
private static bool TryExtractTargetApproachRun(
IReadOnlyList<ElkPoint> path,
string side,

View File

@@ -50,7 +50,13 @@ internal static partial class ElkEdgePostProcessor
|| (!enforceAllNodeEndpoints && ShouldSpreadTargetApproach(edge, graphMinY, graphMaxY)))
&& nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode))
{
var targetSide = ResolveTargetApproachSide(path, targetNode);
var targetSide = ResolveSemanticTargetApproachSide(
edge,
path,
targetNode,
nodesById,
graphMinY,
graphMaxY);
if (targetSide is "left" or "right" or "top" or "bottom")
{
var targetCoordinate = targetSide is "left" or "right"
@@ -69,6 +75,7 @@ internal static partial class ElkEdgePostProcessor
}
PromoteGatewayRepeatCollectorAlternateFaceAssignments(groups, edgesById);
PromoteForkJoinBypassAlternateFaceAssignments(groups, edgesById, nodesById);
foreach (var (_, group) in groups)
{
@@ -612,12 +619,18 @@ internal static partial class ElkEdgePostProcessor
desiredTargetAxis = ResolveDefaultTargetApproachAxis(targetNode, targetSide);
}
var targetCandidate = BuildTargetApproachCandidatePath(
currentPath,
targetNode,
targetSide,
desiredTargetBoundary,
desiredTargetAxis);
var targetCandidate = ShouldDockForkBranchOnRepeatHeader(edge, targetNode, nodesById)
&& string.Equals(targetSide, "top", StringComparison.Ordinal)
? BuildRepeatHeaderTargetDockPath(
currentPath,
targetNode,
desiredTargetBoundary)
: BuildTargetApproachCandidatePath(
currentPath,
targetNode,
targetSide,
desiredTargetBoundary,
desiredTargetAxis);
var targetCandidateAccepted = IsValidSharedLaneBoundaryRepairCandidate(
edge,
currentPath,
@@ -883,4 +896,96 @@ internal static partial class ElkEdgePostProcessor
return changed ? result : edges;
}
private static void PromoteForkJoinBypassAlternateFaceAssignments(
Dictionary<string, List<(string EdgeId, ElkPositionedNode Node, string Side, double Coordinate, bool IsOutgoing)>> groups,
IReadOnlyDictionary<string, ElkRoutedEdge> edgesById,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById)
{
var moves = new List<(
string SourceKey,
(string EdgeId, ElkPositionedNode Node, string Side, double Coordinate, bool IsOutgoing) Entry,
string TargetKey,
(string EdgeId, ElkPositionedNode Node, string Side, double Coordinate, bool IsOutgoing) ReassignedEntry)>();
foreach (var (key, group) in groups.ToArray())
{
if (group.Count < 2)
{
continue;
}
var sourceNode = group[0].Node;
var side = group[0].Side;
if (!string.Equals(sourceNode.Kind, "Fork", StringComparison.Ordinal)
|| side is not ("left" or "right")
|| group.Any(entry => !entry.IsOutgoing))
{
continue;
}
var hasJoinTarget = false;
var hasWorkTarget = false;
foreach (var entry in group)
{
if (!edgesById.TryGetValue(entry.EdgeId, out var edge)
|| string.IsNullOrWhiteSpace(edge.TargetNodeId)
|| !nodesById.TryGetValue(edge.TargetNodeId, out var targetNode))
{
continue;
}
if (string.Equals(targetNode.Kind, "Join", StringComparison.Ordinal))
{
hasJoinTarget = true;
}
else
{
hasWorkTarget = true;
}
}
if (!hasJoinTarget || !hasWorkTarget)
{
continue;
}
var centerCoordinate = sourceNode.Y + (sourceNode.Height / 2d);
foreach (var entry in group)
{
if (!edgesById.TryGetValue(entry.EdgeId, out var edge)
|| string.IsNullOrWhiteSpace(edge.TargetNodeId)
|| !nodesById.TryGetValue(edge.TargetNodeId, out var targetNode)
|| !string.Equals(targetNode.Kind, "Join", StringComparison.Ordinal))
{
continue;
}
var alternateSide = entry.Coordinate <= centerCoordinate ? "top" : "bottom";
var alternateCoordinate = sourceNode.X + (sourceNode.Width / 2d);
var alternateKey = $"{sourceNode.Id}|{alternateSide}";
moves.Add((
key,
entry,
alternateKey,
(entry.EdgeId, sourceNode, alternateSide, alternateCoordinate, entry.IsOutgoing)));
}
}
foreach (var move in moves)
{
if (groups.TryGetValue(move.SourceKey, out var sourceGroup))
{
sourceGroup.Remove(move.Entry);
}
if (!groups.TryGetValue(move.TargetKey, out var targetGroup))
{
targetGroup = [];
groups[move.TargetKey] = targetGroup;
}
targetGroup.Add(move.ReassignedEntry);
}
}
}

View File

@@ -0,0 +1,187 @@
namespace StellaOps.ElkSharp;
internal static partial class ElkEdgePostProcessor
{
/// <summary>
/// Enforces a minimum vertical gap between adjacent above-graph corridors.
/// Preserves the existing corridor order — only pushes corridors further
/// from the graph when they are too close to their neighbors.
/// </summary>
internal static ElkRoutedEdge[] SpreadOuterCorridors(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes)
{
if (edges.Length < 2 || nodes.Length == 0)
{
return edges;
}
var graphMinY = nodes.Min(node => node.Y);
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 minGap = Math.Max(18d, minLineClearance * 0.6d);
// Collect all above-graph corridor lanes (distinct rounded Y values)
var corridorEntries = new List<(int EdgeIndex, double CorridorY)>();
for (var i = 0; i < edges.Length; i++)
{
var bestAboveY = double.NaN;
var bestLength = 0d;
foreach (var section in edges[i].Sections)
{
var points = new List<ElkPoint> { section.StartPoint };
points.AddRange(section.BendPoints);
points.Add(section.EndPoint);
for (var j = 0; j < points.Count - 1; j++)
{
if (Math.Abs(points[j].Y - points[j + 1].Y) > 0.5d
|| points[j].Y >= graphMinY - 8d)
{
continue;
}
var length = Math.Abs(points[j + 1].X - points[j].X);
if (length > bestLength)
{
bestLength = length;
bestAboveY = points[j].Y;
}
}
}
if (!double.IsNaN(bestAboveY) && bestLength > 40d)
{
corridorEntries.Add((i, bestAboveY));
}
}
if (corridorEntries.Count < 2)
{
return edges;
}
// Group by rounded corridor Y (edges sharing a corridor lane)
var lanes = corridorEntries
.GroupBy(entry => Math.Round(entry.CorridorY, 0))
.OrderByDescending(group => group.Key) // closest to graph first (least negative)
.Select(group => new
{
CurrentY = group.Key,
Entries = group.ToArray(),
})
.ToArray();
if (lanes.Length < 2)
{
return edges;
}
// Walk from closest-to-graph outward, enforcing minGap
var targetYValues = new double[lanes.Length];
targetYValues[0] = lanes[0].CurrentY; // keep the closest lane where it is
var needsShift = false;
for (var i = 1; i < lanes.Length; i++)
{
var idealY = lanes[i].CurrentY;
var maxAllowedY = targetYValues[i - 1] - minGap;
if (idealY > maxAllowedY)
{
targetYValues[i] = maxAllowedY;
needsShift = true;
}
else
{
targetYValues[i] = idealY;
}
}
if (!needsShift)
{
return edges;
}
for (var i = 0; i < lanes.Length; i++)
{
var shift = targetYValues[i] - lanes[i].CurrentY;
}
// Apply shifts
var result = edges.ToArray();
for (var i = 0; i < lanes.Length; i++)
{
var shift = targetYValues[i] - lanes[i].CurrentY;
if (Math.Abs(shift) < 1d)
{
continue;
}
foreach (var entry in lanes[i].Entries)
{
var edge = result[entry.EdgeIndex];
var shifted = ShiftEdgeCorridorY(
edge,
lanes[i].CurrentY,
shift,
graphMinY);
result[entry.EdgeIndex] = shifted;
}
}
return result;
}
private static ElkRoutedEdge ShiftEdgeCorridorY(
ElkRoutedEdge edge,
double corridorY,
double shift,
double graphMinY)
{
const double tolerance = 2d;
var sectionArray = edge.Sections.ToArray();
var newSections = new ElkEdgeSection[sectionArray.Length];
for (var s = 0; s < sectionArray.Length; s++)
{
var section = sectionArray[s];
var newStart = ShiftCorridorPoint(section.StartPoint, corridorY, shift, graphMinY, tolerance);
var newEnd = ShiftCorridorPoint(section.EndPoint, corridorY, shift, graphMinY, tolerance);
var newBends = section.BendPoints
.Select(bp => ShiftCorridorPoint(bp, corridorY, shift, graphMinY, tolerance))
.ToArray();
newSections[s] = new ElkEdgeSection
{
StartPoint = newStart,
EndPoint = newEnd,
BendPoints = newBends,
};
}
return edge with { Sections = newSections };
}
private static ElkPoint ShiftCorridorPoint(
ElkPoint point,
double corridorY,
double shift,
double graphMinY,
double tolerance)
{
// Only shift points that are in the above-graph corridor region
// and are at the corridor Y level
if (point.Y >= graphMinY - 8d)
{
return point;
}
if (Math.Abs(point.Y - corridorY) <= tolerance)
{
return new ElkPoint { X = point.X, Y = point.Y + shift };
}
return point;
}
}

View File

@@ -0,0 +1,261 @@
namespace StellaOps.ElkSharp;
internal static partial class ElkEdgePostProcessor
{
/// <summary>
/// Shifts long vertical segments of high-crossing edges toward their target
/// node boundary to reduce edge-edge crossings. Only accepts shifts that
/// strictly reduce the total crossing count without increasing hard violations.
/// </summary>
internal static ElkRoutedEdge[] ShiftHighCrossingVerticals(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
double minLineClearance)
{
if (edges.Length < 3 || nodes.Length == 0)
{
return edges;
}
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
var severityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountEdgeEdgeCrossings(edges, severityByEdgeId, 1);
var result = edges;
var changed = false;
foreach (var entry in severityByEdgeId
.Where(pair => pair.Value >= 2)
.OrderByDescending(pair => pair.Value)
.ThenBy(pair => pair.Key, StringComparer.Ordinal))
{
var edgeIndex = Array.FindIndex(result, edge =>
string.Equals(edge.Id, entry.Key, StringComparison.Ordinal));
if (edgeIndex < 0)
{
continue;
}
var edge = result[edgeIndex];
if (string.IsNullOrWhiteSpace(edge.TargetNodeId)
|| !nodesById.TryGetValue(edge.TargetNodeId!, out var targetNode)
|| string.IsNullOrWhiteSpace(edge.SourceNodeId)
|| !nodesById.TryGetValue(edge.SourceNodeId!, out var sourceNode))
{
continue;
}
var path = ExtractFullPath(edge);
if (path.Count < 4)
{
continue;
}
var (verticalIndex, verticalLength) = FindLongestInteriorVerticalSegment(path);
if (verticalIndex < 1 || verticalLength < minLineClearance * 2d)
{
continue;
}
var currentScore = ElkEdgeRoutingScoring.ComputeScore(result, nodes);
var bestCandidate = TryBuildShiftedVerticalCandidate(
edge,
edgeIndex,
path,
verticalIndex,
targetNode,
sourceNode,
result,
nodes,
currentScore);
if (bestCandidate is null)
{
continue;
}
result = bestCandidate;
changed = true;
}
return changed ? result : edges;
}
private static ElkRoutedEdge[]? TryBuildShiftedVerticalCandidate(
ElkRoutedEdge edge,
int edgeIndex,
IReadOnlyList<ElkPoint> path,
int verticalIndex,
ElkPositionedNode targetNode,
ElkPositionedNode sourceNode,
ElkRoutedEdge[] currentEdges,
ElkPositionedNode[] nodes,
EdgeRoutingScore currentScore)
{
const double coordinateTolerance = 0.5d;
var verticalX = path[verticalIndex].X;
// Candidate X values: exact node boundaries (obstacle check uses strict
// inequality, so boundary X is safe) plus offsets.
var candidateXValues = new[]
{
targetNode.X,
targetNode.X - 8d,
targetNode.X + targetNode.Width,
targetNode.X + targetNode.Width + 8d,
sourceNode.X + sourceNode.Width + 24d,
};
ElkRoutedEdge[]? bestResult = null;
var bestCrossings = currentScore.EdgeCrossings;
foreach (var candidateX in candidateXValues
.Where(x => Math.Abs(x - verticalX) > 8d)
.Distinct()
.OrderBy(x => Math.Abs(x - verticalX)))
{
var candidatePath = BuildShiftedVerticalPath(
path,
verticalIndex,
candidateX,
coordinateTolerance);
if (candidatePath is null || candidatePath.Count < 3)
{
continue;
}
if (HasNodeObstacleCrossing(
candidatePath,
nodes,
edge.SourceNodeId,
edge.TargetNodeId))
{
continue;
}
var candidateEdge = BuildSingleSectionEdge(edge, candidatePath);
var candidateEdges = currentEdges.ToArray();
candidateEdges[edgeIndex] = candidateEdge;
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes);
var crossingGain = bestCrossings - candidateScore.EdgeCrossings;
if (crossingGain < 1
|| candidateScore.NodeCrossings > currentScore.NodeCrossings
|| candidateScore.UnderNodeViolations > currentScore.UnderNodeViolations
|| candidateScore.TargetApproachJoinViolations > currentScore.TargetApproachJoinViolations
|| candidateScore.GatewaySourceExitViolations > currentScore.GatewaySourceExitViolations
|| candidateScore.TargetApproachBacktrackingViolations > currentScore.TargetApproachBacktrackingViolations + 1
|| candidateScore.EntryAngleViolations > currentScore.EntryAngleViolations + 1
|| candidateScore.SharedLaneViolations > currentScore.SharedLaneViolations + (crossingGain >= 2 ? 1 : 0)
|| candidateScore.BoundarySlotViolations > currentScore.BoundarySlotViolations + (crossingGain >= 3 ? 1 : 0))
{
continue;
}
bestResult = candidateEdges;
bestCrossings = candidateScore.EdgeCrossings;
}
return bestResult;
}
private static List<ElkPoint>? BuildShiftedVerticalPath(
IReadOnlyList<ElkPoint> path,
int verticalIndex,
double newX,
double coordinateTolerance)
{
if (verticalIndex < 1 || verticalIndex >= path.Count - 1)
{
return null;
}
var verticalTopY = path[verticalIndex].Y;
var verticalBottomY = path[verticalIndex + 1].Y;
// Build the prefix: everything up to the vertical, ending with a
// horizontal connection to the new vertical X.
var rebuilt = new List<ElkPoint>();
for (var i = 0; i < verticalIndex; i++)
{
rebuilt.Add(new ElkPoint { X = path[i].X, Y = path[i].Y });
}
if (Math.Abs(rebuilt[^1].Y - verticalTopY) <= coordinateTolerance)
{
rebuilt.Add(new ElkPoint { X = newX, Y = verticalTopY });
}
else
{
rebuilt.Add(new ElkPoint { X = rebuilt[^1].X, Y = verticalTopY });
rebuilt.Add(new ElkPoint { X = newX, Y = verticalTopY });
}
// Find where the suffix starts: skip the old vertical and any
// horizontal bridge that connected the old vertical bottom to the
// target approach area.
var suffixStart = verticalIndex + 2;
while (suffixStart < path.Count
&& Math.Abs(path[suffixStart].Y - verticalBottomY) <= coordinateTolerance)
{
suffixStart++;
}
if (suffixStart >= path.Count)
{
// The vertical bottom is the path endpoint — the target approach
// is at the vertical bottom Y. Connect directly.
rebuilt.Add(new ElkPoint { X = newX, Y = verticalBottomY });
rebuilt.Add(new ElkPoint { X = path[^1].X, Y = path[^1].Y });
return NormalizeOrthogonalPath(rebuilt, coordinateTolerance);
}
// The suffix starts at a point that diverges from the vertical bottom Y.
// Connect the shifted vertical to this point.
var suffixEntry = path[suffixStart];
rebuilt.Add(new ElkPoint { X = newX, Y = suffixEntry.Y });
// Append the suffix
for (var i = suffixStart; i < path.Count; i++)
{
if (rebuilt.Count > 0
&& Math.Abs(rebuilt[^1].X - path[i].X) <= coordinateTolerance
&& Math.Abs(rebuilt[^1].Y - path[i].Y) <= coordinateTolerance)
{
continue;
}
rebuilt.Add(new ElkPoint { X = path[i].X, Y = path[i].Y });
}
return NormalizeOrthogonalPath(rebuilt, coordinateTolerance);
}
/// <summary>
/// Finds the longest vertical segment that is NOT the first or last segment
/// in the path (those connect to source/target endpoints and must not be shifted).
/// </summary>
private static (int Index, double Length) FindLongestInteriorVerticalSegment(
IReadOnlyList<ElkPoint> path)
{
var bestIndex = -1;
var bestLength = 0d;
// Skip first segment (i=0) and last segment (i=path.Count-2)
for (var i = 1; i < path.Count - 2; i++)
{
if (Math.Abs(path[i].X - path[i + 1].X) > 0.5d)
{
continue;
}
var length = Math.Abs(path[i + 1].Y - path[i].Y);
if (length > bestLength)
{
bestLength = length;
bestIndex = i;
}
}
return (bestIndex, bestLength);
}
}

View File

@@ -106,6 +106,15 @@ internal static partial class ElkEdgePostProcessor
orderedEntries.Length,
minLineClearance));
var topFamilyCount = orderedEntries.Count(entry => entry.UsesAboveGraph);
// Reverse above-graph slot assignment so the lead lane (ordinal 0)
// gets the bottom slot. This prevents the regular corridor's approach
// vertical from crossing through the lead lane's final horizontal.
if (topFamilyCount > 1)
{
Array.Reverse(assignedSlotCoordinates, 0, topFamilyCount);
}
var topFamilyTrunkX = Math.Min(
approachX - 18d,
approachX - Math.Max(32d, Math.Max(2, topFamilyCount + 1) * trunkSpacing));
@@ -117,6 +126,11 @@ internal static partial class ElkEdgePostProcessor
var sideFamilyOrdinal = 0;
var groupChanged = false;
var topFamilyCorridorY = ResolveTopFamilyCorridorY(
currentEdges,
orderedEntries,
graphMinY,
minLineClearance);
var groupedCandidateEdges = currentEdges.ToArray();
var builtGroupedCandidate = false;
var groupedCandidateValid = true;
@@ -136,14 +150,14 @@ internal static partial class ElkEdgePostProcessor
topFamilyTrunkX,
endpointY,
minLineClearance,
graphMinY,
topFamilyCorridorY,
coordinateTolerance)
: RewriteLeftFaceEndTopCorridor(
entry.Path,
targetNode,
topFamilyTrunkX,
endpointY,
graphMinY,
topFamilyCorridorY,
coordinateTolerance);
topFamilyOrdinal++;
}
@@ -211,6 +225,11 @@ internal static partial class ElkEdgePostProcessor
for (var i = 0; i < orderedEntries.Length; i++)
{
topFamilyCorridorY = ResolveTopFamilyCorridorY(
currentEdges,
orderedEntries,
graphMinY,
minLineClearance);
var entry = orderedEntries[i];
var endpointY = assignedSlotCoordinates[i];
var candidateVariants = new List<(List<ElkPoint> Candidate, double Cost)>();
@@ -226,7 +245,7 @@ internal static partial class ElkEdgePostProcessor
topFamilyTrunkX,
endpointY,
minLineClearance,
graphMinY,
topFamilyCorridorY,
coordinateTolerance),
-0.15d));
}
@@ -236,7 +255,7 @@ internal static partial class ElkEdgePostProcessor
targetNode,
topFamilyTrunkX,
endpointY,
graphMinY,
topFamilyCorridorY,
coordinateTolerance,
usesAboveGraphCorridor: true));
topFamilyOrdinal++;
@@ -289,6 +308,7 @@ internal static partial class ElkEdgePostProcessor
candidateEdges[entry.Index] = candidateEdge;
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes);
if (candidateScore.NodeCrossings > currentScore.NodeCrossings
|| candidateScore.EdgeCrossings > currentScore.EdgeCrossings
|| candidateScore.BoundarySlotViolations > currentScore.BoundarySlotViolations
|| candidateScore.TargetApproachBacktrackingViolations > currentScore.TargetApproachBacktrackingViolations
|| candidateScore.GatewaySourceExitViolations > currentScore.GatewaySourceExitViolations
@@ -335,6 +355,7 @@ internal static partial class ElkEdgePostProcessor
continue;
}
if (!preferredLocal.Value.IsBetterThan(currentLocal)
&& (!preferredLocal.Value.IsEquivalentTo(currentLocal)
|| preferredScore.Value.Value <= currentScore.Value + 0.5d))
@@ -491,7 +512,7 @@ internal static partial class ElkEdgePostProcessor
ElkPositionedNode targetNode,
double trunkX,
double endpointY,
double graphMinY,
double topCorridorY,
double coordinateTolerance,
bool usesAboveGraphCorridor)
{
@@ -502,7 +523,7 @@ internal static partial class ElkEdgePostProcessor
targetNode,
trunkX,
endpointY,
graphMinY,
topCorridorY,
coordinateTolerance);
yield return (topCorridorCandidate, 0d);
}
@@ -513,8 +534,15 @@ internal static partial class ElkEdgePostProcessor
yield return (slotRailCandidate, 0d);
}
var preservedBandCandidate = RewriteLeftFaceEndTrunk(path, targetNode, trunkX, endpointY, coordinateTolerance);
yield return (preservedBandCandidate, usesAboveGraphCorridor ? 0.2d : 0.35d);
// The preserved-band fallback is only offered for side-family entries.
// For above-graph entries, allowing the trunk variant lets the per-edge
// pass regress the corridor back into the repeat-return band because the
// shorter path outweighs the crossing topology in the score comparison.
if (!usesAboveGraphCorridor)
{
var preservedBandCandidate = RewriteLeftFaceEndTrunk(path, targetNode, trunkX, endpointY, coordinateTolerance);
yield return (preservedBandCandidate, 0.35d);
}
}
private static List<ElkPoint> RewriteLeftFaceEndTopCorridor(
@@ -522,13 +550,12 @@ internal static partial class ElkEdgePostProcessor
ElkPositionedNode targetNode,
double trunkX,
double endpointY,
double graphMinY,
double corridorY,
double coordinateTolerance)
{
var targetX = targetNode.X;
var feederX = Math.Min(targetX - 18d, Math.Max(trunkX + 20d, targetX - 44d));
var approachX = Math.Min(targetX - 8d, Math.Max(feederX + 10d, targetX - 18d));
var corridorY = graphMinY - 18d;
var rebuilt = new List<ElkPoint>
{
new() { X = path[0].X, Y = path[0].Y },
@@ -568,11 +595,10 @@ internal static partial class ElkEdgePostProcessor
double trunkX,
double endpointY,
double minLineClearance,
double graphMinY,
double corridorY,
double coordinateTolerance)
{
var targetX = targetNode.X;
var corridorY = graphMinY - 18d;
var entryRailX = trunkX;
var jogX = Math.Min(targetX - 22d, entryRailX + Math.Max(24d, minLineClearance * 0.55d));
var preTerminalY = Math.Max(corridorY + 18d, endpointY - Math.Max(18d, minLineClearance * 0.35d));
@@ -618,6 +644,83 @@ internal static partial class ElkEdgePostProcessor
coordinateTolerance);
}
private static double ResolveTopFamilyCorridorY(
IReadOnlyList<ElkRoutedEdge> edges,
IReadOnlyList<EndTerminalEntry> orderedEntries,
double graphMinY,
double minLineClearance)
{
var defaultCorridorY = graphMinY - 18d;
var topEntries = orderedEntries
.Where(entry => entry.UsesAboveGraph)
.ToArray();
if (topEntries.Length == 0)
{
return defaultCorridorY;
}
var focusEdgeIds = topEntries
.Select(entry => entry.Edge.Id)
.ToHashSet(StringComparer.Ordinal);
var preservedCorridorY = topEntries
.Select(entry => TryResolveAboveGraphRun(entry.Path, graphMinY))
.Where(run => run is not null)
.Select(run => run!.Value.CorridorY)
.DefaultIfEmpty(defaultCorridorY)
.Min();
var focusMinX = topEntries.Min(entry => entry.Path.Min(point => point.X));
var focusMaxX = topEntries.Max(entry => entry.Path.Max(point => point.X));
var blockingCorridorY = edges
.Where(edge => !focusEdgeIds.Contains(edge.Id))
.Select(edge => TryResolveAboveGraphRun(ExtractFullPath(edge), graphMinY))
.Where(run => run is not null
&& run.Value.MaxX >= focusMinX - 0.5d
&& run.Value.MinX <= focusMaxX + 0.5d)
.Select(run => run!.Value.CorridorY)
.DefaultIfEmpty(double.NaN)
.Min();
if (double.IsNaN(blockingCorridorY))
{
return preservedCorridorY;
}
var laneGap = Math.Max(24d, minLineClearance);
return Math.Min(preservedCorridorY, blockingCorridorY - laneGap);
}
private static AboveGraphRun? TryResolveAboveGraphRun(
IReadOnlyList<ElkPoint> path,
double graphMinY)
{
AboveGraphRun? best = null;
for (var i = 0; i < path.Count - 1; i++)
{
var start = path[i];
var end = path[i + 1];
if (Math.Abs(start.Y - end.Y) > 0.5d
|| start.Y >= graphMinY - 8d)
{
continue;
}
var minX = Math.Min(start.X, end.X);
var maxX = Math.Max(start.X, end.X);
var length = maxX - minX;
if (length <= 1d)
{
continue;
}
var candidate = new AboveGraphRun(start.Y, minX, maxX, length);
if (best is null || candidate.Length > best.Value.Length)
{
best = candidate;
}
}
return best;
}
private static List<ElkPoint> RewriteLeftFaceEndSlotRail(
IReadOnlyList<ElkPoint> path,
ElkPositionedNode targetNode,
@@ -1091,4 +1194,10 @@ internal static partial class ElkEdgePostProcessor
&& BrokenHighways == other.BrokenHighways;
}
}
private readonly record struct AboveGraphRun(
double CorridorY,
double MinX,
double MaxX,
double Length);
}

View File

@@ -220,6 +220,8 @@ internal static partial class ElkEdgePostProcessor
}
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
var graphMinY = nodes.Min(node => node.Y);
var graphMaxY = nodes.Max(node => node.Y + node.Height);
var restrictedSet = restrictedEdgeIds is null
? null
: restrictedEdgeIds.ToHashSet(StringComparer.Ordinal);
@@ -238,8 +240,21 @@ internal static partial class ElkEdgePostProcessor
item =>
{
var targetNode = nodesById[item.Edge.TargetNodeId ?? string.Empty];
var side = ResolveTargetApproachSide(item.Path, targetNode);
return $"{targetNode.Id}|{side}";
var side = ResolveSemanticTargetApproachSide(
item.Edge,
item.Path,
targetNode,
nodesById,
graphMinY,
graphMaxY);
var family = ResolveSemanticTargetApproachFamily(
item.Edge,
item.Path,
targetNode,
nodesById,
graphMinY,
graphMaxY);
return $"{targetNode.Id}|{side}|{family}";
},
StringComparer.Ordinal);
@@ -249,7 +264,13 @@ internal static partial class ElkEdgePostProcessor
.Select(item =>
{
var targetNode = nodesById[item.Edge.TargetNodeId ?? string.Empty];
var side = ResolveTargetApproachSide(item.Path, targetNode);
var side = ResolveSemanticTargetApproachSide(
item.Edge,
item.Path,
targetNode,
nodesById,
graphMinY,
graphMaxY);
return TryExtractTargetApproachFeeder(item.Path, side, out var feeder)
? new
{

View File

@@ -34,8 +34,21 @@ internal static partial class ElkEdgePostProcessor
item =>
{
var targetNode = nodesById[item.Edge.TargetNodeId ?? string.Empty];
var side = ResolveTargetApproachSide(item.Path, targetNode);
return $"{targetNode.Id}|{side}";
var side = ResolveSemanticTargetApproachSide(
item.Edge,
item.Path,
targetNode,
nodesById,
graphMinY,
graphMaxY);
var family = ResolveSemanticTargetApproachFamily(
item.Edge,
item.Path,
targetNode,
nodesById,
graphMinY,
graphMaxY);
return $"{targetNode.Id}|{side}|{family}";
},
StringComparer.Ordinal);
@@ -45,7 +58,13 @@ internal static partial class ElkEdgePostProcessor
.Select(item =>
{
var targetNode = nodesById[item.Edge.TargetNodeId ?? string.Empty];
var side = ResolveTargetApproachSide(item.Path, targetNode);
var side = ResolveSemanticTargetApproachSide(
item.Edge,
item.Path,
targetNode,
nodesById,
graphMinY,
graphMaxY);
var endpoint = item.Path[^1];
return new
{
@@ -177,7 +196,8 @@ internal static partial class ElkEdgePostProcessor
gatewayCandidate,
sorted[i].Side,
slotPoint,
gatewayApproachAxis);
gatewayApproachAxis,
preserveDiagonalLeadIn: true);
spreadGatewayCandidate = PreferGatewayDiagonalTargetEntry(spreadGatewayCandidate, targetNode);
if (PathChanged(gatewayCandidate, spreadGatewayCandidate)
&& CanAcceptGatewayTargetRepair(spreadGatewayCandidate, targetNode))
@@ -218,12 +238,33 @@ internal static partial class ElkEdgePostProcessor
var desiredBandCoordinate = side is "left" or "right"
? desiredEndpoint.Y
: desiredEndpoint.X;
desiredBandCoordinate = ResolveClearanceAwareTargetBandCoordinate(
candidatePath,
sorted[i].Side,
desiredBandCoordinate,
minLineClearance,
nodes,
sorted[i].Edge.SourceNodeId,
sorted[i].Edge.TargetNodeId);
var bandCandidate = RewriteTargetApproachBand(
candidatePath,
sorted[i].Side,
desiredBandCoordinate,
desiredRunAxis,
targetNode);
if (TryExtractTargetApproachBand(candidatePath, sorted[i].Side, out _))
{
var localBandCandidate = RewriteTargetApproachBandFromLocalPivot(
candidatePath,
sorted[i].Side,
desiredBandCoordinate,
desiredEndpoint,
desiredRunAxis);
if (PathChanged(candidatePath, localBandCandidate))
{
bandCandidate = localBandCandidate;
}
}
if (PathChanged(candidatePath, bandCandidate))
{
candidatePath = bandCandidate;
@@ -248,6 +289,68 @@ internal static partial class ElkEdgePostProcessor
return result;
}
private static double ResolveClearanceAwareTargetBandCoordinate(
IReadOnlyList<ElkPoint> path,
string side,
double desiredBandCoordinate,
double minLineClearance,
IReadOnlyCollection<ElkPositionedNode> nodes,
string? sourceNodeId,
string? targetNodeId)
{
if (side is not ("left" or "right" or "top" or "bottom"))
{
return desiredBandCoordinate;
}
var adjustedBand = desiredBandCoordinate;
var pathMinX = path.Min(point => point.X);
var pathMaxX = path.Max(point => point.X);
var pathMinY = path.Min(point => point.Y);
var pathMaxY = path.Max(point => point.Y);
foreach (var node in nodes)
{
if (string.Equals(node.Id, sourceNodeId, StringComparison.Ordinal)
|| string.Equals(node.Id, targetNodeId, StringComparison.Ordinal))
{
continue;
}
if (side is "left" or "right")
{
if (node.X >= pathMaxX || node.X + node.Width <= pathMinX)
{
continue;
}
if (adjustedBand >= node.Y - 0.5d
&& adjustedBand < node.Y + node.Height + minLineClearance
&& pathMaxY >= node.Y - 0.5d
&& pathMinY <= node.Y + node.Height + minLineClearance)
{
adjustedBand = Math.Max(adjustedBand, node.Y + node.Height + minLineClearance);
}
continue;
}
if (node.Y >= pathMaxY || node.Y + node.Height <= pathMinY)
{
continue;
}
if (adjustedBand >= node.X - 0.5d
&& adjustedBand < node.X + node.Width + minLineClearance
&& pathMaxX >= node.X - 0.5d
&& pathMinX <= node.X + node.Width + minLineClearance)
{
adjustedBand = Math.Max(adjustedBand, node.X + node.Width + minLineClearance);
}
}
return adjustedBand;
}
private static Dictionary<string, double> ResolveGatewayBoundaryBandSlotCoordinates(
IReadOnlyList<(string EdgeId, ElkPoint Endpoint)> entries,
ElkPositionedNode targetNode,

View File

@@ -0,0 +1,685 @@
namespace StellaOps.ElkSharp;
internal static partial class ElkEdgePostProcessor
{
internal static bool TryBuildFocusedGatewayJoinTargetRepair(
ElkRoutedEdge edge,
IReadOnlyList<ElkPositionedNode> nodes,
string? preferredSide,
ElkPoint? preferredBoundary,
out List<ElkPoint> candidatePath)
{
candidatePath = [];
if (string.IsNullOrWhiteSpace(edge.TargetNodeId))
{
return false;
}
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
if (!nodesById.TryGetValue(edge.TargetNodeId, out var targetNode)
|| !string.Equals(targetNode.Kind, "Join", StringComparison.Ordinal))
{
return false;
}
var path = ExtractFullPath(edge);
if (path.Count < 2 || ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, path[^1], path[^2]))
{
return false;
}
var side = preferredSide;
if (string.IsNullOrWhiteSpace(side))
{
side = ResolveTargetApproachSide(path, targetNode);
}
if (string.IsNullOrWhiteSpace(preferredSide)
&& TryResolvePreferredGatewayJoinEntrySide(edge, targetNode, nodesById, out var resolvedPreferredSide))
{
side = resolvedPreferredSide;
}
if (side is not ("left" or "right" or "top" or "bottom"))
{
return false;
}
if (preferredBoundary is null
&& !string.IsNullOrWhiteSpace(edge.SourceNodeId)
&& nodesById.TryGetValue(edge.SourceNodeId, out var sourceNode)
&& TryBuildPreferredGatewayJoinShortcut(
edge,
path,
sourceNode,
targetNode,
side,
nodes,
out candidatePath))
{
return true;
}
var desiredEndpoint = preferredBoundary;
if (desiredEndpoint is null)
{
var slotCoordinates = ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates(targetNode, side, 1);
if (slotCoordinates.Length == 0)
{
return false;
}
desiredEndpoint = ElkBoundarySlots.BuildBoundarySlotPoint(targetNode, side, slotCoordinates[0]);
}
var candidatePaths = new List<List<ElkPoint>>();
var exteriorIndex = FindLastGatewayExteriorPointIndex(path, targetNode);
var exteriorAnchor = path[exteriorIndex];
var slottedCandidate = TryBuildSlottedGatewayEntryPath(
path,
targetNode,
exteriorIndex,
exteriorAnchor,
desiredEndpoint);
if (slottedCandidate is not null)
{
candidatePaths.Add(slottedCandidate);
var forcedSlottedCandidate = ForceGatewayExteriorTargetApproach(slottedCandidate, targetNode, desiredEndpoint);
if (PathChanged(slottedCandidate, forcedSlottedCandidate))
{
candidatePaths.Add(forcedSlottedCandidate);
}
}
var targetApproachCandidate = BuildTargetApproachCandidatePath(path, targetNode, side, desiredEndpoint, double.NaN);
candidatePaths.Add(targetApproachCandidate);
var forcedTargetApproachCandidate = ForceGatewayExteriorTargetApproach(targetApproachCandidate, targetNode, desiredEndpoint);
if (PathChanged(targetApproachCandidate, forcedTargetApproachCandidate))
{
candidatePaths.Add(forcedTargetApproachCandidate);
}
var normalizedGatewayCandidate = NormalizeGatewayEntryPath(path, targetNode, desiredEndpoint);
candidatePaths.Add(normalizedGatewayCandidate);
var forcedNormalizedGatewayCandidate = ForceGatewayExteriorTargetApproach(normalizedGatewayCandidate, targetNode, desiredEndpoint);
if (PathChanged(normalizedGatewayCandidate, forcedNormalizedGatewayCandidate))
{
candidatePaths.Add(forcedNormalizedGatewayCandidate);
}
var directForcedCandidate = ForceGatewayExteriorTargetApproach(path, targetNode, desiredEndpoint);
if (PathChanged(path, directForcedCandidate))
{
candidatePaths.Add(directForcedCandidate);
}
var bestCandidate = default(List<ElkPoint>);
var bestScore = double.PositiveInfinity;
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var candidate in candidatePaths)
{
if (!PathChanged(path, candidate)
|| !CanAcceptGatewayTargetRepair(candidate, targetNode)
|| !HasAcceptableGatewayBoundaryPath(candidate, nodes, edge.SourceNodeId, edge.TargetNodeId, targetNode, fromStart: false))
{
continue;
}
var signature = string.Join(";", candidate.Select(point => $"{point.X:F3},{point.Y:F3}"));
if (!seen.Add(signature))
{
continue;
}
var candidateEdge = BuildSingleSectionEdge(edge, candidate);
if (ElkEdgeRoutingScoring.CountEdgeNodeCrossings([candidateEdge], nodes, null) > 0)
{
continue;
}
var endpointPenalty =
Math.Abs(candidate[^1].X - desiredEndpoint.X)
+ Math.Abs(candidate[^1].Y - desiredEndpoint.Y);
var pathLength = ElkEdgeRoutingGeometry.ComputePathLength(candidateEdge);
var score = endpointPenalty * 1000d + pathLength;
if (score >= bestScore)
{
continue;
}
bestCandidate = candidate;
bestScore = score;
}
if (bestCandidate is null)
{
return false;
}
candidatePath = bestCandidate;
return true;
}
internal static bool TryBuildFocusedGatewayJoinTargetRepair(
ElkRoutedEdge edge,
IReadOnlyList<ElkPositionedNode> nodes,
out List<ElkPoint> candidatePath)
{
return TryBuildFocusedGatewayJoinTargetRepair(edge, nodes, null, null, out candidatePath);
}
internal static bool TryBuildFocusedDecisionTargetBoundarySlotRepair(
ElkRoutedEdge edge,
IReadOnlyList<ElkPositionedNode> nodes,
string side,
ElkPoint desiredBoundary,
out List<ElkPoint> candidatePath)
{
candidatePath = [];
if (string.IsNullOrWhiteSpace(edge.TargetNodeId))
{
return false;
}
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
if (!nodesById.TryGetValue(edge.TargetNodeId, out var targetNode)
|| !string.Equals(targetNode.Kind, "Decision", StringComparison.Ordinal))
{
return false;
}
var path = ExtractFullPath(edge);
if (path.Count < 2 || side is not ("left" or "right" or "top" or "bottom"))
{
return false;
}
var candidatePaths = new List<List<ElkPoint>>();
var exteriorIndex = FindLastGatewayExteriorPointIndex(path, targetNode);
var exteriorAnchor = path[exteriorIndex];
var slottedCandidate = TryBuildSlottedGatewayEntryPath(
path,
targetNode,
exteriorIndex,
exteriorAnchor,
desiredBoundary);
if (slottedCandidate is not null)
{
candidatePaths.Add(slottedCandidate);
var forcedSlottedCandidate = ForceGatewayExteriorTargetApproach(slottedCandidate, targetNode, desiredBoundary);
if (PathChanged(slottedCandidate, forcedSlottedCandidate))
{
candidatePaths.Add(forcedSlottedCandidate);
}
}
var targetApproachCandidate = BuildTargetApproachCandidatePath(path, targetNode, side, desiredBoundary, double.NaN);
candidatePaths.Add(targetApproachCandidate);
var forcedTargetApproachCandidate = ForceGatewayExteriorTargetApproach(targetApproachCandidate, targetNode, desiredBoundary);
if (PathChanged(targetApproachCandidate, forcedTargetApproachCandidate))
{
candidatePaths.Add(forcedTargetApproachCandidate);
}
var normalizedGatewayCandidate = NormalizeGatewayEntryPath(path, targetNode, desiredBoundary);
candidatePaths.Add(normalizedGatewayCandidate);
var forcedNormalizedGatewayCandidate = ForceGatewayExteriorTargetApproach(normalizedGatewayCandidate, targetNode, desiredBoundary);
if (PathChanged(normalizedGatewayCandidate, forcedNormalizedGatewayCandidate))
{
candidatePaths.Add(forcedNormalizedGatewayCandidate);
}
var directForcedCandidate = ForceGatewayExteriorTargetApproach(path, targetNode, desiredBoundary);
if (PathChanged(path, directForcedCandidate))
{
candidatePaths.Add(directForcedCandidate);
}
var bestCandidate = default(List<ElkPoint>);
var bestScore = double.PositiveInfinity;
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var candidate in candidatePaths)
{
if (!PathChanged(path, candidate)
|| !string.Equals(ResolveTargetApproachSide(candidate, targetNode), side, StringComparison.Ordinal)
|| !CanAcceptGatewayTargetRepair(candidate, targetNode)
|| !HasAcceptableGatewayBoundaryPath(candidate, nodes, edge.SourceNodeId, edge.TargetNodeId, targetNode, fromStart: false))
{
continue;
}
var signature = string.Join(";", candidate.Select(point => $"{point.X:F3},{point.Y:F3}"));
if (!seen.Add(signature))
{
continue;
}
var candidateEdge = BuildSingleSectionEdge(edge, candidate);
if (ElkEdgeRoutingScoring.CountEdgeNodeCrossings([candidateEdge], nodes, null) > 0)
{
continue;
}
var endpointPenalty =
Math.Abs(candidate[^1].X - desiredBoundary.X)
+ Math.Abs(candidate[^1].Y - desiredBoundary.Y);
var pathLength = ElkEdgeRoutingGeometry.ComputePathLength(candidateEdge);
var score = endpointPenalty * 1000d + pathLength;
if (score >= bestScore)
{
continue;
}
bestCandidate = candidate;
bestScore = score;
}
if (bestCandidate is null)
{
return false;
}
candidatePath = bestCandidate;
return true;
}
private static bool TryBuildPreferredGatewayJoinShortcut(
ElkRoutedEdge edge,
IReadOnlyList<ElkPoint> currentPath,
ElkPositionedNode sourceNode,
ElkPositionedNode targetNode,
string targetSide,
IReadOnlyCollection<ElkPositionedNode> nodes,
out List<ElkPoint> candidatePath)
{
candidatePath = [];
var sourceSide = targetSide switch
{
"left" => "right",
"right" => "left",
"top" => "bottom",
"bottom" => "top",
_ => string.Empty,
};
if (string.IsNullOrWhiteSpace(sourceSide)
|| !TryBuildPreferredBoundaryShortcutPath(
sourceNode,
targetNode,
sourceSide,
targetSide,
nodes,
edge.SourceNodeId,
edge.TargetNodeId,
out var shortcut)
|| !PathChanged(currentPath, shortcut))
{
return false;
}
if (!CanAcceptGatewayTargetRepair(shortcut, targetNode)
|| !HasAcceptableGatewayBoundaryPath(shortcut, nodes, edge.SourceNodeId, edge.TargetNodeId, targetNode, fromStart: false)
|| ElkEdgeRoutingScoring.CountEdgeNodeCrossings(
[
BuildSingleSectionEdge(edge, shortcut),
],
nodes,
null) > 0)
{
return false;
}
candidatePath = shortcut;
return true;
}
internal static bool TryBuildCenteredForkWorkBranchDeparture(
ElkRoutedEdge edge,
IReadOnlyCollection<ElkPositionedNode> nodes,
out List<ElkPoint> candidatePath)
{
candidatePath = [];
if (string.IsNullOrWhiteSpace(edge.SourceNodeId))
{
return false;
}
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
if (!nodesById.TryGetValue(edge.SourceNodeId, out var sourceNode)
|| !string.Equals(sourceNode.Kind, "Fork", StringComparison.Ordinal))
{
return false;
}
var path = ExtractFullPath(edge);
if (path.Count < 2)
{
return false;
}
var side = ResolveSourceDepartureSide(path, sourceNode);
if (side is not ("left" or "right" or "top" or "bottom"))
{
return false;
}
var preferredSides = new List<string>();
if (TryResolvePreferredForkWorkBranchDepartureSide(edge, sourceNode, nodesById, out var preferredSide))
{
preferredSides.Add(preferredSide);
}
preferredSides.Add(side);
foreach (var candidateSide in preferredSides
.Where(sideCandidate => sideCandidate is "left" or "right" or "top" or "bottom")
.Distinct(StringComparer.Ordinal))
{
var centerCoordinate = candidateSide is "left" or "right"
? sourceNode.Y + (sourceNode.Height / 2d)
: sourceNode.X + (sourceNode.Width / 2d);
if (!ElkShapeBoundaries.TryProjectGatewayBoundarySlot(sourceNode, candidateSide, centerCoordinate, out var centeredBoundary))
{
continue;
}
centeredBoundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(
sourceNode,
centeredBoundary,
BuildGatewaySideAnchorPoint(sourceNode, candidateSide));
var desiredAxis = TryExtractSourceDepartureRun(path, candidateSide, out _, out var runEndIndex)
? candidateSide is "left" or "right"
? path[runEndIndex].X
: path[runEndIndex].Y
: ResolveDefaultSourceDepartureAxis(sourceNode, candidateSide);
var candidate = BuildSourceDepartureCandidatePath(
path,
sourceNode,
candidateSide,
centeredBoundary,
desiredAxis,
nodes,
edge.SourceNodeId,
edge.TargetNodeId);
if (!PathChanged(path, candidate)
|| !HasAcceptableGatewayBoundaryPath(candidate, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true)
|| ElkEdgeRoutingScoring.CountEdgeNodeCrossings(
[
BuildSingleSectionEdge(edge, candidate),
],
nodes,
null) > 0)
{
continue;
}
candidatePath = candidate;
return true;
}
return false;
}
internal static bool TryBuildFocusedDecisionSourceBoundarySlotRepair(
ElkRoutedEdge edge,
ElkPositionedNode sourceNode,
string side,
ElkPoint boundaryPoint,
IReadOnlyCollection<ElkPositionedNode> nodes,
out List<ElkPoint> candidatePath)
{
candidatePath = [];
if (!string.Equals(sourceNode.Kind, "Decision", StringComparison.Ordinal))
{
return false;
}
var path = ExtractFullPath(edge);
if (path.Count < 2 || side is not ("left" or "right" or "top" or "bottom"))
{
return false;
}
var desiredAxis = TryExtractSourceDepartureRun(path, side, out _, out var runEndIndex)
? side is "left" or "right"
? path[runEndIndex].X
: path[runEndIndex].Y
: ResolveDefaultSourceDepartureAxis(sourceNode, side);
var candidates = new[]
{
BuildStrictSourceDepartureSlotCandidatePath(
path,
sourceNode,
side,
boundaryPoint,
desiredAxis),
BuildSourceDepartureCandidatePath(
path,
sourceNode,
side,
boundaryPoint,
desiredAxis,
nodes,
edge.SourceNodeId,
edge.TargetNodeId),
};
foreach (var candidate in candidates)
{
if (!PathChanged(path, candidate)
|| !string.Equals(ResolveSourceDepartureSide(candidate, sourceNode), side, StringComparison.Ordinal)
|| !HasAcceptableGatewayBoundaryPath(candidate, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true))
{
continue;
}
var candidateEdge = BuildSingleSectionEdge(edge, candidate);
if (ElkEdgeRoutingScoring.CountEdgeNodeCrossings([candidateEdge], nodes, null) > 0
|| ElkEdgeRoutingScoring.CountGatewaySourceExitViolations([candidateEdge], nodes) > 0)
{
continue;
}
candidatePath = candidate;
return true;
}
return false;
}
internal static bool TryBuildForkBypassDepartureAwayFromPrimaryAxis(
ElkRoutedEdge edge,
ElkPositionedNode sourceNode,
ElkPositionedNode targetNode,
ElkPositionedNode workTargetNode,
IReadOnlyCollection<ElkPositionedNode> nodes,
double minLineClearance,
out List<ElkPoint> candidatePath)
{
candidatePath = [];
if (!string.Equals(sourceNode.Kind, "Fork", StringComparison.Ordinal)
|| !string.Equals(targetNode.Kind, "Join", StringComparison.Ordinal))
{
return false;
}
var path = ExtractFullPath(edge);
if (path.Count < 2)
{
return false;
}
var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d);
var workCenterY = workTargetNode.Y + (workTargetNode.Height / 2d);
var preferredSide = workCenterY >= sourceCenterY ? "top" : "bottom";
var candidateSides = new[] { preferredSide, preferredSide == "top" ? "bottom" : "top" };
const double coordinateTolerance = 0.5d;
foreach (var candidateSide in candidateSides)
{
var sourceReference = new ElkPoint
{
X = targetNode.X + (targetNode.Width / 2d),
Y = targetNode.Y + (targetNode.Height / 2d),
};
var sourceCoordinate = sourceNode.X + (sourceNode.Width / 2d);
var targetCoordinate = targetNode.X + (targetNode.Width / 2d);
if ((!ElkShapeBoundaries.TryProjectGatewayBoundarySlot(sourceNode, candidateSide, sourceCoordinate, out var sourceBoundary)
&& !TryResolvePreferredGatewaySourceBoundary(sourceNode, sourceReference, sourceReference, out sourceBoundary))
|| !ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, candidateSide, targetCoordinate, out var targetBoundary))
{
continue;
}
sourceBoundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(
sourceNode,
sourceBoundary,
BuildGatewaySideAnchorPoint(sourceNode, candidateSide));
targetBoundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(
targetNode,
targetBoundary,
BuildGatewaySideAnchorPoint(targetNode, candidateSide));
var corridorY = candidateSide == "top"
? Math.Min(sourceNode.Y, targetNode.Y) - Math.Max(32d, minLineClearance + 12d)
: Math.Max(sourceNode.Y + sourceNode.Height, targetNode.Y + targetNode.Height) + Math.Max(32d, minLineClearance + 12d);
var candidate = new List<ElkPoint>
{
new() { X = sourceBoundary.X, Y = sourceBoundary.Y },
};
if (Math.Abs(candidate[^1].Y - corridorY) > coordinateTolerance)
{
candidate.Add(new ElkPoint { X = candidate[^1].X, Y = corridorY });
}
if (Math.Abs(candidate[^1].X - targetBoundary.X) > coordinateTolerance)
{
candidate.Add(new ElkPoint { X = targetBoundary.X, Y = corridorY });
}
if (!ElkEdgeRoutingGeometry.PointsEqual(candidate[^1], targetBoundary))
{
candidate.Add(new ElkPoint { X = targetBoundary.X, Y = targetBoundary.Y });
}
candidate = NormalizeOrthogonalPath(candidate, coordinateTolerance);
if (!PathChanged(path, candidate)
|| !HasAcceptableGatewayBoundaryPath(candidate, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true)
|| !HasAcceptableGatewayBoundaryPath(candidate, nodes, edge.SourceNodeId, edge.TargetNodeId, targetNode, fromStart: false)
|| ElkEdgeRoutingScoring.CountEdgeNodeCrossings(
[
BuildSingleSectionEdge(edge, candidate),
],
nodes,
null) > 0)
{
continue;
}
candidatePath = candidate;
return true;
}
return false;
}
private static bool TryResolvePreferredForkWorkBranchDepartureSide(
ElkRoutedEdge edge,
ElkPositionedNode sourceNode,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
out string preferredSide)
{
preferredSide = string.Empty;
if (string.IsNullOrWhiteSpace(edge.TargetNodeId)
|| !nodesById.TryGetValue(edge.TargetNodeId, out var targetNode)
|| string.Equals(targetNode.Kind, "Join", StringComparison.Ordinal))
{
return false;
}
var sourceCenterX = sourceNode.X + (sourceNode.Width / 2d);
var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d);
var targetCenterX = targetNode.X + (targetNode.Width / 2d);
var targetCenterY = targetNode.Y + (targetNode.Height / 2d);
var deltaX = targetCenterX - sourceCenterX;
var deltaY = targetCenterY - sourceCenterY;
var absDx = Math.Abs(deltaX);
var absDy = Math.Abs(deltaY);
if (absDx >= absDy * 0.85d
&& Math.Sign(deltaX) != 0)
{
preferredSide = deltaX > 0d ? "right" : "left";
return true;
}
if (Math.Sign(deltaY) != 0)
{
preferredSide = deltaY > 0d ? "bottom" : "top";
return true;
}
return false;
}
private static ElkPoint BuildGatewaySideAnchorPoint(
ElkPositionedNode node,
string side)
{
var centerX = node.X + (node.Width / 2d);
var centerY = node.Y + (node.Height / 2d);
return side switch
{
"left" => new ElkPoint { X = node.X - 48d, Y = centerY },
"right" => new ElkPoint { X = node.X + node.Width + 48d, Y = centerY },
"top" => new ElkPoint { X = centerX, Y = node.Y - 48d },
"bottom" => new ElkPoint { X = centerX, Y = node.Y + node.Height + 48d },
_ => new ElkPoint { X = centerX, Y = centerY },
};
}
private static bool TryResolvePreferredGatewayJoinEntrySide(
ElkRoutedEdge edge,
ElkPositionedNode targetNode,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
out string preferredSide)
{
preferredSide = string.Empty;
if (!string.Equals(targetNode.Kind, "Join", StringComparison.Ordinal)
|| string.IsNullOrWhiteSpace(edge.SourceNodeId)
|| !nodesById.TryGetValue(edge.SourceNodeId, out var sourceNode))
{
return false;
}
var sourceCenterX = sourceNode.X + (sourceNode.Width / 2d);
var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d);
var targetCenterX = targetNode.X + (targetNode.Width / 2d);
var targetCenterY = targetNode.Y + (targetNode.Height / 2d);
var deltaX = targetCenterX - sourceCenterX;
var deltaY = targetCenterY - sourceCenterY;
var absDx = Math.Abs(deltaX);
var absDy = Math.Abs(deltaY);
var sameRowThreshold = Math.Max(48d, targetNode.Height);
var sameColumnThreshold = Math.Max(48d, targetNode.Width);
if (absDx >= absDy * 1.15d
&& absDy <= sameRowThreshold
&& Math.Sign(deltaX) != 0)
{
preferredSide = deltaX > 0d ? "left" : "right";
return true;
}
if (absDy >= absDx * 1.15d
&& absDx <= sameColumnThreshold
&& Math.Sign(deltaY) != 0)
{
preferredSide = deltaY > 0d ? "top" : "bottom";
return true;
}
return false;
}
}

View File

@@ -327,9 +327,15 @@ internal static partial class ElkEdgePostProcessor
path.RemoveAt(path.Count - 2);
}
var anchor = path[^2];
var anchorIndex = path.Count - 2;
if (anchorIndex > 0 && !IsOrthogonal(path[anchorIndex - 1], path[anchorIndex]))
{
anchorIndex--;
}
var anchor = path[anchorIndex];
var endpoint = explicitEndpoint ?? BuildRectBoundaryPointForSide(targetNode, side, anchor);
var rebuilt = path.Take(path.Count - 2).ToList();
var rebuilt = path.Take(anchorIndex).ToList();
if (rebuilt.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], anchor))
{
rebuilt.Add(anchor);
@@ -360,9 +366,15 @@ internal static partial class ElkEdgePostProcessor
path.RemoveAt(path.Count - 2);
}
var verticalAnchor = path[^2];
var anchorIndexY = path.Count - 2;
if (anchorIndexY > 0 && !IsOrthogonal(path[anchorIndexY - 1], path[anchorIndexY]))
{
anchorIndexY--;
}
var verticalAnchor = path[anchorIndexY];
var verticalEndpoint = explicitEndpoint ?? BuildRectBoundaryPointForSide(targetNode, side, verticalAnchor);
var verticalRebuilt = path.Take(path.Count - 2).ToList();
var verticalRebuilt = path.Take(anchorIndexY).ToList();
if (verticalRebuilt.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(verticalRebuilt[^1], verticalAnchor))
{
verticalRebuilt.Add(verticalAnchor);

View File

@@ -20,7 +20,8 @@ internal static partial class ElkEdgePostProcessor
nodes,
sourceNodeId,
targetNodeId);
return IsMaterialGatewaySourceRepairImprovement(sourcePath, candidate);
return IsMaterialGatewaySourceRepairImprovement(sourcePath, candidate)
&& !ShouldSuppressGatewaySourceOptimization(sourcePath, candidate, sourceNode, nodes);
}
internal static bool TryBuildGatewaySourceScoringCandidate(
@@ -89,6 +90,12 @@ internal static partial class ElkEdgePostProcessor
return false;
}
if (ShouldSuppressGatewaySourceOptimization(sourcePath, candidate, sourceNode, nodes))
{
candidate = [];
return false;
}
var lengthGain = ComputePathLength(sourcePath) - ComputePathLength(candidate);
var originalBends = Math.Max(0, sourcePath.Count - 2);
var candidateBends = Math.Max(0, candidate.Count - 2);
@@ -117,6 +124,16 @@ internal static partial class ElkEdgePostProcessor
out _);
}
internal static bool HasProblematicGatewaySourceVertexExit(
IReadOnlyList<ElkPoint> path,
ElkPositionedNode sourceNode)
{
return ElkShapeBoundaries.IsGatewayShape(sourceNode)
&& path.Count >= 2
&& ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, path[0])
&& !HasCleanOrthogonalGatewaySourceDeparture(path, sourceNode);
}
private static bool IsPathClearOfObstacles(
IReadOnlyList<ElkPoint> path,
(double Left, double Top, double Right, double Bottom, string Id)[] obstacles,
@@ -350,6 +367,68 @@ internal static partial class ElkEdgePostProcessor
return candidateLength <= originalLength + 120d;
}
private static bool ShouldSuppressGatewaySourceOptimization(
IReadOnlyList<ElkPoint> originalPath,
IReadOnlyList<ElkPoint> candidate,
ElkPositionedNode sourceNode,
IReadOnlyCollection<ElkPositionedNode> nodes)
{
if (!PathChanged(originalPath, candidate) || !ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
return false;
}
if (HasProtectedGatewaySourceCorridorPath(originalPath, nodes)
&& !HasGatewaySourceExitBacktracking(originalPath)
&& !HasGatewaySourceExitCurl(originalPath))
{
return true;
}
if (HasCleanOrthogonalGatewaySourceDeparture(originalPath, sourceNode)
&& !HasCleanOrthogonalGatewaySourceDeparture(candidate, sourceNode))
{
return true;
}
var originalBends = Math.Max(0, originalPath.Count - 2);
var candidateBends = Math.Max(0, candidate.Count - 2);
var lengthGain = ComputePathLength(originalPath) - ComputePathLength(candidate);
var onlyDecisionFacePreferenceDefect = sourceNode.Kind == "Decision"
&& NeedsDecisionSourcePreferredFaceRepair(originalPath, sourceNode)
&& !HasGatewaySourcePreferredFaceMismatch(originalPath, sourceNode)
&& !HasGatewaySourceDominantAxisDetour(originalPath, sourceNode)
&& !HasGatewaySourceExitBacktracking(originalPath)
&& !HasGatewaySourceExitCurl(originalPath);
return HasCleanOrthogonalGatewaySourceDeparture(originalPath, sourceNode)
&& onlyDecisionFacePreferenceDefect
&& candidateBends >= originalBends
&& lengthGain < 40d;
}
private static bool HasCleanOrthogonalGatewaySourceDeparture(
IReadOnlyList<ElkPoint> path,
ElkPositionedNode sourceNode)
{
if (!ElkShapeBoundaries.IsGatewayShape(sourceNode) || path.Count < 2)
{
return false;
}
const double tolerance = 0.5d;
var boundary = path[0];
var adjacent = path[1];
var side = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(boundary, adjacent, sourceNode);
return side switch
{
"left" => adjacent.X < boundary.X - tolerance && Math.Abs(adjacent.Y - boundary.Y) <= tolerance,
"right" => adjacent.X > boundary.X + tolerance && Math.Abs(adjacent.Y - boundary.Y) <= tolerance,
"top" => adjacent.Y < boundary.Y - tolerance && Math.Abs(adjacent.X - boundary.X) <= tolerance,
"bottom" => adjacent.Y > boundary.Y + tolerance && Math.Abs(adjacent.X - boundary.X) <= tolerance,
_ => false,
};
}
private static bool ShouldSuppressGatewaySourceScoringCandidateForResolvedSingletonSlot(
IReadOnlyList<ElkPoint> originalPath,
IReadOnlyList<ElkPoint> candidate,

View File

@@ -36,9 +36,9 @@ internal static partial class ElkEdgePostProcessor
return path;
}
preferredBoundary = PreferGatewaySourceExitBoundary(sourceNode, preferredBoundary, path[^1]);
var firstExteriorIndex = FindFirstGatewayExteriorPointIndex(path, sourceNode);
var continuationPoint = path[firstExteriorIndex];
var continuationIndex = FindPreferredGatewayExitContinuationIndex(path, sourceNode, firstExteriorIndex);
var continuationPoint = path[continuationIndex];
var adjacentPoint = ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, preferredBoundary, continuationPoint);
if (dominantHorizontal)
@@ -93,7 +93,7 @@ internal static partial class ElkEdgePostProcessor
rebuilt,
rebuilt[^1],
continuationPoint,
firstExteriorIndex + 1 < path.Count ? path[firstExteriorIndex + 1] : null,
continuationIndex + 1 < path.Count ? path[continuationIndex + 1] : null,
preferHorizontalFromReference: ShouldPreferHorizontalGatewayExit(rebuilt[^1], continuationPoint));
if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], continuationPoint))
{
@@ -101,7 +101,7 @@ internal static partial class ElkEdgePostProcessor
}
}
for (var i = firstExteriorIndex + 1; i < path.Count; i++)
for (var i = continuationIndex + 1; i < path.Count; i++)
{
rebuilt.Add(path[i]);
}

View File

@@ -2,6 +2,41 @@ namespace StellaOps.ElkSharp;
internal static partial class ElkEdgePostProcessor
{
internal static bool TryBuildGatewaySourceQualityCandidate(
IReadOnlyList<ElkPoint> sourcePath,
ElkPositionedNode sourceNode,
IReadOnlyCollection<ElkPositionedNode> nodes,
string? sourceNodeId,
string? targetNodeId,
out List<ElkPoint> candidate)
{
candidate = [];
if (!ElkShapeBoundaries.IsGatewayShape(sourceNode) || sourcePath.Count < 2)
{
return false;
}
candidate = EnforceGatewaySourceExitQuality(
sourcePath,
sourceNode,
nodes,
sourceNodeId,
targetNodeId);
if (!PathChanged(sourcePath, candidate)
|| !HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true)
|| HasGatewaySourceExitBacktracking(candidate)
|| HasGatewaySourceExitCurl(candidate)
|| HasGatewaySourceDominantAxisDetour(candidate, sourceNode)
|| HasGatewaySourcePreferredFaceMismatch(candidate, sourceNode)
|| HasGatewaySourceLeadIntoDominantBlocker(candidate, sourceNode, nodes, sourceNodeId, targetNodeId))
{
candidate = [];
return false;
}
return true;
}
private static List<ElkPoint> EnforceGatewaySourceExitQuality(
IReadOnlyList<ElkPoint> sourcePath,
ElkPositionedNode sourceNode,
@@ -105,6 +140,7 @@ internal static partial class ElkEdgePostProcessor
ConsiderCandidate(scoringCandidate);
ConsiderCandidate(directDominantCandidate);
ConsiderCandidate(TryBuildGatewaySourceTargetAnchoredPath(path, sourceNode, nodes, sourceNodeId, targetNodeId));
ConsiderCandidate(TryBuildDirectGatewaySourcePath(path, sourceNode, nodes, sourceNodeId, targetNodeId));
ConsiderCandidate(ForceGatewaySourcePreferredFaceAlignment(path, sourceNode));
ConsiderCandidate(FixGatewaySourceDominantAxisDetour(path, sourceNode));
@@ -113,6 +149,108 @@ internal static partial class ElkEdgePostProcessor
return bestCandidate ?? path;
}
internal static List<ElkPoint> TryBuildGatewaySourceTargetAnchoredPath(
IReadOnlyList<ElkPoint> sourcePath,
ElkPositionedNode sourceNode,
IReadOnlyCollection<ElkPositionedNode> nodes,
string? sourceNodeId,
string? targetNodeId)
{
var path = sourcePath
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToList();
if (!ElkShapeBoundaries.IsGatewayShape(sourceNode)
|| path.Count < 3
|| string.IsNullOrWhiteSpace(targetNodeId))
{
return path;
}
var targetNode = nodes.FirstOrDefault(node => string.Equals(node.Id, targetNodeId, StringComparison.Ordinal));
if (targetNode is null)
{
return path;
}
var anchorIndex = path.Count - 2;
var anchorPoint = path[anchorIndex];
if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(sourceNode, anchorPoint))
{
return path;
}
var referencePoint = anchorPoint;
if (!TryResolvePreferredGatewaySourceBoundary(sourceNode, anchorPoint, referencePoint, out var boundary))
{
return path;
}
var rebuilt = new List<ElkPoint> { boundary };
var exteriorApproach = ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, boundary, anchorPoint);
if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], exteriorApproach))
{
rebuilt.Add(exteriorApproach);
}
if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], anchorPoint))
{
AppendGatewayOrthogonalCorner(
rebuilt,
rebuilt[^1],
anchorPoint,
anchorIndex + 1 < path.Count ? path[anchorIndex + 1] : null,
preferHorizontalFromReference: ShouldPreferHorizontalGatewayExit(rebuilt[^1], anchorPoint));
if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], anchorPoint))
{
rebuilt.Add(anchorPoint);
}
}
for (var i = anchorIndex + 1; i < path.Count; i++)
{
rebuilt.Add(path[i]);
}
var candidate = NormalizePathPoints(rebuilt);
if (!PathChanged(path, candidate))
{
return path;
}
if (!TryNormalizeTargetBoundaryAfterSourceRepair(
candidate,
nodes,
sourceNodeId,
targetNodeId,
out candidate))
{
return path;
}
if (!HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true)
|| HasGatewaySourceExitBacktracking(candidate)
|| HasGatewaySourceExitCurl(candidate)
|| HasGatewaySourceDominantAxisDetour(candidate, sourceNode)
|| HasGatewaySourcePreferredFaceMismatch(candidate, sourceNode))
{
return path;
}
if (ElkShapeBoundaries.IsGatewayShape(targetNode))
{
if (!HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, targetNode, fromStart: false))
{
return path;
}
}
else if (candidate.Count < 2 || !HasValidBoundaryAngle(candidate[^1], candidate[^2], targetNode))
{
return path;
}
return candidate;
}
private static List<ElkPoint> RefineGatewaySourceScoringCandidate(
IReadOnlyList<ElkPoint> sourcePath,
ElkPositionedNode sourceNode,

View File

@@ -122,12 +122,11 @@ internal static partial class ElkEdgePostProcessor
}
var corridorPoint = path[corridorIndex];
var boundary = sourceNode.Kind == "Decision"
? ResolveDecisionSourceExitBoundary(sourceNode, corridorPoint, corridorPoint)
: PreferGatewaySourceExitBoundary(
sourceNode,
ElkShapeBoundaries.ProjectOntoShapeBoundary(sourceNode, corridorPoint),
corridorPoint);
var referencePoint = path[^1];
if (!TryResolvePreferredGatewaySourceBoundary(sourceNode, corridorPoint, referencePoint, out var boundary))
{
return path;
}
return BuildGatewaySourceRepairPath(
path,
@@ -135,7 +134,7 @@ internal static partial class ElkEdgePostProcessor
boundary,
corridorPoint,
corridorIndex,
corridorPoint);
referencePoint);
}
private static List<ElkPoint> TryBuildProtectedGatewaySourcePath(

View File

@@ -24,7 +24,9 @@ internal static partial class ElkEdgePostProcessor
ElkPoint boundary;
var assignedEndpointUsable = ElkShapeBoundaries.IsGatewayBoundaryPoint(targetNode, assignedEndpoint)
&& !ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, assignedApproach)
&& !ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, actualAdjacent)
&& ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, assignedEndpoint, assignedApproach)
&& ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, assignedEndpoint, actualAdjacent)
&& !ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, exteriorAnchor);
if (assignedEndpointUsable)
{

View File

@@ -341,7 +341,8 @@ internal static partial class ElkEdgePostProcessor
}
}
if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out sourceNode)
if (!preserveSourceExit
&& nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out sourceNode)
&& ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
var lateSourceRepaired = RepairGatewaySourceBoundaryPath(

View File

@@ -101,7 +101,8 @@ internal static partial class ElkEdgePostProcessor
edge.TargetNodeId);
}
if (ElkShapeBoundaries.IsGatewayShape(sourceNode))
if (ElkShapeBoundaries.IsGatewayShape(sourceNode)
&& !preserveSourceExit)
{
normalized = EnforceGatewaySourceExitQuality(
normalized,

View File

@@ -0,0 +1,522 @@
namespace StellaOps.ElkSharp;
internal static partial class ElkEdgePostProcessor
{
internal static ElkRoutedEdge[] NormalizeDecisionTimerSetterFamilies(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
double minLineClearance,
IReadOnlyCollection<string>? restrictedTargetNodeIds = null)
{
if (edges.Length < 2 || nodes.Length == 0)
{
return edges;
}
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
var restrictedTargets = restrictedTargetNodeIds is not null && restrictedTargetNodeIds.Count > 0
? restrictedTargetNodeIds.ToHashSet(StringComparer.Ordinal)
: null;
var graphMinY = nodes.Min(node => node.Y);
var graphMaxY = nodes.Max(node => node.Y + node.Height);
var result = edges.ToArray();
var changed = false;
var groups = result
.Select((edge, index) => new
{
Edge = edge,
Index = index,
Path = ExtractFullPath(edge),
})
.Where(item =>
item.Path.Count >= 2
&& !string.IsNullOrWhiteSpace(item.Edge.SourceNodeId)
&& !string.IsNullOrWhiteSpace(item.Edge.TargetNodeId)
&& nodesById.TryGetValue(item.Edge.SourceNodeId, out var sourceNode)
&& nodesById.TryGetValue(item.Edge.TargetNodeId, out var targetNode)
&& (restrictedTargets is null || restrictedTargets.Contains(item.Edge.TargetNodeId!))
&& string.Equals(targetNode.Kind, "SetState", StringComparison.Ordinal)
&& sourceNode.Kind is "Decision" or "Timer")
.GroupBy(item => item.Edge.TargetNodeId!, StringComparer.Ordinal);
foreach (var group in groups)
{
if (!nodesById.TryGetValue(group.Key, out var targetNode))
{
continue;
}
var entries = group
.Select(item =>
{
var path = item.Path;
var side = ResolveSemanticTargetApproachSide(
item.Edge,
path,
targetNode,
nodesById,
graphMinY,
graphMaxY);
if (side is not ("left" or "right")
|| string.IsNullOrWhiteSpace(item.Edge.SourceNodeId)
|| !nodesById.TryGetValue(item.Edge.SourceNodeId, out var sourceNode))
{
return null;
}
var departureX = path.Count >= 2
? ResolveSetterFamilyDepartureX(path, sourceNode, targetNode)
: side == "left"
? sourceNode.X + sourceNode.Width + 24d
: sourceNode.X - 24d;
return new
{
item.Edge,
item.Index,
item.Path,
SourceNode = sourceNode,
Side = side,
DepartureX = departureX,
EndpointCoordinate = side is "left" or "right" ? path[^1].Y : path[^1].X,
};
})
.Where(entry => entry is not null)
.Select(entry => entry!)
.ToArray();
if (entries.Length < 2)
{
continue;
}
var side = entries[0].Side;
if (entries.Any(entry => !string.Equals(entry.Side, side, StringComparison.Ordinal)))
{
continue;
}
var approachX = side == "left"
? targetNode.X - 24d
: targetNode.X + targetNode.Width + 24d;
var laneMinX = Math.Min(entries.Min(entry => entry.DepartureX), approachX);
var laneMaxX = Math.Max(entries.Max(entry => entry.DepartureX), approachX);
var laneTop = entries.Min(entry => Math.Min(entry.Path[0].Y, entry.Path[^1].Y)) - 16d;
var laneBottom = entries.Max(entry => Math.Max(entry.Path[0].Y, entry.Path[^1].Y)) + 16d;
var familyIds = entries
.Select(entry => entry.Edge.SourceNodeId)
.Append(group.Key)
.Where(id => !string.IsNullOrWhiteSpace(id))
.ToHashSet(StringComparer.Ordinal);
var familyTop = entries
.Select(entry => Math.Min(entry.SourceNode.Y, targetNode.Y))
.Append(targetNode.Y)
.Min();
var blockerTop = nodes
.Where(node =>
!familyIds.Contains(node.Id)
&& node.X < laneMaxX - 1d
&& node.X + node.Width > laneMinX + 1d
&& node.Y < laneBottom
&& node.Y + node.Height > laneTop)
.Select(node => node.Y)
.DefaultIfEmpty(double.NaN)
.Min();
var orderedEntries = entries
.OrderBy(entry => entry.EndpointCoordinate)
.ThenBy(entry => entry.Edge.Id, StringComparer.Ordinal)
.ToArray();
var timerBandCandidates = orderedEntries
.Where(entry => string.Equals(entry.SourceNode.Kind, "Timer", StringComparison.Ordinal))
.Select(entry =>
TryResolveDominantHorizontalRun(entry.Path, out var railY, out var runLength) && runLength >= 24d
? railY
: entry.SourceNode.Y + (entry.SourceNode.Height / 2d))
.ToArray();
var preferredTimerBandY = timerBandCandidates.Length > 0
? timerBandCandidates.Average()
: double.NaN;
var bandClearance = Math.Max(32d, minLineClearance + 8d);
var clearanceTop = double.IsNaN(blockerTop)
? familyTop
: blockerTop;
var bandY = clearanceTop - bandClearance;
if (!double.IsNaN(preferredTimerBandY))
{
bandY = Math.Max(bandY, preferredTimerBandY);
}
var trunkSpacing = Math.Max(
24d,
ElkBoundarySlots.ResolveRequiredBoundarySlotGap(
targetNode,
side,
orderedEntries.Length,
minLineClearance));
var assignedSlotCoordinates = ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates(
targetNode,
side,
orderedEntries.Select(entry => entry.EndpointCoordinate).ToArray());
for (var i = 0; i < orderedEntries.Length; i++)
{
var entry = orderedEntries[i];
var desiredEndpoint = ElkBoundarySlots.BuildBoundarySlotPoint(targetNode, side, assignedSlotCoordinates[i]);
var feederTrunkX = side == "left"
? approachX - ((i + 1) * trunkSpacing)
: approachX + ((i + 1) * trunkSpacing);
var candidatePaths = new List<List<ElkPoint>>();
if (string.Equals(entry.SourceNode.Kind, "Timer", StringComparison.Ordinal)
&& TryBuildDirectTimerSetterFamilyPath(
entry.Path,
entry.DepartureX,
side,
feederTrunkX,
desiredEndpoint,
out var timerContinuation))
{
candidatePaths.Add(timerContinuation);
}
else
{
candidatePaths.Add(BuildDecisionTimerSetterFamilyPath(
entry.Path,
entry.DepartureX,
feederTrunkX,
bandY,
desiredEndpoint));
if (string.Equals(entry.SourceNode.Kind, "Decision", StringComparison.Ordinal))
{
var laneShift = Math.Max(16d, Math.Min(40d, minLineClearance * 0.45d));
var alternateBandYs = new List<double>();
if (!double.IsNaN(preferredTimerBandY))
{
alternateBandYs.Add(preferredTimerBandY - laneShift);
alternateBandYs.Add(preferredTimerBandY + laneShift);
alternateBandYs.Add(preferredTimerBandY + (laneShift * 2d));
}
else
{
var maxBandY = Math.Min(targetNode.Y - 24d, entry.SourceNode.Y + 24d);
alternateBandYs.Add(Math.Min(maxBandY, bandY + laneShift));
alternateBandYs.Add(Math.Min(maxBandY, bandY + (laneShift * 2d)));
}
foreach (var alternateBandY in alternateBandYs
.Where(value => Math.Abs(value - bandY) > 0.5d)
.Distinct())
{
candidatePaths.Add(BuildDecisionTimerSetterFamilyPath(
entry.Path,
entry.DepartureX,
feederTrunkX,
alternateBandY,
desiredEndpoint));
}
}
}
var baselineSharedLaneConflicts = CountSharedLaneConflictsForEdge(result, nodes, entry.Edge.Id);
var baselineBrokenHighways = CountBrokenSetterFamilyHighwaysForTarget(result, nodes, group.Key);
var baselineBandDetour = HasSetterFamilyBandDetour(entry.Path, entry.SourceNode, targetNode, preferredTimerBandY);
ElkRoutedEdge? preferredCandidate = null;
var preferredSharedLaneConflicts = int.MaxValue;
var preferredBrokenHighways = int.MaxValue;
var preferredBandDetour = true;
var preferredPathLength = double.MaxValue;
foreach (var candidatePath in candidatePaths)
{
if (!PathChanged(entry.Path, candidatePath))
{
continue;
}
var candidateEdge = BuildSingleSectionEdge(entry.Edge, candidatePath);
if (HasNodeObstacleCrossing(candidatePath, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId)
|| ElkEdgeRoutingScoring.CountEdgeNodeCrossings([candidateEdge], nodes, null) > 0
|| ElkEdgeRoutingScoring.CountUnderNodeViolations([candidateEdge], nodes) > 0)
{
continue;
}
var candidateEdges = result.ToArray();
candidateEdges[entry.Index] = candidateEdge;
var sharedLaneConflicts = CountSharedLaneConflictsForEdge(candidateEdges, nodes, entry.Edge.Id);
var brokenHighways = CountBrokenSetterFamilyHighwaysForTarget(candidateEdges, nodes, group.Key);
var bandDetour = HasSetterFamilyBandDetour(candidatePath, entry.SourceNode, targetNode, preferredTimerBandY);
var candidatePathLength = ElkEdgeRoutingGeometry.ComputePathLength(candidateEdge);
if (brokenHighways < preferredBrokenHighways
|| (brokenHighways == preferredBrokenHighways
&& preferredBandDetour
&& !bandDetour)
|| (brokenHighways == preferredBrokenHighways
&& preferredBandDetour == bandDetour
&& sharedLaneConflicts < preferredSharedLaneConflicts)
|| (brokenHighways == preferredBrokenHighways
&& preferredBandDetour == bandDetour
&& sharedLaneConflicts == preferredSharedLaneConflicts
&& candidatePathLength < preferredPathLength - 0.5d))
{
preferredCandidate = candidateEdge;
preferredBrokenHighways = brokenHighways;
preferredBandDetour = bandDetour;
preferredSharedLaneConflicts = sharedLaneConflicts;
preferredPathLength = candidatePathLength;
}
}
if (preferredCandidate is null
|| (preferredBrokenHighways > baselineBrokenHighways)
|| (preferredBrokenHighways == baselineBrokenHighways
&& preferredBandDetour
&& !baselineBandDetour)
|| (preferredBrokenHighways == baselineBrokenHighways
&& preferredBandDetour == baselineBandDetour
&& preferredSharedLaneConflicts > baselineSharedLaneConflicts))
{
continue;
}
result[entry.Index] = preferredCandidate;
changed = true;
}
}
return changed ? result : edges;
}
private static List<ElkPoint> BuildDecisionTimerSetterFamilyPath(
IReadOnlyList<ElkPoint> path,
double departureX,
double approachX,
double bandY,
ElkPoint desiredEndpoint)
{
const double coordinateTolerance = 0.5d;
var rebuilt = BuildPreferredSetterSourceDeparturePrefix(
departureX,
bandY,
path,
coordinateTolerance);
if (Math.Abs(rebuilt[^1].X - approachX) > coordinateTolerance)
{
rebuilt.Add(new ElkPoint { X = approachX, Y = bandY });
}
if (Math.Abs(rebuilt[^1].Y - desiredEndpoint.Y) > coordinateTolerance)
{
rebuilt.Add(new ElkPoint { X = approachX, Y = desiredEndpoint.Y });
}
rebuilt.Add(new ElkPoint { X = desiredEndpoint.X, Y = desiredEndpoint.Y });
return NormalizeOrthogonalPath(rebuilt, coordinateTolerance);
}
private static List<ElkPoint> BuildPreferredSetterSourceDeparturePrefix(
double departureX,
double bandY,
IReadOnlyList<ElkPoint> path,
double coordinateTolerance)
{
var rebuilt = new List<ElkPoint>
{
new ElkPoint { X = path[0].X, Y = path[0].Y },
};
if (Math.Abs(rebuilt[^1].X - departureX) > coordinateTolerance)
{
rebuilt.Add(new ElkPoint { X = departureX, Y = rebuilt[^1].Y });
}
if (Math.Abs(rebuilt[^1].Y - bandY) > coordinateTolerance)
{
rebuilt.Add(new ElkPoint { X = departureX, Y = bandY });
}
return rebuilt;
}
private static bool TryBuildDirectTimerSetterFamilyPath(
IReadOnlyList<ElkPoint> path,
double departureX,
string side,
double feederTrunkX,
ElkPoint desiredEndpoint,
out List<ElkPoint> candidatePath)
{
const double coordinateTolerance = 0.5d;
var railX = side switch
{
"left" => Math.Min(
Math.Max(feederTrunkX, desiredEndpoint.X - 18d),
desiredEndpoint.X - 6d),
"right" => Math.Max(
Math.Min(feederTrunkX, desiredEndpoint.X + 18d),
desiredEndpoint.X + 6d),
_ => feederTrunkX,
};
candidatePath =
[
new ElkPoint { X = path[0].X, Y = path[0].Y },
];
if (Math.Abs(candidatePath[^1].X - departureX) > coordinateTolerance)
{
candidatePath.Add(new ElkPoint { X = departureX, Y = candidatePath[^1].Y });
}
if (Math.Abs(candidatePath[^1].X - railX) > coordinateTolerance)
{
candidatePath.Add(new ElkPoint { X = railX, Y = candidatePath[^1].Y });
}
if (Math.Abs(candidatePath[^1].Y - desiredEndpoint.Y) > coordinateTolerance)
{
candidatePath.Add(new ElkPoint { X = railX, Y = desiredEndpoint.Y });
}
candidatePath.Add(new ElkPoint { X = desiredEndpoint.X, Y = desiredEndpoint.Y });
candidatePath = NormalizeOrthogonalPath(candidatePath, coordinateTolerance);
return candidatePath.Count >= 2 && PathChanged(path, candidatePath);
}
private static double ResolveSetterFamilyDepartureX(
IReadOnlyList<ElkPoint> path,
ElkPositionedNode sourceNode,
ElkPositionedNode targetNode)
{
const double departureClearance = 24d;
var sourceCenterX = sourceNode.X + (sourceNode.Width / 2d);
var targetCenterX = targetNode.X + (targetNode.Width / 2d);
if (targetCenterX >= sourceCenterX + 1d)
{
return sourceNode.X + sourceNode.Width + departureClearance;
}
if (targetCenterX <= sourceCenterX - 1d)
{
return sourceNode.X - departureClearance;
}
var sourceSide = ResolveSourceDepartureSide(path, sourceNode);
return sourceSide switch
{
"right" => Math.Max(path[0].X + departureClearance, sourceNode.X + sourceNode.Width + departureClearance),
"left" => Math.Min(path[0].X - departureClearance, sourceNode.X - departureClearance),
_ => path.Count >= 2 ? path[1].X : sourceNode.X + sourceNode.Width + departureClearance,
};
}
private static int CountSharedLaneConflictsForEdge(
IReadOnlyCollection<ElkRoutedEdge> edges,
IReadOnlyCollection<ElkPositionedNode> nodes,
string edgeId)
{
return ElkEdgeRoutingScoring.DetectSharedLaneConflicts(edges, nodes)
.Count(conflict =>
string.Equals(conflict.LeftEdgeId, edgeId, StringComparison.Ordinal)
|| string.Equals(conflict.RightEdgeId, edgeId, StringComparison.Ordinal));
}
private static int CountBrokenSetterFamilyHighwaysForTarget(
IReadOnlyCollection<ElkRoutedEdge> edges,
IReadOnlyCollection<ElkPositionedNode> nodes,
string targetNodeId)
{
return ElkEdgeRouterHighway.DetectRemainingBrokenHighways(edges.ToArray(), nodes.ToArray())
.Count(diagnostic => string.Equals(diagnostic.TargetNodeId, targetNodeId, StringComparison.Ordinal));
}
private static bool HasSetterFamilyEarlyTargetDescent(
IReadOnlyList<ElkPoint> path,
ElkPositionedNode targetNode)
{
if (path.Count < 3)
{
return false;
}
var targetSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(path[^1], targetNode);
if (targetSide is not ("left" or "right"))
{
return false;
}
const double coordinateTolerance = 0.5d;
var approachRailX = string.Equals(targetSide, "left", StringComparison.Ordinal)
? targetNode.X - 24d
: targetNode.X + targetNode.Width + 24d;
var endpointY = path[^1].Y;
double? descentRailX = null;
for (var i = path.Count - 2; i >= 0; i--)
{
if (Math.Abs(path[i].Y - endpointY) > coordinateTolerance)
{
descentRailX = path[i + 1].X;
break;
}
}
if (!descentRailX.HasValue)
{
return false;
}
return string.Equals(targetSide, "left", StringComparison.Ordinal)
? descentRailX.Value < approachRailX - 24d
: descentRailX.Value > approachRailX + 24d;
}
internal static bool HasSetterFamilyBandDetour(
IReadOnlyList<ElkPoint> path,
ElkPositionedNode sourceNode,
ElkPositionedNode targetNode,
double preferredTimerBandY)
{
if (HasSetterFamilyEarlyTargetDescent(path, targetNode))
{
return true;
}
if (!string.Equals(sourceNode.Kind, "Decision", StringComparison.Ordinal)
|| double.IsNaN(preferredTimerBandY)
|| !TryResolveDominantHorizontalRun(path, out var dominantRailY, out var dominantRunLength)
|| dominantRunLength < 24d)
{
return false;
}
const double allowedBandDrift = 40d;
return Math.Abs(dominantRailY - preferredTimerBandY) > allowedBandDrift;
}
private static bool TryResolveDominantHorizontalRun(
IReadOnlyList<ElkPoint> path,
out double railY,
out double runLength)
{
const double coordinateTolerance = 0.5d;
railY = double.NaN;
runLength = 0d;
for (var i = 0; i < path.Count - 1; i++)
{
if (Math.Abs(path[i].Y - path[i + 1].Y) > coordinateTolerance)
{
continue;
}
var candidateLength = Math.Abs(path[i + 1].X - path[i].X);
if (candidateLength <= runLength + coordinateTolerance)
{
continue;
}
runLength = candidateLength;
railY = path[i].Y;
}
return !double.IsNaN(railY);
}
}

View File

@@ -182,16 +182,7 @@ internal static partial class ElkEdgePostProcessor
private static void WriteUnderNodeDebug(string? edgeId, string message)
{
if (edgeId is not ("edge/9" or "edge/25"))
{
return;
}
var path = System.IO.Path.Combine(System.AppContext.BaseDirectory, "elksharp.undernode-debug.log");
lock (UnderNodeDebugSync)
{
System.IO.File.AppendAllText(path, $"[{System.DateTime.UtcNow:O}] {edgeId} {message}{System.Environment.NewLine}");
}
return;
}
private static string FormatPath(IReadOnlyList<ElkPoint> path)

View File

@@ -66,10 +66,20 @@ internal static partial class ElkEdgeRouterHighway
var pairMetrics = ComputePairMetrics(members);
var actualGap = ComputeMinEndpointGap(members);
var effectiveEndpointCount = edges
.Where(edge =>
string.Equals(edge.TargetNodeId, targetNode.Id, StringComparison.Ordinal))
.Select(edge => ExtractFullPath(edge))
.Count(path =>
path.Count >= 2
&& string.Equals(
ElkEdgeRoutingGeometry.ResolveBoundarySide(path[^1], targetNode),
side,
StringComparison.Ordinal));
var requiredGap = ElkBoundarySlots.ResolveRequiredBoundarySlotGap(
targetNode,
side,
members.Count,
Math.Max(members.Count, effectiveEndpointCount),
minLineClearance);
var requiresSpread = (actualGap + CoordinateTolerance) < requiredGap
&& !pairMetrics.AllPairsApplicable;
@@ -120,7 +130,7 @@ internal static partial class ElkEdgeRouterHighway
}
hasSharedSegment = true;
var shortestPath = Math.Min(members[i].PathLength, members[j].PathLength);
var shortestPath = Math.Min(members[i].ApproachPathLength, members[j].ApproachPathLength);
if (shortestPath <= 1d)
{
allPairsApplicable = false;
@@ -173,6 +183,7 @@ internal static partial class ElkEdgeRouterHighway
EdgeId: edge.Id,
Path: path,
PathLength: ElkEdgeRoutingGeometry.ComputePathLength(edge),
ApproachPathLength: ElkEdgeRoutingGeometry.ComputePathLengthNearEnd(path),
EndpointCoord: endpointCoord);
}
@@ -181,6 +192,7 @@ internal static partial class ElkEdgeRouterHighway
string EdgeId,
List<ElkPoint> Path,
double PathLength,
double ApproachPathLength,
double EndpointCoord);
private readonly record struct HighwayPairMetrics(

View File

@@ -161,6 +161,17 @@ internal static partial class ElkEdgeRouterIterative
// Any segment length with a detected violation: push away
// from the closest blocking node boundary (top or bottom).
var laneY = path[bestSegStart].Y;
if (ShouldPreserveImmediateTargetApproachBand(edge, path, bestSegStart, nodesById))
{
continue;
}
var effectiveMinLineClearance = ResolveImmediateTargetApproachClearance(
edge,
path,
bestSegStart,
nodesById,
minLineClearance);
var bestPushY = double.NaN;
var minX = Math.Min(path[bestSegStart].X, path[bestSegStart + 1].X);
var maxX = Math.Max(path[bestSegStart].X, path[bestSegStart + 1].X);
@@ -184,9 +195,9 @@ internal static partial class ElkEdgeRouterIterative
var gapAbove = nodeTop - laneY;
// Edge runs close below the node bottom.
if (gapBelow > -4d && gapBelow < minLineClearance)
if (gapBelow > -4d && gapBelow < effectiveMinLineClearance)
{
var pushY = nodeBottom + minLineClearance + 4d;
var pushY = nodeBottom + effectiveMinLineClearance + 4d;
if (double.IsNaN(bestPushY) || pushY > bestPushY)
{
bestPushY = pushY;
@@ -194,9 +205,9 @@ internal static partial class ElkEdgeRouterIterative
}
// Edge runs close above the node top.
if (gapAbove > -4d && gapAbove < minLineClearance)
if (gapAbove > -4d && gapAbove < effectiveMinLineClearance)
{
var pushY = nodeTop - minLineClearance - 4d;
var pushY = nodeTop - effectiveMinLineClearance - 4d;
if (double.IsNaN(bestPushY) || pushY < bestPushY)
{
bestPushY = pushY;
@@ -251,6 +262,156 @@ internal static partial class ElkEdgeRouterIterative
return result;
}
private static bool ShouldPreserveImmediateTargetApproachBand(
ElkRoutedEdge edge,
IReadOnlyList<ElkPoint> path,
int segmentStartIndex,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById)
{
if (path.Count < 4
|| string.IsNullOrWhiteSpace(edge.TargetNodeId)
|| !nodesById.TryGetValue(edge.TargetNodeId, out var targetNode)
|| ElkShapeBoundaries.IsGatewayShape(targetNode))
{
return false;
}
if (!string.IsNullOrWhiteSpace(edge.SourceNodeId)
&& nodesById.TryGetValue(edge.SourceNodeId, out var sourceNode)
&& string.Equals(targetNode.Kind, "SetState", StringComparison.Ordinal)
&& sourceNode.Kind is "Decision" or "Timer"
&& TryResolveRectImmediateTargetBand(path, targetNode, out var setterBandSegmentStartIndex, out _))
{
return segmentStartIndex >= setterBandSegmentStartIndex;
}
const double coordinateTolerance = 1d;
var endpoint = path[^1];
var leftDistance = Math.Abs(endpoint.X - targetNode.X);
var rightDistance = Math.Abs(endpoint.X - (targetNode.X + targetNode.Width));
var topDistance = Math.Abs(endpoint.Y - targetNode.Y);
var bottomDistance = Math.Abs(endpoint.Y - (targetNode.Y + targetNode.Height));
if (leftDistance <= coordinateTolerance || rightDistance <= coordinateTolerance)
{
var runStartIndex = path.Count - 1;
while (runStartIndex > 0 && Math.Abs(path[runStartIndex - 1].Y - endpoint.Y) <= coordinateTolerance)
{
runStartIndex--;
}
return runStartIndex >= 2 && segmentStartIndex == runStartIndex - 2;
}
if (topDistance <= coordinateTolerance || bottomDistance <= coordinateTolerance)
{
var runStartIndex = path.Count - 1;
while (runStartIndex > 0 && Math.Abs(path[runStartIndex - 1].X - endpoint.X) <= coordinateTolerance)
{
runStartIndex--;
}
return runStartIndex >= 2 && segmentStartIndex == runStartIndex - 2;
}
return false;
}
private static double ResolveImmediateTargetApproachClearance(
ElkRoutedEdge edge,
IReadOnlyList<ElkPoint> path,
int segmentStartIndex,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
double minLineClearance)
{
if (double.IsNaN(minLineClearance)
|| string.IsNullOrWhiteSpace(edge.TargetNodeId)
|| !nodesById.TryGetValue(edge.TargetNodeId, out var targetNode)
|| string.IsNullOrWhiteSpace(edge.SourceNodeId)
|| !nodesById.TryGetValue(edge.SourceNodeId, out var sourceNode))
{
return minLineClearance;
}
if (!string.Equals(targetNode.Kind, "SetState", StringComparison.Ordinal)
|| sourceNode.Kind is not ("Decision" or "Timer"))
{
return minLineClearance;
}
if (TryResolveRectImmediateTargetBand(path, targetNode, out var bandSegmentStartIndex, out _)
&& segmentStartIndex >= bandSegmentStartIndex)
{
return Math.Min(minLineClearance, 24d);
}
if (segmentStartIndex < Math.Max(0, path.Count - 4))
{
return minLineClearance;
}
return Math.Min(minLineClearance, 24d);
}
private static bool TryResolveRectImmediateTargetBand(
IReadOnlyList<ElkPoint> path,
ElkPositionedNode targetNode,
out int bandSegmentStartIndex,
out double bandCoordinate)
{
bandSegmentStartIndex = -1;
bandCoordinate = double.NaN;
if (path.Count < 4 || ElkShapeBoundaries.IsGatewayShape(targetNode))
{
return false;
}
const double coordinateTolerance = 1d;
var endpoint = path[^1];
var leftDistance = Math.Abs(endpoint.X - targetNode.X);
var rightDistance = Math.Abs(endpoint.X - (targetNode.X + targetNode.Width));
var topDistance = Math.Abs(endpoint.Y - targetNode.Y);
var bottomDistance = Math.Abs(endpoint.Y - (targetNode.Y + targetNode.Height));
if (leftDistance <= coordinateTolerance || rightDistance <= coordinateTolerance)
{
var runStartIndex = path.Count - 1;
while (runStartIndex > 0 && Math.Abs(path[runStartIndex - 1].Y - endpoint.Y) <= coordinateTolerance)
{
runStartIndex--;
}
if (runStartIndex < 2
|| Math.Abs(path[runStartIndex - 2].Y - path[runStartIndex - 1].Y) > coordinateTolerance)
{
return false;
}
bandSegmentStartIndex = runStartIndex - 2;
bandCoordinate = path[runStartIndex - 1].Y;
return true;
}
if (topDistance <= coordinateTolerance || bottomDistance <= coordinateTolerance)
{
var runStartIndex = path.Count - 1;
while (runStartIndex > 0 && Math.Abs(path[runStartIndex - 1].X - endpoint.X) <= coordinateTolerance)
{
runStartIndex--;
}
if (runStartIndex < 2
|| Math.Abs(path[runStartIndex - 2].X - path[runStartIndex - 1].X) > coordinateTolerance)
{
return false;
}
bandSegmentStartIndex = runStartIndex - 2;
bandCoordinate = path[runStartIndex - 1].X;
return true;
}
return false;
}
private static ElkRoutedEdge[] ReplaceEdgePath(
ElkRoutedEdge[]? result,
ElkRoutedEdge[] edges,

View File

@@ -6,6 +6,10 @@ internal static partial class ElkEdgeRouterIterative
string[] EdgeIds,
string[] ConflictKeys);
private readonly record struct HybridRepairUnit(
string[] EdgeIds,
ConflictZone Zone);
private static CandidateSolution OptimizeHybrid(
ElkRoutedEdge[] baselineProcessed,
EdgeRoutingScore baselineProcessedScore,
@@ -200,6 +204,12 @@ internal static partial class ElkEdgeRouterIterative
layoutOptions.Direction,
minLineClearance,
preferLowWaveRuntimePolish: config.MaxRepairWaves <= 2);
current = ApplyAbsoluteSemanticTail(
current,
nodes,
layoutOptions.Direction,
minLineClearance);
if (liveStrategyDiagnostics is not null)
{
lock (diagnostics!.SyncRoot)
@@ -219,6 +229,311 @@ internal static partial class ElkEdgeRouterIterative
return current;
}
private static CandidateSolution ApplyAbsoluteSemanticTail(
CandidateSolution solution,
ElkPositionedNode[] nodes,
ElkLayoutDirection direction,
double minLineClearance)
{
if (solution.Edges.Length == 0 || nodes.Length == 0)
{
return solution;
}
var current = solution;
current = ApplyFinalForkDepartureRestabilization(current, nodes, direction, minLineClearance);
current = ApplyFinalJoinTargetRestabilization(current, nodes, direction, minLineClearance);
current = ApplyFinalDecisionSourceBoundarySlotRestabilization(current, nodes, direction, minLineClearance);
current = ApplyFinalFocusedGatewayArtifactRewrite(current, nodes, minLineClearance);
current = ApplyFinalRectBoundaryAngleOrthogonalization(current, nodes);
current = ApplyFinalImmediateSetStateBandLift(current, nodes, direction, minLineClearance);
current = RefreshCandidateSolution(current, nodes);
current = ApplyFinalFocusedSetterUnderNodeRepair(current, nodes, minLineClearance);
current = RefreshCandidateSolution(current, nodes);
var normalizedSetterEdges = ElkEdgePostProcessor.NormalizeDecisionTimerSetterFamilies(
current.Edges,
nodes,
minLineClearance);
if (!ReferenceEquals(normalizedSetterEdges, current.Edges))
{
current = current with { Edges = normalizedSetterEdges };
current = RefreshCandidateSolution(current, nodes);
}
current = ApplyFinalForkDepartureRestabilization(current, nodes, direction, minLineClearance);
current = ApplyFinalJoinTargetRestabilization(current, nodes, direction, minLineClearance);
current = ApplyFinalEndTerminalStabilization(current, nodes, direction, minLineClearance);
var finalEndClampEdges = ElkEdgePostProcessor.DistributeEndTerminalLeftFaceTrunks(
current.Edges,
nodes,
minLineClearance);
if (!ReferenceEquals(finalEndClampEdges, current.Edges))
{
current = current with { Edges = finalEndClampEdges };
current = RefreshCandidateSolution(current, nodes);
}
current = ApplyAbsoluteForkDepartureOverrides(current, nodes, minLineClearance);
current = ApplyAbsoluteJoinTargetOverrides(current, nodes);
current = ApplyAbsoluteSetterBandOverrides(current, nodes, minLineClearance);
ElkLayoutDiagnostics.LogProgress(
$"Hybrid absolute semantic tail complete: score={current.Score.Value:F0} retry={DescribeRetryState(current.RetryState)}");
return current;
}
private static CandidateSolution ApplyAbsoluteSetterBandOverrides(
CandidateSolution solution,
ElkPositionedNode[] nodes,
double minLineClearance)
{
if (solution.Edges.Length == 0 || nodes.Length == 0)
{
return solution;
}
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
var candidateEdges = solution.Edges.ToArray();
var changed = false;
var setterGroups = candidateEdges
.Select((edge, index) => new { Edge = edge, Index = index })
.Where(entry =>
!string.IsNullOrWhiteSpace(entry.Edge.SourceNodeId)
&& !string.IsNullOrWhiteSpace(entry.Edge.TargetNodeId)
&& nodesById.TryGetValue(entry.Edge.SourceNodeId!, out var sourceNode)
&& nodesById.TryGetValue(entry.Edge.TargetNodeId!, out var targetNode)
&& string.Equals(targetNode.Kind, "SetState", StringComparison.Ordinal)
&& sourceNode.Kind is "Decision" or "Timer")
.GroupBy(entry => entry.Edge.TargetNodeId!, StringComparer.Ordinal);
foreach (var group in setterGroups)
{
var groupEntries = group
.Select(entry => new
{
entry.Edge,
entry.Index,
SourceNode = nodesById[entry.Edge.SourceNodeId!],
})
.ToArray();
if (!groupEntries.Any(entry => string.Equals(entry.SourceNode.Kind, "Decision", StringComparison.Ordinal))
|| !groupEntries.Any(entry => string.Equals(entry.SourceNode.Kind, "Timer", StringComparison.Ordinal)))
{
continue;
}
foreach (var entry in groupEntries.Where(entry => string.Equals(entry.SourceNode.Kind, "Decision", StringComparison.Ordinal)))
{
if (ElkEdgeRoutingScoring.CountUnderNodeViolations([entry.Edge], nodes) == 0)
{
continue;
}
var path = ExtractPath(entry.Edge);
if (!TryBuildImmediateSetStateBandRewrite(
entry.Edge,
path,
nodes,
nodesById,
minLineClearance,
out var candidatePath))
{
continue;
}
var candidateEdge = BuildSingleSectionCandidateEdge(entry.Edge, candidatePath);
if (ElkEdgeRoutingScoring.CountEdgeNodeCrossings([candidateEdge], nodes, null) > 0
|| ElkEdgeRoutingScoring.CountUnderNodeViolations([candidateEdge], nodes) > 0)
{
continue;
}
candidateEdges[entry.Index] = candidateEdge;
changed = true;
}
}
if (!changed)
{
return solution;
}
var updated = solution with { Edges = candidateEdges };
return RefreshCandidateSolution(updated, nodes);
}
private static CandidateSolution ApplyAbsoluteJoinTargetOverrides(
CandidateSolution solution,
ElkPositionedNode[] nodes)
{
if (solution.Edges.Length == 0 || nodes.Length == 0)
{
return solution;
}
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
var graphMinY = nodes.Min(node => node.Y);
var graphMaxY = nodes.Max(node => node.Y + node.Height);
var candidateEdges = solution.Edges.ToArray();
var changed = false;
for (var edgeIndex = 0; edgeIndex < candidateEdges.Length; edgeIndex++)
{
var edge = candidateEdges[edgeIndex];
if (string.IsNullOrWhiteSpace(edge.TargetNodeId)
|| !nodesById.TryGetValue(edge.TargetNodeId, out var targetNode)
|| !string.Equals(targetNode.Kind, "Join", StringComparison.Ordinal))
{
continue;
}
var path = ExtractPath(edge);
var restrictedEdgeIds = new HashSet<string>(StringComparer.Ordinal) { edge.Id };
var (_, targetSlots) = ElkEdgePostProcessor.ResolveCombinedBoundarySlots(
candidateEdges,
nodesById,
graphMinY,
graphMaxY,
restrictedEdgeIds,
enforceAllNodeEndpoints: true);
var hasTargetSlot = targetSlots.TryGetValue(edge.Id, out var targetSlot);
if (path.Count < 2
|| ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, path[^1], path[^2])
|| !ElkEdgePostProcessor.TryBuildFocusedGatewayJoinTargetRepair(
edge,
nodes,
hasTargetSlot ? targetSlot.Side : null,
hasTargetSlot ? targetSlot.Boundary : null,
out var candidatePath))
{
continue;
}
var repairedEdges = candidateEdges.ToArray();
repairedEdges[edgeIndex] = BuildSingleSectionCandidateEdge(edge, candidatePath);
var rawCandidateEdge = repairedEdges[edgeIndex];
repairedEdges = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
repairedEdges,
nodes,
Math.Max(24d, nodes.Min(node => node.Height) * 0.25d),
restrictedEdgeIds,
enforceAllNodeEndpoints: true);
if (HasBoundaryAngleViolation(repairedEdges[edgeIndex], nodesById)
&& !HasBoundaryAngleViolation(rawCandidateEdge, nodesById))
{
repairedEdges[edgeIndex] = rawCandidateEdge;
}
if (ElkEdgeRoutingScoring.CountEdgeNodeCrossings([repairedEdges[edgeIndex]], nodes, null) > 0
|| HasBoundaryAngleViolation(repairedEdges[edgeIndex], nodesById))
{
continue;
}
candidateEdges = repairedEdges;
changed = true;
}
if (!changed)
{
return solution;
}
var updated = solution with { Edges = candidateEdges };
return RefreshCandidateSolution(updated, nodes);
}
private static CandidateSolution ApplyAbsoluteForkDepartureOverrides(
CandidateSolution solution,
ElkPositionedNode[] nodes,
double minLineClearance)
{
if (solution.Edges.Length == 0 || nodes.Length == 0)
{
return solution;
}
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
var candidateEdges = solution.Edges.ToArray();
var changed = false;
var forkGroups = candidateEdges
.Select((edge, index) => new { Edge = edge, Index = index })
.Where(entry =>
!string.IsNullOrWhiteSpace(entry.Edge.SourceNodeId)
&& nodesById.TryGetValue(entry.Edge.SourceNodeId!, out var sourceNode)
&& string.Equals(sourceNode.Kind, "Fork", StringComparison.Ordinal))
.GroupBy(entry => entry.Edge.SourceNodeId!, StringComparer.Ordinal);
foreach (var group in forkGroups)
{
if (!nodesById.TryGetValue(group.Key, out var sourceNode))
{
continue;
}
var entries = group
.Select(entry => new
{
entry.Edge,
entry.Index,
Path = ExtractPath(entry.Edge),
TargetNode = !string.IsNullOrWhiteSpace(entry.Edge.TargetNodeId) && nodesById.TryGetValue(entry.Edge.TargetNodeId!, out var targetNode)
? targetNode
: null,
})
.ToArray();
var workEntry = entries.FirstOrDefault(entry => entry.TargetNode is not null && !string.Equals(entry.TargetNode.Kind, "Join", StringComparison.Ordinal));
if (workEntry is not null
&& ElkEdgePostProcessor.TryBuildCenteredForkWorkBranchDeparture(workEntry.Edge, nodes, out var workCandidatePath))
{
candidateEdges[workEntry.Index] = BuildSingleSectionCandidateEdge(workEntry.Edge, workCandidatePath);
changed = true;
}
if (workEntry?.TargetNode is null)
{
continue;
}
foreach (var bypassEntry in entries.Where(entry => entry.TargetNode is not null && string.Equals(entry.TargetNode.Kind, "Join", StringComparison.Ordinal)))
{
if (!ElkEdgePostProcessor.TryBuildForkBypassDepartureAwayFromPrimaryAxis(
bypassEntry.Edge,
sourceNode,
bypassEntry.TargetNode!,
workEntry.TargetNode,
nodes,
minLineClearance,
out var bypassCandidatePath))
{
continue;
}
var baselineMetrics = MeasureForkPrimaryAxisMetrics(bypassEntry.Path, sourceNode.Y + (sourceNode.Height / 2d));
var candidateMetrics = MeasureForkPrimaryAxisMetrics(bypassCandidatePath, sourceNode.Y + (sourceNode.Height / 2d));
if (MeasureForkBypassVisualImprovement(baselineMetrics, candidateMetrics) <= 0d)
{
continue;
}
candidateEdges[bypassEntry.Index] = BuildSingleSectionCandidateEdge(bypassEntry.Edge, bypassCandidatePath);
changed = true;
}
}
if (!changed)
{
return solution;
}
var updated = solution with { Edges = candidateEdges };
return RefreshCandidateSolution(updated, nodes);
}
private static RoutingStrategy BuildHybridStrategy(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
@@ -280,7 +595,7 @@ internal static partial class ElkEdgeRouterIterative
// routed path plus a margin. Two edges conflict if their zones overlap
// spatially, or if they share a repeat-collector label on the same
// source-target pair.
var zones = new List<(string EdgeId, ConflictZone Zone)>();
var zones = new List<(string EdgeId, ConflictZone Zone, string CouplingKey)>();
foreach (var edgeId in repairPlan.EdgeIds)
{
if (!edgesById.TryGetValue(edgeId, out var edge))
@@ -288,31 +603,39 @@ internal static partial class ElkEdgeRouterIterative
continue;
}
zones.Add((edgeId, BuildConflictZone(edge, nodesById)));
zones.Add((edgeId, BuildConflictZone(edge, nodesById), ResolveHybridRepairCouplingKey(edge, nodesById)));
}
var units = zones
.GroupBy(entry => entry.CouplingKey, StringComparer.Ordinal)
.Select(group => new HybridRepairUnit(
group.Select(entry => entry.EdgeId).OrderBy(id => id, StringComparer.Ordinal).ToArray(),
MergeConflictZones(group.Select(entry => entry.Zone).ToArray())))
.OrderBy(unit => unit.EdgeIds[0], StringComparer.Ordinal)
.ToArray();
// Greedy first-fit batching: assign each edge to the first batch
// whose existing zones don't spatially conflict.
var orderedBatches = new List<(List<string> EdgeIds, List<ConflictZone> Zones)>();
foreach (var (edgeId, zone) in zones)
foreach (var unit in units)
{
var assigned = false;
foreach (var batch in orderedBatches)
{
if (batch.Zones.Any(existing => existing.ConflictsWith(zone)))
if (batch.Zones.Any(existing => existing.ConflictsWith(unit.Zone)))
{
continue;
}
batch.EdgeIds.Add(edgeId);
batch.Zones.Add(zone);
batch.EdgeIds.AddRange(unit.EdgeIds);
batch.Zones.Add(unit.Zone);
assigned = true;
break;
}
if (!assigned)
{
orderedBatches.Add((EdgeIds: [edgeId], Zones: [zone]));
orderedBatches.Add((EdgeIds: [.. unit.EdgeIds], Zones: [unit.Zone]));
}
}
@@ -324,6 +647,56 @@ internal static partial class ElkEdgeRouterIterative
.ToArray();
}
private static string ResolveHybridRepairCouplingKey(
ElkRoutedEdge edge,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById)
{
if (!string.IsNullOrWhiteSpace(edge.TargetNodeId)
&& nodesById.TryGetValue(edge.TargetNodeId, out var targetNode)
&& !ElkShapeBoundaries.IsGatewayShape(targetNode))
{
return $"target:{edge.TargetNodeId}";
}
return $"edge:{edge.Id}";
}
private static ConflictZone MergeConflictZones(IReadOnlyList<ConflictZone> zones)
{
if (zones.Count == 0)
{
return new ConflictZone(
MinX: 0d,
MinY: 0d,
MaxX: 0d,
MaxY: 0d,
SourceNodeId: string.Empty,
TargetNodeId: string.Empty,
IsCollector: false,
DescriptiveKeys: []);
}
var minX = zones.Min(zone => zone.MinX);
var minY = zones.Min(zone => zone.MinY);
var maxX = zones.Max(zone => zone.MaxX);
var maxY = zones.Max(zone => zone.MaxY);
var isCollector = zones.Any(zone => zone.IsCollector);
var descriptiveKeys = zones
.SelectMany(zone => zone.DescriptiveKeys)
.Distinct(StringComparer.Ordinal)
.OrderBy(key => key, StringComparer.Ordinal)
.ToArray();
return new ConflictZone(
MinX: minX,
MinY: minY,
MaxX: maxX,
MaxY: maxY,
SourceNodeId: zones[0].SourceNodeId,
TargetNodeId: zones[0].TargetNodeId,
IsCollector: isCollector,
DescriptiveKeys: descriptiveKeys);
}
/// <summary>
/// Geometric conflict zone for an edge: bounding box of its routed path
/// expanded by a margin, plus endpoint node IDs for collector-label

View File

@@ -73,6 +73,37 @@ internal static partial class ElkEdgeRouterIterative
|| candidate.ExcessiveDetourViolations > baseline.ExcessiveDetourViolations;
}
private static bool HasBlockingSetterFamilyPromotionRegression(
RoutingRetryState candidate,
RoutingRetryState baseline,
bool localImproved)
{
var allowTemporaryDetourTrade =
localImproved
&& candidate.RemainingShortHighways <= baseline.RemainingShortHighways
&& candidate.UnderNodeViolations <= baseline.UnderNodeViolations
&& candidate.ExcessiveDetourViolations <= baseline.ExcessiveDetourViolations + 1;
var allowTemporaryBoundarySlotTrade =
localImproved
&& candidate.BoundarySlotViolations <= baseline.BoundarySlotViolations + 1;
return candidate.RemainingShortHighways > baseline.RemainingShortHighways
|| candidate.RepeatCollectorCorridorViolations > baseline.RepeatCollectorCorridorViolations
|| candidate.RepeatCollectorNodeClearanceViolations > baseline.RepeatCollectorNodeClearanceViolations
|| candidate.BelowGraphViolations > baseline.BelowGraphViolations
|| candidate.UnderNodeViolations > baseline.UnderNodeViolations
|| candidate.LongDiagonalViolations > baseline.LongDiagonalViolations
|| candidate.EntryAngleViolations > baseline.EntryAngleViolations
|| candidate.GatewaySourceExitViolations > baseline.GatewaySourceExitViolations
|| candidate.SharedLaneViolations > baseline.SharedLaneViolations
|| (!allowTemporaryBoundarySlotTrade
&& candidate.BoundarySlotViolations > baseline.BoundarySlotViolations)
|| candidate.TargetApproachJoinViolations > baseline.TargetApproachJoinViolations
|| candidate.TargetApproachBacktrackingViolations > baseline.TargetApproachBacktrackingViolations
|| (!allowTemporaryDetourTrade
&& candidate.ExcessiveDetourViolations > baseline.ExcessiveDetourViolations);
}
private static int CompareRetryStates(RoutingRetryState left, RoutingRetryState right)
{
if (left.RemainingShortHighways != right.RemainingShortHighways)

View File

@@ -14,6 +14,7 @@ internal static partial class ElkEdgeRouterIterative
int maxRounds = 3)
{
var current = solution;
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
for (var round = 0; round < maxRounds; round++)
{
@@ -30,7 +31,10 @@ internal static partial class ElkEdgeRouterIterative
.Take(MaxWinnerPolishBatchedRootEdges)
.Select(pair => pair.Key)
.ToArray();
var batchedFocusEdgeIds = ExpandWinningSolutionFocus(current.Edges, batchedRootEdgeIds).ToArray();
var batchedFocusEdgeIds = ResolveBoundarySlotRepairFocus(
current.Edges,
nodesById,
batchedRootEdgeIds);
if (batchedFocusEdgeIds.Length > 0)
{
var batchedCandidateEdges = BuildFinalBoundarySlotCandidate(
@@ -53,7 +57,10 @@ internal static partial class ElkEdgeRouterIterative
.ThenBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => pair.Key))
{
var focusEdgeIds = ExpandWinningSolutionFocus(current.Edges, [edgeId]).ToArray();
var focusEdgeIds = ResolveBoundarySlotRepairFocus(
current.Edges,
nodesById,
[edgeId]);
if (focusEdgeIds.Length == 0)
{
continue;
@@ -86,6 +93,76 @@ internal static partial class ElkEdgeRouterIterative
return current;
}
private static string[] ResolveBoundarySlotRepairFocus(
IReadOnlyCollection<ElkRoutedEdge> edges,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
IReadOnlyCollection<string> rootEdgeIds)
{
if (rootEdgeIds.Count == 0)
{
return [];
}
if (TryResolveGatewayBoundarySlotLocalFocus(edges, nodesById, rootEdgeIds, out var localFocus))
{
return localFocus;
}
return ExpandWinningSolutionFocus(edges, rootEdgeIds).ToArray();
}
private static bool TryResolveGatewayBoundarySlotLocalFocus(
IReadOnlyCollection<ElkRoutedEdge> edges,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
IReadOnlyCollection<string> rootEdgeIds,
out string[] focusEdgeIds)
{
focusEdgeIds = [];
if (rootEdgeIds.Count == 0)
{
return false;
}
var edgesById = edges.ToDictionary(edge => edge.Id, StringComparer.Ordinal);
var localFocus = new HashSet<string>(StringComparer.Ordinal);
foreach (var edgeId in rootEdgeIds)
{
if (!edgesById.TryGetValue(edgeId, out var edge))
{
return false;
}
var touchesGateway = false;
if (!string.IsNullOrWhiteSpace(edge.SourceNodeId)
&& nodesById.TryGetValue(edge.SourceNodeId, out var sourceNode)
&& ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
touchesGateway = true;
}
if (!touchesGateway
&& !string.IsNullOrWhiteSpace(edge.TargetNodeId)
&& nodesById.TryGetValue(edge.TargetNodeId, out var targetNode)
&& ElkShapeBoundaries.IsGatewayShape(targetNode))
{
touchesGateway = true;
}
if (!touchesGateway)
{
return false;
}
localFocus.Add(edgeId);
}
focusEdgeIds = localFocus
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
return focusEdgeIds.Length > 0;
}
private static CandidateSolution ApplyFinalPostSlotHardRulePolish(
CandidateSolution solution,
ElkPositionedNode[] nodes,

View File

@@ -12,7 +12,8 @@ internal static partial class ElkEdgeRouterIterative
ElkLayoutDirection direction,
double minLineClearance,
IReadOnlyCollection<string>? restrictedEdgeIds = null,
bool allowLateRestabilizedClosure = true)
bool allowLateRestabilizedClosure = true,
bool stopAfterLeanRestrictedPass = false)
{
var focusEdgeIds = restrictedEdgeIds?.Count > 0
? restrictedEdgeIds
@@ -27,6 +28,10 @@ internal static partial class ElkEdgeRouterIterative
var useUltraLeanRestrictedBoundarySlotPass =
restrictedEdgeIds?.Count > 0
&& restrictedEdgeIds.Count <= MaxWinnerPolishBatchedRootEdges + 1;
var useGatewayRestrictedBoundarySlotPass =
restrictedEdgeIds?.Count > 0
&& !allowLateRestabilizedClosure
&& AreGatewayTouchingBoundarySlotEdges(edges, nodes, restrictedEdgeIds);
ElkLayoutDiagnostics.LogProgress(
$"Boundary-slot candidate start: focus={focusEdgeIds.Count} allowLateRestabilizedClosure={allowLateRestabilizedClosure}");
@@ -35,11 +40,13 @@ internal static partial class ElkEdgeRouterIterative
edges,
nodes,
minLineClearance,
restrictedEdgeIds,
enforceAllNodeEndpoints: true);
restrictedEdgeIds,
enforceAllNodeEndpoints: true);
best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes);
ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after initial snap");
var terminalClosureCandidate = useLeanRestrictedBoundarySlotPass
var terminalClosureCandidate = useGatewayRestrictedBoundarySlotPass
? candidate
: useLeanRestrictedBoundarySlotPass
? ApplyHybridTerminalRuleCleanupRound(
candidate,
nodes,
@@ -106,6 +113,18 @@ internal static partial class ElkEdgeRouterIterative
enforceAllNodeEndpoints: true);
best = ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes);
ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate after detour snap");
if (useGatewayRestrictedBoundarySlotPass)
{
ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate complete (gateway restricted path)");
return ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes);
}
if (stopAfterLeanRestrictedPass && restrictedEdgeIds?.Count > 0)
{
ElkLayoutDiagnostics.LogProgress("Boundary-slot candidate complete (restricted lean-only path)");
return ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes);
}
if (useLeanRestrictedBoundarySlotPass)
{
if (HasRemainingRestrictedBoundarySlotHardPressure(candidate, nodes, restrictedEdgeIds))
@@ -241,6 +260,50 @@ internal static partial class ElkEdgeRouterIterative
return ChoosePreferredBoundarySlotRepairLayout(best, candidate, nodes);
}
private static bool AreGatewayTouchingBoundarySlotEdges(
IReadOnlyCollection<ElkRoutedEdge> edges,
IReadOnlyCollection<ElkPositionedNode> nodes,
IReadOnlyCollection<string> restrictedEdgeIds)
{
if (restrictedEdgeIds.Count == 0)
{
return false;
}
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
var edgesById = edges.ToDictionary(edge => edge.Id, StringComparer.Ordinal);
foreach (var edgeId in restrictedEdgeIds)
{
if (!edgesById.TryGetValue(edgeId, out var edge))
{
return false;
}
var touchesGateway = false;
if (!string.IsNullOrWhiteSpace(edge.SourceNodeId)
&& nodesById.TryGetValue(edge.SourceNodeId, out var sourceNode)
&& ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
touchesGateway = true;
}
if (!touchesGateway
&& !string.IsNullOrWhiteSpace(edge.TargetNodeId)
&& nodesById.TryGetValue(edge.TargetNodeId, out var targetNode)
&& ElkShapeBoundaries.IsGatewayShape(targetNode))
{
touchesGateway = true;
}
if (!touchesGateway)
{
return false;
}
}
return true;
}
private static bool HasRemainingRestrictedBoundarySlotHardPressure(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,

View File

@@ -110,18 +110,20 @@ internal static partial class ElkEdgeRouterIterative
config,
minLineClearance,
cancellationToken);
var cleanedHybridEdges = ElkEdgePostProcessor.ClearInternalRoutingMarkers(hybridBest.Edges);
if (diagnostics is not null)
{
diagnostics.SelectedStrategyIndex = 1;
diagnostics.FinalScore = AdjustFinalScoreForValidGatewayApproaches(
hybridBest.Score, hybridBest.Edges, nodes);
ElkEdgeRoutingScoring.ComputeScore(cleanedHybridEdges, nodes),
cleanedHybridEdges,
nodes);
diagnostics.FinalBrokenShortHighwayCount = HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(hybridBest.Edges, nodes).Count
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(cleanedHybridEdges, nodes).Count
: 0;
ElkLayoutDiagnostics.FlushSnapshot(diagnostics);
}
var cleanedHybridEdges = ElkEdgePostProcessor.ClearInternalRoutingMarkers(hybridBest.Edges);
ElkLayoutDiagnostics.LogProgress("Hybrid refinement markers cleared");
return cleanedHybridEdges;
}
@@ -172,19 +174,22 @@ internal static partial class ElkEdgeRouterIterative
: SelectBestFallbackSolution(fallbackSolutions);
best = RefineWinningSolution(best, nodes, layoutOptions.Direction, minLineClearance);
ElkLayoutDiagnostics.LogProgress($"Winner refinement complete: score={best.Score.Value:F0} retry={DescribeRetryState(best.RetryState)}");
var cleanedEdges = ElkEdgePostProcessor.ClearInternalRoutingMarkers(best.Edges);
if (diagnostics is not null)
{
diagnostics.SelectedStrategyIndex = best.StrategyIndex;
diagnostics.FinalScore = best.Score;
diagnostics.FinalScore = AdjustFinalScoreForValidGatewayApproaches(
ElkEdgeRoutingScoring.ComputeScore(cleanedEdges, nodes),
cleanedEdges,
nodes);
diagnostics.FinalBrokenShortHighwayCount = HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(best.Edges, nodes).Count
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(cleanedEdges, nodes).Count
: 0;
ElkLayoutDiagnostics.FlushSnapshot(diagnostics);
ElkLayoutDiagnostics.LogProgress("Winner refinement snapshot flushed");
}
var cleanedEdges = ElkEdgePostProcessor.ClearInternalRoutingMarkers(best.Edges);
ElkLayoutDiagnostics.LogProgress("Winner refinement markers cleared");
return cleanedEdges;
}

View File

@@ -90,6 +90,14 @@ internal static partial class ElkEdgeRoutingGeometry
return longest;
}
internal static double ComputePathLengthNearEnd(
IReadOnlyList<ElkPoint> path,
int maxSegmentsFromEnd = 3)
{
return FlattenSegmentsNearEnd(path, maxSegmentsFromEnd)
.Sum(segment => ComputeSegmentLength(segment.Start, segment.End));
}
internal static ElkPoint ResolveApproachPoint(ElkRoutedEdge edge)
{
var lastSection = edge.Sections.Last();

View File

@@ -15,13 +15,21 @@ internal static partial class ElkEdgeRoutingScoring
Dictionary<string, int>? severityByEdgeId,
int severityWeight = 1)
{
var edgesById = edges.ToDictionary(edge => edge.Id, StringComparer.Ordinal);
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
// Use node-size clearance for boundary-slot gaps: slot spacing depends
// on node face geometry, not inter-node spacing.
var minClearance = ResolveNodeSizeClearance(nodes);
var coordinateTolerance = Math.Max(1d, Math.Min(6d, minClearance * 0.2d));
var entries = new List<(string EdgeId, ElkPositionedNode Node, string Side, double Coordinate, bool IsOutgoing)>();
var graphMinY = nodes.Min(node => node.Y);
var graphMaxY = nodes.Max(node => node.Y + node.Height);
var (sourceSlots, targetSlots) = ElkEdgePostProcessor.ResolveCombinedBoundarySlots(
edges,
nodesById,
graphMinY,
graphMaxY,
restrictedEdgeIds: null,
enforceAllNodeEndpoints: true);
var count = 0;
foreach (var edge in edges)
{
@@ -32,118 +40,71 @@ internal static partial class ElkEdgeRoutingScoring
}
if (string.IsNullOrWhiteSpace(edge.SourcePortId)
&& nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode))
&& !string.IsNullOrWhiteSpace(edge.SourceNodeId)
&& nodesById.TryGetValue(edge.SourceNodeId, out var sourceNode)
&& sourceSlots.TryGetValue(edge.Id, out var sourceSlot)
&& !MatchesResolvedBoundarySlot(
path[0],
path.Count >= 2 ? path[1] : path[0],
sourceNode,
sourceSlot.Side,
sourceSlot.Boundary,
coordinateTolerance,
isOutgoing: true))
{
var sourceSide = path.Count < 2
? ElkEdgeRoutingGeometry.ResolveBoundarySide(path[0], sourceNode)
: ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(path[0], path[1], sourceNode);
var sourceCoordinate = sourceSide is "left" or "right" ? path[0].Y : path[0].X;
entries.Add((edge.Id, sourceNode, sourceSide, sourceCoordinate, true));
}
if (string.IsNullOrWhiteSpace(edge.TargetPortId)
&& nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode))
{
var targetSide = path.Count < 2
? ElkEdgeRoutingGeometry.ResolveBoundarySide(path[^1], targetNode)
: ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(path[^1], path[^2], targetNode);
var targetCoordinate = targetSide is "left" or "right" ? path[^1].Y : path[^1].X;
entries.Add((edge.Id, targetNode, targetSide, targetCoordinate, false));
}
}
var count = 0;
foreach (var group in entries
.Where(entry => entry.Side is "left" or "right" or "top" or "bottom")
.GroupBy(
entry => $"{entry.Node.Id}|{entry.Side}",
StringComparer.Ordinal))
{
var ordered = group
.OrderBy(entry => entry.Coordinate)
.ThenBy(entry => entry.IsOutgoing ? 0 : 1)
.ThenBy(entry => entry.EdgeId, StringComparer.Ordinal)
.ToArray();
var node = ordered[0].Node;
var side = ordered[0].Side;
if (ordered.Length == 1
&& edgesById.TryGetValue(ordered[0].EdgeId, out var singletonEdge))
{
var singletonPath = ExtractPath(singletonEdge);
if (ElkEdgePostProcessor.TryResolveGatewaySingletonBoundarySlot(
singletonPath,
node,
side,
ordered[0].IsOutgoing,
out var singletonBoundary))
{
var expectedCoordinate = side is "left" or "right"
? singletonBoundary.Y
: singletonBoundary.X;
if (Math.Abs(ordered[0].Coordinate - expectedCoordinate) > coordinateTolerance)
{
count++;
if (severityByEdgeId is not null)
{
severityByEdgeId[ordered[0].EdgeId] = severityByEdgeId.GetValueOrDefault(ordered[0].EdgeId) + severityWeight;
}
}
continue;
}
}
// Gateway vertex exemption: when all target entries on a gateway side
// share the same vertex position (left/right tip), they're converging
// at a natural diamond corner — not competing for face slots.
var isGatewayVertexGroup = ElkShapeBoundaries.IsGatewayShape(node)
&& ordered.All(entry => !entry.IsOutgoing)
&& ordered.Length >= 2;
if (isGatewayVertexGroup)
{
var centerY = node.Y + (node.Height / 2d);
var allAtVertex = ordered.All(entry =>
Math.Abs(entry.Coordinate - centerY) <= coordinateTolerance);
if (allAtVertex)
{
continue; // Skip slot checks — valid vertex convergence
}
}
var uniqueSlotCoordinates = ElkBoundarySlots.BuildUniqueBoundarySlotCoordinates(node, side, ordered.Length);
var assignedSlotCoordinates = ElkBoundarySlots.BuildAssignedBoundarySlotAxisCoordinates(
node,
side,
ordered.Select(entry => entry.Coordinate).ToArray());
var slotOccupancy = new int[uniqueSlotCoordinates.Length];
for (var i = 0; i < ordered.Length; i++)
{
var slotIndex = ElkBoundarySlots.ResolveOrderedSlotIndex(i, ordered.Length, uniqueSlotCoordinates.Length);
if (slotOccupancy[slotIndex] > 0)
{
count++;
if (severityByEdgeId is not null)
{
severityByEdgeId[ordered[i].EdgeId] = severityByEdgeId.GetValueOrDefault(ordered[i].EdgeId) + severityWeight;
}
}
slotOccupancy[slotIndex]++;
if (Math.Abs(ordered[i].Coordinate - assignedSlotCoordinates[i]) <= coordinateTolerance)
{
continue;
}
count++;
if (severityByEdgeId is not null)
{
severityByEdgeId[ordered[i].EdgeId] = severityByEdgeId.GetValueOrDefault(ordered[i].EdgeId) + severityWeight;
severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + severityWeight;
}
}
if (string.IsNullOrWhiteSpace(edge.TargetPortId)
&& !string.IsNullOrWhiteSpace(edge.TargetNodeId)
&& nodesById.TryGetValue(edge.TargetNodeId, out var targetNode)
&& targetSlots.TryGetValue(edge.Id, out var targetSlot)
&& !MatchesResolvedBoundarySlot(
path[^1],
path.Count >= 2 ? path[^2] : path[^1],
targetNode,
targetSlot.Side,
targetSlot.Boundary,
coordinateTolerance,
isOutgoing: false))
{
count++;
if (severityByEdgeId is not null)
{
severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + severityWeight;
}
}
}
return count;
}
private static bool MatchesResolvedBoundarySlot(
ElkPoint boundaryPoint,
ElkPoint adjacentPoint,
ElkPositionedNode node,
string expectedSide,
ElkPoint expectedBoundary,
double coordinateTolerance,
bool isOutgoing)
{
var actualSide = ElkShapeBoundaries.IsGatewayShape(node)
? node.Kind is "Fork" or "Decision" or "Join"
? (isOutgoing
? ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(boundaryPoint, adjacentPoint, node)
: ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(boundaryPoint, adjacentPoint, node))
: ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(boundaryPoint, adjacentPoint, node)
: ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(boundaryPoint, adjacentPoint, node);
if (!string.Equals(actualSide, expectedSide, StringComparison.Ordinal))
{
return false;
}
return Math.Abs(boundaryPoint.X - expectedBoundary.X) <= coordinateTolerance
&& Math.Abs(boundaryPoint.Y - expectedBoundary.Y) <= coordinateTolerance;
}
}

View File

@@ -85,9 +85,10 @@ internal static partial class ElkEdgeRoutingScoring
Dictionary<string, int>? severityByEdgeId,
int severityWeight = 1)
{
// A labeled edge needs a long-enough first segment for the label to fit.
// In LTR, the first segment exits the source horizontally — if it's too short
// (immediate bend), the label gets squeezed or displaced.
// The SVG renderer anchors edge labels to the longest available segment,
// not strictly to the first source-exit stub. Score against the same
// anchor contract so wrapped leader-badge labels are not penalized for
// intentionally short source stubs.
const double minLabelSegmentLength = 40d;
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
var count = 0;
@@ -114,8 +115,10 @@ internal static partial class ElkEdgeRoutingScoring
continue;
}
// Measure the first segment length
var firstSegLen = ElkEdgeRoutingGeometry.ComputeSegmentLength(points[0], points[1]);
var anchorSegLen = points
.Zip(points.Skip(1), static (start, end) => ElkEdgeRoutingGeometry.ComputeSegmentLength(start, end))
.DefaultIfEmpty(0d)
.Max();
// If the edge is very short overall (source and target close together), skip
if (!nodesById.TryGetValue(edge.SourceNodeId ?? "", out var srcNode)
@@ -130,7 +133,7 @@ internal static partial class ElkEdgeRoutingScoring
continue;
}
if (firstSegLen < minLabelSegmentLength)
if (anchorSegLen < minLabelSegmentLength)
{
count++;
if (severityByEdgeId is not null)

View File

@@ -260,6 +260,10 @@ 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);
routedEdges = ElkEdgePostProcessor.SpreadOuterCorridors(routedEdges, finalNodes);
var minLC = finalNodes.Where(n => n.Kind is not "Start" and not "End").ToArray() is { Length: > 0 } svc
? Math.Min(svc.Average(n => n.Width), svc.Average(n => n.Height)) / 2d : 50d;
routedEdges = ElkEdgePostProcessor.ShiftHighCrossingVerticals(routedEdges, finalNodes, minLC);
ElkLayoutDiagnostics.LogProgress("ElkSharp layout optimize returned");
return Task.FromResult(new ElkLayoutResult

View File

@@ -119,6 +119,13 @@ internal static class ElkSharpLayoutInitialPlacement
ElkNodePlacementAlignment.PropagateSuccessorPositionBackward(
positionedNodes, outgoingNodeIds, nodesById, options.Direction);
// Fork-chain back-propagation can pull the upstream branch row back onto
// the direct bypass runway. Re-center multi-incoming joins/end nodes
// once more after that shift so the visible mainline reflects the full
// incoming family, not just the bypass successor row.
ElkNodePlacementAlignment.CenterMultiIncomingNodes(
positionedNodes, incomingNodeIds, nodesById, options.Direction);
// Final routing-clearance enforcement: after all refinement converged,
// push connected nodes apart where the Y-gap is too tight for clean
// edge routing (< 12px). This is a one-shot nudge — no cascade.
@@ -271,6 +278,9 @@ internal static class ElkSharpLayoutInitialPlacement
ElkNodePlacementAlignment.PropagateSuccessorPositionBackward(
positionedNodes, outgoingNodeIds, nodesById, options.Direction);
ElkNodePlacementAlignment.CenterMultiIncomingNodes(
positionedNodes, incomingNodeIds, nodesById, options.Direction);
minNodeX = positionedNodes.Values.Min(n => n.X);
if (minNodeX < -0.01d)
{