elksharp: stabilize document-processing terminal routing

This commit is contained in:
master
2026-04-05 15:02:12 +03:00
parent 3a0cfcbc89
commit 1151c30e3a
11 changed files with 2946 additions and 71 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -171,7 +171,14 @@ internal static partial class ElkEdgeRouterIterative
if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)
&& ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
if (ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, path[0]))
var suppressForkBypassGatewayChecks = ElkEdgeRoutingScoring.ShouldSuppressForkBypassGatewaySourceExitChecks(
edge,
path,
edges,
nodesById,
sourceNode);
if (ElkEdgePostProcessor.HasProblematicGatewaySourceVertexExit(path, sourceNode))
{
sourceVertexExits++;
focus.Add(edge.Id);
@@ -189,7 +196,8 @@ internal static partial class ElkEdgeRouterIterative
focus.Add(edge.Id);
}
if (HasGatewaySourcePreferredFaceMismatchArtifact(
if (!suppressForkBypassGatewayChecks
&& HasGatewaySourcePreferredFaceMismatchArtifact(
path,
sourceNode,
nodes,
@@ -200,7 +208,8 @@ internal static partial class ElkEdgeRouterIterative
focus.Add(edge.Id);
}
if (HasGatewaySourceDominantAxisDetourArtifact(
if (!suppressForkBypassGatewayChecks
&& HasGatewaySourceDominantAxisDetourArtifact(
path,
sourceNode,
nodes,
@@ -211,7 +220,8 @@ internal static partial class ElkEdgeRouterIterative
focus.Add(edge.Id);
}
if (ElkEdgePostProcessor.HasClearGatewaySourceScoringOpportunity(
if (!suppressForkBypassGatewayChecks
&& ElkEdgePostProcessor.HasClearGatewaySourceScoringOpportunity(
path,
sourceNode,
nodes,

View File

@@ -32,7 +32,9 @@ internal static partial class ElkEdgeRoutingScoring
continue;
}
if (!ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, firstSection.StartPoint))
if (!ElkEdgePostProcessor.HasProblematicGatewaySourceVertexExit(
[firstSection.StartPoint, firstSection.BendPoints.FirstOrDefault() ?? firstSection.EndPoint],
sourceNode))
{
continue;
}
@@ -130,14 +132,24 @@ internal static partial class ElkEdgeRoutingScoring
sourceSideCounts,
boundarySlotSeverityByEdgeId);
var suppressSoftGatewayChecks = allowsSaturatedAlternateFace || isResolvedDiscreteSlotExit;
var suppressForkBypassGatewayChecks = ShouldSuppressForkBypassGatewaySourceExitChecks(
edge,
path,
edges,
nodesById,
sourceNode);
var suppressStableOpportunity = suppressSoftGatewayChecks || suppressForkBypassGatewayChecks;
var hasViolation = HasGatewaySourceExitBacktracking(path)
|| (!suppressSoftGatewayChecks
&& !suppressForkBypassGatewayChecks
&& HasGatewaySourceDominantAxisDetour(path, sourceNode))
|| (!suppressSoftGatewayChecks
&& ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, path[0]))
&& !suppressForkBypassGatewayChecks
&& ElkEdgePostProcessor.HasProblematicGatewaySourceVertexExit(path, sourceNode))
|| (!suppressSoftGatewayChecks
&& !suppressForkBypassGatewayChecks
&& HasGatewaySourcePreferredFaceMismatch(path, sourceNode))
|| (!suppressSoftGatewayChecks
|| (!suppressStableOpportunity
&& currentBoundarySlotViolations == 0
&& currentBadBoundaryAngles == 0
&& HasGraphStableGatewaySourceOpportunity(
@@ -163,6 +175,145 @@ internal static partial class ElkEdgeRoutingScoring
return count;
}
internal static bool ShouldSuppressForkBypassGatewaySourceExitChecks(
ElkRoutedEdge edge,
IReadOnlyList<ElkPoint> path,
IReadOnlyCollection<ElkRoutedEdge> edges,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
ElkPositionedNode sourceNode)
{
if (!string.Equals(sourceNode.Kind, "Fork", StringComparison.Ordinal)
|| path.Count < 2
|| !HasCleanOrthogonalGatewayDeparture(path, sourceNode)
|| HasGatewaySourceExitBacktracking(path)
|| ElkEdgePostProcessor.HasProblematicGatewaySourceVertexExit(path, sourceNode))
{
return false;
}
if (!nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)
|| !string.Equals(targetNode.Kind, "Join", StringComparison.Ordinal))
{
return false;
}
var desiredSide = ResolvePrimaryGatewayDirection(sourceNode, targetNode);
if (desiredSide is null)
{
return false;
}
var currentSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(path[0], path[1], sourceNode);
if (string.Equals(currentSide, desiredSide, StringComparison.Ordinal))
{
return false;
}
if (!IsOrthogonalGatewaySideTransition(currentSide, desiredSide))
{
return false;
}
return edges.Any(peer =>
!string.Equals(peer.Id, edge.Id, StringComparison.Ordinal)
&& string.Equals(peer.SourceNodeId, edge.SourceNodeId, StringComparison.Ordinal)
&& DoesPeerOwnForkPrimaryAxis(peer, nodesById, sourceNode, desiredSide));
}
private static bool DoesPeerOwnForkPrimaryAxis(
ElkRoutedEdge peer,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
ElkPositionedNode sourceNode,
string desiredSide)
{
var peerPath = ExtractPath(peer);
if (peerPath.Count < 2
|| !HasCleanOrthogonalGatewayDeparture(peerPath, sourceNode)
|| HasGatewaySourceExitBacktracking(peerPath)
|| ElkEdgePostProcessor.HasProblematicGatewaySourceVertexExit(peerPath, sourceNode)
|| !string.Equals(
ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(peerPath[0], peerPath[1], sourceNode),
desiredSide,
StringComparison.Ordinal))
{
return false;
}
if (!nodesById.TryGetValue(peer.TargetNodeId ?? string.Empty, out var peerTargetNode))
{
return true;
}
return !string.Equals(peerTargetNode.Kind, "Join", StringComparison.Ordinal);
}
private static string? ResolvePrimaryGatewayDirection(
ElkPositionedNode sourceNode,
ElkPositionedNode targetNode)
{
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 dx = targetCenterX - sourceCenterX;
var dy = targetCenterY - sourceCenterY;
if (Math.Abs(dx) >= Math.Abs(dy) * 1.15d && Math.Sign(dx) != 0)
{
return dx > 0d ? "right" : "left";
}
if (Math.Abs(dy) >= Math.Abs(dx) * 1.15d && Math.Sign(dy) != 0)
{
return dy > 0d ? "bottom" : "top";
}
if (Math.Sign(dx) != 0)
{
return dx > 0d ? "right" : "left";
}
return Math.Sign(dy) == 0
? null
: (dy > 0d ? "bottom" : "top");
}
private static bool HasCleanOrthogonalGatewayDeparture(
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 IsOrthogonalGatewaySideTransition(string? currentSide, string? desiredSide)
{
if (currentSide is not ("left" or "right" or "top" or "bottom")
|| desiredSide is not ("left" or "right" or "top" or "bottom"))
{
return false;
}
var currentIsHorizontal = currentSide is "left" or "right";
var desiredIsHorizontal = desiredSide is "left" or "right";
return currentIsHorizontal != desiredIsHorizontal;
}
private static bool IsResolvedGatewaySourceSlotExit(
ElkRoutedEdge edge,
IReadOnlyList<ElkPoint> path,

View File

@@ -0,0 +1,428 @@
namespace StellaOps.ElkSharp;
internal static class ElkTopCorridorOwnership
{
private const double CoordinateTolerance = 0.5d;
internal static ElkRoutedEdge[] SpreadAboveGraphCorridorLanes(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
double minLineClearance)
{
if (edges.Length < 2 || nodes.Length == 0)
{
return edges;
}
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
var graphMinY = nodes.Min(node => node.Y);
var result = edges.ToArray();
var changed = false;
var candidates = result
.Select((edge, index) => CreateCandidate(edge, index, nodesById, graphMinY))
.Where(candidate => candidate is not null)
.Select(candidate => candidate!.Value)
.OrderBy(candidate => candidate.MinX)
.ThenBy(candidate => candidate.MaxX)
.ToArray();
if (candidates.Length < 2)
{
return edges;
}
foreach (var cluster in BuildOverlapClusters(candidates))
{
if (cluster.Length < 2)
{
continue;
}
var baselineMetrics = MeasureClusterMetrics(cluster, result, nodes, nodesById, graphMinY);
var candidateEdges = RewriteCluster(result, cluster, graphMinY, minLineClearance);
if (ReferenceEquals(candidateEdges, result))
{
continue;
}
var candidateMetrics = MeasureClusterMetrics(cluster, candidateEdges, nodes, nodesById, graphMinY);
if (!candidateMetrics.IsBetterThan(baselineMetrics))
{
continue;
}
result = candidateEdges;
changed = true;
}
return changed ? result : edges;
}
private static double ResolveLaneStep(
AboveGraphCorridorCandidate? previousAssignedLane,
AboveGraphCorridorCandidate currentLane,
double minLineClearance)
{
var compactGap = Math.Max(14d, (minLineClearance * 0.55d) + 4d);
if (previousAssignedLane is null)
{
return compactGap;
}
return previousAssignedLane.Value.Priority == currentLane.Priority
? compactGap
: Math.Max(compactGap + 4d, Math.Min(26d, minLineClearance + 2d));
}
private static List<AboveGraphCorridorCandidate[]> BuildOverlapClusters(
IReadOnlyList<AboveGraphCorridorCandidate> candidates)
{
var clusters = new List<AboveGraphCorridorCandidate[]>();
var current = new List<AboveGraphCorridorCandidate>();
var currentMaxX = double.NegativeInfinity;
foreach (var candidate in candidates)
{
if (current.Count == 0
|| candidate.MinX <= currentMaxX + CoordinateTolerance)
{
current.Add(candidate);
currentMaxX = Math.Max(currentMaxX, candidate.MaxX);
continue;
}
clusters.Add(current.ToArray());
current =
[
candidate,
];
currentMaxX = candidate.MaxX;
}
if (current.Count > 0)
{
clusters.Add(current.ToArray());
}
return clusters;
}
private static AboveGraphCorridorCandidate? CreateCandidate(
ElkRoutedEdge edge,
int index,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
double graphMinY)
{
var path = ExtractPath(edge);
if (path.Count < 2)
{
return null;
}
AboveGraphCorridorCandidate? 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) > CoordinateTolerance)
{
continue;
}
var corridorY = (start.Y + end.Y) / 2d;
if (corridorY >= graphMinY - 8d)
{
continue;
}
var length = Math.Abs(end.X - start.X);
if (length <= 1d)
{
continue;
}
var priority = ResolvePriority(edge, nodesById);
var ownershipKey = ResolveOwnershipKey(edge, nodesById);
var candidate = new AboveGraphCorridorCandidate(
index,
edge.Id,
corridorY,
Math.Min(start.X, end.X),
Math.Max(start.X, end.X),
length,
priority,
ownershipKey);
if (best is null || candidate.Length > best.Value.Length)
{
best = candidate;
}
}
return best;
}
private static int ResolvePriority(
ElkRoutedEdge edge,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById)
{
if (ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label))
{
return 0;
}
if (!string.IsNullOrWhiteSpace(edge.TargetNodeId)
&& nodesById.TryGetValue(edge.TargetNodeId, out var targetNode)
&& string.Equals(targetNode.Kind, "End", StringComparison.Ordinal))
{
return 2;
}
return 1;
}
private static string ResolveOwnershipKey(
ElkRoutedEdge edge,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById)
{
if (!string.IsNullOrWhiteSpace(edge.TargetNodeId)
&& nodesById.TryGetValue(edge.TargetNodeId, out var targetNode)
&& string.Equals(targetNode.Kind, "End", StringComparison.Ordinal))
{
return $"end:{targetNode.Id}";
}
if (ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label))
{
return $"repeat:{edge.TargetNodeId ?? edge.Id}";
}
return edge.Id;
}
private static ElkRoutedEdge[] RewriteCluster(
IReadOnlyList<ElkRoutedEdge> edges,
IReadOnlyList<AboveGraphCorridorCandidate> cluster,
double graphMinY,
double minLineClearance)
{
var groups = cluster
.GroupBy(candidate => candidate.OwnershipKey, StringComparer.Ordinal)
.Select(group =>
{
var orderedMembers = group
.OrderBy(member => member.Index)
.ToArray();
var representative = orderedMembers
.OrderByDescending(member => member.Length)
.ThenByDescending(member => member.CorridorY)
.ThenBy(member => member.EdgeId, StringComparer.Ordinal)
.First();
return new AboveGraphOwnershipGroup(
OwnershipKey: group.Key,
Members: orderedMembers,
Representative: representative,
Priority: orderedMembers.Min(member => member.Priority),
PreferredLaneY: orderedMembers.Max(member => member.CorridorY),
Span: orderedMembers.Max(member => member.MaxX) - orderedMembers.Min(member => member.MinX));
})
.OrderBy(group => group.Priority)
.ThenByDescending(group => group.PreferredLaneY)
.ThenByDescending(group => group.Span)
.ThenBy(group => group.OwnershipKey, StringComparer.Ordinal)
.ToArray();
if (groups.Length < 2 && groups[0].Members.All(member => Math.Abs(member.CorridorY - groups[0].PreferredLaneY) <= CoordinateTolerance))
{
return edges as ElkRoutedEdge[] ?? edges.ToArray();
}
var result = edges.ToArray();
var anchorY = groups.Max(group => group.PreferredLaneY);
AboveGraphCorridorCandidate? previousAssignedLane = null;
var assignedY = anchorY;
var changed = false;
foreach (var group in groups)
{
if (previousAssignedLane is not null)
{
assignedY -= ResolveLaneStep(previousAssignedLane.Value, group.Representative, minLineClearance);
}
foreach (var member in group.Members)
{
var rewritten = RewriteCorridorLane(result[member.Index], member.CorridorY, assignedY, graphMinY);
if (!ReferenceEquals(rewritten, result[member.Index]))
{
result[member.Index] = rewritten;
changed = true;
}
}
previousAssignedLane = group.Representative;
}
return changed ? result : (edges as ElkRoutedEdge[] ?? edges.ToArray());
}
private static CorridorClusterMetrics MeasureClusterMetrics(
IReadOnlyList<AboveGraphCorridorCandidate> cluster,
IReadOnlyList<ElkRoutedEdge> edges,
ElkPositionedNode[] nodes,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
double graphMinY)
{
var liveCandidates = cluster
.Select(member => CreateCandidate(edges[member.Index], member.Index, nodesById, graphMinY))
.Where(candidate => candidate is not null)
.Select(candidate => candidate!.Value)
.ToArray();
var ownershipSplits = liveCandidates
.GroupBy(candidate => candidate.OwnershipKey, StringComparer.Ordinal)
.Count(group =>
group.Select(candidate => Math.Round(candidate.CorridorY, 1))
.Distinct()
.Count() > 1);
var orderedOwnershipLanes = liveCandidates
.GroupBy(candidate => candidate.OwnershipKey, StringComparer.Ordinal)
.Select(group => new
{
Priority = group.Min(candidate => candidate.Priority),
LaneY = group.Max(candidate => candidate.CorridorY),
})
.OrderBy(entry => entry.Priority)
.ThenByDescending(entry => entry.LaneY)
.ToArray();
var priorityInversions = 0;
for (var i = 1; i < orderedOwnershipLanes.Length; i++)
{
if (orderedOwnershipLanes[i - 1].Priority == orderedOwnershipLanes[i].Priority)
{
continue;
}
if (orderedOwnershipLanes[i - 1].LaneY + CoordinateTolerance < orderedOwnershipLanes[i].LaneY)
{
priorityInversions++;
}
}
var clusterEdgeIds = cluster
.Select(candidate => candidate.EdgeId)
.ToHashSet(StringComparer.Ordinal);
var brokenTopHighways = ElkEdgeRouterHighway
.DetectRemainingBrokenHighways(edges as ElkRoutedEdge[] ?? edges.ToArray(), nodes)
.Count(diagnostic =>
diagnostic.WasBroken
&& string.Equals(diagnostic.SharedAxis, "top", StringComparison.Ordinal)
&& diagnostic.EdgeIds.Any(clusterEdgeIds.Contains));
var laneSpan = liveCandidates.Length == 0
? 0d
: liveCandidates.Max(candidate => candidate.CorridorY) - liveCandidates.Min(candidate => candidate.CorridorY);
return new CorridorClusterMetrics(
BrokenTopHighways: brokenTopHighways,
OwnershipSplits: ownershipSplits,
PriorityInversions: priorityInversions,
LaneSpan: laneSpan);
}
private static ElkRoutedEdge RewriteCorridorLane(
ElkRoutedEdge edge,
double currentY,
double assignedY,
double graphMinY)
{
if (Math.Abs(currentY - assignedY) <= CoordinateTolerance)
{
return edge;
}
bool ShouldShift(ElkPoint point) =>
Math.Abs(point.Y - currentY) <= CoordinateTolerance
&& point.Y < graphMinY - 8d;
ElkPoint Shift(ElkPoint point) => ShouldShift(point)
? new ElkPoint { X = point.X, Y = assignedY }
: point;
return new ElkRoutedEdge
{
Id = edge.Id,
SourceNodeId = edge.SourceNodeId,
TargetNodeId = edge.TargetNodeId,
SourcePortId = edge.SourcePortId,
TargetPortId = edge.TargetPortId,
Kind = edge.Kind,
Label = edge.Label,
Sections = edge.Sections
.Select(section => new ElkEdgeSection
{
StartPoint = Shift(section.StartPoint),
EndPoint = Shift(section.EndPoint),
BendPoints = section.BendPoints.Select(Shift).ToArray(),
})
.ToArray(),
};
}
private static List<ElkPoint> ExtractPath(ElkRoutedEdge edge)
{
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);
}
return path;
}
private readonly record struct AboveGraphCorridorCandidate(
int Index,
string EdgeId,
double CorridorY,
double MinX,
double MaxX,
double Length,
int Priority,
string OwnershipKey);
private readonly record struct AboveGraphOwnershipGroup(
string OwnershipKey,
AboveGraphCorridorCandidate[] Members,
AboveGraphCorridorCandidate Representative,
int Priority,
double PreferredLaneY,
double Span);
private readonly record struct CorridorClusterMetrics(
int BrokenTopHighways,
int OwnershipSplits,
int PriorityInversions,
double LaneSpan)
{
internal bool IsBetterThan(CorridorClusterMetrics baseline)
{
if (BrokenTopHighways != baseline.BrokenTopHighways)
{
return BrokenTopHighways < baseline.BrokenTopHighways;
}
if (OwnershipSplits != baseline.OwnershipSplits)
{
return OwnershipSplits < baseline.OwnershipSplits;
}
if (PriorityInversions != baseline.PriorityInversions)
{
return PriorityInversions < baseline.PriorityInversions;
}
return LaneSpan + CoordinateTolerance < baseline.LaneSpan;
}
}
}