Files
git.stella-ops.org/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs

1407 lines
51 KiB
C#

namespace StellaOps.ElkSharp;
internal static partial class ElkEdgePostProcessor
{
private static readonly object UnderNodeDebugSync = new();
private const string ProtectedUnderNodeKindMarker = "protected-undernode";
internal static ElkRoutedEdge[] SnapAnchorsToNodeBoundary(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes)
{
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
var result = new ElkRoutedEdge[edges.Length];
for (var i = 0; i < edges.Length; i++)
{
var edge = edges[i];
var anyChanged = false;
var newSections = edge.Sections.ToList();
for (var s = 0; s < newSections.Count; s++)
{
var section = newSections[s];
var startFixed = false;
var endFixed = false;
var newStart = section.StartPoint;
var newEnd = section.EndPoint;
if (edge.SourceNodeId is not null && nodesById.TryGetValue(edge.SourceNodeId, out var srcNode) && s == 0)
{
if (newStart.X > srcNode.X + 1d && newStart.X < srcNode.X + srcNode.Width - 1d
&& newStart.Y > srcNode.Y + 1d && newStart.Y < srcNode.Y + srcNode.Height - 1d)
{
var target = section.BendPoints.Count > 0 ? section.BendPoints.First() : section.EndPoint;
newStart = ElkShapeBoundaries.ProjectOntoShapeBoundary(srcNode, target);
startFixed = true;
}
}
if (edge.TargetNodeId is not null && nodesById.TryGetValue(edge.TargetNodeId, out var tgtNode) && s == newSections.Count - 1)
{
var source = section.BendPoints.Count > 0 ? section.BendPoints.Last() : section.StartPoint;
var projected = ElkShapeBoundaries.ProjectOntoShapeBoundary(tgtNode, source);
if (Math.Abs(projected.X - newEnd.X) > 3d || Math.Abs(projected.Y - newEnd.Y) > 3d)
{
newEnd = projected;
endFixed = true;
}
}
if (startFixed || endFixed)
{
anyChanged = true;
newSections[s] = new ElkEdgeSection
{
StartPoint = newStart,
EndPoint = newEnd,
BendPoints = section.BendPoints,
};
}
}
result[i] = anyChanged
? new ElkRoutedEdge { Id = edge.Id, SourceNodeId = edge.SourceNodeId, TargetNodeId = edge.TargetNodeId, Label = edge.Label, Sections = newSections }
: edge;
}
return result;
}
internal static ElkRoutedEdge[] ClearInternalRoutingMarkers(ElkRoutedEdge[] edges)
{
if (edges.Length == 0)
{
return edges;
}
var changed = false;
var result = new ElkRoutedEdge[edges.Length];
for (var i = 0; i < edges.Length; i++)
{
var edge = edges[i];
var cleanedKind = RemoveInternalKindMarker(edge.Kind, ProtectedUnderNodeKindMarker);
if (string.Equals(cleanedKind, edge.Kind, StringComparison.Ordinal))
{
result[i] = edge;
continue;
}
changed = true;
result[i] = CloneEdgeWithKind(edge, cleanedKind);
}
return changed ? result : edges;
}
internal static ElkRoutedEdge[] AvoidNodeCrossings(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
ElkLayoutDirection direction,
IReadOnlyCollection<string>? restrictedEdgeIds = null)
{
if (direction != ElkLayoutDirection.LeftToRight || nodes.Length == 0)
{
return edges;
}
const double margin = 18d;
var obstacles = nodes.Select(n => (
Left: n.X - margin, Top: n.Y - margin,
Right: n.X + n.Width + margin, Bottom: n.Y + n.Height + margin,
Id: n.Id
)).ToArray();
var graphMinY = nodes.Min(n => n.Y);
var graphMaxY = nodes.Max(n => n.Y + n.Height);
var restrictedSet = restrictedEdgeIds is null
? null
: restrictedEdgeIds.ToHashSet(StringComparer.Ordinal);
var result = new ElkRoutedEdge[edges.Length];
for (var edgeIndex = 0; edgeIndex < edges.Length; edgeIndex++)
{
var edge = edges[edgeIndex];
if (restrictedSet is not null && !restrictedSet.Contains(edge.Id))
{
result[edgeIndex] = edge;
continue;
}
var sourceId = edge.SourceNodeId ?? "";
var targetId = edge.TargetNodeId ?? "";
var hasCorridorPts = HasCorridorBendPoints(edge, graphMinY, graphMaxY);
var hasCrossing = false;
foreach (var section in edge.Sections)
{
var pts = new List<ElkPoint> { section.StartPoint };
pts.AddRange(section.BendPoints);
pts.Add(section.EndPoint);
for (var i = 0; i < pts.Count - 1 && !hasCrossing; i++)
{
if (hasCorridorPts && IsCorridorSegment(pts[i], pts[i + 1], graphMinY, graphMaxY))
{
continue;
}
hasCrossing = SegmentCrossesObstacle(pts[i], pts[i + 1], obstacles, sourceId, targetId);
}
}
if (!hasCrossing)
{
result[edgeIndex] = edge;
continue;
}
var hasCorridorPoints = hasCorridorPts;
var newSections = new List<ElkEdgeSection>(edge.Sections.Count);
foreach (var section in edge.Sections)
{
if (hasCorridorPoints)
{
var corridorRerouted = ElkEdgePostProcessorCorridor.ReroutePreservingCorridor(
section, obstacles, sourceId, targetId, margin, graphMinY, graphMaxY);
if (corridorRerouted is not null)
{
newSections.Add(corridorRerouted);
continue;
}
}
var rerouted = ElkEdgePostProcessorAStar.RerouteWithGridAStar(
section.StartPoint, section.EndPoint,
obstacles, sourceId, targetId, margin);
if (rerouted is not null && rerouted.Count >= 2)
{
newSections.Add(new ElkEdgeSection
{
StartPoint = rerouted[0],
EndPoint = rerouted[^1],
BendPoints = rerouted.Skip(1).Take(rerouted.Count - 2).ToArray(),
});
}
else
{
newSections.Add(section);
}
}
result[edgeIndex] = new ElkRoutedEdge
{
Id = edge.Id,
SourceNodeId = edge.SourceNodeId,
TargetNodeId = edge.TargetNodeId,
Label = edge.Label,
Sections = newSections,
};
}
return result;
}
internal static ElkRoutedEdge[] EliminateDiagonalSegments(ElkRoutedEdge[] edges, ElkPositionedNode[] nodes)
{
var graphMinY = nodes.Length > 0 ? nodes.Min(n => n.Y) : 0d;
var graphMaxY = nodes.Length > 0 ? nodes.Max(n => n.Y + n.Height) : 0d;
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
var obstacles = nodes.Select(n => (L: n.X - 4d, T: n.Y - 4d, R: n.X + n.Width + 4d, B: n.Y + n.Height + 4d, Id: n.Id)).ToArray();
var result = new ElkRoutedEdge[edges.Length];
for (var i = 0; i < edges.Length; i++)
{
var edge = edges[i];
var anyFixed = false;
var newSections = new List<ElkEdgeSection>();
var sectionList = edge.Sections.ToList();
for (var sIdx = 0; sIdx < sectionList.Count; sIdx++)
{
var section = sectionList[sIdx];
var isLastSection = sIdx == sectionList.Count - 1;
var pts = new List<ElkPoint> { section.StartPoint };
pts.AddRange(section.BendPoints);
pts.Add(section.EndPoint);
var fixedPts = new List<ElkPoint> { pts[0] };
for (var j = 1; j < pts.Count; j++)
{
var prev = fixedPts[^1];
var curr = pts[j];
var dx = Math.Abs(curr.X - prev.X);
var dy = Math.Abs(curr.Y - prev.Y);
if (dx > 3d && dy > 3d)
{
var prevIsCorridor = prev.Y < graphMinY - 8d || prev.Y > graphMaxY + 8d;
var currIsCorridor = curr.Y < graphMinY - 8d || curr.Y > graphMaxY + 8d;
var isBackwardSection = section.EndPoint.X < section.StartPoint.X - 1d;
if (prevIsCorridor)
{
fixedPts.Add(new ElkPoint { X = curr.X, Y = prev.Y });
anyFixed = true;
}
else if (currIsCorridor && isBackwardSection)
{
// Preserve diagonal for backward collector edges
}
else if (isLastSection && j == pts.Count - 1 && !isBackwardSection)
{
// Target approach: L-corner must be perpendicular to the entry side.
// Vertical side (left/right) → last segment horizontal (default).
// Horizontal side (top/bottom) → last segment vertical (flipped).
var targetNode = nodesById.GetValueOrDefault(edge.TargetNodeId ?? "");
var onHorizontalSide = targetNode is not null
&& (Math.Abs(curr.Y - targetNode.Y) < 2d
|| Math.Abs(curr.Y - (targetNode.Y + targetNode.Height)) < 2d);
if (onHorizontalSide)
{
fixedPts.Add(new ElkPoint { X = curr.X, Y = prev.Y });
}
else
{
fixedPts.Add(new ElkPoint { X = prev.X, Y = curr.Y });
}
anyFixed = true;
}
else
{
fixedPts.Add(new ElkPoint { X = prev.X, Y = curr.Y });
anyFixed = true;
}
}
fixedPts.Add(curr);
}
newSections.Add(new ElkEdgeSection
{
StartPoint = fixedPts[0],
EndPoint = fixedPts[^1],
BendPoints = fixedPts.Skip(1).Take(fixedPts.Count - 2).ToArray(),
});
}
result[i] = anyFixed
? new ElkRoutedEdge { Id = edge.Id, SourceNodeId = edge.SourceNodeId, TargetNodeId = edge.TargetNodeId, Label = edge.Label, Sections = newSections }
: edge;
}
return result;
}
internal static ElkRoutedEdge[] NormalizeBoundaryAngles(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes)
{
if (edges.Length == 0 || nodes.Length == 0)
{
return edges;
}
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 result = new ElkRoutedEdge[edges.Length];
for (var i = 0; i < edges.Length; i++)
{
var edge = edges[i];
var path = new List<ElkPoint>();
foreach (var section in edge.Sections)
{
if (path.Count == 0)
{
path.Add(section.StartPoint);
}
path.AddRange(section.BendPoints);
path.Add(section.EndPoint);
}
if (path.Count < 2)
{
result[i] = edge;
continue;
}
var normalized = path;
var preserveSourceExit = ShouldPreserveSourceExitGeometry(edge, graphMinY, graphMaxY);
if (!preserveSourceExit
&& nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode))
{
if (!(ElkShapeBoundaries.IsGatewayShape(sourceNode)
&& ShouldPreserveSaturatedGatewaySourceFace(edge, edges, sourceNode, normalized)))
{
List<ElkPoint> sourceNormalized;
if (ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
sourceNormalized = NormalizeGatewayExitPath(normalized, sourceNode, nodes, edge.SourceNodeId, edge.TargetNodeId);
if (PathStartsAtDecisionVertex(sourceNormalized, sourceNode))
{
sourceNormalized = ForceDecisionSourceExitOffVertex(
sourceNormalized,
sourceNode,
nodes,
edge.SourceNodeId,
edge.TargetNodeId);
}
}
else
{
var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[0], sourceNode);
sourceNormalized = NormalizeExitPath(normalized, sourceNode, sourceSide);
}
if (HasClearSourceExitSegment(sourceNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId))
{
normalized = sourceNormalized;
}
}
}
if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode))
{
if (ElkShapeBoundaries.IsGatewayShape(targetNode))
{
var gatewayNormalized = NormalizeGatewayEntryPath(normalized, targetNode, normalized[^1]);
if (HasAcceptableGatewayBoundaryPath(gatewayNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId, targetNode, fromStart: false))
{
normalized = gatewayNormalized;
}
}
else
{
var targetSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[^1], targetNode);
ElkPoint? preferredEndpoint = null;
if (HasTargetApproachBacktracking(normalized, targetNode)
&& TryResolveNonGatewayBacktrackingEndpoint(normalized, targetNode, out var preferredSide, out var preferredBoundary))
{
targetSide = preferredSide;
preferredEndpoint = preferredBoundary;
}
normalized = NormalizeEntryPath(normalized, targetNode, targetSide, preferredEndpoint);
if (HasTargetApproachBacktracking(normalized, targetNode)
&& TryNormalizeNonGatewayBacktrackingEntry(normalized, targetNode, out var backtrackingRepair))
{
normalized = backtrackingRepair;
}
}
}
if (normalized.Count == path.Count
&& normalized.Zip(path, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal))
{
result[i] = edge;
continue;
}
result[i] = new ElkRoutedEdge
{
Id = edge.Id,
SourceNodeId = edge.SourceNodeId,
TargetNodeId = edge.TargetNodeId,
SourcePortId = edge.SourcePortId,
TargetPortId = edge.TargetPortId,
Kind = edge.Kind,
Label = edge.Label,
Sections =
[
new ElkEdgeSection
{
StartPoint = normalized[0],
EndPoint = normalized[^1],
BendPoints = normalized.Count > 2
? normalized.Skip(1).Take(normalized.Count - 2).ToArray()
: [],
},
],
};
}
return result;
}
internal static ElkRoutedEdge[] NormalizeTargetEntryAngles(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes)
{
return NormalizeBoundaryAngles(edges, nodes);
}
internal static ElkRoutedEdge[] NormalizeSourceExitAngles(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes)
{
if (edges.Length == 0 || nodes.Length == 0)
{
return edges;
}
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 result = new ElkRoutedEdge[edges.Length];
for (var i = 0; i < edges.Length; i++)
{
var edge = edges[i];
var preserveSourceExit = ShouldPreserveSourceExitGeometry(edge, graphMinY, graphMaxY);
if (!nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode))
{
result[i] = edge;
continue;
}
var path = new List<ElkPoint>();
foreach (var section in edge.Sections)
{
if (path.Count == 0)
{
path.Add(section.StartPoint);
}
path.AddRange(section.BendPoints);
path.Add(section.EndPoint);
}
if (path.Count < 2)
{
result[i] = edge;
continue;
}
if (preserveSourceExit)
{
var hasAcceptablePreservedExit = ElkShapeBoundaries.IsGatewayShape(sourceNode)
? HasAcceptableGatewayBoundaryPath(path, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true)
: HasValidBoundaryAngle(path[0], path[1], sourceNode);
if (hasAcceptablePreservedExit)
{
result[i] = edge;
continue;
}
}
if (ElkShapeBoundaries.IsGatewayShape(sourceNode)
&& ShouldPreserveSaturatedGatewaySourceFace(edge, edges, sourceNode, path))
{
result[i] = edge;
continue;
}
var sourceSide = ElkShapeBoundaries.IsGatewayShape(sourceNode)
? ElkEdgeRoutingGeometry.ResolveBoundarySide(path[0], sourceNode)
: ResolvePreferredRectSourceExitSide(path, sourceNode);
List<ElkPoint> normalized;
if (ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
normalized = NormalizeGatewayExitPath(path, sourceNode, nodes, edge.SourceNodeId, edge.TargetNodeId);
}
else
{
var sourcePath = path
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToList();
if (!HasValidBoundaryAngle(sourcePath[0], sourcePath[1], sourceNode))
{
sourcePath[0] = BuildRectBoundaryPointForSide(sourceNode, sourceSide, sourcePath[1]);
}
normalized = NormalizeExitPath(sourcePath, sourceNode, sourceSide);
}
if (ElkShapeBoundaries.IsGatewayShape(sourceNode)
&& PathStartsAtDecisionVertex(normalized, sourceNode))
{
normalized = ForceDecisionSourceExitOffVertex(
normalized,
sourceNode,
nodes,
edge.SourceNodeId,
edge.TargetNodeId);
}
if (ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
normalized = EnforceGatewaySourceExitQuality(
normalized,
sourceNode,
nodes,
edge.SourceNodeId,
edge.TargetNodeId);
}
if (ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
if (!HasAcceptableGatewayBoundaryPath(normalized, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true))
{
result[i] = edge;
continue;
}
}
else if (!HasClearSourceExitSegment(normalized, nodes, edge.SourceNodeId, edge.TargetNodeId))
{
result[i] = edge;
continue;
}
if (normalized.Count == path.Count
&& normalized.Zip(path, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal))
{
result[i] = edge;
continue;
}
result[i] = new ElkRoutedEdge
{
Id = edge.Id,
SourceNodeId = edge.SourceNodeId,
TargetNodeId = edge.TargetNodeId,
SourcePortId = edge.SourcePortId,
TargetPortId = edge.TargetPortId,
Kind = edge.Kind,
Label = edge.Label,
Sections =
[
new ElkEdgeSection
{
StartPoint = normalized[0],
EndPoint = normalized[^1],
BendPoints = normalized.Count > 2
? normalized.Skip(1).Take(normalized.Count - 2).ToArray()
: [],
},
],
};
}
return result;
}
internal static ElkRoutedEdge[] FinalizeDecisionTargetEntries(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
IReadOnlyCollection<string>? restrictedEdgeIds = null)
{
if (edges.Length == 0 || nodes.Length == 0)
{
return edges;
}
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
var restrictedSet = restrictedEdgeIds is null
? null
: restrictedEdgeIds.ToHashSet(StringComparer.Ordinal);
var result = edges.ToArray();
var changedEdgeIds = new List<string>();
for (var i = 0; i < result.Length; i++)
{
var edge = result[i];
if (restrictedSet is not null && !restrictedSet.Contains(edge.Id))
{
continue;
}
if (!nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)
|| targetNode.Kind != "Decision")
{
continue;
}
var path = ExtractFullPath(edge);
List<ElkPoint> repaired;
if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)
&& TryBuildPreferredGatewayTargetEntryPath(
path,
sourceNode,
targetNode,
nodes,
edge.SourceNodeId,
edge.TargetNodeId,
out var preferredGatewayTargetRepair))
{
repaired = preferredGatewayTargetRepair;
}
else
{
repaired = NormalizeGatewayEntryPath(path, targetNode, path[^1]);
}
if ((!CanAcceptGatewayTargetRepair(repaired, targetNode)
|| ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, repaired[^2])
|| HasShortGatewayTargetOrthogonalHook(repaired, targetNode))
&& targetNode.Kind == "Decision")
{
repaired = ForceDecisionExteriorTargetEntry(path, targetNode);
}
if ((!PathChanged(path, repaired)
|| !CanAcceptGatewayTargetRepair(repaired, targetNode)
|| HasShortGatewayTargetOrthogonalHook(repaired, targetNode))
&& targetNode.Kind == "Decision")
{
repaired = ForceDecisionDirectTargetEntry(path, targetNode);
}
if (!PathChanged(path, repaired)
|| !CanAcceptGatewayTargetRepair(repaired, targetNode)
|| HasShortGatewayTargetOrthogonalHook(repaired, targetNode))
{
continue;
}
result[i] = BuildSingleSectionEdge(edge, repaired);
changedEdgeIds.Add(edge.Id);
}
if (changedEdgeIds.Count == 0)
{
return result;
}
var focusedEdgeIds = changedEdgeIds
.Distinct(StringComparer.Ordinal)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
var minLineClearance = ResolveMinLineClearance(nodes);
result = SpreadSourceDepartureJoins(result, nodes, minLineClearance, focusedEdgeIds);
result = SeparateMixedNodeFaceLaneConflicts(result, nodes, minLineClearance, focusedEdgeIds);
result = SeparateSharedLaneConflicts(result, nodes, minLineClearance, focusedEdgeIds);
return result;
}
private static bool TryBuildPreferredGatewayTargetEntryPath(
IReadOnlyList<ElkPoint> path,
ElkPositionedNode sourceNode,
ElkPositionedNode targetNode,
IReadOnlyCollection<ElkPositionedNode> nodes,
string? sourceNodeId,
string? targetNodeId,
out List<ElkPoint> repaired)
{
repaired = [];
if (!ElkShapeBoundaries.IsGatewayShape(targetNode) || path.Count < 2)
{
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(40d, (sourceNode.Height + targetNode.Height) / 3d);
var sameColumnThreshold = Math.Max(40d, (sourceNode.Width + targetNode.Width) / 3d);
string? preferredSide = null;
double preferredSlotCoordinate = 0d;
if (((absDx >= absDy * 1.15d && absDy <= sameRowThreshold)
|| (absDx >= absDy * 1.75d))
&& Math.Sign(deltaX) != 0)
{
preferredSide = deltaX > 0d ? "left" : "right";
preferredSlotCoordinate = sourceCenterY;
}
else if (((absDy >= absDx * 1.15d && absDx <= sameColumnThreshold)
|| (absDy >= absDx * 1.75d))
&& Math.Sign(deltaY) != 0)
{
preferredSide = deltaY > 0d ? "top" : "bottom";
preferredSlotCoordinate = sourceCenterX;
}
if (preferredSide is null
|| !ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, preferredSide, preferredSlotCoordinate, out var preferredBoundary))
{
return false;
}
var exteriorIndex = FindLastGatewayExteriorPointIndex(path, targetNode);
var exteriorAnchor = path[exteriorIndex];
preferredBoundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(targetNode, preferredBoundary, exteriorAnchor);
var directPreferred = path.Take(exteriorIndex + 1)
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToList();
if (directPreferred.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(directPreferred[^1], exteriorAnchor))
{
directPreferred.Add(new ElkPoint { X = exteriorAnchor.X, Y = exteriorAnchor.Y });
}
directPreferred.Add(new ElkPoint { X = preferredBoundary.X, Y = preferredBoundary.Y });
var directNormalized = NormalizePathPoints(directPreferred);
if (directNormalized.Count >= 2
&& string.Equals(ElkEdgeRoutingGeometry.ResolveBoundarySide(directNormalized[^1], targetNode), preferredSide, StringComparison.Ordinal)
&& CanAcceptGatewayTargetRepair(directNormalized, targetNode)
&& HasAcceptableGatewayBoundaryPath(directNormalized, nodes, sourceNodeId, targetNodeId, targetNode, fromStart: false))
{
repaired = directNormalized;
return true;
}
var normalizedPreferred = NormalizeGatewayEntryPath(path, targetNode, preferredBoundary);
if (normalizedPreferred.Count < 2
|| !string.Equals(ElkEdgeRoutingGeometry.ResolveBoundarySide(normalizedPreferred[^1], targetNode), preferredSide, StringComparison.Ordinal)
|| !CanAcceptGatewayTargetRepair(normalizedPreferred, targetNode)
|| !HasAcceptableGatewayBoundaryPath(normalizedPreferred, nodes, sourceNodeId, targetNodeId, targetNode, fromStart: false))
{
return false;
}
repaired = normalizedPreferred;
return true;
}
internal static ElkRoutedEdge[] PreferShortestBoundaryShortcuts(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
IReadOnlyCollection<string>? restrictedEdgeIds = null)
{
if (edges.Length == 0 || nodes.Length == 0)
{
return edges;
}
var restrictedSet = restrictedEdgeIds is null
? null
: restrictedEdgeIds.ToHashSet(StringComparer.Ordinal);
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 minLineClearance = ResolveMinLineClearance(nodes);
var result = edges.ToArray();
for (var i = 0; i < result.Length; i++)
{
var edge = result[i];
if (restrictedSet is not null && !restrictedSet.Contains(edge.Id))
{
continue;
}
if (!string.IsNullOrWhiteSpace(edge.SourcePortId)
|| !string.IsNullOrWhiteSpace(edge.TargetPortId)
|| !nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)
|| !nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode))
{
continue;
}
if (!string.IsNullOrWhiteSpace(edge.Kind)
&& edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (HasProtectedUnderNodeGeometry(edge)
&& ElkEdgeRoutingScoring.CountUnderNodeViolations([edge], nodes) > 0)
{
continue;
}
if (IsRepeatCollectorLabel(edge.Label)
&& HasCorridorBendPoints(edge, graphMinY, graphMaxY))
{
continue;
}
var path = ExtractFullPath(edge);
if (path.Count < 2)
{
continue;
}
List<ElkPoint>? bestShortcut = null;
var currentLength = ComputePathLength(path);
bool IsAcceptableShortcutCandidate(IReadOnlyList<ElkPoint> candidate)
{
if (candidate.Count < 2
|| HasNodeObstacleCrossing(candidate, nodes, edge.SourceNodeId, edge.TargetNodeId))
{
return false;
}
if (ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
if (!HasAcceptableGatewayBoundaryPath(
candidate,
nodes,
edge.SourceNodeId,
edge.TargetNodeId,
sourceNode,
fromStart: true))
{
return false;
}
}
else if (!HasValidBoundaryAngle(candidate[0], candidate[1], sourceNode))
{
return false;
}
if (ElkShapeBoundaries.IsGatewayShape(targetNode))
{
return CanAcceptGatewayTargetRepair(candidate, targetNode)
&& HasAcceptableGatewayBoundaryPath(
candidate,
nodes,
edge.SourceNodeId,
edge.TargetNodeId,
targetNode,
fromStart: false);
}
return !HasTargetApproachBacktracking(candidate, targetNode)
&& HasValidBoundaryAngle(candidate[^1], candidate[^2], targetNode);
}
void ConsiderShortcutCandidate(List<ElkPoint>? candidate)
{
if (candidate is null
|| ComputePathLength(candidate) + 16d >= currentLength
|| !IsAcceptableShortcutCandidate(candidate))
{
return;
}
if (bestShortcut is not null
&& ComputePathLength(candidate) + 0.5d >= ComputePathLength(bestShortcut))
{
return;
}
bestShortcut = candidate;
}
if (TryBuildDominantPreferredBoundaryShortcutPath(
sourceNode,
targetNode,
nodes,
edge.SourceNodeId,
edge.TargetNodeId,
out var dominantShortcut))
{
ConsiderShortcutCandidate(dominantShortcut);
}
if (TryBuildPreferredBoundaryShortcutPath(
sourceNode,
targetNode,
nodes,
edge.SourceNodeId,
edge.TargetNodeId,
out var preferredShortcut))
{
ConsiderShortcutCandidate(preferredShortcut);
}
var localSkirtShortcut = TryBuildLocalObstacleSkirtBoundaryShortcut(
path,
path[0],
path[^1],
nodes,
edge.SourceNodeId,
edge.TargetNodeId,
targetNode,
minLineClearance);
ConsiderShortcutCandidate(localSkirtShortcut);
if (bestShortcut is null)
{
continue;
}
result[i] = BuildSingleSectionEdge(edge, bestShortcut);
}
return result;
}
private static bool TryBuildDominantPreferredBoundaryShortcutPath(
ElkPositionedNode sourceNode,
ElkPositionedNode targetNode,
IReadOnlyCollection<ElkPositionedNode> nodes,
string? sourceNodeId,
string? targetNodeId,
out List<ElkPoint> shortcut)
{
shortcut = [];
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 < 16d && absDy < 16d)
{
return false;
}
var horizontalDominant = absDx >= absDy;
var preferredSourceSide = horizontalDominant
? deltaX >= 0d ? "right" : "left"
: deltaY >= 0d ? "bottom" : "top";
var preferredTargetSide = horizontalDominant
? deltaX >= 0d ? "left" : "right"
: deltaY >= 0d ? "top" : "bottom";
return TryBuildPreferredBoundaryShortcutPath(
sourceNode,
targetNode,
preferredSourceSide,
preferredTargetSide,
nodes,
sourceNodeId,
targetNodeId,
out shortcut);
}
internal static bool TryBuildPreferredBoundaryShortcutPath(
ElkPositionedNode sourceNode,
ElkPositionedNode targetNode,
IReadOnlyCollection<ElkPositionedNode> nodes,
string? sourceNodeId,
string? targetNodeId,
out List<ElkPoint> shortcut)
{
shortcut = [];
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 < 16d && absDy < 16d)
{
return false;
}
var horizontalDominant = absDx >= absDy;
var preferredSourceSide = horizontalDominant
? deltaX >= 0d ? "right" : "left"
: deltaY >= 0d ? "bottom" : "top";
var preferredTargetSide = horizontalDominant
? deltaX >= 0d ? "left" : "right"
: deltaY >= 0d ? "top" : "bottom";
if (!ElkShapeBoundaries.IsGatewayShape(sourceNode)
&& !ElkShapeBoundaries.IsGatewayShape(targetNode))
{
return TryBuildPreferredBoundaryShortcutPath(
sourceNode,
targetNode,
preferredSourceSide,
preferredTargetSide,
nodes,
sourceNodeId,
targetNodeId,
out shortcut);
}
var minLineClearance = ResolveMinLineClearance(nodes);
var bestScore = double.PositiveInfinity;
foreach (var candidateTargetSide in EnumeratePreferredShortcutTargetSides(preferredTargetSide))
{
if (!TryBuildPreferredBoundaryShortcutPath(
sourceNode,
targetNode,
preferredSourceSide,
candidateTargetSide,
nodes,
sourceNodeId,
targetNodeId,
out var candidate))
{
continue;
}
var score =
(CountUnderNodeSegments(candidate, nodes, sourceNodeId, targetNodeId, minLineClearance) * 100_000d)
+ ComputePathLength(candidate)
+ (Math.Max(0, candidate.Count - 2) * 4d);
if (!string.Equals(candidateTargetSide, preferredTargetSide, StringComparison.Ordinal))
{
score += 40d;
}
if (score >= bestScore)
{
continue;
}
bestScore = score;
shortcut = candidate;
}
return shortcut.Count > 0;
}
internal static bool TryBuildPreferredBoundaryShortcutPath(
ElkPositionedNode sourceNode,
ElkPositionedNode targetNode,
string preferredSourceSide,
string preferredTargetSide,
IReadOnlyCollection<ElkPositionedNode> nodes,
string? sourceNodeId,
string? targetNodeId,
out List<ElkPoint> shortcut)
{
shortcut = [];
var start = BuildPreferredShortcutBoundaryPoint(sourceNode, preferredSourceSide, targetNode);
var end = BuildPreferredShortcutBoundaryPoint(targetNode, preferredTargetSide, sourceNode);
bool SegmentIsClear(ElkPoint from, ElkPoint to)
{
var obstacles = nodes.Select(node => (
Left: node.X,
Top: node.Y,
Right: node.X + node.Width,
Bottom: node.Y + node.Height,
Id: node.Id)).ToArray();
return !SegmentCrossesObstacle(from, to, obstacles, sourceNodeId, targetNodeId);
}
var prefix = new List<ElkPoint> { start };
var routeStart = start;
if (ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
var sourceExterior = ElkShapeBoundaries.BuildPreferredGatewaySourceExteriorPoint(sourceNode, start, end);
if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(sourceNode, sourceExterior)
|| !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(sourceNode, start, sourceExterior))
{
return false;
}
if (!SegmentIsClear(start, sourceExterior))
{
return false;
}
prefix.Add(sourceExterior);
routeStart = sourceExterior;
}
var routeEnd = end;
if (ElkShapeBoundaries.IsGatewayShape(targetNode))
{
var targetExterior = ElkShapeBoundaries.BuildGatewayDirectionalExteriorPoint(targetNode, end, routeStart);
if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(targetNode, targetExterior)
|| !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, end, targetExterior))
{
return false;
}
routeEnd = targetExterior;
}
List<ElkPoint>? bridge = null;
if (Math.Abs(routeStart.X - routeEnd.X) <= 0.5d || Math.Abs(routeStart.Y - routeEnd.Y) <= 0.5d)
{
if (SegmentIsClear(routeStart, routeEnd))
{
bridge =
[
routeStart,
routeEnd,
];
}
}
if (bridge is null)
{
var pivots = new[]
{
new ElkPoint { X = routeEnd.X, Y = routeStart.Y },
new ElkPoint { X = routeStart.X, Y = routeEnd.Y },
};
foreach (var pivot in pivots)
{
if (!SegmentIsClear(routeStart, pivot) || !SegmentIsClear(pivot, routeEnd))
{
continue;
}
bridge =
[
routeStart,
pivot,
routeEnd,
];
break;
}
}
if (bridge is null)
{
return false;
}
var candidate = prefix
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToList();
foreach (var point in bridge.Skip(1))
{
if (!ElkEdgeRoutingGeometry.PointsEqual(candidate[^1], point))
{
candidate.Add(new ElkPoint { X = point.X, Y = point.Y });
}
}
if (ElkShapeBoundaries.IsGatewayShape(targetNode)
&& !ElkEdgeRoutingGeometry.PointsEqual(candidate[^1], end))
{
candidate.Add(new ElkPoint { X = end.X, Y = end.Y });
}
shortcut = NormalizePathPoints(candidate);
if (shortcut.Count < 2)
{
shortcut = [];
return false;
}
if (ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
if (!HasAcceptableGatewayBoundaryPath(shortcut, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true))
{
shortcut = [];
return false;
}
}
else if (!HasValidBoundaryAngle(shortcut[0], shortcut[1], sourceNode))
{
shortcut = [];
return false;
}
if (ElkShapeBoundaries.IsGatewayShape(targetNode))
{
if (!CanAcceptGatewayTargetRepair(shortcut, targetNode)
|| !HasAcceptableGatewayBoundaryPath(shortcut, nodes, sourceNodeId, targetNodeId, targetNode, fromStart: false))
{
shortcut = [];
return false;
}
}
else if (HasTargetApproachBacktracking(shortcut, targetNode)
|| !HasValidBoundaryAngle(shortcut[^1], shortcut[^2], targetNode))
{
shortcut = [];
return false;
}
return true;
}
private static ElkPoint BuildPreferredShortcutBoundaryPoint(
ElkPositionedNode node,
string side,
ElkPositionedNode otherNode)
{
var horizontalInset = Math.Min(24d, Math.Max(12d, node.Width / 4d));
var verticalInset = Math.Min(24d, Math.Max(12d, node.Height / 4d));
var otherCenterX = otherNode.X + (otherNode.Width / 2d);
var otherCenterY = otherNode.Y + (otherNode.Height / 2d);
var boundary = side switch
{
"left" => new ElkPoint
{
X = node.X,
Y = Math.Clamp(otherCenterY, node.Y + verticalInset, (node.Y + node.Height) - verticalInset),
},
"right" => new ElkPoint
{
X = node.X + node.Width,
Y = Math.Clamp(otherCenterY, node.Y + verticalInset, (node.Y + node.Height) - verticalInset),
},
"top" => new ElkPoint
{
X = Math.Clamp(otherCenterX, node.X + horizontalInset, (node.X + node.Width) - horizontalInset),
Y = node.Y,
},
_ => new ElkPoint
{
X = Math.Clamp(otherCenterX, node.X + horizontalInset, (node.X + node.Width) - horizontalInset),
Y = node.Y + node.Height,
},
};
if (!ElkShapeBoundaries.IsGatewayShape(node))
{
return boundary;
}
var referencePoint = side switch
{
"left" => new ElkPoint { X = node.X - Math.Max(24d, node.Width / 3d), Y = boundary.Y },
"right" => new ElkPoint { X = node.X + node.Width + Math.Max(24d, node.Width / 3d), Y = boundary.Y },
"top" => new ElkPoint { X = boundary.X, Y = node.Y - Math.Max(24d, node.Height / 3d) },
_ => new ElkPoint { X = boundary.X, Y = node.Y + node.Height + Math.Max(24d, node.Height / 3d) },
};
var projected = ElkShapeBoundaries.ProjectOntoShapeBoundary(node, referencePoint);
return ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(node, projected, referencePoint);
}
private static IEnumerable<string> EnumeratePreferredShortcutTargetSides(string preferredTargetSide)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
bool Add(string side)
{
return side is "left" or "right" or "top" or "bottom"
&& seen.Add(side);
}
if (Add(preferredTargetSide))
{
yield return preferredTargetSide;
}
if (preferredTargetSide is "left" or "right")
{
if (Add("top"))
{
yield return "top";
}
if (Add("bottom"))
{
yield return "bottom";
}
var oppositeHorizontal = string.Equals(preferredTargetSide, "left", StringComparison.Ordinal)
? "right"
: "left";
if (Add(oppositeHorizontal))
{
yield return oppositeHorizontal;
}
yield break;
}
if (Add("left"))
{
yield return "left";
}
if (Add("right"))
{
yield return "right";
}
var oppositeVertical = string.Equals(preferredTargetSide, "top", StringComparison.Ordinal)
? "bottom"
: "top";
if (Add(oppositeVertical))
{
yield return oppositeVertical;
}
}
private static ElkRoutedEdge CloneEdgeWithKind(ElkRoutedEdge edge, string? kind)
{
return new ElkRoutedEdge
{
Id = edge.Id,
SourceNodeId = edge.SourceNodeId,
TargetNodeId = edge.TargetNodeId,
SourcePortId = edge.SourcePortId,
TargetPortId = edge.TargetPortId,
Kind = kind,
Label = edge.Label,
Sections = edge.Sections
.Select(section => new ElkEdgeSection
{
StartPoint = section.StartPoint,
EndPoint = section.EndPoint,
BendPoints = section.BendPoints.ToArray(),
})
.ToArray(),
};
}
private static bool ContainsInternalKindMarker(string? kind, string marker)
{
if (string.IsNullOrWhiteSpace(kind))
{
return false;
}
return kind
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(part => string.Equals(part, marker, StringComparison.OrdinalIgnoreCase));
}
private static string AppendInternalKindMarker(string? kind, string marker)
{
if (ContainsInternalKindMarker(kind, marker))
{
return kind!;
}
return string.IsNullOrWhiteSpace(kind)
? marker
: $"{kind}|{marker}";
}
private static string? RemoveInternalKindMarker(string? kind, string marker)
{
if (string.IsNullOrWhiteSpace(kind))
{
return kind;
}
var parts = kind
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(part => !string.Equals(part, marker, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (parts.Length == 0)
{
return null;
}
return string.Join("|", parts);
}
private static List<ElkPoint> NormalizePathPoints(IReadOnlyList<ElkPoint> points)
{
const double coordinateTolerance = 0.5d;
var deduped = new List<ElkPoint>();
foreach (var point in points)
{
if (deduped.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(deduped[^1], point))
{
deduped.Add(point);
}
}
if (deduped.Count <= 2)
{
return deduped;
}
var simplified = new List<ElkPoint> { deduped[0] };
for (var i = 1; i < deduped.Count - 1; i++)
{
var previous = simplified[^1];
var current = deduped[i];
var next = deduped[i + 1];
var sameX = Math.Abs(previous.X - current.X) <= coordinateTolerance
&& Math.Abs(current.X - next.X) <= coordinateTolerance;
var sameY = Math.Abs(previous.Y - current.Y) <= coordinateTolerance
&& Math.Abs(current.Y - next.Y) <= coordinateTolerance;
if (!sameX && !sameY)
{
simplified.Add(current);
}
}
simplified.Add(deduped[^1]);
return simplified;
}
}