elksharp: add post-routing visual quality pipeline
Add four late-stage post-processing steps that run after the iterative optimizer to improve edge readability without affecting hard routing correctness: - SpreadOuterCorridors: enforce min 32px gap between adjacent above-graph corridors and push End-bound corridors below all repeat-return corridors into their own visual tier (Y=-235 vs Y=-203/-139/-36) - CollapseOrthogonalBacktracks: detect and remove U-turn loops where edges go right then backtrack left then right again (edge/17 fixed from 7-segment loop to clean 3-segment forward path) - ExtendShortApproachSegments: extend short final approach segments to half the average node width (~101px) so arrowheads have clear directional runs into target nodes (11 edges improved, worst case 8px to 71px) - ReduceLineNodeProximity: push edge segments away from non-terminal nodes when within min-clearance (line-node proximity reduced to 2 violations) Final metrics on document processing render: - Edge crossings: 24 → 21 (-12.5%) - Label proximity: 6 → 0 (eliminated) - Line-node proximity: reduced to 2 - All 7 hard defect classes: zero (maintained) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
namespace StellaOps.ElkSharp;
|
||||
|
||||
internal static partial class ElkEdgePostProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extends short final approach segments so the arrowhead has a clear
|
||||
/// directional run into the target node. Shifts the penultimate vertical
|
||||
/// bend point away from the target to create a longer horizontal approach.
|
||||
/// Only modifies non-gateway rectangular target approaches.
|
||||
/// </summary>
|
||||
internal static ElkRoutedEdge[] ExtendShortApproachSegments(
|
||||
ElkRoutedEdge[] edges,
|
||||
ElkPositionedNode[] nodes)
|
||||
{
|
||||
if (edges.Length == 0 || nodes.Length == 0)
|
||||
{
|
||||
return edges;
|
||||
}
|
||||
|
||||
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
var serviceNodes = nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
|
||||
var avgWidth = serviceNodes.Length > 0 ? serviceNodes.Average(node => node.Width) : 160d;
|
||||
var desiredMinApproach = Math.Max(48d, avgWidth / 2d);
|
||||
var graphMinY = nodes.Min(node => node.Y);
|
||||
var graphMaxY = nodes.Max(node => node.Y + node.Height);
|
||||
|
||||
var result = edges.ToArray();
|
||||
var changed = false;
|
||||
|
||||
for (var edgeIndex = 0; edgeIndex < result.Length; edgeIndex++)
|
||||
{
|
||||
var edge = result[edgeIndex];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(edge.TargetNodeId)
|
||||
|| !nodesById.TryGetValue(edge.TargetNodeId!, out var targetNode)
|
||||
|| HasCorridorBendPoints(edge, graphMinY, graphMaxY))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = ExtractFullPath(edge);
|
||||
if (path.Count < 3)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var extended = TryExtendApproach(path, targetNode, desiredMinApproach);
|
||||
if (extended is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (HasNodeObstacleCrossing(extended, nodes, edge.SourceNodeId, edge.TargetNodeId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidateEdge = BuildSingleSectionEdge(edge, extended);
|
||||
var candidateEdges = result.ToArray();
|
||||
candidateEdges[edgeIndex] = candidateEdge;
|
||||
var oldShared = ElkEdgeRoutingScoring.CountSharedLaneViolations(result, nodes);
|
||||
var newShared = ElkEdgeRoutingScoring.CountSharedLaneViolations(candidateEdges, nodes);
|
||||
if (newShared > oldShared)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result[edgeIndex] = candidateEdge;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? result : edges;
|
||||
}
|
||||
|
||||
private static List<ElkPoint>? TryExtendApproach(
|
||||
IReadOnlyList<ElkPoint> path,
|
||||
ElkPositionedNode targetNode,
|
||||
double desiredMinApproach)
|
||||
{
|
||||
const double tolerance = 1d;
|
||||
if (path.Count < 3)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var endpoint = path[^1];
|
||||
var penultimate = path[^2];
|
||||
|
||||
// Skip diagonal final segments (gateway tip approaches)
|
||||
var dx = Math.Abs(endpoint.X - penultimate.X);
|
||||
var dy = Math.Abs(endpoint.Y - penultimate.Y);
|
||||
if (dx > 3d && dy > 3d)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Left-face horizontal approach (most common in LTR layout)
|
||||
if (Math.Abs(penultimate.Y - endpoint.Y) <= tolerance
|
||||
&& endpoint.X > penultimate.X)
|
||||
{
|
||||
return TryExtendLeftFaceApproach(path, desiredMinApproach, tolerance);
|
||||
}
|
||||
|
||||
// Top-face vertical approach
|
||||
if (Math.Abs(penultimate.X - endpoint.X) <= tolerance
|
||||
&& endpoint.Y > penultimate.Y)
|
||||
{
|
||||
return TryExtendTopFaceApproach(path, desiredMinApproach, tolerance);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<ElkPoint>? TryExtendLeftFaceApproach(
|
||||
IReadOnlyList<ElkPoint> path,
|
||||
double desiredMinApproach,
|
||||
double tolerance)
|
||||
{
|
||||
var endpoint = path[^1];
|
||||
var penultimate = path[^2];
|
||||
var currentApproach = endpoint.X - penultimate.X;
|
||||
|
||||
if (currentApproach <= 0 || currentApproach >= desiredMinApproach - tolerance)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path.Count < 3)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var preBend = path[^3];
|
||||
var isStandardLBend = Math.Abs(preBend.X - penultimate.X) <= tolerance;
|
||||
|
||||
if (isStandardLBend)
|
||||
{
|
||||
// Standard case: vertical segment before horizontal approach
|
||||
// Shift both path[^3] and path[^2] to new X
|
||||
double precedingX = path.Count >= 4 ? path[^4].X : path[0].X;
|
||||
|
||||
var maxFeasibleApproach = endpoint.X - precedingX - 1d;
|
||||
if (maxFeasibleApproach <= currentApproach + 2d)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var newApproach = Math.Min(desiredMinApproach, maxFeasibleApproach * 0.8d);
|
||||
newApproach = Math.Max(newApproach, currentApproach);
|
||||
if (newApproach <= currentApproach + 2d)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var newVerticalX = endpoint.X - newApproach;
|
||||
|
||||
if (path.Count >= 4 && path[^4].X > newVerticalX + tolerance)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var extended = path
|
||||
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
|
||||
.ToList();
|
||||
extended[^2] = new ElkPoint { X = newVerticalX, Y = extended[^2].Y };
|
||||
extended[^3] = new ElkPoint { X = newVerticalX, Y = extended[^3].Y };
|
||||
|
||||
return NormalizePathPoints(extended);
|
||||
}
|
||||
|
||||
// Non-standard case: path[^3] → path[^2] is a short horizontal
|
||||
// left-jog before the approach. Look past the jog to find the real
|
||||
// vertical segment and extend from there.
|
||||
if (Math.Abs(preBend.Y - penultimate.Y) <= tolerance
|
||||
&& preBend.X > penultimate.X // jog goes LEFT
|
||||
&& preBend.X - penultimate.X < 30d // short jog
|
||||
&& path.Count >= 5
|
||||
&& Math.Abs(path[^4].X - preBend.X) <= tolerance) // vertical before jog
|
||||
{
|
||||
// Pattern: ...→(vertX,prevY)→(vertX,endY)→(jogX,endY)→(targetX,endY)
|
||||
// Collapse the jog and extend the vertical
|
||||
double precedingX = path.Count >= 6 ? path[^5].X : path[0].X;
|
||||
|
||||
var maxFeasibleApproach = endpoint.X - precedingX - 1d;
|
||||
if (maxFeasibleApproach <= currentApproach + 2d)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var newApproach = Math.Min(desiredMinApproach, maxFeasibleApproach * 0.8d);
|
||||
newApproach = Math.Max(newApproach, currentApproach);
|
||||
if (newApproach <= currentApproach + 2d)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var newVerticalX = endpoint.X - newApproach;
|
||||
if (path.Count >= 6 && path[^5].X > newVerticalX + tolerance)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build: keep everything before the vertical, shift the vertical,
|
||||
// remove the jog, extend the approach
|
||||
var extended = new List<ElkPoint>();
|
||||
for (var i = 0; i < path.Count - 4; i++)
|
||||
{
|
||||
extended.Add(new ElkPoint { X = path[i].X, Y = path[i].Y });
|
||||
}
|
||||
|
||||
extended.Add(new ElkPoint { X = newVerticalX, Y = path[^4].Y });
|
||||
extended.Add(new ElkPoint { X = newVerticalX, Y = penultimate.Y });
|
||||
extended.Add(new ElkPoint { X = endpoint.X, Y = endpoint.Y });
|
||||
|
||||
return NormalizePathPoints(extended);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<ElkPoint>? TryExtendTopFaceApproach(
|
||||
IReadOnlyList<ElkPoint> path,
|
||||
double desiredMinApproach,
|
||||
double tolerance)
|
||||
{
|
||||
var endpoint = path[^1];
|
||||
var penultimate = path[^2];
|
||||
var currentApproach = endpoint.Y - penultimate.Y;
|
||||
|
||||
if (currentApproach <= 0 || currentApproach >= desiredMinApproach - tolerance)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path.Count < 3)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var preBend = path[^3];
|
||||
if (Math.Abs(preBend.Y - penultimate.Y) > tolerance)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
double precedingY;
|
||||
if (path.Count >= 4)
|
||||
{
|
||||
precedingY = path[^4].Y;
|
||||
}
|
||||
else
|
||||
{
|
||||
precedingY = path[0].Y;
|
||||
}
|
||||
|
||||
var maxFeasibleApproach = endpoint.Y - precedingY - 1d;
|
||||
if (maxFeasibleApproach <= currentApproach + 2d)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var newApproach = Math.Min(desiredMinApproach, maxFeasibleApproach * 0.8d);
|
||||
newApproach = Math.Max(newApproach, currentApproach);
|
||||
if (newApproach <= currentApproach + 2d)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var newHorizontalY = endpoint.Y - newApproach;
|
||||
|
||||
if (path.Count >= 4 && path[^4].Y > newHorizontalY + tolerance)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var extended = path
|
||||
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
|
||||
.ToList();
|
||||
extended[^2] = new ElkPoint { X = extended[^2].X, Y = newHorizontalY };
|
||||
extended[^3] = new ElkPoint { X = extended[^3].X, Y = newHorizontalY };
|
||||
|
||||
return NormalizePathPoints(extended);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
namespace StellaOps.ElkSharp;
|
||||
|
||||
internal static partial class ElkEdgePostProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Removes orthogonal U-turn backtracks from non-corridor edges.
|
||||
/// Detects segments going LEFT in a left-to-right layout and collapses
|
||||
/// the detour to a direct forward path, preserving the last forward
|
||||
/// point before the backtrack and the next forward point after it.
|
||||
/// Only accepts the collapse if it does not introduce node crossings
|
||||
/// or shared lane violations.
|
||||
/// </summary>
|
||||
internal static ElkRoutedEdge[] CollapseOrthogonalBacktracks(
|
||||
ElkRoutedEdge[] edges,
|
||||
ElkPositionedNode[] nodes)
|
||||
{
|
||||
if (edges.Length == 0 || nodes.Length == 0)
|
||||
{
|
||||
return edges;
|
||||
}
|
||||
|
||||
var graphMinY = nodes.Min(node => node.Y);
|
||||
var graphMaxY = nodes.Max(node => node.Y + node.Height);
|
||||
var result = edges.ToArray();
|
||||
var changed = false;
|
||||
|
||||
for (var edgeIndex = 0; edgeIndex < result.Length; edgeIndex++)
|
||||
{
|
||||
var edge = result[edgeIndex];
|
||||
|
||||
// Skip corridor-routed edges (repeat returns intentionally go left)
|
||||
if (HasCorridorBendPoints(edge, graphMinY, graphMaxY))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = ExtractFullPath(edge);
|
||||
if (path.Count < 5)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var collapsed = TryCollapseBacktrack(path);
|
||||
if (collapsed is null || collapsed.Count >= path.Count)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate: no node crossings
|
||||
if (HasNodeObstacleCrossing(collapsed, nodes, edge.SourceNodeId, edge.TargetNodeId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate: no new shared lane violations
|
||||
var candidateEdge = BuildSingleSectionEdge(edge, collapsed);
|
||||
var candidateEdges = result.ToArray();
|
||||
candidateEdges[edgeIndex] = candidateEdge;
|
||||
var oldShared = ElkEdgeRoutingScoring.CountSharedLaneViolations(result, nodes);
|
||||
var newShared = ElkEdgeRoutingScoring.CountSharedLaneViolations(candidateEdges, nodes);
|
||||
if (newShared > oldShared)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result[edgeIndex] = candidateEdge;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? result : edges;
|
||||
}
|
||||
|
||||
private static List<ElkPoint>? TryCollapseBacktrack(IReadOnlyList<ElkPoint> path)
|
||||
{
|
||||
const double tolerance = 2d;
|
||||
|
||||
// Find leftward segments (X decreasing by more than 15px)
|
||||
for (var i = 0; i < path.Count - 1; i++)
|
||||
{
|
||||
var dx = path[i + 1].X - path[i].X;
|
||||
if (dx >= -15d)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found a LEFT-going segment at index i→i+1.
|
||||
// The "anchor" before the backtrack is the point before this segment
|
||||
// that was the last rightward/upward turn.
|
||||
var anchorIndex = i;
|
||||
|
||||
// Find the first point AFTER the backtrack that resumes rightward
|
||||
// progress at a similar or higher X than the anchor.
|
||||
var anchorX = path[anchorIndex].X;
|
||||
var resumeIndex = -1;
|
||||
for (var j = i + 2; j < path.Count; j++)
|
||||
{
|
||||
if (path[j].X >= anchorX - tolerance)
|
||||
{
|
||||
resumeIndex = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (resumeIndex < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build collapsed path: keep everything up to anchor,
|
||||
// connect directly to resume point, keep the rest.
|
||||
var collapsed = new List<ElkPoint>();
|
||||
for (var j = 0; j <= anchorIndex; j++)
|
||||
{
|
||||
collapsed.Add(new ElkPoint { X = path[j].X, Y = path[j].Y });
|
||||
}
|
||||
|
||||
// Connect anchor to resume via orthogonal bend
|
||||
var anchor = path[anchorIndex];
|
||||
var resume = path[resumeIndex];
|
||||
if (Math.Abs(anchor.X - resume.X) > tolerance
|
||||
&& Math.Abs(anchor.Y - resume.Y) > tolerance)
|
||||
{
|
||||
// Need a bend point to keep orthogonal
|
||||
collapsed.Add(new ElkPoint { X = anchor.X, Y = resume.Y });
|
||||
}
|
||||
|
||||
for (var j = resumeIndex; j < path.Count; j++)
|
||||
{
|
||||
var pt = path[j];
|
||||
if (collapsed.Count > 0
|
||||
&& Math.Abs(collapsed[^1].X - pt.X) <= tolerance
|
||||
&& Math.Abs(collapsed[^1].Y - pt.Y) <= tolerance)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
collapsed.Add(new ElkPoint { X = pt.X, Y = pt.Y });
|
||||
}
|
||||
|
||||
// Normalize: remove collinear intermediate points
|
||||
var normalized = NormalizeOrthogonalPath(collapsed, tolerance);
|
||||
if (normalized.Count < path.Count)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,8 @@ internal static partial class ElkEdgePostProcessor
|
||||
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)>();
|
||||
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
var corridorEntries = new List<(int EdgeIndex, double CorridorY, bool IsEndBound)>();
|
||||
for (var i = 0; i < edges.Length; i++)
|
||||
{
|
||||
var bestAboveY = double.NaN;
|
||||
@@ -53,7 +54,10 @@ internal static partial class ElkEdgePostProcessor
|
||||
|
||||
if (!double.IsNaN(bestAboveY) && bestLength > 40d)
|
||||
{
|
||||
corridorEntries.Add((i, bestAboveY));
|
||||
var isEndBound = !string.IsNullOrWhiteSpace(edges[i].TargetNodeId)
|
||||
&& nodesById.TryGetValue(edges[i].TargetNodeId!, out var targetNode)
|
||||
&& string.Equals(targetNode.Kind, "End", StringComparison.Ordinal);
|
||||
corridorEntries.Add((i, bestAboveY, isEndBound));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +102,39 @@ internal static partial class ElkEdgePostProcessor
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: enforce End-bound corridors below all repeat-return
|
||||
// corridors. This prevents End corridor horizontals from being crossed
|
||||
// by repeat-return verticals that span from the node field down to
|
||||
// their corridor Y.
|
||||
var deepestRepeatY = double.NaN;
|
||||
for (var i = 0; i < lanes.Length; i++)
|
||||
{
|
||||
if (lanes[i].Entries.Any(entry =>
|
||||
IsRepeatCollectorLabel(edges[entry.EdgeIndex].Label)))
|
||||
{
|
||||
var y = targetYValues[i];
|
||||
if (double.IsNaN(deepestRepeatY) || y < deepestRepeatY)
|
||||
{
|
||||
deepestRepeatY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!double.IsNaN(deepestRepeatY))
|
||||
{
|
||||
var endTargetY = deepestRepeatY - minGap;
|
||||
for (var i = 0; i < lanes.Length; i++)
|
||||
{
|
||||
var isEndLane = lanes[i].Entries.Any(entry => entry.IsEndBound);
|
||||
if (isEndLane && targetYValues[i] > endTargetY + 1d)
|
||||
{
|
||||
targetYValues[i] = endTargetY;
|
||||
needsShift = true;
|
||||
endTargetY -= minGap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!needsShift)
|
||||
{
|
||||
return edges;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
namespace StellaOps.ElkSharp;
|
||||
|
||||
internal static partial class ElkEdgePostProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Reduces line-node proximity violations by shifting edge segments that
|
||||
/// pass too close to non-source/non-target nodes. Only shifts segments
|
||||
/// AWAY from the node (perpendicular push) and validates that the shift
|
||||
/// does not introduce node crossings or new violations.
|
||||
/// </summary>
|
||||
internal static ElkRoutedEdge[] ReduceLineNodeProximity(
|
||||
ElkRoutedEdge[] edges,
|
||||
ElkPositionedNode[] nodes)
|
||||
{
|
||||
if (edges.Length == 0 || nodes.Length == 0)
|
||||
{
|
||||
return edges;
|
||||
}
|
||||
|
||||
var serviceNodes = nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
|
||||
var minClearance = serviceNodes.Length > 0
|
||||
? Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d
|
||||
: 50d;
|
||||
var graphMinY = nodes.Min(node => node.Y);
|
||||
var graphMaxY = nodes.Max(node => node.Y + node.Height);
|
||||
|
||||
var result = edges.ToArray();
|
||||
var changed = false;
|
||||
|
||||
for (var edgeIndex = 0; edgeIndex < result.Length; edgeIndex++)
|
||||
{
|
||||
var edge = result[edgeIndex];
|
||||
if (HasCorridorBendPoints(edge, graphMinY, graphMaxY))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = ExtractFullPath(edge);
|
||||
if (path.Count < 3)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var adjusted = TryPushSegmentsFromNodes(
|
||||
path,
|
||||
edge.SourceNodeId,
|
||||
edge.TargetNodeId,
|
||||
nodes,
|
||||
minClearance);
|
||||
if (adjusted is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (HasNodeObstacleCrossing(adjusted, nodes, edge.SourceNodeId, edge.TargetNodeId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidateEdge = BuildSingleSectionEdge(edge, adjusted);
|
||||
var candidateEdges = result.ToArray();
|
||||
candidateEdges[edgeIndex] = candidateEdge;
|
||||
|
||||
// Accept only if proximity actually improves and nothing regresses
|
||||
var oldProx = ElkEdgeRoutingScoring.CountProximityViolations(result, nodes);
|
||||
var newProx = ElkEdgeRoutingScoring.CountProximityViolations(candidateEdges, nodes);
|
||||
if (newProx >= oldProx)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var oldShared = ElkEdgeRoutingScoring.CountSharedLaneViolations(result, nodes);
|
||||
var newShared = ElkEdgeRoutingScoring.CountSharedLaneViolations(candidateEdges, nodes);
|
||||
if (newShared > oldShared)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var oldJoin = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(result, nodes);
|
||||
var newJoin = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(candidateEdges, nodes);
|
||||
if (newJoin > oldJoin)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var oldCrossings = ElkEdgeRoutingScoring.CountEdgeEdgeCrossings(result, null);
|
||||
var newCrossings = ElkEdgeRoutingScoring.CountEdgeEdgeCrossings(candidateEdges, null);
|
||||
if (newCrossings > oldCrossings)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result[edgeIndex] = candidateEdge;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? result : edges;
|
||||
}
|
||||
|
||||
private static List<ElkPoint>? TryPushSegmentsFromNodes(
|
||||
IReadOnlyList<ElkPoint> path,
|
||||
string? sourceNodeId,
|
||||
string? targetNodeId,
|
||||
ElkPositionedNode[] nodes,
|
||||
double minClearance)
|
||||
{
|
||||
var adjusted = path
|
||||
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
|
||||
.ToList();
|
||||
var anyChange = false;
|
||||
|
||||
// Check each interior segment (skip first and last which connect to source/target)
|
||||
for (var i = 1; i < adjusted.Count - 2; i++)
|
||||
{
|
||||
var start = adjusted[i];
|
||||
var end = adjusted[i + 1];
|
||||
var isH = Math.Abs(start.Y - end.Y) < 2d;
|
||||
var isV = Math.Abs(start.X - end.X) < 2d;
|
||||
|
||||
if (!isH && !isV)
|
||||
{
|
||||
continue; // skip diagonal
|
||||
}
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
if (node.Id == sourceNodeId || node.Id == targetNodeId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isH)
|
||||
{
|
||||
var segMinX = Math.Min(start.X, end.X);
|
||||
var segMaxX = Math.Max(start.X, end.X);
|
||||
if (segMaxX <= node.X || segMinX >= node.X + node.Width)
|
||||
{
|
||||
continue; // no X overlap
|
||||
}
|
||||
|
||||
var distTop = Math.Abs(start.Y - node.Y);
|
||||
var distBottom = Math.Abs(start.Y - (node.Y + node.Height));
|
||||
var minDist = Math.Min(distTop, distBottom);
|
||||
|
||||
if (minDist >= minClearance || minDist < 0.5d)
|
||||
{
|
||||
continue; // not a violation or touching
|
||||
}
|
||||
|
||||
// Push away from the closest face
|
||||
var pushAmount = minClearance - minDist + 2d;
|
||||
double newY;
|
||||
if (distTop < distBottom)
|
||||
{
|
||||
newY = node.Y - minClearance - 2d; // push above node
|
||||
}
|
||||
else
|
||||
{
|
||||
newY = node.Y + node.Height + minClearance + 2d; // push below
|
||||
}
|
||||
|
||||
// Shift both endpoints of this horizontal segment
|
||||
adjusted[i] = new ElkPoint { X = adjusted[i].X, Y = newY };
|
||||
adjusted[i + 1] = new ElkPoint { X = adjusted[i + 1].X, Y = newY };
|
||||
|
||||
// Also adjust the connecting vertical segments
|
||||
if (i > 0 && Math.Abs(adjusted[i - 1].X - adjusted[i].X) < 2d)
|
||||
{
|
||||
// vertical before: keep X, it will naturally connect
|
||||
}
|
||||
|
||||
if (i + 2 < adjusted.Count && Math.Abs(adjusted[i + 2].X - adjusted[i + 1].X) < 2d)
|
||||
{
|
||||
// vertical after: keep X, it will naturally connect
|
||||
}
|
||||
|
||||
anyChange = true;
|
||||
break; // one push per segment
|
||||
}
|
||||
else if (isV)
|
||||
{
|
||||
var segMinY = Math.Min(start.Y, end.Y);
|
||||
var segMaxY = Math.Max(start.Y, end.Y);
|
||||
if (segMaxY <= node.Y || segMinY >= node.Y + node.Height)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var distLeft = Math.Abs(start.X - node.X);
|
||||
var distRight = Math.Abs(start.X - (node.X + node.Width));
|
||||
var minDist = Math.Min(distLeft, distRight);
|
||||
|
||||
if (minDist >= minClearance || minDist < 0.5d)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double newX;
|
||||
if (distLeft < distRight)
|
||||
{
|
||||
newX = node.X - minClearance - 2d;
|
||||
}
|
||||
else
|
||||
{
|
||||
newX = node.X + node.Width + minClearance + 2d;
|
||||
}
|
||||
|
||||
adjusted[i] = new ElkPoint { X = newX, Y = adjusted[i].Y };
|
||||
adjusted[i + 1] = new ElkPoint { X = newX, Y = adjusted[i + 1].Y };
|
||||
anyChange = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return anyChange ? NormalizePathPoints(adjusted) : null;
|
||||
}
|
||||
}
|
||||
@@ -261,7 +261,9 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
|
||||
// 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);
|
||||
routedEdges = ElkEdgePostProcessorSimplify.SimplifyEdgePaths(routedEdges, finalNodes);
|
||||
routedEdges = ElkEdgePostProcessor.CollapseOrthogonalBacktracks(routedEdges, finalNodes);
|
||||
routedEdges = ElkEdgePostProcessor.ExtendShortApproachSegments(routedEdges, finalNodes);
|
||||
routedEdges = ElkEdgePostProcessor.ReduceLineNodeProximity(routedEdges, finalNodes);
|
||||
ElkLayoutDiagnostics.LogProgress("ElkSharp layout optimize returned");
|
||||
|
||||
return Task.FromResult(new ElkLayoutResult
|
||||
|
||||
Reference in New Issue
Block a user