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:
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -101,7 +101,8 @@ internal static partial class ElkEdgePostProcessor
|
||||
edge.TargetNodeId);
|
||||
}
|
||||
|
||||
if (ElkShapeBoundaries.IsGatewayShape(sourceNode))
|
||||
if (ElkShapeBoundaries.IsGatewayShape(sourceNode)
|
||||
&& !preserveSourceExit)
|
||||
{
|
||||
normalized = EnforceGatewaySourceExitQuality(
|
||||
normalized,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user